Skip to content

Commit

Permalink
Add free-threaded Python support (#2809)
Browse files Browse the repository at this point in the history
Co-authored-by: Bernát Gábor <[email protected]>
  • Loading branch information
robsdedude and gaborbernat authored Jan 15, 2025
1 parent bc7a91a commit b00d59c
Show file tree
Hide file tree
Showing 25 changed files with 306 additions and 103 deletions.
23 changes: 19 additions & 4 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
fail-fast: false
matrix:
py:
- "3.13t"
- "3.13"
- "3.12"
- "3.11"
Expand Down Expand Up @@ -77,27 +78,41 @@ jobs:
shell: bash
run: echo ~/.local/bin >> $GITHUB_PATH
- name: Install tox
if: matrix.py == '3.13'
if: matrix.py == '3.13' || matrix.py == '3.13t'
run: uv tool install --python-preference only-managed --python 3.12 tox --with tox-uv
- name: Install tox
if: matrix.py != '3.13'
if: "!(matrix.py == '3.13' || matrix.py == '3.13t')"
run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv
- name: Setup brew python for test ${{ matrix.py }}
if: startsWith(matrix.py,'brew@')
if: startsWith(matrix.py, 'brew@')
run: |
set -e
PY=$(echo '${{ matrix.py }}' | cut -c 6-)
brew cleanup && brew upgrade python@$PY || brew install python@$PY
echo "/usr/local/opt/python@$PY/libexec/bin" >>"${GITHUB_PATH}"
shell: bash
- name: Setup python for test ${{ matrix.py }}
if: "!( startsWith(matrix.py,'brew@') || endsWith(matrix.py, '-dev') )"
if: "!(startsWith(matrix.py, 'brew@') || endsWith(matrix.py, 't'))"
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.py }}
allow-prereleases: true
# quansight-labs to install free-threaded python until actions/setup-python supports it
# https://github.com/actions/setup-python/issues/771
- name: Setup python for test ${{ matrix.py }}
if: endsWith(matrix.py, 't')
uses: quansight-labs/[email protected]
with:
python-version: ${{ matrix.py }}
- name: Pick environment to run
if: matrix.py != '3.13t'
run: python tasks/pick_tox_env.py ${{ matrix.py }}
- name: Pick environment to run
if: matrix.py == '3.13t' && runner.os != 'Windows'
run: python tasks/pick_tox_env.py ${{ matrix.py }} $Python_ROOT_DIR/bin/python
- name: Pick environment to run
if: matrix.py == '3.13t' && runner.os == 'Windows'
run: python tasks/pick_tox_env.py ${{ matrix.py }} $env:Python_ROOT_DIR\python.exe
- name: Setup test suite
run: tox run -vv --notest --skip-missing-interpreters false
- name: Run test suite
Expand Down
1 change: 1 addition & 0 deletions docs/changelog/2809.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for selecting free-threaded Python interpreters, e.g., `python3.13t`.
2 changes: 1 addition & 1 deletion docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Python and OS Compatibility

virtualenv works with the following Python interpreter implementations:

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

This means virtualenv works on the latest patch version of each of these minor versions. Previous patch versions are
Expand Down
3 changes: 2 additions & 1 deletion docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,14 @@ format is either:

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

For example:

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

Expand Down
17 changes: 14 additions & 3 deletions src/virtualenv/discovery/cached_py_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
_CACHE = OrderedDict()
_CACHE[Path(sys.executable)] = PythonInfo()
LOGGER = logging.getLogger(__name__)
_CACHE_FILE_VERSION = 1


def from_exe(cls, app_data, exe, env=None, raise_on_error=True, ignore_cache=False): # noqa: FBT002, PLR0913
Expand Down Expand Up @@ -64,8 +65,13 @@ def _get_via_file_cache(cls, app_data, path, exe, env):
with py_info_store.locked():
if py_info_store.exists(): # if exists and matches load
data = py_info_store.read()
of_path, of_st_mtime, of_content = data["path"], data["st_mtime"], data["content"]
if of_path == path_text and of_st_mtime == path_modified:
of_path, of_st_mtime, of_content, version = (
data["path"],
data["st_mtime"],
data["content"],
data.get("version"),
)
if of_path == path_text and of_st_mtime == path_modified and version == _CACHE_FILE_VERSION:
py_info = cls._from_dict(of_content.copy())
sys_exe = py_info.system_executable
if sys_exe is not None and not os.path.exists(sys_exe):
Expand All @@ -76,7 +82,12 @@ def _get_via_file_cache(cls, app_data, path, exe, env):
if py_info is None: # if not loaded run and save
failure, py_info = _run_subprocess(cls, exe, app_data, env)
if failure is None:
data = {"st_mtime": path_modified, "path": path_text, "content": py_info._to_dict()} # noqa: SLF001
data = {
"st_mtime": path_modified,
"path": path_text,
"content": py_info._to_dict(), # noqa: SLF001
"version": _CACHE_FILE_VERSION,
}
py_info_store.write(data)
else:
py_info = failure
Expand Down
25 changes: 19 additions & 6 deletions src/virtualenv/discovery/py_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def abs_path(v):

self.version = sys.version
self.os = os.name
self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1

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

@property
def spec(self):
return "{}{}-{}".format(self.implementation, ".".join(str(i) for i in self.version_info), self.architecture)
return "{}{}{}-{}".format(
self.implementation,
".".join(str(i) for i in self.version_info),
"t" if self.free_threaded else "",
self.architecture,
)

@classmethod
def clear_cache(cls, app_data):
Expand All @@ -300,7 +306,7 @@ def clear_cache(cls, app_data):
clear(app_data)
cls._cache_exe_discovery.clear()

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

if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
return False

for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro)):
if req is not None and our is not None and our != req:
return False
Expand Down Expand Up @@ -522,10 +531,14 @@ def _find_possible_exe_names(self):
for name in self._possible_base():
for at in (3, 2, 1, 0):
version = ".".join(str(i) for i in self.version_info[:at])
for arch in [f"-{self.architecture}", ""]:
for ext in EXTENSIONS:
candidate = f"{name}{version}{arch}{ext}"
name_candidate[candidate] = None
mods = [""]
if self.free_threaded:
mods.append("t")
for mod in mods:
for arch in [f"-{self.architecture}", ""]:
for ext in EXTENSIONS:
candidate = f"{name}{version}{mod}{arch}{ext}"
name_candidate[candidate] = None
return list(name_candidate.keys())

def _possible_base(self):
Expand Down
17 changes: 12 additions & 5 deletions src/virtualenv/discovery/py_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import os
import re

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


class PythonSpec:
Expand All @@ -20,18 +20,21 @@ def __init__( # noqa: PLR0913
micro: int | None,
architecture: int | None,
path: str | None,
*,
free_threaded: bool | None = None,
) -> None:
self.str_spec = str_spec
self.implementation = implementation
self.major = major
self.minor = minor
self.micro = micro
self.free_threaded = free_threaded
self.architecture = architecture
self.path = path

@classmethod
def from_string_spec(cls, string_spec: str): # noqa: C901, PLR0912
impl, major, minor, micro, arch, path = None, None, None, None, None, None
impl, major, minor, micro, threaded, arch, path = None, None, None, None, None, None, None
if os.path.isabs(string_spec): # noqa: PLR1702
path = string_spec
else:
Expand All @@ -58,6 +61,7 @@ def _int_or_none(val):
major = int(str(version_data)[0]) # first digit major
if version_data > 9: # noqa: PLR2004
minor = int(str(version_data)[1:])
threaded = bool(groups["threaded"])
ok = True
except ValueError:
pass
Expand All @@ -70,14 +74,15 @@ def _int_or_none(val):
if not ok:
path = string_spec

return cls(string_spec, impl, major, minor, micro, arch, path)
return cls(string_spec, impl, major, minor, micro, arch, path, free_threaded=threaded)

def generate_re(self, *, windows: bool) -> re.Pattern:
"""Generate a regular expression for matching against a filename."""
version = r"{}(\.{}(\.{})?)?".format(
*(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro))
)
impl = "python" if self.implementation is None else f"python|{re.escape(self.implementation)}"
mod = "t?" if self.free_threaded else ""
suffix = r"\.exe" if windows else ""
version_conditional = (
"?"
Expand All @@ -89,7 +94,7 @@ def generate_re(self, *, windows: bool) -> re.Pattern:
)
# Try matching `direct` first, so the `direct` group is filled when possible.
return re.compile(
rf"(?P<impl>{impl})(?P<v>{version}){version_conditional}{suffix}$",
rf"(?P<impl>{impl})(?P<v>{version}{mod}){version_conditional}{suffix}$",
flags=re.IGNORECASE,
)

Expand All @@ -105,6 +110,8 @@ def satisfies(self, spec):
return False
if spec.architecture is not None and spec.architecture != self.architecture:
return False
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
return False

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

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


Expand Down
4 changes: 2 additions & 2 deletions src/virtualenv/discovery/windows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ def propose_interpreters(spec, cache_dir, env):
reverse=True,
)

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

# Pre-filtering based on Windows Registry metadata, for CPython only
skip_pre_filter = implementation.lower() != "cpython"
registry_spec = PythonSpec(None, implementation, major, minor, None, arch, exe)
registry_spec = PythonSpec(None, implementation, major, minor, None, arch, exe, free_threaded=threaded)
if skip_pre_filter or registry_spec.satisfies(spec):
interpreter = Pep514PythonInfo.from_exe(exe, cache_dir, env=env, raise_on_error=False)
if interpreter is not None and interpreter.satisfies(spec, impl_must_match=True):
Expand Down
15 changes: 14 additions & 1 deletion src/virtualenv/discovery/windows/pep514.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ def process_tag(hive_name, company, company_key, tag, default_arch):
exe_data = load_exe(hive_name, company, company_key, tag)
if exe_data is not None:
exe, args = exe_data
return company, major, minor, arch, exe, args
threaded = load_threaded(hive_name, company, tag, tag_key)
return company, major, minor, arch, threaded, exe, args
return None
return None
return None
Expand Down Expand Up @@ -138,6 +139,18 @@ def parse_version(version_str):
raise ValueError(error)


def load_threaded(hive_name, company, tag, tag_key):
display_name = get_value(tag_key, "DisplayName")
if display_name is not None:
if isinstance(display_name, str):
if "freethreaded" in display_name.lower():
return True
else:
key_path = f"{hive_name}/{company}/{tag}/DisplayName"
msg(key_path, f"display name is not string: {display_name!r}")
return bool(re.match(r"^\d+(\.\d+){0,2}t$", tag, flags=re.IGNORECASE))


def msg(path, what):
LOGGER.warning("PEP-514 violation in Windows Registry at %s error: %s", path, what)

Expand Down
2 changes: 2 additions & 0 deletions tasks/pick_tox_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@
if py.startswith("brew@"):
py = py[len("brew@") :]
env = f"TOXENV={py}"
if len(sys.argv) > 2: # noqa: PLR2004
env += f"\nTOX_BASEPYTHON={sys.argv[2]}"
with Path(os.environ["GITHUB_ENV"]).open("ta", encoding="utf-8") as file_handler:
file_handler.write(env)
37 changes: 20 additions & 17 deletions tests/integration/test_zipapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,26 @@ def zipapp_build_env(tmp_path_factory):
exe, found = None, False
# prefer CPython as builder as pypy is slow
for impl in ["cpython", ""]:
for version in range(11, 6, -1):
with suppress(Exception):
# create a virtual environment which is also guaranteed to contain a recent enough pip (bundled)
session = cli_run(
[
"-vvv",
"-p",
f"{impl}3.{version}",
"--activators",
"",
str(create_env_path),
"--no-download",
"--no-periodic-update",
],
)
exe = str(session.creator.exe)
found = True
for threaded in ["", "t"]:
for version in range(11, 6, -1):
with suppress(Exception):
# create a virtual environment which is also guaranteed to contain a recent enough pip (bundled)
session = cli_run(
[
"-vvv",
"-p",
f"{impl}3.{version}{threaded}",
"--activators",
"",
str(create_env_path),
"--no-download",
"--no-periodic-update",
],
)
exe = str(session.creator.exe)
found = True
break
if found:
break
if found:
break
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,6 @@
"system_stdlib": "c:\\path\\to\\python\\Lib",
"system_stdlib_platform": "c:\\path\\to\\python\\Lib",
"max_size": 9223372036854775807,
"_creators": null
"_creators": null,
"free_threaded": false
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,6 @@
"system_stdlib": "/usr/lib/pypy3/lib-python/3.7",
"system_stdlib_platform": "/usr/lib/pypy3/lib-python/3.7",
"max_size": 9223372036854775807,
"_creators": null
"_creators": null,
"free_threaded": false
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,6 @@
"system_stdlib": "/usr/lib/pypy3.8",
"system_stdlib_platform": "/usr/lib/pypy3.8",
"max_size": 9223372036854775807,
"_creators": null
"_creators": null,
"free_threaded": false
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,6 @@
"system_stdlib": "/tmp/pypy3.8-v7.3.8-linux64/lib/pypy3.8",
"system_stdlib_platform": "/tmp/pypy3.8-v7.3.8-linux64/lib/pypy3.8",
"max_size": 9223372036854775807,
"_creators": null
"_creators": null,
"free_threaded": false
}
Loading

0 comments on commit b00d59c

Please sign in to comment.