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