diff --git a/changes/3061-FaresAhmedb.md b/changes/3061-FaresAhmedb.md new file mode 100644 index 0000000000..622dc0e7b3 --- /dev/null +++ b/changes/3061-FaresAhmedb.md @@ -0,0 +1 @@ +Add percent encoding in `AnyUrl` and descendent types diff --git a/docs/examples/types_url_building.py b/docs/examples/types_url_building.py new file mode 100644 index 0000000000..7d8d67b2ba --- /dev/null +++ b/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) diff --git a/docs/usage/types.md b/docs/usage/types.md index 0fe38d1901..d063186cbc 100644 --- a/docs/usage/types.md +++ b/docs/usage/types.md @@ -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: @@ -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 diff --git a/pydantic/networks.py b/pydantic/networks.py index c7d97186b9..725e4f8a27 100644 --- a/pydantic/networks.py +++ b/pydantic/networks.py @@ -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 @@ -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') @@ -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 @@ -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})' @@ -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( @@ -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) diff --git a/tests/test_networks.py b/tests/test_networks.py index 7381710e44..95af277b35 100644 --- a/tests/test_networks.py +++ b/tests/test_networks.py @@ -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): @@ -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', [