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
17 changes: 17 additions & 0 deletions news/5893.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Fix Windows py-launcher Python discovery for full version strings such as
``python_full_version = "3.11.9"`` in Pipfile and ``pipenv --python 3.11.9``
on the CLI.

Two bugs were addressed:

1. ``find_python_from_py_launcher`` incorrectly required three whitespace-separated
tokens on each ``py --list-paths`` output line, which caused every non-default
Python installation (lines without the ``*`` marker) to be silently skipped.
The parser now accepts both two-token (non-default) and three-token (default)
lines, using the last token as the executable path in both cases.

2. ``PyLauncherFinder`` stored only the ``major.minor`` version reported by
``py --list-paths`` (e.g. ``3.11``), so searches that required a specific
patch level (``patch=9``) never matched. The finder now queries each
discovered executable for its real version string (e.g. ``3.11.9``) so that
full-version lookups succeed.
18 changes: 11 additions & 7 deletions pipenv/utils/virtualenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,27 +485,31 @@ def find_python_from_py_launcher(version):
if c.returncode != 0:
return None

# Parse the output to find the requested version
# Parse the output to find the requested version.
# The format from `py --list-paths` is one of:
# -V:3.12 * C:\...\python.exe (default version, has *)
# -V:3.11 C:\...\python.exe (non-default, no *)
# split(None, 2) gives 3 parts for default entries and only 2 parts
# for non-default entries, so we must NOT require exactly 3 parts.
for line in c.stdout.splitlines():
line = line.strip()
if not line:
continue

# Format is: -V:3.9 * path\to\python.exe
# We need to extract the version and path
parts = line.split(None, 2)
if len(parts) < 3:
if len(parts) < 2:
continue

# Extract version from -V:3.9
# Extract version from -V:X.Y
v_part = parts[0]
if not v_part.startswith("-V:"):
continue

v = v_part[3:] # Remove -V: prefix
# Path is always the last token regardless of whether * is present
path = parts[-1]
if v == version:
# Found the requested version, return the path
return parts[-1]
return path
except Exception:
# If anything goes wrong, fall back to other methods
pass
Expand Down
42 changes: 30 additions & 12 deletions pipenv/vendor/importlib_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,18 @@
NullFinder,
install,
)
from ._context import ExceptionTrap
from ._functools import method_cache, noop, pass_none, passthrough
from ._itertools import always_iterable, bucket, unique_everseen
from ._meta import PackageMetadata, SimplePath
from ._typing import md_none
from .compat import py39, py311
from .compat import py311

__all__ = [
'Distribution',
'DistributionFinder',
'PackageMetadata',
'PackageNotFoundError',
'MetadataNotFound',
'SimplePath',
'distribution',
'distributions',
Expand All @@ -70,6 +71,10 @@ def name(self) -> str: # type: ignore[override] # make readonly
return name


class MetadataNotFound(FileNotFoundError):
"""No metadata file is present in the distribution."""


class Sectioned:
"""
A simple entry point config parser for performance
Expand Down Expand Up @@ -340,7 +345,7 @@ def select(self, **params) -> EntryPoints:
Select entry points from self that match the
given parameters (typically group and/or name).
"""
return EntryPoints(ep for ep in self if py39.ep_matches(ep, **params))
return EntryPoints(ep for ep in self if ep.matches(**params))

@property
def names(self) -> set[str]:
Expand Down Expand Up @@ -491,7 +496,12 @@ def _prefer_valid(dists: Iterable[Distribution]) -> Iterable[Distribution]:

Ref python/importlib_resources#489.
"""
buckets = bucket(dists, lambda dist: bool(dist.metadata))

has_metadata = ExceptionTrap(MetadataNotFound).passes(
operator.attrgetter('metadata')
)

buckets = bucket(dists, has_metadata)
return itertools.chain(buckets[True], buckets[False])

@staticmethod
Expand All @@ -512,7 +522,7 @@ def _discover_resolvers():
return filter(None, declared)

@property
def metadata(self) -> _meta.PackageMetadata | None:
def metadata(self) -> _meta.PackageMetadata:
"""Return the parsed metadata for this Distribution.

The returned object will have keys that name the various bits of
Expand All @@ -521,6 +531,8 @@ def metadata(self) -> _meta.PackageMetadata | None:

Custom providers may provide the METADATA file or override this
property.

:raises MetadataNotFound: If no metadata file is present.
"""

text = (
Expand All @@ -531,20 +543,25 @@ def metadata(self) -> _meta.PackageMetadata | None:
# (which points to the egg-info file) attribute unchanged.
or self.read_text('')
)
return self._assemble_message(text)
return self._assemble_message(self._ensure_metadata_present(text))

@staticmethod
@pass_none
def _assemble_message(text: str) -> _meta.PackageMetadata:
# deferred for performance (python/cpython#109829)
from . import _adapters

return _adapters.Message(email.message_from_string(text))

def _ensure_metadata_present(self, text: str | None) -> str:
if text is not None:
return text

raise MetadataNotFound('No package metadata was found.')

@property
def name(self) -> str:
"""Return the 'Name' metadata for the distribution package."""
return md_none(self.metadata)['Name']
return self.metadata['Name']

@property
def _normalized_name(self):
Expand All @@ -554,7 +571,7 @@ def _normalized_name(self):
@property
def version(self) -> str:
"""Return the 'Version' metadata for the distribution package."""
return md_none(self.metadata)['Version']
return self.metadata['Version']

@property
def entry_points(self) -> EntryPoints:
Expand Down Expand Up @@ -1067,11 +1084,12 @@ def distributions(**kwargs) -> Iterable[Distribution]:
return Distribution.discover(**kwargs)


def metadata(distribution_name: str) -> _meta.PackageMetadata | None:
def metadata(distribution_name: str) -> _meta.PackageMetadata:
"""Get the metadata for the named package.

:param distribution_name: The name of the distribution package to query.
:return: A PackageMetadata containing the parsed metadata.
:raises MetadataNotFound: If no metadata file is present in the distribution.
"""
return Distribution.from_name(distribution_name).metadata

Expand All @@ -1088,7 +1106,7 @@ def version(distribution_name: str) -> str:

_unique = functools.partial(
unique_everseen,
key=py39.normalized_name,
key=operator.attrgetter('_normalized_name'),
)
"""
Wrapper for ``distributions`` to return unique distributions by name.
Expand Down Expand Up @@ -1142,7 +1160,7 @@ def packages_distributions() -> Mapping[str, list[str]]:
pkg_to_dist = collections.defaultdict(list)
for dist in distributions():
for pkg in _top_level_declared(dist) or _top_level_inferred(dist):
pkg_to_dist[pkg].append(md_none(dist.metadata)['Name'])
pkg_to_dist[pkg].append(dist.metadata['Name'])
return dict(pkg_to_dist)


Expand Down
118 changes: 118 additions & 0 deletions pipenv/vendor/importlib_metadata/_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from __future__ import annotations

import functools
import operator


# from jaraco.context 6.1
class ExceptionTrap:
"""
A context manager that will catch certain exceptions and provide an
indication they occurred.

>>> with ExceptionTrap() as trap:
... raise Exception()
>>> bool(trap)
True

>>> with ExceptionTrap() as trap:
... pass
>>> bool(trap)
False

>>> with ExceptionTrap(ValueError) as trap:
... raise ValueError("1 + 1 is not 3")
>>> bool(trap)
True
>>> trap.value
ValueError('1 + 1 is not 3')
>>> trap.tb
<traceback object at ...>

>>> with ExceptionTrap(ValueError) as trap:
... raise Exception()
Traceback (most recent call last):
...
Exception

>>> bool(trap)
False
"""

exc_info = None, None, None

def __init__(self, exceptions=(Exception,)):
self.exceptions = exceptions

def __enter__(self):
return self

@property
def type(self):
return self.exc_info[0]

@property
def value(self):
return self.exc_info[1]

@property
def tb(self):
return self.exc_info[2]

def __exit__(self, *exc_info):
type = exc_info[0]
matches = type and issubclass(type, self.exceptions)
if matches:
self.exc_info = exc_info
return matches

def __bool__(self):
return bool(self.type)

def raises(self, func, *, _test=bool):
"""
Wrap func and replace the result with the truth
value of the trap (True if an exception occurred).

First, give the decorator an alias to support Python 3.8
Syntax.

>>> raises = ExceptionTrap(ValueError).raises

Now decorate a function that always fails.

>>> @raises
... def fail():
... raise ValueError('failed')
>>> fail()
True
"""

@functools.wraps(func)
def wrapper(*args, **kwargs):
with ExceptionTrap(self.exceptions) as trap:
func(*args, **kwargs)
return _test(trap)

return wrapper

def passes(self, func):
"""
Wrap func and replace the result with the truth
value of the trap (True if no exception).

First, give the decorator an alias to support Python 3.8
Syntax.

>>> passes = ExceptionTrap(ValueError).passes

Now decorate a function that always fails.

>>> @passes
... def fail():
... raise ValueError('failed')

>>> fail()
False
"""
return self.raises(func, _test=operator.not_)
3 changes: 2 additions & 1 deletion pipenv/vendor/importlib_metadata/_functools.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import functools
import types
from typing import Callable, TypeVar
from collections.abc import Callable
from typing import TypeVar


# from jaraco.functools 3.3
Expand Down
15 changes: 0 additions & 15 deletions pipenv/vendor/importlib_metadata/_typing.py

This file was deleted.

42 changes: 0 additions & 42 deletions pipenv/vendor/importlib_metadata/compat/py39.py

This file was deleted.

Loading
Loading