Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Context argument annotation #155

Merged
merged 8 commits into from
Apr 4, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/source/dev/types.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,8 @@ Timeout
=======

.. autoclass:: uplink.Timeout

Context
=======

.. autoclass:: uplink.Context
29 changes: 23 additions & 6 deletions tests/integration/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,28 @@ def handle_error(exc_type, exc_value, exc_tb):

class Calendar(uplink.Consumer):
@handle_response_with_consumer
@uplink.get("/calendar/{todo_id}")
@uplink.get("todos/{todo_id}")
def get_todo(self, todo_id):
pass

@handle_response
@uplink.get("/calendar/{name}")
@uplink.get("months/{name}/todos")
def get_month(self, name):
pass

@handle_response_with_consumer
@handle_response
@uplink.get("months/{month}/days/{day}/todos")
def get_day(self, month, day):
pass

@handle_error_with_consumer
@uplink.get("/calendar/{user_id}")
@uplink.get("users/{user_id}")
def get_user(self, user_id):
pass

@handle_error
@uplink.get("/calendar/{event_id}")
@uplink.get("events/{event_id}")
def get_event(self, event_id):
pass

Expand All @@ -76,6 +82,17 @@ def test_response_handler(mock_client):
assert response.flagged is True


def test_multiple_response_handlers(mock_client):
calendar = Calendar(base_url=BASE_URL, client=mock_client)

# Run
response = calendar.get_day("September", 2)

# Verify
assert response.flagged
assert calendar.flagged


def test_error_handler_with_consumer(mock_client):
# Setup: raise specific exception
expected_error = IOError()
Expand All @@ -91,7 +108,7 @@ def test_error_handler_with_consumer(mock_client):
assert err.exception == expected_error
assert calendar.flagged is True
else:
assert False
raise AssertionError


def test_error_handler(mock_client):
Expand All @@ -107,4 +124,4 @@ def test_error_handler(mock_client):
except WrappedException as err:
assert err.exception == expected_error
else:
assert False
raise AssertionError
7 changes: 1 addition & 6 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@ def http_client_mock(mocker):
return client


@pytest.fixture
def request_mock(mocker):
# TODO: Remove
return None


@pytest.fixture
def transaction_hook_mock(mocker):
return mocker.Mock(spec=hooks.TransactionHook)
Expand Down Expand Up @@ -70,6 +64,7 @@ def uplink_builder_mock(mocker):
def request_builder(mocker):
builder = mocker.MagicMock(spec=helpers.RequestBuilder)
builder.info = collections.defaultdict(dict)
builder.context = {}
builder.get_converter.return_value = lambda x: x
builder.client.exceptions = Exceptions()
return builder
26 changes: 23 additions & 3 deletions tests/unit/test_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,9 +276,7 @@ def test_modify_request_with_mismatched_encoding(self, request_builder):
)

def test_skip_none(self, request_builder):
arguments.Query("name").modify_request(
request_builder, None
)
arguments.Query("name").modify_request(request_builder, None)
assert request_builder.info["params"] == {}

def test_encode_none(self, request_builder):
Expand Down Expand Up @@ -433,3 +431,25 @@ class TestTimeout(ArgumentTestCase, FuncDecoratorTestCase):
def test_modify_request(self, request_builder):
arguments.Timeout().modify_request(request_builder, 10)
assert request_builder.info["timeout"] == 10


class TestContext(ArgumentTestCase, FuncDecoratorTestCase):
type_cls = arguments.Context
expected_converter_key = keys.Identity()

def test_modify_request(self, request_builder):
arguments.Context("key").modify_request(request_builder, "value")
assert request_builder.context["key"] == "value"


class TestContextMap(ArgumentTestCase, FuncDecoratorTestCase):
type_cls = arguments.ContextMap
expected_converter_key = keys.Identity()

def test_modify_request(self, request_builder):
arguments.ContextMap().modify_request(request_builder, {"key": "value"})
assert request_builder.context == {"key": "value"}

def test_modify_request_not_mapping(self, request_builder):
with pytest.raises(TypeError):
arguments.ContextMap().modify_request(request_builder, "value")
19 changes: 17 additions & 2 deletions tests/unit/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def test_prepare_request_with_transaction_hook(
request_builder.url = "/example/path"
request_builder.request_template = "request_template"
uplink_builder.base_url = "https://example.com"
uplink_builder.add_hook(transaction_hook_mock)
request_builder.transaction_hooks = [transaction_hook_mock]
request_preparer = builder.RequestPreparer(uplink_builder)
execution_builder = mocker.Mock(spec=io.RequestExecutionBuilder)
request_preparer.prepare_request(request_builder, execution_builder)
Expand All @@ -61,10 +61,25 @@ def test_prepare_request_with_transaction_hook(
execution_builder.with_io.assert_called_with(uplink_builder.client.io())
execution_builder.with_template(request_builder.request_template)

def test_create_request_builder(self, uplink_builder, request_definition):
def test_create_request_builder(self, mocker, request_definition):
uplink_builder = mocker.Mock(spec=builder.Builder)
uplink_builder.converters = ()
uplink_builder.hooks = ()
request_definition.make_converter_registry.return_value = {}
request_preparer = builder.RequestPreparer(uplink_builder)
request = request_preparer.create_request_builder(request_definition)
assert isinstance(request, helpers.RequestBuilder)

def test_create_request_builder_with_session_hooks(
self, mocker, request_definition, transaction_hook_mock
):
uplink_builder = mocker.Mock(spec=builder.Builder)
uplink_builder.converters = ()
uplink_builder.hooks = (transaction_hook_mock,)
request_definition.make_converter_registry.return_value = {}
request_preparer = builder.RequestPreparer(uplink_builder)
request = request_preparer.create_request_builder(request_definition)
assert transaction_hook_mock.audit_request.called
assert isinstance(request, helpers.RequestBuilder)


Expand Down
10 changes: 10 additions & 0 deletions tests/unit/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,13 @@ def test_add_transaction_hook(self, transaction_hook_mock):

# Verify
assert list(builder.transaction_hooks) == [transaction_hook_mock]

def test_context(self):
# Setup
builder = helpers.RequestBuilder(None, {}, "base_url")

# Run
builder.context["key"] = "value"

# Verify
assert builder.context["key"] == "value"
12 changes: 12 additions & 0 deletions tests/unit/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,15 @@ def test_auth_set(uplink_builder_mock):

# Verify
assert ("username", "password") == uplink_builder_mock.auth


def test_context(uplink_builder_mock):
# Setup
sess = session.Session(uplink_builder_mock)

# Run
sess.context["key"] = "value"

# Verify
assert uplink_builder_mock.add_hook.called
assert sess.context == {"key": "value"}
2 changes: 2 additions & 0 deletions uplink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
Body,
Url,
Timeout,
Context,
)
from uplink.ratelimit import ratelimit
from uplink.retry import retry
Expand Down Expand Up @@ -90,6 +91,7 @@
"Body",
"Url",
"Timeout",
"Context",
"retry",
"ratelimit",
]
Expand Down
79 changes: 77 additions & 2 deletions uplink/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"Body",
"Url",
"Timeout",
"Context",
]


Expand Down Expand Up @@ -683,7 +684,7 @@ def _modify_request(cls, request_builder, value):

class Timeout(FuncDecoratorMixin, ArgumentAnnotation):
"""
Pass a timeout as a method argument at runtime.
Passes a timeout as a method argument at runtime.

While :py:class:`uplink.timeout` attaches static timeout to all requests
sent from a consumer method, this class turns a method argument into a
Expand All @@ -696,7 +697,6 @@ class Timeout(FuncDecoratorMixin, ArgumentAnnotation):
def get_posts(self, timeout: Timeout() = 60):
\"""Fetch all posts for the current users giving up after given
number of seconds.\"""

"""

@property
Expand All @@ -711,3 +711,78 @@ def converter_key(self):
def _modify_request(self, request_builder, value):
"""Modifies request timeout."""
request_builder.info["timeout"] = value


class Context(FuncDecoratorMixin, NamedArgument):
"""
Defines a name-value pair that is accessible to middleware at
runtime.

Request middleware can leverage this annotation to give users
control over the middleware's behavior.

Example:
Consider a custom decorator :obj:`@cache` (this would be a
subclass of :class:`uplink.decorators.MethodAnnotation`):

.. code-block:: python

@cache(hours=3)
@get("users/user_id")
def get_user(self, user_id)
\"""Retrieves a single user.\"""

As its name suggests, the :obj:`@cache` decorator enables
caching server responses so that, once a request is cached,
subsequent identical requests can be served by the cache
provider rather than adding load to the upstream service.

Importantly, the writers of the :obj:`@cache` decorators can
allow users to pass their own cache provider implementation
through an argument annotated with :class:`Context <uplink.Context>`:

.. code-block:: python

@cache(hours=3)
@get("users/user_id")
def get_user(self, user_id, cache_provider: Context)
\"""Retrieves a single user.\"""

To add a name-value pair to the context of any request made from
a :class:`Consumer <uplink.Consumer>` instance, you
can use the :attr:`Consumer.session.context
<uplink.session.Session.context>` property. Alternatively, you
can annotate a constructor argument of a :class:`Consumer
<uplink.Consumer>` subclass with :class:`Context
<uplink.Context>`, as explained
:ref:`here <annotating constructor arguments>`.
"""

@property
def converter_key(self):
"""Do not convert passed argument."""
return keys.Identity()

def _modify_request(self, request_builder, value):
"""Sets the name-value pair in the context."""
request_builder.context[self.name] = value


class ContextMap(FuncDecoratorMixin, ArgumentAnnotation):
"""
Defines a mapping of name-value pairs that are accessible to
middleware at runtime.
"""

@property
def converter_key(self):
"""Do not convert passed argument."""
return keys.Identity()

def _modify_request(self, request_builder, value):
"""Updates the context with the given name-value pairs."""
if not isinstance(value, collections.Mapping):
raise TypeError(
"ContextMap requires a mapping; got %s instead.", type(value)
)
request_builder.context.update(value)
43 changes: 30 additions & 13 deletions uplink/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,49 +22,66 @@

class RequestPreparer(object):
def __init__(self, builder, consumer=None):
self._hooks = list(builder.hooks)
self._client = builder.client
self._base_url = str(builder.base_url)
self._converters = list(builder.converters)
self._auth = builder.auth
self._consumer = consumer

if builder.hooks:
self._session_chain = hooks_.TransactionHookChain(*builder.hooks)
else:
self._session_chain = None

def _join_url_with_base(self, url):
return utils.urlparse.urljoin(self._base_url, url)

def _get_hook_chain(self, contract):
@staticmethod
def _get_request_hooks(contract):
chain = list(contract.transaction_hooks)
if callable(contract.return_type):
chain.append(hooks_.ResponseHandler(contract.return_type))
chain.extend(self._hooks)
return chain

def _wrap_hook(self, func):
return functools.partial(func, self._consumer)

def apply_hooks(self, execution_builder, chain, request_builder):
hook = hooks_.TransactionHookChain(*chain)
hook.audit_request(self._consumer, request_builder)
if hook.handle_response is not None:
def apply_hooks(self, execution_builder, chain):
# TODO:
# Instead of creating a TransactionChain, we could simply
# add each response and error handler in the chain to the
# execution builder. This would allow heterogenous response
# and error handlers. Right now, the TransactionChain
# enforces that all response/error handlers are blocking if
# any response/error handler is blocking, which is
# unnecessary now that we delegate execution to an IO layer.
if chain.handle_response is not None:
execution_builder.with_callbacks(
self._wrap_hook(hook.handle_response)
self._wrap_hook(chain.handle_response)
)
execution_builder.with_errbacks(self._wrap_hook(hook.handle_exception))
execution_builder.with_errbacks(self._wrap_hook(chain.handle_exception))

def prepare_request(self, request_builder, execution_builder):
request_builder.url = self._join_url_with_base(request_builder.url)
self._auth(request_builder)
chain = self._get_hook_chain(request_builder)
if chain:
self.apply_hooks(execution_builder, chain, request_builder)
request_hooks = self._get_request_hooks(request_builder)
if request_hooks:
chain = hooks_.TransactionHookChain(*request_hooks)
chain.audit_request(self._consumer, request_builder)
self.apply_hooks(execution_builder, chain)
if self._session_chain:
self.apply_hooks(execution_builder, self._session_chain)

execution_builder.with_client(self._client)
execution_builder.with_io(self._client.io())
execution_builder.with_template(request_builder.request_template)

def create_request_builder(self, definition):
registry = definition.make_converter_registry(self._converters)
return helpers.RequestBuilder(self._client, registry, self._base_url)
req = helpers.RequestBuilder(self._client, registry, self._base_url)
if self._session_chain:
self._session_chain.audit_request(self._consumer, req)
return req


class CallFactory(object):
Expand Down
Loading