A clearer implementation of MultiDictView

This makes MultiDictView work with a simple getter/setter pair, rather than
using attributes with implicit leading underscores. Also move MultiDictView
into multidict.py and adds some simple unit tests.
This commit is contained in:
Aldo Cortesi 2016-05-21 11:37:36 +12:00
parent 96d8ec1ee3
commit a5c4cd0340
6 changed files with 119 additions and 108 deletions

View File

@ -2,13 +2,13 @@ from __future__ import absolute_import, print_function, division
from .request import Request from .request import Request
from .response import Response from .response import Response
from .headers import Headers from .headers import Headers
from .message import MultiDictView, decoded from .message import decoded
from . import http1, http2, status_codes from . import http1, http2, status_codes
__all__ = [ __all__ = [
"Request", "Request",
"Response", "Response",
"Headers", "Headers",
"MultiDictView", "decoded", "decoded",
"http1", "http2", "status_codes", "http1", "http2", "status_codes",
] ]

View File

@ -236,72 +236,3 @@ class decoded(object):
def __exit__(self, type, value, tb): def __exit__(self, type, value, tb):
if self.ce: if self.ce:
self.message.encode(self.ce) self.message.encode(self.ce)
class MultiDictView(MultiDict):
"""
Some parts in HTTP (Cookies, URL query strings, ...) require a specific data structure: A MultiDict.
It behaves mostly like an ordered dict but it can have several values for the same key.
The MultiDictView provides a MultiDict *view* on an :py:class:`Request` or :py:class:`Response`.
That is, it represents a part of the request as a MultiDict, but doesn't contain state/data themselves.
For example, ``request.cookies`` provides a view on the ``Cookie: ...`` header.
Any change to ``request.cookies`` will also modify the ``Cookie`` header.
Any change to the ``Cookie`` header will also modify ``request.cookies``.
Example:
.. code-block:: python
# Cookies are represented as a MultiDict.
>>> request.cookies
MultiDictView[("name", "value"), ("a", "false"), ("a", "42")]
# MultiDicts mostly behave like a normal dict.
>>> request.cookies["name"]
"value"
# If there is more than one value, only the first value is returned.
>>> request.cookies["a"]
"false"
# `.get_all(key)` returns a list of all values.
>>> request.cookies.get_all("a")
["false", "42"]
# Changes to the headers are immediately reflected in the cookies.
>>> request.cookies
MultiDictView[("name", "value"), ...]
>>> del request.headers["Cookie"]
>>> request.cookies
MultiDictView[] # empty now
"""
def __init__(self, attr, message):
if False: # pragma: no cover
# We do not want to call the parent constructor here as that
# would cause an unnecessary parse/unparse pass.
# This is here to silence linters. Message
super(MultiDictView, self).__init__(None)
self._attr = attr
self._message = message # type: Message
@staticmethod
def _kconv(key):
# All request-attributes are case-sensitive.
return key
@staticmethod
def _reduce_values(values):
# We just return the first element if
# multiple elements exist with the same key.
return values[0]
@property
def fields(self):
return getattr(self._message, "_" + self._attr)
@fields.setter
def fields(self, value):
setattr(self._message, self._attr, value)

View File

@ -10,8 +10,9 @@ from netlib import utils
from netlib.http import cookies from netlib.http import cookies
from netlib.odict import ODict from netlib.odict import ODict
from .. import encoding from .. import encoding
from ..multidict import MultiDictView
from .headers import Headers from .headers import Headers
from .message import Message, _native, _always_bytes, MessageData, MultiDictView from .message import Message, _native, _always_bytes, MessageData
# This regex extracts & splits the host header into host and port. # This regex extracts & splits the host header into host and port.
# Handles the edge case of IPv6 addresses containing colons. # Handles the edge case of IPv6 addresses containing colons.
@ -228,20 +229,25 @@ class Request(Message):
""" """
The request query string as an :py:class:`MultiDictView` object. The request query string as an :py:class:`MultiDictView` object.
""" """
return MultiDictView("query", self) return MultiDictView(
self._get_query,
self._set_query
)
@property def _get_query(self):
def _query(self):
_, _, _, _, query, _ = urllib.parse.urlparse(self.url) _, _, _, _, query, _ = urllib.parse.urlparse(self.url)
return tuple(utils.urldecode(query)) return tuple(utils.urldecode(query))
@query.setter def _set_query(self, value):
def query(self, value):
query = utils.urlencode(value) query = utils.urlencode(value)
scheme, netloc, path, params, _, fragment = urllib.parse.urlparse(self.url) scheme, netloc, path, params, _, fragment = urllib.parse.urlparse(self.url)
_, _, _, self.path = utils.parse_url( _, _, _, self.path = utils.parse_url(
urllib.parse.urlunparse([scheme, netloc, path, params, query, fragment])) urllib.parse.urlunparse([scheme, netloc, path, params, query, fragment]))
@query.setter
def query(self, value):
self._set_query(value)
@property @property
def cookies(self): def cookies(self):
# type: () -> MultiDictView # type: () -> MultiDictView
@ -250,16 +256,21 @@ class Request(Message):
An empty :py:class:`MultiDictView` object if the cookie monster ate them all. An empty :py:class:`MultiDictView` object if the cookie monster ate them all.
""" """
return MultiDictView("cookies", self) return MultiDictView(
self._get_cookies,
self._set_cookies
)
@property def _get_cookies(self):
def _cookies(self):
h = self.headers.get_all("Cookie") h = self.headers.get_all("Cookie")
return tuple(cookies.parse_cookie_headers(h)) return tuple(cookies.parse_cookie_headers(h))
def _set_cookies(self, value):
self.headers["cookie"] = cookies.format_cookie_header(value)
@cookies.setter @cookies.setter
def cookies(self, value): def cookies(self, value):
self.headers["cookie"] = cookies.format_cookie_header(value) self._set_cookies(value)
@property @property
def path_components(self): def path_components(self):
@ -322,17 +333,18 @@ class Request(Message):
An empty MultiDictView if the content-type indicates non-form data An empty MultiDictView if the content-type indicates non-form data
or the content could not be parsed. or the content could not be parsed.
""" """
return MultiDictView("urlencoded_form", self) return MultiDictView(
self._get_urlencoded_form,
self._set_urlencoded_form
)
@property def _get_urlencoded_form(self):
def _urlencoded_form(self):
is_valid_content_type = "application/x-www-form-urlencoded" in self.headers.get("content-type", "").lower() is_valid_content_type = "application/x-www-form-urlencoded" in self.headers.get("content-type", "").lower()
if is_valid_content_type: if is_valid_content_type:
return tuple(utils.urldecode(self.content)) return tuple(utils.urldecode(self.content))
return () return ()
@urlencoded_form.setter def _set_urlencoded_form(self, value):
def urlencoded_form(self, value):
""" """
Sets the body to the URL-encoded form data, and adds the appropriate content-type header. Sets the body to the URL-encoded form data, and adds the appropriate content-type header.
This will overwrite the existing content if there is one. This will overwrite the existing content if there is one.
@ -340,21 +352,30 @@ class Request(Message):
self.headers["content-type"] = "application/x-www-form-urlencoded" self.headers["content-type"] = "application/x-www-form-urlencoded"
self.content = utils.urlencode(value) self.content = utils.urlencode(value)
@urlencoded_form.setter
def urlencoded_form(self, value):
self._set_urlencoded_form(value)
@property @property
def multipart_form(self): def multipart_form(self):
""" """
The multipart form data as an :py:class:`MultipartFormDict` object. The multipart form data as an :py:class:`MultipartFormDict` object.
None if the content-type indicates non-form data. None if the content-type indicates non-form data.
""" """
return MultiDictView("multipart_form", self) return MultiDictView(
self._get_multipart_form,
self._set_multipart_form
)
@property def _get_multipart_form(self):
def _multipart_form(self):
is_valid_content_type = "multipart/form-data" in self.headers.get("content-type", "").lower() is_valid_content_type = "multipart/form-data" in self.headers.get("content-type", "").lower()
if is_valid_content_type: if is_valid_content_type:
return utils.multipartdecode(self.headers, self.content) return utils.multipartdecode(self.headers, self.content)
return () return ()
def _set_multipart_form(self, value):
raise NotImplementedError()
@multipart_form.setter @multipart_form.setter
def multipart_form(self, value): def multipart_form(self, value):
raise NotImplementedError() self._set_multipart_form(value)

View File

@ -5,7 +5,8 @@ import time
from . import cookies from . import cookies
from .headers import Headers from .headers import Headers
from .message import Message, _native, _always_bytes, MessageData, MultiDictView from .message import Message, _native, _always_bytes, MessageData
from ..multidict import MultiDictView
from .. import utils from .. import utils
@ -80,21 +81,26 @@ class Response(Message):
Caveats: Caveats:
Updating the attr Updating the attr
""" """
return MultiDictView("cookies", self) return MultiDictView(
self._get_cookies,
self._set_cookies
)
@property def _get_cookies(self):
def _cookies(self):
h = self.headers.get_all("set-cookie") h = self.headers.get_all("set-cookie")
return tuple(cookies.parse_set_cookie_headers(h)) return tuple(cookies.parse_set_cookie_headers(h))
@cookies.setter def _set_cookies(self, value):
def cookies(self, all_cookies):
cookie_headers = [] cookie_headers = []
for k, v in all_cookies: for k, v in value:
header = cookies.format_set_cookie_header(k, v[0], v[1]) header = cookies.format_set_cookie_header(k, v[0], v[1])
cookie_headers.append(header) cookie_headers.append(header)
self.headers.set_all("set-cookie", cookie_headers) self.headers.set_all("set-cookie", cookie_headers)
@cookies.setter
def cookies(self, value):
self._set_cookies(value)
def refresh(self, now=None): def refresh(self, now=None):
""" """
This fairly complex and heuristic function refreshes a server This fairly complex and heuristic function refreshes a server

View File

@ -15,13 +15,7 @@ from .utils import Serializable
@six.add_metaclass(ABCMeta) @six.add_metaclass(ABCMeta)
class MultiDict(MutableMapping, Serializable): class _MultiDict(MutableMapping, Serializable):
def __init__(self, fields=None):
# it is important for us that .fields is immutable, so that we can easily
# detect changes to it.
self.fields = tuple(fields) if fields else tuple() # type: Tuple[Tuple[bytes, bytes], ...]
def __repr__(self): def __repr__(self):
fields = tuple( fields = tuple(
repr(field) repr(field)
@ -173,7 +167,7 @@ class MultiDict(MutableMapping, Serializable):
if multi: if multi:
return self.fields return self.fields
else: else:
return super(MultiDict, self).items() return super(_MultiDict, self).items()
def to_dict(self): def to_dict(self):
""" """
@ -213,6 +207,12 @@ class MultiDict(MutableMapping, Serializable):
return cls(tuple(x) for x in state) return cls(tuple(x) for x in state)
class MultiDict(_MultiDict):
def __init__(self, fields=None):
super(MultiDict, self).__init__()
self.fields = tuple(fields) if fields else tuple() # type: Tuple[Tuple[bytes, bytes], ...]
@six.add_metaclass(ABCMeta) @six.add_metaclass(ABCMeta)
class ImmutableMultiDict(MultiDict): class ImmutableMultiDict(MultiDict):
def _immutable(self, *_): def _immutable(self, *_):
@ -246,3 +246,34 @@ class ImmutableMultiDict(MultiDict):
ret = self.copy() ret = self.copy()
super(ImmutableMultiDict, ret).insert(index, key, value) super(ImmutableMultiDict, ret).insert(index, key, value)
return ret return ret
class MultiDictView(_MultiDict):
"""
The MultiDictView provides the MultiDict interface over calculated data.
The view itself contains no state - data is retrieved from the parent on
request, and stored back to the parent on change.
"""
def __init__(self, getter, setter):
self._getter = getter
self._setter = setter
super(MultiDictView, self).__init__()
@staticmethod
def _kconv(key):
# All request-attributes are case-sensitive.
return key
@staticmethod
def _reduce_values(values):
# We just return the first element if
# multiple elements exist with the same key.
return values[0]
@property
def fields(self):
return self._getter()
@fields.setter
def fields(self, value):
return self._setter(value)

View File

@ -1,5 +1,5 @@
from netlib import tutils from netlib import tutils
from netlib.multidict import MultiDict, ImmutableMultiDict from netlib.multidict import MultiDict, ImmutableMultiDict, MultiDictView
class _TMulti(object): class _TMulti(object):
@ -214,4 +214,26 @@ class TestImmutableMultiDict(object):
def test_with_insert(self): def test_with_insert(self):
md = TImmutableMultiDict() md = TImmutableMultiDict()
assert md.with_insert(0, "foo", "bar").fields == (("foo", "bar"),) assert md.with_insert(0, "foo", "bar").fields == (("foo", "bar"),)
assert md.fields == ()
class TParent(object):
def __init__(self):
self.vals = tuple()
def setter(self, vals):
self.vals = vals
def getter(self):
return self.vals
class TestMultiDictView(object):
def test_modify(self):
p = TParent()
tv = MultiDictView(p.getter, p.setter)
assert len(tv) == 0
tv["a"] = "b"
assert p.vals == (("a", "b"),)
tv["c"] = "b"
assert p.vals == (("a", "b"), ("c", "b"))
assert tv["a"] == "b"