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