Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/poetry/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ class Config:
"no-binary": None,
"only-binary": None,
"build-config-settings": {},
"minimum-release-age": None,
"minimum-release-age-exclude": [],
},
"python": {"installation-dir": os.path.join("{data-dir}", "python")},
"solver": {
Expand Down Expand Up @@ -398,9 +400,17 @@ def _get_normalizer(name: str) -> Callable[[str], Any]:
if name in {
"installer.max-workers",
"requests.max-retries",
"installer.minimum-release-age",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The additions in this file have the same code as console.commands.config which seems like a recipe for unexpected behaviour. For example, this version does not apply the console version's check that the minimum release age is greater than zero.

}:
return int_normalizer

if name == "installer.minimum-release-age-exclude":
return lambda val: (
[v.strip() for v in val.split(",")]
if isinstance(val, str)
else list(val)
)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated

if name in ["installer.no-binary", "installer.only-binary"]:
return PackageFilterPolicy.normalize

Expand Down
55 changes: 55 additions & 0 deletions src/poetry/repositories/http_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

from contextlib import contextmanager
from contextlib import suppress
from datetime import datetime
from datetime import timezone
from fnmatch import fnmatch
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -71,6 +74,12 @@ def __init__(

self._lazy_wheel = config.get("solver.lazy-wheel", True)
self._max_retries = config.get("requests.max-retries", 0)
self._minimum_release_age: int | None = config.get(
"installer.minimum-release-age"
)
self._minimum_release_age_exclude: list[str] = config.get(
"installer.minimum-release-age-exclude", []
)
# We are tracking if a domain supports range requests or not to avoid
# unnecessary requests.
# ATTENTION: A domain might support range requests only for some files, so the
Expand Down Expand Up @@ -477,3 +486,49 @@ def _get_page(self, name: NormalizedName) -> LinkSource:
if self._is_json_response(response):
return SimpleJsonPage(response.url, response.json())
return HTMLPage(response.url, response.text)

def _release_age_days(
self, page: LinkSource, name: NormalizedName, version: Any
) -> int | None:
"""
Return the age in days of the oldest distribution file for the given version,
or None if no upload_time data is available (e.g. HTML-only index pages).
"""
upload_times = [
datetime.fromisoformat(link.upload_time_isoformat)
for link in page.links_for_version(name, version)
if link.upload_time_isoformat is not None
]
if not upload_times:
return None
return (datetime.now(timezone.utc) - min(upload_times)).days
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

def _is_release_age_excluded(self, name: NormalizedName) -> bool:
return any(
fnmatch(str(name), pattern)
for pattern in self._minimum_release_age_exclude
)

def _version_meets_minimum_age(
self, page: LinkSource, name: NormalizedName, version: Any
) -> bool:
"""
Return True if the version is old enough to satisfy minimum-release-age,
or if the feature is disabled / the package is excluded / age data is absent.
"""
if self._minimum_release_age is None:
return True
if self._is_release_age_excluded(name):
return True
age = self._release_age_days(page, name, version)
if age is None:
# No upload_time data available (HTML index) — fail open.
return True
if age < self._minimum_release_age:
self._log(
f"Skipping {name} {version}: released {age} day(s) ago"
f" (minimum-release-age={self._minimum_release_age})",
level="debug",
)
return False
return True
1 change: 1 addition & 0 deletions src/poetry/repositories/legacy_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def _find_packages(
(version, page.yanked(name, version))
for version in page.versions(name)
if constraint.allows(version)
and self._version_meets_minimum_age(page, name, version)
]

return [
Expand Down
1 change: 1 addition & 0 deletions src/poetry/repositories/pypi_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def _find_packages(
(version, json_page.yanked(name, version))
for version in json_page.versions(name)
if constraint.allows(version)
and self._version_meets_minimum_age(json_page, name, version)
]

return [Package(name, version, yanked=yanked) for version, yanked in versions]
Expand Down
108 changes: 108 additions & 0 deletions tests/repositories/test_pypi_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from requests.models import Response

from poetry.factory import Factory
from poetry.repositories.link_sources.json import SimpleJsonPage
from poetry.repositories.pypi_repository import PyPiRepository


Expand All @@ -29,6 +30,41 @@
pass


def test_release_age_days_with_upload_time(
pypi_repository: PyPiRepository,
) -> None:
# requests fixture has upload-time from 2017, so always well over 1000 days old
page = pypi_repository.get_page(canonicalize_name("requests"))
age = pypi_repository._release_age_days(

Check failure on line 38 in tests/repositories/test_pypi_repository.py

View workflow job for this annotation

GitHub Actions / Ubuntu (Python 3.10) / pytest

test_release_age_days_with_upload_time ValueError: Invalid isoformat string: '2017-06-14T15:44:35.080617Z'

Check failure on line 38 in tests/repositories/test_pypi_repository.py

View workflow job for this annotation

GitHub Actions / macOS aarch64 (Python 3.10) / pytest

test_release_age_days_with_upload_time ValueError: Invalid isoformat string: '2017-06-14T15:44:35.080617Z'
page, canonicalize_name("requests"), Version.parse("2.18.0")
)
assert age is not None
assert age > 1000


def test_release_age_days_without_upload_time(
pypi_repository: PyPiRepository,
) -> None:
page = SimpleJsonPage(
"https://pypi.org/simple/requests/",
{
"meta": {"api-version": "1.0"},
"name": "requests",
"files": [
{
"filename": "requests-2.18.0-py2.py3-none-any.whl",
"url": "https://files.pythonhosted.org/packages/requests-2.18.0.whl",
"hashes": {"sha256": "abc123"},
}
],
},
)
age = pypi_repository._release_age_days(
page, canonicalize_name("requests"), Version.parse("2.18.0")
)
assert age is None


def test_find_packages(pypi_repository: PyPiRepository) -> None:
repo = pypi_repository
packages = repo.find_packages(Factory.create_dependency("requests", "~2.18.0"))
Expand Down Expand Up @@ -84,6 +120,78 @@
assert [str(p.version) for p in packages] == expected


@pytest.mark.parametrize(
["minimum_age", "expected_count"],
[
# disabled (default): all versions returned
(None, 5),
# threshold well below fixture age (~3100+ days): all versions pass
(365, 5),
# threshold far exceeding fixture age: all versions filtered
(99999, 0),
],
)
def test_find_packages_minimum_release_age(
minimum_age: int | None,
expected_count: int,
pypi_repository: PyPiRepository,
) -> None:
pypi_repository._minimum_release_age = minimum_age
packages = pypi_repository.find_packages(

Check failure on line 140 in tests/repositories/test_pypi_repository.py

View workflow job for this annotation

GitHub Actions / Ubuntu (Python 3.10) / pytest

test_find_packages_minimum_release_age[365-5] ValueError: Invalid isoformat string: '2017-06-14T15:44:35.080617Z'

Check failure on line 140 in tests/repositories/test_pypi_repository.py

View workflow job for this annotation

GitHub Actions / Ubuntu (Python 3.10) / pytest

test_find_packages_minimum_release_age[99999-0] ValueError: Invalid isoformat string: '2017-06-14T15:44:35.080617Z'

Check failure on line 140 in tests/repositories/test_pypi_repository.py

View workflow job for this annotation

GitHub Actions / macOS aarch64 (Python 3.10) / pytest

test_find_packages_minimum_release_age[99999-0] ValueError: Invalid isoformat string: '2017-06-14T15:44:35.080617Z'
Factory.create_dependency("requests", "~2.18.0")
)
assert len(packages) == expected_count


@pytest.mark.parametrize(
["exclude_patterns", "expected_count"],
[
# no exclusions: all filtered by the high threshold
([], 0),
# exact name bypasses filter
(["requests"], 5),
# glob pattern bypasses filter
(["req*"], 5),
],
)
def test_find_packages_minimum_release_age_exclude(
exclude_patterns: list[str],
expected_count: int,
pypi_repository: PyPiRepository,
) -> None:
pypi_repository._minimum_release_age = 99999
pypi_repository._minimum_release_age_exclude = exclude_patterns
packages = pypi_repository.find_packages(

Check failure on line 164 in tests/repositories/test_pypi_repository.py

View workflow job for this annotation

GitHub Actions / Ubuntu (Python 3.10) / pytest

test_find_packages_minimum_release_age_exclude[exclude_patterns0-0] ValueError: Invalid isoformat string: '2017-06-14T15:44:35.080617Z'

Check failure on line 164 in tests/repositories/test_pypi_repository.py

View workflow job for this annotation

GitHub Actions / macOS aarch64 (Python 3.10) / pytest

test_find_packages_minimum_release_age_exclude[exclude_patterns0-0] ValueError: Invalid isoformat string: '2017-06-14T15:44:35.080617Z'
Factory.create_dependency("requests", "~2.18.0")
)
assert len(packages) == expected_count


def test_version_meets_minimum_age_fails_open_without_upload_time(
pypi_repository: PyPiRepository,
) -> None:
# A page with no upload-time should allow the version through (fail open)
page = SimpleJsonPage(
"https://pypi.org/simple/requests/",
{
"meta": {"api-version": "1.0"},
"name": "requests",
"files": [
{
"filename": "requests-2.18.0-py2.py3-none-any.whl",
"url": "https://files.pythonhosted.org/packages/requests-2.18.0.whl",
"hashes": {"sha256": "abc123"},
}
],
},
)
pypi_repository._minimum_release_age = 99999
result = pypi_repository._version_meets_minimum_age(
page, canonicalize_name("requests"), Version.parse("2.18.0")
)
assert result is True


def test_package(
pypi_repository: PyPiRepository,
dist_hash_getter: DistributionHashGetter,
Expand Down
Loading