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