diff --git a/src/findpython/__main__.py b/src/findpython/__main__.py index 4e4906d..24f9bf1 100644 --- a/src/findpython/__main__.py +++ b/src/findpython/__main__.py @@ -80,7 +80,7 @@ def cli(argv: list[str] | None = None) -> int: python_versions = [python_versions] print("Found matching python versions:", file=sys.stderr) for python_version in python_versions: - print(python_version.executable if args.path else python_version) + print(python_version.executable if args.path else python_version.display()) return 0 diff --git a/src/findpython/finder.py b/src/findpython/finder.py index 3ccb3de..7180abf 100644 --- a/src/findpython/finder.py +++ b/src/findpython/finder.py @@ -67,6 +67,7 @@ def find_all( architecture: str | None = None, allow_prereleases: bool = False, implementation: str | None = None, + freethreaded: bool | None = None, ) -> list[PythonVersion]: """ Return all Python versions matching the given version criteria. @@ -80,6 +81,7 @@ def find_all( :param architecture: The architecture of the python. :param allow_prereleases: Whether to allow prereleases. :param implementation: The implementation of the python. E.g. "cpython", "pypy". + :param freethreaded: Whether the python is freethreaded. :return: a list of PythonVersion objects """ if allow_prereleases and (pre is False or dev is False): @@ -104,6 +106,7 @@ def find_all( dev = dev or None architecture = version_dict["architecture"] implementation = version_dict["implementation"] + freethreaded = version_dict["freethreaded"] else: name, major = major, None @@ -117,6 +120,7 @@ def find_all( name, architecture, implementation, + freethreaded, ) # Deduplicate with the python executable path matched_python = set(self._find_all_python_versions()) diff --git a/src/findpython/python.py b/src/findpython/python.py index cdf154c..c5b30f7 100644 --- a/src/findpython/python.py +++ b/src/findpython/python.py @@ -39,6 +39,7 @@ class PythonVersion: _architecture: str | None = None _interpreter: Path | None = None keep_symlink: bool = False + _freethreaded: bool | None = None def is_valid(self) -> bool: """Return True if the python is not broken.""" @@ -66,7 +67,7 @@ def real_path(self) -> Path: @property def implementation(self) -> str: """Return the implementation of the python.""" - script = "import platform; print(platform.python_implementation())" + script = "import platform; print(platform.python_implementation().lower())" return _run_script(str(self.executable), script).strip() @property @@ -118,6 +119,12 @@ def architecture(self) -> str: self._architecture = self._get_architecture() return self._architecture + @property + def freethreaded(self) -> bool: + if self._freethreaded is None: + self._freethreaded = self._get_freethreaded() + return self._freethreaded + def binary_hash(self) -> str: """Return the binary hash of the python.""" return get_binary_hash(self.real_path) @@ -132,6 +139,7 @@ def matches( name: str | None = None, architecture: str | None = None, implementation: str | None = None, + freethreaded: bool | None = None, ) -> bool: """ Return True if the python matches the provided criteria. @@ -152,6 +160,8 @@ def matches( :type architecture: str :param implementation: The implementation of the python. :type implementation: str + :param freethreaded: Whether the python is freethreaded. + :type freethreaded: bool :return: Whether the python matches the provided criteria. :rtype: bool """ @@ -174,6 +184,8 @@ def matches( and self.implementation.lower() != implementation.lower() ): return False + if freethreaded is not None and self.freethreaded != freethreaded: + return False return True def __hash__(self) -> int: @@ -188,13 +200,21 @@ def __repr__(self) -> str: "major", "minor", "patch", + "freethreaded", ) return "".format( ", ".join(f"{attr}={getattr(self, attr)!r}" for attr in attrs) ) + def display(self) -> str: + threaded_flag = "t" if self.freethreaded else "" + return ( + f"{self.implementation:>9}@{self.version}{threaded_flag}: {self.executable}" + ) + def __str__(self) -> str: - return f"{self.implementation:>9}@{self.version}: {self.executable}" + threaded_flag = "t" if self.freethreaded else "" + return f"{self.implementation}@{self.version}{threaded_flag}" def _get_version(self) -> Version: """Get the version of the python.""" @@ -216,14 +236,22 @@ def _get_interpreter(self) -> str: script = "import sys; print(sys.executable)" return _run_script(str(self.executable), script).strip() + def _get_freethreaded(self) -> bool: + script = ( + 'import sysconfig;print(sysconfig.get_config_var("Py_GIL_DISABLED") or 0)' + ) + return _run_script(str(self.executable), script).strip() == "1" + def __lt__(self, other: PythonVersion) -> bool: """Sort by the version, then by length of the executable path.""" return ( self.version, int(self.architecture.startswith("64bit")), len(self.executable.as_posix()), + self.freethreaded, ) < ( other.version, int(other.architecture.startswith("64bit")), len(other.executable.as_posix()), + other.freethreaded, ) diff --git a/src/findpython/utils.py b/src/findpython/utils.py index 3869d9e..2c0516e 100644 --- a/src/findpython/utils.py +++ b/src/findpython/utils.py @@ -16,7 +16,7 @@ r"(?:(?P\w+)@)?(?P\d+)(?:\.(?P\d+)(?:\.(?P[0-9]+))?)?\.?" r"(?:(?P[abc]|rc|dev)(?:(?P\d+(?:\.\d+)*))?)" r"?(?P(\.post(?P\d+))?(\.dev(?P\d+))?)?" - r"(?:-(?P32|64))?" + r"(?Pt)?(?:-(?P32|64))?" ) WINDOWS = sys.platform == "win32" MACOS = sys.platform == "darwin" @@ -37,7 +37,7 @@ else: KNOWN_EXTS = ("", ".sh", ".bash", ".csh", ".zsh", ".fish", ".py") PY_MATCH_STR = ( - r"((?P{0})(?:\d(?:\.?\d\d?[cpm]{{0,3}})?)?" + r"((?P{0})(?:\d(?:\.?\d\d?(?:[cpm]|td?){{0,3}})?)?" r"(?:(?<=\d)-[\d\.]+)*(?!w))(?P{1})$".format( "|".join(PYTHON_IMPLEMENTATIONS), "|".join(KNOWN_EXTS), @@ -130,6 +130,7 @@ class VersionDict(TypedDict): patch: int | None architecture: str | None implementation: str | None + freethreaded: bool def parse_major(version: str) -> VersionDict | None: @@ -140,6 +141,7 @@ def parse_major(version: str) -> VersionDict | None: rv = match.groupdict() rv["pre"] = bool(rv.pop("prerel")) rv["dev"] = bool(rv.pop("dev")) + rv["freethreaded"] = bool(rv.pop("freethreaded")) for int_values in ("major", "minor", "patch"): if rv[int_values] is not None: rv[int_values] = int(rv[int_values]) diff --git a/tests/conftest.py b/tests/conftest.py index bb7bbdc..ee00a76 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ def add_python( architecture="64bit", interpreter=None, keep_symlink=False, + freethreaded=False, ) -> PythonVersion: if version is not None: version = parse(version) @@ -31,7 +32,7 @@ def add_python( executable.touch(exist_ok=True) executable.chmod(0o744) py_ver = PythonVersion( - executable, version, architecture, interpreter, keep_symlink + executable, version, architecture, interpreter, keep_symlink, freethreaded ) if version is not None: py_ver._get_version = lambda: version # type:ignore[method-assign] @@ -56,7 +57,7 @@ def mocked_python(tmp_path, monkeypatch) -> _MockRegistry: ) monkeypatch.setattr( "findpython.python.PythonVersion.implementation", - PropertyMock(return_value="CPython"), + PropertyMock(return_value="cpython"), ) ALL_PROVIDERS.clear() ALL_PROVIDERS["path"] = PathProvider diff --git a/tests/test_cli.py b/tests/test_cli.py index dd6bc05..0f071be 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,7 +7,7 @@ def test_cli_find_pythons(mocked_python, capsys): out, _ = capsys.readouterr() lines = out.strip().splitlines() for version, line in zip(("3.9", "3.8", "3.7"), lines): - assert line.lstrip().startswith(f"CPython@{version}.0") + assert line.lstrip().startswith(f"cpython@{version}.0") def test_cli_find_python_by_version(mocked_python, capsys, tmp_path): @@ -15,5 +15,24 @@ def test_cli_find_python_by_version(mocked_python, capsys, tmp_path): assert retcode == 0 out, _ = capsys.readouterr() line = out.strip() - assert line.startswith("CPython@3.8.0") + assert line.startswith("cpython@3.8.0") assert line.endswith(str(tmp_path / "python3.8")) + + +def test_cli_find_python_freethreaded(mocked_python, capsys, tmp_path): + mocked_python.add_python(tmp_path / "python3.13", "3.13.0") + mocked_python.add_python(tmp_path / "python3.13t", "3.13.0", freethreaded=True) + + retcode = cli(["--all", "3.13"]) + assert retcode == 0 + out, _ = capsys.readouterr() + line = out.strip() + assert "\n" not in line + assert line.lstrip().split(":")[0] == "cpython@3.13.0" + + retcode = cli(["--all", "3.13t"]) + assert retcode == 0 + out, _ = capsys.readouterr() + line = out.strip() + assert "\n" not in line + assert line.lstrip().split(":")[0] == "cpython@3.13.0t"