Skip to content

Commit

Permalink
Extend Consumer Methods to Reduce Boilerplate (#159)
Browse files Browse the repository at this point in the history
* Allow extending consumer methods

* Remove Python 3 function annotations

* Document consumer method extension

* Fix whitespace issues

* Fix broken logic with @returns.*
  • Loading branch information
prkumar committed Apr 23, 2019
1 parent 0d29483 commit 8483dcc
Show file tree
Hide file tree
Showing 5 changed files with 311 additions and 54 deletions.
2 changes: 2 additions & 0 deletions docs/source/user/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ So far, this class looks like any other Python class. The real magic
happens when you define methods to interact with the webservice using
Uplink's HTTP method decorators, which we cover next.

.. _making-a-request:

Making a Request
================

Expand Down
61 changes: 61 additions & 0 deletions docs/source/user/tips.rst
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,64 @@ calculate request properties within plain old python methods.
Similar to the annotation style, request properties added with
:py:meth:`~uplink.Consumer._inject` method are applied to all requests made
through the consumer instance.


Extend Consumer Methods to Reduce Boilerplate
=============================================

.. versionadded:: v0.9.0

**Consumer methods** are methods decorated with Uplink's HTTP method decorators,
such as :class:`@get <uplink.get>` or :class:`@post <uplink.post>` (see
:ref:`here <making-a-request>` for more background).

Consumer methods can be used as decorators to minimize duplication across similar
consumer method definitions.

For example, you can define consumer method templates like so:

.. code-block:: python
:emphasize-lines: 6-7,10
from uplink import Consumer, get, json, returns
@returns.json
@json
@get
def get_json():
"""Template for GET request that consumes and produces JSON."""
class GitHub(Consumer):
@get_json("/users/{user}")
def get_user(self, user):
"""Fetches a specific GitHub user."""
Further, you can use this technique to remove duplication across definitions
of similar consumer methods, whether or not the methods are defined in the same
class:

.. code-block:: python
:emphasize-lines: 9-11,19-20
from uplink import Consumer, get, params, timeout
class GitHub(Consumer):
@timeout(10)
@get("/users/{user}/repos")
def get_user_repos(self, user):
"""Retrieves the repos that the user owns."""
# Extends the above method to define a variant:
@params(type="member")
@get_user_repos
def get_repos_for_collaborator(self, user):
"""
Retrieves the repos for which the given user is
a collaborator.
"""
class EnhancedGitHub(Github):
# Updates the return type of an inherited method.
@GitHub.get_user_repos
def get_user_repos(self, user) -> List[Repo]:
"""Retrieves the repos that the user owns."""
94 changes: 94 additions & 0 deletions tests/integration/test_extend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Third-party imports
import pytest

# Local imports
import uplink

# Constants
BASE_URL = "https://api.github.com"


class GitHubError(Exception):
pass


@uplink.response_handler
def github_error(response):
if "errors" in response.json():
raise GitHubError()
return response


@uplink.timeout(10)
class GitHubService(uplink.Consumer):
@github_error
@uplink.json
@uplink.post("graphql", args=(uplink.Body,))
def graphql(self, **body):
pass

@uplink.returns.json(member=("data", "repository"))
@uplink.args(body=uplink.Body)
@graphql
def get_repository(self, **body):
pass

@uplink.returns.json(member=("data", "repository"))
@graphql.extend("graphql2", args=(uplink.Body,))
def get_repository2(self, **body):
pass


def test_get_repository(mock_client, mock_response):
data = {
"query": """\
query {
repository(owner: "prkumar", name: "uplink") {
nameWithOwner
}
}"""
}
result = {"data": {"repository": {"nameWithOwner": "prkumar/uplink"}}}
mock_response.with_json(result)
mock_client.with_response(mock_response)
github = GitHubService(base_url=BASE_URL, client=mock_client)
response = github.get_repository(**data)
request = mock_client.history[0]
assert request.method == "POST"
assert request.base_url == BASE_URL
assert request.endpoint == "/graphql"
assert request.timeout == 10
assert request.json == data
assert response == result["data"]["repository"]


def test_get_repository2_failure(mock_client, mock_response):
data = {
"query": """\
query {
repository(owner: "prkumar", name: "uplink") {
nameWithOwner
}
}"""
}
result = {
"data": {"repository": None},
"errors": [
{
"type": "NOT_FOUND",
"path": ["repository"],
"locations": [{"line": 7, "column": 3}],
"message": "Could not resolve to a User with the username 'prkussmar'.",
}
],
}
mock_response.with_json(result)
mock_client.with_response(mock_response)
github = GitHubService(base_url=BASE_URL, client=mock_client)
with pytest.raises(GitHubError):
github.get_repository2(**data)
request = mock_client.history[0]
assert request.method == "POST"
assert request.base_url == BASE_URL
assert request.endpoint == "/graphql2"
assert request.timeout == 10
200 changes: 150 additions & 50 deletions uplink/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,62 +34,16 @@ def __init__(self, uri, remaining_variables):
self.message = self.message % (uri, "', '".join(remaining_variables))


class HttpMethodFactory(object):
def __init__(self, method):
self._method = method

def __call__(self, uri=None, args=()):
if callable(uri) and not args:
return HttpMethod(self._method)(uri)
else:
return HttpMethod(self._method, uri, args)


class HttpMethod(object):
@staticmethod
def _add_args(obj):
return obj

def __init__(self, method, uri=None, args=None):
self._method = method
self._uri = uri

# Register argument annotations
if args:
is_map = isinstance(args, collections.Mapping)
args, kwargs = ((), args) if is_map else (args, {})
self._add_args = decorators.args(*args, **kwargs)

def __call__(self, func):
spec = utils.get_arg_spec(func)
arg_handler = arguments.ArgumentAnnotationHandlerBuilder(
func, spec.args
)
builder = RequestDefinitionBuilder(
self._method,
URIDefinitionBuilder(self._uri),
arg_handler,
decorators.MethodAnnotationHandlerBuilder(),
)

# Need to add the annotations after constructing the request
# definition builder so it has a chance to attach its listener.
arg_handler.set_annotations(spec.annotations)

# Use return value type hint as expected return type
if spec.return_annotation is not None:
builder = returns.schema(spec.return_annotation)(builder)
functools.update_wrapper(builder, func)
builder = self._add_args(builder)
return builder


class URIDefinitionBuilder(interfaces.UriDefinitionBuilder):
def __init__(self, uri):
self._uri = uri
self._is_dynamic = False
self._uri_variables = set()

@property
def template(self):
return self._uri

@property
def is_static(self):
return self._uri is not None
Expand Down Expand Up @@ -164,6 +118,88 @@ def return_type(self):
def return_type(self, return_type):
self._return_type = return_type

def __call__(self, uri=None, args=()):
"""
Applies the decorators, HTTP method, and optionally the URI
of this consumer method to the decorated method.
This makes the request definition reusable and can help
minimize duplication across similar consumer methods.
Examples:
Define request templates:
.. code-block:: python
from uplink import Consumer, get, json, returns
@returns.json
@json
@get
def get_json():
\"""GET request that consumes and produces JSON.\"""
class GitHub(Consumer):
@get_json("/users/{user}")
def get_user(self, user):
\"""Fetches a specific GitHub user.\"""
Remove duplication across definitions of similar consumer
methods, whether or not the methods are defined in the same
class:
.. code-block:: python
from uplink import Consumer, get, params, timeout
class GitHub(Consumer):
@timeout(10)
@get("/users/{user}/repos")
def get_user_repos(self, user):
\"""Retrieves the repos that the user owns.\"""
# Extends the above method to define a variant:
@params(type="member")
@get_user_repos
def get_repos_for_collaborator(self, user):
\"""
Retrieves the repos for which the given user is
a collaborator.
\"""
class EnhancedGitHub(Github):
# Updates the return type of an inherited method.
@GitHub.get_user_repos
def get_user_repos(self, user) -> List[Repo]:
\"""Retrieves the repos that the user owns.\"""
Args:
uri (str, optional): the request's relative path
args: a list or mapping of function annotations (e.g.
:class:`uplink.Path`) corresponding to the decorated
function's arguments
"""
return self.extend(uri, args)

def extend(self, uri=None, args=()):
factory = HttpMethodFactory(
method=self.method, request_definition_builder_factory=self._extend
)

if callable(uri):
return factory(self.uri.template, args)(uri)
else:
uri = self.uri.template if uri is None else uri
return factory(uri, args)

def _extend(self, method, uri, arg_handler, _):
builder = RequestDefinitionBuilder(
method, uri, arg_handler, self.method_handler_builder.copy()
)
builder.return_type = self.return_type
return builder

def copy(self):
builder = RequestDefinitionBuilder(
self._method,
Expand Down Expand Up @@ -235,6 +271,70 @@ def define_request(self, request_builder, func_args, func_kwargs):
request_builder.url = request_builder.url.build()


class HttpMethodFactory(object):
def __init__(
self,
method,
request_definition_builder_factory=RequestDefinitionBuilder,
):
self._method = method
self._request_definition_builder_factory = (
request_definition_builder_factory
)

def __call__(self, uri=None, args=()):
if callable(uri) and not args:
return HttpMethod(self._method)(
uri, self._request_definition_builder_factory
)
else:
return functools.partial(
HttpMethod(self._method, uri, args),
request_definition_builder_factory=self._request_definition_builder_factory,
)


class HttpMethod(object):
@staticmethod
def _add_args(obj):
return obj

def __init__(self, method, uri=None, args=None):
self._method = method
self._uri = uri

# Register argument annotations
if args:
is_map = isinstance(args, collections.Mapping)
args, kwargs = ((), args) if is_map else (args, {})
self._add_args = decorators.args(*args, **kwargs)

def __call__(
self, func, request_definition_builder_factory=RequestDefinitionBuilder
):
spec = utils.get_arg_spec(func)
arg_handler = arguments.ArgumentAnnotationHandlerBuilder(
func, spec.args
)
builder = request_definition_builder_factory(
self._method,
URIDefinitionBuilder(self._uri),
arg_handler,
decorators.MethodAnnotationHandlerBuilder(),
)

# Need to add the annotations after constructing the request
# definition builder so it has a chance to attach its listener.
arg_handler.set_annotations(spec.annotations)

# Use return value type hint as expected return type
if spec.return_annotation is not None:
builder = returns.schema(spec.return_annotation)(builder)
functools.update_wrapper(builder, func)
builder = self._add_args(builder)
return builder


get = HttpMethodFactory("GET").__call__
head = HttpMethodFactory("HEAD").__call__
put = HttpMethodFactory("PUT").__call__
Expand Down

0 comments on commit 8483dcc

Please sign in to comment.