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
4 changes: 4 additions & 0 deletions src/httpx2/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
* Add the public `Origin` value object and `URL.origin` property for normalized,
hashable origin comparisons. ([#1134](https://github.com/pydantic/httpx2/pull/1134))

### Changed

* Improve URL parsing performance by approximately 2x. ([#1139](https://github.com/pydantic/httpx2/pull/1139))

## 2.10.0 (August 9th, 2026)

### Added
Expand Down
22 changes: 15 additions & 7 deletions src/httpx2/httpx2/_urlparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@

PERCENT_ENCODED_REGEX = re.compile("%[A-Fa-f0-9]{2}")

# These are the ASCII characters that are not printable: the C0 control
# characters and DEL. One regex search is faster than a test of each character.
NON_PRINTABLE_ASCII_REGEX = re.compile("[\x00-\x1f\x7f]")

# https://url.spec.whatwg.org/#percent-encoded-bytes

# The fragment percent-encode set is the C0 control percent-encode set
Expand Down Expand Up @@ -208,9 +212,9 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:

# If a URL includes any ASCII control characters including \t, \r, \n,
# then treat it as invalid.
if any(char.isascii() and not char.isprintable() for char in url):
char = next(char for char in url if char.isascii() and not char.isprintable())
idx = url.find(char)
if (match := NON_PRINTABLE_ASCII_REGEX.search(url)) is not None:
char = match.group()
idx = match.start()
error = f"Invalid non-printable ASCII character in URL, {char!r} at position {idx}."
raise InvalidURL(error)

Expand Down Expand Up @@ -256,9 +260,9 @@ def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:

# If a component includes any ASCII control characters including \t, \r, \n,
# then treat it as invalid.
if any(char.isascii() and not char.isprintable() for char in value):
char = next(char for char in value if char.isascii() and not char.isprintable())
idx = value.find(char)
if (match := NON_PRINTABLE_ASCII_REGEX.search(value)) is not None:
char = match.group()
idx = match.start()
error = f"Invalid non-printable ASCII character in URL {key} component, {char!r} at position {idx}."
raise InvalidURL(error)

Expand Down Expand Up @@ -480,9 +484,13 @@ def quote(string: str, safe: str) -> str:
need to be escaped. Unreserved characters are always treated as safe.
See: https://www.rfc-editor.org/rfc/rfc3986#section-2.3
"""
# Fast path for strings that contain no '%xx' escape sequence.
if "%" not in string:
return percent_encoded(string, safe=safe)

parts: list[str] = []
current_position = 0
for match in re.finditer(PERCENT_ENCODED_REGEX, string):
for match in PERCENT_ENCODED_REGEX.finditer(string):
start_position, end_position = match.start(), match.end()
matched_text = match.group(0)
# Add any text up to the '%xx' escape sequence.
Expand Down
46 changes: 46 additions & 0 deletions tests/httpx2/models/test_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,52 @@ def test_url_non_printing_character_in_component() -> None:
assert str(exc.value) == ("Invalid non-printable ASCII character in URL path component, '\\n' at position 1.")


# The limits of the control character ranges (0x00, 0x1f, 0x7f), and CR, which
# a URL must not contain.
CONTROL_CHARACTERS = [0x00, 0x0D, 0x1F, 0x7F]


@pytest.mark.parametrize("code", CONTROL_CHARACTERS)
def test_url_ascii_control_character_in_url(code: int) -> None:
char = chr(code)
with pytest.raises(httpx2.InvalidURL) as exc:
httpx2.URL("https://www.example.com/" + char)
assert str(exc.value) == (f"Invalid non-printable ASCII character in URL, {char!r} at position 24.")


@pytest.mark.parametrize("code", CONTROL_CHARACTERS)
def test_url_ascii_control_character_in_component(code: int) -> None:
char = chr(code)
with pytest.raises(httpx2.InvalidURL) as exc:
httpx2.URL("https://www.example.com", path="/" + char)
assert str(exc.value) == (f"Invalid non-printable ASCII character in URL path component, {char!r} at position 1.")


@pytest.mark.parametrize(("code", "expected"), [(0x20, "%20"), (0x7E, "~")])
def test_url_printable_ascii_next_to_control_range_is_allowed(code: int, expected: str) -> None:
# Space (0x20) and '~' (0x7e) are adjacent to the control character ranges.
# The parser must not reject them.
char = chr(code)
assert char.isprintable()
assert str(httpx2.URL("https://www.example.com/" + char)) == "https://www.example.com/" + expected


@pytest.mark.parametrize("code", [0x85, 0xA0, 0x200B])
def test_url_non_printing_character_outside_ascii_is_allowed(code: int) -> None:
# These characters are not printable, but they are also not ASCII.
# The parser applies percent-encoding to them and does not reject them.
char = chr(code)
assert not char.isprintable() and not char.isascii()
percent_encoded = "".join(f"%{byte:02X}" for byte in char.encode("utf-8"))
assert str(httpx2.URL("https://www.example.com/" + char)) == "https://www.example.com/" + percent_encoded


def test_url_reports_first_control_character_position() -> None:
with pytest.raises(httpx2.InvalidURL) as exc:
httpx2.URL("https://www.example.com/a\tb\nc")
assert str(exc.value) == ("Invalid non-printable ASCII character in URL, '\\t' at position 25.")


# Test for url components


Expand Down
13 changes: 13 additions & 0 deletions tests/test_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@

TYPICAL_URL = "https://www.example.org:8443/path/to/resource?key=value&other=1#frag"

# The cost to parse a URL increases with its length. These constants give a long
# URL and a URL that contains '%xx' escape sequences.
LONG_QUERY_URL = "https://www.example.org/search?" + "&".join(f"field{i}=value{i}" for i in range(60))
PERCENT_ENCODED_URL = "https://www.example.org/path%2Fto%2Fresource?key=a%20value&other=%C3%A9"

HEADERS: list[tuple[str, str]] = [
("host", "example.org"),
("user-agent", "httpx2-bench/1.0"),
Expand Down Expand Up @@ -53,6 +58,14 @@ def test_bench_url_parse(benchmark: BenchmarkFixture) -> None:
benchmark(httpx2.URL, TYPICAL_URL)


def test_bench_url_parse_long_query(benchmark: BenchmarkFixture) -> None:
benchmark(httpx2.URL, LONG_QUERY_URL)


def test_bench_url_parse_percent_encoded(benchmark: BenchmarkFixture) -> None:
benchmark(httpx2.URL, PERCENT_ENCODED_URL)


def test_bench_url_join(benchmark: BenchmarkFixture) -> None:
base = httpx2.URL(TYPICAL_URL)
benchmark(base.join, "/path/to/resource?key=value")
Expand Down
Loading