Coverage for tdom/template_utils.py: 100%
88 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 21:37 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 21:37 +0000
1import typing as t
2from collections.abc import Sequence
3from dataclasses import dataclass
4from string.templatelib import Interpolation, Template
7def template_from_parts(
8 strings: Sequence[str], interpolations: Sequence[Interpolation]
9) -> Template:
10 """Construct a template string from the given strings and parts."""
11 assert len(strings) == len(interpolations) + 1, (
12 "A template must have one more string than interpolations."
13 )
14 flat = [x for pair in zip(strings, interpolations) for x in pair] + [strings[-1]]
15 return Template(*flat)
18@dataclass(slots=True, frozen=True)
19class TemplateRef:
20 """Template strings whose interpolations are supplied by another template."""
22 strings: tuple[str, ...]
23 """Static string parts of the original string.templatelib.Template"""
25 i_start: int = 0
26 """Index of the first interpolation in the original template."""
28 @property
29 def i_count(self) -> int:
30 """Number of interpolations referenced by this template."""
31 return len(self.strings) - 1
33 @property
34 def i_stop(self) -> int:
35 """Exclusive stop index of the interpolations in the original template."""
36 return self.i_start + self.i_count
38 @property
39 def is_literal(self) -> bool:
40 """Return True if there are no interpolations."""
41 return self.i_count == 0
43 @property
44 def is_empty(self) -> bool:
45 """Return True if the template is empty."""
46 return self.is_literal and self.strings[0] == ""
48 @property
49 def is_singleton(self) -> bool:
50 """Return True if there is exactly one interpolation and no other content."""
51 return self.strings == ("", "")
53 @classmethod
54 def literal(cls, s: str) -> t.Self:
55 return cls((s,))
57 @classmethod
58 def empty(cls) -> t.Self:
59 return cls.literal("")
61 @classmethod
62 def singleton(cls, i_index: int) -> t.Self:
63 return cls(("", ""), i_index)
65 def __post_init__(self) -> None:
66 if not self.strings:
67 raise ValueError("TemplateRef must have at least one string.")
68 if self.is_literal and self.i_start != 0:
69 raise ValueError("Literal TemplateRef instances must have i_start 0.")
71 def __iter__(self):
72 index = 0
73 last_s_index = len(self.strings) - 1
74 while index <= last_s_index:
75 s = self.strings[index]
76 if s:
77 yield s
78 if index < last_s_index:
79 yield self.i_start + index
80 index += 1
82 def concat(self, other: TemplateRef) -> TemplateRef:
83 """Join two adjacent template references."""
84 if (
85 not self.is_literal
86 and not other.is_literal
87 and self.i_stop != other.i_start
88 ):
89 raise ValueError("TemplateRef interpolation ranges must be contiguous.")
91 return TemplateRef(
92 strings=(
93 *self.strings[:-1],
94 self.strings[-1] + other.strings[0],
95 *other.strings[1:],
96 ),
97 i_start=other.i_start if self.is_literal else self.i_start,
98 )
100 def bind(self, interpolations: Sequence[Interpolation]) -> Template:
101 """Bind this reference's strings to the corresponding interpolations."""
102 return template_from_parts(
103 self.strings, interpolations[self.i_start : self.i_stop]
104 )
107@dataclass(slots=True, frozen=True, order=True)
108class PartPosition:
109 """
110 A position relative to one of a template's static strings.
112 The end of a string is immediately before its following interpolation, and
113 the start of the next string is immediately after that interpolation. Thus a
114 span between those positions contains exactly that atomic interpolation.
115 """
117 s_index: int
118 """Index of the static string relative to which the position is measured."""
120 offset: int = 0
121 """Offset from the start of the static string."""
123 def __post_init__(self) -> None:
124 """Validate invariants independent of a particular template."""
125 if self.s_index < 0:
126 raise ValueError("String index must always be positive or zero.")
127 if self.offset < 0:
128 raise ValueError("Offset must always be positive or zero.")
130 def validate(self, source: Template) -> None:
131 """Raise if this position falls outside the source template."""
132 if self.s_index >= len(source.strings):
133 raise ValueError(
134 "PartPosition string index falls outside the template: "
135 f"{self.s_index} >= {len(source.strings)}."
136 )
137 string = source.strings[self.s_index]
138 if self.offset > len(string):
139 raise ValueError(
140 "PartPosition offset falls outside its string: "
141 f"{self.offset} > {len(string)}."
142 )
145@dataclass(slots=True, frozen=True)
146class TemplateSpan:
147 """A half-open span in the static-string coordinates of a template."""
149 start: PartPosition
150 stop: PartPosition
152 def __post_init__(self) -> None:
153 if self.start > self.stop:
154 raise ValueError("TemplateSpan start must not be after stop.")
156 def extract(self, source: Template) -> Template:
157 """Extract this span from a structurally compatible template."""
158 self.start.validate(source)
159 self.stop.validate(source)
161 first_string = self.start.s_index
162 last_string = self.stop.s_index
163 strings = list(source.strings[first_string : last_string + 1])
165 strings[-1] = strings[-1][: self.stop.offset]
166 strings[0] = strings[0][self.start.offset :]
168 return template_from_parts(
169 strings, source.interpolations[first_string:last_string]
170 )