Skip to content

Commit 92d4ed3

Browse files
Feat: free-threaded python support (#3526)
Co-authored-by: Bernát Gábor <[email protected]>
1 parent 957f2f8 commit 92d4ed3

File tree

12 files changed

+117
-23
lines changed

12 files changed

+117
-23
lines changed

docs/changelog/3391.feature.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add support for free-threaded python builds.
2+
Factors like ``py313t`` will only pick builds with the GIL disabled while factors without trailing ``t`` will only pick
3+
builds without no-GIL support.

docs/config.rst

+29-4
Original file line numberDiff line numberDiff line change
@@ -542,11 +542,12 @@ Base options
542542
- ✅
543543
- ✅
544544
- ❌
545+
* - PYTHON_GIL
546+
- ✅
547+
- ✅
548+
- ✅
545549

546-
547-
548-
More environment variable-related information
549-
can be found in :ref:`environment variable substitutions`.
550+
More environment variable-related information can be found in :ref:`environment variable substitutions`.
550551

551552
.. conf::
552553
:keys: set_env, setenv
@@ -834,6 +835,7 @@ Python options
834835
Python version for a tox environment. If not specified, the virtual environments factors (e.g. name part) will be
835836
used to automatically set one. For example, ``py310`` means ``python3.10``, ``py3`` means ``python3`` and ``py``
836837
means ``python``. If the name does not match this pattern the same Python version tox is installed into will be used.
838+
A base interpreter ending with ``t`` means that only free threaded Python implementations are accepted.
837839

838840
.. versionchanged:: 3.1
839841

@@ -866,6 +868,29 @@ Python options
866868

867869
The Python executable from within the tox environment.
868870

871+
.. conf::
872+
:keys: py_dot_ver
873+
:constant:
874+
:version_added: 4.0.10
875+
876+
Major.Minor version of the Python interpreter in the tox environment (e.g., ``3.13``).
877+
878+
.. conf::
879+
:keys: py_impl
880+
:constant:
881+
:version_added: 4.0.10
882+
883+
Name of the Python implementation in the tox environment in lowercase (e.g., ``cpython``, ``pypy``).
884+
885+
.. conf::
886+
:keys: py_free_threaded
887+
:constant:
888+
:version_added: 4.26
889+
890+
``True`` if the Python interpreter in the tox environment is an experimental free-threaded CPython build,
891+
else ``False``.
892+
893+
869894
Python run
870895
~~~~~~~~~~
871896
.. conf::

src/tox/session/env_select.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ class _ToxEnvInfo:
136136
package_skip: tuple[str, Skip] | None = None #: if set the creation of the packaging environment failed
137137

138138

139-
_DYNAMIC_ENV_FACTORS = re.compile(r"(pypy|py|cython|)((\d(\.\d+(\.\d+)?)?)|\d+)?")
139+
_DYNAMIC_ENV_FACTORS = re.compile(r"(pypy|py|cython|)(((\d(\.\d+(\.\d+)?)?)|\d+)t?)?")
140140
_PY_PRE_RELEASE_FACTOR = re.compile(r"alpha|beta|rc\.\d+")
141141

142142

src/tox/tox_env/api.py

+1
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ def _default_pass_env(self) -> list[str]: # noqa: PLR6301
222222
"FORCE_COLOR", # force color output
223223
"NO_COLOR", # disable color output
224224
"NETRC", # used by pip and netrc modules
225+
"PYTHON_GIL", # allows controlling python gil
225226
]
226227
if sys.stdout.isatty(): # if we're on a interactive shell pass on the TERM
227228
env.append("TERM")

src/tox/tox_env/python/api.py

+20-8
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
import logging
66
import re
77
import sys
8+
import sysconfig
89
from abc import ABC, abstractmethod
10+
from dataclasses import dataclass
911
from pathlib import Path
10-
from typing import TYPE_CHECKING, Any, List, NamedTuple, cast
12+
from typing import TYPE_CHECKING, Any, List, NamedTuple
1113

1214
from virtualenv.discovery.py_spec import PythonSpec
1315

@@ -26,13 +28,15 @@ class VersionInfo(NamedTuple):
2628
serial: int
2729

2830

29-
class PythonInfo(NamedTuple):
31+
@dataclass(frozen=True)
32+
class PythonInfo:
3033
implementation: str
3134
version_info: VersionInfo
3235
version: str
3336
is_64: bool
3437
platform: str
3538
extra: dict[str, Any]
39+
free_threaded: bool = False
3640

3741
@property
3842
def version_no_dot(self) -> str:
@@ -51,11 +55,14 @@ def version_dot(self) -> str:
5155
r"""
5256
^(?!py$) # don't match 'py' as it doesn't provide any info
5357
(?P<impl>py|pypy|cpython|jython|graalpy|rustpython|ironpython) # the interpreter; most users will simply use 'py'
54-
(?P<version>[2-9]\.?[0-9]?[0-9]?)?$ # the version; one of: MAJORMINOR, MAJOR.MINOR
58+
(?:
59+
(?P<version>[2-9]\.?[0-9]?[0-9]?) # the version; one of: MAJORMINOR, MAJOR.MINOR
60+
(?P<threaded>t?) # version followed by t for free-threading
61+
)?$
5562
""",
5663
re.VERBOSE,
5764
)
58-
PY_FACTORS_RE_EXPLICIT_VERSION = re.compile(r"^((?P<impl>cpython|pypy)-)?(?P<version>[2-9]\.[0-9]+)$")
65+
PY_FACTORS_RE_EXPLICIT_VERSION = re.compile(r"^((?P<impl>cpython|pypy)-)?(?P<version>[2-9]\.[0-9]+)(?P<threaded>t?)$")
5966

6067

6168
class Python(ToxEnv, ABC):
@@ -100,6 +107,7 @@ def validate_base_python(value: list[str]) -> list[str]:
100107
)
101108
self.conf.add_constant("py_dot_ver", "<python major>.<python minor>", value=self.py_dot_ver)
102109
self.conf.add_constant("py_impl", "python implementation", value=self.py_impl)
110+
self.conf.add_constant("py_free_threaded", "is no-gil interpreted", value=self.py_free_threaded)
103111

104112
def _default_set_env(self) -> dict[str, str]:
105113
env = super()._default_set_env()
@@ -111,6 +119,9 @@ def _default_set_env(self) -> dict[str, str]:
111119
def py_dot_ver(self) -> str:
112120
return self.base_python.version_dot
113121

122+
def py_free_threaded(self) -> bool:
123+
return self.base_python.free_threaded
124+
114125
def py_impl(self) -> str:
115126
return self.base_python.impl_lower
116127

@@ -145,7 +156,7 @@ def extract_base_python(cls, env_name: str) -> str | None:
145156
match = PY_FACTORS_RE_EXPLICIT_VERSION.match(env_name)
146157
if match:
147158
found = match.groupdict()
148-
candidates.append(f"{'pypy' if found['impl'] == 'pypy' else ''}{found['version']}")
159+
candidates.append(f"{'pypy' if found['impl'] == 'pypy' else ''}{found['version']}{found['threaded']}")
149160
else:
150161
for factor in env_name.split("-"):
151162
match = PY_FACTORS_RE.match(factor)
@@ -163,7 +174,8 @@ def _python_spec_for_sys_executable(cls) -> PythonSpec:
163174
implementation = sys.implementation.name
164175
version = sys.version_info
165176
bits = "64" if sys.maxsize > 2**32 else "32"
166-
string_spec = f"{implementation}{version.major}{version.minor}-{bits}"
177+
threaded = "t" if sysconfig.get_config_var("Py_GIL_DISABLED") == 1 else ""
178+
string_spec = f"{implementation}{version.major}{version.minor}{threaded}-{bits}"
167179
return PythonSpec.from_string_spec(string_spec)
168180

169181
@classmethod
@@ -186,7 +198,7 @@ def _validate_base_python(
186198
spec_base = cls.python_spec_for_path(path)
187199
if any(
188200
getattr(spec_base, key) != getattr(spec_name, key)
189-
for key in ("implementation", "major", "minor", "micro", "architecture")
201+
for key in ("implementation", "major", "minor", "micro", "architecture", "free_threaded")
190202
if getattr(spec_name, key) is not None
191203
):
192204
msg = f"env name {env_name} conflicting with base python {base_python}"
@@ -290,7 +302,7 @@ def base_python(self) -> PythonInfo:
290302
raise Skip(msg)
291303
raise NoInterpreter(base_pythons)
292304

293-
return cast("PythonInfo", self._base_python)
305+
return self._base_python
294306

295307
def _get_env_journal_python(self) -> dict[str, Any]:
296308
return {

src/tox/tox_env/python/virtual_env/api.py

+1
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ def _get_python(self, base_python: list[str]) -> PythonInfo | None: # noqa: ARG
146146
is_64=(interpreter.architecture == 64), # noqa: PLR2004
147147
platform=interpreter.platform,
148148
extra={"executable": Path(interpreter.system_executable).resolve()},
149+
free_threaded=interpreter.free_threaded,
149150
)
150151

151152
def prepend_env_var_path(self) -> list[Path]:

tests/conftest.py

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import sys
5+
import sysconfig
56
from pathlib import Path
67
from typing import TYPE_CHECKING, Callable, Iterator, Protocol, Sequence
78
from unittest.mock import patch
@@ -100,6 +101,7 @@ def get_python(self: VirtualEnv, base_python: list[str]) -> PythonInfo | None:
100101
is_64=True,
101102
platform=sys.platform,
102103
extra={"executable": Path(sys.executable)},
104+
free_threaded=sysconfig.get_config_var("Py_GIL_DISABLED") == 1,
103105
)
104106

105107
mocker.patch.object(VirtualEnv, "_get_python", get_python)

tests/session/cmd/test_show_config.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def test_pass_env_config_default(tox_project: ToxProjectCreator, stdout_is_atty:
135135
+ (["PROGRAMDATA"] if is_win else [])
136136
+ (["PROGRAMFILES"] if is_win else [])
137137
+ (["PROGRAMFILES(x86)"] if is_win else [])
138-
+ ["REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"]
138+
+ ["PYTHON_GIL", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"]
139139
+ (["SYSTEMDRIVE", "SYSTEMROOT", "TEMP"] if is_win else [])
140140
+ (["TERM"] if stdout_is_atty else [])
141141
+ (["TMP", "USERPROFILE"] if is_win else ["TMPDIR"])

tests/session/test_env_select.py

+5
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,15 @@ def test_matches_combined_env(env_name: str, tox_project: ToxProjectCreator) ->
261261
"pypy312",
262262
"py3",
263263
"py3.12",
264+
"py3.12t",
264265
"py312",
266+
"py312t",
265267
"3",
268+
"3t",
266269
"3.12",
270+
"3.12t",
267271
"3.12.0",
272+
"3.12.0t",
268273
],
269274
)
270275
def test_dynamic_env_factors_match(env: str) -> None:

tests/tox_env/python/test_python_api.py

+44-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import sys
4+
import sysconfig
45
from types import SimpleNamespace
56
from typing import TYPE_CHECKING, Callable
67
from unittest.mock import patch
@@ -81,30 +82,56 @@ def test_diff_msg_no_diff() -> None:
8182
("env", "base_python"),
8283
[
8384
("py3", "py3"),
85+
("py3t", "py3t"),
8486
("py311", "py311"),
87+
("py311t", "py311t"),
8588
("py3.12", "py3.12"),
89+
("py3.12t", "py3.12t"),
8690
("pypy2", "pypy2"),
91+
("pypy2t", "pypy2t"),
8792
("rustpython3", "rustpython3"),
93+
("rustpython3t", "rustpython3t"),
8894
("graalpy", "graalpy"),
95+
("graalpyt", None),
8996
("jython", "jython"),
97+
("jythont", None),
9098
("cpython3.8", "cpython3.8"),
99+
("cpython3.8t", "cpython3.8t"),
91100
("ironpython2.7", "ironpython2.7"),
101+
("ironpython2.7t", "ironpython2.7t"),
92102
("functional-py310", "py310"),
103+
("functional-py310t", "py310t"),
93104
("bar-pypy2-foo", "pypy2"),
105+
("bar-foo2t-py2", "py2"),
106+
("bar-pypy2t-foo", "pypy2t"),
94107
("py", None),
108+
("pyt", None),
95109
("django-32", None),
110+
("django-32t", None),
96111
("eslint-8.3", None),
112+
("eslint-8.3t", None),
97113
("py-310", None),
114+
("py-310t", None),
98115
("py3000", None),
116+
("py3000t", None),
99117
("4.foo", None),
118+
("4.foot", None),
100119
("310", None),
120+
("310t", None),
101121
("5", None),
122+
("5t", None),
102123
("2000", None),
124+
("2000t", None),
103125
("4000", None),
126+
("4000t", None),
104127
("3.10", "3.10"),
128+
("3.10t", "3.10t"),
105129
("3.9", "3.9"),
130+
("3.9t", "3.9t"),
106131
("2.7", "2.7"),
132+
("2.7t", "2.7t"),
107133
("pypy-3.10", "pypy3.10"),
134+
("pypy-3.10t", "pypy3.10t"),
108135
],
109136
ids=lambda a: "|".join(a) if isinstance(a, list) else str(a),
110137
)
@@ -294,13 +321,24 @@ def test_usedevelop_with_nonexistent_basepython(tox_project: ToxProjectCreator)
294321

295322

296323
@pytest.mark.parametrize(
297-
("impl", "major", "minor", "arch"),
324+
("impl", "major", "minor", "arch", "free_threaded"),
298325
[
299-
("cpython", 3, 12, 64),
300-
("pypy", 3, 9, 32),
326+
("cpython", 3, 12, 64, None),
327+
("cpython", 3, 13, 64, True),
328+
("cpython", 3, 13, 64, False),
329+
("pypy", 3, 9, 32, None),
301330
],
302331
)
303-
def test_python_spec_for_sys_executable(impl: str, major: int, minor: int, arch: int, mocker: MockerFixture) -> None:
332+
def test_python_spec_for_sys_executable( # noqa: PLR0913
333+
impl: str, major: int, minor: int, arch: int, free_threaded: bool | None, mocker: MockerFixture
334+
) -> None:
335+
get_config_var_ = sysconfig.get_config_var
336+
337+
def get_config_var(name: str) -> object:
338+
if name == "Py_GIL_DISABLED":
339+
return free_threaded
340+
return get_config_var_(name)
341+
304342
version_info = SimpleNamespace(major=major, minor=minor, micro=5, releaselevel="final", serial=0)
305343
implementation = SimpleNamespace(
306344
name=impl,
@@ -312,8 +350,10 @@ def test_python_spec_for_sys_executable(impl: str, major: int, minor: int, arch:
312350
mocker.patch.object(sys, "version_info", version_info)
313351
mocker.patch.object(sys, "implementation", implementation)
314352
mocker.patch.object(sys, "maxsize", 2**arch // 2 - 1)
353+
mocker.patch.object(sysconfig, "get_config_var", get_config_var)
315354
spec = Python._python_spec_for_sys_executable() # noqa: SLF001
316355
assert spec.implementation == impl
317356
assert spec.major == major
318357
assert spec.minor == minor
319358
assert spec.architecture == arch
359+
assert spec.free_threaded == bool(free_threaded)

tests/tox_env/python/test_python_runner.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import sys
4+
import sysconfig
45
from pathlib import Path
56
from typing import TYPE_CHECKING
67

@@ -152,11 +153,16 @@ def test_config_skip_missing_interpreters(
152153
assert result.code == (0 if expected else -1)
153154

154155

156+
SYS_PY_VER = "".join(str(i) for i in sys.version_info[0:2]) + (
157+
"t" if sysconfig.get_config_var("Py_GIL_DISABLED") == 1 else ""
158+
)
159+
160+
155161
@pytest.mark.parametrize(
156162
("skip", "env", "retcode"),
157163
[
158-
("true", f"py{''.join(str(i) for i in sys.version_info[0:2])}", 0),
159-
("false", f"py{''.join(str(i) for i in sys.version_info[0:2])}", 0),
164+
("true", f"py{SYS_PY_VER}", 0),
165+
("false", f"py{SYS_PY_VER}", 0),
160166
("true", "py31", -1),
161167
("false", "py31", 1),
162168
("true", None, 0),
@@ -169,8 +175,7 @@ def test_skip_missing_interpreters_specified_env(
169175
env: str | None,
170176
retcode: int,
171177
) -> None:
172-
py_ver = "".join(str(i) for i in sys.version_info[0:2])
173-
project = tox_project({"tox.ini": f"[tox]\nenvlist=py31,py{py_ver}\n[testenv]\nusedevelop=true"})
178+
project = tox_project({"tox.ini": f"[tox]\nenvlist=py31,py{SYS_PY_VER}\n[testenv]\nusedevelop=true"})
174179
args = [f"--skip-missing-interpreters={skip}"]
175180
if env:
176181
args += ["-e", env]

tox.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
requires = ["tox>=4.24.1"]
2-
env_list = ["fix", "3.14", "3.13", "3.12", "3.11", "3.10", "3.9", "cov", "type", "docs", "pkg_meta"]
2+
env_list = ["fix", "3.14t", "3.14", "3.13", "3.12", "3.11", "3.10", "3.9", "cov", "type", "docs", "pkg_meta"]
33
skip_missing_interpreters = true
44

55
[env_run_base]

0 commit comments

Comments
 (0)