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
7 changes: 7 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Changelog - uritemplate
=======================

Unreleased
----------

- Percent-encode disallowed characters in template literal text as required
by RFC 6570 Section 3.1 (e.g. the literal in ``café/{x}`` now expands to
``caf%C3%A9/...``).

4.2.0 - 2025-06-01
------------------

Expand Down
11 changes: 11 additions & 0 deletions tests/fixtures/extended-tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,16 @@
["{#keys}", "#key1,val1%2F,key2,val2%2F"],
["{keys}", "key1,val1%252F,key2,val2%252F"]
]
},
"Additional Examples 8: Literal Encoding":{
"level":4,
"variables":{
"var" : "value"
},
"testcases":[
[ "café/{var}" , "caf%C3%A9/value" ],
[ "x%20y/{var}" , "x%20y/value" ],
[ "x%20y{var}z%20w" , "x%20yvaluez%20w" ]
]
}
}
4 changes: 4 additions & 0 deletions tests/test_from_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,7 @@ def test_additional_examples_5(self) -> None:
def test_additional_examples_6(self) -> None:
"""Check Additional Examples 6."""
self._test("Additional Examples 6: Reserved Expansion")

def test_additional_examples_8(self) -> None:
"""Check Additional Examples 8."""
self._test("Additional Examples 8: Literal Encoding")
46 changes: 46 additions & 0 deletions tests/test_uritemplate.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,45 @@ def test_expand(self) -> None:
t.expand({"repo": "github3.py"}, user="sigmavirus24"), expanded
)

def test_literal_encoding(self) -> None:
"""Literal text is percent-encoded per RFC 6570 Section 3.1."""
# Official uri-templates/uritemplate-test "Literal Encoding" cases.
self.assertEqual(
URITemplate("café/{var}").expand(var="value"), "caf%C3%A9/value"
)
self.assertEqual(
URITemplate("x%20y/{var}").expand(var="value"), "x%20y/value"
)
self.assertEqual(
URITemplate("x%20y{var}z%20w").expand(var="value"),
"x%20yvaluez%20w",
)
# Multi-octet literal and a literal with no expression at all.
self.assertEqual(URITemplate("€/{v}").expand(v="1"), "%E2%82%AC/1")
self.assertEqual(URITemplate("café").expand(), "caf%C3%A9")
# Space and a bare percent sign are not URI-legal and get encoded.
self.assertEqual(URITemplate("a b/{v}").expand(v="1"), "a%20b/1")
self.assertEqual(URITemplate("100%/{v}").expand(v="1"), "100%25/1")
# Reserved characters and existing triples are kept verbatim.
self.assertEqual(URITemplate("a,b:c/{v}").expand(v="1"), "a,b:c/1")
self.assertEqual(URITemplate("%C3%A9/{v}").expand(v="1"), "%C3%A9/1")

def test_literal_encoding_across_operators(self) -> None:
"""Literal runs are encoded around every expression operator."""
for expr in ("{v}", "{+v}", "{#v}", "{/v}", "{.v}", "{?v}", "{&v}"):
expanded = URITemplate("café" + expr).expand(v="x")
self.assertTrue(expanded.startswith("caf%C3%A9"), expanded)
# The expanded value is not double-encoded by the literal pass.
self.assertEqual(
URITemplate("café{?q}").expand(q="thé"), "caf%C3%A9?q=th%C3%A9"
)

def test_literal_encoding_idempotent_on_partial(self) -> None:
"""Partial expansion keeps literals encoded exactly once."""
template = URITemplate("café{?q}").partial()
self.assertEqual(str(template), "caf%C3%A9{?q}")
self.assertEqual(template.expand(q="thé"), "caf%C3%A9?q=th%C3%A9")

def test_str_repr(self) -> None:
uri = "https://api.github.com{/endpoint}"
t = URITemplate(uri)
Expand Down Expand Up @@ -612,6 +651,13 @@ def test_list_of_tuples_test(self) -> None:
d = dict(a_list)
self.assertEqual(variable.dict_test(d), True)

def test_encode_literal(self) -> None:
self.assertEqual(variable.encode_literal("café"), "caf%C3%A9")
self.assertEqual(variable.encode_literal("x%20y"), "x%20y")
self.assertEqual(variable.encode_literal("a,b:c"), "a,b:c")
self.assertEqual(variable.encode_literal("100%"), "100%25")
self.assertEqual(variable.encode_literal(""), "")


class TestAPI(unittest.TestCase):
uri = "https://api.github.com{/endpoint}"
Expand Down
19 changes: 13 additions & 6 deletions uritemplate/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,9 @@ def __hash__(self) -> int:
def _expand(
self, var_dict: variable.VariableValueMapping, replace: bool
) -> str:
if not self.variables:
return self.uri

expansion = var_dict
expanded: t.Dict[str, str] = {}
for v in self.variables:
expanded.update(v.expand(expansion))
expanded.update(v.expand(var_dict))

def replace_all(match: "re.Match[str]") -> str:
return expanded.get(match.groups()[0], "")
Expand All @@ -117,7 +113,18 @@ def replace_partial(match: "re.Match[str]") -> str:

replace_func = replace_partial if replace else replace_all

return template_re.sub(replace_func, self.uri)
# Percent-encode literal runs (RFC 6570 3.1) around each expanded
# expression; a single-pass re.sub would re-encode the expansion
# output, so encode the literals and expand the expressions separately.
result: t.List[str] = []
index = 0
for match in template_re.finditer(self.uri):
start, end = match.start(), match.end()
result.append(variable.encode_literal(self.uri[index:start]))
result.append(replace_func(match))
index = end
result.append(variable.encode_literal(self.uri[index:]))
return "".join(result)

def expand(
self,
Expand Down
22 changes: 22 additions & 0 deletions uritemplate/variable.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import collections.abc
import enum
import re
import string
import typing as t
import urllib.parse
Expand All @@ -39,6 +40,8 @@
_SUB_DELIMS: t.Final[str] = "!$&'()*+,;="
_RESERVED_CHARACTERS: t.Final[str] = f"{_GEN_DELIMS}{_SUB_DELIMS}"

_pct_encoded = re.compile("%[0-9A-Fa-f]{2}")


class Operator(enum.Enum):
# Section 2.2. Expressions
Expand Down Expand Up @@ -562,3 +565,22 @@ def quote(value: t.Any, safe: str) -> str:
if not isinstance(value, (str, bytes)):
value = str(value)
return urllib.parse.quote(_encode(value), safe)


def encode_literal(literal: str) -> str:
"""Percent-encode template literal text per RFC 6570 Section 3.1.

Characters allowed in a URI (unreserved, reserved, or already
percent-encoded) are copied verbatim; any other character is encoded as
its UTF-8 percent-encoded octets. Existing ``%XX`` triplets are preserved
rather than being re-encoded.
"""
encoded = []
index = 0
for match in _pct_encoded.finditer(literal):
start, end = match.start(), match.end()
encoded.append(quote(literal[index:start], _RESERVED_CHARACTERS))
encoded.append(match.group())
index = end
encoded.append(quote(literal[index:], _RESERVED_CHARACTERS))
return "".join(encoded)