Skip to content

Commit b00d59c

Browse files
Add free-threaded Python support (#2809)
Co-authored-by: Bernát Gábor <[email protected]>
1 parent bc7a91a commit b00d59c

25 files changed

+306
-103
lines changed

.github/workflows/check.yaml

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ jobs:
2222
fail-fast: false
2323
matrix:
2424
py:
25+
- "3.13t"
2526
- "3.13"
2627
- "3.12"
2728
- "3.11"
@@ -77,27 +78,41 @@ jobs:
7778
shell: bash
7879
run: echo ~/.local/bin >> $GITHUB_PATH
7980
- name: Install tox
80-
if: matrix.py == '3.13'
81+
if: matrix.py == '3.13' || matrix.py == '3.13t'
8182
run: uv tool install --python-preference only-managed --python 3.12 tox --with tox-uv
8283
- name: Install tox
83-
if: matrix.py != '3.13'
84+
if: "!(matrix.py == '3.13' || matrix.py == '3.13t')"
8485
run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv
8586
- name: Setup brew python for test ${{ matrix.py }}
86-
if: startsWith(matrix.py,'brew@')
87+
if: startsWith(matrix.py, 'brew@')
8788
run: |
8889
set -e
8990
PY=$(echo '${{ matrix.py }}' | cut -c 6-)
9091
brew cleanup && brew upgrade python@$PY || brew install python@$PY
9192
echo "/usr/local/opt/python@$PY/libexec/bin" >>"${GITHUB_PATH}"
9293
shell: bash
9394
- name: Setup python for test ${{ matrix.py }}
94-
if: "!( startsWith(matrix.py,'brew@') || endsWith(matrix.py, '-dev') )"
95+
if: "!(startsWith(matrix.py, 'brew@') || endsWith(matrix.py, 't'))"
9596
uses: actions/setup-python@v5
9697
with:
9798
python-version: ${{ matrix.py }}
9899
allow-prereleases: true
100+
# quansight-labs to install free-threaded python until actions/setup-python supports it
101+
# https://github.com/actions/setup-python/issues/771
102+
- name: Setup python for test ${{ matrix.py }}
103+
if: endsWith(matrix.py, 't')
104+
uses: quansight-labs/[email protected]
105+
with:
106+
python-version: ${{ matrix.py }}
99107
- name: Pick environment to run
108+
if: matrix.py != '3.13t'
100109
run: python tasks/pick_tox_env.py ${{ matrix.py }}
110+
- name: Pick environment to run
111+
if: matrix.py == '3.13t' && runner.os != 'Windows'
112+
run: python tasks/pick_tox_env.py ${{ matrix.py }} $Python_ROOT_DIR/bin/python
113+
- name: Pick environment to run
114+
if: matrix.py == '3.13t' && runner.os == 'Windows'
115+
run: python tasks/pick_tox_env.py ${{ matrix.py }} $env:Python_ROOT_DIR\python.exe
101116
- name: Setup test suite
102117
run: tox run -vv --notest --skip-missing-interpreters false
103118
- name: Run test suite

docs/changelog/2809.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for selecting free-threaded Python interpreters, e.g., `python3.13t`.

docs/installation.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Python and OS Compatibility
8484

8585
virtualenv works with the following Python interpreter implementations:
8686

87-
- `CPython <https://www.python.org/>`_: ``3.12 >= python_version >= 3.7``
87+
- `CPython <https://www.python.org/>`_: ``3.13 >= python_version >= 3.7``
8888
- `PyPy <https://pypy.org/>`_: ``3.10 >= python_version >= 3.7``
8989

9090
This means virtualenv works on the latest patch version of each of these minor versions. Previous patch versions are

docs/user_guide.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,14 @@ format is either:
8686

8787
- the python implementation is all alphabetic characters (``python`` means any implementation, and if is missing it
8888
defaults to ``python``),
89-
- the version is a dot separated version number,
89+
- the version is a dot separated version number optionally followed by ``t`` for free-threading,
9090
- the architecture is either ``-64`` or ``-32`` (missing means ``any``).
9191

9292
For example:
9393

9494
- ``python3.8.1`` means any python implementation having the version ``3.8.1``,
9595
- ``3`` means any python implementation having the major version ``3``,
96+
- ``3.13t`` means any python implementation having the version ``3.13`` with free threading,
9697
- ``cpython3`` means a ``CPython`` implementation having the version ``3``,
9798
- ``pypy2`` means a python interpreter with the ``PyPy`` implementation and major version ``2``.
9899

src/virtualenv/discovery/cached_py_info.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
_CACHE = OrderedDict()
2525
_CACHE[Path(sys.executable)] = PythonInfo()
2626
LOGGER = logging.getLogger(__name__)
27+
_CACHE_FILE_VERSION = 1
2728

2829

2930
def from_exe(cls, app_data, exe, env=None, raise_on_error=True, ignore_cache=False): # noqa: FBT002, PLR0913
@@ -64,8 +65,13 @@ def _get_via_file_cache(cls, app_data, path, exe, env):
6465
with py_info_store.locked():
6566
if py_info_store.exists(): # if exists and matches load
6667
data = py_info_store.read()
67-
of_path, of_st_mtime, of_content = data["path"], data["st_mtime"], data["content"]
68-
if of_path == path_text and of_st_mtime == path_modified:
68+
of_path, of_st_mtime, of_content, version = (
69+
data["path"],
70+
data["st_mtime"],
71+
data["content"],
72+
data.get("version"),
73+
)
74+
if of_path == path_text and of_st_mtime == path_modified and version == _CACHE_FILE_VERSION:
6975
py_info = cls._from_dict(of_content.copy())
7076
sys_exe = py_info.system_executable
7177
if sys_exe is not None and not os.path.exists(sys_exe):
@@ -76,7 +82,12 @@ def _get_via_file_cache(cls, app_data, path, exe, env):
7682
if py_info is None: # if not loaded run and save
7783
failure, py_info = _run_subprocess(cls, exe, app_data, env)
7884
if failure is None:
79-
data = {"st_mtime": path_modified, "path": path_text, "content": py_info._to_dict()} # noqa: SLF001
85+
data = {
86+
"st_mtime": path_modified,
87+
"path": path_text,
88+
"content": py_info._to_dict(), # noqa: SLF001
89+
"version": _CACHE_FILE_VERSION,
90+
}
8091
py_info_store.write(data)
8192
else:
8293
py_info = failure

src/virtualenv/discovery/py_info.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def abs_path(v):
5252

5353
self.version = sys.version
5454
self.os = os.name
55+
self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
5556

5657
# information about the prefix - determines python home
5758
self.prefix = abs_path(getattr(sys, "prefix", None)) # prefix we think
@@ -290,7 +291,12 @@ def __str__(self) -> str:
290291

291292
@property
292293
def spec(self):
293-
return "{}{}-{}".format(self.implementation, ".".join(str(i) for i in self.version_info), self.architecture)
294+
return "{}{}{}-{}".format(
295+
self.implementation,
296+
".".join(str(i) for i in self.version_info),
297+
"t" if self.free_threaded else "",
298+
self.architecture,
299+
)
294300

295301
@classmethod
296302
def clear_cache(cls, app_data):
@@ -300,7 +306,7 @@ def clear_cache(cls, app_data):
300306
clear(app_data)
301307
cls._cache_exe_discovery.clear()
302308

303-
def satisfies(self, spec, impl_must_match): # noqa: C901
309+
def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911
304310
"""Check if a given specification can be satisfied by the this python interpreter instance."""
305311
if spec.path:
306312
if self.executable == os.path.abspath(spec.path):
@@ -326,6 +332,9 @@ def satisfies(self, spec, impl_must_match): # noqa: C901
326332
if spec.architecture is not None and spec.architecture != self.architecture:
327333
return False
328334

335+
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
336+
return False
337+
329338
for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro)):
330339
if req is not None and our is not None and our != req:
331340
return False
@@ -522,10 +531,14 @@ def _find_possible_exe_names(self):
522531
for name in self._possible_base():
523532
for at in (3, 2, 1, 0):
524533
version = ".".join(str(i) for i in self.version_info[:at])
525-
for arch in [f"-{self.architecture}", ""]:
526-
for ext in EXTENSIONS:
527-
candidate = f"{name}{version}{arch}{ext}"
528-
name_candidate[candidate] = None
534+
mods = [""]
535+
if self.free_threaded:
536+
mods.append("t")
537+
for mod in mods:
538+
for arch in [f"-{self.architecture}", ""]:
539+
for ext in EXTENSIONS:
540+
candidate = f"{name}{version}{mod}{arch}{ext}"
541+
name_candidate[candidate] = None
529542
return list(name_candidate.keys())
530543

531544
def _possible_base(self):

src/virtualenv/discovery/py_spec.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import os
66
import re
77

8-
PATTERN = re.compile(r"^(?P<impl>[a-zA-Z]+)?(?P<version>[0-9.]+)?(?:-(?P<arch>32|64))?$")
8+
PATTERN = re.compile(r"^(?P<impl>[a-zA-Z]+)?(?P<version>[0-9.]+)?(?P<threaded>t)?(?:-(?P<arch>32|64))?$")
99

1010

1111
class PythonSpec:
@@ -20,18 +20,21 @@ def __init__( # noqa: PLR0913
2020
micro: int | None,
2121
architecture: int | None,
2222
path: str | None,
23+
*,
24+
free_threaded: bool | None = None,
2325
) -> None:
2426
self.str_spec = str_spec
2527
self.implementation = implementation
2628
self.major = major
2729
self.minor = minor
2830
self.micro = micro
31+
self.free_threaded = free_threaded
2932
self.architecture = architecture
3033
self.path = path
3134

3235
@classmethod
3336
def from_string_spec(cls, string_spec: str): # noqa: C901, PLR0912
34-
impl, major, minor, micro, arch, path = None, None, None, None, None, None
37+
impl, major, minor, micro, threaded, arch, path = None, None, None, None, None, None, None
3538
if os.path.isabs(string_spec): # noqa: PLR1702
3639
path = string_spec
3740
else:
@@ -58,6 +61,7 @@ def _int_or_none(val):
5861
major = int(str(version_data)[0]) # first digit major
5962
if version_data > 9: # noqa: PLR2004
6063
minor = int(str(version_data)[1:])
64+
threaded = bool(groups["threaded"])
6165
ok = True
6266
except ValueError:
6367
pass
@@ -70,14 +74,15 @@ def _int_or_none(val):
7074
if not ok:
7175
path = string_spec
7276

73-
return cls(string_spec, impl, major, minor, micro, arch, path)
77+
return cls(string_spec, impl, major, minor, micro, arch, path, free_threaded=threaded)
7478

7579
def generate_re(self, *, windows: bool) -> re.Pattern:
7680
"""Generate a regular expression for matching against a filename."""
7781
version = r"{}(\.{}(\.{})?)?".format(
7882
*(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro))
7983
)
8084
impl = "python" if self.implementation is None else f"python|{re.escape(self.implementation)}"
85+
mod = "t?" if self.free_threaded else ""
8186
suffix = r"\.exe" if windows else ""
8287
version_conditional = (
8388
"?"
@@ -89,7 +94,7 @@ def generate_re(self, *, windows: bool) -> re.Pattern:
8994
)
9095
# Try matching `direct` first, so the `direct` group is filled when possible.
9196
return re.compile(
92-
rf"(?P<impl>{impl})(?P<v>{version}){version_conditional}{suffix}$",
97+
rf"(?P<impl>{impl})(?P<v>{version}{mod}){version_conditional}{suffix}$",
9398
flags=re.IGNORECASE,
9499
)
95100

@@ -105,6 +110,8 @@ def satisfies(self, spec):
105110
return False
106111
if spec.architecture is not None and spec.architecture != self.architecture:
107112
return False
113+
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
114+
return False
108115

109116
for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)):
110117
if req is not None and our is not None and our != req:
@@ -113,7 +120,7 @@ def satisfies(self, spec):
113120

114121
def __repr__(self) -> str:
115122
name = type(self).__name__
116-
params = "implementation", "major", "minor", "micro", "architecture", "path"
123+
params = "implementation", "major", "minor", "micro", "architecture", "path", "free_threaded"
117124
return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})"
118125

119126

src/virtualenv/discovery/windows/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ def propose_interpreters(spec, cache_dir, env):
2727
reverse=True,
2828
)
2929

30-
for name, major, minor, arch, exe, _ in existing:
30+
for name, major, minor, arch, threaded, exe, _ in existing:
3131
# Map well-known/most common organizations to a Python implementation, use the org name as a fallback for
3232
# backwards compatibility.
3333
implementation = _IMPLEMENTATION_BY_ORG.get(name, name)
3434

3535
# Pre-filtering based on Windows Registry metadata, for CPython only
3636
skip_pre_filter = implementation.lower() != "cpython"
37-
registry_spec = PythonSpec(None, implementation, major, minor, None, arch, exe)
37+
registry_spec = PythonSpec(None, implementation, major, minor, None, arch, exe, free_threaded=threaded)
3838
if skip_pre_filter or registry_spec.satisfies(spec):
3939
interpreter = Pep514PythonInfo.from_exe(exe, cache_dir, env=env, raise_on_error=False)
4040
if interpreter is not None and interpreter.satisfies(spec, impl_must_match=True):

src/virtualenv/discovery/windows/pep514.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ def process_tag(hive_name, company, company_key, tag, default_arch):
6565
exe_data = load_exe(hive_name, company, company_key, tag)
6666
if exe_data is not None:
6767
exe, args = exe_data
68-
return company, major, minor, arch, exe, args
68+
threaded = load_threaded(hive_name, company, tag, tag_key)
69+
return company, major, minor, arch, threaded, exe, args
6970
return None
7071
return None
7172
return None
@@ -138,6 +139,18 @@ def parse_version(version_str):
138139
raise ValueError(error)
139140

140141

142+
def load_threaded(hive_name, company, tag, tag_key):
143+
display_name = get_value(tag_key, "DisplayName")
144+
if display_name is not None:
145+
if isinstance(display_name, str):
146+
if "freethreaded" in display_name.lower():
147+
return True
148+
else:
149+
key_path = f"{hive_name}/{company}/{tag}/DisplayName"
150+
msg(key_path, f"display name is not string: {display_name!r}")
151+
return bool(re.match(r"^\d+(\.\d+){0,2}t$", tag, flags=re.IGNORECASE))
152+
153+
141154
def msg(path, what):
142155
LOGGER.warning("PEP-514 violation in Windows Registry at %s error: %s", path, what)
143156

tasks/pick_tox_env.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@
88
if py.startswith("brew@"):
99
py = py[len("brew@") :]
1010
env = f"TOXENV={py}"
11+
if len(sys.argv) > 2: # noqa: PLR2004
12+
env += f"\nTOX_BASEPYTHON={sys.argv[2]}"
1113
with Path(os.environ["GITHUB_ENV"]).open("ta", encoding="utf-8") as file_handler:
1214
file_handler.write(env)

tests/integration/test_zipapp.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,26 @@ def zipapp_build_env(tmp_path_factory):
2626
exe, found = None, False
2727
# prefer CPython as builder as pypy is slow
2828
for impl in ["cpython", ""]:
29-
for version in range(11, 6, -1):
30-
with suppress(Exception):
31-
# create a virtual environment which is also guaranteed to contain a recent enough pip (bundled)
32-
session = cli_run(
33-
[
34-
"-vvv",
35-
"-p",
36-
f"{impl}3.{version}",
37-
"--activators",
38-
"",
39-
str(create_env_path),
40-
"--no-download",
41-
"--no-periodic-update",
42-
],
43-
)
44-
exe = str(session.creator.exe)
45-
found = True
29+
for threaded in ["", "t"]:
30+
for version in range(11, 6, -1):
31+
with suppress(Exception):
32+
# create a virtual environment which is also guaranteed to contain a recent enough pip (bundled)
33+
session = cli_run(
34+
[
35+
"-vvv",
36+
"-p",
37+
f"{impl}3.{version}{threaded}",
38+
"--activators",
39+
"",
40+
str(create_env_path),
41+
"--no-download",
42+
"--no-periodic-update",
43+
],
44+
)
45+
exe = str(session.creator.exe)
46+
found = True
47+
break
48+
if found:
4649
break
4750
if found:
4851
break

tests/unit/create/via_global_ref/builtin/cpython/cpython3_win_embed.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,6 @@
5757
"system_stdlib": "c:\\path\\to\\python\\Lib",
5858
"system_stdlib_platform": "c:\\path\\to\\python\\Lib",
5959
"max_size": 9223372036854775807,
60-
"_creators": null
60+
"_creators": null,
61+
"free_threaded": false
6162
}

tests/unit/create/via_global_ref/builtin/pypy/deb_pypy37.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,6 @@
6060
"system_stdlib": "/usr/lib/pypy3/lib-python/3.7",
6161
"system_stdlib_platform": "/usr/lib/pypy3/lib-python/3.7",
6262
"max_size": 9223372036854775807,
63-
"_creators": null
63+
"_creators": null,
64+
"free_threaded": false
6465
}

tests/unit/create/via_global_ref/builtin/pypy/deb_pypy38.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,6 @@
6060
"system_stdlib": "/usr/lib/pypy3.8",
6161
"system_stdlib_platform": "/usr/lib/pypy3.8",
6262
"max_size": 9223372036854775807,
63-
"_creators": null
63+
"_creators": null,
64+
"free_threaded": false
6465
}

tests/unit/create/via_global_ref/builtin/pypy/portable_pypy38.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,6 @@
5959
"system_stdlib": "/tmp/pypy3.8-v7.3.8-linux64/lib/pypy3.8",
6060
"system_stdlib_platform": "/tmp/pypy3.8-v7.3.8-linux64/lib/pypy3.8",
6161
"max_size": 9223372036854775807,
62-
"_creators": null
62+
"_creators": null,
63+
"free_threaded": false
6364
}

0 commit comments

Comments
 (0)