Skip to content

Commit 80187a5

Browse files
add PEP 658 support!!!
1 parent 9a327b5 commit 80187a5

File tree

8 files changed

+228
-47
lines changed

8 files changed

+228
-47
lines changed

src/pip/_internal/commands/download.py

+136-11
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,132 @@
11
import json
22
import logging
33
import os
4+
from dataclasses import dataclass, field
45
from optparse import Values
5-
from typing import Dict, List
6+
from typing import Any, Dict, List, Optional, Union
7+
8+
from pip._vendor.packaging.requirements import Requirement
69

710
from pip._internal.cli import cmdoptions
811
from pip._internal.cli.cmdoptions import make_target_python
912
from pip._internal.cli.req_command import RequirementCommand, with_cleanup
1013
from pip._internal.cli.status_codes import SUCCESS
14+
from pip._internal.models.link import Link
1115
from pip._internal.req.req_tracker import get_requirement_tracker
1216
from pip._internal.utils.misc import ensure_dir, normalize_path, write_output
1317
from pip._internal.utils.temp_dir import TempDirectory
1418

1519
logger = logging.getLogger(__name__)
1620

1721

22+
@dataclass
23+
class RequirementHash:
24+
hash_name: str
25+
hash_value: str
26+
27+
@classmethod
28+
def from_dist_info_metadata(
29+
cls,
30+
dist_info_metadata: Optional[str],
31+
) -> Optional["RequirementHash"]:
32+
if dist_info_metadata is None:
33+
return None
34+
if dist_info_metadata == "true":
35+
return None
36+
# FIXME: don't use private `_hash_re`!
37+
hash_match = Link._hash_re.match(dist_info_metadata)
38+
if hash_match is None:
39+
return None
40+
hash_name, hash_value = hash_match.groups()
41+
return cls(hash_name=hash_name, hash_value=hash_value)
42+
43+
@classmethod
44+
def from_link(cls, link: Link) -> Optional["RequirementHash"]:
45+
if not link.is_hash_allowed:
46+
return None
47+
hash_name = link.hash_name
48+
hash_value = link.hash
49+
assert hash_name is not None
50+
assert hash_value is not None
51+
return cls(hash_name=hash_name, hash_value=hash_value)
52+
53+
def as_json(self) -> Dict[str, str]:
54+
return {
55+
"hash_name": self.hash_name,
56+
"hash_value": self.hash_value,
57+
}
58+
59+
60+
@dataclass
61+
class DistInfoMetadata:
62+
"""???/From PEP 658"""
63+
64+
metadata_url: str
65+
metadata_hash: Optional[RequirementHash]
66+
67+
@classmethod
68+
def from_link(cls, link: Link) -> Optional["DistInfoMetadata"]:
69+
if link.dist_info_metadata is None:
70+
return None
71+
metadata_url = f"{link.url_without_fragment}.metadata"
72+
metadata_hash = RequirementHash.from_dist_info_metadata(link.dist_info_metadata)
73+
return cls(metadata_url=metadata_url, metadata_hash=metadata_hash)
74+
75+
def as_json(self) -> Dict[str, Union[str, Optional[Dict[str, str]]]]:
76+
return {
77+
"metadata_url": self.metadata_url,
78+
"metadata_hash": (
79+
self.metadata_hash.as_json() if self.metadata_hash else None
80+
),
81+
}
82+
83+
84+
@dataclass
85+
class RequirementDownloadInfo:
86+
req: Requirement
87+
url: str
88+
file_hash: Optional[RequirementHash]
89+
dist_info_metadata: Optional[DistInfoMetadata]
90+
91+
@classmethod
92+
def from_req_and_link(
93+
cls,
94+
req: Requirement,
95+
link: Link,
96+
) -> "RequirementDownloadInfo":
97+
return cls(
98+
req=req,
99+
url=link.url,
100+
file_hash=RequirementHash.from_link(link),
101+
dist_info_metadata=DistInfoMetadata.from_link(link),
102+
)
103+
104+
def as_json(self) -> Dict[str, Any]:
105+
return {
106+
"req": str(self.req),
107+
"url": self.url,
108+
"hash": self.file_hash and self.file_hash.as_json(),
109+
"dist_info_metadata": (
110+
self.dist_info_metadata and self.dist_info_metadata.as_json()
111+
),
112+
}
113+
114+
115+
@dataclass
116+
class DownloadInfos:
117+
# python_version: Optional[Requirement] = None
118+
python_version: Optional[str] = None
119+
resolution: Dict[str, RequirementDownloadInfo] = field(default_factory=dict)
120+
121+
def as_json(self) -> Dict[str, Any]:
122+
return {
123+
"python_version": self.python_version and str(self.python_version),
124+
"resolution": {
125+
name: info.as_json() for name, info in self.resolution.items()
126+
},
127+
}
128+
129+
18130
class DownloadCommand(RequirementCommand):
19131
"""
20132
Download packages from:
@@ -149,24 +261,37 @@ def run(self, options: Values, args: List[str]) -> int:
149261
requirement_set = resolver.resolve(reqs, check_supported_wheels=True)
150262

151263
downloaded: List[str] = []
152-
download_infos: List[Dict[str, str]] = []
153264
for req in requirement_set.requirements.values():
265+
# If this distribution was not already satisfied, that means we
266+
# downloaded it.
154267
if req.satisfied_by is None:
155-
assert req.name is not None
156-
assert req.link is not None
157-
download_infos.append(
158-
{
159-
"name": req.name,
160-
"url": req.link.url,
161-
}
162-
)
163268
preparer.save_linked_requirement(req)
269+
assert req.name is not None
164270
downloaded.append(req.name)
165271

272+
download_infos = DownloadInfos()
273+
assert requirement_set.candidates is not None
274+
for candidate in requirement_set.candidates.mapping.values():
275+
# This will occur for the python version requirement, for example.
276+
if candidate.name not in requirement_set.requirements:
277+
# download_infos.python_version = candidate.as_requirement()
278+
download_infos.python_version = str(candidate)
279+
continue
280+
req = requirement_set.requirements[candidate.name]
281+
assert req.name is not None
282+
assert req.link is not None
283+
assert req.name not in download_infos.resolution
284+
download_infos.resolution[
285+
req.name
286+
] = RequirementDownloadInfo.from_req_and_link(
287+
req=candidate.as_requirement(),
288+
link=req.link,
289+
)
290+
166291
if downloaded:
167292
write_output("Successfully downloaded %s", " ".join(downloaded))
168293
if options.print_download_urls:
169294
with open(options.print_download_urls, "w") as f:
170-
json.dump(download_infos, f, indent=4)
295+
json.dump(download_infos.as_json(), f, indent=4)
171296

172297
return SUCCESS

src/pip/_internal/index/collector.py

+2
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,14 @@ def _create_link_from_element(
248248
url = _clean_link(urllib.parse.urljoin(base_url, href))
249249
pyrequire = anchor.get("data-requires-python")
250250
yanked_reason = anchor.get("data-yanked")
251+
dist_info_metadata = anchor.get("data-dist-info-metadata")
251252

252253
link = Link(
253254
url,
254255
comes_from=page_url,
255256
requires_python=pyrequire,
256257
yanked_reason=yanked_reason,
258+
dist_info_metadata=dist_info_metadata,
257259
)
258260

259261
return link

src/pip/_internal/models/link.py

+5
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class Link(KeyBasedCompareMixin):
3434
"comes_from",
3535
"requires_python",
3636
"yanked_reason",
37+
"dist_info_metadata",
3738
"cache_link_parsing",
3839
]
3940

@@ -43,6 +44,7 @@ def __init__(
4344
comes_from: Optional[Union[str, "HTMLPage"]] = None,
4445
requires_python: Optional[str] = None,
4546
yanked_reason: Optional[str] = None,
47+
dist_info_metadata: Optional[str] = None,
4648
cache_link_parsing: bool = True,
4749
) -> None:
4850
"""
@@ -59,6 +61,7 @@ def __init__(
5961
a simple repository HTML link. If the file has been yanked but
6062
no reason was provided, this should be the empty string. See
6163
PEP 592 for more information and the specification.
64+
:param dist_info_metadata: ???/PEP 658
6265
:param cache_link_parsing: A flag that is used elsewhere to determine
6366
whether resources retrieved from this link
6467
should be cached. PyPI index urls should
@@ -78,6 +81,7 @@ def __init__(
7881
self.comes_from = comes_from
7982
self.requires_python = requires_python if requires_python else None
8083
self.yanked_reason = yanked_reason
84+
self.dist_info_metadata = dist_info_metadata
8185

8286
super().__init__(key=url, defining_class=Link)
8387

@@ -165,6 +169,7 @@ def subdirectory_fragment(self) -> Optional[str]:
165169
return None
166170
return match.group(1)
167171

172+
# FIXME: retrieve all the `re.compile` anchor tags when the Link is constructed!!
168173
_hash_re = re.compile(
169174
r"({choices})=([a-f0-9]+)".format(choices="|".join(_SUPPORTED_HASHES))
170175
)

src/pip/_internal/req/req_install.py

+21-14
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,23 @@
6262
logger = logging.getLogger(__name__)
6363

6464

65+
def produce_exact_version_requirement(name: str, version: str) -> Requirement:
66+
if isinstance(parse_version(version), Version):
67+
op = "=="
68+
else:
69+
op = "==="
70+
71+
return Requirement(
72+
"".join(
73+
[
74+
name,
75+
op,
76+
version,
77+
]
78+
)
79+
)
80+
81+
6582
class InstallRequirement:
6683
"""
6784
Represents something that may be installed later on, may have information
@@ -348,20 +365,10 @@ def _set_requirement(self) -> None:
348365
assert self.metadata is not None
349366
assert self.source_dir is not None
350367

351-
# Construct a Requirement object from the generated metadata
352-
if isinstance(parse_version(self.metadata["Version"]), Version):
353-
op = "=="
354-
else:
355-
op = "==="
356-
357-
self.req = Requirement(
358-
"".join(
359-
[
360-
self.metadata["Name"],
361-
op,
362-
self.metadata["Version"],
363-
]
364-
)
368+
# Construct a Requirement object from the generated metadata.
369+
self.req = produce_exact_version_requirement(
370+
self.metadata["Name"],
371+
self.metadata["Version"],
365372
)
366373

367374
def warn_on_mismatching_name(self) -> None:

src/pip/_internal/resolution/base.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
1-
from typing import Callable, List, Optional
1+
from typing import TYPE_CHECKING, Callable, List, Optional
22

33
from pip._internal.req.req_install import InstallRequirement
44
from pip._internal.req.req_set import RequirementSet
55

6+
if TYPE_CHECKING:
7+
from pip._vendor.resolvelib.resolvers import Result as RLResult
8+
9+
from .resolvelib.base import Candidate, Requirement
10+
11+
Result = RLResult[Requirement, Candidate, str]
12+
613
InstallRequirementProvider = Callable[
714
[str, Optional[InstallRequirement]], InstallRequirement
815
]
916

1017

18+
class RequirementSetWithCandidates(RequirementSet):
19+
def __init__(self, check_supported_wheels: bool = True) -> None:
20+
super().__init__(check_supported_wheels=check_supported_wheels)
21+
self.candidates: Optional[Result] = None
22+
23+
1124
class BaseResolver:
1225
def resolve(
1326
self, root_reqs: List[InstallRequirement], check_supported_wheels: bool
14-
) -> RequirementSet:
27+
) -> RequirementSetWithCandidates:
1528
raise NotImplementedError()
1629

1730
def get_installation_order(

src/pip/_internal/resolution/resolvelib/base.py

+23-16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import abc
12
from typing import FrozenSet, Iterable, Optional, Tuple, Union
23

4+
from pip._vendor.packaging.requirements import Requirement as PkgRequirement
35
from pip._vendor.packaging.specifiers import SpecifierSet
46
from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
57
from pip._vendor.packaging.version import LegacyVersion, Version
@@ -95,47 +97,52 @@ def _match_link(link: Link, candidate: "Candidate") -> bool:
9597
return False
9698

9799

98-
class Candidate:
99-
@property
100+
class Candidate(metaclass=abc.ABCMeta):
101+
@abc.abstractproperty
100102
def project_name(self) -> NormalizedName:
101103
"""The "project name" of the candidate.
102104
103105
This is different from ``name`` if this candidate contains extras,
104106
in which case ``name`` would contain the ``[...]`` part, while this
105107
refers to the name of the project.
106108
"""
107-
raise NotImplementedError("Override in subclass")
108109

109-
@property
110+
@abc.abstractproperty
110111
def name(self) -> str:
111112
"""The name identifying this candidate in the resolver.
112113
113114
This is different from ``project_name`` if this candidate contains
114115
extras, where ``project_name`` would not contain the ``[...]`` part.
115116
"""
116-
raise NotImplementedError("Override in subclass")
117117

118-
@property
118+
@abc.abstractproperty
119119
def version(self) -> CandidateVersion:
120-
raise NotImplementedError("Override in subclass")
120+
...
121121

122-
@property
122+
@abc.abstractmethod
123+
def as_requirement(self) -> PkgRequirement:
124+
...
125+
126+
@abc.abstractproperty
123127
def is_installed(self) -> bool:
124-
raise NotImplementedError("Override in subclass")
128+
...
125129

126-
@property
130+
@abc.abstractproperty
127131
def is_editable(self) -> bool:
128-
raise NotImplementedError("Override in subclass")
132+
...
129133

130-
@property
134+
@abc.abstractproperty
131135
def source_link(self) -> Optional[Link]:
132-
raise NotImplementedError("Override in subclass")
136+
...
133137

138+
@abc.abstractmethod
134139
def iter_dependencies(self, with_requires: bool) -> Iterable[Optional[Requirement]]:
135-
raise NotImplementedError("Override in subclass")
140+
...
136141

142+
@abc.abstractmethod
137143
def get_install_requirement(self) -> Optional[InstallRequirement]:
138-
raise NotImplementedError("Override in subclass")
144+
...
139145

146+
@abc.abstractmethod
140147
def format_for_error(self) -> str:
141-
raise NotImplementedError("Subclass should override")
148+
...

0 commit comments

Comments
 (0)