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