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
7 changes: 7 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,10 @@
.. automodule:: webtest.debugapp
:members:
:show-inheritance:

:class:`webtest.utils.URL`
--------------------------------------

.. autoclass:: webtest.utils.URL
:member-order: bysource
:members:
14 changes: 14 additions & 0 deletions docs/testresponse.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)``:
Expand Down Expand Up @@ -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

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
122 changes: 122 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,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)
== "<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.\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
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)
168 changes: 168 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,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)
Loading