"""RFC 6570 URI Templates with bidirectional support. Provides both expansion (template + variables → URI) and matching (URI → variables). RFC 6570 only specifies expansion; matching is the inverse operation needed by MCP servers to route ``resources/read`` requests to handlers. Supports Levels 1-3 fully, plus Level 4 explode modifier for path-like operators (``{/var*}``, ``{.var*}``, ``{;var*}``). The Level 4 prefix modifier (``{var:N}``) and query-explode (``{?var*}``) are not supported. Matching semantics ------------------ Matching is not specified by RFC 6570 (§1.4 explicitly defers to regex languages). This implementation uses a two-ended scan that never backtracks: match time is O(n·v) where n is URI length and v is the number of template variables. Realistic templates have v < 10, making this effectively linear; there is no input that produces superpolynomial time. A template may contain **at most one multi-segment variable** — ``{+var}``, ``{#var}``, or an explode-modified variable (``{/var*}``, ``{.var*}``, ``{;var*}``). This variable greedily consumes whatever the surrounding bounded variables and literals do not. Two such variables in one template are inherently ambiguous (which one gets the extra segment?) and are rejected at parse time. So are any two variables adjacent with no literal between them — including a variable adjacent to the multi-segment variable: the scan has nothing to anchor the boundary on. Operators that emit their own lead character supply that literal themselves, so ``{+path}{.ext}`` and ``{a}{.b}`` are fine while ``{+path}{ext}`` and ``{a}{b}`` are not. Bounded variables before the multi-segment variable match **lazily** (first occurrence of the following literal); those after match **greedily** (last occurrence of the preceding literal). Templates without a multi-segment variable match greedily throughout, identical to regex semantics. Reserved expansion ``{+var}`` leaves ``?`` and ``#`` unencoded, but the scan stops at those characters so ``{+path}{?q}`` can separate path from query. A value containing a literal ``?`` or ``#`` expands fine but will not round-trip through ``match()``. """ from __future__ import annotations import re from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from typing import Literal, TypeAlias, cast from urllib.parse import quote, unquote __all__ = [ "DEFAULT_MAX_TEMPLATE_LENGTH", "DEFAULT_MAX_VARIABLES", "DEFAULT_MAX_URI_LENGTH", "InvalidUriTemplate", "Operator", "UriTemplate", "Variable", ] Operator = Literal["", "+", "#", ".", "/", ";", "?", "&"] _OPERATORS: frozenset[str] = frozenset({"+", "#", ".", "/", ";", "?", "&"}) # RFC 6570 §2.3: varname = varchar *(["."] varchar), varchar = ALPHA / DIGIT / "_" # Dots appear only between varchar groups — not consecutive, not trailing. # (Percent-encoded varchars are technically allowed but unseen in practice.) _VARNAME_RE = re.compile(r"^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*$") DEFAULT_MAX_TEMPLATE_LENGTH = 8_192 DEFAULT_MAX_VARIABLES = 256 DEFAULT_MAX_URI_LENGTH = 65_536 # RFC 3986 reserved characters, kept unencoded by {+var} and {#var}. _RESERVED = ":/?#[]@!$&'()*+,;=" @dataclass(frozen=True) class _OperatorSpec: """Expansion behavior for a single operator (RFC 6570 §3.2, Table in §A).""" prefix: str """Leading character emitted before the first variable.""" separator: str """Character between variables (and between exploded list items).""" named: bool """Emit ``name=value`` pairs (query/path-param style) rather than bare values.""" allow_reserved: bool """Keep reserved characters unencoded ({+var}, {#var}).""" ifemp: str """Suffix after a named variable whose expanded value is empty (RFC §A): '' for ;, '=' for ?/&.""" _OPERATOR_SPECS: dict[Operator, _OperatorSpec] = { "": _OperatorSpec(prefix="", separator=",", named=False, allow_reserved=False, ifemp=""), "+": _OperatorSpec(prefix="", separator=",", named=False, allow_reserved=True, ifemp=""), "#": _OperatorSpec(prefix="#", separator=",", named=False, allow_reserved=True, ifemp=""), ".": _OperatorSpec(prefix=".", separator=".", named=False, allow_reserved=False, ifemp=""), "/": _OperatorSpec(prefix="/", separator="/", named=False, allow_reserved=False, ifemp=""), ";": _OperatorSpec(prefix=";", separator=";", named=True, allow_reserved=False, ifemp=""), "?": _OperatorSpec(prefix="?", separator="&", named=True, allow_reserved=False, ifemp="="), "&": _OperatorSpec(prefix="&", separator="&", named=True, allow_reserved=False, ifemp="="), } # Per-operator stop characters for the linear scan. A bounded variable's # value ends at the first occurrence of any character in its stop set, # mirroring the character-class boundaries a regex would use but without # the backtracking. _STOP_CHARS: dict[Operator, str] = { "": "/?#&,", # simple: everything structural is pct-encoded "+": "?#", # reserved: / allowed, stop at query/fragment "#": "", # fragment: tail of URI, nothing stops it ".": "./?#", # label: stop at next . "/": "/?#", # path segment: stop at next / ";": ";/?#", # path-param value (may be empty: ;name) "?": "&#", # query value (may be empty: ?name=) "&": "&#", # query-cont value } class InvalidUriTemplate(ValueError): """Raised when a URI template string is malformed or unsupported. Attributes: template: The template string that failed to parse. position: Character offset where the error was detected, or None if the error is not tied to a specific position. """ def __init__(self, message: str, *, template: str, position: int | None = None) -> None: super().__init__(message) self.template = template self.position = position @dataclass(frozen=True) class Variable: """A single variable within a URI template expression.""" name: str operator: Operator explode: bool = False @dataclass class _Expression: """A parsed ``{...}`` expression: one operator, one or more variables.""" operator: Operator variables: list[Variable] _Part = str | _Expression @dataclass(frozen=True) class _Lit: """A literal run in the flattened match-atom sequence.""" text: str @dataclass(frozen=True) class _Cap: """A single-variable capture in the flattened match-atom sequence. ``ifemp`` marks the ``;`` operator's optional-equals quirk: ``{;id}`` expands to ``;id=value`` or bare ``;id`` when the value is empty, so the scan must accept both forms. """ var: Variable ifemp: bool = False _Atom: TypeAlias = _Lit | _Cap def _is_greedy(var: Variable) -> bool: """Return True if this variable can span multiple path segments. Reserved/fragment expansion and explode variables are the only constructs whose match range is not bounded by a single structural delimiter. A template may contain at most one such variable. """ return var.explode or var.operator in ("+", "#") def _is_str_sequence(value: object) -> bool: """Check if value is a non-string sequence whose items are all strings.""" if isinstance(value, str) or not isinstance(value, Sequence): return False seq = cast(Sequence[object], value) return all(isinstance(item, str) for item in seq) _PCT_TRIPLET_RE = re.compile(r"%[0-9A-Fa-f]{2}") def _encode(value: str, *, allow_reserved: bool) -> str: """Percent-encode a value per RFC 6570 §3.2.1. Simple expansion encodes everything except unreserved characters. Reserved expansion (``{+var}``, ``{#var}``) additionally keeps RFC 3986 reserved characters intact and passes through existing ``%XX`` pct-triplets unchanged (RFC 6570 §3.2.3). A bare ``%`` not followed by two hex digits is still encoded to ``%25``. """ if not allow_reserved: return quote(value, safe="") # Reserved expansion: walk the string, pass through triplets as-is, # quote the gaps between them. A bare % with no triplet lands in a # gap and gets encoded normally. out: list[str] = [] last = 0 for m in _PCT_TRIPLET_RE.finditer(value): out.append(quote(value[last : m.start()], safe=_RESERVED)) out.append(m.group()) last = m.end() out.append(quote(value[last:], safe=_RESERVED)) return "".join(out) def _expand_expression(expr: _Expression, variables: Mapping[str, str | Sequence[str]]) -> str: """Expand a single ``{...}`` expression into its URI fragment. Walks the expression's variables, encoding and joining defined ones according to the operator's spec. Undefined variables are skipped (RFC 6570 §2.3); if all are undefined, the expression contributes nothing (no prefix is emitted). """ spec = _OPERATOR_SPECS[expr.operator] rendered: list[str] = [] for var in expr.variables: if var.name not in variables: # Undefined: skip entirely, no placeholder. continue value = variables[var.name] # Explicit type guard: reject non-str scalars with a clear message # rather than a confusing "not iterable" from the sequence branch. if not isinstance(value, str) and not _is_str_sequence(value): raise TypeError(f"Variable {var.name!r} must be str or a sequence of str, got {type(value).__name__}") if isinstance(value, str): encoded = _encode(value, allow_reserved=spec.allow_reserved) if spec.named: rendered.append(f"{var.name}{spec.ifemp}" if value == "" else f"{var.name}={encoded}") else: rendered.append(encoded) else: # Sequence value. items = [_encode(v, allow_reserved=spec.allow_reserved) for v in value] if not items: continue if var.explode: # Each item gets the operator's separator; named ops repeat the key. if spec.named: rendered.append( spec.separator.join(f"{var.name}{spec.ifemp}" if v == "" else f"{var.name}={v}" for v in items) ) else: rendered.append(spec.separator.join(items)) else: # Non-explode: comma-join into a single value, then apply # ifemp to the joined result (RFC §3.2.1: behaves as if the # value were the joined string). joined = ",".join(items) if spec.named: rendered.append(f"{var.name}{spec.ifemp}" if joined == "" else f"{var.name}={joined}") else: rendered.append(joined) if not rendered: return "" return spec.prefix + spec.separator.join(rendered) @dataclass(frozen=True) class UriTemplate: """A parsed RFC 6570 URI template. Construct via :meth:`parse`. Instances are immutable and hashable; equality is based on the template string alone. """ template: str _parts: list[_Part] = field(repr=False, compare=False) _variables: list[Variable] = field(repr=False, compare=False) _prefix: list[_Atom] = field(repr=False, compare=False) _greedy: Variable | None = field(repr=False, compare=False) _suffix: list[_Atom] = field(repr=False, compare=False) _query_variables: list[Variable] = field(repr=False, compare=False) @staticmethod def is_template(value: str) -> bool: """Check whether a string contains URI template expressions. A cheap heuristic for distinguishing concrete URIs from templates without the cost of full parsing. Returns ``True`` if the string contains at least one ``{...}`` pair. Example:: >>> UriTemplate.is_template("file://docs/{name}") True >>> UriTemplate.is_template("file://docs/readme.txt") False Note: This does not validate the template. A ``True`` result does not guarantee :meth:`parse` will succeed. """ open_i = value.find("{") return open_i != -1 and value.find("}", open_i) != -1 @classmethod def parse( cls, template: str, *, max_length: int = DEFAULT_MAX_TEMPLATE_LENGTH, max_variables: int = DEFAULT_MAX_VARIABLES, ) -> UriTemplate: """Parse a URI template string. Args: template: An RFC 6570 URI template. max_length: Maximum permitted length of the template string. Guards against resource exhaustion. max_variables: Maximum number of variables permitted across all expressions. Counting variables rather than ``{...}`` expressions closes the gap where a single ``{v0,v1,...,vN}`` expression packs arbitrarily many variables under one expression count. Raises: InvalidUriTemplate: If the template is malformed, exceeds the size limits, or uses unsupported RFC 6570 features. """ if len(template) > max_length: raise InvalidUriTemplate( f"Template exceeds maximum length of {max_length}", template=template, ) parts, variables = _parse(template, max_variables=max_variables) # Trailing {?...}/{&...} expressions are split off and matched as # a query string (order-agnostic, partial, extras ignored) rather # than via the linear scan. path_parts, query_vars = _split_query_tail(parts) atoms = _flatten(path_parts) prefix, greedy, suffix = _partition_greedy(atoms, template) return cls( template=template, _parts=parts, _variables=variables, _prefix=prefix, _greedy=greedy, _suffix=suffix, _query_variables=query_vars, ) @property def variables(self) -> list[Variable]: """All variables in the template, in order of appearance.""" return list(self._variables) @property def variable_names(self) -> list[str]: """All variable names in the template, in order of appearance.""" return [v.name for v in self._variables] @property def query_variable_names(self) -> frozenset[str]: """Names of variables that :meth:`match` treats as optional query parameters. These are the variables in a trailing run of ``{?...}``/``{&...}`` expressions, which are matched leniently: a URI that omits some (or all) of them still matches, and the omitted names are simply absent from the result. Any value bound to such a name therefore needs a fallback for the omitted case. Every other variable is bound on every successful :meth:`match` (possibly to an empty string) and is *not* in this set. That includes a ``{&...}`` expression with no preceding ``{?...}``: it never emits the ``?`` the lenient query split keys on, so it is matched strictly. """ return frozenset(v.name for v in self._query_variables) def expand(self, variables: Mapping[str, str | Sequence[str]]) -> str: """Expand the template by substituting variable values. String values are percent-encoded according to their operator: simple ``{var}`` encodes reserved characters; ``{+var}`` and ``{#var}`` leave them intact. Sequence values are joined with commas for non-explode variables, or with the operator's separator for explode variables. Example:: >>> t = UriTemplate.parse("file://docs/{name}") >>> t.expand({"name": "hello world.txt"}) 'file://docs/hello%20world.txt' >>> t = UriTemplate.parse("file://docs/{+path}") >>> t.expand({"path": "src/main.py"}) 'file://docs/src/main.py' >>> t = UriTemplate.parse("/search{?q,lang}") >>> t.expand({"q": "mcp", "lang": "en"}) '/search?q=mcp&lang=en' >>> t = UriTemplate.parse("/files{/path*}") >>> t.expand({"path": ["a", "b", "c"]}) '/files/a/b/c' Args: variables: Values for each template variable. Keys must be strings; values must be ``str`` or a sequence of ``str``. Returns: The expanded URI string. Note: Per RFC 6570, variables absent from the mapping are **silently omitted**. This is the correct behavior for optional query parameters (``{?page}`` with no page yields no ``?page=``), but for required path segments it produces a structurally incomplete URI. If you need all variables present, validate before calling:: missing = set(t.variable_names) - variables.keys() if missing: raise ValueError(f"Missing: {missing}") Raises: TypeError: If a value is neither ``str`` nor an iterable of ``str``. Non-string scalars (``int``, ``None``) are not coerced. """ out: list[str] = [] for part in self._parts: if isinstance(part, str): out.append(part) else: out.append(_expand_expression(part, variables)) return "".join(out) def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> dict[str, str | list[str]] | None: """Match a concrete URI against this template and extract variables. This is the inverse of :meth:`expand`. The URI is matched via a linear scan of the template and captured values are percent-decoded. The round-trip ``match(expand({k: v})) == {k: v}`` holds when ``v`` does not contain its operator's separator unencoded: ``{.ext}`` with ``ext="tar.gz"`` expands to ``.tar.gz`` but does not match — the scan stops ``ext`` at the first ``.`` and the trailing ``.gz`` has nothing to consume it. RFC 6570 §1.4 notes this is an inherent reversal limitation. Matching is structural at the URI level only: a simple ``{name}`` will not match across a literal ``/`` in the URI (the scan stops there), but a percent-encoded ``%2F`` that decodes to ``/`` is accepted as part of the value. Path-safety validation belongs at a higher layer; see :mod:`mcp.shared.path_security`. Example:: >>> t = UriTemplate.parse("file://docs/{name}") >>> t.match("file://docs/readme.txt") {'name': 'readme.txt'} >>> t.match("file://docs/hello%20world.txt") {'name': 'hello world.txt'} >>> t = UriTemplate.parse("file://docs/{+path}") >>> t.match("file://docs/src/main.py") {'path': 'src/main.py'} >>> t = UriTemplate.parse("/files{/path*}") >>> t.match("/files/a/b/c") {'path': ['a', 'b', 'c']} **Query parameters** (``{?q,lang}`` at the end of a template) are matched leniently: order-agnostic, partial, and unrecognized params are ignored. Absent params are omitted from the result so downstream function defaults can apply:: >>> t = UriTemplate.parse("logs://{service}{?since,level}") >>> t.match("logs://api") {'service': 'api'} >>> t.match("logs://api?level=error") {'service': 'api', 'level': 'error'} >>> t.match("logs://api?level=error&since=5m&utm=x") {'service': 'api', 'since': '5m', 'level': 'error'} Args: uri: A concrete URI string. max_uri_length: Maximum permitted length of the input URI. Oversized inputs return ``None`` without scanning, guarding against resource exhaustion. Returns: A mapping from variable names to decoded values (``str`` for scalar variables, ``list[str]`` for explode variables), or ``None`` if the URI does not match the template or exceeds ``max_uri_length``. """ if len(uri) > max_uri_length: return None if self._query_variables: # Two-phase: scan matches the path, the query is split and # decoded manually. Query params may be partial, reordered, # or include extras; absent params stay absent so downstream # defaults can apply. Fragment is stripped first since the # template's {?...} tail never describes a fragment. before_fragment, _, _ = uri.partition("#") path, _, query = before_fragment.partition("?") result = self._scan(path) if result is None: return None if query: parsed = _parse_query(query) for var in self._query_variables: if var.name in parsed: result[var.name] = parsed[var.name] return result return self._scan(uri) def _scan(self, uri: str) -> dict[str, str | list[str]] | None: """Run the two-ended linear scan against the path portion of a URI.""" n = len(uri) if self._greedy is None: # No greedy var: the suffix IS the whole template, scanned # right-to-left and anchored so atoms[0] matches at position 0. suffix = _scan_suffix(self._suffix, uri, n, anchored=True) if suffix is None: return None suffix_result, suffix_start = suffix return suffix_result if suffix_start == 0 else None # Greedy var present. The parser rejects a capture adjacent to # the greedy slot, so a non-empty suffix begins with a _Lit whose # rfind-derived anchor does not depend on how far the prefix # scans. Scan the suffix first, then give the prefix that exact # position as its ceiling so it cannot consume past the anchor. suffix = _scan_suffix(self._suffix, uri, n, anchored=False) if suffix is None: return None suffix_result, suffix_start = suffix prefix = _scan_prefix(self._prefix, uri, 0, suffix_start) if prefix is None: return None prefix_result, prefix_end = prefix # Prefix consumed [0, prefix_end); suffix consumed [suffix_start, n); # the greedy var takes the gap. The prefix scan is bounded by # suffix_start, so this holds by construction; guard explicitly # rather than asserting so a future regression surfaces as a # non-match, not an exception. if suffix_start < prefix_end: return None # pragma: no cover - unreachable while bounds hold middle = uri[prefix_end:suffix_start] greedy_value = _extract_greedy(self._greedy, middle) if greedy_value is None: return None return {**prefix_result, self._greedy.name: greedy_value, **suffix_result} def __str__(self) -> str: return self.template def _parse_query(query: str) -> dict[str, str]: """Parse a query string into a name→value mapping. Unlike ``urllib.parse.parse_qs``, this follows RFC 3986 semantics: ``+`` is a literal sub-delim, not a space. Form-urlencoding treats ``+`` as space for HTML form submissions, but RFC 6570 and MCP resource URIs follow RFC 3986 where only ``%20`` encodes a space. Parameter names are **not** percent-decoded. RFC 6570 expansion never encodes variable names, so a legitimate match will always have the name in literal form. Decoding names would let ``%74oken=evil&token=real`` shadow the real ``token`` parameter via first-wins. Duplicate keys keep the first value. Pairs without ``=`` are treated as empty-valued. """ result: dict[str, str] = {} for pair in query.split("&"): name, _, value = pair.partition("=") if name and name not in result: result[name] = unquote(value) return result def _extract_greedy(var: Variable, raw: str) -> str | list[str] | None: """Decode the greedy variable's isolated middle span. For scalar greedy (``{+var}``, ``{#var}``) this is a stop-char validation and a single ``unquote``. For explode variables the span is a run of separator-delimited segments (``/a/b/c`` or ``;keys=a;keys=b``) that is split, validated, and decoded per item. """ spec = _OPERATOR_SPECS[var.operator] stops = _STOP_CHARS[var.operator] if not var.explode: if any(c in stops for c in raw): return None return unquote(raw) sep = spec.separator if not raw: return [] # A non-empty explode span must begin with the separator: {/a*} # expands to "/x/y", never "x/y". The scan does not consume the # separator itself, so it must be the first character here. if raw[0] != sep: return None # Segments must not contain the operator's non-separator stop # characters (e.g. {/path*} segments may contain neither ? nor #). body_stops = set(stops) - {sep} if any(c in body_stops for c in raw): return None segments: list[str] = [] prefix = f"{var.name}=" # split()[0] is always "" because raw starts with the separator; # subsequent empties are legitimate values ({/path*} with # ["a","","c"] expands to /a//c). for seg in raw.split(sep)[1:]: if spec.named: # Named explode emits name=value per item (or bare name # under ; with empty value). Validate the name and strip # the prefix before decoding. if seg.startswith(prefix): seg = seg[len(prefix) :] elif seg == var.name: seg = "" else: return None segments.append(unquote(seg)) return segments def _split_query_tail(parts: list[_Part]) -> tuple[list[_Part], list[Variable]]: """Separate trailing ``?``/``&`` expressions from the path portion. Lenient query matching (order-agnostic, partial, ignores extras) applies when a template ends with one or more consecutive ``?``/``&`` expressions and the preceding path portion contains no literal ``?``. If the path has a literal ``?`` (e.g., ``?fixed=1{&page}``), the URI's ``?`` split won't align with the template's expression boundary, so the strict scan is used instead. Returns: A pair ``(path_parts, query_vars)``. If lenient matching does not apply, ``query_vars`` is empty and ``path_parts`` is the full input. """ split = len(parts) for i in range(len(parts) - 1, -1, -1): part = parts[i] if isinstance(part, _Expression) and part.operator in ("?", "&"): split = i else: break if split == len(parts): return parts, [] # The tail must start with a {?...} expression so that expand() # emits a ? the URI can split on. A standalone {&page} expands # with an & prefix, which partition("?") won't find. first = parts[split] assert isinstance(first, _Expression) if first.operator != "?": return parts, [] # If the path portion contains a literal ?/# or a {?...}/{#...} # expression, lenient matching's partition("#") then partition("?") # would strip content the path scan expects to see. Fall back to # the strict scan. for part in parts[:split]: if isinstance(part, str): if "?" in part or "#" in part: return parts, [] elif part.operator in ("?", "#"): return parts, [] query_vars: list[Variable] = [] for part in parts[split:]: assert isinstance(part, _Expression) query_vars.extend(part.variables) return parts[:split], query_vars def _parse(template: str, *, max_variables: int) -> tuple[list[_Part], list[Variable]]: """Split a template into an ordered sequence of literals and expressions. Walks the string, alternating between collecting literal runs and parsing ``{...}`` expressions. The resulting ``parts`` sequence preserves positional interleaving so ``match()`` and ``expand()`` can walk it in order. Raises: InvalidUriTemplate: On unclosed braces, too many expressions, or any error surfaced by :func:`_parse_expression`. """ parts: list[_Part] = [] variables: list[Variable] = [] i = 0 n = len(template) while i < n: # Find the next expression opener from the current cursor. brace = template.find("{", i) if brace == -1: # No more expressions; everything left is a trailing literal. parts.append(template[i:]) break if brace > i: # Literal text between cursor and the brace. parts.append(template[i:brace]) end = template.find("}", brace) if end == -1: raise InvalidUriTemplate( f"Unclosed expression at position {brace}", template=template, position=brace, ) # Delegate body (between braces, exclusive) to the expression parser. expr = _parse_expression(template, template[brace + 1 : end], brace) parts.append(expr) variables.extend(expr.variables) if len(variables) > max_variables: raise InvalidUriTemplate( f"Template exceeds maximum of {max_variables} variables", template=template, ) # Advance past the closing brace. i = end + 1 _check_duplicate_variables(template, variables) _check_single_query_expression(template, parts) return parts, variables def _parse_expression(template: str, body: str, pos: int) -> _Expression: """Parse the body of a single ``{...}`` expression. The body is everything between the braces. It consists of an optional leading operator character followed by one or more comma-separated variable specifiers. Each specifier is a name with an optional trailing ``*`` (explode modifier). Args: template: The full template string, for error reporting. body: The expression body, braces excluded. pos: Character offset of the opening brace, for error reporting. Raises: InvalidUriTemplate: On empty body, invalid variable names, or unsupported modifiers. """ if not body: raise InvalidUriTemplate(f"Empty expression at position {pos}", template=template, position=pos) # Peel off the operator, if any. Membership check justifies the cast. operator: Operator = "" if body[0] in _OPERATORS: operator = cast(Operator, body[0]) body = body[1:] if not body: raise InvalidUriTemplate( f"Expression has operator but no variables at position {pos}", template=template, position=pos, ) # Remaining body is comma-separated variable specs: name[*] variables: list[Variable] = [] for spec in body.split(","): if ":" in spec: raise InvalidUriTemplate( f"Prefix modifier {{var:N}} is not supported (in {spec!r} at position {pos})", template=template, position=pos, ) explode = spec.endswith("*") name = spec[:-1] if explode else spec if not _VARNAME_RE.fullmatch(name): raise InvalidUriTemplate( f"Invalid variable name {name!r} at position {pos}", template=template, position=pos, ) # Explode only makes sense for operators that repeat a separator. # Simple/reserved/fragment have no per-item separator; query-explode # needs order-agnostic dict matching which we don't support yet. if explode and operator in ("", "+", "#", "?", "&"): raise InvalidUriTemplate( f"Explode modifier on {{{operator}{name}*}} is not supported for matching", template=template, position=pos, ) variables.append(Variable(name=name, operator=operator, explode=explode)) return _Expression(operator=operator, variables=variables) def _check_duplicate_variables(template: str, variables: list[Variable]) -> None: """Reject templates that use the same variable name more than once. RFC 6570 requires repeated variables to expand to the same value, which would require backreference matching with potentially exponential cost. Rather than silently returning only the last captured value, we reject at parse time. Raises: InvalidUriTemplate: If any variable name appears more than once. """ seen: set[str] = set() for var in variables: if var.name in seen: raise InvalidUriTemplate( f"Variable {var.name!r} appears more than once; repeated variables are not supported", template=template, ) seen.add(var.name) def _check_single_query_expression(template: str, parts: list[_Part]) -> None: """Reject templates with more than one ``{?...}`` expression. The ``?`` operator emits a leading ``?``, so two such expressions expand to a URI with two ``?`` characters — malformed per RFC 3986 §3.4. Use ``{?a,b}`` or ``{?a}{&b}`` for multiple query parameters. """ seen = False for part in parts: if isinstance(part, _Expression) and part.operator == "?": if seen: raise InvalidUriTemplate( "Template contains more than one {?...} expression; " "use {?a,b} or {?a}{&b} for multiple query parameters", template=template, ) seen = True def _flatten(parts: list[_Part]) -> list[_Atom]: """Lower expressions into a flat sequence of literals and single-variable captures. Operator prefixes and separators become explicit ``_Lit`` atoms so the scan only ever sees two atom kinds. Adjacent literals are coalesced so that anchor-finding (``find``/``rfind``) operates on the longest possible literal, reducing false matches. Explode variables emit no lead literal: the explode capture includes its own separator-prefixed repetitions (``{/a*}`` → ``/x/y/z``, not ``/`` then ``x/y/z``). """ atoms: list[_Atom] = [] def push_lit(text: str) -> None: if not text: return if atoms and isinstance(atoms[-1], _Lit): atoms[-1] = _Lit(atoms[-1].text + text) else: atoms.append(_Lit(text)) for part in parts: if isinstance(part, str): push_lit(part) continue spec = _OPERATOR_SPECS[part.operator] for i, var in enumerate(part.variables): lead = spec.prefix if i == 0 else spec.separator if var.explode: atoms.append(_Cap(var)) elif spec.named: # ; uses ifemp (bare name when empty); ? and & always # emit name= so the equals is part of the literal. if part.operator == ";": push_lit(f"{lead}{var.name}") atoms.append(_Cap(var, ifemp=True)) else: push_lit(f"{lead}{var.name}=") atoms.append(_Cap(var)) else: push_lit(lead) atoms.append(_Cap(var)) return atoms def _partition_greedy(atoms: list[_Atom], template: str) -> tuple[list[_Atom], Variable | None, list[_Atom]]: """Split atoms at the single greedy variable, if any. Returns ``(prefix, greedy_var, suffix)``. If there is no greedy variable the entire atom list is returned as the suffix so that the right-to-left scan (which matches regex-greedy semantics) handles it. Raises: InvalidUriTemplate: If two variables are adjacent with no literal between them — whether or not one is the multi-segment variable, the scan has nothing to anchor the boundary on — or if more than one multi-segment variable is present (two are inherently ambiguous: there is no principled way to decide which one absorbs an extra segment). """ greedy_idx: int | None = None prev: _Atom | None = None for i, atom in enumerate(atoms): if isinstance(atom, _Cap): if isinstance(prev, _Cap): raise InvalidUriTemplate( f"Variables {prev.var.name!r} and {atom.var.name!r} are adjacent " "with no literal separator; matching cannot determine where one " "ends and the other begins. Add a literal between them or use a " "single variable.", template=template, ) if _is_greedy(atom.var): if greedy_idx is not None: raise InvalidUriTemplate( "Template contains more than one multi-segment variable " "({+var}, {#var}, or explode modifier); matching would be ambiguous", template=template, ) greedy_idx = i prev = atom if greedy_idx is None: return [], None, atoms greedy = atoms[greedy_idx] assert isinstance(greedy, _Cap) return atoms[:greedy_idx], greedy.var, atoms[greedy_idx + 1 :] def _scan_suffix( atoms: Sequence[_Atom], uri: str, end: int, *, anchored: bool ) -> tuple[dict[str, str | list[str]], int] | None: """Scan atoms right-to-left from ``end``, returning captures and start position. Each bounded variable takes the minimum span that lets its preceding literal match (found via ``rfind``), which makes the *first* variable in template order greedy — identical to Python regex semantics for a sequence of greedy groups. When ``anchored`` is true the atom sequence is the entire template (no greedy variable), so ``atoms[0]`` must match at URI position 0 rather than at its rightmost occurrence. """ result: dict[str, str | list[str]] = {} pos = end i = len(atoms) - 1 while i >= 0: atom = atoms[i] if isinstance(atom, _Lit): n = len(atom.text) if pos < n or uri[pos - n : pos] != atom.text: return None pos -= n i -= 1 continue var = atom.var stops = _STOP_CHARS[var.operator] prev = atoms[i - 1] if i > 0 else None if atom.ifemp: # ;name or ;name=value. The preceding _Lit is ";name". # Try empty first: if the lit ends at pos the value is # absent (RFC ifemp). Otherwise require =value. assert isinstance(prev, _Lit) if uri.endswith(prev.text, 0, pos): result[var.name] = "" i -= 1 continue earliest = pos while earliest > 0 and uri[earliest - 1] not in stops: earliest -= 1 eq = uri.find("=", earliest, pos) if eq == -1: return None result[var.name] = unquote(uri[eq + 1 : pos]) pos = eq i -= 1 continue # Earliest valid start: the var cannot extend left past any # stop-char, so scan backward to find that boundary. earliest = pos while earliest > 0 and uri[earliest - 1] not in stops: earliest -= 1 if prev is None: start = earliest else: # prev is a _Lit: the parser rejects two adjacent captures, # so the only possible neighbour kind is a literal. assert isinstance(prev, _Lit) if anchored and i - 1 == 0: # First atom of the whole template: positionally fixed at # 0, not rightmost occurrence. rfind would land inside the # value when the literal repeats there (e.g. "prefix-{id}" # against "prefix-prefix-123"). start = len(prev.text) if start < earliest or start > pos: return None else: # Rightmost occurrence of the preceding literal whose end # falls within the var's valid range. idx = uri.rfind(prev.text, 0, pos) if idx == -1 or idx + len(prev.text) < earliest: return None start = idx + len(prev.text) result[var.name] = unquote(uri[start:pos]) pos = start i -= 1 return result, pos def _scan_prefix( atoms: Sequence[_Atom], uri: str, start: int, limit: int ) -> tuple[dict[str, str | list[str]], int] | None: """Scan atoms left-to-right from ``start``, not exceeding ``limit``. Each bounded variable takes the minimum span that lets its following literal match (found via ``find``), leaving the greedy variable as much of the URI as possible. """ result: dict[str, str | list[str]] = {} pos = start for i, atom in enumerate(atoms): if isinstance(atom, _Lit): end = pos + len(atom.text) if end > limit or uri[pos:end] != atom.text: return None pos = end continue var = atom.var stops = _STOP_CHARS[var.operator] # Every capture here is followed by a literal: the parser rejects # two adjacent captures, and a capture at the END of the prefix # would be adjacent to the greedy variable. nxt = atoms[i + 1] assert isinstance(nxt, _Lit) if atom.ifemp: # RFC §3.2.7 ifemp: ;name=val for non-empty, bare ;name for # empty. Decide which form is present without falling through # to the stop-char scan when the value is empty. if uri.startswith(nxt.text, pos): # Following literal begins immediately: value is empty. # Checked before '=' so a literal that itself starts # with '=' is not mistaken for the ifemp separator. result[var.name] = "" continue if pos < limit and uri[pos] == "=": pos += 1 # value follows; fall through to the scan else: # The following literal does not start here and there is # no '=': the URI's name continued past the template's # (e.g. ;keys vs ;key) — no parse. return None # Latest valid end: the var stops at the first stop-char or # the scan limit, whichever comes first. latest = pos while latest < limit and uri[latest] not in stops: latest += 1 # First occurrence of the following literal: the capture takes # the minimum span, leaving the greedy variable as much of the # URI as possible. The search window's upper bound already # forces any hit to start at or before ``latest``, so the var # never extends past a stop-char. end = uri.find(nxt.text, pos, latest + len(nxt.text)) if end == -1: return None result[var.name] = unquote(uri[pos:end]) pos = end return result, pos