From 82cc82b2ffcda629342269cecee9d442b5f6e190 Mon Sep 17 00:00:00 2001 From: Anish Pentyala Date: Thu, 12 Nov 2026 18:03:20 -0800 Subject: [PATCH 1/2] Recognize read-only Mapping values --- uritemplate/variable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uritemplate/variable.py b/uritemplate/variable.py index 1f7993c..6065774 100644 --- a/uritemplate/variable.py +++ b/uritemplate/variable.py @@ -549,7 +549,7 @@ def list_test(value: t.Any) -> bool: def dict_test(value: t.Any) -> bool: - return isinstance(value, (dict, collections.abc.MutableMapping)) + return isinstance(value, collections.abc.Mapping) def _encode(value: t.AnyStr, encoding: str = "utf-8") -> bytes: From ee27880e2a0d974f254b6d8c67d24e67e6f70d1e Mon Sep 17 00:00:00 2001 From: Anish Pentyala Date: Thu, 12 Nov 2026 18:03:44 -0800 Subject: [PATCH 2/2] Test read-only associative mappings across operators --- tests/test_readonly_mappings.py | 83 +++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/test_readonly_mappings.py diff --git a/tests/test_readonly_mappings.py b/tests/test_readonly_mappings.py new file mode 100644 index 0000000..6d4cba7 --- /dev/null +++ b/tests/test_readonly_mappings.py @@ -0,0 +1,83 @@ +"""Associative values may implement Mapping without supporting mutation.""" + +import collections.abc +import types +import typing as t + +import pytest + +from uritemplate import expand +from uritemplate import variable + + +class ReadOnlyMapping(t.Mapping[str, variable.ScalarVariableValue]): + def __init__(self, values: t.Mapping[str, variable.ScalarVariableValue]): + self._values = dict(values) + + def __getitem__(self, key: str) -> variable.ScalarVariableValue: + return self._values[key] + + def __iter__(self) -> t.Iterator[str]: + return iter(self._values) + + def __len__(self) -> int: + return len(self._values) + + +@pytest.mark.parametrize("factory", [types.MappingProxyType, ReadOnlyMapping]) +@pytest.mark.parametrize( + "operator,plain,exploded", + [ + ("", "a,one,b,two", "a=one,b=two"), + ("+", "a,one,b,two", "a=one,b=two"), + ("#", "#a,one,b,two", "#a=one,b=two"), + (".", ".a,one,b,two", ".a=one.b=two"), + ("/", "/a,one,b,two", "/a=one/b=two"), + (";", ";v=a,one,b,two", ";a=one;b=two"), + ("?", "?v=a,one,b,two", "?a=one&b=two"), + ("&", "&v=a,one,b,two", "&a=one&b=two"), + ], +) +@pytest.mark.parametrize("explode", [False, True]) +def test_readonly_mapping_expands_as_associative_value( + factory: t.Callable[ + [t.Dict[str, variable.ScalarVariableValue]], + t.Mapping[str, variable.ScalarVariableValue], + ], + operator: str, + plain: str, + exploded: str, + explode: bool, +) -> None: + # Reverse insertion order also pins existing sorted-mapping behavior. + values: t.Dict[str, variable.ScalarVariableValue] = { + "b": "two", + "a": "one", + } + mapping = factory(values) + template = "{" + operator + "v" + ("*" if explode else "") + "}" + assert expand(template, v=mapping) == (exploded if explode else plain) + assert dict(mapping) == values + assert not isinstance(mapping, collections.abc.MutableMapping) + + +@pytest.mark.parametrize("operator", ["", "+", "#", ".", "/", ";", "?", "&"]) +@pytest.mark.parametrize("explode", [False, True]) +def test_mapping_input_types_keep_identical_quoting( + operator: str, explode: bool +) -> None: + values: t.Dict[str, variable.ScalarVariableValue] = { + "a b": "x/y", + "z": "0", + } + template = "{" + operator + "v" + ("*" if explode else "") + "}" + expected = expand(template, v=values) + assert expand(template, v=types.MappingProxyType(values)) == expected + assert expand(template, v=ReadOnlyMapping(values)) == expected + + +@pytest.mark.parametrize("value", ["text", ["a", "b"], [("a", "b")], 0, None]) +def test_mapping_detection_does_not_reclassify_other_values( + value: t.Any, +) -> None: + assert not variable.dict_test(value)