diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml
index cb3ea8491..6ed1814da 100644
--- a/.github/workflows/check.yaml
+++ b/.github/workflows/check.yaml
@@ -22,6 +22,7 @@ jobs:
fail-fast: false
matrix:
py:
+ - "3.13t"
- "3.13"
- "3.12"
- "3.11"
@@ -77,13 +78,13 @@ 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-)
@@ -91,13 +92,27 @@ jobs:
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/setup-python@v5.3.1
+ 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
diff --git a/docs/changelog/2809.feature.rst b/docs/changelog/2809.feature.rst
new file mode 100644
index 000000000..30849296a
--- /dev/null
+++ b/docs/changelog/2809.feature.rst
@@ -0,0 +1 @@
+Add support for selecting free-threaded Python interpreters, e.g., `python3.13t`.
diff --git a/docs/installation.rst b/docs/installation.rst
index c48db8f07..b82d1dd19 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -84,7 +84,7 @@ Python and OS Compatibility
virtualenv works with the following Python interpreter implementations:
-- `CPython `_: ``3.12 >= python_version >= 3.7``
+- `CPython `_: ``3.13 >= python_version >= 3.7``
- `PyPy `_: ``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
diff --git a/docs/user_guide.rst b/docs/user_guide.rst
index 54169e062..ed0fe1d9b 100644
--- a/docs/user_guide.rst
+++ b/docs/user_guide.rst
@@ -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``.
diff --git a/src/virtualenv/discovery/cached_py_info.py b/src/virtualenv/discovery/cached_py_info.py
index 846856d0e..d0b65676b 100644
--- a/src/virtualenv/discovery/cached_py_info.py
+++ b/src/virtualenv/discovery/cached_py_info.py
@@ -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
@@ -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):
@@ -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
diff --git a/src/virtualenv/discovery/py_info.py b/src/virtualenv/discovery/py_info.py
index 4895e2408..be978116e 100644
--- a/src/virtualenv/discovery/py_info.py
+++ b/src/virtualenv/discovery/py_info.py
@@ -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
@@ -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):
@@ -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):
@@ -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
@@ -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):
diff --git a/src/virtualenv/discovery/py_spec.py b/src/virtualenv/discovery/py_spec.py
index dcd84f423..d8519c23d 100644
--- a/src/virtualenv/discovery/py_spec.py
+++ b/src/virtualenv/discovery/py_spec.py
@@ -5,7 +5,7 @@
import os
import re
-PATTERN = re.compile(r"^(?P[a-zA-Z]+)?(?P[0-9.]+)?(?:-(?P32|64))?$")
+PATTERN = re.compile(r"^(?P[a-zA-Z]+)?(?P[0-9.]+)?(?Pt)?(?:-(?P32|64))?$")
class PythonSpec:
@@ -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:
@@ -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
@@ -70,7 +74,7 @@ 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."""
@@ -78,6 +82,7 @@ def generate_re(self, *, windows: bool) -> re.Pattern:
*(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 = (
"?"
@@ -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})(?P{version}){version_conditional}{suffix}$",
+ rf"(?P{impl})(?P{version}{mod}){version_conditional}{suffix}$",
flags=re.IGNORECASE,
)
@@ -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:
@@ -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)})"
diff --git a/src/virtualenv/discovery/windows/__init__.py b/src/virtualenv/discovery/windows/__init__.py
index 9efd5b6ab..b7206406a 100644
--- a/src/virtualenv/discovery/windows/__init__.py
+++ b/src/virtualenv/discovery/windows/__init__.py
@@ -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):
diff --git a/src/virtualenv/discovery/windows/pep514.py b/src/virtualenv/discovery/windows/pep514.py
index 8bc9e3060..a75dad36d 100644
--- a/src/virtualenv/discovery/windows/pep514.py
+++ b/src/virtualenv/discovery/windows/pep514.py
@@ -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
@@ -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)
diff --git a/tasks/pick_tox_env.py b/tasks/pick_tox_env.py
index fb0088458..2b0bffdbe 100644
--- a/tasks/pick_tox_env.py
+++ b/tasks/pick_tox_env.py
@@ -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)
diff --git a/tests/integration/test_zipapp.py b/tests/integration/test_zipapp.py
index b14344d16..e666657a9 100644
--- a/tests/integration/test_zipapp.py
+++ b/tests/integration/test_zipapp.py
@@ -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
diff --git a/tests/unit/create/via_global_ref/builtin/cpython/cpython3_win_embed.json b/tests/unit/create/via_global_ref/builtin/cpython/cpython3_win_embed.json
index e8d0d01c9..c75c6f4fc 100644
--- a/tests/unit/create/via_global_ref/builtin/cpython/cpython3_win_embed.json
+++ b/tests/unit/create/via_global_ref/builtin/cpython/cpython3_win_embed.json
@@ -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
}
diff --git a/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy37.json b/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy37.json
index eb694a840..4e6ca4dda 100644
--- a/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy37.json
+++ b/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy37.json
@@ -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
}
diff --git a/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy38.json b/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy38.json
index 0d91a969d..070867210 100644
--- a/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy38.json
+++ b/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy38.json
@@ -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
}
diff --git a/tests/unit/create/via_global_ref/builtin/pypy/portable_pypy38.json b/tests/unit/create/via_global_ref/builtin/pypy/portable_pypy38.json
index 0761455bb..136c1b4f6 100644
--- a/tests/unit/create/via_global_ref/builtin/pypy/portable_pypy38.json
+++ b/tests/unit/create/via_global_ref/builtin/pypy/portable_pypy38.json
@@ -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
}
diff --git a/tests/unit/create/via_global_ref/test_build_c_ext.py b/tests/unit/create/via_global_ref/test_build_c_ext.py
index db1c16eb0..08320673a 100644
--- a/tests/unit/create/via_global_ref/test_build_c_ext.py
+++ b/tests/unit/create/via_global_ref/test_build_c_ext.py
@@ -41,10 +41,19 @@ def test_can_build_c_extensions(creator, tmp_path, coverage_env):
shutil.copytree(str(Path(__file__).parent.resolve() / "greet"), greet)
session = cli_run(["--creator", creator, "--seeder", "app-data", str(env), "-vvv"])
coverage_env()
+ setuptools_index_args = ()
+ if CURRENT.version_info >= (3, 12):
+ # requires to be able to install setuptools as build dependency
+ setuptools_index_args = (
+ "--find-links",
+ "https://pypi.org/simple/setuptools/",
+ )
+
cmd = [
str(session.creator.script("pip")),
"install",
"--no-index",
+ *setuptools_index_args,
"--no-deps",
"--disable-pip-version-check",
"-vvv",
diff --git a/tests/unit/discovery/py_info/test_py_info.py b/tests/unit/discovery/py_info/test_py_info.py
index 7e9994fa3..c15387726 100644
--- a/tests/unit/discovery/py_info/test_py_info.py
+++ b/tests/unit/discovery/py_info/test_py_info.py
@@ -26,7 +26,9 @@ def test_current_as_json():
result = CURRENT._to_json() # noqa: SLF001
parsed = json.loads(result)
a, b, c, d, e = sys.version_info
+ f = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
assert parsed["version_info"] == {"major": a, "minor": b, "micro": c, "releaselevel": d, "serial": e}
+ assert parsed["free_threaded"] is f
def test_bad_exe_py_info_raise(tmp_path, session_app_data):
@@ -59,7 +61,7 @@ def test_bad_exe_py_info_no_raise(tmp_path, caplog, capsys, session_app_data):
itertools.chain(
[sys.executable],
[
- f"{impl}{'.'.join(str(i) for i in ver)}{arch}"
+ f"{impl}{'.'.join(str(i) for i in ver)}{'t' if CURRENT.free_threaded else ''}{arch}"
for impl, ver, arch in itertools.product(
(
[CURRENT.implementation]
@@ -90,6 +92,14 @@ def test_satisfy_not_arch():
assert matches is False
+def test_satisfy_not_threaded():
+ parsed_spec = PythonSpec.from_string_spec(
+ f"{CURRENT.implementation}{CURRENT.version_info.major}{'' if CURRENT.free_threaded else 't'}",
+ )
+ matches = CURRENT.satisfies(parsed_spec, True)
+ assert matches is False
+
+
def _generate_not_match_current_interpreter_version():
result = []
for i in range(3):
diff --git a/tests/unit/discovery/py_info/test_py_info_exe_based_of.py b/tests/unit/discovery/py_info/test_py_info_exe_based_of.py
index c843ca7be..90894a59c 100644
--- a/tests/unit/discovery/py_info/test_py_info_exe_based_of.py
+++ b/tests/unit/discovery/py_info/test_py_info_exe_based_of.py
@@ -30,7 +30,7 @@ def test_discover_ok(tmp_path, suffix, impl, version, arch, into, caplog, sessio
caplog.set_level(logging.DEBUG)
folder = tmp_path / into
folder.mkdir(parents=True, exist_ok=True)
- name = f"{impl}{version}"
+ name = f"{impl}{version}{'t' if CURRENT.free_threaded else ''}"
if arch:
name += f"-{arch}"
name += suffix
diff --git a/tests/unit/discovery/test_discovery.py b/tests/unit/discovery/test_discovery.py
index 8597c6651..c0156836b 100644
--- a/tests/unit/discovery/test_discovery.py
+++ b/tests/unit/discovery/test_discovery.py
@@ -21,6 +21,7 @@ def test_discovery_via_path(monkeypatch, case, specificity, tmp_path, caplog, se
caplog.set_level(logging.DEBUG)
current = PythonInfo.current_system(session_app_data)
name = "somethingVeryCryptic"
+ threaded = "t" if current.free_threaded else ""
if case == "lower":
name = name.lower()
elif case == "upper":
@@ -28,7 +29,7 @@ def test_discovery_via_path(monkeypatch, case, specificity, tmp_path, caplog, se
if specificity == "more":
# e.g. spec: python3, exe: /bin/python3.12
core_ver = current.version_info.major
- exe_ver = ".".join(str(i) for i in current.version_info[0:2])
+ exe_ver = ".".join(str(i) for i in current.version_info[0:2]) + threaded
elif specificity == "less":
# e.g. spec: python3.12.1, exe: /bin/python3
core_ver = ".".join(str(i) for i in current.version_info[0:3])
@@ -37,7 +38,7 @@ def test_discovery_via_path(monkeypatch, case, specificity, tmp_path, caplog, se
# e.g. spec: python3.12.1, exe: /bin/python
core_ver = ".".join(str(i) for i in current.version_info[0:3])
exe_ver = ""
- core = "" if specificity == "none" else f"{name}{core_ver}"
+ core = "" if specificity == "none" else f"{name}{core_ver}{threaded}"
exe_name = f"{name}{exe_ver}{'.exe' if sys.platform == 'win32' else ''}"
target = tmp_path / current.install_path("scripts")
target.mkdir(parents=True)
diff --git a/tests/unit/discovery/test_py_spec.py b/tests/unit/discovery/test_py_spec.py
index 765686645..0841019ec 100644
--- a/tests/unit/discovery/test_py_spec.py
+++ b/tests/unit/discovery/test_py_spec.py
@@ -45,6 +45,16 @@ def test_spec_satisfies_arch():
assert spec_2.satisfies(spec_1) is False
+def test_spec_satisfies_free_threaded():
+ spec_1 = PythonSpec.from_string_spec("python3.13t")
+ spec_2 = PythonSpec.from_string_spec("python3.13")
+
+ assert spec_1.satisfies(spec_1) is True
+ assert spec_1.free_threaded is True
+ assert spec_2.satisfies(spec_1) is False
+ assert spec_2.free_threaded is False
+
+
@pytest.mark.parametrize(
("req", "spec"),
[("py", "python"), ("jython", "jython"), ("CPython", "cpython")],
@@ -66,13 +76,22 @@ def test_spec_satisfies_implementation_nok():
def _version_satisfies_pairs():
target = set()
version = tuple(str(i) for i in sys.version_info[0:3])
- for i in range(len(version) + 1):
- req = ".".join(version[0:i])
- for j in range(i + 1):
- sat = ".".join(version[0:j])
- # can be satisfied in both directions
- target.add((req, sat))
- target.add((sat, req))
+ for threading in (False, True):
+ for i in range(len(version) + 1):
+ req = ".".join(version[0:i])
+ for j in range(i + 1):
+ sat = ".".join(version[0:j])
+ # can be satisfied in both directions
+ if sat:
+ target.add((req, sat))
+ # else: no version => no free-threading info
+ target.add((sat, req))
+ if not threading or not sat or not req:
+ # free-threading info requires a version
+ continue
+ target.add((f"{req}t", f"{sat}t"))
+ target.add((f"{sat}t", f"{req}t"))
+
return sorted(target)
diff --git a/tests/unit/discovery/windows/conftest.py b/tests/unit/discovery/windows/conftest.py
index 21c16891c..f75278e06 100644
--- a/tests/unit/discovery/windows/conftest.py
+++ b/tests/unit/discovery/windows/conftest.py
@@ -65,7 +65,7 @@ def _open_key_ex(*args):
mocker.patch("os.path.exists", return_value=True)
-def _mock_pyinfo(major, minor, arch, exe):
+def _mock_pyinfo(major, minor, arch, exe, threaded=False):
"""Return PythonInfo objects with essential metadata set for the given args"""
from virtualenv.discovery.py_info import PythonInfo, VersionInfo # noqa: PLC0415
@@ -75,6 +75,7 @@ def _mock_pyinfo(major, minor, arch, exe):
info.implementation = "CPython"
info.architecture = arch
info.version_info = VersionInfo(major, minor, 0, "final", 0)
+ info.free_threaded = threaded
return info
@@ -84,19 +85,21 @@ def _populate_pyinfo_cache(monkeypatch):
import virtualenv.discovery.cached_py_info # noqa: PLC0415
# Data matches _mock_registry fixture
+ python_core_path = "C:\\Users\\user\\AppData\\Local\\Programs\\Python"
interpreters = [
- ("ContinuumAnalytics", 3, 10, 32, "C:\\Users\\user\\Miniconda3\\python.exe", None),
- ("ContinuumAnalytics", 3, 10, 64, "C:\\Users\\user\\Miniconda3-64\\python.exe", None),
- ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None),
- ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None),
- ("PythonCore", 3, 5, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python35\\python.exe", None),
- ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None),
- ("PythonCore", 3, 7, 32, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python37-32\\python.exe", None),
- ("PythonCore", 3, 12, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", None),
- ("PythonCore", 2, 7, 64, "C:\\Python27\\python.exe", None),
- ("PythonCore", 3, 4, 64, "C:\\Python34\\python.exe", None),
- ("CompanyA", 3, 6, 64, "Z:\\CompanyA\\Python\\3.6\\python.exe", None),
+ ("ContinuumAnalytics", 3, 10, 32, False, "C:\\Users\\user\\Miniconda3\\python.exe"),
+ ("ContinuumAnalytics", 3, 10, 64, False, "C:\\Users\\user\\Miniconda3-64\\python.exe"),
+ ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"),
+ ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"),
+ ("PythonCore", 3, 5, 64, False, f"{python_core_path}\\Python35\\python.exe"),
+ ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"),
+ ("PythonCore", 3, 7, 32, False, f"{python_core_path}\\Python37-32\\python.exe"),
+ ("PythonCore", 3, 12, 64, False, f"{python_core_path}\\Python312\\python.exe"),
+ ("PythonCore", 3, 13, 64, True, f"{python_core_path}\\Python313\\python3.13t.exe"),
+ ("PythonCore", 2, 7, 64, False, "C:\\Python27\\python.exe"),
+ ("PythonCore", 3, 4, 64, False, "C:\\Python34\\python.exe"),
+ ("CompanyA", 3, 6, 64, False, "Z:\\CompanyA\\Python\\3.6\\python.exe"),
]
- for _, major, minor, arch, exe, _ in interpreters:
- info = _mock_pyinfo(major, minor, arch, exe)
+ for _, major, minor, arch, threaded, exe in interpreters:
+ info = _mock_pyinfo(major, minor, arch, exe, threaded)
monkeypatch.setitem(virtualenv.discovery.cached_py_info._CACHE, Path(info.executable), info) # noqa: SLF001
diff --git a/tests/unit/discovery/windows/test_windows.py b/tests/unit/discovery/windows/test_windows.py
index aca2afc14..594a1302f 100644
--- a/tests/unit/discovery/windows/test_windows.py
+++ b/tests/unit/discovery/windows/test_windows.py
@@ -20,11 +20,16 @@
("python3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
("cpython3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
# resolves to highest available version
- ("python", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
- ("cpython", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
+ ("python", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"),
+ ("cpython", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"),
+ ("python3", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
+ ("cpython3", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
# Non-standard org name
("python3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"),
("cpython3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"),
+ # free-threaded
+ ("3t", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"),
+ ("python3.13t", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"),
],
)
def test_propose_interpreters(string_spec, expected_exe):
diff --git a/tests/unit/discovery/windows/test_windows_pep514.py b/tests/unit/discovery/windows/test_windows_pep514.py
index 3b1c46984..0498352aa 100644
--- a/tests/unit/discovery/windows/test_windows_pep514.py
+++ b/tests/unit/discovery/windows/test_windows_pep514.py
@@ -13,17 +13,74 @@ def test_pep514():
interpreters = list(discover_pythons())
assert interpreters == [
- ("ContinuumAnalytics", 3, 10, 32, "C:\\Users\\user\\Miniconda3\\python.exe", None),
- ("ContinuumAnalytics", 3, 10, 64, "C:\\Users\\user\\Miniconda3-64\\python.exe", None),
- ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None),
- ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None),
- ("PythonCore", 3, 8, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe", None),
- ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None),
- ("PythonCore", 3, 10, 32, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe", None),
- ("PythonCore", 3, 12, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", None),
- ("CompanyA", 3, 6, 64, "Z:\\CompanyA\\Python\\3.6\\python.exe", None),
- ("PythonCore", 2, 7, 64, "C:\\Python27\\python.exe", None),
- ("PythonCore", 3, 7, 64, "C:\\Python37\\python.exe", None),
+ ("ContinuumAnalytics", 3, 10, 32, False, "C:\\Users\\user\\Miniconda3\\python.exe", None),
+ ("ContinuumAnalytics", 3, 10, 64, False, "C:\\Users\\user\\Miniconda3-64\\python.exe", None),
+ (
+ "PythonCore",
+ 3,
+ 9,
+ 64,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 9,
+ 64,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 8,
+ 64,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 9,
+ 64,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 10,
+ 32,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 12,
+ 64,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 13,
+ 64,
+ True,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe",
+ None,
+ ),
+ ("CompanyA", 3, 6, 64, False, "Z:\\CompanyA\\Python\\3.6\\python.exe", None),
+ ("PythonCore", 2, 7, 64, False, "C:\\Python27\\python.exe", None),
+ ("PythonCore", 3, 7, 64, False, "C:\\Python37\\python.exe", None),
]
@@ -36,18 +93,19 @@ def test_pep514_run(capsys, caplog):
out, err = capsys.readouterr()
expected = textwrap.dedent(
r"""
- ('CompanyA', 3, 6, 64, 'Z:\\CompanyA\\Python\\3.6\\python.exe', None)
- ('ContinuumAnalytics', 3, 10, 32, 'C:\\Users\\user\\Miniconda3\\python.exe', None)
- ('ContinuumAnalytics', 3, 10, 64, 'C:\\Users\\user\\Miniconda3-64\\python.exe', None)
- ('PythonCore', 2, 7, 64, 'C:\\Python27\\python.exe', None)
- ('PythonCore', 3, 10, 32, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe', None)
- ('PythonCore', 3, 12, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe', None)
- ('PythonCore', 3, 7, 64, 'C:\\Python37\\python.exe', None)
- ('PythonCore', 3, 8, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', None)
- ('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
- ('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
- ('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
- """,
+ ('CompanyA', 3, 6, 64, False, 'Z:\\CompanyA\\Python\\3.6\\python.exe', None)
+ ('ContinuumAnalytics', 3, 10, 32, False, 'C:\\Users\\user\\Miniconda3\\python.exe', None)
+ ('ContinuumAnalytics', 3, 10, 64, False, 'C:\\Users\\user\\Miniconda3-64\\python.exe', None)
+ ('PythonCore', 2, 7, 64, False, 'C:\\Python27\\python.exe', None)
+ ('PythonCore', 3, 10, 32, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe', None)
+ ('PythonCore', 3, 12, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe', None)
+ ('PythonCore', 3, 13, 64, True, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe', None)
+ ('PythonCore', 3, 7, 64, False, 'C:\\Python37\\python.exe', None)
+ ('PythonCore', 3, 8, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', None)
+ ('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
+ ('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
+ ('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
+ """, # noqa: E501
).strip()
assert out.strip() == expected
assert not err
diff --git a/tests/unit/discovery/windows/winreg-mock-values.py b/tests/unit/discovery/windows/winreg-mock-values.py
index da76c56b9..fa2619ba7 100644
--- a/tests/unit/discovery/windows/winreg-mock-values.py
+++ b/tests/unit/discovery/windows/winreg-mock-values.py
@@ -35,6 +35,8 @@
"3.11": 78700656,
"3.12\\InstallPath": 78703632,
"3.12": 78702608,
+ "3.13t\\InstallPath": 78703633,
+ "3.13t": 78702609,
"3.X": 78703088,
},
78702960: {"2.7\\InstallPath": 78700912, "2.7": 78703136, "3.7\\InstallPath": 78703648, "3.7": 78704032},
@@ -45,27 +47,27 @@
},
}
value_collect = {
- 78703568: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1)},
+ 78703568: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1), "DisplayName": ("Python 3.10 (32-bit)", 1)},
78703200: {
"ExecutablePath": ("C:\\Users\\user\\Miniconda3\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78702368: {"SysVersion": ("3.10", 1), "SysArchitecture": ("64bit", 1)},
+ 78702368: {"SysVersion": ("3.10", 1), "SysArchitecture": ("64bit", 1), "DisplayName": ("Python 3.10 (64-bit)", 1)},
78703520: {
"ExecutablePath": ("C:\\Users\\user\\Miniconda3-64\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78700704: {"SysVersion": ("3.9", 1), "SysArchitecture": ("magic", 1)},
+ 78700704: {"SysVersion": ("3.9", 1), "SysArchitecture": ("magic", 1), "DisplayName": ("Python 3.9 (wizardry)", 1)},
78701824: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78704368: {"SysVersion": ("3.9", 1), "SysArchitecture": (100, 4)},
+ 78704368: {"SysVersion": ("3.9", 1), "SysArchitecture": (100, 4), "DisplayName": ("Python 3.9 (64-bit)", 1)},
78704048: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78703024: {"SysVersion": ("3.9", 1), "SysArchitecture": ("64bit", 1)},
+ 78703024: {"SysVersion": ("3.9", 1), "SysArchitecture": ("64bit", 1), "DisplayName": ("Python 3.9 (64-bit)", 1)},
78701936: {
"ExecutablePath": OSError(2, "The system cannot find the file specified"),
None: OSError(2, "The system cannot find the file specified"),
@@ -73,17 +75,18 @@
78701792: {
"SysVersion": OSError(2, "The system cannot find the file specified"),
"SysArchitecture": OSError(2, "The system cannot find the file specified"),
+ "DisplayName": OSError(2, "The system cannot find the file specified"),
},
78703792: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78703424: {"SysVersion": ("3.9", 1), "SysArchitecture": ("64bit", 1)},
+ 78703424: {"SysVersion": ("3.9", 1), "SysArchitecture": ("64bit", 1), "DisplayName": ("Python 3.9 (64-bit)", 1)},
78701888: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78704512: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1)},
+ 78704512: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1), "DisplayName": ("Python 3.10 (32-bit)", 1)},
78703600: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
@@ -91,16 +94,31 @@
78700656: {
"SysVersion": OSError(2, "The system cannot find the file specified"),
"SysArchitecture": OSError(2, "The system cannot find the file specified"),
+ "DisplayName": OSError(2, "The system cannot find the file specified"),
+ },
+ 78702608: {
+ "SysVersion": ("magic", 1),
+ "SysArchitecture": ("64bit", 1),
+ "DisplayName": ("Python 3.12 (wizard edition)", 1),
},
- 78702608: {"SysVersion": ("magic", 1), "SysArchitecture": ("64bit", 1)},
78703632: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
+ 78702609: {
+ "SysVersion": ("3.13", 1),
+ "SysArchitecture": ("64bit", 1),
+ "DisplayName": ("Python 3.13 (64-bit, freethreaded)", 1),
+ },
+ 78703633: {
+ "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe", 1),
+ "ExecutableArguments": OSError(2, "The system cannot find the file specified"),
+ },
78703088: {"SysVersion": (2778, 11)},
78703136: {
"SysVersion": OSError(2, "The system cannot find the file specified"),
"SysArchitecture": OSError(2, "The system cannot find the file specified"),
+ "DisplayName": OSError(2, "The system cannot find the file specified"),
},
78700912: {
"ExecutablePath": OSError(2, "The system cannot find the file specified"),
@@ -110,6 +128,7 @@
78704032: {
"SysVersion": OSError(2, "The system cannot find the file specified"),
"SysArchitecture": OSError(2, "The system cannot find the file specified"),
+ "DisplayName": OSError(2, "The system cannot find the file specified"),
},
78703648: {
"ExecutablePath": OSError(2, "The system cannot find the file specified"),
@@ -120,7 +139,11 @@
"ExecutablePath": ("Z:\\CompanyA\\Python\\3.6\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 88820000: {"SysVersion": ("3.6", 1), "SysArchitecture": ("64bit", 1)},
+ 88820000: {
+ "SysVersion": ("3.6", 1),
+ "SysArchitecture": ("64bit", 1),
+ "DisplayName": OSError(2, "The system cannot find the file specified"),
+ },
}
enum_collect = {
78701856: [
@@ -139,6 +162,7 @@
"3.10-32",
"3.11",
"3.12",
+ "3.13t",
"3.X",
OSError(22, "No more data is available", None, 259, None),
],
diff --git a/tox.ini b/tox.ini
index e7e944189..3feaa4ef0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -13,6 +13,7 @@ env_list =
coverage
readme
docs
+ 3.13t
skip_missing_interpreters = true
[testenv]
@@ -67,6 +68,9 @@ commands =
sphinx-build -d "{envtmpdir}/doctree" docs "{toxworkdir}/docs_out" --color -b html {posargs:-W}
python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))'
+[testenv:3.13t]
+base_python = {env:TOX_BASEPYTHON}
+
[testenv:upgrade]
description = upgrade pip/wheels/setuptools to latest
skip_install = true