Skip to content

Commit 4ee8e0c

Browse files
committed
add the missing py.finder I claimed I added.
1 parent edd3b52 commit 4ee8e0c

File tree

3 files changed

+242
-6
lines changed

3 files changed

+242
-6
lines changed

src/pythonfinder/finders/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@
1414
"AsdfFinder",
1515
]
1616

17-
# Import Windows registry finder if on Windows
17+
# Import Windows-specific finders if on Windows
1818
import os
1919

2020
if os.name == "nt":
2121
from .windows_registry import WindowsRegistryFinder
22+
from .py_launcher_finder import PyLauncherFinder
2223

23-
__all__.append("WindowsRegistryFinder")
24+
__all__.extend(["WindowsRegistryFinder", "PyLauncherFinder"])
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import subprocess
5+
import re
6+
from pathlib import Path
7+
from typing import Iterator
8+
9+
from ..exceptions import InvalidPythonVersion
10+
from ..models.python_info import PythonInfo
11+
from ..utils.version_utils import parse_python_version
12+
from .base_finder import BaseFinder
13+
14+
15+
class PyLauncherFinder(BaseFinder):
16+
"""
17+
Finder that uses the Windows py launcher (py.exe) to find Python installations.
18+
This is only available on Windows and requires the py launcher to be installed.
19+
"""
20+
21+
def __init__(self, ignore_unsupported: bool = True):
22+
"""
23+
Initialize a new PyLauncherFinder.
24+
25+
Args:
26+
ignore_unsupported: Whether to ignore unsupported Python versions.
27+
"""
28+
self.ignore_unsupported = ignore_unsupported
29+
self._python_versions: dict[Path, PythonInfo] = {}
30+
self._available = os.name == "nt" and self._is_py_launcher_available()
31+
32+
def _is_py_launcher_available(self) -> bool:
33+
"""
34+
Check if the py launcher is available.
35+
36+
Returns:
37+
True if the py launcher is available, False otherwise.
38+
"""
39+
try:
40+
subprocess.run(
41+
["py", "--list-paths"],
42+
stdout=subprocess.PIPE,
43+
stderr=subprocess.PIPE,
44+
text=True,
45+
check=False,
46+
)
47+
return True
48+
except (FileNotFoundError, subprocess.SubprocessError):
49+
return False
50+
51+
def _get_py_launcher_versions(self) -> list[tuple[str, str, str]]:
52+
"""
53+
Get a list of Python versions available through the py launcher.
54+
55+
Returns:
56+
A list of tuples (version, path, is_default) where:
57+
- version is the Python version (e.g. "3.11")
58+
- path is the path to the Python executable
59+
- is_default is "*" if this is the default version, "" otherwise
60+
"""
61+
if not self._available:
62+
return []
63+
64+
try:
65+
result = subprocess.run(
66+
["py", "--list-paths"],
67+
stdout=subprocess.PIPE,
68+
stderr=subprocess.PIPE,
69+
text=True,
70+
check=True,
71+
)
72+
73+
versions = []
74+
# Parse output like:
75+
# -V:3.12 * C:\Software\Python\Python_3_12\python.exe
76+
# -V:3.11 C:\Software\Python\Python_3_11\python.exe
77+
pattern = r'-V:(\S+)\s+(\*?)\s+(.+)'
78+
for line in result.stdout.splitlines():
79+
match = re.match(pattern, line.strip())
80+
if match:
81+
version, is_default, path = match.groups()
82+
versions.append((version, path, is_default))
83+
84+
return versions
85+
except (subprocess.SubprocessError, Exception):
86+
return []
87+
88+
def _create_python_info_from_py_launcher(
89+
self, version: str, path: str, is_default: str
90+
) -> PythonInfo | None:
91+
"""
92+
Create a PythonInfo object from py launcher information.
93+
94+
Args:
95+
version: The Python version (e.g. "3.11").
96+
path: The path to the Python executable.
97+
is_default: "*" if this is the default version, "" otherwise.
98+
99+
Returns:
100+
A PythonInfo object, or None if the information is invalid.
101+
"""
102+
if not path or not os.path.exists(path):
103+
return None
104+
105+
# Parse the version
106+
try:
107+
version_data = parse_python_version(version)
108+
except InvalidPythonVersion:
109+
if not self.ignore_unsupported:
110+
raise
111+
return None
112+
113+
# Create the PythonInfo object
114+
return PythonInfo(
115+
path=Path(path),
116+
version_str=version,
117+
major=version_data["major"],
118+
minor=version_data["minor"],
119+
patch=version_data["patch"],
120+
is_prerelease=version_data["is_prerelease"],
121+
is_postrelease=version_data["is_postrelease"],
122+
is_devrelease=version_data["is_devrelease"],
123+
is_debug=version_data["is_debug"],
124+
version=version_data["version"],
125+
architecture=None, # Will be determined when needed
126+
company="PythonCore", # Assuming py launcher only finds official Python
127+
name=f"python-{version}",
128+
executable=path,
129+
)
130+
131+
def _iter_pythons(self) -> Iterator[PythonInfo]:
132+
"""
133+
Iterate over all Python installations found by the py launcher.
134+
135+
Returns:
136+
An iterator of PythonInfo objects.
137+
"""
138+
if not self._available:
139+
return
140+
141+
for version, path, is_default in self._get_py_launcher_versions():
142+
python_info = self._create_python_info_from_py_launcher(version, path, is_default)
143+
if python_info:
144+
yield python_info
145+
146+
def find_all_python_versions(
147+
self,
148+
major: str | int | None = None,
149+
minor: int | None = None,
150+
patch: int | None = None,
151+
pre: bool | None = None,
152+
dev: bool | None = None,
153+
arch: str | None = None,
154+
name: str | None = None,
155+
) -> list[PythonInfo]:
156+
"""
157+
Find all Python versions matching the specified criteria.
158+
159+
Args:
160+
major: Major version number or full version string.
161+
minor: Minor version number.
162+
patch: Patch version number.
163+
pre: Whether to include pre-releases.
164+
dev: Whether to include dev-releases.
165+
arch: Architecture to include, e.g. '64bit'.
166+
name: The name of a python version, e.g. ``anaconda3-5.3.0``.
167+
168+
Returns:
169+
A list of PythonInfo objects matching the criteria.
170+
"""
171+
if not self._available:
172+
return []
173+
174+
# Parse the major version if it's a string
175+
if isinstance(major, str) and not any([minor, patch, pre, dev, arch]):
176+
version_dict = self.parse_major(major, minor, patch, pre, dev, arch)
177+
major = version_dict.get("major")
178+
minor = version_dict.get("minor")
179+
patch = version_dict.get("patch")
180+
pre = version_dict.get("is_prerelease")
181+
dev = version_dict.get("is_devrelease")
182+
arch = version_dict.get("arch")
183+
name = version_dict.get("name")
184+
185+
# Find all Python versions
186+
python_versions = []
187+
for python_info in self._iter_pythons():
188+
if python_info.matches(major, minor, patch, pre, dev, arch, None, name):
189+
python_versions.append(python_info)
190+
191+
# Sort by version
192+
return sorted(
193+
python_versions,
194+
key=lambda x: x.version_sort,
195+
reverse=True,
196+
)
197+
198+
def find_python_version(
199+
self,
200+
major: str | int | None = None,
201+
minor: int | None = None,
202+
patch: int | None = None,
203+
pre: bool | None = None,
204+
dev: bool | None = None,
205+
arch: str | None = None,
206+
name: str | None = None,
207+
) -> PythonInfo | None:
208+
"""
209+
Find a Python version matching the specified criteria.
210+
211+
Args:
212+
major: Major version number or full version string.
213+
minor: Minor version number.
214+
patch: Patch version number.
215+
pre: Whether to include pre-releases.
216+
dev: Whether to include dev-releases.
217+
arch: Architecture to include, e.g. '64bit'.
218+
name: The name of a python version, e.g. ``anaconda3-5.3.0``.
219+
220+
Returns:
221+
A PythonInfo object matching the criteria, or None if not found.
222+
"""
223+
python_versions = self.find_all_python_versions(
224+
major, minor, patch, pre, dev, arch, name
225+
)
226+
return python_versions[0] if python_versions else None

src/pythonfinder/pythonfinder.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515

1616
from .models.python_info import PythonInfo
1717

18-
# Import Windows registry finder if on Windows
18+
# Import Windows-specific finders if on Windows
1919
if os.name == "nt":
20-
from .finders import WindowsRegistryFinder
20+
from .finders import PyLauncherFinder, WindowsRegistryFinder
2121

2222

2323
class Finder:
@@ -64,22 +64,31 @@ def __init__(
6464
ignore_unsupported=ignore_unsupported,
6565
)
6666

67-
# Initialize Windows registry finder if on Windows
67+
# Initialize Windows-specific finders if on Windows
68+
self.py_launcher_finder = None
6869
self.windows_finder = None
6970
if os.name == "nt":
71+
self.py_launcher_finder = PyLauncherFinder(
72+
ignore_unsupported=ignore_unsupported,
73+
)
7074
self.windows_finder = WindowsRegistryFinder(
7175
ignore_unsupported=ignore_unsupported,
7276
)
7377

7478
# List of all finders
7579
self.finders: list[BaseFinder] = [
76-
self.system_finder,
7780
self.pyenv_finder,
7881
self.asdf_finder,
7982
]
8083

84+
# Add Windows-specific finders if on Windows
85+
if self.py_launcher_finder:
86+
self.finders.append(self.py_launcher_finder)
8187
if self.windows_finder:
8288
self.finders.append(self.windows_finder)
89+
90+
# Add system finder last
91+
self.finders.append(self.system_finder)
8392

8493
def which(self, executable: str) -> Path | None:
8594
"""

0 commit comments

Comments
 (0)