Skip to content

Commit a4603e7

Browse files
committed
Preserve literal percent escapes in decoded URL fragments
1 parent 978f2e6 commit a4603e7

3 files changed

Lines changed: 36 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Next
44

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

78
## 21.0.0
89

‎src/hyperlink/_url.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2266,6 +2266,8 @@ def replace(
22662266
"""
22672267
if path is not _UNSET:
22682268
path = tuple(_encode_reserved(p) for p in path)
2269+
if fragment is not _UNSET:
2270+
fragment = _encode_reserved(fragment)
22692271
if query is not _UNSET:
22702272
query = cast(
22712273
QueryPairs,

‎src/hyperlink/test/test_decoded_url.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,39 @@ def test_replace_roundtrip(self):
190190

191191
assert durl == durl2
192192

193+
def test_replace_fragment_percent_encoding(self):
194+
# type: () -> None
195+
"""Fragment replacement treats percent escapes as literal text."""
196+
durl = DecodedURL.from_text("https://example.com/path?q=1#original")
197+
for fragment in ("%41", "%25", "%20", "%C3%A9", "100%", ""):
198+
replaced = durl.replace(fragment=fragment)
199+
assert replaced.fragment == fragment
200+
assert DecodedURL.from_text(replaced.to_text()).fragment == fragment
201+
assert replaced.replace(fragment=replaced.fragment) == replaced
202+
assert replaced.path == durl.path
203+
assert replaced.query == durl.query
204+
assert durl.fragment == "original"
205+
206+
def test_replace_fragment_percent_bytes(self):
207+
# type: () -> None
208+
"""Literal percent bytes need not represent valid UTF-8."""
209+
for lazy in (False, True):
210+
durl = DecodedURL.from_text("https://example.com/", lazy=lazy)
211+
replaced = durl.replace(fragment="%FF")
212+
assert replaced.fragment == "%FF"
213+
assert replaced.to_text() == "https://example.com/#%25FF"
214+
215+
def test_replace_fragment_preservation(self):
216+
# type: () -> None
217+
"""Reserved characters round-trip and omitted fragments stay encoded."""
218+
durl = DecodedURL.from_text("https://example.com/#%2541")
219+
assert durl.replace().to_text() == durl.to_text()
220+
assert durl.replace(query=(("q", "1"),)).fragment == "%41"
221+
fragment = "résumé #/?+"
222+
replaced = durl.replace(fragment=fragment)
223+
assert replaced.fragment == fragment
224+
assert DecodedURL.from_text(replaced.to_text()).fragment == fragment
225+
193226
def test_replace_userinfo(self):
194227
# type: () -> None
195228
durl = DecodedURL.from_text(TOTAL_URL)

0 commit comments

Comments
 (0)