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

1import typing as t 

2from collections.abc import Sequence 

3from dataclasses import dataclass 

4from string.templatelib import Interpolation, Template 

5 

6 

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) 

16 

17 

18@dataclass(slots=True, frozen=True) 

19class TemplateRef: 

20 """Template strings whose interpolations are supplied by another template.""" 

21 

22 strings: tuple[str, ...] 

23 """Static string parts of the original string.templatelib.Template""" 

24 

25 i_start: int = 0 

26 """Index of the first interpolation in the original template.""" 

27 

28 @property 

29 def i_count(self) -> int: 

30 """Number of interpolations referenced by this template.""" 

31 return len(self.strings) - 1 

32 

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 

37 

38 @property 

39 def is_literal(self) -> bool: 

40 """Return True if there are no interpolations.""" 

41 return self.i_count == 0 

42 

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] == "" 

47 

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 == ("", "") 

52 

53 @classmethod 

54 def literal(cls, s: str) -> t.Self: 

55 return cls((s,)) 

56 

57 @classmethod 

58 def empty(cls) -> t.Self: 

59 return cls.literal("") 

60 

61 @classmethod 

62 def singleton(cls, i_index: int) -> t.Self: 

63 return cls(("", ""), i_index) 

64 

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.") 

70 

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 

81 

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.") 

90 

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 ) 

99 

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 ) 

105 

106 

107@dataclass(slots=True, frozen=True, order=True) 

108class PartPosition: 

109 """ 

110 A position relative to one of a template's static strings. 

111 

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 """ 

116 

117 s_index: int 

118 """Index of the static string relative to which the position is measured.""" 

119 

120 offset: int = 0 

121 """Offset from the start of the static string.""" 

122 

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.") 

129 

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 ) 

143 

144 

145@dataclass(slots=True, frozen=True) 

146class TemplateSpan: 

147 """A half-open span in the static-string coordinates of a template.""" 

148 

149 start: PartPosition 

150 stop: PartPosition 

151 

152 def __post_init__(self) -> None: 

153 if self.start > self.stop: 

154 raise ValueError("TemplateSpan start must not be after stop.") 

155 

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) 

160 

161 first_string = self.start.s_index 

162 last_string = self.stop.s_index 

163 strings = list(source.strings[first_string : last_string + 1]) 

164 

165 strings[-1] = strings[-1][: self.stop.offset] 

166 strings[0] = strings[0][self.start.offset :] 

167 

168 return template_from_parts( 

169 strings, source.interpolations[first_string:last_string] 

170 )