Source code for github2.core

# -*- coding: utf-8 -*-
# Copyright (C) 2009-2012 Ask Solem <askh@modwheel.net>
#                         Fernando Perez <Fernando.Perez@berkeley.edu>
#                         James Rowe <jnrowe@gmail.com>
#                         Mark Paschal <markpasc@markpasc.org>
#                         Patryk Zawadzki <patrys@pld-linux.org>
#                         Sameer Al-Sakran <sameer@whitelabellabs.com>
#                         St├ęphane Angel <s.angel@twidi.com>
#                         Vincent Driessen <vincent@datafox.nl>
#
# This file is part of python-github2, and is made available under the 3-clause
# BSD license.  See LICENSE for the full details.

import logging
import sys

from datetime import datetime
from dateutil import (parser, tz)


#: Logger for core module
LOGGER = logging.getLogger('github2.core')

#: Running under Python 3
PY3K = sys.version_info[0] == 3

#: Running under Python 2.7, or newer
PY27 = sys.version_info[:2] >= (2, 7)

GITHUB_DATE_FORMAT = "%Y/%m/%d %H:%M:%S %z"
# We need to manually mangle the timezone for commit date formatting because it
# uses -xx:xx format
COMMIT_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
#: GitHub timezone used in API output
GITHUB_TZ = tz.gettz("America/Los_Angeles")

#: Operate on naive :class:`datetime.datetime` objects, this is the default
#: for backwards compatibility
NAIVE = True


[docs]def string_to_datetime(string): """Convert a string to Python datetime. :param str github_date: date string to parse """ parsed = parser.parse(string) if NAIVE: parsed = parsed.replace(tzinfo=None) return parsed
def _handle_naive_datetimes(f): """Decorator to make datetime arguments use GitHub timezone. :param func f: Function to wrap """ def wrapper(datetime_): if not datetime_.tzinfo: datetime_ = datetime_.replace(tzinfo=GITHUB_TZ) else: datetime_ = datetime_.astimezone(GITHUB_TZ) return f(datetime_) wrapped = wrapper wrapped.__name__ = f.__name__ wrapped.__doc__ = ( f.__doc__ + """\n .. note:: Supports naive and timezone-aware datetimes""" ) return wrapped @_handle_naive_datetimes
[docs]def datetime_to_ghdate(datetime_): """Convert Python datetime to GitHub date string. :param datetime datetime_: datetime object to convert """ return datetime_.strftime(GITHUB_DATE_FORMAT)
@_handle_naive_datetimes
[docs]def datetime_to_commitdate(datetime_): """Convert Python datetime to GitHub date string. :param datetime datetime_: datetime object to convert """ date_without_tz = datetime_.strftime(COMMIT_DATE_FORMAT) utcoffset = GITHUB_TZ.utcoffset(datetime_) hours, minutes = divmod(utcoffset.days * 86400 + utcoffset.seconds, 3600) return "".join([date_without_tz, "%+03d:%02d" % (hours, minutes)])
[docs]def datetime_to_isodate(datetime_): """Convert Python datetime to GitHub date string. :param str datetime_: datetime object to convert .. note:: Supports naive and timezone-aware datetimes """ if not datetime_.tzinfo: datetime_ = datetime_.replace(tzinfo=tz.tzutc()) else: datetime_ = datetime_.astimezone(tz.tzutc()) return "%sZ" % datetime_.isoformat()[:-6]
class AuthError(Exception): """Requires authentication."""
[docs]def requires_auth(f): """Decorate to check a function call for authentication. Sets a ``requires_auth`` attribute on functions, for use in introspection. :param func f: Function to wrap :raises AuthError: If function called without an authenticated session """ # When Python 2.4 support is dropped move straight to functools.wraps, # don't pass go and don't collect $200. def wrapper(self, *args, **kwargs): if not self.request.access_token and not self.request.api_token: raise AuthError("%r requires an authenticated session" % f.__name__) return f(self, *args, **kwargs) wrapped = wrapper wrapped.__orig_func__ = f wrapped.__name__ = f.__name__ wrapped.__doc__ = f.__doc__ + """\n.. warning:: Requires authentication""" wrapped.__module__ = f.__module__ wrapped.requires_auth = True return wrapped
[docs]def enhanced_by_auth(f): """Decorator to mark a function as enhanced by authentication. Sets a ``enhanced_by_auth`` attribute on functions, for use in introspection. :param func f: Function to wrap """ f.enhanced_by_auth = True f.__doc__ += """\n.. note:: This call is enhanced with authentication""" return f
[docs]class GithubCommand(object): """Main API binding interface.""" def __init__(self, request): """Setup command object. :param github2.request.GithubRequest request: HTTP request handler """ self.request = request
[docs] def make_request(self, command, *args, **kwargs): """Make an API request. Various options are supported if they exist in ``kwargs``: * The value of a ``method`` argument will define the HTTP method to perform for this request, the default is ``GET`` * The value of a ``filter`` argument will restrict the response to that data * The value of a ``page`` argument will be used to fetch a specific page of results, default of 1 is assumed if not given """ filter = kwargs.get("filter") post_data = kwargs.get("post_data") or {} page = kwargs.pop("page", 1) if page and not page == 1: post_data["page"] = page method = kwargs.get("method", "GET").upper() if method == "POST" or method == "GET" and post_data: response = self.request.post(self.domain, command, *args, **post_data) elif method == "PUT": response = self.request.put(self.domain, command, *args, **post_data) elif method == "DELETE": response = self.request.delete(self.domain, command, *args, **post_data) else: response = self.request.get(self.domain, command, *args) if filter: return response[filter] return response
[docs] def get_value(self, *args, **kwargs): """Process a single-value response from the API. If a ``datatype`` parameter is given it defines the :class:`BaseData`-derived class we should build from the provided data """ datatype = kwargs.pop("datatype", None) value = self.make_request(*args, **kwargs) if datatype: if not PY27: # unicode keys are not accepted as kwargs by python, until 2.7: # http://bugs.python.org/issue2646 # So we make a local dict with the same keys but as strings: return datatype(**dict((str(k), v) for (k, v) in value.items())) else: return datatype(**value) return value
[docs] def get_values(self, *args, **kwargs): """Process a multi-value response from the API. :see: :meth:`get_value` """ datatype = kwargs.pop("datatype", None) values = self.make_request(*args, **kwargs) if datatype: if not PY27: # Same as above, unicode keys will blow up in **args, so we # need to create a new 'values' dict with string keys return [datatype(**dict((str(k), v) for (k, v) in value.items())) for value in values] else: return [datatype(**value) for value in values] else: return values
[docs]def doc_generator(docstring, attributes): """Utility function to augment BaseDataType docstring. :param str docstring: docstring to augment :param dict attributes: attributes to add to docstring """ docstring = docstring or "" def bullet(title, text): return """.. attribute:: %s\n\n %s\n""" % (title, text) b = "\n".join([bullet(attr_name, attr.help) for attr_name, attr in attributes.items()]) return "\n\n".join([docstring, b])
[docs]class Attribute(object): """Generic object attribute for use with :class:`BaseData`.""" def __init__(self, help): """Setup Attribute object. :param str help: Attribute description """ self.help = help def to_python(self, value): return value from_python = to_python
[docs]class DateAttribute(Attribute): """Date handling attribute for use with :class:`BaseData`.""" format = "github" converter_for_format = { "github": datetime_to_ghdate, "commit": datetime_to_commitdate, "user": datetime_to_ghdate, "iso": datetime_to_isodate, } def __init__(self, *args, **kwargs): """Setup DateAttribute object. :param str format: The date format to support, see :data:`convertor_for_format` for supported options """ self.format = kwargs.pop("format", self.format) super(DateAttribute, self).__init__(*args, **kwargs) def to_python(self, value): if value and not isinstance(value, datetime): return string_to_datetime(value) return value def from_python(self, value): if value and isinstance(value, datetime): return self.converter_for_format[self.format](value) return value
class BaseDataType(type): def __new__(cls, name, bases, attrs): super_new = super(BaseDataType, cls).__new__ _meta = dict([(attr_name, attr_value) for attr_name, attr_value in attrs.items() if isinstance(attr_value, Attribute)]) attrs["_meta"] = _meta attributes = _meta.keys() attrs.update(dict([(attr_name, None) for attr_name in attributes])) def _contribute_method(name, func): func.__name__ = name attrs[name] = func def constructor(self, **kwargs): for attr_name, attr_value in kwargs.items(): attr = self._meta.get(attr_name) if attr: setattr(self, attr_name, attr.to_python(attr_value)) else: setattr(self, attr_name, attr_value) _contribute_method("__init__", constructor) def iterate(self): not_empty = lambda e: e[1] is not None return iter(filter(not_empty, vars(self).items())) _contribute_method("__iter__", iterate) result_cls = super_new(cls, name, bases, attrs) result_cls.__doc__ = doc_generator(result_cls.__doc__, _meta) return result_cls # Ugly base class definition for Python 2 and 3 compatibility, where metaclass # syntax is incompatible
[docs]class BaseData(BaseDataType('BaseData', (object, ), {})): """Wrapper for API responses. .. warning:: Supports subscript attribute access purely for backwards compatibility, you shouldn't rely on that functionality in new code """ def __getitem__(self, key): """Access objects's attribute using subscript notation. This is here purely to maintain compatibility when switching ``dict`` responses to ``BaseData`` derived objects. """ LOGGER.warning("Subscript access on %r is deprecated, use object " "attributes" % self.__class__.__name__) if not key in self._meta.keys(): raise KeyError(key) return getattr(self, key) def __setitem__(self, key, value): """Update object's attribute using subscript notation. :see: :meth:`BaseData.__getitem__` """ LOGGER.warning("Subscript access on %r is deprecated, use object " "attributes" % self.__class__.__name__) if not key in self._meta.keys(): raise KeyError(key) setattr(self, key, value)
[docs]def repr_string(string): """Shorten string for use in repr() output. :param str string: string to operate on :return: string, with maximum length of 20 characters """ if len(string) > 20: string = string[:17] + '...' if not PY3K: string.decode('utf-8') return string