1#!/usr/bin/env python 2# 3# Copyright 2015 Google Inc. 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17"""Assorted utilities shared between parts of apitools.""" 18from __future__ import print_function 19 20import collections 21import contextlib 22import json 23import keyword 24import logging 25import os 26import re 27 28import six 29from six.moves import urllib_parse 30import six.moves.urllib.error as urllib_error 31import six.moves.urllib.request as urllib_request 32 33 34class Error(Exception): 35 36 """Base error for apitools generation.""" 37 38 39class CommunicationError(Error): 40 41 """Error in network communication.""" 42 43 44def _SortLengthFirstKey(a): 45 return -len(a), a 46 47 48class Names(object): 49 50 """Utility class for cleaning and normalizing names in a fixed style.""" 51 DEFAULT_NAME_CONVENTION = 'LOWER_CAMEL' 52 NAME_CONVENTIONS = ['LOWER_CAMEL', 'LOWER_WITH_UNDER', 'NONE'] 53 54 def __init__(self, strip_prefixes, 55 name_convention=None, 56 capitalize_enums=False): 57 self.__strip_prefixes = sorted(strip_prefixes, key=_SortLengthFirstKey) 58 self.__name_convention = ( 59 name_convention or self.DEFAULT_NAME_CONVENTION) 60 self.__capitalize_enums = capitalize_enums 61 62 @staticmethod 63 def __FromCamel(name, separator='_'): 64 name = re.sub(r'([a-z0-9])([A-Z])', r'\1%s\2' % separator, name) 65 return name.lower() 66 67 @staticmethod 68 def __ToCamel(name, separator='_'): 69 # TODO(craigcitro): Consider what to do about leading or trailing 70 # underscores (such as `_refValue` in discovery). 71 return ''.join(s[0:1].upper() + s[1:] for s in name.split(separator)) 72 73 @staticmethod 74 def __ToLowerCamel(name, separator='_'): 75 name = Names.__ToCamel(name, separator=separator) 76 return name[0].lower() + name[1:] 77 78 def __StripName(self, name): 79 """Strip strip_prefix entries from name.""" 80 if not name: 81 return name 82 for prefix in self.__strip_prefixes: 83 if name.startswith(prefix): 84 return name[len(prefix):] 85 return name 86 87 @staticmethod 88 def CleanName(name): 89 """Perform generic name cleaning.""" 90 name = re.sub('[^_A-Za-z0-9]', '_', name) 91 if name[0].isdigit(): 92 name = '_%s' % name 93 while keyword.iskeyword(name): 94 name = '%s_' % name 95 # If we end up with __ as a prefix, we'll run afoul of python 96 # field renaming, so we manually correct for it. 97 if name.startswith('__'): 98 name = 'f%s' % name 99 return name 100 101 @staticmethod 102 def NormalizeRelativePath(path): 103 """Normalize camelCase entries in path.""" 104 path_components = path.split('/') 105 normalized_components = [] 106 for component in path_components: 107 if re.match(r'{[A-Za-z0-9_]+}$', component): 108 normalized_components.append( 109 '{%s}' % Names.CleanName(component[1:-1])) 110 else: 111 normalized_components.append(component) 112 return '/'.join(normalized_components) 113 114 def NormalizeEnumName(self, enum_name): 115 if self.__capitalize_enums: 116 enum_name = enum_name.upper() 117 return self.CleanName(enum_name) 118 119 def ClassName(self, name, separator='_'): 120 """Generate a valid class name from name.""" 121 # TODO(craigcitro): Get rid of this case here and in MethodName. 122 if name is None: 123 return name 124 # TODO(craigcitro): This is a hack to handle the case of specific 125 # protorpc class names; clean this up. 126 if name.startswith(('protorpc.', 'message_types.', 127 'apitools.base.protorpclite.', 128 'apitools.base.protorpclite.message_types.')): 129 return name 130 name = self.__StripName(name) 131 name = self.__ToCamel(name, separator=separator) 132 return self.CleanName(name) 133 134 def MethodName(self, name, separator='_'): 135 """Generate a valid method name from name.""" 136 if name is None: 137 return None 138 name = Names.__ToCamel(name, separator=separator) 139 return Names.CleanName(name) 140 141 def FieldName(self, name): 142 """Generate a valid field name from name.""" 143 # TODO(craigcitro): We shouldn't need to strip this name, but some 144 # of the service names here are excessive. Fix the API and then 145 # remove this. 146 name = self.__StripName(name) 147 if self.__name_convention == 'LOWER_CAMEL': 148 name = Names.__ToLowerCamel(name) 149 elif self.__name_convention == 'LOWER_WITH_UNDER': 150 name = Names.__FromCamel(name) 151 return Names.CleanName(name) 152 153 154@contextlib.contextmanager 155def Chdir(dirname, create=True): 156 if not os.path.exists(dirname): 157 if not create: 158 raise OSError('Cannot find directory %s' % dirname) 159 else: 160 os.mkdir(dirname) 161 previous_directory = os.getcwd() 162 try: 163 os.chdir(dirname) 164 yield 165 finally: 166 os.chdir(previous_directory) 167 168 169def NormalizeVersion(version): 170 # Currently, '.' is the only character that might cause us trouble. 171 return version.replace('.', '_') 172 173 174def _ComputePaths(package, version, discovery_doc): 175 full_path = urllib_parse.urljoin( 176 discovery_doc['rootUrl'], discovery_doc['servicePath']) 177 api_path_component = '/'.join((package, version, '')) 178 if api_path_component not in full_path: 179 return full_path, '' 180 prefix, _, suffix = full_path.rpartition(api_path_component) 181 return prefix + api_path_component, suffix 182 183 184class ClientInfo(collections.namedtuple('ClientInfo', ( 185 'package', 'scopes', 'version', 'client_id', 'client_secret', 186 'user_agent', 'client_class_name', 'url_version', 'api_key', 187 'base_url', 'base_path'))): 188 189 """Container for client-related info and names.""" 190 191 @classmethod 192 def Create(cls, discovery_doc, 193 scope_ls, client_id, client_secret, user_agent, names, api_key): 194 """Create a new ClientInfo object from a discovery document.""" 195 scopes = set( 196 discovery_doc.get('auth', {}).get('oauth2', {}).get('scopes', {})) 197 scopes.update(scope_ls) 198 package = discovery_doc['name'] 199 url_version = discovery_doc['version'] 200 base_url, base_path = _ComputePaths(package, url_version, 201 discovery_doc) 202 203 client_info = { 204 'package': package, 205 'version': NormalizeVersion(discovery_doc['version']), 206 'url_version': url_version, 207 'scopes': sorted(list(scopes)), 208 'client_id': client_id, 209 'client_secret': client_secret, 210 'user_agent': user_agent, 211 'api_key': api_key, 212 'base_url': base_url, 213 'base_path': base_path, 214 } 215 client_class_name = '%s%s' % ( 216 names.ClassName(client_info['package']), 217 names.ClassName(client_info['version'])) 218 client_info['client_class_name'] = client_class_name 219 return cls(**client_info) 220 221 @property 222 def default_directory(self): 223 return self.package 224 225 @property 226 def cli_rule_name(self): 227 return '%s_%s' % (self.package, self.version) 228 229 @property 230 def cli_file_name(self): 231 return '%s.py' % self.cli_rule_name 232 233 @property 234 def client_rule_name(self): 235 return '%s_%s_client' % (self.package, self.version) 236 237 @property 238 def client_file_name(self): 239 return '%s.py' % self.client_rule_name 240 241 @property 242 def messages_rule_name(self): 243 return '%s_%s_messages' % (self.package, self.version) 244 245 @property 246 def services_rule_name(self): 247 return '%s_%s_services' % (self.package, self.version) 248 249 @property 250 def messages_file_name(self): 251 return '%s.py' % self.messages_rule_name 252 253 @property 254 def messages_proto_file_name(self): 255 return '%s.proto' % self.messages_rule_name 256 257 @property 258 def services_proto_file_name(self): 259 return '%s.proto' % self.services_rule_name 260 261 262def CleanDescription(description): 263 """Return a version of description safe for printing in a docstring.""" 264 if not isinstance(description, six.string_types): 265 return description 266 return description.replace('"""', '" " "') 267 268 269class SimplePrettyPrinter(object): 270 271 """Simple pretty-printer that supports an indent contextmanager.""" 272 273 def __init__(self, out): 274 self.__out = out 275 self.__indent = '' 276 self.__skip = False 277 self.__comment_context = False 278 279 @property 280 def indent(self): 281 return self.__indent 282 283 def CalculateWidth(self, max_width=78): 284 return max_width - len(self.indent) 285 286 @contextlib.contextmanager 287 def Indent(self, indent=' '): 288 previous_indent = self.__indent 289 self.__indent = '%s%s' % (previous_indent, indent) 290 yield 291 self.__indent = previous_indent 292 293 @contextlib.contextmanager 294 def CommentContext(self): 295 """Print without any argument formatting.""" 296 old_context = self.__comment_context 297 self.__comment_context = True 298 yield 299 self.__comment_context = old_context 300 301 def __call__(self, *args): 302 if self.__comment_context and args[1:]: 303 raise Error('Cannot do string interpolation in comment context') 304 if args and args[0]: 305 if not self.__comment_context: 306 line = (args[0] % args[1:]).rstrip() 307 else: 308 line = args[0].rstrip() 309 line = line.encode('ascii', 'backslashreplace') 310 print('%s%s' % (self.__indent, line), file=self.__out) 311 else: 312 print('', file=self.__out) 313 314 315def _NormalizeDiscoveryUrls(discovery_url): 316 """Expands a few abbreviations into full discovery urls.""" 317 if discovery_url.startswith('http'): 318 return [discovery_url] 319 elif '.' not in discovery_url: 320 raise ValueError('Unrecognized value "%s" for discovery url') 321 api_name, _, api_version = discovery_url.partition('.') 322 return [ 323 'https://www.googleapis.com/discovery/v1/apis/%s/%s/rest' % ( 324 api_name, api_version), 325 'https://%s.googleapis.com/$discovery/rest?version=%s' % ( 326 api_name, api_version), 327 ] 328 329 330def FetchDiscoveryDoc(discovery_url, retries=5): 331 """Fetch the discovery document at the given url.""" 332 discovery_urls = _NormalizeDiscoveryUrls(discovery_url) 333 discovery_doc = None 334 last_exception = None 335 for url in discovery_urls: 336 for _ in range(retries): 337 try: 338 discovery_doc = json.loads(urllib_request.urlopen(url).read()) 339 break 340 except (urllib_error.HTTPError, urllib_error.URLError) as e: 341 logging.info( 342 'Attempting to fetch discovery doc again after "%s"', e) 343 last_exception = e 344 if discovery_doc is None: 345 raise CommunicationError( 346 'Could not find discovery doc at any of %s: %s' % ( 347 discovery_urls, last_exception)) 348 return discovery_doc 349