diff --git a/docs/api.rst b/docs/api.rst index 48b196d..bbea0ec 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -55,3 +55,10 @@ .. automodule:: webtest.debugapp :members: :show-inheritance: + +:class:`webtest.utils.URL` +-------------------------------------- + +.. autoclass:: webtest.utils.URL + :member-order: bysource + :members: diff --git a/docs/testresponse.rst b/docs/testresponse.rst index 7e27a26..f84b01f 100644 --- a/docs/testresponse.rst +++ b/docs/testresponse.rst @@ -50,6 +50,16 @@ The inherited attributes that are most interesting: The :class:`webob.request.BaseRequest` object used to generate this response. +``response.location`` + The `Location` header URL of a redirect as a :class:`webtest.utils.URL` object or + None. You can easily test properties of the URL using its + :meth:`webtest.utils.URL.match` and :meth:`webtest.utils.URL.loose_match` + methods. + +``response.content_location`` + The `Content-Location` header URL of a redirect as a :class:`webtest.utils.URL` object or + None. + The added methods: ``response.follow(**kw)``: @@ -96,6 +106,10 @@ The added methods: If there is just a single form, this returns that. It is an error if you use this and there are multiple forms. +``response.url`` + The URL of the request as a :class:`webtest.utils.URL` object or None. + + .. rubric:: Footnotes diff --git a/tests/test_response.py b/tests/test_response.py index 5ad1583..f92db70 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -416,6 +416,15 @@ def test_content_dezips(self): resp = app.get('/') self.assertEqual(resp.body, b'test') + def test_urls(self): + app = webtest.TestApp(debug_app) + res = app.post('/') + res.location = 'http://pylons.org' + res.content_location = 'https://example.org/a/b/c' + self.assertTrue(res.location.loose_match('http://')) + self.assertTrue(res.content_location.match('https://example.org/a/b/c')) + self.assertTrue(res.url.match('http://localhost/')) + class TestFollow(unittest.TestCase): diff --git a/tests/test_utils.py b/tests/test_utils.py index 7bf54ff..a24cc56 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,6 +5,7 @@ from .compat import unittest from webtest import utils +import pytest class NoDefaultTest(unittest.TestCase): @@ -117,3 +118,124 @@ def test_json_method_doc(self): def test_json_method_name(self): self.assertEqual(self.mock.foo_json.__name__, 'foo_json') + +class TestURL: + @property + def url(self): + return utils.URL('https://example.com/a/b/c?foo=bar&foo=barbar&bar=foo') + + def test_str(self): + assert ( + str(self.url) + == 'https://example.com/a/b/c?foo=bar&foo=barbar&bar=foo' + ) + + def test_repr(self): + assert ( + repr(self.url) + == "" + ) + + def test_scheme(self): + assert self.url.scheme == 'https' + + def test_domain(self): + assert self.url.domain == 'example.com' + + def test_host(self): + # webob.Response is wrong in environ_from_url(), here it should be + # example.com + assert self.url.host == 'example.com:443' + + def test_netloc(self): + # so netloc was implemented to replace it + assert self.url.netloc == 'example.com' + + def test_host_url(self): + assert self.url.host_url == 'https://example.com' + + def test_path(self): + assert self.url.path == '/a/b/c' + + def test_path_url(self): + assert self.url.path_url == 'https://example.com/a/b/c' + + def test_path_qs(self): + assert self.url.path_qs == '/a/b/c?foo=bar&foo=barbar&bar=foo' + + def test_params(self): + assert list(self.url.params.items()) == [ + ('foo', 'bar'), ('foo', 'barbar'), ('bar', 'foo')] + assert self.url.params['foo'] == 'barbar' + assert self.url.query_string == 'foo=bar&foo=barbar&bar=foo' + + def test_join(self): + assert (self.url.join('x/y?foofo=bar') + == 'https://example.com/a/b/x/y?foofo=bar') + + @pytest.mark.parametrize('other', [ + 'https://example.com/a/b/c?foo=bar&foo=barbar&bar=foo', + 'https://example.com/a/b/c?foo=*&bar=?&!foobar', + ]) + def test_do_match(self, other): + assert self.url.match(other) + + @pytest.mark.parametrize('other', [ + 'https://example.com/a/b/c?foo=bar', + ]) + def test_do_not_match(self, other): + assert not self.url.match(other) + + @pytest.mark.parametrize('other,_repr', [ + ('https://example.com/a/b/c?foo=bar&bar=foo', + '?foo=barbar was not expected.'), + # multiple errors + ('https://example.com/a/b/c?foo=bar', + '?foo=barbar was not expected.\n?bar=foo was not expected.'), + ]) + def test_do_not_match_repr(self, other, _repr): + assert repr(self.url.match(other)) == _repr + + @pytest.mark.parametrize('other', [ + 'https://', + '//example.com', + 'https://example.com', + '/a/b/c', + 'https://example.com/a/b/c', + 'https://example.com/a/b/c?foo=bar&foo=barbar&bar=foo', + 'https://example.com/a/b/c?foo=bar', + 'https://example.com/a/b/c?foo=barbar', + '?!foobar', + '?bar=?', + '?bar=?&foo=*', + ]) + def test_do_loose_match(self, other): + assert self.url.loose_match(other) + + @pytest.mark.parametrize('other', [ + 'http://', + '//a.example.com', + '/a/b/c/', + '/x', + 'https://example.com/a/b/c/', + 'https://example.com/x', + ]) + def test_do_not_loose_match(self, other): + assert not self.url.loose_match(other) + + @pytest.mark.parametrize('other,_repr', [ + ('http://', 'scheme differs https != http'), + ('//a.example.com', 'netloc differs example.com != a.example.com'), + ('/a/b/c/', 'path differs /a/b/c != /a/b/c/'), + + ('?!foo', 'foo should be absent, but ?foo=bar&foo=barbar found.'), + ('?foo=?', + 'foo should have only one value but ?foo=bar&foo=barbar found.'), + ('?foobar=?', 'foobar should have only one value but is absent.'), + ('?foo=barfoo', + 'foo should have value \'barfoo\' but ?foo=bar&foo=barbar found.'), + ('?foobar=barfoo', + 'foobar should have value \'barfoo\' but is absent.'), + ]) + def test_do_not_loose_match_repr(self, other, _repr): + assert repr(self.url.loose_match(other)) == _repr diff --git a/webtest/response.py b/webtest/response.py index 98609b5..22497bf 100644 --- a/webtest/response.py +++ b/webtest/response.py @@ -545,3 +545,19 @@ def showbrowser(self): else: url = 'file://' + name webbrowser.open_new(url) + + @webob.Response.location.getter + def location(self): + value = webob.Response.location.fget(self) + if value is not None: + return utils.URL(value) + + @webob.Response.content_location.getter + def content_location(self): + value = webob.Response.content_location.fget(self) + if value is not None: + return utils.URL(value) + + @property + def url(self): + return utils.URL(self.request.url) diff --git a/webtest/utils.py b/webtest/utils.py index e99df59..10c175d 100644 --- a/webtest/utils.py +++ b/webtest/utils.py @@ -1,5 +1,9 @@ +import functools import re from json import dumps +import urllib.parse + +import webob from webtest.compat import urlencode @@ -167,3 +171,167 @@ def getheaders(self, header): def get_all(self, headers, default): # NOQA # This is undocumented method that Python 3 cookielib uses return self._response.headers.getall(headers) + + +class URLMismatch: + def __init__(self, message): + self.messages = message if isinstance(message, list) else [message] + + def __bool__(self): + return not bool(self.messages) + + def __repr__(self): + return '\n'.join(self.messages) + + +class URL(str): + ''' + string subclass with methods to extract or test parts of the URL + structure. + ''' + + @functools.cached_property + def request(self): + '''Returns a :class:`webob.BaseRequest` object corresponding to this URL.''' + parsed = urllib.parse.urlparse(self) + request = webob.Request.blank(self) + # XXX: webob force the http: scheme if parsed.scheme is absent XXX: + # webob does not understand //{host}/ it interprets it as if //{host}/ + # is a path + if not parsed.scheme: + request.environ['PATH_INFO'] = parsed.path + request.environ['HTTP_HOST'] = parsed.netloc + request.environ['wsgi.url_scheme'] = None + return request + + def __getattr__(self, attr): + return getattr(self.request, attr) + + def join(self, other): + '''Apply :meth:`urllib.parse.urljoin` and returns a new URL object.''' + return URL( + urllib.parse.urljoin(str(self), str(other) if other else '')) + + @property + def netloc(self): + '''netloc (host + port) of the URL.''' + return urllib.parse.urlparse(self).netloc + + @property + def host_url(self): + '''returns :attr:`webob.BaseRequest.host_url` of the equivalent request.''' + return URL(self.request.host_url) + + @property + def path(self): + '''returns :attr:`webob.BaseRequest.path` of the equivalent request.''' + return URL(self.request.path) + + @property + def path_url(self): + '''returns :attr:`webob.BaseRequest.path_url` of the equivalent request.''' + return URL(self.request.path_url) + + @property + def path_qs(self): + '''returns :attr:`webob.BaseRequest.path_qs` of the equivalent request.''' + return URL(self.request.path_qs) + + def __repr__(self): + return f'<{self.__class__.__name__} {str(self)!r}>' + + def match(self, other, strict=True): + ''' + Returns True if `self` matches the `other` URL given as a string or + an URL object. + + * scheme, netloc and path must be equal or missing + * `/*/` can be used as a wildcard path + + >>> assert URL('https://example.com/foor/bar').match('https://example.com/*/') + + * in the query string, parameters must matches: + + >>> assert URL('//example.com/?foo=bar&bar=foo').loose_match('//example.com/?foo=bar') + + * but a parameter whose name starts with `!` must be absent (useful + mainly for `loose_match()`) + + >>> assert URL('?foo=bar').loose_match('?!bar') + + * but a paramter whose value is `?` must have only one non empty + value. + + >>> assert not URL('?foo=bar&foo=foo').match('https://example.com/?foo=?') + + * but a parameter whose value is `*` acccept any number of value or + none, it's the wildcard match, + + >>> assert URL('?foo=bar&foo=foo').match('?foo=*') + ''' + return self._match(other, strict=True) + + def loose_match(self, other): + ''' + Match loosely against another URL. It's like `match()` but if a part of + the URL is missing it will not returns False. + ''' + return self._match(other, strict=False) + + + def _match(self, other, strict=True): + if not isinstance(other, URL): + other = URL(other) + errors = [] + if (strict or other.scheme) and other.scheme != self.scheme: + errors.append(f'scheme differs {self.scheme} != {other.scheme}') + if (strict or other.netloc) and self.netloc != other.netloc: + errors.append(f'netloc differs {self.netloc} != {other.netloc}') + if ((strict or other.path) and other.path != '/*/' + and other.path != self.path): + errors.append(f'path differs {self.path} != {other.path}') + expected = set() + if other.params: + for key, value in other.params.items(): + # &!key forbids key in query string + if key.startswith('!'): + if key[1:] in self.params: + qs = urllib.parse.urlencode( + [(key[1:], v) for v in self.params.getall(key[1:])]) + errors.append( + f'{key[1:]} should be absent, but ?{qs} found.') + elif value == '?': + values = self.params.getall(key) + if len(values) == 0 or (len(values) == 1 and not values[0]): + errors.append( + f'{key} should have only one value but is absent.') + elif len(values) > 1: + qs = urllib.parse.urlencode( + [(key, v) for v in self.params.getall(key)]) + errors.append( + f'{key} should have only one value but ?{qs}' + ' found.') + else: + expected.add((key, self.params[key])) + elif value == '*': + for v in self.params.getall(key): + expected.add((key, v)) + else: + if key not in self.params: + errors.append( + f'{key} should have value {value!r} but is absent.') + elif value not in self.params.getall(key): + qs = urllib.parse.urlencode( + [(key, v) for v in self.params.getall(key)]) + errors.append( + f'{key} should have value {value!r} but' + f' ?{qs} found.') + else: + expected.add((key, value)) + + if strict: + for key, value in self.params.items(): + if (key, value) not in expected: + errors.append(f'?{key}={value} was not expected.') + + return URLMismatch(errors)