Files
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

1004 lines
39 KiB
Python

"""Tests for RFC 6570 URI template parsing, expansion, and matching."""
import dataclasses
import random
import string
import pytest
from mcp.shared.uri_template import DEFAULT_MAX_URI_LENGTH, InvalidUriTemplate, UriTemplate, Variable
def test_parse_literal_only():
tmpl = UriTemplate.parse("file://docs/readme.txt")
assert tmpl.variables == []
assert tmpl.variable_names == []
assert str(tmpl) == "file://docs/readme.txt"
@pytest.mark.parametrize(
("value", "expected"),
[
("file://docs/{name}", True),
("file://docs/readme.txt", False),
("", False),
("{a}", True),
("{", False),
("}", False),
("}{", False),
("prefix{+path}/suffix", True),
("{invalid syntax but still a template}", True),
],
)
def test_is_template(value: str, expected: bool):
assert UriTemplate.is_template(value) is expected
def test_parse_simple_variable():
tmpl = UriTemplate.parse("file://docs/{name}")
assert tmpl.variables == [Variable(name="name", operator="")]
assert tmpl.variable_names == ["name"]
@pytest.mark.parametrize(
("template", "operator"),
[
("{+path}", "+"),
("{#frag}", "#"),
("{.ext}", "."),
("{/seg}", "/"),
("{;param}", ";"),
("{?q}", "?"),
("{&next}", "&"),
],
)
def test_parse_all_operators(template: str, operator: str):
tmpl = UriTemplate.parse(template)
(var,) = tmpl.variables
assert var.operator == operator
assert var.explode is False
def test_parse_multiple_variables_in_expression():
tmpl = UriTemplate.parse("{?q,lang,page}")
assert tmpl.variable_names == ["q", "lang", "page"]
assert all(v.operator == "?" for v in tmpl.variables)
def test_parse_multiple_expressions():
tmpl = UriTemplate.parse("db://{table}/{id}{?format}")
assert tmpl.variable_names == ["table", "id", "format"]
ops = [v.operator for v in tmpl.variables]
assert ops == ["", "", "?"]
@pytest.mark.parametrize(
("template", "expected"),
[
("logs://{service}{?a,b}", frozenset({"a", "b"})),
("logs://{service}{?a,b}{&c}", frozenset({"a", "b", "c"})),
("logs://{service}", frozenset[str]()),
# A lone {&...} never emits the leading ? that lenient query
# matching splits on, so it is matched strictly: c must be
# present in the URI and is not an optional query variable.
("logs://{service}{&c}", frozenset[str]()),
],
)
def test_query_variable_names(template: str, expected: frozenset[str]):
"""query_variable_names is exactly the set match() treats as optional:
the trailing {?...}/{&...} variables a client may omit from the URI."""
assert UriTemplate.parse(template).query_variable_names == expected
def test_parse_explode_modifier():
tmpl = UriTemplate.parse("/files{/path*}")
(var,) = tmpl.variables
assert var.name == "path"
assert var.operator == "/"
assert var.explode is True
@pytest.mark.parametrize("template", ["{.labels*}", "{;params*}"])
def test_parse_explode_supported_operators(template: str):
tmpl = UriTemplate.parse(template)
assert tmpl.variables[0].explode is True
def test_parse_mixed_explode_and_plain():
tmpl = UriTemplate.parse("{/path*}{?q}")
assert tmpl.variables == [
Variable(name="path", operator="/", explode=True),
Variable(name="q", operator="?"),
]
def test_parse_varname_with_dots_and_underscores():
tmpl = UriTemplate.parse("{foo_bar.baz}")
assert tmpl.variable_names == ["foo_bar.baz"]
def test_parse_rejects_unclosed_expression():
with pytest.raises(InvalidUriTemplate, match="Unclosed expression") as exc:
UriTemplate.parse("file://{name")
assert exc.value.position == 7
assert exc.value.template == "file://{name"
def test_parse_rejects_empty_expression():
with pytest.raises(InvalidUriTemplate, match="Empty expression"):
UriTemplate.parse("file://{}")
def test_parse_rejects_operator_without_variable():
with pytest.raises(InvalidUriTemplate, match="operator but no variables"):
UriTemplate.parse("{+}")
@pytest.mark.parametrize(
"name",
[
"-bad",
"bad-name",
"bad name",
"bad/name",
# RFC §2.3: dots only between varchars, not consecutive or trailing
"foo..bar",
"foo.",
# a single trailing newline slipped past `$` with re.match
"foo\n",
],
)
def test_parse_rejects_invalid_varname(name: str):
with pytest.raises(InvalidUriTemplate, match="Invalid variable name"):
UriTemplate.parse(f"{{{name}}}")
def test_parse_accepts_dotted_varname():
t = UriTemplate.parse("{a.b.c}")
assert t.variable_names == ["a.b.c"]
def test_parse_rejects_empty_spec_in_list():
with pytest.raises(InvalidUriTemplate, match="Invalid variable name"):
UriTemplate.parse("{a,,b}")
def test_parse_rejects_prefix_modifier():
with pytest.raises(InvalidUriTemplate, match="Prefix modifier"):
UriTemplate.parse("{var:3}")
@pytest.mark.parametrize("template", ["{var*}", "{+var*}", "{#var*}", "{?var*}", "{&var*}"])
def test_parse_rejects_unsupported_explode(template: str):
with pytest.raises(InvalidUriTemplate, match="Explode modifier"):
UriTemplate.parse(template)
@pytest.mark.parametrize(
"template",
[
"{/a*}/x{/b*}", # two explode vars: a literal between them doesn't help
# Multi-var + expression: each var is greedy (',' separates them)
"{+a,b}",
# Two {+var}/{#var} anywhere
"{+a}/x/{+b}",
"{+a},{+b}",
"{#a}/x/{+b}",
"{+a}.foo.{#b}",
],
)
def test_parse_rejects_multiple_multi_segment_variables(template: str):
# Two multi-segment variables make matching inherently ambiguous:
# there is no principled way to decide which one absorbs an extra
# segment. The linear scan can only partition the URI around a
# single greedy slot. (Two ADJACENT multi-segment variables are
# caught by the adjacency rule first; see the test below.)
with pytest.raises(InvalidUriTemplate, match="more than one multi-segment"):
UriTemplate.parse(template)
@pytest.mark.parametrize(
"template",
[
# Two bounded variables
"{a}{b}",
"{.a}{b}",
"{/a}{b}",
"{;a}{b}",
"{a}{b}X{+p}",
"{+p}X{a}{b}",
"pre{a}{b}post",
# A bounded variable adjacent to the multi-segment variable
"{a}{+b}",
"{+a}{b}",
"{#a}{b}",
"{.a}{+b}",
"{/a}{+b}",
"x{name}{+path}y",
"X{+a}{b}",
"{+p}{n}",
"{x}Y{+p}{n}",
"{?a}{+b}x",
# ... on either side, with a literal on the OTHER side
"{a}-{+p}{b}",
"{a}{+p}-{b}",
"{name}{+path}{.ext}",
"{base}{+p}{;k}",
# ... or on both sides
"{a}{+b}{c}",
"{a}{+p}{b}Y{c}",
"X{a}{+p}{b}Y{c}",
"{a}{/p*}{b}",
# An explode variable carries its operator's separators inside
# the capture, so it emits no lead literal that could anchor it
"{a}{/p*}",
"{/seg}{;k*}",
"item://{id}{;opts*}",
# ifemp: the ';key' literal anchors the LEFT edge of {;key}, but
# nothing separates its right edge from the multi-segment var
"api{;key}{+rest}",
# Two multi-segment variables that are ALSO adjacent
"{/a*}{/b*}",
"{/a*}{.b*}",
"{.a*}{;b*}",
"{/a*}{b}{.c*}",
"{+a}{/b*}",
],
)
def test_parse_rejects_adjacent_variables(template: str) -> None:
# Two captures with no literal between them give the scan nothing to
# anchor the boundary on — whether or not one of them is the
# multi-segment variable.
with pytest.raises(InvalidUriTemplate, match="adjacent with no literal separator"):
UriTemplate.parse(template)
@pytest.mark.parametrize(
"template",
[
"file://docs/{+path}", # + at end of template
"file://{+path}.txt", # + followed by literal only
"file://{+path}/edit", # + followed by literal only
"api/{+path}{?v,page}", # + followed by query tail (split off before scan)
"api/{+path}{&next}", # + followed by query-continuation
"page{#section}", # # at end
"{a}{#b}", # # emits a literal '#' that anchors the boundary
"{+a}/sep/{b}", # + with bounded vars after
"{+a},{b}",
# Operators that emit their own lead character ('.', '/', ';name')
# supply the literal anchor, so these are NOT adjacent variables.
"{+a}{/b}",
"{+a}{.b}",
"{+a}{;b}",
"{+path}{.ext}",
"prefix/{+path}{.ext}",
"tree://nodes{/path*}",
"api{;key}/{+rest}",
],
)
def test_parse_allows_single_multi_segment_variable(template: str):
# One multi-segment variable is fine: the linear scan isolates it
# between the prefix and suffix boundaries, and the scan never
# backtracks so match time stays O(n) regardless of URI content.
t = UriTemplate.parse(template)
assert t is not None
@pytest.mark.parametrize(
"template",
["{x}/{x}", "{x,x}", "{a}{b}{a}", "{+x}/foo/{x}"],
)
def test_parse_rejects_duplicate_variable_names(template: str):
with pytest.raises(InvalidUriTemplate, match="appears more than once"):
UriTemplate.parse(template)
@pytest.mark.parametrize(
"template",
["/x{?a}{?b}", "/x{?a}/y{?b}", "{?a}{&b}{?c}"],
)
def test_parse_rejects_multiple_query_expressions(template: str) -> None:
with pytest.raises(InvalidUriTemplate, match=r"more than one \{\?"):
UriTemplate.parse(template)
def test_query_tail_roundtrip_correct_spellings() -> None:
for tmpl in ("/x{?a,b}", "/x{?a}{&b}"):
t = UriTemplate.parse(tmpl)
assert t.match(t.expand({"a": "1", "b": "2"})) == {"a": "1", "b": "2"}
def test_invalid_uri_template_is_value_error():
with pytest.raises(ValueError):
UriTemplate.parse("{}")
@pytest.mark.parametrize(
"template",
[
"{{name}}", # nested open: body becomes "{name"
"{a{b}c}", # brace inside expression
"{{]{}}{}", # garbage soup
"{a,{b}", # brace in comma list
],
)
def test_parse_rejects_nested_braces(template: str):
# Nested/stray { inside an expression lands in the varname and
# fails the varname regex rather than needing special handling.
with pytest.raises(InvalidUriTemplate, match="Invalid variable name"):
UriTemplate.parse(template)
@pytest.mark.parametrize(
("template", "position"),
[
("{", 0),
("{{", 0),
("file://{name", 7),
("{a}{", 3),
("}{", 1), # stray } is literal, then unclosed {
],
)
def test_parse_rejects_unclosed_brace(template: str, position: int):
with pytest.raises(InvalidUriTemplate, match="Unclosed") as exc:
UriTemplate.parse(template)
assert exc.value.position == position
@pytest.mark.parametrize(
"template",
["}}", "}", "a}b", "{a}}{b}"],
)
def test_parse_treats_stray_close_brace_as_literal(template: str):
# RFC 6570 §2.1 strictly excludes } from literals, but we accept it
# for TypeScript SDK parity. A stray } almost always indicates a
# typo; rejecting would be more helpful but would also break
# cross-SDK behavior.
tmpl = UriTemplate.parse(template)
assert str(tmpl) == template
def test_parse_stray_close_brace_between_expressions():
tmpl = UriTemplate.parse("{a}}{b}")
assert tmpl.variable_names == ["a", "b"]
def test_parse_rejects_oversized_template():
with pytest.raises(InvalidUriTemplate, match="maximum length"):
UriTemplate.parse("x" * 101, max_length=100)
def test_parse_rejects_too_many_variables():
template = "".join(f"{{v{i}}}" for i in range(11))
with pytest.raises(InvalidUriTemplate, match="maximum of 10 variables"):
UriTemplate.parse(template, max_variables=10)
def test_parse_counts_variables_not_expressions():
# A single {v0,v1,...} expression packs many variables under one
# brace pair. Counting expressions would miss this.
template = "{" + ",".join(f"v{i}" for i in range(11)) + "}"
with pytest.raises(InvalidUriTemplate, match="maximum of 10 variables"):
UriTemplate.parse(template, max_variables=10)
def test_parse_custom_limits_allow_larger():
template = "/".join(f"{{v{i}}}" for i in range(20))
tmpl = UriTemplate.parse(template, max_variables=20)
assert len(tmpl.variables) == 20
def test_equality_based_on_template_string():
a = UriTemplate.parse("file://{name}")
b = UriTemplate.parse("file://{name}")
c = UriTemplate.parse("file://{other}")
assert a == b
assert a != c
assert hash(a) == hash(b)
def test_frozen():
tmpl = UriTemplate.parse("{x}")
with pytest.raises(dataclasses.FrozenInstanceError):
tmpl.template = "changed" # type: ignore[misc]
@pytest.mark.parametrize(
("template", "variables", "expected"),
[
# Level 1: simple, encodes reserved chars
("{var}", {"var": "value"}, "value"),
("{var}", {"var": "hello world"}, "hello%20world"),
("{var}", {"var": "a/b"}, "a%2Fb"),
("file://docs/{name}", {"name": "readme.txt"}, "file://docs/readme.txt"),
# Level 2: reserved expansion keeps / ? # etc.
("{+var}", {"var": "a/b/c"}, "a/b/c"),
("{+var}", {"var": "a?b#c"}, "a?b#c"),
# RFC §3.2.3: reserved expansion passes through existing
# pct-triplets unchanged; bare % is still encoded.
("{+var}", {"var": "path%2Fto"}, "path%2Fto"),
("{+var}", {"var": "50%"}, "50%25"),
("{+var}", {"var": "50%2"}, "50%252"),
("{+var}", {"var": "a%2Fb%20c"}, "a%2Fb%20c"),
("{#var}", {"var": "a%2Fb"}, "#a%2Fb"),
# Simple expansion still encodes % unconditionally (triplet
# preservation is reserved-only).
("{var}", {"var": "path%2Fto"}, "path%252Fto"),
("file://docs/{+path}", {"path": "src/main.py"}, "file://docs/src/main.py"),
# Level 2: fragment
("{#var}", {"var": "section"}, "#section"),
("{#var}", {"var": "a/b"}, "#a/b"),
# Level 3: label
("file{.ext}", {"ext": "txt"}, "file.txt"),
# Level 3: path segment
("{/seg}", {"seg": "docs"}, "/docs"),
# Level 3: path-style param
("{;id}", {"id": "42"}, ";id=42"),
("{;id}", {"id": ""}, ";id"),
# Level 3: query
("{?q}", {"q": "search"}, "?q=search"),
("{?q}", {"q": ""}, "?q="),
("/search{?q,lang}", {"q": "mcp", "lang": "en"}, "/search?q=mcp&lang=en"),
# Level 3: query continuation
("?a=1{&b}", {"b": "2"}, "?a=1&b=2"),
# Multi-var in one expression
("{x,y}", {"x": "1", "y": "2"}, "1,2"),
# {+x,y} is rejected at parse time: each var in a + expression
# is multi-segment, and a template may only have one.
# Sequence values, non-explode (comma-join)
("{/list}", {"list": ["a", "b", "c"]}, "/a,b,c"),
("{?list}", {"list": ["a", "b"]}, "?list=a,b"),
# Explode: each item gets separator
("{/path*}", {"path": ["a", "b", "c"]}, "/a/b/c"),
("{.labels*}", {"labels": ["x", "y"]}, ".x.y"),
("{;keys*}", {"keys": ["a", "b"]}, ";keys=a;keys=b"),
# RFC §3.2.7 ifemp: ; omits = for empty explode items
("{;keys*}", {"keys": ["a", "", "b"]}, ";keys=a;keys;keys=b"),
# RFC §3.2.7 ifemp: ; omits = for empty, including non-explode list [""]
("{;name}", {"name": [""]}, ";name"),
("{;name}", {"name": ["", ""]}, ";name=,"),
("{?name}", {"name": [""]}, "?name="),
("{&name}", {"name": [""]}, "&name="),
("{;name}", {"name": ""}, ";name"),
# Undefined variables omitted
("{?q,page}", {"q": "x"}, "?q=x"),
("{a,b}", {"a": "x"}, "x"),
("{?page}", {}, ""),
# Empty sequence omitted
("{/path*}", {"path": []}, ""),
# Literal-only template
("file://static", {}, "file://static"),
],
)
def test_expand(template: str, variables: dict[str, str | list[str]], expected: str):
assert UriTemplate.parse(template).expand(variables) == expected
def test_expand_encodes_special_chars_in_simple():
t = UriTemplate.parse("{v}")
assert t.expand({"v": "a&b=c"}) == "a%26b%3Dc"
def test_expand_preserves_special_chars_in_reserved():
t = UriTemplate.parse("{+v}")
assert t.expand({"v": "a&b=c"}) == "a&b=c"
@pytest.mark.parametrize(
"value",
[42, None, 3.14, {"a": "b"}, ["ok", 42], b"bytes"],
)
def test_expand_rejects_invalid_value_types(value: object):
t = UriTemplate.parse("{v}")
with pytest.raises(TypeError, match="must be str or a sequence of str"):
t.expand({"v": value}) # type: ignore[dict-item]
@pytest.mark.parametrize(
("template", "uri", "expected"),
[
# Level 1: simple
("{var}", "hello", {"var": "hello"}),
("file://docs/{name}", "file://docs/readme.txt", {"name": "readme.txt"}),
("{a}/{b}", "foo/bar", {"a": "foo", "b": "bar"}),
# Level 2: reserved allows /
("file://docs/{+path}", "file://docs/src/main.py", {"path": "src/main.py"}),
("{+var}", "a/b/c", {"var": "a/b/c"}),
# Level 2: fragment
("page{#section}", "page#intro", {"section": "intro"}),
# A multi-segment var next to an operator that emits its own
# lead character: the lead ('.', '/', '#') is a literal anchor,
# so these are NOT two adjacent variables.
("{+path}{/name}", "a/b/c/readme", {"path": "a/b/c", "name": "readme"}),
("{+path}{.ext}", "src/main.py", {"path": "src/main", "ext": "py"}),
("prefix/{+path}{.ext}", "prefix/a/b.txt", {"path": "a/b", "ext": "txt"}),
("{#section}{/page}", "#intro/1", {"section": "intro", "page": "1"}),
# Bounded vars before the multi-segment var match lazily (first
# anchor); those after match greedily (last anchor).
("{owner}@{+path}", "alice@src/main", {"owner": "alice", "path": "src/main"}),
("{+path}@{name}", "src@main@v1", {"path": "src@main", "name": "v1"}),
# Level 3: label
("file{.ext}", "file.txt", {"ext": "txt"}),
# Level 3: path segment
("api{/version}", "api/v1", {"version": "v1"}),
# Level 3: path-style param
("item{;id}", "item;id=42", {"id": "42"}),
("item{;id}", "item;id", {"id": ""}),
# Explode: ; emits name=value per item, match strips the prefix
("item{;keys*}", "item;keys=a;keys=b", {"keys": ["a", "b"]}),
("item{;keys*}", "item;keys=a;keys;keys=b", {"keys": ["a", "", "b"]}),
("item{;keys*}", "item", {"keys": []}),
# Level 3: query. Lenient matching: partial, reordered, and
# extra params are all accepted. Absent params stay absent.
("search{?q}", "search?q=hello", {"q": "hello"}),
("search{?q}", "search?q=", {"q": ""}),
("search{?q}", "search", {}),
("search{?q,lang}", "search?q=mcp&lang=en", {"q": "mcp", "lang": "en"}),
("search{?q,lang}", "search?lang=en&q=mcp", {"q": "mcp", "lang": "en"}),
("search{?q,lang}", "search?q=mcp", {"q": "mcp"}),
("search{?q,lang}", "search", {}),
("search{?q}", "search?q=mcp&utm=x&ref=y", {"q": "mcp"}),
# URL-encoded query values are decoded
("search{?q}", "search?q=hello%20world", {"q": "hello world"}),
# + is a literal sub-delim per RFC 3986, not a space (form-encoding)
("search{?q}", "search?q=C++", {"q": "C++"}),
("search{?q}", "search?q=1.0+build.5", {"q": "1.0+build.5"}),
# Fragment is stripped before query parsing
("logs://{service}{?level}", "logs://api?level=error#section1", {"service": "api", "level": "error"}),
("search{?q}", "search#frag", {}),
# Multiple ?/& expressions collected together
("api{?v}{&page,limit}", "api?limit=10&v=2", {"v": "2", "limit": "10"}),
# Standalone {&var} falls through to the strict scan (expands
# with & prefix, no ? for lenient matching to split on)
("api{&page}", "api&page=2", {"page": "2"}),
# Literal ? in path portion falls through to the strict scan
("api?x{?page}", "api?x?page=2", {"page": "2"}),
# {#...} or literal # in path portion falls through: lenient
# matching would strip the fragment before the path scan sees it
("page{#section}{?q}", "page#intro?q=x", {"section": "intro", "q": "x"}),
("page#lit{?q}", "page#lit?q=x", {"q": "x"}),
# Empty & segments in query are skipped
("search{?q}", "search?&q=hello&", {"q": "hello"}),
# Duplicate query keys keep first value
("search{?q}", "search?q=first&q=second", {"q": "first"}),
# Percent-encoded parameter names are NOT decoded: RFC 6570
# expansion never encodes names, so an encoded name cannot be
# a legitimate match. Prevents HTTP parameter pollution.
("api://x{?token}", "api://x?%74oken=evil&token=real", {"token": "real"}),
("api://x{?token}", "api://x?%74oken=evil", {}),
# Level 3: query continuation with literal ? falls back to
# the strict scan (template-order, all-present required)
("?a=1{&b}", "?a=1&b=2", {"b": "2"}),
# Explode: path segments as list
("/files{/path*}", "/files/a/b/c", {"path": ["a", "b", "c"]}),
("/files{/path*}", "/files", {"path": []}),
("/files{/path*}/edit", "/files/a/b/edit", {"path": ["a", "b"]}),
# Explode: labels
("host{.labels*}", "host.example.com", {"labels": ["example", "com"]}),
# Repeated-slash literals preserved exactly
("///{a}////{b}////", "///x////y////", {"a": "x", "b": "y"}),
],
)
def test_match(template: str, uri: str, expected: dict[str, str | list[str]]):
assert UriTemplate.parse(template).match(uri) == expected
@pytest.mark.parametrize(
("template", "uri"),
[
("file://docs/{name}", "file://other/readme.txt"),
("{a}/{b}", "foo"),
("file{.ext}", "file"),
("static", "different"),
# Anchoring: trailing extra component must not match. Guards
# against a refactor from fullmatch() to match() or search().
("/users/{id}", "/users/123/extra"),
("/users/{id}/posts/{pid}", "/users/1/posts/2/extra"),
# Repeated-slash literal with wrong slash count
("///{a}////{b}////", "//x////y////"),
# ; name boundary: {;id} must not match a longer parameter name
("item{;id}", "item;identity=john"),
("item{;id}", "item;ident"),
# ; explode: wrong parameter name in any segment rejects the match
("item{;keys*}", "item;admin=true"),
("item{;keys*}", "item;keys=a;admin=true"),
# Lenient-query branch: path portion fails to match
("api/{name}{?q}", "wrong/path?q=x"),
# Lenient-query branch: ; explode name mismatch in path portion
("item{;keys*}{?q}", "item;wrong=x?q=1"),
],
)
def test_match_no_match(template: str, uri: str):
assert UriTemplate.parse(template).match(uri) is None
def test_match_explode_preserves_empty_list_items():
# Splitting the explode capture on its separator yields a leading
# empty item from the operator prefix; only that one is stripped.
# Subsequent empties are legitimate values from the input list.
t = UriTemplate.parse("{/path*}")
assert t.match("/a//c") == {"path": ["a", "", "c"]}
assert t.match("//a") == {"path": ["", "a"]}
assert t.match("/a/") == {"path": ["a", ""]}
t = UriTemplate.parse("host{.labels*}")
assert t.match("host.a..c") == {"labels": ["a", "", "c"]}
def test_match_adjacent_vars_disambiguated_by_literal():
# A literal between vars resolves the ambiguity.
t = UriTemplate.parse("{a}-{b}")
assert t.match("foo-bar") == {"a": "foo", "b": "bar"}
@pytest.mark.parametrize(
("template", "variables"),
[
# Leading literal appears inside the value: must anchor at
# position 0, not rfind to the rightmost occurrence.
("prefix-{id}", {"id": "prefix-123"}),
("u{s}", {"s": "xu"}),
("_{x}", {"x": "_"}),
("~{v}~", {"v": "~~~"}),
# Multi-occurrence with two vars: rfind correctly picks the
# rightmost literal BETWEEN vars, first literal anchors at 0.
("L{a}L{b}", {"a": "xLy", "b": "z"}),
# Leading literal with stop-char: earliest bound still applies.
("api/{name}", {"name": "api"}),
],
)
def test_match_leading_literal_appears_in_value(template: str, variables: dict[str, str]):
# Regression: the R->L scan used rfind for the preceding literal,
# which lands inside the value when the template's leading literal
# is a substring of the expanded value. The first atom must anchor
# at position 0, not search.
t = UriTemplate.parse(template)
uri = t.expand(variables)
assert t.match(uri) == variables
@pytest.mark.parametrize(
("template", "uri"),
[
# Greedy var whose suffix literal is absent from the input.
("{a}-{+b}x", "-" * 200),
# Chained anchors that all appear in input but suffix fails.
("{a}L{b}L{c}L{d}M", "L" * 200),
],
)
def test_match_no_backtracking_on_pathological_input(template: str, uri: str):
# These patterns caused O(n²) or worse backtracking under the regex
# matcher. The linear scan returns None without retrying splits.
# (Correctness check only; we benchmark separately to avoid flaky
# timing assertions in CI.)
assert UriTemplate.parse(template).match(uri) is None
@pytest.mark.parametrize(
("template", "uri"),
[
# Prefix literal mismatch before a greedy var
("file://{+path}", "http://x"),
# Suffix literal absent: the suffix scan fails before the prefix runs
("file://{+path}.txt", "file://x"),
# Prefix anchor not found: {a} needs '@' before greedy but none exists
("{a}@{+path}", "no-at-sign-here"),
# Prefix literal doesn't fit within suffix boundary
("foo{+a}oob", "fooob"),
# Greedy scalar contains its own stop-char ({+var} stops at ?)
("api://{+path}", "api://foo?bar"),
# Explode span doesn't start with its separator
("X{/path*}", "Xnoslash"),
# Explode body contains a non-separator stop-char
("X{/path*}", "X/a?b"),
# ifemp name continuation: the literal after {;key} doesn't start
# at pos and there's no '=', so the URI's name kept going.
("api{;key}suffix/{+p}", "api;keyZ/x"),
# Regression: suffix scan must not walk back into prefix territory.
# Input is shorter than prefix+suffix literals — these used to
# raise AssertionError instead of returning None.
("api://{+path}/{id}", "api://foo"),
("docs/{+path}/v/{name}", "docs/v/x"),
],
)
def test_match_greedy_rejection_paths(template: str, uri: str):
assert UriTemplate.parse(template).match(uri) is None
@pytest.mark.parametrize(
("template", "uri", "expected"),
[
# ifemp before a literal that itself starts with '=': the literal
# check runs first so '=' is not mistaken for the ifemp separator.
("api{;key}=base/{+path}", "api;key=base/a/b", {"key": "", "path": "a/b"}),
("api{;key}=base/{+path}", "api;key=v=base/x", {"key": "v", "path": "x"}),
],
)
def test_match_prefix_scan_edge_cases(template: str, uri: str, expected: dict[str, str]):
assert UriTemplate.parse(template).match(uri) == expected
@pytest.mark.parametrize(
("template", "uri", "expected"),
[
# Suffix-side ifemp: '=' inside the value is preserved — the
# value '=' is the first one after ;name, not the last.
("item{;id}", "item;id=a=b", {"id": "a=b"}),
("{;a}{;b}", ";a=x=y;b=z", {"a": "x=y", "b": "z"}),
],
)
def test_match_suffix_ifemp_equals_in_value(template: str, uri: str, expected: dict[str, str]):
assert UriTemplate.parse(template).match(uri) == expected
def test_match_prefix_ifemp_empty_before_non_stop_literal():
# Regression: _scan_prefix rejected the empty-value case when the
# following template literal starts with a non-stop-char. The
# name-continuation guard saw 'X' after ';key' and assumed the
# name continued, but 'X' is the template's next literal.
t = UriTemplate.parse("api{;key}X{+rest}")
# Non-empty round-trips fine:
assert t.match(t.expand({"key": "abc", "rest": "/tail"})) == {"key": "abc", "rest": "/tail"}
# Empty value (ifemp → bare ;key, then X) must also round-trip:
uri = t.expand({"key": "", "rest": "/tail"})
assert uri == "api;keyX/tail"
assert t.match(uri) == {"key": "", "rest": "/tail"}
# But an actual name continuation still rejects:
assert t.match("api;keyZX/tail") is None
def test_match_large_uri_against_greedy_template():
# Large payload against a greedy template — the scan visits each
# character once for the suffix anchor and once for the greedy
# validation, so this is O(n) not O(n²).
t = UriTemplate.parse("{+path}/end")
body = "seg/" * 15000
result = t.match(body + "end")
assert result == {"path": body[:-1]}
# And the failing case returns None without retrying splits.
assert t.match(body + "nope") is None
def test_match_decodes_percent_encoding():
t = UriTemplate.parse("file://docs/{name}")
assert t.match("file://docs/hello%20world.txt") == {"name": "hello world.txt"}
def test_match_escapes_template_literals():
# Regression: previous impl didn't escape . in literals, making it
# a regex wildcard. "fileXtxt" should NOT match "file.txt/{id}".
t = UriTemplate.parse("file.txt/{id}")
assert t.match("file.txt/42") == {"id": "42"}
assert t.match("fileXtxt/42") is None
@pytest.mark.parametrize(
("template", "uri", "expected"),
[
# Percent-encoded delimiters round-trip through match/expand.
# Path-safety validation belongs to ResourceSecurity, not here.
("file://docs/{name}", "file://docs/a%2Fb", {"name": "a/b"}),
("{var}", "a%3Fb", {"var": "a?b"}),
("{var}", "a%23b", {"var": "a#b"}),
("{var}", "a%26b", {"var": "a&b"}),
("file{.ext}", "file.a%2Eb", {"ext": "a.b"}),
("api{/v}", "api/a%2Fb", {"v": "a/b"}),
("search{?q}", "search?q=a%26b", {"q": "a&b"}),
("{;filter}", ";filter=a%3Bb", {"filter": "a;b"}),
],
)
def test_match_encoded_delimiters_roundtrip(template: str, uri: str, expected: dict[str, str]):
assert UriTemplate.parse(template).match(uri) == expected
def test_match_reserved_expansion_handles_slash():
# {+var} allows literal / (not just encoded)
t = UriTemplate.parse("{+path}")
assert t.match("a%2Fb") == {"path": "a/b"}
assert t.match("a/b") == {"path": "a/b"}
def test_match_double_encoding_decoded_once():
# %252F is %2F encoded again. Single decode gives "%2F" (a literal
# percent sign, a '2', and an 'F'). Guards against over-decoding.
t = UriTemplate.parse("file://docs/{name}")
assert t.match("file://docs/..%252Fetc") == {"name": "..%2Fetc"}
def test_match_rejects_oversized_uri():
t = UriTemplate.parse("{var}")
assert t.match("x" * 100, max_uri_length=50) is None
def test_match_accepts_uri_within_custom_limit():
t = UriTemplate.parse("{var}")
assert t.match("x" * 100, max_uri_length=200) == {"var": "x" * 100}
def test_match_default_uri_length_limit():
t = UriTemplate.parse("{+var}")
# Just at the limit: should match
assert t.match("x" * DEFAULT_MAX_URI_LENGTH) is not None
# One over: should reject
assert t.match("x" * (DEFAULT_MAX_URI_LENGTH + 1)) is None
def test_match_explode_encoded_separator_in_segment():
# An encoded separator inside a segment decodes as part of the value,
# not as a split point. The split happens at literal separators only.
t = UriTemplate.parse("/files{/path*}")
assert t.match("/files/a%2Fb/c") == {"path": ["a/b", "c"]}
@pytest.mark.parametrize(
("template", "variables"),
[
("{var}", {"var": "hello"}),
("file://docs/{name}", {"name": "readme.txt"}),
("file://docs/{+path}", {"path": "src/main.py"}),
("search{?q,lang}", {"q": "mcp", "lang": "en"}),
("file{.ext}", {"ext": "txt"}),
("/files{/path*}", {"path": ["a", "b", "c"]}),
("{var}", {"var": "hello world"}),
("item{;id}", {"id": "42"}),
("item{;id}", {"id": ""}),
# Defined-but-empty values still emit the operator prefix; match
# must accept the empty capture after it.
("page{#section}", {"section": ""}),
("file{.ext}", {"ext": ""}),
("api{/v}", {"v": ""}),
("x{name}y", {"name": ""}),
("item{;keys*}", {"keys": ["a", "b", "c"]}),
("item{;keys*}", {"keys": ["a", "", "b"]}),
# Empty strings in explode lists round-trip for unnamed operators
("{/path*}", {"path": ["a", "", "c"]}),
("{/path*}", {"path": ["", "a"]}),
("host{.labels*}", {"labels": ["a", "", "c"]}),
# Partial query expansion round-trips: expand omits undefined
# vars, match leaves them absent from the result.
("logs://{service}{?since,level}", {"service": "api"}),
("logs://{service}{?since,level}", {"service": "api", "since": "1h"}),
("logs://{service}{?since,level}", {"service": "api", "since": "1h", "level": "error"}),
("api{;key}=base/{+path}", {"key": "", "path": "a/b"}),
],
)
def test_roundtrip_expand_then_match(template: str, variables: dict[str, str | list[str]]):
t = UriTemplate.parse(template)
uri = t.expand(variables)
assert t.match(uri) == variables
def test_match_simple_var_accepts_empty() -> None:
# RFC 6570 §3.2.2: {var} with var="" expands to nothing, so the inverse
# must accept it. v1.x's [^/]+ regex did not — see migration guide.
t = UriTemplate.parse("tickets://{ticket_id}")
assert t.match("tickets://") == {"ticket_id": ""}
assert t.match("tickets://42") == {"ticket_id": "42"}
# --- Property tests over the generated template space ------------------------
#
# The two tests below generate template strings instead of enumerating
# examples, so the contracts they state are checked over the whole space
# `parse()` accepts in a single deterministic run. The generator deliberately
# produces strings the parser rejects (adjacent variables, two greedy
# variables, unsupported explode placements, a second `{?...}` expression) and
# relies on `parse()` to filter them: pre-selecting "known good" shapes would
# only ever exercise the shapes someone already thought of.
_PROPERTY_SEED = 20260626
_PROPERTY_OPERATORS = ["", "+", "#", ".", "/", ";", "?", "&"]
# Literal runs draw from URI punctuation (`- . / ~ _`) plus uppercase letters.
# Values draw only from lowercase letters and digits. The two alphabets are
# disjoint, so a round-trip failure can never be explained away as a value
# colliding with a literal, an operator prefix, or a separator.
_LITERAL_CHARS = "XY-._~/Z"
_VALUE_CHARS = string.ascii_lowercase + string.digits
_FUZZ_CHARS = string.printable
def _random_template(rng: random.Random) -> tuple[str, list[tuple[str, bool]]]:
"""Build a candidate template string plus the (name, explode) spec of each variable."""
parts: list[str] = []
specs: list[tuple[str, bool]] = []
for _ in range(rng.randint(1, 5)):
if rng.random() < 0.45:
parts.append("".join(rng.choice(_LITERAL_CHARS) for _ in range(rng.randint(1, 2))))
continue
operator = rng.choice(_PROPERTY_OPERATORS)
names: list[str] = []
# Multi-variable expressions and the explode modifier are produced for
# every operator; `parse()` rejects the combinations it does not allow.
for _ in range(2 if rng.random() < 0.2 else 1):
name = f"v{len(specs)}"
explode = rng.random() < 0.25
specs.append((name, explode))
names.append(f"{name}*" if explode else name)
parts.append("{" + operator + ",".join(names) + "}")
return "".join(parts), specs
def _random_value(rng: random.Random) -> str:
"""Draw a short (possibly empty) value from the literal-disjoint alphabet."""
return "".join(rng.choice(_VALUE_CHARS) for _ in range(rng.randint(0, 4)))
def _random_values(specs: list[tuple[str, bool]], rng: random.Random) -> dict[str, str | list[str]]:
"""Draw a value for every variable: a string, or a non-empty list for explode variables."""
return {
name: [_random_value(rng) for _ in range(rng.randint(1, 3))] if explode else _random_value(rng)
for name, explode in specs
}
def _mangled_inputs(uri: str, rng: random.Random) -> list[str]:
"""Mangle one expansion into a batch of candidate inputs for `match()`."""
candidates = [uri, "", uri[::-1], uri * 2]
for _ in range(6):
chars = list(uri)
mutation = rng.randint(0, 2)
if mutation == 0 and chars:
del chars[rng.randrange(len(chars))]
elif mutation == 1:
chars.insert(rng.randint(0, len(chars)), rng.choice(_FUZZ_CHARS))
elif chars:
chars[rng.randrange(len(chars))] = rng.choice(_FUZZ_CHARS)
candidates.append("".join(chars))
candidates.extend("".join(rng.choice(_FUZZ_CHARS) for _ in range(rng.randint(0, 30))) for _ in range(3))
return candidates
def test_match_inverts_expand_for_every_parseable_template() -> None:
"""For every template the parser accepts, matching the template's own expansion
yields a value set that re-expands to the same URI.
Exact equality with the original values is not required: a different
pre-image (e.g. an explode list that flattens) is a correct answer as long
as it re-expands identically. SDK-defined contract — RFC 6570 specifies
only expansion, so `match()` is the inverse the SDK promises.
"""
rng = random.Random(_PROPERTY_SEED)
accepted = 0
for _ in range(600):
template, specs = _random_template(rng)
try:
t = UriTemplate.parse(template)
except InvalidUriTemplate:
continue
accepted += 1
for _ in range(2):
values = _random_values(specs, rng)
uri = t.expand(values)
got = t.match(uri)
assert got is not None, f"{template!r} did not match its own expansion {uri!r} of {values!r}"
assert t.expand(got) == uri, f"{template!r}: match({uri!r}) -> {got!r}, which re-expands differently"
# Floor the accepted count so the property can never go vacuous: a future
# change that rejects every generated template would otherwise pass silently.
assert accepted >= 150
def test_match_never_raises() -> None:
"""`match()` returns a dict or None for every input string; it never raises.
Each accepted template's own expansion is mangled (a character inserted,
deleted, or replaced from a wide printable alphabet; emptied; reversed;
doubled) alongside fully random strings. SDK-defined contract — a URI that
does not fit the template is a non-match, not an error.
"""
rng = random.Random(_PROPERTY_SEED)
calls = 0
for _ in range(600):
template, specs = _random_template(rng)
try:
t = UriTemplate.parse(template)
except InvalidUriTemplate:
continue
uri = t.expand(_random_values(specs, rng))
for candidate in _mangled_inputs(uri, rng):
result = t.match(candidate)
assert result is None or isinstance(result, dict), f"{template!r}: match({candidate!r}) -> {result!r}"
calls += 1
# Floor the call count so the property can never go vacuous: a future
# change that rejects every generated template would otherwise pass silently.
assert calls >= 4000