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 .github/workflows/auto_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.14-dev", "3.13", "3.12", "3.11", "3.10"]
python-version: ["3.14", "3.13", "3.12", "3.11", "3.10"]

steps:
- uses: actions/checkout@v5
Expand Down
115 changes: 90 additions & 25 deletions src/ducktools/pythonfinder/venv.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

try:
from _collections_abc import Iterable
except ImportError:
except ImportError: # pragma: nocover
from collections.abc import Iterable

import os
Expand Down Expand Up @@ -76,20 +76,58 @@ class PythonVEnv(Prefab):
executable: str
version: tuple[int, int, int, str, int]
parent_path: str
_implementation: str | None = attribute(default=None, repr=False)
_parent_executable: str | None = attribute(default=None, repr=False)

@property
def version_str(self) -> str:
return version_tuple_to_str(self.version)

@property
def implementation(self) -> str | None:
if not self._implementation:
try:
pyout = _laz.run(
[
self.executable,
"-c",
"import sys; sys.stdout.write(sys.implementation.name)"
],
capture_output=True,
text=True,
check=True,
)
except (_laz.subprocess.CalledProcessError, FileNotFoundError):
pass
else:
if out_implementation := pyout.stdout:
self._implementation = out_implementation.lower().strip()

return self._implementation

@property
def parent_executable(self) -> str | None:
if self._parent_executable is None:
# Guess the parent executable file
parent_exe = None
if sys.platform == "win32":
parent_exe = os.path.join(self.parent_path, "python.exe")
else:

parent_exe: None | str = None

implementation_bins = {
"cpython": "python",
"pypy": "pypy",
"graalpy": "graalpy",
}

venv_exe_path = _laz.Path(self.executable)

if venv_exe_path.is_symlink():
parent_path = venv_exe_path.resolve()
if parent_path.exists():
parent_exe = str(venv_exe_path.resolve())

elif self.implementation and self.implementation in implementation_bins:

bin_name = implementation_bins[self.implementation]

# try with additional numbers in order eg: python3.13, python313, python3, python
suffixes = [
f"{self.version[0]}.{self.version[1]}",
Expand All @@ -98,28 +136,42 @@ def parent_executable(self) -> str | None:
""
]

for suffix in suffixes:
parent_exe = os.path.join(self.parent_path, f"python{suffix}")
# Guess the parent executable file
if sys.platform == "win32":
names = [
f"{bin_name}{suffix}.exe" for suffix in suffixes
]
else:
names = [
f"{bin_name}{suffix}" for suffix in suffixes
]

for candidate in names:
parent_exe = os.path.join(self.parent_path, candidate)
if os.path.exists(parent_exe):
break

if not (parent_exe and os.path.exists(parent_exe)):
try:
pyout = _laz.run(
[
self.executable,
"-c",
"import sys; sys.stdout.write(getattr(sys, '_base_executable', ''))",
],
capture_output=True,
text=True,
check=True,
)
except (_laz.subprocess.CalledProcessError, FileNotFoundError):
pass
else:
if out_exe := pyout.stdout:
parent_exe = os.path.join(self.parent_path, os.path.basename(out_exe))
# Exhausted options and none exist
parent_exe = None

# base_executable should point to the correct path from 3.11+, except on PyPy
if not parent_exe and self.version >= (3, 11) and self.implementation != "pypy":
try:
pyout = _laz.run(
[
self.executable,
"-c",
"import sys; sys.stdout.write(getattr(sys, '_base_executable', ''))",
],
capture_output=True,
text=True,
check=True,
)
except (_laz.subprocess.CalledProcessError, FileNotFoundError):
pass
else:
if out_exe := pyout.stdout:
parent_exe = out_exe

self._parent_executable = parent_exe

Expand Down Expand Up @@ -202,8 +254,20 @@ def from_cfg(cls, cfg_path: str | os.PathLike) -> PythonVEnv:

parent_path = conf.get("home")
version_str = conf.get("version", conf.get("version_info"))

# Included in venv and virtualenv generated venvs
parent_exe = conf.get("executable", conf.get("base-executable"))

# Included in virtualenv and uv generated venvs
implementation = conf.get("implementation")

if implementation:
implementation = implementation.lower()
# More graalpy special casing
# For whatever reason in pyvenv the listing is graalvm not graalpy
if implementation == "graalvm":
implementation = "graalpy"

if parent_path is None or version_str is None:
# Not a valid venv
raise InvalidVEnvError(f"Path or version not defined in {cfg_path}")
Expand Down Expand Up @@ -238,6 +302,7 @@ def from_cfg(cls, cfg_path: str | os.PathLike) -> PythonVEnv:
version=version_tuple,
parent_path=parent_path,
_parent_executable=parent_exe,
_implementation=implementation,
)


Expand Down
13 changes: 10 additions & 3 deletions src/ducktools/pythonfinder/win32/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ def get_python_installs(
*,
finder: DetailFinder | None = None
) -> Iterator[PythonInstall]:
listed_installs = set()
listed_stdlibs = set()
listed_bins = set()

finder = DetailFinder() if finder is None else finder

Expand All @@ -48,6 +49,12 @@ def get_python_installs(
get_pyenv_pythons(finder=finder),
get_uv_pythons(finder=finder),
):
if py.executable not in listed_installs:
# Compare by stdlib paths for uniqueness
stdlib_path = py.paths.get("stdlib")
if stdlib_path:
if stdlib_path not in listed_stdlibs:
yield py
listed_stdlibs.add(stdlib_path)
elif py.executable not in listed_bins:
yield py
listed_installs.add(py.executable)
listed_bins.add(py.executable)
51 changes: 37 additions & 14 deletions tests/test_venv_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,13 @@ def make_venv(pth):
subprocess.run(
[
py_exe,
"-m", "venv",
"-m",
"venv",
"--without-pip",
os.path.join(tmpdir, pth),
],
check=True,
capture_output=True
capture_output=True,
)

make_venv(".venv")
Expand Down Expand Up @@ -90,29 +91,40 @@ def test_local_found(with_venvs):


def test_parent_not_always_searched(with_venvs):
venvs = list_python_venvs(base_dir=os.path.join(with_venvs, "subfolder"), search_parent_folders=False)
venvs = list_python_venvs(
base_dir=os.path.join(with_venvs, "subfolder"), search_parent_folders=False
)

assert len(venvs) == 1
assert os.path.samefile(venvs[0].folder, os.path.join(with_venvs, "subfolder/.venv"))
assert os.path.samefile(
venvs[0].folder, os.path.join(with_venvs, "subfolder/.venv")
)


def test_found_in_parent(with_venvs):
venvs = list_python_venvs(base_dir=os.path.join(with_venvs, "subfolder"), search_parent_folders=True)
venvs = list_python_venvs(
base_dir=os.path.join(with_venvs, "subfolder"), search_parent_folders=True
)

assert os.path.samefile(venvs[0].folder, os.path.join(with_venvs, "subfolder/.venv"))
assert os.path.samefile(
venvs[0].folder, os.path.join(with_venvs, "subfolder/.venv")
)
assert os.path.samefile(venvs[1].folder, os.path.join(with_venvs, ".venv"))


def test_all_found(with_venvs):
venvs = sorted(
list_python_venvs(base_dir=with_venvs, recursive=True),
key=lambda x: x.folder
list_python_venvs(base_dir=with_venvs, recursive=True), key=lambda x: x.folder
)

assert len(venvs) == 3
assert os.path.samefile(venvs[0].folder, os.path.join(with_venvs, ".venv"))
assert os.path.samefile(venvs[1].folder, os.path.join(with_venvs, "subfolder/.venv"))
assert os.path.samefile(venvs[2].folder, os.path.join(with_venvs, "subfolder/subsubfolder/env"))
assert os.path.samefile(
venvs[1].folder, os.path.join(with_venvs, "subfolder/.venv")
)
assert os.path.samefile(
venvs[2].folder, os.path.join(with_venvs, "subfolder/subsubfolder/env")
)


def test_recursive_parents(with_venvs):
Expand All @@ -122,13 +134,19 @@ def test_recursive_parents(with_venvs):
recursive=True,
search_parent_folders=True,
),
key=lambda x: x.folder
key=lambda x: x.folder,
)

assert len(venvs) == 3
assert os.path.samefile(venvs[0].folder, os.path.join(with_venvs, ".venv"))
assert os.path.samefile(venvs[1].folder, os.path.join(with_venvs, "subfolder/.venv"))
assert os.path.samefile(venvs[2].folder, os.path.join(with_venvs, "subfolder/subsubfolder/env"))
assert os.path.samefile(
venvs[1].folder,
os.path.join(with_venvs, "subfolder/.venv"),
)
assert os.path.samefile(
venvs[2].folder,
os.path.join(with_venvs, "subfolder/subsubfolder/env"),
)


def test_found_parent(with_venvs, this_python, this_venv):
Expand All @@ -138,13 +156,18 @@ def test_found_parent(with_venvs, this_python, this_venv):

# We found the base env that created this python, all details match
parent = venv_ex.get_parent_install()
assert os.path.dirname(parent.executable) == os.path.dirname(this_python.executable)
assert os.path.samefile(parent.executable, this_python.executable)

# venvs created by the venv module don't record prerelease details in the version
# That's not my fault that's venv!
assert venv_ex.version[:3] == parent.version[:3]


def test_found_implementation(with_venvs, this_python):
venv_ex = list_python_venvs(base_dir=with_venvs, recursive=False)[0]
assert venv_ex.implementation == this_python.implementation


def test_found_parent_cache(with_venvs, this_python, temp_finder):
venv_ex = list_python_venvs(base_dir=with_venvs, recursive=False)[0]

Expand Down
Loading