diff --git a/tests/test_uritemplate.py b/tests/test_uritemplate.py index dfe19f5..90a401a 100644 --- a/tests/test_uritemplate.py +++ b/tests/test_uritemplate.py @@ -589,6 +589,14 @@ def test_no_mutate(self) -> None: t.expand(args, key=1) self.assertEqual(args, {}) + def test_reserved_expansion_with_percent_escapes(self) -> None: + self.assertEqual(expand("{+var}", {"var": "a b%41"}), "a%20b%41") + self.assertEqual(expand("{#var}", {"var": "a b%41"}), "#a%20b%41") + self.assertEqual( + expand("{+path}", {"path": "/foo bar/baz%20qux"}), + "/foo%20bar/baz%20qux", + ) + class TestVariableModule(unittest.TestCase): def test_is_list_of_tuples(self) -> None: @@ -663,6 +671,11 @@ def test_expand(self) -> None: # explicit testing for issue #53 self.assertEqual(expand("{/zero}", self.context), "/0") self.assertEqual(expand("{/zero,a_float}", self.context), "/0/3.1415") + # explicit testing for issue #163 + self.assertEqual(expand("{a_float:3}", self.context), "3.1") + self.assertEqual(expand("{?a_float:3}", self.context), "?a_float=3.1") + self.assertEqual(expand("{/a_float:3}", self.context), "/3.1") + self.assertEqual(expand("{;a_float:3}", self.context), ";a_float=3.1") if __name__ == "__main__": diff --git a/uritemplate/variable.py b/uritemplate/variable.py index 1f7993c..d7aa00e 100644 --- a/uritemplate/variable.py +++ b/uritemplate/variable.py @@ -17,6 +17,7 @@ import collections.abc import enum +import re import string import typing as t import urllib.parse @@ -38,6 +39,7 @@ _GEN_DELIMS: t.Final[str] = ":/?#[]@" _SUB_DELIMS: t.Final[str] = "!$&'()*+,;=" _RESERVED_CHARACTERS: t.Final[str] = f"{_GEN_DELIMS}{_SUB_DELIMS}" +_PCT_ENCODED_RE: t.Final[t.Pattern[str]] = re.compile(r"(%[0-9a-fA-F]{2})") class Operator(enum.Enum): @@ -150,9 +152,10 @@ def _always_quote(self, value: str) -> str: return quote(value, "") def _only_quote_unquoted_characters(self, value: str) -> str: - if urllib.parse.unquote(value) == value: - return quote(value, _RESERVED_CHARACTERS) - return value + parts = _PCT_ENCODED_RE.split(value) + for i in range(0, len(parts), 2): + parts[i] = quote(parts[i], _RESERVED_CHARACTERS) + return "".join(parts) def quote(self, value: t.Any) -> str: if not isinstance(value, (str, bytes)): @@ -311,8 +314,7 @@ def _query_expansion( return f"{name}={value}" if value: - value = t.cast(t.Text, value) - value = value[:prefix] if prefix else value + value = str(value)[:prefix] if prefix else value return f"{name}={_quote(value)}" return name + "=" @@ -364,8 +366,7 @@ def _label_path_expansion( ) return expanded if expanded else None - value = t.cast(t.Text, value) - value = value[:prefix] if prefix else value + value = str(value)[:prefix] if prefix else value return self.operator.quote(value) def _semi_path_expansion( @@ -413,8 +414,7 @@ def _semi_path_expansion( ) return f"{name}={expanded}" - value = t.cast(t.Text, value) - value = value[:prefix] if prefix else value + value = str(value)[:prefix] if prefix else value if value: return f"{name}={self.operator.quote(value)}" @@ -446,8 +446,7 @@ def _string_expansion( for k, v in items ) - value = t.cast(t.Text, value) - value = value[:prefix] if prefix else value + value = str(value)[:prefix] if prefix else value return self.operator.quote(value) def expand(