Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

prototyping pythonfinder pep514 support #6140

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
101 changes: 75 additions & 26 deletions pipenv/vendor/pythonfinder/models/python.py
Expand Up @@ -39,6 +39,14 @@
logger = logging.getLogger(__name__)


class WindowsLauncherEntry:
def __init__(self, version: Version, install_path: str, executable_path: str, company: Optional[str]):
matteius marked this conversation as resolved.
Show resolved Hide resolved
self.version = version
self.install_path = install_path
self.executable_path = executable_path
self.company = company

matteius marked this conversation as resolved.
Show resolved Hide resolved

@dataclasses.dataclass
class PythonFinder(PathEntry):
root: Path = field(default_factory=Path)
Expand Down Expand Up @@ -103,7 +111,13 @@ def version_from_bin_dir(cls, entry) -> PathEntry | None:
py_version = next(iter(entry.find_all_python_versions()), None)
return py_version

def _iter_version_bases(self) -> Iterator[tuple[Path, PathEntry]]:
def _iter_version_bases(self):
# Yield versions from the Windows launcher
if os.name == "nt":
for launcher_entry in self.find_python_versions_from_windows_launcher():
yield (launcher_entry.install_path, launcher_entry)

# Yield versions from the existing logic
for p in self.get_version_order():
bin_dir = self.get_bin_dir(p)
if bin_dir.exists() and bin_dir.is_dir():
Expand Down Expand Up @@ -275,6 +289,7 @@ def find_python_version(
:returns: A :class:`~pythonfinder.models.PathEntry` instance matching the version requested.
"""


def sub_finder(obj):
return obj.find_python_version(major, minor, patch, pre, dev, arch, name)

Expand Down Expand Up @@ -302,6 +317,51 @@ def which(self, name) -> PathEntry | None:
non_empty_match = next(iter(m for m in matches if m is not None), None)
return non_empty_match

def find_python_versions_from_windows_launcher(self):
# Open the registry key for Python launcher
key_path = r"Software\Python\PythonCore"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be nice to support non-PythonCore environments at some point, e.g., conda.

try:
import winreg
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path) as key:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's two other registry locations we need to apply the same process to, to find user-land installations and 32-bit installs on 64-bit Windows.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your help with all this -- greatly appreciated. Do you know what the other paths would be? I can get back to looking at this soon.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per PEP-0514, the three registry paths that need to be checked are:

HKEY_CURRENT_USER\Software\Python\<Company>\<Tag>
HKEY_LOCAL_MACHINE\Software\Python\<Company>\<Tag>
HKEY_LOCAL_MACHINE\Software\Wow6432Node\Python\<Company>\<Tag>

You can check them all the same way, there's some notes there about what to do if you see the same Company-Tag combination in different registry paths, and specific notes about how to disambiguate 32-bit and 64-bit Python older than 3.5, which did not include architecture in its Tag, and so has the same Company-Tag for 32-bit and 64-bit installs.

num_subkeys, _, _ = winreg.QueryInfoKey(key)

for i in range(num_subkeys):
version_key_name = winreg.EnumKey(key, i)

with winreg.OpenKey(key, version_key_name) as version_key:
try:
install_path_key = winreg.OpenKey(version_key, "InstallPath")
try:
executable_path = winreg.QueryValue(install_path_key, "ExecutablePath")
except FileNotFoundError:
# TODO: Only a valid default for PythonCore, otherwise skip.
executable_path = winreg.QueryValue(install_path_key, None)+"\\python.exe"
except FileNotFoundError:
continue

try:
version = Version(winreg.QueryValue(key, "SysVersion"))
except FileNotFoundError:
# TODO: Only a valid default for PythonCore, otherwise unknown so will need to be probed later.
version = Version(version_key_name)

try:
architecture = winreg.QueryValue(key, "SysArchitecture")
except FileNotFoundError:
# TODO: Implement PEP-514 defaults for architecture for PythonCore based on key and OS architecture.
architecture = None

launcher_entry = WindowsLauncherEntry(
version=version,
executable_path=executable_path,
company="PythonCore",
)
matteius marked this conversation as resolved.
Show resolved Hide resolved
yield launcher_entry

except FileNotFoundError:
# Python launcher registry key not found, no Python versions registered
return


@dataclasses.dataclass
class PythonVersion:
Expand Down Expand Up @@ -577,39 +637,28 @@ def parse_executable(cls, path) -> dict[str, str | int | Version | None]:
return result_dict

@classmethod
def from_windows_launcher(
cls, launcher_entry, name=None, company=None
) -> PythonVersion:
def from_windows_launcher(cls, launcher_entry, name=None, company=None):
"""Create a new PythonVersion instance from a Windows Launcher Entry

:param launcher_entry: A python launcher environment object.
:param launcher_entry: A WindowsLauncherEntry object.
:param Optional[str] name: The name of the distribution.
:param Optional[str] company: The name of the distributing company.
:return: An instance of a PythonVersion.
"""
creation_dict = cls.parse(launcher_entry.info.version)
base_path = ensure_path(launcher_entry.info.install_path.__getattr__(""))
default_path = base_path / "python.exe"
if not default_path.exists():
default_path = base_path / "Scripts" / "python.exe"
exe_path = ensure_path(
getattr(launcher_entry.info.install_path, "executable_path", default_path)
)
company = getattr(launcher_entry, "company", guess_company(exe_path))
creation_dict.update(
{
"architecture": getattr(
launcher_entry.info, "sys_architecture", SYSTEM_ARCH
),
"executable": exe_path,
"name": name,
"company": company,
}
py_version = cls.create(
major=launcher_entry.version.major,
minor=launcher_entry.version.minor,
patch=launcher_entry.version.micro,
is_prerelease=launcher_entry.version.is_prerelease,
is_devrelease=launcher_entry.version.is_devrelease,
is_debug=False, # Assuming debug information is not available from the registry
architecture=None, # Assuming architecture information is not available from the registry
matteius marked this conversation as resolved.
Show resolved Hide resolved
executable=launcher_entry.executable_path,
company=launcher_entry.company,
)
py_version = cls.create(**creation_dict)
comes_from = PathEntry.create(exe_path, only_python=True, name=name)
comes_from = PathEntry.create(launcher_entry.executable_path, only_python=True, name=name)
py_version.comes_from = comes_from
py_version.name = comes_from.name
py_version.name = name
return py_version

@classmethod
Expand Down