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