Skip to content
Open
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
9 changes: 9 additions & 0 deletions tests/test_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
111 changes: 111 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .compat import unittest
from webtest import utils

import pytest

class NoDefaultTest(unittest.TestCase):

Expand Down Expand Up @@ -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) == "<URL 'https://example.com/a/b/c?foo=bar&foo=barbar&bar=foo'>"

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



16 changes: 16 additions & 0 deletions webtest/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
110 changes: 110 additions & 0 deletions webtest/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import functools
import re
from json import dumps
import urllib.parse

import webob

from webtest.compat import urlencode

Expand Down Expand Up @@ -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
Loading