Skip to content

Commit 6c07aaf

Browse files
authored
Merge pull request #2698 from scauligi/tstrings
Templated strings
2 parents 9c5a735 + 1580ca7 commit 6c07aaf

13 files changed

Lines changed: 227 additions & 53 deletions

File tree

NEWS.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Supports Python 3.x – Python 3.y
1010
New Features
1111
------------------------------
1212
* ``hyc`` now supports ``-q``/``--quiet`` to suppress progress messages.
13+
* Added support for t-strings from Python 3.14.
14+
* Added new pragma `bracketed-templates` to allow parsing `#[t[...]t]` and `#[t-<ident>[...]t-<ident>]` as template strings (analogous to bracketed f-strings).
1315

1416
Bug Fixes
1517
------------------------------
@@ -24,6 +26,7 @@ Bug Fixes
2426
and Complex models.
2527
* Fixed an importlib exception when using `hy.eval` on code that
2628
required other modules.
29+
* Invalid conversion chars in f-strings now properly raise a syntax error.
2730

2831
1.2.0 ("Crackers and Snacks", released 2026-01-14)
2932
======================================================================

docs/api.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ Fundamentals
187187
dangerous, because other macros may call your new macro when they meant to
188188
refer to the core macro.
189189

190+
.. _bracketed-templates:
191+
192+
- ``:bracketed-templates``: If set, then :ref:`bracket strings
193+
<bracket-strings>` using the delimiter "t" or any delimiter starting with
194+
"t-" are parsed as :ref:`template strings <syntax-tstrings>`.
195+
190196
Quoting
191197
~~~~~~~~~~~~
192198

docs/syntax.rst

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ The result is the model type :class:`String <hy.models.String>`.
347347
You may prefix a string literal with ``b`` to treat it as a sequence of bytes,
348348
producing :class:`Bytes <hy.models.Bytes>` instead.
349349

350-
Unlike Python, Hy only recognizes string prefixes (``r``, ``b``, and ``f``) in
350+
Unlike Python, Hy only recognizes string prefixes (``r``, ``b``, ``f``, ``t``) in
351351
lowercase, and doesn't allow the no-op prefix ``u``.
352352

353353
:ref:`F-strings <syntax-fstrings>` are a string-like compound construct
@@ -504,6 +504,28 @@ language. Thus e.g. ``f"{"a"}"`` is legal, and equivalent to ``"a"``.
504504
.. autoclass:: hy.models.FString
505505
.. autoclass:: hy.models.FComponent
506506

507+
.. _syntax-tstrings:
508+
509+
Template strings
510+
~~~~~~~~~~~~~~~~
511+
512+
:py:mod:`Template strings <string.templatelib>` (aka "t-strings" or "template
513+
string literals") are similar to format strings, and parse to :class:`FString <hy.models.FString>`
514+
with the parameter ``:is_tstring`` set to ``True``.
515+
Template strings at runtime evaluate to instances of :py:class:`Template
516+
<string.templatelib.Template>` containing the sequence of string and
517+
expression components, without any interpolation.
518+
Template strings use the prefix "t" in front of a string. Otherwise, the syntax
519+
of t-strings are exactly the same as f-strings. ::
520+
521+
(setv tstr t"The sum is {(+ 1 1)}.")
522+
tstr.strings ; => #("The sum is " ".")
523+
tstr.values ; => #(2)
524+
525+
If the :ref:`bracketed-templates <bracketed-templates>` pragma is set, then
526+
:ref:`bracket strings <bracket-strings>` using the delimiter "t" or any
527+
delimiter starting with "t-" are also parsed as template strings.
528+
507529
.. _more-sugar:
508530

509531
Additional sugar

hy/compat.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
PY3_12 = sys.version_info >= (3, 12)
88
PY3_12_6 = sys.version_info >= (3, 12, 6)
99
PY3_13 = sys.version_info >= (3, 13)
10+
PY3_14 = sys.version_info >= (3, 14)
1011
PYPY = platform.python_implementation() == "PyPy"
1112

1213

hy/compiler.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from funcparserlib.parser import NoParseError, many
1212

1313
import hy
14+
from hy.compat import PY3_14
1415
from hy.errors import HyCompileError, HyLanguageError, HySyntaxError
1516
from hy.macros import macroexpand
1617
from hy.model_patterns import FORM, KEYWORD, unpack
@@ -658,6 +659,12 @@ def compile_string(self, string):
658659

659660
@builds_model(FComponent)
660661
def compile_fcomponent(self, fcomponent):
662+
if not PY3_14 and fcomponent.is_tstring:
663+
raise self._syntax_error(fcomponent, "Template string literals require Python 3.14 or later")
664+
if fcomponent.conversion not in (None, 's', 'r', 'a'):
665+
raise self._syntax_error(
666+
fcomponent, f"Invalid conversion character {fcomponent.conversion!r}"
667+
)
661668
conversion = ord(fcomponent.conversion) if fcomponent.conversion else -1
662669
root, *rest = fcomponent
663670
value = self.compile(root)
@@ -669,15 +676,18 @@ def compile_fcomponent(self, fcomponent):
669676
return (
670677
value
671678
+ ret
672-
+ asty.FormattedValue(
673-
fcomponent, value=value.expr, conversion=conversion, format_spec=spec
679+
+ (asty.Interpolation if fcomponent.is_tstring else asty.FormattedValue)(
680+
fcomponent, value=value.expr, conversion=conversion, format_spec=spec,
681+
**(dict(str=fcomponent.expression) if fcomponent.is_tstring else {}),
674682
)
675683
)
676684

677685
@builds_model(FString)
678686
def compile_fstring(self, fstring):
687+
if not PY3_14 and fstring.is_tstring:
688+
raise self._syntax_error(fstring, "Template string literals require Python 3.14 or later")
679689
elts, ret, _ = self._compile_collect(fstring)
680-
return ret + asty.JoinedStr(fstring, values=elts)
690+
return ret + (asty.TemplateStr if fstring.is_tstring else asty.JoinedStr)(fstring, values=elts)
681691

682692
@builds_model(List, Set)
683693
def compile_list(self, expression):

hy/core/hy_repr.hy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@
197197
"}" "}}")
198198
(hy-repr component)))
199199
"]" fstring.brackets "]")
200-
(+ "f\""
200+
(+ (if fstring.is-tstring "t" "f") "\""
201201
#* (lfor component fstring
202202
:setv s (hy-repr component)
203203
(if (isinstance component hy.models.String)

hy/core/result_macros.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
as_model,
5959
is_unpack,
6060
)
61-
from hy.reader import mangle
61+
from hy.reader import mangle, HyReader
6262
from hy.scoping import OuterVar, ScopeFn, ScopeGen, ScopeLet, is_function_scope, is_inside_function_scope, nearest_python_scope
6363

6464
# ------------------------------------------------
@@ -186,6 +186,11 @@ def compile_pragma(compiler, expr, root, kwargs):
186186
compiler.local_state_stack[-1]['warn_on_core_shadow'] = (
187187
bool(compiler.eval(value)))
188188

189+
elif kw == Keyword("bracketed-templates"):
190+
reader = HyReader.current_reader(create=False)
191+
if reader:
192+
reader.bracketed_templates = bool(compiler.eval(value))
193+
189194
else:
190195
raise compiler._syntax_error(kw, f"Unknown pragma `{kw}`. Perhaps it's implemented by a newer version of Hy.")
191196

@@ -252,10 +257,18 @@ def render_quoted_form(compiler, form, level):
252257
contents.append(f_contents)
253258
body = [List(contents)]
254259

255-
if isinstance(form, FString) and form.brackets is not None:
256-
body.extend([Keyword("brackets"), String(form.brackets)])
257-
elif isinstance(form, FComponent) and form.conversion is not None:
258-
body.extend([Keyword("conversion"), String(form.conversion)])
260+
if isinstance(form, FString):
261+
if form.brackets is not None:
262+
body.extend([Keyword("brackets"), String(form.brackets)])
263+
if form.is_tstring:
264+
body.extend([Keyword("is_tstring"), Symbol("True")])
265+
elif isinstance(form, FComponent):
266+
if form.conversion is not None:
267+
body.extend([Keyword("conversion"), String(form.conversion)])
268+
if form.expression is not None:
269+
body.extend([Keyword("expression"), String(form.expression)])
270+
if form.is_tstring:
271+
body.extend([Keyword("is_tstring"), Symbol("True")])
259272

260273
elif isinstance(form, Symbol):
261274
body = [String(form), Keyword("from_parser"), Symbol("True")]

hy/hy_inspect.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,13 @@
1111

1212
import inspect
1313
import linecache
14-
import sys
1514
from contextlib import suppress
1615

1716
from hy.compat import PY3_13
1817
from hy.errors import HySyntaxError
19-
from hy.models import as_model, Expression, Lazy, Object
18+
from hy.models import Expression, Lazy
2019
from hy.reader import HyReader, read
21-
from hy.reader.exceptions import LexException, PrematureEndOfInput
20+
from hy.reader.exceptions import LexException
2221

2322

2423
class HySafeReader(HyReader):

hy/models.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
from itertools import groupby
55
from math import isinf, isnan
66

7-
from hy import _initialize_env_var
7+
from hy.compat import PY3_14
88
from hy.errors import HyWrapperError
99

10+
if PY3_14:
11+
from string.templatelib import Interpolation, Template
12+
1013
PRETTY = True
1114

1215

@@ -464,22 +467,28 @@ class FComponent(Sequence):
464467
format spec (if any).
465468
"""
466469

467-
_extra_kwargs = ("conversion",)
470+
_extra_kwargs = ("conversion", "expression", "is_tstring")
468471

469-
def __new__(cls, s=None, conversion=None):
472+
def __new__(cls, s=None, conversion=None, expression=None, is_tstring=False):
470473
value = super().__new__(cls, s)
471474
value.conversion = conversion
475+
value.expression = expression
476+
value.is_tstring = is_tstring
472477
return value
473478

474479
def replace(self, other, recursive=True):
475480
super().replace(other, recursive)
476-
if hasattr(other, "conversion"):
477-
self.conversion = other.conversion
481+
for attr in self._extra_kwargs:
482+
if hasattr(other, attr):
483+
setattr(self, attr, getattr(other, attr))
478484
return self
479485

480486
def __repr__(self):
481487
return "hy.models.FComponent({})".format(
482-
super(Object, self).__repr__() + ", conversion=" + repr(self.conversion)
488+
(super(Object, self).__repr__()
489+
+ ", conversion=" + repr(self.conversion)
490+
+ ", expression=" + repr(self.expression)
491+
+ ", is_tstring=" + repr(self.is_tstring))
483492
)
484493

485494

@@ -498,11 +507,12 @@ class FString(Sequence):
498507
and :class:`hy.models.FComponent`. The design mimics :class:`ast.JoinedStr`.
499508
500509
:ivar brackets: As in :class:`hy.models.String`.
510+
:ivar is_tstring: Whether this represents a template string rather than a format string.
501511
"""
502512

503-
_extra_kwargs = ("brackets",)
513+
_extra_kwargs = ("brackets", "is_tstring")
504514

505-
def __new__(cls, s=None, brackets=None):
515+
def __new__(cls, s=None, brackets=None, is_tstring=False):
506516
value = super().__new__(
507517
cls,
508518
# Join adjacent string nodes for the sake of equality
@@ -519,6 +529,7 @@ def __new__(cls, s=None, brackets=None):
519529
if brackets is not None and _string_in_node(f"]{brackets}]", value):
520530
raise ValueError(f"Syntactically illegal bracket string: {s!r}")
521531
value.brackets = brackets
532+
value.is_tstring = is_tstring
522533
return value
523534

524535
def __repr__(self):
@@ -528,13 +539,19 @@ def __str__(self):
528539
return self._suffixize(super().__str__())
529540

530541
def _suffixize(self, x):
531-
if self.brackets is None:
542+
if self.brackets is None and not self.is_tstring:
532543
return x
533-
return "{}{}brackets={!r})".format(
534-
x[:-1], # Clip off the final close paren
535-
"" if x[-2] == "(" else ", ",
536-
self.brackets,
537-
)
544+
args = []
545+
if self.brackets is not None:
546+
args.append(f"brackets={self.brackets!r}")
547+
if PY3_14 and self.is_tstring:
548+
args.append(f"is_tstring={self.is_tstring!r}")
549+
s = x[:-1] # Clip off the final close paren
550+
if s[-1] != "(":
551+
s += ", "
552+
s += ", ".join(args)
553+
s += ")"
554+
return s
538555

539556

540557
class List(Sequence):
@@ -562,9 +579,19 @@ def lambda_to_return(l):
562579

563580

564581
_wrappers[FComponent] = recwrap(FComponent)
582+
if PY3_14:
583+
_wrappers[Interpolation] = lambda interp: FComponent(
584+
[as_model(interp.value), as_model(interp.format_spec)],
585+
conversion=interp.conversion,
586+
expression=interp.expression,
587+
is_tstring=True)
565588
_wrappers[FString] = lambda fstr: FString(
566-
(as_model(x) for x in fstr), brackets=fstr.brackets
589+
(as_model(x) for x in fstr),
590+
brackets=fstr.brackets,
591+
is_tstring=fstr.is_tstring,
567592
)
593+
if PY3_14:
594+
_wrappers[Template] = recwrap(lambda els: FString(els, is_tstring=True))
568595
_wrappers[List] = recwrap(List)
569596
_wrappers[list] = recwrap(List)
570597

0 commit comments

Comments
 (0)