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