Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix AnyUrl.build doesn't do percent encoding (#3061) #4224

Merged
merged 10 commits into from Aug 19, 2022
1 change: 1 addition & 0 deletions changes/3061-FaresAhmedb.md
@@ -0,0 +1 @@
Add percent encoding in `AnyUrl` and descendent types
10 changes: 10 additions & 0 deletions docs/examples/types_url_building.py
@@ -0,0 +1,10 @@
from pydantic import AnyUrl, stricturl

url = AnyUrl.build(scheme='https', host='example.com', query='query=my query')
print(url)

my_url_builder = stricturl(quote_plus=True)
url = my_url_builder.build(
scheme='http', host='example.com', query='query=my query'
)
print(url)
15 changes: 15 additions & 0 deletions docs/usage/types.md
Expand Up @@ -616,6 +616,7 @@ For URI/URL validation the following types are available:
- `tld_required: bool = True`
- `host_required: bool = True`
- `allowed_schemes: Optional[Set[str]] = None`
- `quote_plus: bool = False`

The above types (which all inherit from `AnyUrl`) will attempt to give descriptive errors when invalid URLs are
provided:
Expand Down Expand Up @@ -678,6 +679,20 @@ If further validation is required, these properties can be used by validators to
Also, Chrome, Firefox, and Safari all currently accept `http://exam_ple.com` as a URL, so we're in good
(or at least big) company.

#### Building URLs

You can build URLs from separate [URL Properties](#url-properties) using the `build` method in
[Pydantic URL types](#urls) or any type that inherits from them.

By default, *pydantic* percent encodes the following URL properties: `user`, `password`, `path`, `query`
as per [RFC 3986](https://www.ietf.org/rfc/rfc3986.txt) without replacing spaces with `+` but this can
be changed using the `stricturl` method:

!!! note
Percent encoding was added in V1.10

{!.tmp_examples/types_url_building.md!}

### Color Type

You can use the `Color` data type for storing colors as per
Expand Down
17 changes: 13 additions & 4 deletions pydantic/networks.py
Expand Up @@ -26,6 +26,7 @@
cast,
no_type_check,
)
from urllib.parse import quote, quote_plus

from . import errors
from .utils import Representation, update_not_none
Expand Down Expand Up @@ -177,6 +178,7 @@ class AnyUrl(str):
user_required: bool = False
host_required: bool = True
hidden_parts: Set[str] = set()
quote_plus: bool = False

__slots__ = ('scheme', 'user', 'password', 'host', 'tld', 'host_type', 'port', 'path', 'query', 'fragment')

Expand Down Expand Up @@ -239,18 +241,19 @@ def build(

url = scheme + '://'
if user:
url += user
url += cls.quote(user)
if password:
url += ':' + password
url += ':' + cls.quote(password)
if user or password:
url += '@'
url += host
if port and ('port' not in cls.hidden_parts or cls.get_default_parts(parts).get('port') != port):
url += ':' + port
if path:
url += path
url += '/'.join(map(cls.quote, path.split('/')))
if query:
url += '?' + query
queries = query.split('&')
url += '?' + '&'.join(map(lambda s: '='.join(map(cls.quote, s.split('='))), queries))
if fragment:
url += '#' + fragment
return url
Expand Down Expand Up @@ -391,6 +394,10 @@ def apply_default_parts(cls, parts: 'Parts') -> 'Parts':
parts[key] = value # type: ignore[literal-required]
return parts

@classmethod
def quote(cls, string: str, safe: str = '') -> str:
return quote_plus(string, safe) if cls.quote_plus else quote(string, safe)

def __repr__(self) -> str:
extra = ', '.join(f'{n}={getattr(self, n)!r}' for n in self.__slots__ if getattr(self, n) is not None)
return f'{self.__class__.__name__}({super().__repr__()}, {extra})'
Expand Down Expand Up @@ -558,6 +565,7 @@ def stricturl(
tld_required: bool = True,
host_required: bool = True,
allowed_schemes: Optional[Collection[str]] = None,
quote_plus: bool = False,
) -> Type[AnyUrl]:
# use kwargs then define conf in a dict to aid with IDE type hinting
namespace = dict(
Expand All @@ -567,6 +575,7 @@ def stricturl(
tld_required=tld_required,
host_required=host_required,
allowed_schemes=allowed_schemes,
quote_plus=quote_plus,
)
return type('UrlValue', (AnyUrl,), namespace)

Expand Down
21 changes: 20 additions & 1 deletion tests/test_networks.py
Expand Up @@ -372,7 +372,7 @@ class Model(BaseModel):

@pytest.mark.parametrize(
'value',
['file:///foo/bar', 'file://localhost/foo/bar' 'file:////localhost/foo/bar'],
['file:///foo/bar', 'file://localhost/foo/bar', 'file:////localhost/foo/bar'],
)
def test_file_url_success(value):
class Model(BaseModel):
Expand Down Expand Up @@ -679,12 +679,31 @@ class Model2(BaseModel):
(dict(scheme='ws', user='foo', password='x', host='example.net'), 'ws://foo:x@example.net'),
(dict(scheme='ws', host='example.net', query='a=b', fragment='c=d'), 'ws://example.net?a=b#c=d'),
(dict(scheme='http', host='example.net', port='1234'), 'http://example.net:1234'),
(dict(scheme='http', user='foo@bar', host='example.net'), 'http://foo%40bar@example.net'),
(dict(scheme='http', user='foo', password='a b', host='example.net'), 'http://foo:a%20b@example.net'),
(dict(scheme='http', host='example.net', query='q=foo bar'), 'http://example.net?q=foo%20bar'),
(dict(scheme='http', host='example.net', path="/m&m's"), 'http://example.net/m%26m%27s'),
],
)
def test_build_url(kwargs, expected):
assert AnyUrl(None, **kwargs) == expected


@pytest.mark.parametrize(
'kwargs,expected',
[
(dict(scheme='https', host='example.com', query='query=my query'), 'https://example.com?query=my+query'),
(
dict(scheme='https', host='example.com', user='my name', password='a password'),
'https://my+name:a+password@example.com',
),
(dict(scheme='https', host='example.com', path='/this is a path'), 'https://example.com/this+is+a+path'),
],
)
def test_build_url_quote_plus(kwargs, expected):
assert stricturl(quote_plus=True).build(**kwargs) == expected


@pytest.mark.parametrize(
'kwargs,expected',
[
Expand Down