|
5 | 5 |
|
6 | 6 | from contextlib import contextmanager |
7 | 7 | from contextlib import suppress |
| 8 | +from datetime import datetime |
| 9 | +from datetime import timedelta |
| 10 | +from datetime import timezone |
8 | 11 | from pathlib import Path |
9 | 12 | from tempfile import TemporaryDirectory |
10 | 13 | from typing import TYPE_CHECKING |
|
14 | 17 | import requests.adapters |
15 | 18 |
|
16 | 19 | from packaging.metadata import parse_email |
| 20 | +from poetry.core.constraints.version import Version |
| 21 | +from poetry.core.constraints.version import VersionConstraint |
17 | 22 | from poetry.core.constraints.version import parse_constraint |
18 | 23 | from poetry.core.packages.dependency import Dependency |
19 | 24 | from poetry.core.version.markers import parse_marker |
|
36 | 41 |
|
37 | 42 |
|
38 | 43 | if TYPE_CHECKING: |
| 44 | + from collections.abc import Iterable |
39 | 45 | from collections.abc import Iterator |
40 | 46 |
|
41 | 47 | from packaging.utils import NormalizedName |
| 48 | + from poetry.core.packages.package import Package |
42 | 49 | from poetry.core.packages.package import PackageFile |
43 | 50 | from poetry.core.packages.utils.link import Link |
44 | 51 |
|
@@ -71,6 +78,14 @@ def __init__( |
71 | 78 |
|
72 | 79 | self._lazy_wheel = config.get("solver.lazy-wheel", True) |
73 | 80 | self._max_retries = config.get("requests.max-retries", 0) |
| 81 | + |
| 82 | + self._min_release_age = config.get("solver.min-release-age", 0) |
| 83 | + self._min_release_age_cutoff: datetime | None = None |
| 84 | + if self._min_release_age: |
| 85 | + self._min_release_age_cutoff = datetime.now(tz=timezone.utc) - timedelta( |
| 86 | + days=self._min_release_age |
| 87 | + ) |
| 88 | + self._age_filtered_versions: dict[NormalizedName, set[Version]] = {} |
74 | 89 | # We are tracking if a domain supports range requests or not to avoid |
75 | 90 | # unnecessary requests. |
76 | 91 | # ATTENTION: A domain might support range requests only for some files, so the |
@@ -119,6 +134,64 @@ def _cached_or_downloaded_file( |
119 | 134 | ) |
120 | 135 | yield filepath |
121 | 136 |
|
| 137 | + def _package( |
| 138 | + self, name: NormalizedName, version: Version, yanked: str | bool |
| 139 | + ) -> Package: |
| 140 | + raise NotImplementedError |
| 141 | + |
| 142 | + def _is_version_too_recent(self, links: Iterable[Link]) -> bool: |
| 143 | + """Return True if any file of the version was uploaded after the cutoff. |
| 144 | +
|
| 145 | + If no upload time information is available for any file, |
| 146 | + the version is considered old enough (return False). |
| 147 | + """ |
| 148 | + if not self._min_release_age_cutoff: |
| 149 | + return False |
| 150 | + for link in links: |
| 151 | + upload_time = link.upload_time |
| 152 | + if upload_time is None: |
| 153 | + continue |
| 154 | + if upload_time > self._min_release_age_cutoff: |
| 155 | + return True |
| 156 | + return False |
| 157 | + |
| 158 | + def _find_packages( |
| 159 | + self, name: NormalizedName, constraint: VersionConstraint |
| 160 | + ) -> list[Package]: |
| 161 | + """ |
| 162 | + Find packages on the remote server. |
| 163 | + """ |
| 164 | + try: |
| 165 | + page = self.get_page(name) |
| 166 | + except PackageNotFoundError: |
| 167 | + self._log(f"No packages found for {name}", level="debug") |
| 168 | + return [] |
| 169 | + |
| 170 | + versions = [ |
| 171 | + (version, page.yanked(name, version)) |
| 172 | + for version in page.versions(name) |
| 173 | + if constraint.allows(version) |
| 174 | + ] |
| 175 | + |
| 176 | + if self._min_release_age_cutoff is not None: |
| 177 | + filtered_out: set[Version] = set() |
| 178 | + accepted: list[tuple[Version, str | bool]] = [] |
| 179 | + for version, yanked in versions: |
| 180 | + if self._is_version_too_recent(page.links_for_version(name, version)): |
| 181 | + filtered_out.add(version) |
| 182 | + else: |
| 183 | + accepted.append((version, yanked)) |
| 184 | + if filtered_out: |
| 185 | + self._age_filtered_versions[name] = filtered_out |
| 186 | + self._log( |
| 187 | + f"Ignoring {name} version(s) due to " |
| 188 | + f"solver.min-release-age={self._min_release_age}: {versions}", |
| 189 | + level="debug", |
| 190 | + ) |
| 191 | + versions = accepted |
| 192 | + |
| 193 | + return [self._package(name, version, yanked) for version, yanked in versions] |
| 194 | + |
122 | 195 | def _get_info_from_wheel(self, link: Link) -> PackageInfo: |
123 | 196 | from poetry.inspection.info import PackageInfo |
124 | 197 |
|
@@ -477,3 +550,17 @@ def _get_page(self, name: NormalizedName) -> LinkSource: |
477 | 550 | if self._is_json_response(response): |
478 | 551 | return SimpleJsonPage(response.url, response.json()) |
479 | 552 | return HTMLPage(response.url, response.text) |
| 553 | + |
| 554 | + def log_age_filtered_versions(self, *, level: str) -> None: |
| 555 | + if not self._age_filtered_versions: |
| 556 | + return |
| 557 | + self._log( |
| 558 | + "The following packages versions were ignored" |
| 559 | + f" due to solver.min-release-age={self._min_release_age}", |
| 560 | + level=level, |
| 561 | + ) |
| 562 | + for name in sorted(self._age_filtered_versions): |
| 563 | + versions = ", ".join( |
| 564 | + str(v) for v in sorted(self._age_filtered_versions[name]) |
| 565 | + ) |
| 566 | + self._log(f"{name}: {versions}", level=level) |
0 commit comments