Skip to content

Commit a814290

Browse files
committed
Make sure metadata has Requires-Dist and Provides-Extra
egg-info distributions may not have the Requires-Dist and Provides-Extra fields in their metadata. For consistency and to provide an unsurprising metadata property, we emulate it by reading requires.txt.
1 parent ba11a20 commit a814290

File tree

4 files changed

+125
-91
lines changed

4 files changed

+125
-91
lines changed

src/pip/_internal/metadata/base.py

+91-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import csv
22
import email.message
3+
import functools
34
import json
45
import logging
56
import pathlib
@@ -13,6 +14,7 @@
1314
Iterable,
1415
Iterator,
1516
List,
17+
NamedTuple,
1618
Optional,
1719
Tuple,
1820
Union,
@@ -33,6 +35,7 @@
3335
from pip._internal.utils.compat import stdlib_pkgs # TODO: Move definition here.
3436
from pip._internal.utils.egg_link import egg_link_path_from_sys_path
3537
from pip._internal.utils.misc import is_local, normalize_path
38+
from pip._internal.utils.packaging import safe_extra
3639
from pip._internal.utils.urls import url_to_path
3740

3841
if TYPE_CHECKING:
@@ -91,6 +94,12 @@ def _convert_installed_files_path(
9194
return str(pathlib.Path(*info, *entry))
9295

9396

97+
class RequiresEntry(NamedTuple):
98+
requirement: str
99+
extra: str
100+
marker: str
101+
102+
94103
class BaseDistribution(Protocol):
95104
@classmethod
96105
def from_directory(cls, directory: str) -> "BaseDistribution":
@@ -348,6 +357,17 @@ def read_text(self, path: InfoPath) -> str:
348357
def iter_entry_points(self) -> Iterable[BaseEntryPoint]:
349358
raise NotImplementedError()
350359

360+
def _metadata_impl(self) -> email.message.Message:
361+
raise NotImplementedError()
362+
363+
@functools.lru_cache(maxsize=1)
364+
def _metadata_cached(self) -> email.message.Message:
365+
# When we drop python 3.7 support, move this to the metadata property and use
366+
# functools.cached_property instead of lru_cache.
367+
metadata = self._metadata_impl()
368+
self._add_egg_info_requires(metadata)
369+
return metadata
370+
351371
@property
352372
def metadata(self) -> email.message.Message:
353373
"""Metadata of distribution parsed from e.g. METADATA or PKG-INFO.
@@ -357,7 +377,7 @@ def metadata(self) -> email.message.Message:
357377
:raises NoneMetadataError: If the metadata file is available, but does
358378
not contain valid metadata.
359379
"""
360-
raise NotImplementedError()
380+
return self._metadata_cached()
361381

362382
@property
363383
def metadata_version(self) -> Optional[str]:
@@ -451,6 +471,76 @@ def iter_declared_entries(self) -> Optional[Iterator[str]]:
451471
or self._iter_declared_entries_from_legacy()
452472
)
453473

474+
def _iter_requires_txt_entries(self) -> Iterator[RequiresEntry]:
475+
"""Parse a ``requires.txt`` in an egg-info directory.
476+
477+
This is an INI-ish format where an egg-info stores dependencies. A
478+
section name describes extra other environment markers, while each entry
479+
is an arbitrary string (not a key-value pair) representing a dependency
480+
as a requirement string (no markers).
481+
482+
There is a construct in ``importlib.metadata`` called ``Sectioned`` that
483+
does mostly the same, but the format is currently considered private.
484+
"""
485+
try:
486+
content = self.read_text("requires.txt")
487+
except FileNotFoundError:
488+
return
489+
extra = marker = "" # Section-less entries don't have markers.
490+
for line in content.splitlines():
491+
line = line.strip()
492+
if not line or line.startswith("#"): # Comment; ignored.
493+
continue
494+
if line.startswith("[") and line.endswith("]"): # A section header.
495+
extra, _, marker = line.strip("[]").partition(":")
496+
continue
497+
yield RequiresEntry(requirement=line, extra=extra, marker=marker)
498+
499+
def _iter_egg_info_extras(self) -> Iterable[str]:
500+
"""Get extras from the egg-info directory."""
501+
known_extras = {""}
502+
for entry in self._iter_requires_txt_entries():
503+
if entry.extra in known_extras:
504+
continue
505+
known_extras.add(entry.extra)
506+
yield entry.extra
507+
508+
def _iter_egg_info_dependencies(self) -> Iterable[str]:
509+
"""Get distribution dependencies from the egg-info directory.
510+
511+
To ease parsing, this converts a legacy dependency entry into a PEP 508
512+
requirement string. Like ``_iter_requires_txt_entries()``, there is code
513+
in ``importlib.metadata`` that does mostly the same, but not do exactly
514+
what we need.
515+
516+
Namely, ``importlib.metadata`` does not normalize the extra name before
517+
putting it into the requirement string, which causes marker comparison
518+
to fail because the dist-info format do normalize. This is consistent in
519+
all currently available PEP 517 backends, although not standardized.
520+
"""
521+
for entry in self._iter_requires_txt_entries():
522+
if entry.extra and entry.marker:
523+
marker = f'({entry.marker}) and extra == "{safe_extra(entry.extra)}"'
524+
elif entry.extra:
525+
marker = f'extra == "{safe_extra(entry.extra)}"'
526+
elif entry.marker:
527+
marker = entry.marker
528+
else:
529+
marker = ""
530+
if marker:
531+
yield f"{entry.requirement} ; {marker}"
532+
else:
533+
yield entry.requirement
534+
535+
def _add_egg_info_requires(self, metadata: email.message.Message) -> None:
536+
"""Add egg-info requires.txt information to the metadata."""
537+
if not metadata.get_all("Requires-Dist"):
538+
for dep in self._iter_egg_info_dependencies():
539+
metadata["Requires-Dist"] = dep
540+
if not metadata.get_all("Provides-Extra"):
541+
for extra in self._iter_egg_info_extras():
542+
metadata["Provides-Extra"] = extra
543+
454544

455545
class BaseEnvironment:
456546
"""An environment containing distributions to introspect."""

src/pip/_internal/metadata/importlib/_dists.py

+5-87
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,7 @@
33
import os
44
import pathlib
55
import zipfile
6-
from typing import (
7-
Collection,
8-
Dict,
9-
Iterable,
10-
Iterator,
11-
Mapping,
12-
NamedTuple,
13-
Optional,
14-
Sequence,
15-
)
6+
from typing import Collection, Dict, Iterable, Iterator, Mapping, Optional, Sequence
167

178
from pip._vendor.packaging.requirements import Requirement
189
from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
@@ -92,12 +83,6 @@ def read_text(self, filename: str) -> Optional[str]:
9283
return text
9384

9485

95-
class RequiresEntry(NamedTuple):
96-
requirement: str
97-
extra: str
98-
marker: str
99-
100-
10186
class Distribution(BaseDistribution):
10287
def __init__(
10388
self,
@@ -187,84 +172,17 @@ def iter_entry_points(self) -> Iterable[BaseEntryPoint]:
187172
# importlib.metadata's EntryPoint structure sasitfies BaseEntryPoint.
188173
return self._dist.entry_points
189174

190-
@property
191-
def metadata(self) -> email.message.Message:
175+
def _metadata_impl(self) -> email.message.Message:
192176
return self._dist.metadata
193177

194-
def _iter_requires_txt_entries(self) -> Iterator[RequiresEntry]:
195-
"""Parse a ``requires.txt`` in an egg-info directory.
196-
197-
This is an INI-ish format where an egg-info stores dependencies. A
198-
section name describes extra other environment markers, while each entry
199-
is an arbitrary string (not a key-value pair) representing a dependency
200-
as a requirement string (no markers).
201-
202-
There is a construct in ``importlib.metadata`` called ``Sectioned`` that
203-
does mostly the same, but the format is currently considered private.
204-
"""
205-
content = self._dist.read_text("requires.txt")
206-
if content is None:
207-
return
208-
extra = marker = "" # Section-less entries don't have markers.
209-
for line in content.splitlines():
210-
line = line.strip()
211-
if not line or line.startswith("#"): # Comment; ignored.
212-
continue
213-
if line.startswith("[") and line.endswith("]"): # A section header.
214-
extra, _, marker = line.strip("[]").partition(":")
215-
continue
216-
yield RequiresEntry(requirement=line, extra=extra, marker=marker)
217-
218-
def _iter_egg_info_extras(self) -> Iterable[str]:
219-
"""Get extras from the egg-info directory."""
220-
known_extras = {""}
221-
for entry in self._iter_requires_txt_entries():
222-
if entry.extra in known_extras:
223-
continue
224-
known_extras.add(entry.extra)
225-
yield entry.extra
226-
227178
def iter_provided_extras(self) -> Iterable[str]:
228-
iterator = (
229-
self._dist.metadata.get_all("Provides-Extra")
230-
or self._iter_egg_info_extras()
179+
return (
180+
safe_extra(extra) for extra in self.metadata.get_all("Provides-Extra", [])
231181
)
232-
return (safe_extra(extra) for extra in iterator)
233-
234-
def _iter_egg_info_dependencies(self) -> Iterable[str]:
235-
"""Get distribution dependencies from the egg-info directory.
236-
237-
To ease parsing, this converts a legacy dependency entry into a PEP 508
238-
requirement string. Like ``_iter_requires_txt_entries()``, there is code
239-
in ``importlib.metadata`` that does mostly the same, but not do exactly
240-
what we need.
241-
242-
Namely, ``importlib.metadata`` does not normalize the extra name before
243-
putting it into the requirement string, which causes marker comparison
244-
to fail because the dist-info format do normalize. This is consistent in
245-
all currently available PEP 517 backends, although not standardized.
246-
"""
247-
for entry in self._iter_requires_txt_entries():
248-
if entry.extra and entry.marker:
249-
marker = f'({entry.marker}) and extra == "{safe_extra(entry.extra)}"'
250-
elif entry.extra:
251-
marker = f'extra == "{safe_extra(entry.extra)}"'
252-
elif entry.marker:
253-
marker = entry.marker
254-
else:
255-
marker = ""
256-
if marker:
257-
yield f"{entry.requirement} ; {marker}"
258-
else:
259-
yield entry.requirement
260182

261183
def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requirement]:
262-
req_string_iterator = (
263-
self._dist.metadata.get_all("Requires-Dist")
264-
or self._iter_egg_info_dependencies()
265-
)
266184
contexts: Sequence[Dict[str, str]] = [{"extra": safe_extra(e)} for e in extras]
267-
for req_string in req_string_iterator:
185+
for req_string in self.metadata.get_all("Requires-Dist", []):
268186
req = Requirement(req_string)
269187
if not req.marker:
270188
yield req

src/pip/_internal/metadata/pkg_resources.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,7 @@ def iter_entry_points(self) -> Iterable[BaseEntryPoint]:
171171
name, _, value = str(entry_point).partition("=")
172172
yield EntryPoint(name=name.strip(), value=value.strip(), group=group)
173173

174-
@property
175-
def metadata(self) -> email.message.Message:
174+
def _metadata_impl(self) -> email.message.Message:
176175
"""
177176
:raises NoneMetadataError: if the distribution reports `has_metadata()`
178177
True but `get_metadata()` returns None.

tests/unit/metadata/test_metadata.py

+28-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import logging
2+
from pathlib import Path
23
from typing import cast
34
from unittest import mock
45

56
import pytest
67
from pip._vendor.packaging.utils import NormalizedName
78

8-
from pip._internal.metadata import BaseDistribution
9+
from pip._internal.metadata import BaseDistribution, get_directory_distribution
910
from pip._internal.models.direct_url import DIRECT_URL_METADATA_NAME, ArchiveInfo
1011

1112

@@ -40,6 +41,32 @@ class FakeDistribution(BaseDistribution):
4041
)
4142

4243

44+
def test_metadata_reads_egg_info_requires_txt(tmp_path: Path) -> None:
45+
"""Check Requires-Dist is obtained from requires.txt if absent in PKG-INFO."""
46+
egg_info_path = tmp_path / "whatever.egg-info"
47+
egg_info_path.mkdir()
48+
dist = get_directory_distribution(str(egg_info_path))
49+
assert dist.installed_with_setuptools_egg_info
50+
pkg_info_path = egg_info_path / "PKG-INFO"
51+
pkg_info_path.write_text("Name: whatever\n")
52+
egg_info_path.joinpath("requires.txt").write_text("pkga\npkgb\n")
53+
assert dist.metadata.get_all("Requires-Dist") == ["pkga", "pkgb"]
54+
55+
56+
def test_metadata_pkg_info_requires_priority(tmp_path: Path) -> None:
57+
"""Check Requires-Dist in PKG-INFO has priority over requires.txt."""
58+
egg_info_path = tmp_path / "whatever.egg-info"
59+
egg_info_path.mkdir()
60+
dist = get_directory_distribution(str(egg_info_path))
61+
assert dist.installed_with_setuptools_egg_info
62+
pkg_info_path = egg_info_path / "PKG-INFO"
63+
pkg_info_path.write_text(
64+
"Name: whatever\nRequires-Dist: pkgc\nRequires-Dist: pkgd\n"
65+
)
66+
egg_info_path.joinpath("requires.txt").write_text("pkga\npkgb\n")
67+
assert dist.metadata.get_all("Requires-Dist") == ["pkgc", "pkgd"]
68+
69+
4370
@mock.patch.object(
4471
BaseDistribution,
4572
"read_text",

0 commit comments

Comments
 (0)