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