Skip to content

Commit 823bde0

Browse files
Include urlencode, urldecode, quote, unquote implementations, outside of stdlib. (#83)
* urlencode/urldecode implementations, outside of stdlib * No need to lazy-import json * Dont lazy import json
1 parent d1b2591 commit 823bde0

File tree

13 files changed

+354
-43
lines changed

13 files changed

+354
-43
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
*.pyc
22
.coverage
33
.mypy_cache/
4+
.pytest_cache/
45
__pycache__/
56
dist/
67
venv/

scripts/unasync

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ unasync.unasync_files(
1111
"src/ahttpx/_response.py",
1212
"src/ahttpx/_request.py",
1313
"src/ahttpx/_streams.py",
14+
"src/ahttpx/_urlencode.py",
1415
"src/ahttpx/_urlparse.py",
1516
"src/ahttpx/_urls.py"
1617
],

src/ahttpx/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ._request import * # Request
88
from ._streams import * # ByteStream, IterByteStream, Stream
99
from ._server import * # serve_http, serve_tcp
10+
from ._urlencode import * # quote, unquote, urldecode, urlencode
1011
from ._urls import * # QueryParams, URL
1112

1213

@@ -38,5 +39,9 @@
3839
"timeout",
3940
"Transport",
4041
"QueryParams",
42+
"quote",
43+
"unquote",
4144
"URL",
45+
"urldecode",
46+
"urlencode",
4247
]

src/ahttpx/_content.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import json
12
import os
23
import typing
3-
import urllib.parse
44

55
from ._streams import Stream, ByteStream, IterByteStream
6+
from ._urlencode import urldecode, urlencode
67

78
__all__ = [
89
"Content",
@@ -87,7 +88,7 @@ def __init__(
8788
if form is None:
8889
d = {}
8990
elif isinstance(form, str):
90-
d = urllib.parse.parse_qs(form, keep_blank_values=True)
91+
d = urldecode(form)
9192
elif isinstance(form, Form):
9293
d = form.multi_dict()
9394
elif isinstance(form, typing.Mapping):
@@ -187,7 +188,7 @@ def __eq__(self, other: typing.Any) -> bool:
187188
)
188189

189190
def __str__(self) -> str:
190-
return urllib.parse.urlencode(self.multi_items())
191+
return urlencode(self.multi_dict())
191192

192193
def __repr__(self) -> str:
193194
return f"<Form {self.multi_dict()!r}>"
@@ -317,8 +318,6 @@ def __init__(self, data: typing.Any) -> None:
317318
self._data = data
318319

319320
def encode(self) -> tuple[Stream, str]:
320-
import json
321-
322321
content = json.dumps(
323322
self._data,
324323
ensure_ascii=False,

src/ahttpx/_pool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def description(self) -> str:
128128

129129
# Builtins...
130130
def __repr__(self) -> str:
131-
return f"<ConectionPool [{self.description()}]>"
131+
return f"<ConnectionPool [{self.description()}]>"
132132

133133
async def __aenter__(self) -> "ConnectionPool":
134134
return self

src/ahttpx/_urlencode.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import re
2+
3+
__all__ = ["quote", "unquote", "urldecode", "urlencode"]
4+
5+
6+
# Matchs a sequence of one or more '%xx' escapes.
7+
PERCENT_ENCODED_REGEX = re.compile("(%[A-Fa-f0-9][A-Fa-f0-9])+")
8+
9+
# https://datatracker.ietf.org/doc/html/rfc3986#section-2.3
10+
SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
11+
12+
13+
def urlencode(multidict, safe=SAFE):
14+
pairs = []
15+
for key, values in multidict.items():
16+
pairs.extend([(key, value) for value in values])
17+
18+
safe += "+"
19+
pairs = [(k.replace(" ", "+"), v.replace(" ", "+")) for k, v in pairs]
20+
21+
return "&".join(
22+
f"{quote(key, safe)}={quote(val, safe)}"
23+
for key, val in pairs
24+
)
25+
26+
27+
def urldecode(string):
28+
parts = [part.partition("=") for part in string.split("&") if part]
29+
pairs = [
30+
(unquote(key), unquote(val))
31+
for key, _, val in parts
32+
]
33+
34+
pairs = [(k.replace("+", " "), v.replace("+", " ")) for k, v in pairs]
35+
36+
ret = {}
37+
for k, v in pairs:
38+
ret.setdefault(k, []).append(v)
39+
return ret
40+
41+
42+
def quote(string, safe=SAFE):
43+
# Fast path if the string is already safe.
44+
if not string.strip(safe):
45+
return string
46+
47+
# Replace any characters not in the safe set with '%xx' escape sequences.
48+
return "".join([
49+
char if char in safe else percent(char)
50+
for char in string
51+
])
52+
53+
54+
def unquote(string):
55+
# Fast path if the string is not quoted.
56+
if '%' not in string:
57+
return string
58+
59+
# Unquote.
60+
parts = []
61+
current_position = 0
62+
for match in re.finditer(PERCENT_ENCODED_REGEX, string):
63+
start_position, end_position = match.start(), match.end()
64+
matched_text = match.group(0)
65+
# Include any text up to the '%xx' escape sequence.
66+
if start_position != current_position:
67+
leading_text = string[current_position:start_position]
68+
parts.append(leading_text)
69+
70+
# Decode the '%xx' escape sequence.
71+
hex = matched_text.replace('%', '')
72+
decoded = bytes.fromhex(hex).decode('utf-8')
73+
parts.append(decoded)
74+
current_position = end_position
75+
76+
# Include any text after the final '%xx' escape sequence.
77+
if current_position != len(string):
78+
trailing_text = string[current_position:]
79+
parts.append(trailing_text)
80+
81+
return "".join(parts)
82+
83+
84+
def percent(c):
85+
return ''.join(f"%{b:02X}" for b in c.encode("utf-8"))

src/ahttpx/_urls.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
from __future__ import annotations
1+
from __future__ import annotations
22

33
import typing
4-
from urllib.parse import parse_qs, unquote, urlencode
54

65
from ._urlparse import urlparse
6+
from ._urlencode import unquote, urldecode, urlencode
77

8-
__all__ = ["URL", "QueryParams"]
8+
__all__ = ["QueryParams", "URL"]
99

1010

1111
class URL:
@@ -70,7 +70,7 @@ class URL:
7070
themselves.
7171
"""
7272

73-
def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None:
73+
def __init__(self, url: "URL" | str = "", **kwargs: typing.Any) -> None:
7474
if kwargs:
7575
allowed = {
7676
"scheme": str,
@@ -233,7 +233,7 @@ def query(self) -> bytes:
233233
return query.encode("ascii")
234234

235235
@property
236-
def params(self) -> QueryParams:
236+
def params(self) -> "QueryParams":
237237
"""
238238
The URL query parameters, neatly parsed and packaged into an immutable
239239
multidict representation.
@@ -358,7 +358,7 @@ def __init__(
358358
if params is None:
359359
d = {}
360360
elif isinstance(params, str):
361-
d = parse_qs(params, keep_blank_values=True)
361+
d = urldecode(params)
362362
elif isinstance(params, QueryParams):
363363
d = params.multi_dict()
364364
elif isinstance(params, dict):
@@ -454,7 +454,7 @@ def get_list(self, key: str) -> list[str]:
454454
"""
455455
return list(self._dict.get(key, []))
456456

457-
def set(self, key: str, value: str) -> "QueryParams":
457+
def copy_set(self, key: str, value: str) -> "QueryParams":
458458
"""
459459
Return a new QueryParams instance, setting the value of a key.
460460
@@ -469,7 +469,7 @@ def set(self, key: str, value: str) -> "QueryParams":
469469
q._dict[key] = [value]
470470
return q
471471

472-
def append(self, key: str, value: str) -> "QueryParams":
472+
def copy_append(self, key: str, value: str) -> "QueryParams":
473473
"""
474474
Return a new QueryParams instance, setting or appending the value of a key.
475475
@@ -484,7 +484,7 @@ def append(self, key: str, value: str) -> "QueryParams":
484484
q._dict[key] = q.get_list(key) + [value]
485485
return q
486486

487-
def remove(self, key: str) -> QueryParams:
487+
def copy_remove(self, key: str) -> QueryParams:
488488
"""
489489
Return a new QueryParams instance, removing the value of a key.
490490
@@ -499,7 +499,7 @@ def remove(self, key: str) -> QueryParams:
499499
q._dict.pop(str(key), None)
500500
return q
501501

502-
def copy_with(
502+
def copy_update(
503503
self,
504504
params: (
505505
"QueryParams" | dict[str, str | list[str]] | list[tuple[str, str]] | None
@@ -511,11 +511,11 @@ def copy_with(
511511
Usage:
512512
513513
q = httpx.QueryParams("a=123")
514-
q = q.copy_with({"b": "456"})
514+
q = q.copy_update({"b": "456"})
515515
assert q == httpx.QueryParams("a=123&b=456")
516516
517517
q = httpx.QueryParams("a=123")
518-
q = q.copy_with({"a": "456", "b": "789"})
518+
q = q.copy_update({"a": "456", "b": "789"})
519519
assert q == httpx.QueryParams("a=456&b=789")
520520
"""
521521
q = QueryParams(params)
@@ -546,7 +546,7 @@ def __eq__(self, other: typing.Any) -> bool:
546546
return sorted(self.multi_items()) == sorted(other.multi_items())
547547

548548
def __str__(self) -> str:
549-
return urlencode(self.multi_items())
549+
return urlencode(self.multi_dict())
550550

551551
def __repr__(self) -> str:
552552
return f"<QueryParams {str(self)!r}>"

src/httpx/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ._request import * # Request
88
from ._streams import * # ByteStream, IterByteStream, Stream
99
from ._server import * # serve_http, serve_tcp
10+
from ._urlencode import * # quote, unquote, urldecode, urlencode
1011
from ._urls import * # QueryParams, URL
1112

1213

@@ -38,5 +39,9 @@
3839
"timeout",
3940
"Transport",
4041
"QueryParams",
42+
"quote",
43+
"unquote",
4144
"URL",
45+
"urldecode",
46+
"urlencode",
4247
]

src/httpx/_content.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import json
12
import os
23
import typing
3-
import urllib.parse
44

55
from ._streams import Stream, ByteStream, IterByteStream
6+
from ._urlencode import urldecode, urlencode
67

78
__all__ = [
89
"Content",
@@ -87,7 +88,7 @@ def __init__(
8788
if form is None:
8889
d = {}
8990
elif isinstance(form, str):
90-
d = urllib.parse.parse_qs(form, keep_blank_values=True)
91+
d = urldecode(form)
9192
elif isinstance(form, Form):
9293
d = form.multi_dict()
9394
elif isinstance(form, typing.Mapping):
@@ -187,7 +188,7 @@ def __eq__(self, other: typing.Any) -> bool:
187188
)
188189

189190
def __str__(self) -> str:
190-
return urllib.parse.urlencode(self.multi_items())
191+
return urlencode(self.multi_dict())
191192

192193
def __repr__(self) -> str:
193194
return f"<Form {self.multi_dict()!r}>"
@@ -317,8 +318,6 @@ def __init__(self, data: typing.Any) -> None:
317318
self._data = data
318319

319320
def encode(self) -> tuple[Stream, str]:
320-
import json
321-
322321
content = json.dumps(
323322
self._data,
324323
ensure_ascii=False,

0 commit comments

Comments
 (0)