Skip to content

Commit b61a4dd

Browse files
authored
Avoid the deprecated JSON API (#6081)
Resolves #6076 I've taken the JSON version of the simple API and converted it into a `LinkSource` so that the package-finding logic in the `PyPiRepository` is very similar to - but annoyingly not quite the same as! - the `LegacyRepository`. I've also taken the opportunity to refactor the `LegacyRepository` ever so slightly to emphasise that similarity. I think I've probably fixed a small bug re caching and pre-releases: previously the processing for ignored pre-releases was skipped when reading from the cache. I believe this change will tend to be a modest performance hit. Eg consider a package like `cryptography`, for which there are maybe a couple of dozen downloads available at each release: to get the available versions we now have to iterate over each of those files and parse their names, rather than simply reading the answer. However if the API that poetry currently uses is truly deprecated I see little choice but to suck that up - or risk being in an awkward spot when it is turned off. cf #5970, but worse. Most of the changes are in the test fixtures: - unversioned fixtures were generated from the existing fixtures: I didn't want to download fresh data and start getting different answers than the tests were expecting - new versioned fixtures were downloaded fresh
1 parent 714c09d commit b61a4dd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+4276
-7717
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from __future__ import annotations
2+
3+
from collections import defaultdict
4+
from typing import TYPE_CHECKING
5+
from typing import Any
6+
7+
from poetry.core.packages.utils.link import Link
8+
9+
from poetry.repositories.link_sources.base import LinkSource
10+
from poetry.utils._compat import cached_property
11+
12+
13+
if TYPE_CHECKING:
14+
from poetry.repositories.link_sources.base import LinkCache
15+
16+
17+
class SimpleJsonPage(LinkSource):
18+
"""Links as returned by PEP 691 compatible JSON-based Simple API."""
19+
20+
def __init__(self, url: str, content: dict[str, Any]) -> None:
21+
super().__init__(url=url)
22+
self.content = content
23+
24+
@cached_property
25+
def _link_cache(self) -> LinkCache:
26+
links: LinkCache = defaultdict(lambda: defaultdict(list))
27+
for file in self.content["files"]:
28+
url = file["url"]
29+
requires_python = file.get("requires-python")
30+
yanked = file.get("yanked", False)
31+
link = Link(url, requires_python=requires_python, yanked=yanked)
32+
33+
if link.ext not in self.SUPPORTED_FORMATS:
34+
continue
35+
36+
pkg = self.link_package_data(link)
37+
if pkg:
38+
links[pkg.name][pkg.version].append(link)
39+
40+
return links

src/poetry/repositories/pypi_repository.py

+36-32
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212
from html5lib.html5parser import parse
1313
from poetry.core.packages.package import Package
1414
from poetry.core.packages.utils.link import Link
15-
from poetry.core.semver.version import Version
1615
from poetry.core.version.exceptions import InvalidVersion
1716

1817
from poetry.repositories.exceptions import PackageNotFound
1918
from poetry.repositories.http import HTTPRepository
19+
from poetry.repositories.link_sources.json import SimpleJsonPage
2020
from poetry.utils._compat import to_str
2121
from poetry.utils.constants import REQUESTS_TIMEOUT
2222

@@ -27,6 +27,7 @@
2727

2828
if TYPE_CHECKING:
2929
from packaging.utils import NormalizedName
30+
from poetry.core.semver.version import Version
3031
from poetry.core.semver.version_constraint import VersionConstraint
3132

3233
SUPPORTED_PACKAGE_TYPES = {"sdist", "bdist_wheel"}
@@ -114,50 +115,44 @@ def _find_packages(
114115
Find packages on the remote server.
115116
"""
116117
try:
117-
info = self.get_package_info(name)
118+
json_page = self.get_json_page(name)
118119
except PackageNotFound:
119120
self._log(
120-
f"No packages found for {name} {constraint!s}",
121+
f"No packages found for {name}",
121122
level="debug",
122123
)
123124
return []
124125

125-
packages = []
126+
versions: list[tuple[Version, str | bool]]
126127

127-
for version_string, release in info["releases"].items():
128-
if not release:
129-
# Bad release
130-
self._log(
131-
f"No release information found for {name}-{version_string},"
132-
" skipping",
133-
level="debug",
134-
)
135-
continue
128+
key: str = name
129+
if not constraint.is_any():
130+
key = f"{key}:{constraint!s}"
136131

137-
try:
138-
version = Version.parse(version_string)
139-
except InvalidVersion:
140-
self._log(
141-
f'Unable to parse version "{version_string}" for the'
142-
f" {name} package, skipping",
143-
level="debug",
144-
)
145-
continue
132+
if self._cache.store("matches").has(key):
133+
versions = self._cache.store("matches").get(key)
134+
else:
135+
versions = [
136+
(version, json_page.yanked(name, version))
137+
for version in json_page.versions(name)
138+
if constraint.allows(version)
139+
]
140+
self._cache.store("matches").put(key, versions, 5)
146141

147-
if constraint.allows(version):
148-
# PEP 592: PyPI always yanks entire releases, not individual files,
149-
# so we just have to look for the first file
150-
yanked = self._get_yanked(release[0])
151-
packages.append(Package(info["info"]["name"], version, yanked=yanked))
142+
pretty_name = json_page.content["name"]
143+
packages = [
144+
Package(pretty_name, version, yanked=yanked) for version, yanked in versions
145+
]
152146

153147
return packages
154148

155-
def _get_package_info(self, name: NormalizedName) -> dict[str, Any]:
156-
data = self._get(f"pypi/{name}/json")
157-
if data is None:
149+
def _get_package_info(self, name: str) -> dict[str, Any]:
150+
headers = {"Accept": "application/vnd.pypi.simple.v1+json"}
151+
info = self._get(f"simple/{name}/", headers=headers)
152+
if info is None:
158153
raise PackageNotFound(f"Package [{name}] not found.")
159154

160-
return data
155+
return info
161156

162157
def find_links_for_package(self, package: Package) -> list[Link]:
163158
json_data = self._get(f"pypi/{package.name}/{package.version}/json")
@@ -239,12 +234,20 @@ def _get_release_info(
239234

240235
return data.asdict()
241236

242-
def _get(self, endpoint: str) -> dict[str, Any] | None:
237+
def get_json_page(self, name: NormalizedName) -> SimpleJsonPage:
238+
source = self._base_url + f"simple/{name}/"
239+
info = self.get_package_info(name)
240+
return SimpleJsonPage(source, info)
241+
242+
def _get(
243+
self, endpoint: str, headers: dict[str, str] | None = None
244+
) -> dict[str, Any] | None:
243245
try:
244246
json_response = self.session.get(
245247
self._base_url + endpoint,
246248
raise_for_status=False,
247249
timeout=REQUESTS_TIMEOUT,
250+
headers=headers,
248251
)
249252
except requests.exceptions.TooManyRedirects:
250253
# Cache control redirect loop.
@@ -254,6 +257,7 @@ def _get(self, endpoint: str) -> dict[str, Any] | None:
254257
self._base_url + endpoint,
255258
raise_for_status=False,
256259
timeout=REQUESTS_TIMEOUT,
260+
headers=headers,
257261
)
258262

259263
if json_response.status_code != 200:

src/poetry/utils/authenticator.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,8 @@ def authenticated_url(self, url: str) -> str:
206206
def request(
207207
self, method: str, url: str, raise_for_status: bool = True, **kwargs: Any
208208
) -> requests.Response:
209-
request = requests.Request(method, url)
209+
headers = kwargs.get("headers")
210+
request = requests.Request(method, url, headers=headers)
210211
credential = self.get_credentials_for_url(url)
211212

212213
if credential.username is not None or credential.password is not None:

tests/installation/test_executor.py

+12-12
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def test_execute_executes_a_batch_of_operations(
147147

148148
return_code = executor.execute(
149149
[
150-
Install(Package("pytest", "3.5.2")),
150+
Install(Package("pytest", "3.5.1")),
151151
Uninstall(Package("attrs", "17.4.0")),
152152
Update(Package("requests", "2.18.3"), Package("requests", "2.18.4")),
153153
Uninstall(Package("clikit", "0.2.3")).skip("Not currently installed"),
@@ -160,7 +160,7 @@ def test_execute_executes_a_batch_of_operations(
160160
expected = f"""
161161
Package operations: 4 installs, 1 update, 1 removal
162162
163-
• Installing pytest (3.5.2)
163+
• Installing pytest (3.5.1)
164164
• Removing attrs (17.4.0)
165165
• Updating requests (2.18.3 -> 2.18.4)
166166
• Installing demo (0.1.0 {file_package.source_url})
@@ -182,20 +182,20 @@ def test_execute_executes_a_batch_of_operations(
182182
"operations, has_warning",
183183
[
184184
(
185-
[Install(Package("black", "21.11b0")), Install(Package("pytest", "3.5.2"))],
185+
[Install(Package("black", "21.11b0")), Install(Package("pytest", "3.5.1"))],
186186
True,
187187
),
188188
(
189189
[
190190
Uninstall(Package("black", "21.11b0")),
191-
Uninstall(Package("pytest", "3.5.2")),
191+
Uninstall(Package("pytest", "3.5.1")),
192192
],
193193
False,
194194
),
195195
(
196196
[
197197
Update(Package("black", "19.10b0"), Package("black", "21.11b0")),
198-
Update(Package("pytest", "3.5.1"), Package("pytest", "3.5.2")),
198+
Update(Package("pytest", "3.5.0"), Package("pytest", "3.5.1")),
199199
],
200200
True,
201201
),
@@ -299,18 +299,18 @@ def test_execute_works_with_ansi_output(
299299
mocker.patch.object(env, "_run", return_value=install_output)
300300
return_code = executor.execute(
301301
[
302-
Install(Package("pytest", "3.5.2")),
302+
Install(Package("pytest", "3.5.1")),
303303
]
304304
)
305305
env._run.assert_called_once()
306306

307307
# fmt: off
308308
expected = [
309309
"\x1b[39;1mPackage operations\x1b[39;22m: \x1b[34m1\x1b[39m install, \x1b[34m0\x1b[39m updates, \x1b[34m0\x1b[39m removals", # noqa: E501
310-
"\x1b[34;1m•\x1b[39;22m \x1b[39mInstalling \x1b[39m\x1b[36mpytest\x1b[39m\x1b[39m (\x1b[39m\x1b[39;1m3.5.2\x1b[39;22m\x1b[39m)\x1b[39m: \x1b[34mPending...\x1b[39m", # noqa: E501
311-
"\x1b[34;1m•\x1b[39;22m \x1b[39mInstalling \x1b[39m\x1b[36mpytest\x1b[39m\x1b[39m (\x1b[39m\x1b[39;1m3.5.2\x1b[39;22m\x1b[39m)\x1b[39m: \x1b[34mDownloading...\x1b[39m", # noqa: E501
312-
"\x1b[34;1m•\x1b[39;22m \x1b[39mInstalling \x1b[39m\x1b[36mpytest\x1b[39m\x1b[39m (\x1b[39m\x1b[39;1m3.5.2\x1b[39;22m\x1b[39m)\x1b[39m: \x1b[34mInstalling...\x1b[39m", # noqa: E501
313-
"\x1b[32;1m•\x1b[39;22m \x1b[39mInstalling \x1b[39m\x1b[36mpytest\x1b[39m\x1b[39m (\x1b[39m\x1b[32m3.5.2\x1b[39m\x1b[39m)\x1b[39m", # finished # noqa: E501
310+
"\x1b[34;1m•\x1b[39;22m \x1b[39mInstalling \x1b[39m\x1b[36mpytest\x1b[39m\x1b[39m (\x1b[39m\x1b[39;1m3.5.1\x1b[39;22m\x1b[39m)\x1b[39m: \x1b[34mPending...\x1b[39m", # noqa: E501
311+
"\x1b[34;1m•\x1b[39;22m \x1b[39mInstalling \x1b[39m\x1b[36mpytest\x1b[39m\x1b[39m (\x1b[39m\x1b[39;1m3.5.1\x1b[39;22m\x1b[39m)\x1b[39m: \x1b[34mDownloading...\x1b[39m", # noqa: E501
312+
"\x1b[34;1m•\x1b[39;22m \x1b[39mInstalling \x1b[39m\x1b[36mpytest\x1b[39m\x1b[39m (\x1b[39m\x1b[39;1m3.5.1\x1b[39;22m\x1b[39m)\x1b[39m: \x1b[34mInstalling...\x1b[39m", # noqa: E501
313+
"\x1b[32;1m•\x1b[39;22m \x1b[39mInstalling \x1b[39m\x1b[36mpytest\x1b[39m\x1b[39m (\x1b[39m\x1b[32m3.5.1\x1b[39m\x1b[39m)\x1b[39m", # finished # noqa: E501
314314
]
315315
# fmt: on
316316

@@ -341,15 +341,15 @@ def test_execute_works_with_no_ansi_output(
341341
mocker.patch.object(env, "_run", return_value=install_output)
342342
return_code = executor.execute(
343343
[
344-
Install(Package("pytest", "3.5.2")),
344+
Install(Package("pytest", "3.5.1")),
345345
]
346346
)
347347
env._run.assert_called_once()
348348

349349
expected = """
350350
Package operations: 1 install, 0 updates, 0 removals
351351
352-
• Installing pytest (3.5.2)
352+
• Installing pytest (3.5.1)
353353
"""
354354
expected = set(expected.splitlines())
355355
output = set(io_not_decorated.fetch_output().splitlines())

0 commit comments

Comments
 (0)