From 9e116a00c3abcfcf194373ec90f6a0d8d285679d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:47:08 +0200 Subject: [PATCH 1/3] feat: add a `solver.min-release-age` config option --- docs/configuration.md | 28 +++ src/poetry/config/config.py | 2 + src/poetry/console/commands/config.py | 1 + src/poetry/puzzle/solver.py | 46 ++--- src/poetry/repositories/http_repository.py | 93 ++++++++++ src/poetry/repositories/legacy_repository.py | 41 ++--- src/poetry/repositories/pypi_repository.py | 24 +-- src/poetry/repositories/repository.py | 10 ++ src/poetry/repositories/repository_pool.py | 4 + tests/config/test_config.py | 3 +- tests/console/commands/test_config.py | 24 +++ tests/puzzle/test_solver.py | 36 ++++ tests/repositories/test_http_repository.py | 170 ++++++++++++++++++- tests/repositories/test_legacy_repository.py | 43 +++++ tests/repositories/test_pypi_repository.py | 43 +++++ tests/repositories/test_repository_pool.py | 25 +++ 16 files changed, 519 insertions(+), 74 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index abe4ea38ec8..eb8f96c872b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -395,6 +395,34 @@ Especially with slow network connections, this setting can speed up dependency r If the cache has already been filled or the server does not support HTTP range requests, this setting makes no difference. +### `solver.min-release-age` + +**Type**: `int` + +**Default**: `0` + +**Environment Variable**: `POETRY_SOLVER_MIN_RELEASE_AGE` + +*Introduced in 2.4.0* + +Minimum age of a package release in **days** before it is considered during dependency resolution. +When set, any package version where at least one distribution file was uploaded more recently +than the specified number of days ago will be ignored by the solver. + +For example, with a value of `7`, a version is only considered +if all known distribution files are at least seven days old. +If the option is not set or set to `0`, all versions are considered. + +This option is useful to protect against supply chain attacks where a new release +of a dependency is published with malicious code. +This is often detected within hours or days and the compromised release is removed. + +{{% note %}} +This filter can only be enforced for package sources that expose file upload timestamps. +If a source does not provide upload times for a release, +that release is not filtered out by this setting. +{{% /note %}} + ### `system-git-client` **Type**: `boolean` diff --git a/src/poetry/config/config.py b/src/poetry/config/config.py index c4c6cdf3433..e79ee0aebf2 100644 --- a/src/poetry/config/config.py +++ b/src/poetry/config/config.py @@ -174,6 +174,7 @@ class Config: "python": {"installation-dir": os.path.join("{data-dir}", "python")}, "solver": { "lazy-wheel": True, + "min-release-age": 0, }, "system-git-client": False, "keyring": { @@ -398,6 +399,7 @@ def _get_normalizer(name: str) -> Callable[[str], Any]: if name in { "installer.max-workers", "requests.max-retries", + "solver.min-release-age", }: return int_normalizer diff --git a/src/poetry/console/commands/config.py b/src/poetry/console/commands/config.py index 60eaa33b046..2a38fc0367a 100644 --- a/src/poetry/console/commands/config.py +++ b/src/poetry/console/commands/config.py @@ -102,6 +102,7 @@ def unique_config_values(self) -> dict[str, tuple[Any, Any]]: PackageFilterPolicy.normalize, ), "solver.lazy-wheel": (boolean_validator, boolean_normalizer), + "solver.min-release-age": (lambda val: int(val) >= 0, int_normalizer), "keyring.enabled": (boolean_validator, boolean_normalizer), "python.installation-dir": (str, lambda val: str(Path(val))), } diff --git a/src/poetry/puzzle/solver.py b/src/poetry/puzzle/solver.py index 539f7eeb6f1..a0a0bcd63b7 100644 --- a/src/poetry/puzzle/solver.py +++ b/src/poetry/puzzle/solver.py @@ -86,28 +86,34 @@ def solve( ) -> Transaction: from poetry.puzzle.transaction import Transaction - with self._progress(), self._provider.use_latest_for(use_latest or []): - start = time.time() - packages = self._solve() - # simplify markers by removing redundant information - for transitive_info in packages.values(): - for group, marker in transitive_info.markers.items(): - transitive_info.markers[group] = simplify_marker( - marker, self._package.python_constraint + try: + with self._progress(), self._provider.use_latest_for(use_latest or []): + start = time.time() + packages = self._solve() + # simplify markers by removing redundant information + for transitive_info in packages.values(): + for group, marker in transitive_info.markers.items(): + transitive_info.markers[group] = simplify_marker( + marker, self._package.python_constraint + ) + end = time.time() + + if len(self._overrides) > 1: + self._provider.debug( + # ignore the warning as provider does not do interpolation + f"Complete version solving took {end - start:.3f}" + f" seconds with {len(self._overrides)} overrides" ) - end = time.time() + self._provider.debug( + # ignore the warning as provider does not do interpolation + "Resolved with overrides:" + f" {', '.join(f'({b})' for b in self._overrides)}" + ) + except SolverProblemError: + self._pool.log_age_filtered_versions(level="warning", reset=True) + raise - if len(self._overrides) > 1: - self._provider.debug( - # ignore the warning as provider does not do interpolation - f"Complete version solving took {end - start:.3f}" - f" seconds with {len(self._overrides)} overrides" - ) - self._provider.debug( - # ignore the warning as provider does not do interpolation - "Resolved with overrides:" - f" {', '.join(f'({b})' for b in self._overrides)}" - ) + self._pool.log_age_filtered_versions(level="info", reset=True) for p in packages: if p.yanked: diff --git a/src/poetry/repositories/http_repository.py b/src/poetry/repositories/http_repository.py index 1cf260b391e..5963609e400 100644 --- a/src/poetry/repositories/http_repository.py +++ b/src/poetry/repositories/http_repository.py @@ -3,8 +3,12 @@ import functools import hashlib +from collections import defaultdict from contextlib import contextmanager from contextlib import suppress +from datetime import datetime +from datetime import timedelta +from datetime import timezone from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING @@ -14,6 +18,8 @@ import requests.adapters from packaging.metadata import parse_email +from poetry.core.constraints.version import Version +from poetry.core.constraints.version import VersionConstraint from poetry.core.constraints.version import parse_constraint from poetry.core.packages.dependency import Dependency from poetry.core.version.markers import parse_marker @@ -36,9 +42,11 @@ if TYPE_CHECKING: + from collections.abc import Iterable from collections.abc import Iterator from packaging.utils import NormalizedName + from poetry.core.packages.package import Package from poetry.core.packages.package import PackageFile from poetry.core.packages.utils.link import Link @@ -71,6 +79,16 @@ def __init__( self._lazy_wheel = config.get("solver.lazy-wheel", True) self._max_retries = config.get("requests.max-retries", 0) + + self._min_release_age = config.get("solver.min-release-age", 0) + self._min_release_age_cutoff: datetime | None = None + if self._min_release_age: + self._min_release_age_cutoff = datetime.now(tz=timezone.utc) - timedelta( + days=self._min_release_age + ) + self._age_filtered_versions: defaultdict[NormalizedName, set[Version]] = ( + defaultdict(set) + ) # 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 @@ -119,6 +137,65 @@ def _cached_or_downloaded_file( ) yield filepath + def _package( + self, name: NormalizedName, version: Version, yanked: str | bool + ) -> Package: + raise NotImplementedError + + def _is_version_too_recent(self, links: Iterable[Link]) -> bool: + """Return True if any file of the version was uploaded after the cutoff. + + If no upload time information is available for any file, + the version is considered old enough (return False). + """ + if not self._min_release_age_cutoff: + return False + for link in links: + upload_time = link.upload_time + if upload_time is None: + continue + if upload_time > self._min_release_age_cutoff: + return True + return False + + def _find_packages( + self, name: NormalizedName, constraint: VersionConstraint + ) -> list[Package]: + """ + Find packages on the remote server. + """ + try: + page = self.get_page(name) + except PackageNotFoundError: + self._log(f"No packages found for {name}", level="debug") + return [] + + versions = [ + (version, page.yanked(name, version)) + for version in page.versions(name) + if constraint.allows(version) + ] + + if self._min_release_age_cutoff is not None: + filtered_out: set[Version] = set() + accepted: list[tuple[Version, str | bool]] = [] + for version, yanked in versions: + if self._is_version_too_recent(page.links_for_version(name, version)): + filtered_out.add(version) + else: + accepted.append((version, yanked)) + if filtered_out: + self._age_filtered_versions[name] |= filtered_out + version_list = ", ".join(str(v) for v in sorted(filtered_out)) + self._log( + f"Ignoring {name} version(s) due to " + f"solver.min-release-age={self._min_release_age}: {version_list}", + level="debug", + ) + versions = accepted + + return [self._package(name, version, yanked) for version, yanked in versions] + def _get_info_from_wheel(self, link: Link) -> PackageInfo: from poetry.inspection.info import PackageInfo @@ -477,3 +554,19 @@ 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 log_age_filtered_versions(self, *, level: str, reset: bool) -> None: + if not self._age_filtered_versions: + return + self._log( + "The following package versions were ignored" + f" due to solver.min-release-age={self._min_release_age}", + level=level, + ) + for name in sorted(self._age_filtered_versions): + versions = ", ".join( + str(v) for v in sorted(self._age_filtered_versions[name]) + ) + self._log(f"{name}: {versions}", level=level) + if reset: + self._age_filtered_versions.clear() diff --git a/src/poetry/repositories/legacy_repository.py b/src/poetry/repositories/legacy_repository.py index 04f82d0aa04..0177783ceb9 100644 --- a/src/poetry/repositories/legacy_repository.py +++ b/src/poetry/repositories/legacy_repository.py @@ -20,7 +20,6 @@ if TYPE_CHECKING: from packaging.utils import NormalizedName from poetry.core.constraints.version import Version - from poetry.core.constraints.version import VersionConstraint from poetry.core.packages.utils.link import Link from poetry.config.config import Config @@ -79,35 +78,17 @@ def find_links_for_package(self, package: Package) -> list[Link]: return list(page.links_for_version(package.name, package.version)) - def _find_packages( - self, name: NormalizedName, constraint: VersionConstraint - ) -> list[Package]: - """ - Find packages on the remote server. - """ - try: - page = self.get_page(name) - except PackageNotFoundError: - self._log(f"No packages found for {name}", level="debug") - return [] - - versions = [ - (version, page.yanked(name, version)) - for version in page.versions(name) - if constraint.allows(version) - ] - - return [ - Package( - name, - version, - source_type="legacy", - source_reference=self.name, - source_url=self._url, - yanked=yanked, - ) - for version, yanked in versions - ] + def _package( + self, name: NormalizedName, version: Version, yanked: str | bool + ) -> Package: + return Package( + name, + version, + source_type="legacy", + source_reference=self.name, + source_url=self._url, + yanked=yanked, + ) def _get_release_info( self, name: NormalizedName, version: Version diff --git a/src/poetry/repositories/pypi_repository.py b/src/poetry/repositories/pypi_repository.py index a047492d426..ecf03792e22 100644 --- a/src/poetry/repositories/pypi_repository.py +++ b/src/poetry/repositories/pypi_repository.py @@ -30,7 +30,6 @@ if TYPE_CHECKING: from packaging.utils import NormalizedName from poetry.core.constraints.version import Version - from poetry.core.constraints.version import VersionConstraint from poetry.config.config import Config @@ -102,25 +101,10 @@ def get_package_info(self, name: NormalizedName) -> dict[str, Any]: """ return self._get_package_info(name) - def _find_packages( - self, name: NormalizedName, constraint: VersionConstraint - ) -> list[Package]: - """ - Find packages on the remote server. - """ - try: - json_page = self.get_page(name) - except PackageNotFoundError: - self._log(f"No packages found for {name}", level="debug") - return [] - - versions = [ - (version, json_page.yanked(name, version)) - for version in json_page.versions(name) - if constraint.allows(version) - ] - - return [Package(name, version, yanked=yanked) for version, yanked in versions] + def _package( + self, name: NormalizedName, version: Version, yanked: str | bool + ) -> Package: + return Package(name, version, yanked=yanked) def _get_package_info(self, name: NormalizedName) -> dict[str, Any]: headers = {"Accept": "application/vnd.pypi.simple.v1+json"} diff --git a/src/poetry/repositories/repository.py b/src/poetry/repositories/repository.py index d7559087c8c..8ae12bf6afa 100644 --- a/src/poetry/repositories/repository.py +++ b/src/poetry/repositories/repository.py @@ -109,3 +109,13 @@ def package(self, name: str, version: Version) -> Package: return package raise PackageNotFoundError(f"Package {name} ({version}) not found.") + + def log_age_filtered_versions(self, *, level: str, reset: bool) -> None: + """Log package versions that were ignored due to solver.min-release-age. + + Must be overridden in Repository classes that support filtering by release age. + + Args: + level: The log level to use (e.g. "info", "warning"). + reset: If True, clear the recorded filtered versions after logging. + """ diff --git a/src/poetry/repositories/repository_pool.py b/src/poetry/repositories/repository_pool.py index 9af24da2a97..210f4c6520f 100644 --- a/src/poetry/repositories/repository_pool.py +++ b/src/poetry/repositories/repository_pool.py @@ -189,3 +189,7 @@ def refresh(self, package: Package) -> Package: if isinstance(repo, CachedRepository): repo.forget(package.name, package.version) return repo.package(package.name, package.version) + + def log_age_filtered_versions(self, *, level: str, reset: bool) -> None: + for repo in self.all_repositories: + repo.log_age_filtered_versions(level=level, reset=reset) diff --git a/tests/config/test_config.py b/tests/config/test_config.py index a75b033690f..bf89919069e 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -43,9 +43,10 @@ def get_options_based_on_normalizer(normalizer: Normalizer) -> Iterator[str]: ("installer.parallel", True), ("virtualenvs.create", True), ("requests.max-retries", 0), + ("solver.min-release-age", 0), ], ) -def test_config_get_default_value(config: Config, name: str, value: bool) -> None: +def test_config_get_default_value(config: Config, name: str, value: bool | int) -> None: assert config.get(name) is value diff --git a/tests/console/commands/test_config.py b/tests/console/commands/test_config.py index a49360bb10c..e3faad05007 100644 --- a/tests/console/commands/test_config.py +++ b/tests/console/commands/test_config.py @@ -75,6 +75,7 @@ def test_list_displays_default_value_if_not_set( python.installation-dir = {json.dumps(str(Path("{data-dir}/python")))} # {config_data_dir / "python"} requests.max-retries = 0 solver.lazy-wheel = true +solver.min-release-age = 0 system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -110,6 +111,7 @@ def test_list_displays_set_get_setting( python.installation-dir = {json.dumps(str(Path("{data-dir}/python")))} # {config_data_dir / "python"} requests.max-retries = 0 solver.lazy-wheel = true +solver.min-release-age = 0 system-git-client = false virtualenvs.create = false virtualenvs.in-project = null @@ -166,6 +168,7 @@ def test_unset_setting( python.installation-dir = {json.dumps(str(Path("{data-dir}/python")))} # {config_data_dir / "python"} requests.max-retries = 0 solver.lazy-wheel = true +solver.min-release-age = 0 system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -200,6 +203,7 @@ def test_unset_repo_setting( python.installation-dir = {json.dumps(str(Path("{data-dir}/python")))} # {config_data_dir / "python"} requests.max-retries = 0 solver.lazy-wheel = true +solver.min-release-age = 0 system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -335,6 +339,7 @@ def test_list_displays_set_get_local_setting( python.installation-dir = {json.dumps(str(Path("{data-dir}/python")))} # {config_data_dir / "python"} requests.max-retries = 0 solver.lazy-wheel = true +solver.min-release-age = 0 system-git-client = false virtualenvs.create = false virtualenvs.in-project = null @@ -379,6 +384,7 @@ def test_list_must_not_display_sources_from_pyproject_toml( repositories.foo.url = "https://foo.bar/simple/" requests.max-retries = 0 solver.lazy-wheel = true +solver.min-release-age = 0 system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -600,6 +606,24 @@ def test_config_solver_lazy_wheel( assert not repo._lazy_wheel +def test_config_solver_min_release_age( + tester: CommandTester, command_tester_factory: CommandTesterFactory +) -> None: + tester.execute("--local solver.min-release-age") + assert tester.io.fetch_output().strip() == "0" + + repo = LegacyRepository("foo", "https://foo.com") + assert repo._min_release_age == 0 + + tester.io.clear_output() + tester.execute("--local solver.min-release-age 3") + tester.execute("--local solver.min-release-age") + assert tester.io.fetch_output().strip() == "3" + + repo = LegacyRepository("foo", "https://foo.com") + assert repo._min_release_age == 3 + + current_config = """\ [experimental] system-git-client = true diff --git a/tests/puzzle/test_solver.py b/tests/puzzle/test_solver.py index 3d3e97a87ef..8a30dde71fc 100644 --- a/tests/puzzle/test_solver.py +++ b/tests/puzzle/test_solver.py @@ -5245,3 +5245,39 @@ def test_solver_resolves_duplicate_dependencies_with_restricted_extras( ] ), ) + + +def test_solver_logs_age_filtered_versions_on_failure( + mocker: MockerFixture, pool: RepositoryPool, solver: Solver +) -> None: + """ + When the solver fails with a SolverProblemError, it should call + RepositoryPool.log_age_filtered_versions(level="warning", reset=True). + """ + log_age_filtered_versions_spy = mocker.spy(pool, "log_age_filtered_versions") + + # Force a SolverProblemError from solve() + mock_solve = mocker.patch.object( + solver, "_solve", side_effect=SolverProblemError(mocker.MagicMock()) + ) + + with pytest.raises(SolverProblemError): + solver.solve() + + mock_solve.assert_called_once() + log_age_filtered_versions_spy.assert_called_once_with(level="warning", reset=True) + + +def test_solver_logs_age_filtered_versions_on_success( + mocker: MockerFixture, pool: RepositoryPool, solver: Solver +) -> None: + """ + When the solver succeeds, it should call + RepositoryPool.log_age_filtered_versions(level="info", reset=True). + """ + log_age_filtered_versions_spy = mocker.spy(pool, "log_age_filtered_versions") + mocker.patch.object(solver, "_solve", return_value=mocker.MagicMock()) + + solver.solve() + + log_age_filtered_versions_spy.assert_called_once_with(level="info", reset=True) diff --git a/tests/repositories/test_http_repository.py b/tests/repositories/test_http_repository.py index fa1c9cc417c..fd5c299f00f 100644 --- a/tests/repositories/test_http_repository.py +++ b/tests/repositories/test_http_repository.py @@ -1,8 +1,12 @@ from __future__ import annotations import contextlib +import logging import shutil +from datetime import datetime +from datetime import timedelta +from datetime import timezone from pathlib import Path from typing import TYPE_CHECKING from typing import Any @@ -11,6 +15,8 @@ import pytest from packaging.metadata import parse_email +from packaging.utils import canonicalize_name +from poetry.core.constraints.version import Version from poetry.core.packages.utils.link import Link from poetry.inspection.info import PackageInfoError @@ -21,15 +27,16 @@ if TYPE_CHECKING: from packaging.utils import NormalizedName - from poetry.core.constraints.version import Version from pytest_mock import MockerFixture + from poetry.config.config import Config + class MockRepository(HTTPRepository): DIST_FIXTURES = Path(__file__).parent / "fixtures" / "pypi.org" / "dists" - def __init__(self, lazy_wheel: bool = True) -> None: - super().__init__("foo", "https://foo.com") + def __init__(self, lazy_wheel: bool = True, config: Config | None = None) -> None: + super().__init__("foo", "https://foo.com", config=config) self._lazy_wheel = lazy_wheel def _get_release_info( @@ -38,6 +45,118 @@ def _get_release_info( raise NotImplementedError +@pytest.mark.parametrize("min_release_age", [None, 0, 30]) +def test_min_release_age_and_cutoff_is_set( + mocker: MockerFixture, config: Config, min_release_age: int | None +) -> None: + config.merge({"solver": {"min-release-age": min_release_age}}) + + mocked_now = datetime(2017, 8, 20, tzinfo=timezone.utc) + mocker.patch( # type: ignore[call-overload] + "poetry.repositories.http_repository.datetime", + wraps=datetime, + **{"now.return_value": mocked_now}, + ) + + repo = MockRepository(config=config) + + assert repo._min_release_age == min_release_age + if min_release_age: + assert repo._min_release_age_cutoff == mocked_now - timedelta( + days=min_release_age + ) + else: + assert repo._min_release_age_cutoff is None + + +@pytest.mark.parametrize( + ("links", "expected"), + [ + ( # no upload time + [Link("https://foo.com/pkg-1.0.tar.gz")], + False, + ), + ( # before cutoff date + [ + Link( + "https://foo.com/pkg-1.0.tar.gz", + upload_time="2017-08-09T00:00:00Z", + ), + ], + False, + ), + ( # exact cutoff date + [ + Link( + "https://foo.com/pkg-1.0.tar.gz", + upload_time="2017-08-10T00:00:00Z", + ), + ], + False, + ), + ( # after cutoff date + [ + Link( + "https://foo.com/pkg-1.0.tar.gz", + upload_time="2017-08-11T00:00:00Z", + ), + ], + True, + ), + ( # some files without upload time, others old enough + [ + Link("https://foo.com/pkg-1.0.tar.gz"), + Link( + "https://foo.com/pkg-1.0-py3-none-any.whl", + upload_time="2017-06-01T00:00:00Z", + ), + ], + False, + ), + ( # some files without upload time, others not old enough + [ + Link("https://foo.com/pkg-1.0.tar.gz"), + Link( + "https://foo.com/pkg-1.0-py3-none-any.whl", + upload_time="2017-08-15T00:00:00Z", + ), + ], + True, + ), + ( # some files old enough, some files not + [ + Link( + "https://foo.com/pkg-1.0-py3-none-any.whl", + upload_time="2017-07-01T00:00:00Z", + ), + Link( + "https://foo.com/pkg-1.0-py3-none-any.whl", + upload_time="2017-08-15T00:00:00Z", + ), + ], + True, + ), + ], +) +def test_is_version_too_recent(links: list[Link], expected: bool) -> None: + repo = MockRepository() + repo._min_release_age_cutoff = datetime(2017, 8, 10, tzinfo=timezone.utc) + assert repo._is_version_too_recent(links) == expected + + +def test_is_version_too_recent_no_cutoff_set() -> None: + """When no cutoff is set, always returns False.""" + repo = MockRepository() + assert repo._min_release_age_cutoff is None + links = [ + Link( + "https://foo.com/pkg-1.0.tar.gz", + upload_time="2099-01-01T00:00:00Z", + ) + ] + assert not repo._is_version_too_recent(links) + + @pytest.mark.parametrize("lazy_wheel", [False, True]) @pytest.mark.parametrize("supports_range_requests", [None, False, True]) def test_get_info_from_wheel( @@ -243,3 +362,48 @@ def mock_hashlib_md5_error() -> None: calculated_hash == "sha256:e216b70f013c47b82a72540d34347632c5bfe59fd54f5fe5d51f6a68b19aaf84" ) + + +@pytest.mark.parametrize( + ("level", "log_level"), [("info", logging.INFO), ("warning", logging.WARNING)] +) +@pytest.mark.parametrize("reset", [True, False]) +def test_log_age_filtered_versions( + caplog: pytest.LogCaptureFixture, level: str, log_level: int, reset: bool +) -> None: + repo = MockRepository() + repo._min_release_age = 7 + repo._age_filtered_versions |= { + canonicalize_name("B"): { + Version.parse("2.0"), + Version.parse("1.5"), + }, + canonicalize_name("A"): { + Version.parse("1.0"), + }, + } + + with caplog.at_level(log_level): + repo.log_age_filtered_versions(level=level, reset=reset) + + assert len(caplog.records) == 3 + assert "solver.min-release-age=7" in caplog.records[0].message + # packages are sorted by name + assert "a: 1.0" in caplog.records[1].message + assert "b: 1.5, 2.0" in caplog.records[2].message + assert all(r.levelno == log_level for r in caplog.records) + if reset: + assert repo._age_filtered_versions == {} + else: + assert repo._age_filtered_versions != {} + + +def test_log_age_filtered_versions_empty(caplog: pytest.LogCaptureFixture) -> None: + repo = MockRepository() + repo._min_release_age = 7 + repo._age_filtered_versions.clear() + + with caplog.at_level(logging.WARNING): + repo.log_age_filtered_versions(level="warning", reset=False) + + assert len(caplog.records) == 0 diff --git a/tests/repositories/test_legacy_repository.py b/tests/repositories/test_legacy_repository.py index 5555aedb7f7..f0cf3b91d9d 100644 --- a/tests/repositories/test_legacy_repository.py +++ b/tests/repositories/test_legacy_repository.py @@ -3,6 +3,8 @@ import base64 import re +from datetime import datetime +from datetime import timezone from typing import TYPE_CHECKING from typing import Any @@ -284,6 +286,47 @@ def test_find_packages_yanked( assert [str(p.version) for p in packages] == expected +def test_find_packages_min_release_age(legacy_repository: TestLegacyRepository) -> None: + """Versions with files uploaded within min-release-age days are filtered.""" + repo = legacy_repository + # ipython fixture upload times: + # 4.1.0rc1: 2016-01-26, 5.7.0: 2018-05-10, 7.5.0: 2019-04-25 + repo._min_release_age_cutoff = datetime(2018, 10, 6, tzinfo=timezone.utc) + packages = repo.find_packages(Factory.create_dependency("ipython", "*")) + + # HTML API does not provide upload time + if repo.json: + expected_versions = ["5.7.0"] + expected_filtered = {"ipython": {Version.parse("7.5.0")}} + else: + expected_versions = ["5.7.0", "7.5.0"] + expected_filtered = {} + + assert [str(p.version) for p in packages] == expected_versions + assert repo._age_filtered_versions == expected_filtered + + +@pytest.mark.parametrize("constraints", [(">5", "<7"), ("<7", ">5")]) +def test_find_packages_min_release_age_multiple_calls( + legacy_repository: TestLegacyRepository, constraints: tuple[str, str] +) -> None: + """See test_find_packages_min_release_age for basic setup.""" + repo = legacy_repository + repo._min_release_age_cutoff = datetime(2017, 10, 6, tzinfo=timezone.utc) + + repo.find_packages(Factory.create_dependency("ipython", constraints[0])) + repo.find_packages(Factory.create_dependency("ipython", constraints[1])) + + # HTML API does not provide upload time + expected = ( + {"ipython": {Version.parse("5.7.0"), Version.parse("7.5.0")}} + if repo.json + else {} + ) + + assert repo._age_filtered_versions == expected + + def test_get_package_information_chooses_correct_distribution( legacy_repository: LegacyRepository, ) -> None: diff --git a/tests/repositories/test_pypi_repository.py b/tests/repositories/test_pypi_repository.py index 97af88dd40c..e8245ab1e4a 100644 --- a/tests/repositories/test_pypi_repository.py +++ b/tests/repositories/test_pypi_repository.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime +from datetime import timezone from io import BytesIO from typing import TYPE_CHECKING from typing import Any @@ -84,6 +86,47 @@ def test_find_packages_yanked( assert [str(p.version) for p in packages] == expected +def test_find_packages_min_release_age(pypi_repository: PyPiRepository) -> None: + """Versions with files uploaded within min-release-age days are filtered.""" + repo = pypi_repository + # requests fixture upload times: + # 2.18.0: 2017-06-14, 2.18.1: 2017-06-14, 2.18.2: 2017-07-25, + # 2.18.3: 2017-08-02, 2.18.4: 2017-08-15, 2.19.0: 2018-06-12 + # Set "now" to 2017-08-20 with min-release-age=10 days. + # Cutoff = 2017-08-10. Versions with any file uploaded after that are filtered. + # Note that 2.19.0 is uploaded in the future ;) + repo._min_release_age_cutoff = datetime(2017, 8, 10, tzinfo=timezone.utc) + packages = repo.find_packages(Factory.create_dependency("requests", ">=2.18.0")) + + assert [str(p.version) for p in packages] == [ + "2.18.0", + "2.18.1", + "2.18.2", + "2.18.3", + ] + assert repo._age_filtered_versions == { + "requests": {Version.parse("2.18.4"), Version.parse("2.19.0")} + } + + +@pytest.mark.parametrize( + "constraints", [(">=2.18.0", "<2.19.0"), ("<2.19.0", ">=2.18.0")] +) +def test_find_packages_min_release_age_multiple_calls( + pypi_repository: PyPiRepository, constraints: tuple[str, str] +) -> None: + """See test_find_packages_min_release_age for basic setup.""" + repo = pypi_repository + repo._min_release_age_cutoff = datetime(2017, 8, 10, tzinfo=timezone.utc) + + repo.find_packages(Factory.create_dependency("requests", constraints[0])) + repo.find_packages(Factory.create_dependency("requests", constraints[1])) + + expected_filtered = {"requests": {Version.parse("2.18.4"), Version.parse("2.19.0")}} + + assert repo._age_filtered_versions == expected_filtered + + def test_package( pypi_repository: PyPiRepository, dist_hash_getter: DistributionHashGetter, diff --git a/tests/repositories/test_repository_pool.py b/tests/repositories/test_repository_pool.py index 4a08fde1787..41f5e121f75 100644 --- a/tests/repositories/test_repository_pool.py +++ b/tests/repositories/test_repository_pool.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from poetry.core.constraints.version import Version @@ -13,6 +15,10 @@ from tests.helpers import get_package +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + def test_pool() -> None: pool = RepositoryPool() @@ -282,3 +288,22 @@ def test_search_legacy_repositories_are_not_skipped( assert repo1.search("demo") == [] assert repo2.search("demo") == pool.search("demo") == [demo_package] + + +@pytest.mark.parametrize(("level", "reset"), [("warning", True), ("info", False)]) +def test_log_age_filtered_versions_includes_explicit_repositories( + mocker: MockerFixture, level: str, reset: bool +) -> None: + primary = Repository("primary") + explicit = Repository("explicit") + + primary_spy = mocker.spy(primary, "log_age_filtered_versions") + explicit_spy = mocker.spy(explicit, "log_age_filtered_versions") + + pool = RepositoryPool([primary]) + pool.add_repository(explicit, priority=Priority.EXPLICIT) + + pool.log_age_filtered_versions(level=level, reset=reset) + + primary_spy.assert_called_once_with(level=level, reset=reset) + explicit_spy.assert_called_once_with(level=level, reset=reset) From c5f3f62d4b19d3da95d992a6f42e7532a7b946d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:17:59 +0200 Subject: [PATCH 2/3] feat: add a `solver.min-release-age-exclude` config option --- docs/configuration.md | 19 ++++++++++++++ src/poetry/config/config.py | 7 ++++- src/poetry/console/commands/config.py | 4 +++ src/poetry/repositories/http_repository.py | 11 +++++++- tests/config/test_config.py | 5 +++- tests/console/commands/test_config.py | 27 ++++++++++++++++++++ tests/repositories/test_http_repository.py | 27 ++++++++++++++++++++ tests/repositories/test_legacy_repository.py | 12 +++++++-- tests/repositories/test_pypi_repository.py | 21 +++++++++++---- 9 files changed, 123 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index eb8f96c872b..143eab2e41a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -423,6 +423,25 @@ If a source does not provide upload times for a release, that release is not filtered out by this setting. {{% /note %}} +### `solver.min-release-age-exclude` + +**Type**: `string` + +**Default**: *not set* + +**Environment Variable**: `POETRY_SOLVER_MIN_RELEASE_AGE_EXCLUDE` + +*Introduced in 2.4.0* + +A comma-separated list of package names that should be excluded from the +[`solver.min-release-age`](#solvermin-release-age) filter. +Versions of these packages will always be considered by the solver, +regardless of their upload age. + +```bash +poetry config solver.min-release-age-exclude "my-package,other-package" +``` + ### `system-git-client` **Type**: `boolean` diff --git a/src/poetry/config/config.py b/src/poetry/config/config.py index e79ee0aebf2..b792d440133 100644 --- a/src/poetry/config/config.py +++ b/src/poetry/config/config.py @@ -175,6 +175,7 @@ class Config: "solver": { "lazy-wheel": True, "min-release-age": 0, + "min-release-age-exclude": None, }, "system-git-client": False, "keyring": { @@ -403,7 +404,11 @@ def _get_normalizer(name: str) -> Callable[[str], Any]: }: return int_normalizer - if name in ["installer.no-binary", "installer.only-binary"]: + if name in { + "installer.no-binary", + "installer.only-binary", + "solver.min-release-age-exclude", + }: return PackageFilterPolicy.normalize if name.startswith("installer.build-config-settings."): diff --git a/src/poetry/console/commands/config.py b/src/poetry/console/commands/config.py index 2a38fc0367a..2a3b50c44e3 100644 --- a/src/poetry/console/commands/config.py +++ b/src/poetry/console/commands/config.py @@ -103,6 +103,10 @@ def unique_config_values(self) -> dict[str, tuple[Any, Any]]: ), "solver.lazy-wheel": (boolean_validator, boolean_normalizer), "solver.min-release-age": (lambda val: int(val) >= 0, int_normalizer), + "solver.min-release-age-exclude": ( + PackageFilterPolicy.validator, + PackageFilterPolicy.normalize, + ), "keyring.enabled": (boolean_validator, boolean_normalizer), "python.installation-dir": (str, lambda val: str(Path(val))), } diff --git a/src/poetry/repositories/http_repository.py b/src/poetry/repositories/http_repository.py index 5963609e400..f5b88b241a2 100644 --- a/src/poetry/repositories/http_repository.py +++ b/src/poetry/repositories/http_repository.py @@ -18,6 +18,7 @@ import requests.adapters from packaging.metadata import parse_email +from packaging.utils import canonicalize_name from poetry.core.constraints.version import Version from poetry.core.constraints.version import VersionConstraint from poetry.core.constraints.version import parse_constraint @@ -82,10 +83,15 @@ def __init__( self._min_release_age = config.get("solver.min-release-age", 0) self._min_release_age_cutoff: datetime | None = None + self._min_release_age_exclude: set[NormalizedName] = set() if self._min_release_age: self._min_release_age_cutoff = datetime.now(tz=timezone.utc) - timedelta( days=self._min_release_age ) + self._min_release_age_exclude = { + canonicalize_name(n) + for n in (config.get("solver.min-release-age-exclude") or []) + } self._age_filtered_versions: defaultdict[NormalizedName, set[Version]] = ( defaultdict(set) ) @@ -176,7 +182,10 @@ def _find_packages( if constraint.allows(version) ] - if self._min_release_age_cutoff is not None: + if ( + self._min_release_age_cutoff is not None + and name not in self._min_release_age_exclude + ): filtered_out: set[Version] = set() accepted: list[tuple[Version, str | bool]] = [] for version, yanked in versions: diff --git a/tests/config/test_config.py b/tests/config/test_config.py index bf89919069e..375efe4217e 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -44,9 +44,12 @@ def get_options_based_on_normalizer(normalizer: Normalizer) -> Iterator[str]: ("virtualenvs.create", True), ("requests.max-retries", 0), ("solver.min-release-age", 0), + ("solver.min-release-age-exclude", None), ], ) -def test_config_get_default_value(config: Config, name: str, value: bool | int) -> None: +def test_config_get_default_value( + config: Config, name: str, value: bool | int | None +) -> None: assert config.get(name) is value diff --git a/tests/console/commands/test_config.py b/tests/console/commands/test_config.py index e3faad05007..8e81e49da5e 100644 --- a/tests/console/commands/test_config.py +++ b/tests/console/commands/test_config.py @@ -76,6 +76,7 @@ def test_list_displays_default_value_if_not_set( requests.max-retries = 0 solver.lazy-wheel = true solver.min-release-age = 0 +solver.min-release-age-exclude = null system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -112,6 +113,7 @@ def test_list_displays_set_get_setting( requests.max-retries = 0 solver.lazy-wheel = true solver.min-release-age = 0 +solver.min-release-age-exclude = null system-git-client = false virtualenvs.create = false virtualenvs.in-project = null @@ -169,6 +171,7 @@ def test_unset_setting( requests.max-retries = 0 solver.lazy-wheel = true solver.min-release-age = 0 +solver.min-release-age-exclude = null system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -204,6 +207,7 @@ def test_unset_repo_setting( requests.max-retries = 0 solver.lazy-wheel = true solver.min-release-age = 0 +solver.min-release-age-exclude = null system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -340,6 +344,7 @@ def test_list_displays_set_get_local_setting( requests.max-retries = 0 solver.lazy-wheel = true solver.min-release-age = 0 +solver.min-release-age-exclude = null system-git-client = false virtualenvs.create = false virtualenvs.in-project = null @@ -385,6 +390,7 @@ def test_list_must_not_display_sources_from_pyproject_toml( requests.max-retries = 0 solver.lazy-wheel = true solver.min-release-age = 0 +solver.min-release-age-exclude = null system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -624,6 +630,27 @@ def test_config_solver_min_release_age( assert repo._min_release_age == 3 +def test_config_solver_min_release_age_exclude( + tester: CommandTester, command_tester_factory: CommandTesterFactory +) -> None: + tester.execute("--local solver.min-release-age-exclude") + assert tester.io.fetch_output().strip() == "null" + + repo = LegacyRepository("foo", "https://foo.com") + assert repo._min_release_age_exclude == set() + + tester.io.clear_output() + tester.execute("--local solver.min-release-age 3") + tester.execute("--local solver.min-release-age-exclude 'my-pkg,Other-Pkg'") + tester.execute("--local solver.min-release-age-exclude") + output = tester.io.fetch_output().strip() + assert "my-pkg" in output + assert "other-pkg" in output + + repo = LegacyRepository("foo", "https://foo.com") + assert repo._min_release_age_exclude == {"my-pkg", "other-pkg"} + + current_config = """\ [experimental] system-git-client = true diff --git a/tests/repositories/test_http_repository.py b/tests/repositories/test_http_repository.py index fd5c299f00f..223f302b0e2 100644 --- a/tests/repositories/test_http_repository.py +++ b/tests/repositories/test_http_repository.py @@ -69,6 +69,33 @@ def test_min_release_age_and_cutoff_is_set( assert repo._min_release_age_cutoff is None +def test_min_release_age_exclude_is_set(config: Config) -> None: + config.merge( + { + "solver": { + "min-release-age": 1, + "min-release-age-exclude": ["My-Package", "other-pkg"], + } + } + ) + repo = MockRepository(config=config) + assert repo._min_release_age_exclude == {"my-package", "other-pkg"} + + +def test_min_release_age_exclude_is_not_set_without_min_release_age( + config: Config, +) -> None: + config.merge({"solver": {"min-release-age-exclude": ["My-Package", "other-pkg"]}}) + repo = MockRepository(config=config) + assert repo._min_release_age_exclude == set() + + +def test_min_release_age_exclude_default(config: Config) -> None: + config.merge({"solver": {"min-release-age": 1}}) + repo = MockRepository(config=config) + assert repo._min_release_age_exclude == set() + + @pytest.mark.parametrize( ("links", "expected"), [ diff --git a/tests/repositories/test_legacy_repository.py b/tests/repositories/test_legacy_repository.py index f0cf3b91d9d..bfb1d02af6b 100644 --- a/tests/repositories/test_legacy_repository.py +++ b/tests/repositories/test_legacy_repository.py @@ -11,6 +11,7 @@ import pytest import requests +from packaging.utils import NormalizedName from packaging.utils import canonicalize_name from poetry.core.constraints.version import Version from poetry.core.packages.dependency import Dependency @@ -286,16 +287,23 @@ def test_find_packages_yanked( assert [str(p.version) for p in packages] == expected -def test_find_packages_min_release_age(legacy_repository: TestLegacyRepository) -> None: +@pytest.mark.parametrize( + "min_release_age_exclude", [set(), {"a", "b"}, {"ipython", "other"}] +) +def test_find_packages_min_release_age( + legacy_repository: TestLegacyRepository, + min_release_age_exclude: set[NormalizedName], +) -> None: """Versions with files uploaded within min-release-age days are filtered.""" repo = legacy_repository # ipython fixture upload times: # 4.1.0rc1: 2016-01-26, 5.7.0: 2018-05-10, 7.5.0: 2019-04-25 repo._min_release_age_cutoff = datetime(2018, 10, 6, tzinfo=timezone.utc) + repo._min_release_age_exclude = min_release_age_exclude packages = repo.find_packages(Factory.create_dependency("ipython", "*")) # HTML API does not provide upload time - if repo.json: + if repo.json and "ipython" not in min_release_age_exclude: expected_versions = ["5.7.0"] expected_filtered = {"ipython": {Version.parse("7.5.0")}} else: diff --git a/tests/repositories/test_pypi_repository.py b/tests/repositories/test_pypi_repository.py index e8245ab1e4a..24aaa306904 100644 --- a/tests/repositories/test_pypi_repository.py +++ b/tests/repositories/test_pypi_repository.py @@ -8,6 +8,7 @@ import pytest +from packaging.utils import NormalizedName from packaging.utils import canonicalize_name from poetry.core.constraints.version import Version from poetry.core.packages.dependency import Dependency @@ -86,7 +87,12 @@ def test_find_packages_yanked( assert [str(p.version) for p in packages] == expected -def test_find_packages_min_release_age(pypi_repository: PyPiRepository) -> None: +@pytest.mark.parametrize( + "min_release_age_exclude", [set(), {"a", "b"}, {"requests", "other"}] +) +def test_find_packages_min_release_age( + pypi_repository: PyPiRepository, min_release_age_exclude: set[NormalizedName] +) -> None: """Versions with files uploaded within min-release-age days are filtered.""" repo = pypi_repository # requests fixture upload times: @@ -96,17 +102,22 @@ def test_find_packages_min_release_age(pypi_repository: PyPiRepository) -> None: # Cutoff = 2017-08-10. Versions with any file uploaded after that are filtered. # Note that 2.19.0 is uploaded in the future ;) repo._min_release_age_cutoff = datetime(2017, 8, 10, tzinfo=timezone.utc) + repo._min_release_age_exclude = min_release_age_exclude packages = repo.find_packages(Factory.create_dependency("requests", ">=2.18.0")) - assert [str(p.version) for p in packages] == [ + expected_versions = [ "2.18.0", "2.18.1", "2.18.2", "2.18.3", ] - assert repo._age_filtered_versions == { - "requests": {Version.parse("2.18.4"), Version.parse("2.19.0")} - } + expected_filtered = {"requests": {Version.parse("2.18.4"), Version.parse("2.19.0")}} + if "requests" in min_release_age_exclude: + expected_versions += ["2.18.4", "2.19.0"] + expected_filtered = {} + + assert [str(p.version) for p in packages] == expected_versions + assert repo._age_filtered_versions == expected_filtered @pytest.mark.parametrize( From 5cb23ae90e967e7f45aff39ae0d3909b17638dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:57:14 +0200 Subject: [PATCH 3/3] feat: add a `solver.min-release-age-exclude-source` config option --- docs/configuration.md | 20 +++++++++++++ src/poetry/config/config.py | 13 +++++++-- src/poetry/console/commands/config.py | 5 ++++ src/poetry/repositories/http_repository.py | 26 +++++++++++++---- tests/config/test_config.py | 14 +++++++++ tests/console/commands/test_config.py | 23 +++++++++++++++ tests/repositories/test_http_repository.py | 34 ++++++++++++++++++++++ 7 files changed, 126 insertions(+), 9 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 143eab2e41a..ba72daf346c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -442,6 +442,26 @@ regardless of their upload age. poetry config solver.min-release-age-exclude "my-package,other-package" ``` +### `solver.min-release-age-exclude-source` + +**Type**: `string` + +**Default**: *not set* + +**Environment Variable**: `POETRY_SOLVER_MIN_RELEASE_AGE_EXCLUDE_SOURCE` + +*Introduced in 2.4.0* + +A comma-separated list of source names or URLs that should be excluded from the +[`solver.min-release-age`](#solvermin-release-age) filter. +All packages from these sources will always be considered by the solver, +regardless of their upload age. +Sources can be referenced by the name defined in `pyproject.toml` or by URL. + +```bash +poetry config solver.min-release-age-exclude-source "private-repo,https://example.com/simple/" +``` + ### `system-git-client` **Type**: `boolean` diff --git a/src/poetry/config/config.py b/src/poetry/config/config.py index b792d440133..aba2f77ab29 100644 --- a/src/poetry/config/config.py +++ b/src/poetry/config/config.py @@ -44,6 +44,10 @@ def int_normalizer(val: str) -> int: return int(val) +def str_list_normalizer(val: str) -> list[str]: + return [vs for v in val.split(",") if (vs := v.strip())] + + def build_config_setting_validator(val: str) -> bool: try: value = build_config_setting_normalizer(val) @@ -114,9 +118,8 @@ def normalize(cls, policy: str) -> list[str]: return list( { - name.strip() if cls.is_reserved(name) else canonicalize_name(name) - for name in policy.strip().split(",") - if name + name if cls.is_reserved(name) else canonicalize_name(name) + for name in str_list_normalizer(policy) } ) @@ -176,6 +179,7 @@ class Config: "lazy-wheel": True, "min-release-age": 0, "min-release-age-exclude": None, + "min-release-age-exclude-source": None, }, "system-git-client": False, "keyring": { @@ -411,6 +415,9 @@ def _get_normalizer(name: str) -> Callable[[str], Any]: }: return PackageFilterPolicy.normalize + if name == "solver.min-release-age-exclude-source": + return str_list_normalizer + if name.startswith("installer.build-config-settings."): return build_config_setting_normalizer diff --git a/src/poetry/console/commands/config.py b/src/poetry/console/commands/config.py index 2a3b50c44e3..6bcfd9d6a8a 100644 --- a/src/poetry/console/commands/config.py +++ b/src/poetry/console/commands/config.py @@ -19,6 +19,7 @@ from poetry.config.config import build_config_setting_normalizer from poetry.config.config import build_config_setting_validator from poetry.config.config import int_normalizer +from poetry.config.config import str_list_normalizer from poetry.config.config_source import UNSET from poetry.config.config_source import ConfigSourceMigration from poetry.config.config_source import PropertyNotFoundError @@ -107,6 +108,10 @@ def unique_config_values(self) -> dict[str, tuple[Any, Any]]: PackageFilterPolicy.validator, PackageFilterPolicy.normalize, ), + "solver.min-release-age-exclude-source": ( + lambda val: bool(val.strip()), + str_list_normalizer, + ), "keyring.enabled": (boolean_validator, boolean_normalizer), "python.installation-dir": (str, lambda val: str(Path(val))), } diff --git a/src/poetry/repositories/http_repository.py b/src/poetry/repositories/http_repository.py index f5b88b241a2..7eda4561c61 100644 --- a/src/poetry/repositories/http_repository.py +++ b/src/poetry/repositories/http_repository.py @@ -85,13 +85,21 @@ def __init__( self._min_release_age_cutoff: datetime | None = None self._min_release_age_exclude: set[NormalizedName] = set() if self._min_release_age: - self._min_release_age_cutoff = datetime.now(tz=timezone.utc) - timedelta( - days=self._min_release_age + exclude_sources: set[str] = set( + config.get("solver.min-release-age-exclude-source") or [] ) - self._min_release_age_exclude = { - canonicalize_name(n) - for n in (config.get("solver.min-release-age-exclude") or []) - } + if self._is_name_excluded_from_min_release_age( + exclude_sources + ) or self._is_url_excluded_from_min_release_age(exclude_sources): + self._min_release_age = 0 + else: + self._min_release_age_cutoff = datetime.now( + tz=timezone.utc + ) - timedelta(days=self._min_release_age) + self._min_release_age_exclude = { + canonicalize_name(n) + for n in (config.get("solver.min-release-age-exclude") or []) + } self._age_filtered_versions: defaultdict[NormalizedName, set[Version]] = ( defaultdict(set) ) @@ -104,6 +112,12 @@ def __init__( # - False: The domain does not support range requests for the files we tried. self._supports_range_requests: dict[str, bool] = {} + def _is_name_excluded_from_min_release_age(self, exclude_sources: set[str]) -> bool: + return self.name.lower() in {s.lower() for s in exclude_sources} + + def _is_url_excluded_from_min_release_age(self, exclude_sources: set[str]) -> bool: + return self.url.rstrip("/") in {s.rstrip("/") for s in exclude_sources} + @property def session(self) -> Authenticator: return self._authenticator diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 375efe4217e..2e81c35a37f 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -15,6 +15,7 @@ from poetry.config.config import Config from poetry.config.config import boolean_normalizer from poetry.config.config import int_normalizer +from poetry.config.config import str_list_normalizer from poetry.utils.password_manager import PasswordManager from tests.helpers import flatten_dict from tests.helpers import isolated_environment @@ -37,6 +38,18 @@ def get_options_based_on_normalizer(normalizer: Normalizer) -> Iterator[str]: yield k +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("foo, bar , baz", ["foo", "bar", "baz"]), + (", ,", []), + ("", []), + ], +) +def test_str_list_normalizer(value: str, expected: list[str]) -> None: + assert str_list_normalizer(value) == expected + + @pytest.mark.parametrize( ("name", "value"), [ @@ -45,6 +58,7 @@ def get_options_based_on_normalizer(normalizer: Normalizer) -> Iterator[str]: ("requests.max-retries", 0), ("solver.min-release-age", 0), ("solver.min-release-age-exclude", None), + ("solver.min-release-age-exclude-source", None), ], ) def test_config_get_default_value( diff --git a/tests/console/commands/test_config.py b/tests/console/commands/test_config.py index 8e81e49da5e..1d21f1ef147 100644 --- a/tests/console/commands/test_config.py +++ b/tests/console/commands/test_config.py @@ -77,6 +77,7 @@ def test_list_displays_default_value_if_not_set( solver.lazy-wheel = true solver.min-release-age = 0 solver.min-release-age-exclude = null +solver.min-release-age-exclude-source = null system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -114,6 +115,7 @@ def test_list_displays_set_get_setting( solver.lazy-wheel = true solver.min-release-age = 0 solver.min-release-age-exclude = null +solver.min-release-age-exclude-source = null system-git-client = false virtualenvs.create = false virtualenvs.in-project = null @@ -172,6 +174,7 @@ def test_unset_setting( solver.lazy-wheel = true solver.min-release-age = 0 solver.min-release-age-exclude = null +solver.min-release-age-exclude-source = null system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -208,6 +211,7 @@ def test_unset_repo_setting( solver.lazy-wheel = true solver.min-release-age = 0 solver.min-release-age-exclude = null +solver.min-release-age-exclude-source = null system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -345,6 +349,7 @@ def test_list_displays_set_get_local_setting( solver.lazy-wheel = true solver.min-release-age = 0 solver.min-release-age-exclude = null +solver.min-release-age-exclude-source = null system-git-client = false virtualenvs.create = false virtualenvs.in-project = null @@ -391,6 +396,7 @@ def test_list_must_not_display_sources_from_pyproject_toml( solver.lazy-wheel = true solver.min-release-age = 0 solver.min-release-age-exclude = null +solver.min-release-age-exclude-source = null system-git-client = false virtualenvs.create = true virtualenvs.in-project = null @@ -651,6 +657,23 @@ def test_config_solver_min_release_age_exclude( assert repo._min_release_age_exclude == {"my-pkg", "other-pkg"} +def test_config_solver_min_release_age_exclude_source( + tester: CommandTester, command_tester_factory: CommandTesterFactory +) -> None: + tester.execute("--local solver.min-release-age-exclude-source") + assert tester.io.fetch_output().strip() == "null" + + tester.io.clear_output() + tester.execute( + "--local solver.min-release-age-exclude-source" + " 'private-repo,https://example.com/simple/'" + ) + tester.execute("--local solver.min-release-age-exclude-source") + output = tester.io.fetch_output().strip() + assert "private-repo" in output + assert "https://example.com/simple/" in output + + current_config = """\ [experimental] system-git-client = true diff --git a/tests/repositories/test_http_repository.py b/tests/repositories/test_http_repository.py index 223f302b0e2..56e84814016 100644 --- a/tests/repositories/test_http_repository.py +++ b/tests/repositories/test_http_repository.py @@ -96,6 +96,40 @@ def test_min_release_age_exclude_default(config: Config) -> None: assert repo._min_release_age_exclude == set() +@pytest.mark.parametrize( + ("exclude_sources", "expect_cutoff"), + [ + ([], True), + (["bar"], True), # name does not match + (["foo"], False), # matches repo name + (["FOO"], False), # matches repo name (repo names are case-insensitive) + (["https://foo.com"], False), # matches repo URL + (["https://foo.com/"], False), # matches repo URL with trailing slash + (["other", "https://foo.com"], False), # URL in list + ], +) +def test_min_release_age_exclude_source( + config: Config, + exclude_sources: list[str], + expect_cutoff: bool, +) -> None: + config.merge( + { + "solver": { + "min-release-age": 7, + "min-release-age-exclude-source": exclude_sources, + } + } + ) + repo = MockRepository(config=config) + if expect_cutoff: + assert repo._min_release_age == 7 + assert repo._min_release_age_cutoff is not None + else: + assert repo._min_release_age == 0 + assert repo._min_release_age_cutoff is None + + @pytest.mark.parametrize( ("links", "expected"), [