Skip to content

Commit d804cfb

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

File tree

12 files changed

+108
-17
lines changed

12 files changed

+108
-17
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::

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ dependencies = [
6060
"pyproject-api>=1.8",
6161
"tomli>=2.2.1; python_version<'3.11'",
6262
"typing-extensions>=4.12.2; python_version<'3.11'",
63-
"virtualenv>=20.29.1",
63+
"virtualenv>=20.31",
6464
]
6565
optional-dependencies.test = [
6666
"devpi-process>=1.0.2",

src/tox/pytest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,8 @@ def our_setup_state(value: Sequence[str]) -> State:
281281
m.setenv("VIRTUALENV_SYMLINK_APP_DATA", "1")
282282
m.setenv("VIRTUALENV_SYMLINKS", "1")
283283
m.setenv("VIRTUALENV_PIP", "embed")
284-
m.setenv("VIRTUALENV_WHEEL", "embed")
284+
if sys.version_info[:2] < (3, 9):
285+
m.setenv("VIRTUALENV_WHEEL", "embed")
285286
m.setenv("VIRTUALENV_SETUPTOOLS", "embed")
286287
try:
287288
tox_run(args)

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/cmd/test_sequential.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ def test_result_json_sequential(
114114
py_test = get_cmd_exit_run_id(log_report, "py", "test")
115115
assert py_test == [(1, "commands[0]"), (0, "commands[1]")]
116116
packaging_installed = log_report["testenvs"]["py"].pop("installed_packages")
117-
expected_pkg = {"pip", "setuptools", "wheel", "a"}
117+
expected_pkg = {"pip", "setuptools", "a"}
118+
if sys.version_info[0:2] == (3, 8):
119+
expected_pkg.add("wheel")
118120
assert {i[: i.find("==")] if "@" not in i else "a" for i in packaging_installed} == expected_pkg
119121
install_package = log_report["testenvs"]["py"].pop("installpkg")
120122
assert re.match(r"^[a-fA-F0-9]{64}$", install_package.pop("sha256"))

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:

0 commit comments

Comments
 (0)