Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/findpython/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
4 changes: 4 additions & 0 deletions src/findpython/finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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):
Expand All @@ -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

Expand All @@ -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())
Expand Down
32 changes: 30 additions & 2 deletions src/findpython/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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
"""
Expand All @@ -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:
Expand All @@ -188,13 +200,21 @@ def __repr__(self) -> str:
"major",
"minor",
"patch",
"freethreaded",
)
return "<PythonVersion {}>".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."""
Expand All @@ -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,
)
6 changes: 4 additions & 2 deletions src/findpython/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
r"(?:(?P<implementation>\w+)@)?(?P<major>\d+)(?:\.(?P<minor>\d+)(?:\.(?P<patch>[0-9]+))?)?\.?"
r"(?:(?P<prerel>[abc]|rc|dev)(?:(?P<prerelversion>\d+(?:\.\d+)*))?)"
r"?(?P<postdev>(\.post(?P<post>\d+))?(\.dev(?P<dev>\d+))?)?"
r"(?:-(?P<architecture>32|64))?"
r"(?P<freethreaded>t)?(?:-(?P<architecture>32|64))?"
)
WINDOWS = sys.platform == "win32"
MACOS = sys.platform == "darwin"
Expand All @@ -37,7 +37,7 @@
else:
KNOWN_EXTS = ("", ".sh", ".bash", ".csh", ".zsh", ".fish", ".py")
PY_MATCH_STR = (
r"((?P<implementation>{0})(?:\d(?:\.?\d\d?[cpm]{{0,3}})?)?"
r"((?P<implementation>{0})(?:\d(?:\.?\d\d?(?:[cpm]|td?){{0,3}})?)?"
r"(?:(?<=\d)-[\d\.]+)*(?!w))(?P<suffix>{1})$".format(
"|".join(PYTHON_IMPLEMENTATIONS),
"|".join(KNOWN_EXTS),
Expand Down Expand Up @@ -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:
Expand All @@ -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])
Expand Down
5 changes: 3 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]
Expand All @@ -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
Expand Down
23 changes: 21 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,32 @@ 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):
retcode = cli(["3.8"])
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"