Skip to content

Commit fca7f70

Browse files
authored
Merge pull request #76 from DavidCEllis/improve-venv-base-path
* Fix UV created PyPy venvs using system PyPy pointing to the incorrect base Python. * Fix UV PyPy and Graalpy installs being listed twice on Windows.
2 parents 36e17d0 + 2d92230 commit fca7f70

File tree

5 files changed

+332
-186
lines changed

5 files changed

+332
-186
lines changed

.github/workflows/auto_test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
fail-fast: false
1515
matrix:
1616
os: [ubuntu-latest, windows-latest, macos-latest]
17-
python-version: ["3.14-dev", "3.13", "3.12", "3.11", "3.10"]
17+
python-version: ["3.14", "3.13", "3.12", "3.11", "3.10"]
1818

1919
steps:
2020
- uses: actions/checkout@v5

src/ducktools/pythonfinder/venv.py

Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
try:
2626
from _collections_abc import Iterable
27-
except ImportError:
27+
except ImportError: # pragma: nocover
2828
from collections.abc import Iterable
2929

3030
import os
@@ -76,20 +76,58 @@ class PythonVEnv(Prefab):
7676
executable: str
7777
version: tuple[int, int, int, str, int]
7878
parent_path: str
79+
_implementation: str | None = attribute(default=None, repr=False)
7980
_parent_executable: str | None = attribute(default=None, repr=False)
8081

8182
@property
8283
def version_str(self) -> str:
8384
return version_tuple_to_str(self.version)
8485

86+
@property
87+
def implementation(self) -> str | None:
88+
if not self._implementation:
89+
try:
90+
pyout = _laz.run(
91+
[
92+
self.executable,
93+
"-c",
94+
"import sys; sys.stdout.write(sys.implementation.name)"
95+
],
96+
capture_output=True,
97+
text=True,
98+
check=True,
99+
)
100+
except (_laz.subprocess.CalledProcessError, FileNotFoundError):
101+
pass
102+
else:
103+
if out_implementation := pyout.stdout:
104+
self._implementation = out_implementation.lower().strip()
105+
106+
return self._implementation
107+
85108
@property
86109
def parent_executable(self) -> str | None:
87110
if self._parent_executable is None:
88-
# Guess the parent executable file
89-
parent_exe = None
90-
if sys.platform == "win32":
91-
parent_exe = os.path.join(self.parent_path, "python.exe")
92-
else:
111+
112+
parent_exe: None | str = None
113+
114+
implementation_bins = {
115+
"cpython": "python",
116+
"pypy": "pypy",
117+
"graalpy": "graalpy",
118+
}
119+
120+
venv_exe_path = _laz.Path(self.executable)
121+
122+
if venv_exe_path.is_symlink():
123+
parent_path = venv_exe_path.resolve()
124+
if parent_path.exists():
125+
parent_exe = str(venv_exe_path.resolve())
126+
127+
elif self.implementation and self.implementation in implementation_bins:
128+
129+
bin_name = implementation_bins[self.implementation]
130+
93131
# try with additional numbers in order eg: python3.13, python313, python3, python
94132
suffixes = [
95133
f"{self.version[0]}.{self.version[1]}",
@@ -98,28 +136,42 @@ def parent_executable(self) -> str | None:
98136
""
99137
]
100138

101-
for suffix in suffixes:
102-
parent_exe = os.path.join(self.parent_path, f"python{suffix}")
139+
# Guess the parent executable file
140+
if sys.platform == "win32":
141+
names = [
142+
f"{bin_name}{suffix}.exe" for suffix in suffixes
143+
]
144+
else:
145+
names = [
146+
f"{bin_name}{suffix}" for suffix in suffixes
147+
]
148+
149+
for candidate in names:
150+
parent_exe = os.path.join(self.parent_path, candidate)
103151
if os.path.exists(parent_exe):
104152
break
105-
106-
if not (parent_exe and os.path.exists(parent_exe)):
107-
try:
108-
pyout = _laz.run(
109-
[
110-
self.executable,
111-
"-c",
112-
"import sys; sys.stdout.write(getattr(sys, '_base_executable', ''))",
113-
],
114-
capture_output=True,
115-
text=True,
116-
check=True,
117-
)
118-
except (_laz.subprocess.CalledProcessError, FileNotFoundError):
119-
pass
120153
else:
121-
if out_exe := pyout.stdout:
122-
parent_exe = os.path.join(self.parent_path, os.path.basename(out_exe))
154+
# Exhausted options and none exist
155+
parent_exe = None
156+
157+
# base_executable should point to the correct path from 3.11+, except on PyPy
158+
if not parent_exe and self.version >= (3, 11) and self.implementation != "pypy":
159+
try:
160+
pyout = _laz.run(
161+
[
162+
self.executable,
163+
"-c",
164+
"import sys; sys.stdout.write(getattr(sys, '_base_executable', ''))",
165+
],
166+
capture_output=True,
167+
text=True,
168+
check=True,
169+
)
170+
except (_laz.subprocess.CalledProcessError, FileNotFoundError):
171+
pass
172+
else:
173+
if out_exe := pyout.stdout:
174+
parent_exe = out_exe
123175

124176
self._parent_executable = parent_exe
125177

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

203255
parent_path = conf.get("home")
204256
version_str = conf.get("version", conf.get("version_info"))
257+
258+
# Included in venv and virtualenv generated venvs
205259
parent_exe = conf.get("executable", conf.get("base-executable"))
206260

261+
# Included in virtualenv and uv generated venvs
262+
implementation = conf.get("implementation")
263+
264+
if implementation:
265+
implementation = implementation.lower()
266+
# More graalpy special casing
267+
# For whatever reason in pyvenv the listing is graalvm not graalpy
268+
if implementation == "graalvm":
269+
implementation = "graalpy"
270+
207271
if parent_path is None or version_str is None:
208272
# Not a valid venv
209273
raise InvalidVEnvError(f"Path or version not defined in {cfg_path}")
@@ -238,6 +302,7 @@ def from_cfg(cls, cfg_path: str | os.PathLike) -> PythonVEnv:
238302
version=version_tuple,
239303
parent_path=parent_path,
240304
_parent_executable=parent_exe,
305+
_implementation=implementation,
241306
)
242307

243308

src/ducktools/pythonfinder/win32/__init__.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ def get_python_installs(
3838
*,
3939
finder: DetailFinder | None = None
4040
) -> Iterator[PythonInstall]:
41-
listed_installs = set()
41+
listed_stdlibs = set()
42+
listed_bins = set()
4243

4344
finder = DetailFinder() if finder is None else finder
4445

@@ -48,6 +49,12 @@ def get_python_installs(
4849
get_pyenv_pythons(finder=finder),
4950
get_uv_pythons(finder=finder),
5051
):
51-
if py.executable not in listed_installs:
52+
# Compare by stdlib paths for uniqueness
53+
stdlib_path = py.paths.get("stdlib")
54+
if stdlib_path:
55+
if stdlib_path not in listed_stdlibs:
56+
yield py
57+
listed_stdlibs.add(stdlib_path)
58+
elif py.executable not in listed_bins:
5259
yield py
53-
listed_installs.add(py.executable)
60+
listed_bins.add(py.executable)

tests/test_venv_finder.py

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,13 @@ def make_venv(pth):
5757
subprocess.run(
5858
[
5959
py_exe,
60-
"-m", "venv",
60+
"-m",
61+
"venv",
6162
"--without-pip",
6263
os.path.join(tmpdir, pth),
6364
],
6465
check=True,
65-
capture_output=True
66+
capture_output=True,
6667
)
6768

6869
make_venv(".venv")
@@ -90,29 +91,40 @@ def test_local_found(with_venvs):
9091

9192

9293
def test_parent_not_always_searched(with_venvs):
93-
venvs = list_python_venvs(base_dir=os.path.join(with_venvs, "subfolder"), search_parent_folders=False)
94+
venvs = list_python_venvs(
95+
base_dir=os.path.join(with_venvs, "subfolder"), search_parent_folders=False
96+
)
9497

9598
assert len(venvs) == 1
96-
assert os.path.samefile(venvs[0].folder, os.path.join(with_venvs, "subfolder/.venv"))
99+
assert os.path.samefile(
100+
venvs[0].folder, os.path.join(with_venvs, "subfolder/.venv")
101+
)
97102

98103

99104
def test_found_in_parent(with_venvs):
100-
venvs = list_python_venvs(base_dir=os.path.join(with_venvs, "subfolder"), search_parent_folders=True)
105+
venvs = list_python_venvs(
106+
base_dir=os.path.join(with_venvs, "subfolder"), search_parent_folders=True
107+
)
101108

102-
assert os.path.samefile(venvs[0].folder, os.path.join(with_venvs, "subfolder/.venv"))
109+
assert os.path.samefile(
110+
venvs[0].folder, os.path.join(with_venvs, "subfolder/.venv")
111+
)
103112
assert os.path.samefile(venvs[1].folder, os.path.join(with_venvs, ".venv"))
104113

105114

106115
def test_all_found(with_venvs):
107116
venvs = sorted(
108-
list_python_venvs(base_dir=with_venvs, recursive=True),
109-
key=lambda x: x.folder
117+
list_python_venvs(base_dir=with_venvs, recursive=True), key=lambda x: x.folder
110118
)
111119

112120
assert len(venvs) == 3
113121
assert os.path.samefile(venvs[0].folder, os.path.join(with_venvs, ".venv"))
114-
assert os.path.samefile(venvs[1].folder, os.path.join(with_venvs, "subfolder/.venv"))
115-
assert os.path.samefile(venvs[2].folder, os.path.join(with_venvs, "subfolder/subsubfolder/env"))
122+
assert os.path.samefile(
123+
venvs[1].folder, os.path.join(with_venvs, "subfolder/.venv")
124+
)
125+
assert os.path.samefile(
126+
venvs[2].folder, os.path.join(with_venvs, "subfolder/subsubfolder/env")
127+
)
116128

117129

118130
def test_recursive_parents(with_venvs):
@@ -122,13 +134,19 @@ def test_recursive_parents(with_venvs):
122134
recursive=True,
123135
search_parent_folders=True,
124136
),
125-
key=lambda x: x.folder
137+
key=lambda x: x.folder,
126138
)
127139

128140
assert len(venvs) == 3
129141
assert os.path.samefile(venvs[0].folder, os.path.join(with_venvs, ".venv"))
130-
assert os.path.samefile(venvs[1].folder, os.path.join(with_venvs, "subfolder/.venv"))
131-
assert os.path.samefile(venvs[2].folder, os.path.join(with_venvs, "subfolder/subsubfolder/env"))
142+
assert os.path.samefile(
143+
venvs[1].folder,
144+
os.path.join(with_venvs, "subfolder/.venv"),
145+
)
146+
assert os.path.samefile(
147+
venvs[2].folder,
148+
os.path.join(with_venvs, "subfolder/subsubfolder/env"),
149+
)
132150

133151

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

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

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

147165

166+
def test_found_implementation(with_venvs, this_python):
167+
venv_ex = list_python_venvs(base_dir=with_venvs, recursive=False)[0]
168+
assert venv_ex.implementation == this_python.implementation
169+
170+
148171
def test_found_parent_cache(with_venvs, this_python, temp_finder):
149172
venv_ex = list_python_venvs(base_dir=with_venvs, recursive=False)[0]
150173

0 commit comments

Comments
 (0)