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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Next

* CPython 3.9 added to test matrix
* Preserve literal percent escapes when replacing a `DecodedURL` fragment.

## 21.0.0

Expand Down
2 changes: 2 additions & 0 deletions src/hyperlink/_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -2266,6 +2266,8 @@ def replace(
"""
if path is not _UNSET:
path = tuple(_encode_reserved(p) for p in path)
if fragment is not _UNSET:
fragment = _encode_reserved(fragment)
if query is not _UNSET:
query = cast(
QueryPairs,
Expand Down
33 changes: 33 additions & 0 deletions src/hyperlink/test/test_decoded_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,39 @@ def test_replace_roundtrip(self):

assert durl == durl2

def test_replace_fragment_percent_encoding(self):
# type: () -> None
"""Fragment replacement treats percent escapes as literal text."""
durl = DecodedURL.from_text("https://example.com/path?q=1#original")
for fragment in ("%41", "%25", "%20", "%C3%A9", "100%", ""):
replaced = durl.replace(fragment=fragment)
assert replaced.fragment == fragment
assert DecodedURL.from_text(replaced.to_text()).fragment == fragment
assert replaced.replace(fragment=replaced.fragment) == replaced
assert replaced.path == durl.path
assert replaced.query == durl.query
assert durl.fragment == "original"

def test_replace_fragment_percent_bytes(self):
# type: () -> None
"""Literal percent bytes need not represent valid UTF-8."""
for lazy in (False, True):
durl = DecodedURL.from_text("https://example.com/", lazy=lazy)
replaced = durl.replace(fragment="%FF")
assert replaced.fragment == "%FF"
assert replaced.to_text() == "https://example.com/#%25FF"

def test_replace_fragment_preservation(self):
# type: () -> None
"""Reserved characters round-trip and omitted fragments stay encoded."""
durl = DecodedURL.from_text("https://example.com/#%2541")
assert durl.replace().to_text() == durl.to_text()
assert durl.replace(query=(("q", "1"),)).fragment == "%41"
fragment = "résumé #/?+"
replaced = durl.replace(fragment=fragment)
assert replaced.fragment == fragment
assert DecodedURL.from_text(replaced.to_text()).fragment == fragment

def test_replace_userinfo(self):
# type: () -> None
durl = DecodedURL.from_text(TOTAL_URL)
Expand Down