diff --git a/pyproject.toml b/pyproject.toml index 903bcd6..2958d35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,7 @@ dependency_groups = ["dev"] set_env = { LC_ALL = "C", LANG = "C", COVERAGE_FILE = ".coverage.{env_name}" } commands = [ ["python", "--version"], - ["pytest", "--cov", "{posargs}"], + ["pytest", "--cov", { replace = "posargs", default = ["tests/"], extend = true } ], ] [tool.tox.env.coverage] 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..f684e4c 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,113 @@ 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. ?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..c34d3ac 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,109 @@ 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 Error: + def __init__(self, message): + self.message = message + + def __bool__(self): + return False + + def __repr__(self): + return self.message + + +class URL(str): + @functools.cached_property + def request(self): + 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): + return URL(urllib.parse.urljoin(str(self), str(other) if other else '')) + + @property + def netloc(self): + return urllib.parse.urlparse(self).netloc + + @property + def host_url(self): + return URL(self.request.host_url) + + @property + def path(self): + return URL(self.request.path) + + @property + def path_url(self): + return URL(self.request.path_url) + + @property + def path_qs(self): + return URL(self.request.path_qs) + + def __repr__(self): + return f'<{self.__class__.__name__} {str(self)!r}>' + + def loose_match(self, other): + 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 ?{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.') + + if errors: + return Error(' '.join(errors)) + + return True