Skip to content

Commit 169fcfc

Browse files
committed
gh-153056: Fix a data race compiling the string.Template pattern in free-threading builds
Template compiles its substitution pattern lazily and caches it on the class. On the free-threaded build two concurrent first uses could race: a thread that observed the pattern another thread had just compiled would try to recompile it, and re.compile() rejects flags on an already-compiled pattern, raising a spurious ValueError. Return the already-compiled pattern instead. As a side effect, a subclass that supplies an already-compiled pattern now works too; previously it raised the same ValueError at class definition.
1 parent 8b1dbb1 commit 169fcfc

4 files changed

Lines changed: 59 additions & 0 deletions

File tree

Lib/string/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ def _compile_pattern(cls):
8484
import re # deferred import, for performance
8585

8686
pattern = cls.__dict__.get('pattern', _TemplatePattern)
87+
if isinstance(pattern, re.Pattern):
88+
# Already compiled; reuse it (re.compile() rejects flags on a Pattern).
89+
return pattern
8790
if pattern is _TemplatePattern:
8891
delim = re.escape(cls.delimiter)
8992
id = cls.idpattern
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import string
2+
import unittest
3+
from string import Template
4+
5+
from test.support import threading_helper
6+
7+
8+
@threading_helper.requires_working_threading()
9+
class TestTemplateCompileRace(unittest.TestCase):
10+
def test_concurrent_first_use(self):
11+
# Template compiles its pattern lazily on first use and caches it on the
12+
# class. Race that lazy compile from many threads and confirm none hits
13+
# a spurious ValueError from recompiling an already-compiled pattern.
14+
# Use a throwaway subclass each round so the shared string.Template is
15+
# never mutated; subclasses precompile in __init_subclass__, so restore
16+
# the sentinel descriptor (string._TemplatePattern) to re-arm the lazy
17+
# path before racing.
18+
uncompiled = string._TemplatePattern
19+
errors = []
20+
21+
def use_template(cls):
22+
try:
23+
cls("$x and ${y}").substitute(x=1, y=2)
24+
except Exception as e:
25+
errors.append(e)
26+
27+
for _ in range(20):
28+
class T(Template):
29+
pass
30+
T.pattern = uncompiled
31+
T.flags = None
32+
threading_helper.run_concurrently(use_template, nthreads=10, args=(T,))
33+
34+
self.assertEqual(errors, [], msg=f"unexpected errors: {errors}")
35+
36+
37+
if __name__ == "__main__":
38+
unittest.main()

Lib/test/test_string/test_string.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,22 @@ def test_SafeTemplate(self):
299299
eq(s.safe_substitute(dict(who='tim', what='ham', meal='dinner')),
300300
'tim likes ham for dinner')
301301

302+
def test_precompiled_pattern(self):
303+
# A subclass may supply an already-compiled pattern; it must be reused,
304+
# not recompiled (re.compile() rejects flags on a compiled pattern).
305+
# This is the non-threaded form of the free-threading lazy-compile race
306+
# where a thread observes the pattern another thread just compiled.
307+
import re
308+
compiled = re.compile(
309+
r'\$(?:(?P<escaped>\$)|(?P<named>[a-z]+)|'
310+
r'\{(?P<braced>[a-z]+)\}|(?P<invalid>))')
311+
class MyTemplate(Template):
312+
pattern = compiled
313+
self.assertIs(MyTemplate.pattern, compiled)
314+
self.assertEqual(
315+
MyTemplate('$who likes $what').substitute(who='tim', what='ham'),
316+
'tim likes ham')
317+
302318
def test_invalid_placeholders(self):
303319
raises = self.assertRaises
304320
s = Template('$who likes $')
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix a race in :class:`string.Template` where concurrent first use of a
2+
template on the free-threaded build could raise a spurious :exc:`ValueError`.

0 commit comments

Comments
 (0)