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

treq.auth omnibus #315

Merged
merged 12 commits into from Dec 28, 2020
1 change: 1 addition & 0 deletions changelog.d/268.feature.rst
@@ -0,0 +1 @@
The *auth* parameter now accepts arbitrary text and `bytes` for usernames and passwords. Text is encoded as UTF-8, per :rfc:`7617`. Previously only ASCII was allowed.
1 change: 1 addition & 0 deletions changelog.d/312.bugfix.rst
@@ -0,0 +1 @@
The agent returned by :func:`treq.auth.add_auth()` and :func:`treq.auth.add_basic_auth()` is now marked to provide :class:`twisted.web.iweb.IAgent`.
1 change: 1 addition & 0 deletions changelog.d/313.doc.rst
@@ -0,0 +1 @@
The :mod:`treq.auth` module has been documented.
1 change: 1 addition & 0 deletions changelog.d/314.bugfix.rst
@@ -0,0 +1 @@
treq request APIs no longer mutates a :class:`http_headers.Headers <twisted.web.http_headers.Headers>` passed as the *headers* parameter when the *auth* parameter is also passed.
10 changes: 10 additions & 0 deletions docs/api.rst
Expand Up @@ -78,6 +78,16 @@ Augmented Response Objects

See :meth:`IResponse.setPreviousResponse() <twisted.web.iweb.IResponse.setPreviousResponse>`

Authentication
--------------

.. module:: treq.auth

.. autofunction:: add_auth

.. autofunction:: add_basic_auth

.. autoexception:: UnknownAuthConfig

Test Helpers
------------
Expand Down
92 changes: 92 additions & 0 deletions src/treq/_recorder.py
@@ -0,0 +1,92 @@
# Copyright (c) The treq Authors.
# See LICENSE for details.
from typing import Callable, List, Optional, Tuple

import attr
from twisted.internet.defer import Deferred
from twisted.web.http_headers import Headers
from twisted.web.iweb import IAgent, IBodyProducer, IResponse
from zope.interface import implementer


@attr.s(frozen=True, order=False, slots=True)
class RequestRecord(object):
twm marked this conversation as resolved.
Show resolved Hide resolved
"""
The details of a call to :meth:`IAgent.request`

:ivar method: The *method* argument to :meth:`IAgent.request`
:ivar uri: The *uri* argument to :meth:`IAgent.request`
:ivar headers: The *headers* argument to :meth:`IAgent.request`
:ivar bodyProducer: The *bodyProducer* argument to :meth:`IAgent.request`
:ivar deferred: The :class:`Deferred` returned by :meth:`IAgent.request`
"""

method = attr.ib() # type: bytes
uri = attr.ib() # type: bytes
headers = attr.ib() # type: Optional[Headers]
bodyProducer = attr.ib() # type: Optional[IBodyProducer]
deferred = attr.ib() # type: Deferred


@implementer(IAgent)
@attr.s
class _RequestRecordAgent(object):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good. I think that this test helper would also be very useful in core Twisted HTTP as it might be used by other tests in which twisted.web.iweb.IAgent is used.

If you have time for a review, I could try to add it to Twisted, but I need a review buddy.
If if you want to add it to twisted, I would be happy to review it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be happy to see it added there. Indeed it woudn't surprise me if something similar already exists somewhere in twisted.web.test. I'd be happy to review if you port it over, or I can do so.

I filed https://twistedmatrix.com/trac/ticket/10072 (with AgentSpy as the name) to add this to Twisted. Please self-assign the ticket when you start working on it; I'll do the same if I get to it first.

"""
An agent that records HTTP requests

:ivar _callback:
A function called with each :class:`RequestRecord`
"""

_callback = attr.ib() # type: Callable[Tuple[RequestRecord], None]

def request(self, method, uri, headers=None, bodyProducer=None):
# type: (bytes, bytes, Optional[Headers], Optional[IBodyProducer]) -> Deferred[IResponse] # noqa
if not isinstance(method, bytes):
raise TypeError(
"method must be bytes, not {!r} of type {}".format(method, type(method))
)
if not isinstance(uri, bytes):
raise TypeError(
"uri must be bytes, not {!r} of type {}".format(uri, type(uri))
)
if headers is not None and not isinstance(headers, Headers):
raise TypeError(
"headers must be {}, not {!r} of type {}".format(
type(Headers), headers, type(headers)
)
)
if bodyProducer is not None and not IBodyProducer.providedBy(bodyProducer):
raise TypeError(
(
"bodyProducer must implement IBodyProducer, but {!r} does not."
" Is the implementation marked with @implementer(IBodyProducer)?"
).format(bodyProducer)
)
d = Deferred()
record = RequestRecord(method, uri, headers, bodyProducer, d)
self._callback(record)
return d


def recorder():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor comment

maybe rename it as get_http_agent_spy and allow to pass any list?

Right now, having the list managed outside of recorder() makes no difference and maybe just keep it as get_http_agent_spy and return a tupple.

Suggested change
def recorder():
def get_http_agent_spy(accumulator_callable):

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the "agent spy" terminology, but I definitely want a function like recorder() that is low lines-of-code to use in a test. If using this API takes more than one or two lines of code folks will move it to setUp which then makes the tests more verbose and less amenable to local reasoning.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you write one, read many times :) ... and with a good name, you read the name and you don't have to search for the documentation of that name :)

and then, I guess that all developers have auto completion :)

And if someones adds stuff to setUp just complain during review.

It's your call.
I don't think that is fair to make the code harder to read just because some people are lazy :p

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed recorder to agent_spy and _RequestRecordAgent to _AgentSpy. The class could be made public to provide the bring-your-own-accumulator API, or another wrapper function added. Probably the latter is best to prevent subclassing.

# type: () -> Tuple[IAgent, List[RequestRecord]]
"""
Record HTTP requests made with an agent

This is suitable for low-level testing of wrapper agents. It validates
the parameters of each call to :meth:`IAgent.request` (synchronously
raising :exc:`TypeError`) and captures them as a :class:`RequestRecord`,
which can then be used to inspect the request or generate a response by
firing the :attr:`~RequestRecord.deferred`.

:returns:
A two-tuple of:

- An :class:`twisted.web.iweb.IAgent`
- A list of calls made to the agent's
:meth:`~twisted.web.iweb.IAgent.request()` method
"""
records = []
agent = _RequestRecordAgent(records.append)
return agent, records
3 changes: 2 additions & 1 deletion src/treq/api.py
Expand Up @@ -91,7 +91,8 @@ def request(method, url, **kwargs):
:param bool persistent: Use persistent HTTP connections. Default: ``True``
:param bool allow_redirects: Follow HTTP redirects. Default: ``True``

:param auth: HTTP Basic Authentication information.
:param auth: HTTP Basic Authentication information --- see
:func:`treq.auth.add_auth`.
:type auth: tuple of ``('username', 'password')``.

:param cookies: Cookies to send with this request. The HTTP kind, not the
Expand Down
71 changes: 64 additions & 7 deletions src/treq/auth.py
@@ -1,40 +1,97 @@
# Copyright 2012-2020 The treq Authors.
# See LICENSE for details.
from __future__ import absolute_import, division, print_function

import binascii
from typing import Union

from twisted.web.http_headers import Headers
import base64
from twisted.web.iweb import IAgent
from zope.interface import implementer


class UnknownAuthConfig(Exception):
"""
The authentication config provided couldn't be interpreted.
"""
def __init__(self, config):
super(Exception, self).__init__(
'{0!r} not of a known type.'.format(config))


@implementer(IAgent)
class _RequestHeaderSettingAgent(object):
twm marked this conversation as resolved.
Show resolved Hide resolved
"""
Wrap an agent to set request headers

:ivar _agent: The wrapped agent.

:ivar _request_headers:
Headers to set on each request before forwarding it to the wrapped
agent.
"""
def __init__(self, agent, request_headers):
self._agent = agent
self._request_headers = request_headers

def request(self, method, uri, headers=None, bodyProducer=None):
if headers is None:
headers = self._request_headers
new = self._request_headers
else:
new = headers.copy()
for header, values in self._request_headers.getAllRawHeaders():
headers.setRawHeaders(header, values)
new.setRawHeaders(header, values)

return self._agent.request(
method, uri, headers=headers, bodyProducer=bodyProducer)
method, uri, headers=new, bodyProducer=bodyProducer)


def add_basic_auth(agent, username, password):
creds = base64.b64encode(
'{0}:{1}'.format(username, password).encode('ascii'))
# type: (IAgent, Union[str, bytes], Union[str, bytes]) -> IAgent
"""
Wrap an agent to add HTTP basic authentication

The returned agent sets the *Authorization* request header according to the
basic authentication scheme described in :rfc:`7617`. This header contains
the given *username* and *password* in plaintext, and thus should only be
used over an encrypted transport (HTTPS).

Note that the colon (``:``) is used as a delimiter between the *username*
and *password*, so if either parameter includes a colon the interpretation
of the *Authorization* header is server-defined.

:param agent: Agent to wrap.
:param username: The username.
:param password: The password.

:returns: :class:`~twisted.web.iweb.IAgent`
"""
if not isinstance(username, bytes):
username = username.encode('utf-8')
if not isinstance(password, bytes):
password = password.encode('utf-8')

creds = binascii.b2a_base64(b'%s:%s' % (username, password)).rstrip(b'\n')
return _RequestHeaderSettingAgent(
agent,
Headers({b'Authorization': [b'Basic ' + creds]}))
Headers({b'Authorization': [b'Basic ' + creds]}),
)


def add_auth(agent, auth_config):
"""
Wrap an agent to perform authentication

:param agent: Agent to wrap.

:param auth_config:
A ``('username', 'password')`` tuple --- see :func:`add_basic_auth`.

:returns: :class:`~twisted.web.iweb.IAgent`

:raises UnknownAuthConfig:
When the format *auth_config* isn't supported.
"""
if isinstance(auth_config, tuple):
return add_basic_auth(agent, auth_config[0], auth_config[1])

Expand Down