• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python
2#
3# Copyright 2010 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"""Common utility library."""
18from __future__ import with_statement
19
20import datetime
21import functools
22import inspect
23import logging
24import os
25import re
26import sys
27
28import six
29
30__all__ = [
31    'Error',
32    'decode_datetime',
33    'get_package_for_module',
34    'positional',
35    'TimeZoneOffset',
36    'total_seconds',
37]
38
39
40class Error(Exception):
41    """Base class for protorpc exceptions."""
42
43
44_TIME_ZONE_RE_STRING = r"""
45  # Examples:
46  #   +01:00
47  #   -05:30
48  #   Z12:00
49  ((?P<z>Z) | (?P<sign>[-+])
50   (?P<hours>\d\d) :
51   (?P<minutes>\d\d))$
52"""
53_TIME_ZONE_RE = re.compile(_TIME_ZONE_RE_STRING, re.IGNORECASE | re.VERBOSE)
54
55
56def positional(max_positional_args):
57    """A decorator that declares only the first N arguments may be positional.
58
59    This decorator makes it easy to support Python 3 style keyword-only
60    parameters. For example, in Python 3 it is possible to write:
61
62      def fn(pos1, *, kwonly1=None, kwonly1=None):
63        ...
64
65    All named parameters after * must be a keyword:
66
67      fn(10, 'kw1', 'kw2')  # Raises exception.
68      fn(10, kwonly1='kw1')  # Ok.
69
70    Example:
71      To define a function like above, do:
72
73        @positional(1)
74        def fn(pos1, kwonly1=None, kwonly2=None):
75          ...
76
77      If no default value is provided to a keyword argument, it
78      becomes a required keyword argument:
79
80        @positional(0)
81        def fn(required_kw):
82          ...
83
84      This must be called with the keyword parameter:
85
86        fn()  # Raises exception.
87        fn(10)  # Raises exception.
88        fn(required_kw=10)  # Ok.
89
90      When defining instance or class methods always remember to account for
91      'self' and 'cls':
92
93        class MyClass(object):
94
95          @positional(2)
96          def my_method(self, pos1, kwonly1=None):
97            ...
98
99          @classmethod
100          @positional(2)
101          def my_method(cls, pos1, kwonly1=None):
102            ...
103
104      One can omit the argument to 'positional' altogether, and then no
105      arguments with default values may be passed positionally. This
106      would be equivalent to placing a '*' before the first argument
107      with a default value in Python 3. If there are no arguments with
108      default values, and no argument is given to 'positional', an error
109      is raised.
110
111        @positional
112        def fn(arg1, arg2, required_kw1=None, required_kw2=0):
113          ...
114
115        fn(1, 3, 5)  # Raises exception.
116        fn(1, 3)  # Ok.
117        fn(1, 3, required_kw1=5)  # Ok.
118
119    Args:
120      max_positional_arguments: Maximum number of positional arguments.  All
121        parameters after the this index must be keyword only.
122
123    Returns:
124      A decorator that prevents using arguments after max_positional_args from
125      being used as positional parameters.
126
127    Raises:
128      TypeError if a keyword-only argument is provided as a positional
129        parameter.
130      ValueError if no maximum number of arguments is provided and the function
131        has no arguments with default values.
132    """
133    def positional_decorator(wrapped):
134        """Creates a function wraper to enforce number of arguments."""
135        @functools.wraps(wrapped)
136        def positional_wrapper(*args, **kwargs):
137            if len(args) > max_positional_args:
138                plural_s = ''
139                if max_positional_args != 1:
140                    plural_s = 's'
141                raise TypeError('%s() takes at most %d positional argument%s '
142                                '(%d given)' % (wrapped.__name__,
143                                                max_positional_args,
144                                                plural_s, len(args)))
145            return wrapped(*args, **kwargs)
146        return positional_wrapper
147
148    if isinstance(max_positional_args, six.integer_types):
149        return positional_decorator
150    else:
151        args, _, _, defaults, *_ = inspect.getfullargspec(max_positional_args)
152        if defaults is None:
153            raise ValueError(
154                'Functions with no keyword arguments must specify '
155                'max_positional_args')
156        return positional(len(args) - len(defaults))(max_positional_args)
157
158
159@positional(1)
160def get_package_for_module(module):
161    """Get package name for a module.
162
163    Helper calculates the package name of a module.
164
165    Args:
166      module: Module to get name for.  If module is a string, try to find
167        module in sys.modules.
168
169    Returns:
170      If module contains 'package' attribute, uses that as package name.
171      Else, if module is not the '__main__' module, the module __name__.
172      Else, the base name of the module file name.  Else None.
173    """
174    if isinstance(module, six.string_types):
175        try:
176            module = sys.modules[module]
177        except KeyError:
178            return None
179
180    try:
181        return six.text_type(module.package)
182    except AttributeError:
183        if module.__name__ == '__main__':
184            try:
185                file_name = module.__file__
186            except AttributeError:
187                pass
188            else:
189                base_name = os.path.basename(file_name)
190                split_name = os.path.splitext(base_name)
191                if len(split_name) == 1:
192                    return six.text_type(base_name)
193                return u'.'.join(split_name[:-1])
194
195        return six.text_type(module.__name__)
196
197
198def total_seconds(offset):
199    """Backport of offset.total_seconds() from python 2.7+."""
200    seconds = offset.days * 24 * 60 * 60 + offset.seconds
201    microseconds = seconds * 10**6 + offset.microseconds
202    return microseconds / (10**6 * 1.0)
203
204
205class TimeZoneOffset(datetime.tzinfo):
206    """Time zone information as encoded/decoded for DateTimeFields."""
207
208    def __init__(self, offset):
209        """Initialize a time zone offset.
210
211        Args:
212          offset: Integer or timedelta time zone offset, in minutes from UTC.
213            This can be negative.
214        """
215        super(TimeZoneOffset, self).__init__()
216        if isinstance(offset, datetime.timedelta):
217            offset = total_seconds(offset) / 60
218        self.__offset = offset
219
220    def utcoffset(self, _):
221        """Get the a timedelta with the time zone's offset from UTC.
222
223        Returns:
224          The time zone offset from UTC, as a timedelta.
225        """
226        return datetime.timedelta(minutes=self.__offset)
227
228    def dst(self, _):
229        """Get the daylight savings time offset.
230
231        The formats that ProtoRPC uses to encode/decode time zone
232        information don't contain any information about daylight
233        savings time. So this always returns a timedelta of 0.
234
235        Returns:
236          A timedelta of 0.
237
238        """
239        return datetime.timedelta(0)
240
241
242def decode_datetime(encoded_datetime, truncate_time=False):
243    """Decode a DateTimeField parameter from a string to a python datetime.
244
245    Args:
246      encoded_datetime: A string in RFC 3339 format.
247      truncate_time: If true, truncate time string with precision higher than
248          microsecs.
249
250    Returns:
251      A datetime object with the date and time specified in encoded_datetime.
252
253    Raises:
254      ValueError: If the string is not in a recognized format.
255    """
256    # Check if the string includes a time zone offset.  Break out the
257    # part that doesn't include time zone info.  Convert to uppercase
258    # because all our comparisons should be case-insensitive.
259    time_zone_match = _TIME_ZONE_RE.search(encoded_datetime)
260    if time_zone_match:
261        time_string = encoded_datetime[:time_zone_match.start(1)].upper()
262    else:
263        time_string = encoded_datetime.upper()
264
265    if '.' in time_string:
266        format_string = '%Y-%m-%dT%H:%M:%S.%f'
267    else:
268        format_string = '%Y-%m-%dT%H:%M:%S'
269
270    try:
271        decoded_datetime = datetime.datetime.strptime(time_string,
272                                                      format_string)
273    except ValueError:
274        if truncate_time and '.' in time_string:
275            datetime_string, decimal_secs = time_string.split('.')
276            if len(decimal_secs) > 6:
277                # datetime can handle only microsecs precision.
278                truncated_time_string = '{}.{}'.format(
279                    datetime_string, decimal_secs[:6])
280                decoded_datetime = datetime.datetime.strptime(
281                    truncated_time_string,
282                    format_string)
283                logging.warning(
284                    'Truncating the datetime string from %s to %s',
285                    time_string, truncated_time_string)
286            else:
287                raise
288        else:
289            raise
290
291    if not time_zone_match:
292        return decoded_datetime
293
294    # Time zone info was included in the parameter.  Add a tzinfo
295    # object to the datetime.  Datetimes can't be changed after they're
296    # created, so we'll need to create a new one.
297    if time_zone_match.group('z'):
298        offset_minutes = 0
299    else:
300        sign = time_zone_match.group('sign')
301        hours, minutes = [int(value) for value in
302                          time_zone_match.group('hours', 'minutes')]
303        offset_minutes = hours * 60 + minutes
304        if sign == '-':
305            offset_minutes *= -1
306
307    return datetime.datetime(decoded_datetime.year,
308                             decoded_datetime.month,
309                             decoded_datetime.day,
310                             decoded_datetime.hour,
311                             decoded_datetime.minute,
312                             decoded_datetime.second,
313                             decoded_datetime.microsecond,
314                             TimeZoneOffset(offset_minutes))
315