|
22 | 22 | import sys |
23 | 23 | import textwrap |
24 | 24 | import types |
25 | | -from collections.abc import Iterable, Mapping |
| 25 | +from collections.abc import Callable, Iterable, Mapping |
26 | 26 | from contextlib import suppress |
27 | 27 | from importlib import import_module |
28 | 28 | from importlib.abc import MetaPathFinder |
|
35 | 35 | from ._functools import method_cache, noop, pass_none, passthrough |
36 | 36 | from ._itertools import always_iterable, bucket, unique_everseen |
37 | 37 | from ._meta import PackageMetadata, SimplePath |
38 | | -from ._typing import md_none |
39 | 38 |
|
40 | 39 | __all__ = [ |
41 | 40 | 'Distribution', |
42 | 41 | 'DistributionFinder', |
| 42 | + 'MetadataNotFound', |
43 | 43 | 'PackageMetadata', |
44 | 44 | 'PackageNotFoundError', |
45 | 45 | 'PackagePath', |
46 | | - 'MetadataNotFound', |
47 | 46 | 'SimplePath', |
48 | 47 | 'distribution', |
49 | 48 | 'distributions', |
@@ -175,9 +174,11 @@ class EntryPoint: |
175 | 174 | ... |
176 | 175 | ValueError: ('Invalid object reference...invalid-name... |
177 | 176 |
|
178 | | - The same thing happens on construction. |
| 177 | + Construction never raises; the value is validated only when |
| 178 | + accessed or loaded. |
179 | 179 |
|
180 | | - >>> EntryPoint(name=None, group=None, value='invalid-name') |
| 180 | + >>> ep = EntryPoint(name=None, group=None, value='invalid-name') |
| 181 | + >>> ep.load() |
181 | 182 | Traceback (most recent call last): |
182 | 183 | ... |
183 | 184 | ValueError: ('Invalid object reference...invalid-name... |
@@ -213,7 +214,6 @@ class EntryPoint: |
213 | 214 |
|
214 | 215 | def __init__(self, name: str, value: str, group: str) -> None: |
215 | 216 | vars(self).update(name=name, value=value, group=group) |
216 | | - self.module |
217 | 217 |
|
218 | 218 | def load(self) -> Any: |
219 | 219 | """Load the entry point from its definition. If only a module |
@@ -335,7 +335,7 @@ def __repr__(self): |
335 | 335 | Repr with classname and tuple constructor to |
336 | 336 | signal that we deviate from regular tuple behavior. |
337 | 337 | """ |
338 | | - return '%s(%r)' % (self.__class__.__name__, tuple(self)) |
| 338 | + return f'{self.__class__.__name__}({tuple(self)!r})' |
339 | 339 |
|
340 | 340 | def select(self, **params) -> EntryPoints: |
341 | 341 | """ |
@@ -396,6 +396,25 @@ def __repr__(self) -> str: |
396 | 396 | return f'<FileHash mode: {self.mode} value: {self.value}>' |
397 | 397 |
|
398 | 398 |
|
| 399 | +def _identify_dist( |
| 400 | + accessor: Callable[[Distribution], str], |
| 401 | +) -> Callable[[Distribution], str]: |
| 402 | + """ |
| 403 | + Decorate a required metadata accessor to identify the distribution |
| 404 | + in the error raised when its field is absent. |
| 405 | + """ |
| 406 | + |
| 407 | + @functools.wraps(accessor) |
| 408 | + def wrapper(self: Distribution) -> str: |
| 409 | + try: |
| 410 | + return accessor(self) |
| 411 | + except KeyError as exc: |
| 412 | + (field,) = exc.args |
| 413 | + raise KeyError(f'Metadata field {field} not present in {self}') from None |
| 414 | + |
| 415 | + return wrapper |
| 416 | + |
| 417 | + |
399 | 418 | class Distribution(metaclass=abc.ABCMeta): |
400 | 419 | """ |
401 | 420 | An abstract Python distribution package. |
@@ -556,6 +575,7 @@ def _ensure_metadata_present(self, text: str | None) -> str: |
556 | 575 | raise MetadataNotFound('No package metadata was found.') |
557 | 576 |
|
558 | 577 | @property |
| 578 | + @_identify_dist |
559 | 579 | def name(self) -> str: |
560 | 580 | """Return the 'Name' metadata for the distribution package.""" |
561 | 581 | return self.metadata['Name'] |
@@ -566,6 +586,7 @@ def _normalized_name(self): |
566 | 586 | return Prepared.normalize(self.name) |
567 | 587 |
|
568 | 588 | @property |
| 589 | + @_identify_dist |
569 | 590 | def version(self) -> str: |
570 | 591 | """Return the 'Version' metadata for the distribution package.""" |
571 | 592 | return self.metadata['Version'] |
@@ -707,7 +728,7 @@ def make_condition(name): |
707 | 728 |
|
708 | 729 | def quoted_marker(section): |
709 | 730 | section = section or '' |
710 | | - extra, sep, markers = section.partition(':') |
| 731 | + extra, _sep, markers = section.partition(':') |
711 | 732 | if extra and markers: |
712 | 733 | markers = f'({markers})' |
713 | 734 | conditions = list(filter(None, [markers, make_condition(extra)])) |
@@ -832,7 +853,7 @@ class FastPath: |
832 | 853 | """ |
833 | 854 |
|
834 | 855 | @_clear_after_fork # type: ignore[misc] |
835 | | - @functools.lru_cache() |
| 856 | + @functools.lru_cache |
836 | 857 | def __new__(cls, root): |
837 | 858 | return super().__new__(cls) |
838 | 859 |
|
@@ -885,14 +906,22 @@ def __init__(self, path: FastPath): |
885 | 906 | From the children in the path, calculate early all of the |
886 | 907 | children that appear to represent metadata (infos) or legacy |
887 | 908 | metadata (eggs). |
| 909 | +
|
| 910 | + Children are considered in reverse lexicographic order, so when |
| 911 | + a path supplies more than one metadata directory for a |
| 912 | + distribution, as an interrupted or unclean upgrade may leave |
| 913 | + behind, the greater one is found first |
| 914 | + (python/cpython#100666). Because the order is lexicographic on |
| 915 | + the directory name and not on the parsed version, it only |
| 916 | + approximates a preference for the newer version. |
888 | 917 | """ |
889 | 918 |
|
890 | 919 | base = os.path.basename(path.root).lower() |
891 | 920 | base_is_egg = base.endswith(".egg") |
892 | 921 | self.infos = FreezableDefaultDict(list) |
893 | 922 | self.eggs = FreezableDefaultDict(list) |
894 | 923 |
|
895 | | - for child in path.children(): |
| 924 | + for child in sorted(path.children(), key=str.lower, reverse=True): |
896 | 925 | low = child.lower() |
897 | 926 | if low.endswith((".dist-info", ".egg-info")): |
898 | 927 | # rpartition is faster than splitext and suitable for this purpose. |
@@ -958,15 +987,8 @@ def __init__(self, name: str | None): |
958 | 987 | def normalize(name): |
959 | 988 | """ |
960 | 989 | PEP 503 normalization plus dashes as underscores. |
961 | | -
|
962 | | - Specifically avoids ``re.sub`` as prescribed for performance |
963 | | - benefits (see python/cpython#143658). |
964 | 990 | """ |
965 | | - value = name.lower().replace("-", "_").replace(".", "_") |
966 | | - # Condense repeats |
967 | | - while "__" in value: |
968 | | - value = value.replace("__", "_") |
969 | | - return value |
| 991 | + return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_') |
970 | 992 |
|
971 | 993 | @staticmethod |
972 | 994 | def legacy_normalize(name): |
@@ -1017,6 +1039,16 @@ def __init__(self, path: SimplePath) -> None: |
1017 | 1039 | """ |
1018 | 1040 | self._path = path |
1019 | 1041 |
|
| 1042 | + def __str__(self) -> str: |
| 1043 | + """ |
| 1044 | + Render as the metadata path, identifying the distribution |
| 1045 | + even when its metadata cannot name it. |
| 1046 | +
|
| 1047 | + >>> str(Distribution.at('foo-1.0.dist-info')) |
| 1048 | + 'foo-1.0.dist-info' |
| 1049 | + """ |
| 1050 | + return str(self._path) |
| 1051 | + |
1020 | 1052 | def read_text(self, filename: str | os.PathLike[str]) -> str | None: |
1021 | 1053 | with suppress( |
1022 | 1054 | FileNotFoundError, |
@@ -1060,7 +1092,7 @@ def _name_from_stem(stem): |
1060 | 1092 | filename, ext = os.path.splitext(stem) |
1061 | 1093 | if ext not in ('.dist-info', '.egg-info'): |
1062 | 1094 | return |
1063 | | - name, sep, rest = filename.partition('-') |
| 1095 | + name, _sep, _rest = filename.partition('-') |
1064 | 1096 | return name |
1065 | 1097 |
|
1066 | 1098 |
|
|