From 4fb1496880ea741aea44dbd5c6bc74f773ba9baa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Randy=20D=C3=B6ring?=
<30527984+radoering@users.noreply.github.com>
Date: Mon, 13 Jun 2022 17:19:55 +0200
Subject: [PATCH 1/6] repositories/link_sources: support for yanked files
according to PEP 592
---
src/poetry/repositories/link_sources/base.py | 14 +++
src/poetry/repositories/link_sources/html.py | 8 +-
tests/repositories/link_sources/test_base.py | 6 +-
tests/repositories/link_sources/test_html.py | 93 ++++++++++++++++++++
4 files changed, 119 insertions(+), 2 deletions(-)
create mode 100644 tests/repositories/link_sources/test_html.py
diff --git a/src/poetry/repositories/link_sources/base.py b/src/poetry/repositories/link_sources/base.py
index 824f536c1c3..6e07de3cfdd 100644
--- a/src/poetry/repositories/link_sources/base.py
+++ b/src/poetry/repositories/link_sources/base.py
@@ -113,3 +113,17 @@ def clean_link(self, url: str) -> str:
the link, it will be rewritten to %20 (while not over-quoting
% or other characters)."""
return self.CLEAN_REGEX.sub(lambda match: f"%{ord(match.group(0)):02x}", url)
+
+ def yanked(self, name: NormalizedName, version: Version) -> str | bool:
+ reasons = set()
+ for link in self.links_for_version(name, version):
+ if link.yanked:
+ if link.yanked_reason:
+ reasons.add(link.yanked_reason)
+ else:
+ # release is not yanked if at least one file is not yanked
+ return False
+ # if all files are yanked (or there are no files) the release is yanked
+ if reasons:
+ return "\n".join(sorted(reasons))
+ return True
diff --git a/src/poetry/repositories/link_sources/html.py b/src/poetry/repositories/link_sources/html.py
index 9f07740642a..d72a8e06ded 100644
--- a/src/poetry/repositories/link_sources/html.py
+++ b/src/poetry/repositories/link_sources/html.py
@@ -33,7 +33,13 @@ def links(self) -> Iterator[Link]:
url = self.clean_link(urllib.parse.urljoin(self._url, href))
pyrequire = anchor.get("data-requires-python")
pyrequire = unescape(pyrequire) if pyrequire else None
- link = Link(url, requires_python=pyrequire)
+ yanked_value = anchor.get("data-yanked")
+ yanked: str | bool
+ if yanked_value:
+ yanked = unescape(yanked_value)
+ else:
+ yanked = "data-yanked" in anchor.attrib
+ link = Link(url, requires_python=pyrequire, yanked=yanked)
if link.ext not in self.SUPPORTED_FORMATS:
continue
diff --git a/tests/repositories/link_sources/test_base.py b/tests/repositories/link_sources/test_base.py
index 2870499854b..c949f90a5eb 100644
--- a/tests/repositories/link_sources/test_base.py
+++ b/tests/repositories/link_sources/test_base.py
@@ -5,6 +5,7 @@
import pytest
+from packaging.utils import canonicalize_name
from poetry.core.packages.package import Package
from poetry.core.packages.utils.link import Link
from poetry.core.semver.version import Version
@@ -87,4 +88,7 @@ def test_links_for_version(
) -> None:
version = Version.parse(version_string)
expected = {Link(f"{link_source.url}/{name}") for name in filenames}
- assert set(link_source.links_for_version("demo", version)) == expected
+ assert (
+ set(link_source.links_for_version(canonicalize_name("demo"), version))
+ == expected
+ )
diff --git a/tests/repositories/link_sources/test_html.py b/tests/repositories/link_sources/test_html.py
new file mode 100644
index 00000000000..35b9ebfaa88
--- /dev/null
+++ b/tests/repositories/link_sources/test_html.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+import pytest
+
+from poetry.core.packages.utils.link import Link
+from poetry.core.semver.version import Version
+
+from poetry.repositories.link_sources.html import HTMLPage
+
+
+DEMO_TEMPLATE = """
+
+
+
+
+ Links for demo
+
+
+ Links for demo
+ {}
+
+
+"""
+
+
+@pytest.mark.parametrize(
+ "attributes, expected_link",
+ [
+ ("", Link("https://example.org/demo-0.1.whl")),
+ (
+ 'data-requires-python=">=3.7"',
+ Link("https://example.org/demo-0.1.whl", requires_python=">=3.7"),
+ ),
+ (
+ "data-yanked",
+ Link("https://example.org/demo-0.1.whl", yanked=True),
+ ),
+ (
+ 'data-yanked=""',
+ Link("https://example.org/demo-0.1.whl", yanked=True),
+ ),
+ (
+ 'data-yanked="<reason>"',
+ Link("https://example.org/demo-0.1.whl", yanked=""),
+ ),
+ (
+ 'data-requires-python=">=3.7" data-yanked',
+ Link(
+ "https://example.org/demo-0.1.whl", requires_python=">=3.7", yanked=True
+ ),
+ ),
+ ],
+)
+def test_link_attributes(attributes: str, expected_link: Link) -> None:
+ anchor = (
+ f'demo-0.1.whl
'
+ )
+ content = DEMO_TEMPLATE.format(anchor)
+ page = HTMLPage("https://example.org", content)
+
+ assert len(list(page.links)) == 1
+ link = list(page.links)[0]
+ assert link.url == expected_link.url
+ assert link.requires_python == expected_link.requires_python
+ assert link.yanked == expected_link.yanked
+ assert link.yanked_reason == expected_link.yanked_reason
+
+
+@pytest.mark.parametrize(
+ "yanked_attrs, expected",
+ [
+ (("", ""), False),
+ (("data-yanked", ""), False),
+ (("", "data-yanked"), False),
+ (("data-yanked", "data-yanked"), True),
+ (("data-yanked='reason'", "data-yanked"), "reason"),
+ (("data-yanked", "data-yanked='reason'"), "reason"),
+ (("data-yanked='reason'", "data-yanked=''"), "reason"),
+ (("data-yanked=''", "data-yanked='reason'"), "reason"),
+ (("data-yanked='reason'", "data-yanked='reason'"), "reason"),
+ (("data-yanked='reason 1'", "data-yanked='reason 2'"), "reason 1\nreason 2"),
+ ],
+)
+def test_yanked(yanked_attrs: tuple[str, str], expected: bool | str) -> None:
+ anchors = (
+ f''
+ "demo-0.1.tar.gz"
+ f'demo-0.1.whl'
+ )
+ content = DEMO_TEMPLATE.format(anchors)
+ page = HTMLPage("https://example.org", content)
+
+ assert page.yanked("demo", Version.parse("0.1")) == expected
From feca8219a9dd7a990a5c09a8bd8696a67e915ca4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Randy=20D=C3=B6ring?=
<30527984+radoering@users.noreply.github.com>
Date: Mon, 13 Jun 2022 17:37:16 +0200
Subject: [PATCH 2/6] repositories: support for yanked releases according to
PEP 592
---
src/poetry/inspection/info.py | 6 +
src/poetry/repositories/cached.py | 2 +-
src/poetry/repositories/http.py | 3 +
src/poetry/repositories/legacy_repository.py | 11 +-
src/poetry/repositories/pypi_repository.py | 14 +-
src/poetry/repositories/repository.py | 7 +-
tests/console/commands/test_add.py | 8 +-
tests/repositories/fixtures/legacy/black.html | 3 +-
.../fixtures/legacy/futures_partial_yank.html | 12 ++
.../dists/black-21.11b0-py3-none-any.whl | Bin 0 -> 2822 bytes
.../fixtures/pypi.org/json/black.json | 42 +++++
.../fixtures/pypi.org/json/black/21.11b0.json | 155 ++++++++++++++++++
tests/repositories/test_legacy_repository.py | 74 +++++++++
tests/repositories/test_pypi_repository.py | 59 +++++++
tests/repositories/test_repository.py | 63 +++++++
15 files changed, 449 insertions(+), 10 deletions(-)
create mode 100644 tests/repositories/fixtures/legacy/futures_partial_yank.html
create mode 100644 tests/repositories/fixtures/pypi.org/dists/black-21.11b0-py3-none-any.whl
create mode 100644 tests/repositories/fixtures/pypi.org/json/black/21.11b0.json
create mode 100644 tests/repositories/test_repository.py
diff --git a/src/poetry/inspection/info.py b/src/poetry/inspection/info.py
index 7e851223b5a..1495bc7c62c 100644
--- a/src/poetry/inspection/info.py
+++ b/src/poetry/inspection/info.py
@@ -69,6 +69,7 @@ def __init__(self, path: Path | str, *reasons: BaseException | str) -> None:
class PackageInfo:
def __init__(
self,
+ *,
name: str | None = None,
version: str | None = None,
summary: str | None = None,
@@ -76,6 +77,7 @@ def __init__(
requires_dist: list[str] | None = None,
requires_python: str | None = None,
files: list[dict[str, str]] | None = None,
+ yanked: str | bool = False,
cache_version: str | None = None,
) -> None:
self.name = name
@@ -85,6 +87,7 @@ def __init__(
self.requires_dist = requires_dist
self.requires_python = requires_python
self.files = files or []
+ self.yanked = yanked
self._cache_version = cache_version
self._source_type: str | None = None
self._source_url: str | None = None
@@ -117,6 +120,7 @@ def asdict(self) -> dict[str, Any]:
"requires_dist": self.requires_dist,
"requires_python": self.requires_python,
"files": self.files,
+ "yanked": self.yanked,
"_cache_version": self._cache_version,
}
@@ -163,6 +167,7 @@ def to_package(
source_type=self._source_type,
source_url=self._source_url,
source_reference=self._source_reference,
+ yanked=self.yanked,
)
if self.summary is not None:
package.description = self.summary
@@ -450,6 +455,7 @@ def from_package(cls, package: Package) -> PackageInfo:
requires_dist=list(requires),
requires_python=package.python_versions,
files=package.files,
+ yanked=package.yanked_reason if package.yanked else False,
)
@staticmethod
diff --git a/src/poetry/repositories/cached.py b/src/poetry/repositories/cached.py
index 17f18c37a46..a4b6b6ad089 100644
--- a/src/poetry/repositories/cached.py
+++ b/src/poetry/repositories/cached.py
@@ -21,7 +21,7 @@
class CachedRepository(Repository, ABC):
- CACHE_VERSION = parse_constraint("1.0.0")
+ CACHE_VERSION = parse_constraint("1.1.0")
def __init__(
self, name: str, disable_cache: bool = False, config: Config | None = None
diff --git a/src/poetry/repositories/http.py b/src/poetry/repositories/http.py
index a3eef5f1643..c63f2b19281 100644
--- a/src/poetry/repositories/http.py
+++ b/src/poetry/repositories/http.py
@@ -210,6 +210,9 @@ def _links_to_data(self, links: list[Link], data: PackageInfo) -> dict[str, Any]
urls = defaultdict(list)
files: list[dict[str, Any]] = []
for link in links:
+ if link.yanked and not data.yanked:
+ # drop yanked files unless the entire release is yanked
+ continue
if link.is_wheel:
urls["bdist_wheel"].append(link.url)
elif link.filename.endswith(
diff --git a/src/poetry/repositories/legacy_repository.py b/src/poetry/repositories/legacy_repository.py
index 5c115c979f4..51aece929d0 100644
--- a/src/poetry/repositories/legacy_repository.py
+++ b/src/poetry/repositories/legacy_repository.py
@@ -72,7 +72,7 @@ def _find_packages(
"""
Find packages on the remote server.
"""
- versions: list[Version]
+ versions: list[tuple[Version, str | bool]]
key: str = name
if not constraint.is_any():
@@ -90,7 +90,9 @@ def _find_packages(
return []
versions = [
- version for version in page.versions(name) if constraint.allows(version)
+ (version, page.yanked(name, version))
+ for version in page.versions(name)
+ if constraint.allows(version)
]
self._cache.store("matches").put(key, versions, 5)
@@ -101,8 +103,9 @@ def _find_packages(
source_type="legacy",
source_reference=self.name,
source_url=self._url,
+ yanked=yanked,
)
- for version in versions
+ for version, yanked in versions
]
def _get_release_info(
@@ -113,6 +116,7 @@ def _get_release_info(
raise PackageNotFound(f'No package named "{name}"')
links = list(page.links_for_version(name, version))
+ yanked = page.yanked(name, version)
return self._links_to_data(
links,
@@ -124,6 +128,7 @@ def _get_release_info(
requires_dist=[],
requires_python=None,
files=[],
+ yanked=yanked,
cache_version=str(self.CACHE_VERSION),
),
)
diff --git a/src/poetry/repositories/pypi_repository.py b/src/poetry/repositories/pypi_repository.py
index 3413aedde45..277df3cf1ae 100644
--- a/src/poetry/repositories/pypi_repository.py
+++ b/src/poetry/repositories/pypi_repository.py
@@ -144,7 +144,10 @@ def _find_packages(
continue
if constraint.allows(version):
- packages.append(Package(info["info"]["name"], version))
+ # PEP 592: PyPI always yanks entire releases, not individual files,
+ # so we just have to look for the first file
+ yanked = self._get_yanked(release[0])
+ packages.append(Package(info["info"]["name"], version, yanked=yanked))
return packages
@@ -163,7 +166,7 @@ def find_links_for_package(self, package: Package) -> list[Link]:
links = []
for url in json_data["urls"]:
h = f"sha256={url['digests']['sha256']}"
- links.append(Link(url["url"] + "#" + h))
+ links.append(Link(url["url"] + "#" + h, yanked=self._get_yanked(url)))
return links
@@ -188,6 +191,7 @@ def _get_release_info(
requires_dist=info["requires_dist"],
requires_python=info["requires_python"],
files=info.get("files", []),
+ yanked=self._get_yanked(info),
cache_version=str(self.CACHE_VERSION),
)
@@ -254,3 +258,9 @@ def _get(self, endpoint: str) -> dict[str, Any] | None:
json: dict[str, Any] = json_response.json()
return json
+
+ @staticmethod
+ def _get_yanked(json_data: dict[str, Any]) -> str | bool:
+ if json_data.get("yanked", False):
+ return json_data.get("yanked_reason") or True # noqa: SIM222
+ return False
diff --git a/src/poetry/repositories/repository.py b/src/poetry/repositories/repository.py
index b5933a23d66..2d823bf7276 100644
--- a/src/poetry/repositories/repository.py
+++ b/src/poetry/repositories/repository.py
@@ -5,6 +5,7 @@
from typing import TYPE_CHECKING
from poetry.core.semver.helpers import parse_constraint
+from poetry.core.semver.version import Version
from poetry.core.semver.version_constraint import VersionConstraint
from poetry.core.semver.version_range import VersionRange
@@ -16,7 +17,6 @@
from poetry.core.packages.dependency import Dependency
from poetry.core.packages.package import Package
from poetry.core.packages.utils.link import Link
- from poetry.core.semver.version import Version
class Repository:
@@ -43,6 +43,11 @@ def find_packages(self, dependency: Dependency) -> list[Package]:
ignored_pre_release_packages = []
for package in self._find_packages(dependency.name, constraint):
+ if package.yanked and not isinstance(constraint, Version):
+ # PEP 592: yanked files are always ignored, unless they are the only
+ # file that matches a version specifier that "pins" to an exact
+ # version
+ continue
if (
package.is_prerelease()
and not allow_prereleases
diff --git a/tests/console/commands/test_add.py b/tests/console/commands/test_add.py
index 27f803d3915..72dc8836825 100644
--- a/tests/console/commands/test_add.py
+++ b/tests/console/commands/test_add.py
@@ -823,7 +823,9 @@ def test_add_constraint_with_source(
):
repo = LegacyRepository(name="my-index", url="https://my-index.fake")
repo.add_package(get_package("cachy", "0.2.0"))
- repo._cache.store("matches").put("cachy:0.2.0", [Version.parse("0.2.0")], 5)
+ repo._cache.store("matches").put(
+ "cachy:0.2.0", [(Version.parse("0.2.0"), False)], 5
+ )
poetry.pool.add_repository(repo)
@@ -1810,7 +1812,9 @@ def test_add_constraint_with_source_old_installer(
):
repo = LegacyRepository(name="my-index", url="https://my-index.fake")
repo.add_package(get_package("cachy", "0.2.0"))
- repo._cache.store("matches").put("cachy:0.2.0", [Version.parse("0.2.0")], 5)
+ repo._cache.store("matches").put(
+ "cachy:0.2.0", [(Version.parse("0.2.0"), False)], 5
+ )
poetry.pool.add_repository(repo)
diff --git a/tests/repositories/fixtures/legacy/black.html b/tests/repositories/fixtures/legacy/black.html
index 9d3fa08c633..ea050662b83 100644
--- a/tests/repositories/fixtures/legacy/black.html
+++ b/tests/repositories/fixtures/legacy/black.html
@@ -4,7 +4,8 @@
Links for black
Links for black
- black-19.10b0.tar.gz
+ black-19.10b0-py36-none-any.whl
+ black-21.11b0-py3-none-any.whl