Skip to content

Commit 0cd8b4c

Browse files
committed
TemplateStr
1 parent 782e270 commit 0cd8b4c

File tree

1 file changed

+292
-0
lines changed

1 file changed

+292
-0
lines changed

src/python_minifier/t_string.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
"""
2+
Template String (T-String) unparsing
3+
4+
T-strings in Python 3.14 follow PEP 750 and are based on PEP 701,
5+
which means they don't have the quote restrictions of older f-strings.
6+
7+
This implementation is much simpler than f_string.py because:
8+
- No quote tracking needed (PEP 701 benefits)
9+
- No pep701 parameter needed (always true for t-strings)
10+
- No Outer vs Inner distinction needed
11+
- Always use all quote types
12+
"""
13+
14+
import python_minifier.ast_compat as ast
15+
16+
from python_minifier import UnstableMinification
17+
from python_minifier.ast_compare import CompareError, compare_ast
18+
from python_minifier.expression_printer import ExpressionPrinter
19+
from python_minifier.ministring import MiniString
20+
from python_minifier.token_printer import TokenTypes
21+
from python_minifier.util import is_constant_node
22+
23+
24+
class TString(object):
25+
"""
26+
A Template String (t-string)
27+
28+
Much simpler than f-strings because PEP 701 eliminates quote restrictions
29+
"""
30+
31+
def __init__(self, node):
32+
assert isinstance(node, ast.TemplateStr)
33+
self.node = node
34+
# Always use all quotes - no restrictions due to PEP 701
35+
self.allowed_quotes = ['"', "'", '"""', "'''"]
36+
37+
def is_correct_ast(self, code):
38+
"""Check if the generated code produces the same AST"""
39+
try:
40+
c = ast.parse(code, 'TString candidate', mode='eval')
41+
compare_ast(self.node, c.body)
42+
return True
43+
except Exception:
44+
return False
45+
46+
def complete_debug_specifier(self, partial_specifier_candidates, value_node):
47+
"""Complete debug specifier candidates for an Interpolation node"""
48+
assert isinstance(value_node, ast.Interpolation)
49+
50+
conversion = ''
51+
if value_node.conversion == 115: # 's'
52+
conversion = '!s'
53+
elif value_node.conversion == 114 and value_node.format_spec is not None:
54+
# This is the default for debug specifiers, unless there's a format_spec
55+
conversion = '!r'
56+
elif value_node.conversion == 97: # 'a'
57+
conversion = '!a'
58+
59+
conversion_candidates = [x + conversion for x in partial_specifier_candidates]
60+
61+
if value_node.format_spec is not None:
62+
# Handle format specifications in debug specifiers
63+
if isinstance(value_node.format_spec, ast.JoinedStr):
64+
import python_minifier.f_string
65+
format_specs = python_minifier.f_string.FormatSpec(value_node.format_spec, self.allowed_quotes, pep701=True).candidates()
66+
conversion_candidates = [c + ':' + fs for c in conversion_candidates for fs in format_specs]
67+
68+
return [x + '}' for x in conversion_candidates]
69+
70+
def candidates(self):
71+
"""Generate all possible representations"""
72+
actual_candidates = []
73+
74+
for quote in self.allowed_quotes:
75+
candidates = ['']
76+
debug_specifier_candidates = []
77+
78+
for v in self.node.values:
79+
if is_constant_node(v, ast.Constant) and isinstance(v.value, str):
80+
# String literal part - check for debug specifiers
81+
82+
# Could this be used as a debug specifier?
83+
if len(candidates) < 10:
84+
import re
85+
debug_specifier = re.match(r'.*=\s*$', v.value)
86+
if debug_specifier:
87+
# Maybe! Save for potential debug specifier completion
88+
try:
89+
debug_specifier_candidates = [x + '{' + v.value for x in candidates]
90+
except Exception:
91+
continue
92+
93+
try:
94+
candidates = [x + self.str_for(v.value, quote) for x in candidates]
95+
except Exception:
96+
continue
97+
98+
elif isinstance(v, ast.Interpolation):
99+
# Interpolated expression part - check for debug completion
100+
try:
101+
# Try debug specifier completion
102+
completed = self.complete_debug_specifier(debug_specifier_candidates, v)
103+
104+
# Regular interpolation processing
105+
interpolation_candidates = InterpolationValue(v).get_candidates()
106+
candidates = [x + y for x in candidates for y in interpolation_candidates] + completed
107+
108+
debug_specifier_candidates = []
109+
except Exception:
110+
continue
111+
else:
112+
raise RuntimeError('Unexpected TemplateStr value: %r' % v)
113+
114+
actual_candidates.extend(['t' + quote + x + quote for x in candidates])
115+
116+
return filter(self.is_correct_ast, actual_candidates)
117+
118+
def str_for(self, s, quote):
119+
"""Convert string literal to properly escaped form"""
120+
# Use MiniString for optimal string representation
121+
# Always allowed due to PEP 701 - no backslash restrictions
122+
mini_s = str(MiniString(s, quote)).replace('{', '{{').replace('}', '}}')
123+
124+
if mini_s == '':
125+
return '\\\n'
126+
return mini_s
127+
128+
def __str__(self):
129+
"""Generate the shortest valid t-string representation"""
130+
if len(self.node.values) == 0:
131+
return 't' + min(self.allowed_quotes, key=len) * 2
132+
133+
candidates = list(self.candidates())
134+
135+
# Validate all candidates
136+
for candidate in candidates:
137+
try:
138+
minified_t_string = ast.parse(candidate, 'python_minifier.t_string output', mode='eval').body
139+
except SyntaxError as syntax_error:
140+
raise UnstableMinification(syntax_error, '', candidate)
141+
142+
try:
143+
compare_ast(self.node, minified_t_string)
144+
except CompareError as compare_error:
145+
raise UnstableMinification(compare_error, '', candidate)
146+
147+
if not candidates:
148+
raise ValueError('Unable to create representation for t-string')
149+
150+
return min(candidates, key=len)
151+
152+
153+
class InterpolationValue(ExpressionPrinter):
154+
"""
155+
A Template String Interpolation Part
156+
157+
Handles ast.Interpolation nodes (equivalent to FormattedValue for f-strings)
158+
"""
159+
160+
def __init__(self, node):
161+
super(InterpolationValue, self).__init__()
162+
163+
assert isinstance(node, ast.Interpolation)
164+
self.node = node
165+
# Always use all quotes - no restrictions due to PEP 701
166+
self.allowed_quotes = ['"', "'", '"""', "'''"]
167+
self.candidates = ['']
168+
169+
def get_candidates(self):
170+
"""Generate all possible representations of this interpolation"""
171+
172+
self.printer.delimiter('{')
173+
174+
if self.is_curly(self.node.value):
175+
self.printer.delimiter(' ')
176+
177+
self._expression(self.node.value)
178+
179+
# Handle conversion specifiers
180+
if self.node.conversion == 115: # 's'
181+
self.printer.append('!s', TokenTypes.Delimiter)
182+
elif self.node.conversion == 114: # 'r'
183+
self.printer.append('!r', TokenTypes.Delimiter)
184+
elif self.node.conversion == 97: # 'a'
185+
self.printer.append('!a', TokenTypes.Delimiter)
186+
187+
# Handle format specifications
188+
if self.node.format_spec is not None:
189+
self.printer.delimiter(':')
190+
191+
# Format spec is a JoinedStr (f-string) in the AST
192+
if isinstance(self.node.format_spec, ast.JoinedStr):
193+
import python_minifier.f_string
194+
# Use f-string processing for format specs
195+
format_candidates = python_minifier.f_string.OuterFString(
196+
self.node.format_spec, pep701=True
197+
).candidates()
198+
# Remove the f prefix and quotes to get just the format part
199+
format_parts = []
200+
for fmt in format_candidates:
201+
if fmt.startswith('f'):
202+
# Remove f prefix and outer quotes
203+
inner = fmt[1:]
204+
if (inner.startswith('"') and inner.endswith('"')) or \
205+
(inner.startswith("'") and inner.endswith("'")):
206+
format_parts.append(inner[1:-1])
207+
elif (inner.startswith('"""') and inner.endswith('"""')) or \
208+
(inner.startswith("'''") and inner.endswith("'''")):
209+
format_parts.append(inner[3:-3])
210+
else:
211+
format_parts.append(inner)
212+
213+
if format_parts:
214+
self._append(format_parts)
215+
else:
216+
# Simple constant format spec
217+
self.printer.append(str(self.node.format_spec), TokenTypes.Delimiter)
218+
219+
self.printer.delimiter('}')
220+
221+
self._finalize()
222+
return self.candidates
223+
224+
def is_curly(self, node):
225+
"""Check if expression starts with curly braces (needs space)"""
226+
if isinstance(node, (ast.SetComp, ast.DictComp, ast.Set, ast.Dict)):
227+
return True
228+
229+
if isinstance(node, (ast.Expr, ast.Attribute, ast.Subscript)):
230+
return self.is_curly(node.value)
231+
232+
if isinstance(node, (ast.Compare, ast.BinOp)):
233+
return self.is_curly(node.left)
234+
235+
if isinstance(node, ast.Call):
236+
return self.is_curly(node.func)
237+
238+
if isinstance(node, ast.BoolOp):
239+
return self.is_curly(node.values[0])
240+
241+
if isinstance(node, ast.IfExp):
242+
return self.is_curly(node.body)
243+
244+
return False
245+
246+
def visit_Constant(self, node):
247+
"""Handle constant values in interpolations"""
248+
if isinstance(node.value, str):
249+
# Use Str class from f_string module for string handling
250+
from python_minifier.f_string import Str
251+
self.printer.append(str(Str(node.value, self.allowed_quotes, pep701=True)), TokenTypes.NonNumberLiteral)
252+
elif isinstance(node.value, bytes):
253+
# Use Bytes class from f_string module for bytes handling
254+
from python_minifier.f_string import Bytes
255+
self.printer.append(str(Bytes(node.value, self.allowed_quotes)), TokenTypes.NonNumberLiteral)
256+
else:
257+
# Other constants (numbers, None, etc.)
258+
super().visit_Constant(node)
259+
260+
def visit_TemplateStr(self, node):
261+
"""Handle nested t-strings"""
262+
assert isinstance(node, ast.TemplateStr)
263+
if self.printer.previous_token in [TokenTypes.Identifier, TokenTypes.Keyword, TokenTypes.SoftKeyword]:
264+
self.printer.delimiter(' ')
265+
# Nested t-string - no quote restrictions due to PEP 701
266+
self._append(TString(node).candidates())
267+
268+
def visit_JoinedStr(self, node):
269+
"""Handle nested f-strings in t-strings"""
270+
assert isinstance(node, ast.JoinedStr)
271+
if self.printer.previous_token in [TokenTypes.Identifier, TokenTypes.Keyword, TokenTypes.SoftKeyword]:
272+
self.printer.delimiter(' ')
273+
274+
import python_minifier.f_string
275+
# F-strings nested in t-strings also benefit from PEP 701
276+
self._append(python_minifier.f_string.OuterFString(node, pep701=True).candidates())
277+
278+
def visit_Lambda(self, node):
279+
"""Handle lambda expressions in interpolations"""
280+
self.printer.delimiter('(')
281+
super().visit_Lambda(node)
282+
self.printer.delimiter(')')
283+
284+
def _finalize(self):
285+
"""Finalize the current printer state"""
286+
self.candidates = [x + str(self.printer) for x in self.candidates]
287+
self.printer._code = ''
288+
289+
def _append(self, candidates):
290+
"""Append multiple candidate strings"""
291+
self._finalize()
292+
self.candidates = [x + y for x in self.candidates for y in candidates]

0 commit comments

Comments
 (0)