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 @@
Fix `AnyUrl.build` doesn't do percent encoding
faresbakhit marked this conversation as resolved.
Show resolved Hide resolved
16 changes: 11 additions & 5 deletions pydantic/networks.py
@@ -1,4 +1,5 @@
import re
from functools import partial
from ipaddress import (
IPv4Address,
IPv4Interface,
Expand Down Expand Up @@ -26,7 +27,7 @@
)

from . import errors
from .utils import Representation, update_not_none
from .utils import Representation, percent_encode, update_not_none
from .validators import constr_length_validator, str_validator

if TYPE_CHECKING:
Expand Down Expand Up @@ -153,6 +154,7 @@ def __init__(
path: Optional[str] = None,
query: Optional[str] = None,
fragment: Optional[str] = None,
plus: bool = False,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
plus: bool = False,
quote_plus: bool = False,

might be a clearer name.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, is there a good reason not to have quote_plus = True as the default?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add quote_plus to stricturl

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, is there a good reason not to have quote_plus = True as the default?

Just conforming to the standard (RFC 3986)

) -> None:
str.__init__(url)
self.scheme = scheme
Expand All @@ -178,6 +180,7 @@ def build(
path: Optional[str] = None,
query: Optional[str] = None,
fragment: Optional[str] = None,
plus: bool = False,
**_kwargs: str,
) -> str:
parts = Parts(
Expand All @@ -192,20 +195,23 @@ def build(
**_kwargs, # type: ignore[misc]
)

percent_encode_plus = partial(percent_encode, plus=plus)

url = scheme + '://'
if user:
url += user
url += percent_encode_plus(user)
if password:
url += ':' + password
url += ':' + percent_encode_plus(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(percent_encode_plus, path.split('/')))
if query:
url += '?' + query
queries = query.split('&')
url += '?' + '&'.join(map(lambda s: '='.join(map(percent_encode_plus, s.split('='))), queries))
if fragment:
url += '#' + fragment
return url
Expand Down
26 changes: 26 additions & 0 deletions pydantic/utils.py
Expand Up @@ -787,3 +787,29 @@ def __setitem__(self, __key: Any, __value: Any) -> None:
def __class_getitem__(cls, *args: Any) -> Any:
# to avoid errors with 3.7
pass


URI_RESERVED_CHARACTERS = frozenset(":/?#[]@!$&'()*+,;=% ")


def percent_encode(string: str, *, plus: bool = False) -> str:
faresbakhit marked this conversation as resolved.
Show resolved Hide resolved
"""
Percent encode the URI reserved characters in a string
"""
if not string:
return string

string = string.replace('%', f'%{ord("%"):02X}')

if plus:
string = string.replace('+', f'%{ord("+"):02X}')
string = string.replace(' ', '+')
characters = URI_RESERVED_CHARACTERS.symmetric_difference('+% ')
else:
characters = URI_RESERVED_CHARACTERS.symmetric_difference('%')
faresbakhit marked this conversation as resolved.
Show resolved Hide resolved

for c in characters:
if c in string:
string = string.replace(c, f'%{ord(c):02X}')

return string
6 changes: 5 additions & 1 deletion tests/test_networks.py
Expand Up @@ -370,7 +370,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 @@ -559,6 +559,10 @@ 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', plus=True), 'http://example.net?q=foo+bar'),
(dict(scheme='http', host='example.net', path="/m&m's"), 'http://example.net/m%26m%27s'),
],
)
def test_build_url(kwargs, expected):
Expand Down
13 changes: 13 additions & 0 deletions tests/test_utils.py
Expand Up @@ -36,6 +36,7 @@
import_string,
lenient_issubclass,
path_type,
percent_encode,
smart_deepcopy,
truncate,
unique_list,
Expand Down Expand Up @@ -566,3 +567,15 @@ def test_limited_dict():
assert len(d) == 10
d[13] = '13'
assert len(d) == 9


@pytest.mark.parametrize(
'input_value,output,plus',
[
('Pa##w0rd?', 'Pa%23%23w0rd%3F', False),
('A query', 'A%20query', False),
('A query', 'A+query', True),
],
)
def test_percent_encode(input_value, output, plus):
assert percent_encode(input_value, plus=plus) == output