Skip to content

Commit 74943d1

Browse files
evhubclaude
andcommitted
Add Python 3.14 t-string support with tstr backport
Resolves #866. - Add native t-string (template string) support for Python 3.14+ - Backport t-strings to Python 3.10+ using tstr library - T-strings compile to native syntax on 3.14+, _coconut.tstr.t() on older versions - Add py310_spec_test and py314_test for template string verification - Update grammar to recognize t"..." and rt"..." string prefixes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e77f35c commit 74943d1

File tree

13 files changed

+166
-21
lines changed

13 files changed

+166
-21
lines changed

.claude/settings.local.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22
"permissions": {
33
"allow": [
44
"Bash(cat:*)",
5-
"Bash(make:*)",
65
"Bash(python:*)",
76
"Bash(xonsh:*)",
87
"Bash(gh:*)",
8+
"Bash(pip:*)",
9+
"Bash(git add:*)",
10+
"Bash(git commit:*)",
11+
"Bash(make test-tests:*)",
912
"WebSearch",
1013
"WebFetch(domain:coconut.readthedocs.io)",
1114
"WebFetch(domain:github.com)",
12-
"WebFetch(domain:peps.python.org)"
15+
"WebFetch(domain:peps.python.org)",
16+
"WebFetch(domain:pypi.org)",
17+
"WebFetch(domain:discuss.python.org)"
1318
]
1419
}
1520
}

DOCS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ In addition to the newer Python features that Coconut can backport automatically
364364
- [`aenum`](https://pypi.org/project/aenum) for backporting [`enum`](https://docs.python.org/3/library/enum.html).
365365
- [`async_generator`](https://github.com/python-trio/async_generator) for backporting [`async` generators](https://peps.python.org/pep-0525/) and [`asynccontextmanager`](https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager).
366366
- [`trollius`](https://pypi.python.org/pypi/trollius) for backporting [`async`/`await`](https://docs.python.org/3/library/asyncio-task.html) and [`asyncio`](https://docs.python.org/3/library/asyncio.html).
367+
- [`tstr`](https://github.com/ilotoki0804/tstr) for backporting [template strings (`t"..."`)](https://peps.python.org/pep-0750/) on Python < 3.14.
367368

368369
Note that, when distributing compiled Coconut code, if you use any of these backports, you'll need to make sure that the requisite backport module is included as a dependency.
369370

_coconut/__init__.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ else:
5959

6060
if sys.version_info >= (3, 5):
6161
import async_generator as _async_generator # type: ignore
62+
else:
63+
_async_generator = ... # type: ignore
64+
65+
if sys.version_info >= (3, 14):
66+
import tstr as _tstr # type: ignore
67+
else:
68+
_tstr = ... # type: ignore
6269

6370
try:
6471
import numpy as _numpy # type: ignore
@@ -95,6 +102,7 @@ copyreg = _copyreg
95102
asyncio = _asyncio
96103
asyncio_Return = StopIteration
97104
async_generator = _async_generator
105+
tstr = _tstr
98106
pickle = _pickle
99107
if sys.version_info >= (2, 7):
100108
OrderedDict = collections.OrderedDict

coconut/compiler/compiler.py

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,7 @@ def bind(cls):
858858
cls.testlist_star_namedexpr <<= attach(cls.testlist_star_namedexpr_tokens, cls.method("testlist_star_expr_handle"))
859859
cls.ellipsis <<= attach(cls.ellipsis_tokens, cls.method("ellipsis_handle"))
860860
cls.f_string <<= attach(cls.f_string_tokens, cls.method("f_string_handle"))
861+
cls.t_string <<= attach(cls.t_string_tokens, cls.method("t_string_handle"))
861862
cls.funcname_typeparams <<= attach(cls.funcname_typeparams_tokens, cls.method("funcname_typeparams_handle"))
862863

863864
# standard handlers of the form name <<= attach(name_ref, method("name_handle"))
@@ -4644,8 +4645,8 @@ def cases_stmt_handle(self, original, loc, tokens):
46444645
out += "if not " + check_var + default
46454646
return out
46464647

4647-
def f_string_handle(self, original, loc, tokens):
4648-
"""Process Python 3.6 format strings."""
4648+
def f_string_handle(self, original, loc, tokens, is_t=False):
4649+
"""Process Python 3.6 format strings and Python 3.14 template strings."""
46494650
string, = tokens
46504651

46514652
# strip raw r
@@ -4662,7 +4663,7 @@ def f_string_handle(self, original, loc, tokens):
46624663

46634664
# warn if there are no exprs
46644665
if not exprs:
4665-
self.strict_err_or_warn("f-string with no expressions", original, loc)
4666+
self.strict_err_or_warn(("t" if is_t else "f") + "-string with no expressions", original, loc)
46664667

46674668
# handle Python 3.8 f string = specifier
46684669
for i, expr in enumerate(exprs):
@@ -4683,22 +4684,39 @@ def f_string_handle(self, original, loc, tokens):
46834684
raise CoconutDeferredSyntaxError("illegal complex expression in format string: " + co_expr, loc)
46844685
compiled_exprs.append(py_expr)
46854686

4686-
# reconstitute string
4687-
# (though f strings are supported on 3.6+, nested strings with the same strchars are only
4688-
# supported on 3.12+, so we should only use the literal syntax there)
4689-
if self.target_info >= (3, 12):
4687+
# handle t-strings
4688+
if is_t:
46904689
new_text = interleaved_join(string_parts, compiled_exprs)
4691-
return "f" + ("r" if raw else "") + self.wrap_str(new_text, strchar)
4690+
if self.target_info >= (3, 14):
4691+
# Native t-string syntax
4692+
return "t" + ("r" if raw else "") + self.wrap_str(new_text, strchar)
4693+
else:
4694+
# Use tstr backport - tstr.t() uses caller's frame for variable lookup
4695+
# Will raise runtime error if tstr not available (works with universal target)
4696+
template_str = ("r" if raw else "") + self.wrap_str(new_text, strchar)
4697+
return "_coconut.tstr.t(" + template_str + ")" + self.type_ignore_comment()
46924698

46934699
else:
4694-
names = [format_var + "_" + str(i) for i in range(len(compiled_exprs))]
4695-
new_text = interleaved_join(string_parts, names)
4696-
4697-
# generate format call
4698-
return ("r" if raw else "") + self.wrap_str(new_text, strchar) + ".format(" + ", ".join(
4699-
name + "=(" + self.wrap_passthrough(expr) + ")"
4700-
for name, expr in zip(names, compiled_exprs)
4701-
) + ")"
4700+
# reconstitute string
4701+
# (though f strings are supported on 3.6+, nested strings with the same strchars are only
4702+
# supported on 3.12+, so we should only use the literal syntax there)
4703+
if self.target_info >= (3, 12):
4704+
new_text = interleaved_join(string_parts, compiled_exprs)
4705+
return "f" + ("r" if raw else "") + self.wrap_str(new_text, strchar)
4706+
4707+
else:
4708+
names = [format_var + "_" + str(i) for i in range(len(compiled_exprs))]
4709+
new_text = interleaved_join(string_parts, names)
4710+
4711+
# generate format call
4712+
return ("r" if raw else "") + self.wrap_str(new_text, strchar) + ".format(" + ", ".join(
4713+
name + "=(" + self.wrap_passthrough(expr) + ")"
4714+
for name, expr in zip(names, compiled_exprs)
4715+
) + ")"
4716+
4717+
def t_string_handle(self, original, loc, tokens):
4718+
"""Process Python 3.14 template strings."""
4719+
return self.f_string_handle(original, loc, tokens, is_t=True)
47024720

47034721
def decorators_handle(self, loc, tokens):
47044722
"""Process decorators."""

coconut/compiler/grammar.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -929,20 +929,23 @@ class Grammar(object):
929929

930930
u_string = Forward()
931931
f_string = Forward()
932+
t_string = Forward()
932933

933934
bit_b = caseless_literal("b")
934935
raw_r = caseless_literal("r")
935936
unicode_u = caseless_literal("u", suppress=True)
936937
format_f = caseless_literal("f", suppress=True)
938+
template_t = caseless_literal("t", suppress=True)
937939

938940
string = combine(Optional(raw_r) + string_item)
939941
# Python 2 only supports br"..." not rb"..."
940942
b_string = combine((bit_b + Optional(raw_r) | fixto(raw_r + bit_b, "br")) + string_item)
941943
# ur"..."/ru"..." strings are not suppored in Python 3
942944
u_string_ref = combine(unicode_u + string_item)
943945
f_string_tokens = combine((format_f + Optional(raw_r) | raw_r + format_f) + string_item)
946+
t_string_tokens = combine((template_t + Optional(raw_r) | raw_r + template_t) + string_item)
944947
nonbf_string = string | u_string
945-
nonb_string = nonbf_string | f_string
948+
nonb_string = nonbf_string | f_string | t_string
946949
any_string = nonb_string | b_string
947950
moduledoc = any_string + newline
948951
docstring = condense(moduledoc)
@@ -2862,7 +2865,7 @@ class Grammar(object):
28622865
| fixto(end_of_line, "misplaced newline (maybe missing ':')")
28632866
)
28642867

2865-
start_f_str_regex = compile_regex(r"\br?fr?$")
2868+
start_f_str_regex = compile_regex(r"\br?[ft]r?$")
28662869
start_f_str_regex_len = 4
28672870

28682871
end_f_str_expr = StartOfStrGrammar(combine(rbrace | colon | bang).leaveWhitespace())

coconut/compiler/header.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,17 @@ def Return(self, obj):
766766
''',
767767
indent=1,
768768
),
769+
import_tstr=pycondition(
770+
(3, 14),
771+
if_lt='''
772+
try:
773+
import tstr
774+
except ImportError as tstr_import_err:
775+
tstr = _coconut_missing_module(tstr_import_err)
776+
''',
777+
if_ge='',
778+
indent=1,
779+
),
769780
class_amap=pycondition(
770781
(3, 3),
771782
if_lt='''

coconut/compiler/templates/header.py_template

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class _coconut{object}:{COMMENT.EVERYTHING_HERE_MUST_BE_COPIED_TO_STUB_FILE}
2727
import async_generator
2828
except ImportError as async_generator_import_err:
2929
async_generator = _coconut_missing_module(async_generator_import_err)
30+
{import_tstr}
3031
{import_pickle}
3132
{import_OrderedDict}
3233
{import_collections_abc}

coconut/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,6 +968,7 @@ def get_path_env_var(env_var, default):
968968
("dataclasses", "py==36"),
969969
("typing", "py<35"),
970970
("async_generator", "py35"),
971+
("tstr", "py310"),
971972
("exceptiongroup", "py37;py<311"),
972973
("anyio", "py36"),
973974
"setuptools",
@@ -1086,6 +1087,7 @@ def get_path_env_var(env_var, default):
10861087
("pygments", "py>=39"): (2, 19),
10871088
("xonsh", "py311"): (0, 22),
10881089
("async_generator", "py35"): (1, 10),
1090+
("tstr", "py310"): (0, 4),
10891091
("exceptiongroup", "py37;py<311"): (1,),
10901092
("ipython", "py>=311"): (9,),
10911093
"py-spy": (0, 4),

coconut/root.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
VERSION = "3.1.2"
2727
VERSION_NAME = None
2828
# False for release, int >= 1 for develop
29-
DEVELOP = 9
29+
DEVELOP = 10
3030
ALPHA = False # for pre releases rather than post releases
3131

3232
assert DEVELOP is False or DEVELOP >= 1, "DEVELOP must be False or an int >= 1"

coconut/tests/main_test.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,19 @@ def comp_311(args=[], always_sys=False, **kwargs):
659659
comp(path="cocotest", folder="target_311", args=["--target", "311" if not always_sys else "sys"] + args, **kwargs)
660660

661661

662+
def comp_314(args=[], always_sys=False, **kwargs):
663+
"""Compiles target_314."""
664+
# remove --mypy checking when running on Python 3.14 since MyPy can't parse t-strings
665+
if sys.version_info < (3, 14):
666+
try:
667+
mypy_ind = args.index("--mypy")
668+
except ValueError:
669+
pass
670+
else:
671+
args = args[:mypy_ind]
672+
comp(path="cocotest", folder="target_314", args=["--target", "314" if not always_sys else "sys"] + args, **kwargs)
673+
674+
662675
def comp_sys(args=[], **kwargs):
663676
"""Compiles target_sys."""
664677
comp(path="cocotest", folder="target_sys", args=["--target", "sys"] + args, **kwargs)
@@ -719,6 +732,8 @@ def run(
719732
comp_38(args, **spec_kwargs)
720733
if sys.version_info >= (3, 11):
721734
comp_311(args, **spec_kwargs)
735+
if sys.version_info >= (3, 14):
736+
comp_314(args, **spec_kwargs)
722737

723738
if not run_directory:
724739
comp_agnostic(agnostic_args, **kwargs)
@@ -776,6 +791,7 @@ def comp_all(args=[], agnostic_target=None, **kwargs):
776791
comp_36(args, **kwargs)
777792
comp_38(args, **kwargs)
778793
comp_311(args, **kwargs)
794+
comp_314(args, **kwargs)
779795
comp_sys(args, **kwargs)
780796
# do non-strict at the end so we get the non-strict header
781797
comp_non_strict(args, **kwargs)

0 commit comments

Comments
 (0)