Repository: python-hyper/uritemplate

Issue:
Incorrect Reserved Expansion
Valid UTF-8-percent-encoded code points must be preserved in reserved expansion. This implementation (Version: 4.1.1) fails the following test from https://github.com/uri-templates/uritemplate-test/blob/master/extended-tests.json:

```
{
  ...
  "Additional Examples 6: Reserved Expansion": {
    "variables" : {
      "id" : "admin%2F",
      ...
  },
  "testcases": [
    ["{+id}", "admin%2F"],
    ...
  ]
}
```

The correct/expected expansion is `'admin%2F'`, the actual expansion is `'admin%252F'`:

```
>>> from uritemplate import URITemplate, expand
>>> URITemplate('{+id}').expand(id='admin%2F')
'admin%252F'
>>> expand('{+id}', id='admin%2F')
'admin%252F'
```

Discussion:

Solution patch:
diff --git a/uritemplate/variable.py b/uritemplate/variable.py
index 41134a7..02e33e0 100644
--- a/uritemplate/variable.py
+++ b/uritemplate/variable.py
@@ -16,6 +16,8 @@ What do you do?
 """
 
 import collections.abc
+import enum
+import string
 import typing as t
 import urllib.parse
 
@@ -30,6 +32,159 @@ VariableValue = t.Union[
 VariableValueDict = t.Dict[str, VariableValue]
 
 
+_UNRESERVED_CHARACTERS: t.Final[str] = (
+    f"{string.ascii_letters}{string.digits}~-_."
+)
+_GEN_DELIMS: t.Final[str] = ":/?#[]@"
+_SUB_DELIMS: t.Final[str] = "!$&'()*+,;="
+_RESERVED_CHARACTERS: t.Final[str] = f"{_GEN_DELIMS}{_SUB_DELIMS}"
+
+
+class Operator(enum.Enum):
+    # Section 2.2. Expressions
+    #      expression    =  "{" [ operator ] variable-list "}"
+    #      operator      =  op-level2 / op-level3 / op-reserve
+    #      op-level2     =  "+" / "#"
+    #      op-level3     =  "." / "/" / ";" / "?" / "&"
+    #      op-reserve    =  "=" / "," / "!" / "@" / "|"
+    default = ""  # 3.2.2. Simple String Expansiona: {var}
+    # Operator Level 2 (op-level2)
+    reserved = "+"  # 3.2.3. Reserved Expansion: {+var}
+    fragment = "#"  # 3.2.4. Fragment Expansion: {#var}
+    # Operator Level 3 (op-level3)
+    # 3.2.5. Label Expansion with Dot-Prefix: {.var}
+    label_with_dot_prefix = "."
+    path_segment = "/"  # 3.2.6. Path Segment Expansion: {/var}
+    path_style_parameter = (
+        ";"  # 3.2.7. Path-Style Parameter Expansion: {;var}
+    )
+    form_style_query = "?"  # 3.2.8. Form-Style Query Expansion: {?var}
+    # 3.2.9. Form-Style Query Continuation: {&var}
+    form_style_query_continuation = "&"
+    # Reserved Operators (op-reserve)
+    reserved_eq = "="
+    reserved_comma = ","
+    reserved_bang = "!"
+    reserved_at = "@"
+    reserved_pipe = "|"
+
+    def reserved_characters(self) -> str:
+        # TODO: Re-enable after un-commenting 3.9
+        # match self:
+        #     case Operator.reserved:
+        #         return _RESERVED_CHARACTERS + "%"
+        #     # case Operator.default | Operator.reserved | Operator.fragment:
+        #     case Operator.fragment:
+        #         return _RESERVED_CHARACTERS
+        #     case _:
+        #         return ""
+        if self == Operator.reserved:
+            return _RESERVED_CHARACTERS + "%"
+        if self == Operator.fragment:
+            return _RESERVED_CHARACTERS
+        return ""
+
+    def expansion_separator(self) -> str:
+        """Identify the separator used during expansion.
+
+        Per `Section 3.2.1. Variable Expansion`_:
+
+        ======  ===========    =========
+        Type    Separator
+        ======  ===========    =========
+                ``","``        (default)
+        ``+``   ``","``
+        ``#``   ``","``
+        ``.``   ``"."``
+        ``/``   ``"/"``
+        ``;``   ``";"``
+        ``?``   ``"&"``
+        ``&``   ``"&"``
+        ======  ===========    =========
+
+        .. _`Section 3.2.1. Variable Expansion`:
+            https://www.rfc-editor.org/rfc/rfc6570#section-3.2.1
+        """
+        if self == Operator.label_with_dot_prefix:
+            return "."
+        if self == Operator.path_segment:
+            return "/"
+        if self == Operator.path_style_parameter:
+            return ";"
+        if (
+            self == Operator.form_style_query
+            or self == Operator.form_style_query_continuation
+        ):
+            return "&"
+        # if self == Operator.reserved or self == Operator.fragment:
+        #     return ","
+        return ","
+        # match self:
+        #     case Operator.label_with_dot_prefix:
+        #         return "."
+        #     case Operator.path_segment:
+        #         return "/"
+        #     case Operator.path_style_parameter:
+        #         return ";"
+        #     case (
+        #         Operator.form_style_query |
+        #         Operator.form_style_query_continuation
+        #     ):
+        #         return "&"
+        #     case Operator.reserved | Operator.fragment:
+        #         return ","
+        #     case _:
+        #         return ","
+
+    def variable_prefix(self) -> str:
+        if self == Operator.reserved:
+            return ""
+        return t.cast(str, self.value)
+        # match self:
+        #     case Operator.reserved:
+        #         return ""
+        #     case _:
+        #         return t.cast(str, self.value)
+
+    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
+
+    def quote(self, value: t.Any) -> str:
+        if not isinstance(value, (str, bytes)):
+            value = str(value)
+        if isinstance(value, bytes):
+            value = value.decode()
+
+        if self == Operator.reserved or self == Operator.fragment:
+            return self._only_quote_unquoted_characters(value)
+        return self._always_quote(value)
+
+    @staticmethod
+    def from_string(s: str) -> "Operator":
+        return _operators.get(s, Operator.default)
+
+
+_operators: t.Final[t.Dict[str, Operator]] = {
+    "+": Operator.reserved,
+    "#": Operator.fragment,
+    ".": Operator.label_with_dot_prefix,
+    "/": Operator.path_segment,
+    ";": Operator.path_style_parameter,
+    "?": Operator.form_style_query,
+    "&": Operator.form_style_query_continuation,
+    "!": Operator.reserved_bang,
+    "|": Operator.reserved_pipe,
+    "@": Operator.reserved_at,
+    "=": Operator.reserved_eq,
+    ",": Operator.reserved_comma,
+}
+
+
 class URIVariable:
     """This object validates everything inside the URITemplate object.
 
@@ -49,16 +204,11 @@ class URIVariable:
 
     """
 
-    operators = ("+", "#", ".", "/", ";", "?", "&", "|", "!", "@")
-    reserved = ":/?#[]@!$&'()*+,;="
-
     def __init__(self, var: str):
         #: The original string that comes through with the variable
         self.original: str = var
         #: The operator for the variable
-        self.operator: str = ""
-        #: List of safe characters when quoting the string
-        self.safe: str = ""
+        self.operator: Operator = Operator.default
         #: List of variables in this variable
         self.variables: t.List[t.Tuple[str, t.MutableMapping[str, t.Any]]] = (
             []
@@ -69,7 +219,6 @@ class URIVariable:
         self.defaults: t.MutableMapping[str, ScalarVariableValue] = {}
         # Parse the variable itself.
         self.parse()
-        self.post_parse()
 
     def __repr__(self) -> str:
         return "URIVariable(%s)" % self
@@ -88,25 +237,22 @@ class URIVariable:
 
         """
         var_list_str = self.original
-        if self.original[0] in URIVariable.operators:
-            self.operator = self.original[0]
+        if (operator_str := self.original[0]) in _operators:
+            self.operator = Operator.from_string(operator_str)
             var_list_str = self.original[1:]
 
-        if self.operator in URIVariable.operators[:2]:
-            self.safe = URIVariable.reserved
-
         var_list = var_list_str.split(",")
 
         for var in var_list:
             default_val = None
             name = var
+            # NOTE(sigmavirus24): This is from an earlier draft but is not in
+            # the specification
             if "=" in var:
                 name, default_val = tuple(var.split("=", 1))
 
-            explode = False
-            if name.endswith("*"):
-                explode = True
-                name = name[:-1]
+            explode = name.endswith("*")
+            name = name.rstrip("*")
 
             prefix: t.Optional[int] = None
             if ":" in name:
@@ -122,27 +268,6 @@ class URIVariable:
 
         self.variable_names = [varname for (varname, _) in self.variables]
 
-    def post_parse(self) -> None:
-        """Set ``start``, ``join_str`` and ``safe`` attributes.
-
-        After parsing the variable, we need to set up these attributes and it
-        only makes sense to do it in a more easily testable way.
-        """
-        self.safe = ""
-        self.start = self.join_str = self.operator
-        if self.operator == "+":
-            self.start = ""
-        if self.operator in ("+", "#", ""):
-            self.join_str = ","
-        if self.operator == "#":
-            self.start = "#"
-        if self.operator == "?":
-            self.start = "?"
-            self.join_str = "&"
-
-        if self.operator in ("+", "#"):
-            self.safe = URIVariable.reserved
-
     def _query_expansion(
         self,
         name: str,
@@ -156,17 +281,18 @@ class URIVariable:
 
         tuples, items = is_list_of_tuples(value)
 
-        safe = self.safe
+        safe = self.operator.reserved_characters()
+        _quote = self.operator.quote
         if list_test(value) and not tuples:
             if not value:
                 return None
             value = t.cast(t.Sequence[ScalarVariableValue], value)
             if explode:
-                return self.join_str.join(
-                    f"{name}={quote(v, safe)}" for v in value
+                return self.operator.expansion_separator().join(
+                    f"{name}={_quote(v)}" for v in value
                 )
             else:
-                value = ",".join(quote(v, safe) for v in value)
+                value = ",".join(_quote(v) for v in value)
                 return f"{name}={value}"
 
         if dict_test(value) or tuples:
@@ -175,19 +301,19 @@ class URIVariable:
             value = t.cast(t.Mapping[str, ScalarVariableValue], value)
             items = items or sorted(value.items())
             if explode:
-                return self.join_str.join(
-                    f"{quote(k, safe)}={quote(v, safe)}" for k, v in items
+                return self.operator.expansion_separator().join(
+                    f"{quote(k, safe)}={_quote(v)}" for k, v in items
                 )
             else:
                 value = ",".join(
-                    f"{quote(k, safe)},{quote(v, safe)}" for k, v in items
+                    f"{quote(k, safe)},{_quote(v)}" for k, v in items
                 )
                 return f"{name}={value}"
 
         if value:
             value = t.cast(t.Text, value)
             value = value[:prefix] if prefix else value
-            return f"{name}={quote(value, safe)}"
+            return f"{name}={_quote(value)}"
         return name + "="
 
     def _label_path_expansion(
@@ -202,8 +328,8 @@ class URIVariable:
         Expands for operators: '/', '.'
 
         """
-        join_str = self.join_str
-        safe = self.safe
+        join_str = self.operator.expansion_separator()
+        safe = self.operator.reserved_characters()
 
         if value is None or (
             not isinstance(value, (str, int, float, complex))
@@ -218,7 +344,9 @@ class URIVariable:
                 join_str = ","
 
             value = t.cast(t.Sequence[ScalarVariableValue], value)
-            fragments = [quote(v, safe) for v in value if v is not None]
+            fragments = [
+                self.operator.quote(v) for v in value if v is not None
+            ]
             return join_str.join(fragments) if fragments else None
 
         if dict_test(value) or tuples:
@@ -230,7 +358,7 @@ class URIVariable:
                 join_str = ","
 
             expanded = join_str.join(
-                format_str % (quote(k, safe), quote(v, safe))
+                format_str % (quote(k, safe), self.operator.quote(v))
                 for k, v in items
                 if v is not None
             )
@@ -238,7 +366,7 @@ class URIVariable:
 
         value = t.cast(t.Text, value)
         value = value[:prefix] if prefix else value
-        return quote(value, safe)
+        return self.operator.quote(value)
 
     def _semi_path_expansion(
         self,
@@ -248,15 +376,12 @@ class URIVariable:
         prefix: t.Optional[int],
     ) -> t.Optional[str]:
         """Expansion method for ';' operator."""
-        join_str = self.join_str
-        safe = self.safe
+        join_str = self.operator.expansion_separator()
+        safe = self.operator.reserved_characters()
 
         if value is None:
             return None
 
-        if self.operator == "?":
-            join_str = "&"
-
         tuples, items = is_list_of_tuples(value)
 
         if list_test(value) and not tuples:
@@ -276,13 +401,13 @@ class URIVariable:
 
             if explode:
                 return join_str.join(
-                    f"{quote(k, safe)}={quote(v, safe)}"
+                    f"{quote(k, safe)}={self.operator.quote(v)}"
                     for k, v in items
                     if v is not None
                 )
             else:
                 expanded = ",".join(
-                    f"{quote(k, safe)},{quote(v, safe)}"
+                    f"{quote(k, safe)},{self.operator.quote(v)}"
                     for k, v in items
                     if v is not None
                 )
@@ -291,7 +416,7 @@ class URIVariable:
         value = t.cast(t.Text, value)
         value = value[:prefix] if prefix else value
         if value:
-            return f"{name}={quote(value, safe)}"
+            return f"{name}={self.operator.quote(value)}"
 
         return name
 
@@ -309,7 +434,7 @@ class URIVariable:
 
         if list_test(value) and not tuples:
             value = t.cast(t.Sequence[ScalarVariableValue], value)
-            return ",".join(quote(v, self.safe) for v in value)
+            return ",".join(self.operator.quote(v) for v in value)
 
         if dict_test(value) or tuples:
             value = t.cast(t.Mapping[str, ScalarVariableValue], value)
@@ -317,13 +442,13 @@ class URIVariable:
             format_str = "%s=%s" if explode else "%s,%s"
 
             return ",".join(
-                format_str % (quote(k, self.safe), quote(v, self.safe))
+                format_str % (self.operator.quote(k), self.operator.quote(v))
                 for k, v in items
             )
 
         value = t.cast(t.Text, value)
         value = value[:prefix] if prefix else value
-        return quote(value, self.safe)
+        return self.operator.quote(value)
 
     def expand(
         self, var_dict: t.Optional[VariableValueDict] = None
@@ -367,14 +492,30 @@ class URIVariable:
                 continue
 
             expanded = None
-            if self.operator in ("/", "."):
+            if (
+                self.operator == Operator.path_segment
+                or self.operator == Operator.label_with_dot_prefix
+            ):
                 expansion = self._label_path_expansion
-            elif self.operator in ("?", "&"):
+            elif (
+                self.operator == Operator.form_style_query
+                or self.operator == Operator.form_style_query_continuation
+            ):
                 expansion = self._query_expansion
-            elif self.operator == ";":
+            elif self.operator == Operator.path_style_parameter:
                 expansion = self._semi_path_expansion
             else:
                 expansion = self._string_expansion
+            # match self.operator:
+            #     case Operator.path_segment | Operator.label_with_dot_prefix:
+            #         expansion = self._label_path_expansion
+            #     case (Operator.form_style_query |
+            #           Operator.form_style_query_continuation):
+            #         expansion = self._query_expansion
+            #     case Operator.path_style_parameter:
+            #         expansion = self._semi_path_expansion
+            #     case _:
+            #         expansion = self._string_expansion
 
             expanded = expansion(name, value, opts["explode"], opts["prefix"])
 
@@ -383,7 +524,10 @@ class URIVariable:
 
         value = ""
         if return_values:
-            value = self.start + self.join_str.join(return_values)
+            value = (
+                self.operator.variable_prefix()
+                + self.operator.expansion_separator().join(return_values)
+            )
         return {self.original: value}
 
 


Test patch:
diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md
index ffe46cb..1fa3f4b 100644
--- a/tests/fixtures/README.md
+++ b/tests/fixtures/README.md
@@ -1,10 +1,9 @@
-These test are borrowed from https://github.com/uri-templates/uritemplate-test
-at commit: fdd5d611a849b922c2ff40fc3997fd265dd14c02
+
 URI Template Tests
 ==================
 
 This is a set of tests for implementations of
-[RFC6570](http://tools.ietf.org/html/rfc6570) - URI Template. It is designed
+[RFC6570](https://datatracker.ietf.org/doc/html/rfc6570) - URI Template. It is designed
 to be reused by any implementation, to improve interoperability and
 implementation quality.
 
diff --git a/tests/fixtures/extended-tests.json b/tests/fixtures/extended-tests.json
index ad43ad6..df27d95 100644
--- a/tests/fixtures/extended-tests.json
+++ b/tests/fixtures/extended-tests.json
@@ -28,18 +28,8 @@
         "testcases":[
 
             [ "{/id*}" , "/person" ],
-            [ "{/id*}{?fields,first_name,last.name,token}" , [
-            	"/person?fields=id,name,picture&first_name=John&last.name=Doe&token=12345",
-            	"/person?fields=id,picture,name&first_name=John&last.name=Doe&token=12345",
-            	"/person?fields=picture,name,id&first_name=John&last.name=Doe&token=12345",
-            	"/person?fields=picture,id,name&first_name=John&last.name=Doe&token=12345",
-            	"/person?fields=name,picture,id&first_name=John&last.name=Doe&token=12345",
-            	"/person?fields=name,id,picture&first_name=John&last.name=Doe&token=12345"]
-            	],
-            ["/search.{format}{?q,geocode,lang,locale,page,result_type}",
-            	[ "/search.json?q=URI%20Templates&geocode=37.76,-122.427&lang=en&page=5",
-            	  "/search.json?q=URI%20Templates&geocode=-122.427,37.76&lang=en&page=5"]
-                ],
+            [ "{/id*}{?fields,first_name,last.name,token}","/person?fields=id,name,picture&first_name=John&last.name=Doe&token=12345"],
+            ["/search.{format}{?q,geocode,lang,locale,page,result_type}","/search.json?q=URI%20Templates&geocode=37.76,-122.427&lang=en&page=5"],
             ["/test{/Some%20Thing}", "/test/foo" ],
             ["/set{?number}", "/set?number=6"],
             ["/loc{?long,lat}" , "/loc?long=37.76&lat=-122.427"],
@@ -67,21 +57,8 @@
         },
         "testcases":[
 
-            [ "{/id*}" , ["/person/albums","/albums/person"] ],
-            [ "{/id*}{?fields,token}" , [
-            	"/person/albums?fields=id,name,picture&token=12345",
-            	"/person/albums?fields=id,picture,name&token=12345",
-            	"/person/albums?fields=picture,name,id&token=12345",
-            	"/person/albums?fields=picture,id,name&token=12345",
-            	"/person/albums?fields=name,picture,id&token=12345",
-            	"/person/albums?fields=name,id,picture&token=12345",
-            	"/albums/person?fields=id,name,picture&token=12345",
-            	"/albums/person?fields=id,picture,name&token=12345",
-            	"/albums/person?fields=picture,name,id&token=12345",
-            	"/albums/person?fields=picture,id,name&token=12345",
-            	"/albums/person?fields=name,picture,id&token=12345",
-            	"/albums/person?fields=name,id,picture&token=12345"]
-            	]
+            [ "{/id*}" , "/person/albums" ],
+            [ "{/id*}{?fields,token}" , "/person/albums?fields=id,name,picture&token=12345" ]
         ]
     },
     "Additional Examples 3: Empty Variables":{
@@ -114,5 +91,59 @@
             [ "{?1337*}", "?1337=leet&1337=as&1337=it&1337=can&1337=be"],
             [ "{?german*}", [ "?11=elf&12=zw%C3%B6lf", "?12=zw%C3%B6lf&11=elf"] ]
         ]
+    },
+    "Additional Examples 5: Explode Combinations":{
+        "variables" : {
+            "id" : "admin",
+            "token" : "12345",
+            "tab" : "overview",
+            "keys" : {
+                "key1": "val1",
+                "key2": "val2"
+            }
+        },
+        "testcases":[
+            [ "{?id,token,keys*}", [
+                "?id=admin&token=12345&key1=val1&key2=val2",
+                "?id=admin&token=12345&key2=val2&key1=val1"]
+            ],
+            [ "{/id}{?token,keys*}", [
+                "/admin?token=12345&key1=val1&key2=val2",
+                "/admin?token=12345&key2=val2&key1=val1"]
+            ],
+            [ "{?id,token}{&keys*}", [
+                "?id=admin&token=12345&key1=val1&key2=val2",
+                "?id=admin&token=12345&key2=val2&key1=val1"]
+            ],
+            [ "/user{/id}{?token,tab}{&keys*}", [
+                "/user/admin?token=12345&tab=overview&key1=val1&key2=val2",
+                "/user/admin?token=12345&tab=overview&key2=val2&key1=val1"]
+            ]
+        ]
+    },
+    "Additional Examples 6: Reserved Expansion":{
+        "variables" : {
+            "id" : "admin%2F",
+            "not_pct" : "%foo",
+            "list" : ["red%25", "%2Fgreen", "blue "],
+            "keys" : {
+                "key1": "val1%2F",
+                "key2": "val2%2F"
+            }
+        },
+        "testcases": [
+			["{+id}", "admin%2F"],
+			["{#id}", "#admin%2F"],
+			["{id}", "admin%252F"],
+			["{+not_pct}", "%25foo"],
+			["{#not_pct}", "#%25foo"],
+			["{not_pct}", "%25foo"],
+			["{+list}", "red%25,%2Fgreen,blue%20"],
+			["{#list}", "#red%25,%2Fgreen,blue%20"],
+			["{list}", "red%2525,%252Fgreen,blue%20"],
+			["{+keys}", "key1,val1%2F,key2,val2%2F"],
+			["{#keys}", "#key1,val1%2F,key2,val2%2F"],
+			["{keys}", "key1,val1%252F,key2,val2%252F"]
+        ]
     }
 }
diff --git a/tests/fixtures/spec-examples-by-section.json b/tests/fixtures/spec-examples-by-section.json
index 5aef182..01c8eca 100644
--- a/tests/fixtures/spec-examples-by-section.json
+++ b/tests/fixtures/spec-examples-by-section.json
@@ -1,4 +1,13 @@
 {
+  "2.1 Literals" :
+  {
+    "variables": {
+       "count"      : ["one", "two", "three"]
+     },
+     "testcases" : [
+        ["'{count}'", "'one,two,three'"]
+      ]
+  },
   "3.2.1 Variable Expansion" :
   {
     "variables": {
@@ -17,7 +26,7 @@
        "x"          : "1024",
        "y"          : "768",
        "empty"      : "",
-       "empty_keys" : [],
+       "empty_keys" : {},
        "undef"      : null
      },
      "testcases" : [
@@ -50,7 +59,7 @@
        "x"          : "1024",
        "y"          : "768",
        "empty"      : "",
-       "empty_keys" : [],
+       "empty_keys" : {},
        "undef"      : null
      },
      "testcases" : [
@@ -104,7 +113,7 @@
        "x"          : "1024",
        "y"          : "768",
        "empty"      : "",
-       "empty_keys" : [],
+       "empty_keys" : {},
        "undef"      : null
      },
      "testcases" : [
@@ -161,7 +170,7 @@
        "x"          : "1024",
        "y"          : "768",
        "empty"      : "",
-       "empty_keys" : [],
+       "empty_keys" : {},
        "undef"      : null
      },
      "testcases" : [
@@ -203,7 +212,7 @@
        "x"          : "1024",
        "y"          : "768",
        "empty"      : "",
-       "empty_keys" : [],
+       "empty_keys" : {},
        "undef"      : null
     },
     "testcases" : [
@@ -255,7 +264,7 @@
        "x"          : "1024",
        "y"          : "768",
        "empty"      : "",
-       "empty_keys" : [],
+       "empty_keys" : {},
        "undef"      : null
      },
      "testcases" : [
@@ -307,7 +316,7 @@
        "x"          : "1024",
        "y"          : "768",
        "empty"      : "",
-       "empty_keys" : [],
+       "empty_keys" : {},
        "undef"      : null
      },
      "testcases" : [
@@ -358,7 +367,7 @@
        "x"          : "1024",
        "y"          : "768",
        "empty"      : "",
-       "empty_keys" : [],
+       "empty_keys" : {},
        "undef"      : null
      },
      "testcases" : [
@@ -406,7 +415,7 @@
        "x"          : "1024",
        "y"          : "768",
        "empty"      : "",
-       "empty_keys" : [],
+       "empty_keys" : {},
        "undef"      : null
      },
      "testcases" : [
diff --git a/tests/fixtures/spec-examples.json b/tests/fixtures/spec-examples.json
index 2e8e942..652acfa 100644
--- a/tests/fixtures/spec-examples.json
+++ b/tests/fixtures/spec-examples.json
@@ -8,6 +8,7 @@
      },
      "testcases" : [
         ["{var}", "value"],
+        ["'{var}'", "'value'"],
         ["{hello}", "Hello%20World%21"]
      ]
   },
diff --git a/tests/test_from_fixtures.py b/tests/test_from_fixtures.py
index cad9ce5..bbb4645 100644
--- a/tests/test_from_fixtures.py
+++ b/tests/test_from_fixtures.py
@@ -44,8 +44,8 @@ class FixtureMixin:
     ) -> t.Tuple[ExampleVariables, ExampleTemplatesAndResults]:
         test = t.cast(ExampleWithVariables, self.examples.get(section, {}))
         return (
-            t.cast(ExampleVariables, test.get("variables", {})),
-            t.cast(ExampleTemplatesAndResults, test.get("testcases", [])),
+            test.get("variables", {}),
+            test.get("testcases", []),
         )
 
     def _test(self, testname: str) -> None:
@@ -53,7 +53,11 @@ class FixtureMixin:
         for template, expected in testcases:
             expected_templates = expected_set(expected)
             expanded = uritemplate.expand(template, variables)
-            assert expanded in expected_templates
+            assert expanded in expected_templates, (  # nosec
+                f"expanded {template!r} with {variables!r} "
+                f"and got {expanded!r} but expected one of "
+                f"{expected_templates!r}"
+            )
 
 
 class TestSpecExamples(FixtureMixin):
@@ -134,3 +138,11 @@ class TestExtendedTests(FixtureMixin):
     def test_additional_examples_4(self) -> None:
         """Check Additional Examples 4."""
         self._test("Additional Examples 4: Numeric Keys")
+
+    def test_additional_examples_5(self) -> None:
+        """Check Additional Examples 5."""
+        self._test("Additional Examples 5: Explode Combinations")
+
+    def test_additional_examples_6(self) -> None:
+        """Check Additional Examples 6."""
+        self._test("Additional Examples 6: Reserved Expansion")
diff --git a/tests/test_uritemplate.py b/tests/test_uritemplate.py
index ca6363d..dc185d5 100644
--- a/tests/test_uritemplate.py
+++ b/tests/test_uritemplate.py
@@ -562,7 +562,7 @@ class TestURITemplate(unittest.TestCase, metaclass=RFCTemplateExamples):
         self.assertEqual(
             v._semi_path_expansion("foo", None, False, False), None
         )
-        t.variables[0].operator = "?"
+        t.variables[0].operator = variable.Operator.form_style_query
         self.assertEqual(
             v._semi_path_expansion("foo", ["bar", "bogus"], True, False),
             "foo=bar&foo=bogus",
@@ -588,50 +588,6 @@ class TestURITemplate(unittest.TestCase, metaclass=RFCTemplateExamples):
         self.assertEqual(args, {})
 
 
-class TestURIVariable(unittest.TestCase):
-    def setUp(self) -> None:
-        self.v = variable.URIVariable("{foo}")
-
-    def test_post_parse(self) -> None:
-        v = self.v
-        self.assertEqual(v.join_str, ",")
-        self.assertEqual(v.operator, "")
-        self.assertEqual(v.safe, "")
-        self.assertEqual(v.start, "")
-
-    def test_post_parse_plus(self) -> None:
-        v = self.v
-        v.operator = "+"
-        v.post_parse()
-        self.assertEqual(v.join_str, ",")
-        self.assertEqual(v.safe, variable.URIVariable.reserved)
-        self.assertEqual(v.start, "")
-
-    def test_post_parse_octothorpe(self) -> None:
-        v = self.v
-        v.operator = "#"
-        v.post_parse()
-        self.assertEqual(v.join_str, ",")
-        self.assertEqual(v.safe, variable.URIVariable.reserved)
-        self.assertEqual(v.start, "#")
-
-    def test_post_parse_question(self) -> None:
-        v = self.v
-        v.operator = "?"
-        v.post_parse()
-        self.assertEqual(v.join_str, "&")
-        self.assertEqual(v.safe, "")
-        self.assertEqual(v.start, "?")
-
-    def test_post_parse_ampersand(self) -> None:
-        v = self.v
-        v.operator = "&"
-        v.post_parse()
-        self.assertEqual(v.join_str, "&")
-        self.assertEqual(v.safe, "")
-        self.assertEqual(v.start, "&")
-
-
 class TestVariableModule(unittest.TestCase):
     def test_is_list_of_tuples(self) -> None:
         a_list = [(1, 2), (3, 4)]