Skip to content

Commit c37149c

Browse files
committed
Encode disallowed characters around already percent-encoded triples
Reserved ("+") and fragment ("#") expansion must preserve already valid percent-encoded triples (RFC 6570 Section 3.2.3). The current implementation does this by returning the value untouched as soon as `urllib.parse.unquote` detects any triple, which also skips encoding every other disallowed character in the value. As a result `URITemplate("{+v}").expand(v="a b%20c")` produced "a b%20c" (space left raw) instead of "a%20b%20c". This is a regression from the fix for issue #99, which addressed the opposite problem of double-encoding. Quote the value segment by segment instead: pass valid percent-encoded triples through verbatim and percent-encode everything between them. Output is unchanged for values with no triples or with only triples, so existing behavior (and all current tests) is preserved.
1 parent 0ce2974 commit c37149c

3 files changed

Lines changed: 40 additions & 3 deletions

File tree

HISTORY.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
Changelog - uritemplate
22
=======================
33

4+
Unreleased
5+
----------
6+
7+
- Fix bug where reserved (``+``) and fragment (``#``) expansion left
8+
disallowed characters (such as spaces) unencoded whenever the value also
9+
contained an already percent-encoded triple. This was a regression from the
10+
fix for
11+
`issue #99 <https://github.com/python-hyper/uritemplate/issues/99>`_.
12+
413
4.2.0 - 2025-06-01
514
------------------
615

tests/test_uritemplate.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,20 @@ def test_no_mutate(self) -> None:
589589
t.expand(args, key=1)
590590
self.assertEqual(args, {})
591591

592+
def test_reserved_expansion_encodes_around_pct_triples(self) -> None:
593+
# A value mixing disallowed characters with an already valid
594+
# percent-encoded triple must still encode the disallowed characters
595+
# (the triple is preserved, the rest is quoted) -- RFC 6570 3.2.3.
596+
self.assertEqual(URITemplate("{+v}").expand(v="a b%20c"), "a%20b%20c")
597+
self.assertEqual(
598+
URITemplate("{#v}").expand(v="x y%20z"), "#x%20y%20z"
599+
)
600+
# Uppercase triple preserved; reserved characters left untouched.
601+
self.assertEqual(URITemplate("{+v}").expand(v="a%2Fb c"), "a%2Fb%20c")
602+
# No triple present -> ordinary quoting; all triples -> left as-is.
603+
self.assertEqual(URITemplate("{+v}").expand(v="a b c"), "a%20b%20c")
604+
self.assertEqual(URITemplate("{+v}").expand(v="%20"), "%20")
605+
592606

593607
class TestVariableModule(unittest.TestCase):
594608
def test_is_list_of_tuples(self) -> None:

uritemplate/variable.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import collections.abc
1919
import enum
20+
import re
2021
import string
2122
import typing as t
2223
import urllib.parse
@@ -38,6 +39,7 @@
3839
_GEN_DELIMS: t.Final[str] = ":/?#[]@"
3940
_SUB_DELIMS: t.Final[str] = "!$&'()*+,;="
4041
_RESERVED_CHARACTERS: t.Final[str] = f"{_GEN_DELIMS}{_SUB_DELIMS}"
42+
_PERCENT_ENCODED: t.Final["re.Pattern[str]"] = re.compile("%[0-9A-Fa-f]{2}")
4143

4244

4345
class Operator(enum.Enum):
@@ -150,9 +152,21 @@ def _always_quote(self, value: str) -> str:
150152
return quote(value, "")
151153

152154
def _only_quote_unquoted_characters(self, value: str) -> str:
153-
if urllib.parse.unquote(value) == value:
154-
return quote(value, _RESERVED_CHARACTERS)
155-
return value
155+
# For reserved ("+") and fragment ("#") expansion, already
156+
# percent-encoded triples must be preserved (RFC 6570 Section 3.2.3).
157+
# Quote every other disallowed character while leaving valid triples
158+
# untouched, rather than passing the whole value through unquoted as
159+
# soon as a single triple is present.
160+
result = []
161+
last = 0
162+
for match in _PERCENT_ENCODED.finditer(value):
163+
result.append(
164+
quote(value[last : match.start()], _RESERVED_CHARACTERS)
165+
)
166+
result.append(match.group())
167+
last = match.end()
168+
result.append(quote(value[last:], _RESERVED_CHARACTERS))
169+
return "".join(result)
156170

157171
def quote(self, value: t.Any) -> str:
158172
if not isinstance(value, (str, bytes)):

0 commit comments

Comments
 (0)