Skip to content

Commit f00b109

Browse files
committed
Feat: free-threaded python support
1 parent 4a8e50e commit f00b109

File tree

8 files changed

+93
-10
lines changed

8 files changed

+93
-10
lines changed

docs/changelog/3391.feature.rst

Lines changed: 3 additions & 0 deletions
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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,29 @@ Python options
866866

867867
The Python executable from within the tox environment.
868868

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

src/tox/session/env_select.py

Lines changed: 1 addition & 1 deletion
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/python/api.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import re
77
import sys
8+
import sysconfig
89
from abc import ABC, abstractmethod
910
from pathlib import Path
1011
from typing import TYPE_CHECKING, Any, List, NamedTuple, cast
@@ -30,6 +31,7 @@ class PythonInfo(NamedTuple):
3031
implementation: str
3132
version_info: VersionInfo
3233
version: str
34+
free_threaded: bool
3335
is_64: bool
3436
platform: str
3537
extra: dict[str, Any]
@@ -51,11 +53,14 @@ def version_dot(self) -> str:
5153
r"""
5254
^(?!py$) # don't match 'py' as it doesn't provide any info
5355
(?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
56+
(?:
57+
(?P<version>[2-9]\.?[0-9]?[0-9]?) # the version; one of: MAJORMINOR, MAJOR.MINOR
58+
(?P<threaded>t?) # version followed by t for free-threading
59+
)?$
5560
""",
5661
re.VERBOSE,
5762
)
58-
PY_FACTORS_RE_EXPLICIT_VERSION = re.compile(r"^((?P<impl>cpython|pypy)-)?(?P<version>[2-9]\.[0-9]+)$")
63+
PY_FACTORS_RE_EXPLICIT_VERSION = re.compile(r"^((?P<impl>cpython|pypy)-)?(?P<version>[2-9]\.[0-9]+)(?P<threaded>t?)$")
5964

6065

6166
class Python(ToxEnv, ABC):
@@ -100,6 +105,7 @@ def validate_base_python(value: list[str]) -> list[str]:
100105
)
101106
self.conf.add_constant("py_dot_ver", "<python major>.<python minor>", value=self.py_dot_ver)
102107
self.conf.add_constant("py_impl", "python implementation", value=self.py_impl)
108+
self.conf.add_constant("py_free_threaded", "is no-gil interpreted", value=self.py_free_threaded)
103109

104110
def _default_set_env(self) -> dict[str, str]:
105111
env = super()._default_set_env()
@@ -111,6 +117,9 @@ def _default_set_env(self) -> dict[str, str]:
111117
def py_dot_ver(self) -> str:
112118
return self.base_python.version_dot
113119

120+
def py_free_threaded(self) -> bool:
121+
return self.base_python.free_threaded
122+
114123
def py_impl(self) -> str:
115124
return self.base_python.impl_lower
116125

@@ -145,7 +154,7 @@ def extract_base_python(cls, env_name: str) -> str | None:
145154
match = PY_FACTORS_RE_EXPLICIT_VERSION.match(env_name)
146155
if match:
147156
found = match.groupdict()
148-
candidates.append(f"{'pypy' if found['impl'] == 'pypy' else ''}{found['version']}")
157+
candidates.append(f"{'pypy' if found['impl'] == 'pypy' else ''}{found['version']}{found['threaded']}")
149158
else:
150159
for factor in env_name.split("-"):
151160
match = PY_FACTORS_RE.match(factor)
@@ -163,7 +172,8 @@ def _python_spec_for_sys_executable(cls) -> PythonSpec:
163172
implementation = sys.implementation.name
164173
version = sys.version_info
165174
bits = "64" if sys.maxsize > 2**32 else "32"
166-
string_spec = f"{implementation}{version.major}{version.minor}-{bits}"
175+
threaded = "t" if sysconfig.get_config_var("Py_GIL_DISABLED") == 1 else ""
176+
string_spec = f"{implementation}{version.major}{version.minor}{threaded}-{bits}"
167177
return PythonSpec.from_string_spec(string_spec)
168178

169179
@classmethod
@@ -186,7 +196,7 @@ def _validate_base_python(
186196
spec_base = cls.python_spec_for_path(path)
187197
if any(
188198
getattr(spec_base, key) != getattr(spec_name, key)
189-
for key in ("implementation", "major", "minor", "micro", "architecture")
199+
for key in ("implementation", "major", "minor", "micro", "architecture", "free_threaded")
190200
if getattr(spec_name, key) is not None
191201
):
192202
msg = f"env name {env_name} conflicting with base python {base_python}"

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ def _get_python(self, base_python: list[str]) -> PythonInfo | None: # noqa: ARG
143143
implementation=interpreter.implementation,
144144
version_info=interpreter.version_info,
145145
version=interpreter.version,
146+
free_threaded=interpreter.free_threaded,
146147
is_64=(interpreter.architecture == 64), # noqa: PLR2004
147148
platform=interpreter.platform,
148149
extra={"executable": Path(interpreter.system_executable).resolve()},

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def get_python(self: VirtualEnv, base_python: list[str]) -> PythonInfo | None:
9797
implementation=impl,
9898
version_info=ver_info,
9999
version="",
100+
free_threaded=False,
100101
is_64=True,
101102
platform=sys.platform,
102103
extra={"executable": Path(sys.executable)},

tests/session/test_env_select.py

Lines changed: 5 additions & 0 deletions
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

Lines changed: 44 additions & 4 deletions
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(*args: object, **kwargs: object) -> object:
338+
if not kwargs and args == ("Py_GIL_DISABLED",):
339+
return free_threaded
340+
return get_config_var_(*args, **kwargs)
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)

0 commit comments

Comments
 (0)