Skip to content

Commit 278e5e7

Browse files
committed
refactor: use lists instead of tuples for variable-length sequences
Replaces tuple[X, ...] with list[X] throughout UriTemplate internals and public API. The tuples were defensive immutability nobody needed: the dataclass fields are compare=False so they do not participate in hash/eq, and the public properties now return fresh copies so callers cannot mutate internal state. Helper function parameters take Sequence[X] where they only iterate; returns are concrete list[X]. The only remaining tuples are the fixed-arity (pair) return types on _parse and _split_query_tail, which is the correct use of tuple.
1 parent 80c7934 commit 278e5e7

File tree

2 files changed

+30
-32
lines changed

2 files changed

+30
-32
lines changed

src/mcp/shared/uri_template.py

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,12 @@ class Variable:
121121
explode: bool = False
122122

123123

124-
@dataclass(frozen=True)
124+
@dataclass
125125
class _Expression:
126126
"""A parsed ``{...}`` expression: one operator, one or more variables."""
127127

128128
operator: Operator
129-
variables: tuple[Variable, ...]
129+
variables: list[Variable]
130130

131131

132132
_Part = str | _Expression
@@ -236,11 +236,11 @@ class UriTemplate:
236236
"""
237237

238238
template: str
239-
_parts: tuple[_Part, ...] = field(repr=False, compare=False)
240-
_variables: tuple[Variable, ...] = field(repr=False, compare=False)
239+
_parts: list[_Part] = field(repr=False, compare=False)
240+
_variables: list[Variable] = field(repr=False, compare=False)
241241
_pattern: re.Pattern[str] = field(repr=False, compare=False)
242-
_path_variables: tuple[Variable, ...] = field(repr=False, compare=False)
243-
_query_variables: tuple[Variable, ...] = field(repr=False, compare=False)
242+
_path_variables: list[Variable] = field(repr=False, compare=False)
243+
_query_variables: list[Variable] = field(repr=False, compare=False)
244244

245245
@staticmethod
246246
def is_template(value: str) -> bool:
@@ -311,14 +311,14 @@ def parse(
311311
)
312312

313313
@property
314-
def variables(self) -> tuple[Variable, ...]:
314+
def variables(self) -> list[Variable]:
315315
"""All variables in the template, in order of appearance."""
316-
return self._variables
316+
return list(self._variables)
317317

318318
@property
319-
def variable_names(self) -> tuple[str, ...]:
319+
def variable_names(self) -> list[str]:
320320
"""All variable names in the template, in order of appearance."""
321-
return tuple(v.name for v in self._variables)
321+
return [v.name for v in self._variables]
322322

323323
def expand(self, variables: Mapping[str, str | Sequence[str]]) -> str:
324324
"""Expand the template by substituting variable values.
@@ -465,7 +465,7 @@ def __str__(self) -> str:
465465
return self.template
466466

467467

468-
def _extract_path(m: re.Match[str], variables: tuple[Variable, ...]) -> dict[str, str | list[str]] | None:
468+
def _extract_path(m: re.Match[str], variables: Sequence[Variable]) -> dict[str, str | list[str]] | None:
469469
"""Decode regex capture groups into a variable-name mapping.
470470
471471
Handles scalar and explode variables. Named explode (``;``) strips
@@ -506,9 +506,7 @@ def _extract_path(m: re.Match[str], variables: tuple[Variable, ...]) -> dict[str
506506
return result
507507

508508

509-
def _split_query_tail(
510-
parts: tuple[_Part, ...],
511-
) -> tuple[tuple[_Part, ...], tuple[Variable, ...]]:
509+
def _split_query_tail(parts: list[_Part]) -> tuple[list[_Part], list[Variable]]:
512510
"""Separate trailing ``?``/``&`` expressions from the path portion.
513511
514512
Lenient query matching (order-agnostic, partial, ignores extras)
@@ -532,23 +530,23 @@ def _split_query_tail(
532530
break
533531

534532
if split == len(parts):
535-
return parts, ()
533+
return parts, []
536534

537535
# If the path portion contains a literal ?, the URI's ? won't align
538536
# with our template split. Fall back to strict regex.
539537
for part in parts[:split]:
540538
if isinstance(part, str) and "?" in part:
541-
return parts, ()
539+
return parts, []
542540

543541
query_vars: list[Variable] = []
544542
for part in parts[split:]:
545543
assert isinstance(part, _Expression)
546544
query_vars.extend(part.variables)
547545

548-
return parts[:split], tuple(query_vars)
546+
return parts[:split], query_vars
549547

550548

551-
def _build_pattern(parts: tuple[_Part, ...]) -> re.Pattern[str]:
549+
def _build_pattern(parts: Sequence[_Part]) -> re.Pattern[str]:
552550
"""Compile a regex that matches URIs produced by this template.
553551
554552
Walks parts in order: literals are ``re.escape``'d, expressions
@@ -606,7 +604,7 @@ def _expression_pattern(expr: _Expression) -> str:
606604
return "".join(pieces)
607605

608606

609-
def _parse(template: str, *, max_expressions: int) -> tuple[tuple[_Part, ...], tuple[Variable, ...]]:
607+
def _parse(template: str, *, max_expressions: int) -> tuple[list[_Part], list[Variable]]:
610608
"""Split a template into an ordered sequence of literals and expressions.
611609
612610
Walks the string, alternating between collecting literal runs and
@@ -663,7 +661,7 @@ def _parse(template: str, *, max_expressions: int) -> tuple[tuple[_Part, ...], t
663661

664662
_check_adjacent_explodes(template, parts)
665663
_check_duplicate_variables(template, variables)
666-
return tuple(parts), tuple(variables)
664+
return parts, variables
667665

668666

669667
def _parse_expression(template: str, body: str, pos: int) -> _Expression:
@@ -730,7 +728,7 @@ def _parse_expression(template: str, body: str, pos: int) -> _Expression:
730728

731729
variables.append(Variable(name=name, operator=operator, explode=explode))
732730

733-
return _Expression(operator=operator, variables=tuple(variables))
731+
return _Expression(operator=operator, variables=variables)
734732

735733

736734
def _check_duplicate_variables(template: str, variables: list[Variable]) -> None:

tests/shared/test_uri_template.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
def test_parse_literal_only():
99
tmpl = UriTemplate.parse("file://docs/readme.txt")
10-
assert tmpl.variables == ()
11-
assert tmpl.variable_names == ()
10+
assert tmpl.variables == []
11+
assert tmpl.variable_names == []
1212
assert str(tmpl) == "file://docs/readme.txt"
1313

1414

@@ -32,8 +32,8 @@ def test_is_template(value: str, expected: bool):
3232

3333
def test_parse_simple_variable():
3434
tmpl = UriTemplate.parse("file://docs/{name}")
35-
assert tmpl.variables == (Variable(name="name", operator=""),)
36-
assert tmpl.variable_names == ("name",)
35+
assert tmpl.variables == [Variable(name="name", operator="")]
36+
assert tmpl.variable_names == ["name"]
3737

3838

3939
@pytest.mark.parametrize(
@@ -57,13 +57,13 @@ def test_parse_all_operators(template: str, operator: str):
5757

5858
def test_parse_multiple_variables_in_expression():
5959
tmpl = UriTemplate.parse("{?q,lang,page}")
60-
assert tmpl.variable_names == ("q", "lang", "page")
60+
assert tmpl.variable_names == ["q", "lang", "page"]
6161
assert all(v.operator == "?" for v in tmpl.variables)
6262

6363

6464
def test_parse_multiple_expressions():
6565
tmpl = UriTemplate.parse("db://{table}/{id}{?format}")
66-
assert tmpl.variable_names == ("table", "id", "format")
66+
assert tmpl.variable_names == ["table", "id", "format"]
6767
ops = [v.operator for v in tmpl.variables]
6868
assert ops == ["", "", "?"]
6969

@@ -84,15 +84,15 @@ def test_parse_explode_supported_operators(template: str):
8484

8585
def test_parse_mixed_explode_and_plain():
8686
tmpl = UriTemplate.parse("{/path*}{?q}")
87-
assert tmpl.variables == (
87+
assert tmpl.variables == [
8888
Variable(name="path", operator="/", explode=True),
8989
Variable(name="q", operator="?"),
90-
)
90+
]
9191

9292

9393
def test_parse_varname_with_dots_and_underscores():
9494
tmpl = UriTemplate.parse("{foo_bar.baz}")
95-
assert tmpl.variable_names == ("foo_bar.baz",)
95+
assert tmpl.variable_names == ["foo_bar.baz"]
9696

9797

9898
def test_parse_rejects_unclosed_expression():
@@ -131,7 +131,7 @@ def test_parse_rejects_invalid_varname(name: str):
131131

132132
def test_parse_accepts_dotted_varname():
133133
t = UriTemplate.parse("{a.b.c}")
134-
assert t.variable_names == ("a.b.c",)
134+
assert t.variable_names == ["a.b.c"]
135135

136136

137137
def test_parse_rejects_empty_spec_in_list():
@@ -222,7 +222,7 @@ def test_parse_treats_stray_close_brace_as_literal(template: str):
222222

223223
def test_parse_stray_close_brace_between_expressions():
224224
tmpl = UriTemplate.parse("{a}}{b}")
225-
assert tmpl.variable_names == ("a", "b")
225+
assert tmpl.variable_names == ["a", "b"]
226226

227227

228228
def test_parse_allows_explode_separated_by_literal():

0 commit comments

Comments
 (0)