diff --git a/src/fromager/candidate.py b/src/fromager/candidate.py index 36d5955a..140fa597 100644 --- a/src/fromager/candidate.py +++ b/src/fromager/candidate.py @@ -15,18 +15,25 @@ logger = logging.getLogger(__name__) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Cooldown: """Policy for rejecting recently-published package versions. + Frozen so that cooldown policy cannot be accidentally weakened after + construction — all parameters are set once and shared read-only. + bootstrap_time is fixed at construction so all resolutions in a single run share the same cutoff. + + exempt_versions bypasses the age check for specific versions that were + already approved via a top-level exact pin. """ min_age: datetime.timedelta bootstrap_time: datetime.datetime = dataclasses.field( default_factory=lambda: datetime.datetime.now(datetime.UTC) ) + exempt_versions: frozenset[Version] = dataclasses.field(default_factory=frozenset) @dataclasses.dataclass(frozen=True, order=True, slots=True, repr=False, kw_only=True) diff --git a/src/fromager/resolver.py b/src/fromager/resolver.py index 0ac959f8..f5d6ea34 100644 --- a/src/fromager/resolver.py +++ b/src/fromager/resolver.py @@ -147,6 +147,52 @@ def _has_equality_pin(req: Requirement) -> bool: return len(specs) == 1 and specs[0].operator == "==" and "*" not in specs[0].version +def _get_toplevel_pinned_versions( + ctx: context.WorkContext, req: Requirement +) -> frozenset[Version]: + """Return versions of *req* that have a top-level exact ``==`` pin in the graph.""" + top_level_edges = ctx.dependency_graph.get_root_node().get_outgoing_edges( + req.name, RequirementType.TOP_LEVEL + ) + return frozenset( + edge.destination_node.version + for edge in top_level_edges + if _has_equality_pin(edge.req) + ) + + +def _resolve_cooldown_params( + ctx: context.WorkContext, + req: Requirement, +) -> tuple[datetime.timedelta, datetime.datetime] | None: + """Resolve min_age and bootstrap_time for a package's cooldown. + + Returns ``None`` when cooldown is disabled (no global/per-package + configuration, or a per-package override of 0). Otherwise returns + the effective ``(min_age, bootstrap_time)`` pair. + """ + min_age_override = ctx.package_build_info(req).resolver_min_release_age + + if ctx.cooldown is None and min_age_override is None: + return None + if min_age_override == 0: + return None + + if min_age_override is not None: + min_age = datetime.timedelta(days=min_age_override) + elif ctx.cooldown is not None: + min_age = ctx.cooldown.min_age + else: + return None + + bootstrap_time = ( + ctx.cooldown.bootstrap_time + if ctx.cooldown is not None + else datetime.datetime.now(datetime.UTC) + ) + return min_age, bootstrap_time + + def resolve_package_cooldown( ctx: context.WorkContext, req: Requirement, @@ -154,35 +200,36 @@ def resolve_package_cooldown( ) -> Cooldown | None: """Compute the effective cooldown for a single package. - Args: - ctx: The current work context (provides the global cooldown). - req: The package requirement being resolved. - req_type: The requirement type (top-level, install, etc.). + Returns ``None`` (cooldown disabled) when: + + * The requirement is a top-level exact ``==`` pin — the user explicitly + approved that version. + * A per-package ``min_release_age=0`` override disables cooldown. + * No global cooldown is configured and no per-package override enables one. - Returns: - The cooldown to pass to the provider, or ``None`` if disabled. + Otherwise a ``Cooldown`` is returned with: + + * *min_age* from the per-package override (if set) or the global cooldown. + * *bootstrap_time* inherited from the global cooldown (for a consistent + cutoff across the entire run) or the current time. + * *exempt_versions* populated from top-level exact-pinned entries in the + dependency graph, so transitive resolutions of the same package honour + the user's explicit pin. """ if req_type == RequirementType.TOP_LEVEL and _has_equality_pin(req): if ctx.cooldown is not None: logger.info("cooldown bypassed as the top-level requirement uses == pin") return None - per_package_days = ctx.package_build_info(req).resolver_min_release_age - global_cooldown = ctx.cooldown - if per_package_days is None: - return global_cooldown - if per_package_days == 0: + params = _resolve_cooldown_params(ctx, req) + if params is None: return None - # Per-package positive override: inherit bootstrap_time from global so all - # resolutions in a single run share the same fixed cutoff point. - bootstrap_time = ( - global_cooldown.bootstrap_time - if global_cooldown is not None - else datetime.datetime.now(datetime.UTC) - ) + + min_age, bootstrap_time = params return Cooldown( - min_age=datetime.timedelta(days=per_package_days), + min_age=min_age, bootstrap_time=bootstrap_time, + exempt_versions=_get_toplevel_pinned_versions(ctx, req), ) @@ -685,11 +732,12 @@ def is_satisfied_by(self, requirement: Requirement, candidate: Candidate) -> boo def is_blocked_by_cooldown(self, candidate: Candidate) -> bool: """Return True if the candidate is rejected by the release-age cooldown.""" - # a cooldown is not specified... if self.cooldown is None: return False - # the target candidate doesn't provide a valid upload timestamp + if candidate.version in self.cooldown.exempt_versions: + return False + if candidate.upload_time is None: if not self.supports_upload_time: # this provider does not yet support timestamp retrieval (e.g. GitHub). diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py index 098081a3..e2a9f173 100644 --- a/tests/test_cooldown.py +++ b/tests/test_cooldown.py @@ -378,10 +378,13 @@ def _make_ctx( def test_resolve_package_cooldown_inherits_global(tmp_path: pathlib.Path) -> None: - """No per-package override returns the global cooldown unchanged.""" + """No per-package override returns a cooldown equal to the global one.""" ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) result = resolver.resolve_package_cooldown(ctx, Requirement("test-pkg")) - assert result is _COOLDOWN + assert result is not None + assert result.min_age == _COOLDOWN.min_age + assert result.bootstrap_time == _COOLDOWN.bootstrap_time + assert result.exempt_versions == frozenset() def test_resolve_package_cooldown_disabled_per_package(tmp_path: pathlib.Path) -> None: @@ -896,7 +899,10 @@ def test_resolve_package_cooldown_enforced_transitive_equality_pin( result = resolver.resolve_package_cooldown( ctx, Requirement("test-pkg==1.3.2"), req_type=RequirementType.INSTALL ) - assert result is _COOLDOWN + assert result is not None + assert result.min_age == _COOLDOWN.min_age + assert result.bootstrap_time == _COOLDOWN.bootstrap_time + assert result.exempt_versions == frozenset() def test_resolve_package_cooldown_enforced_toplevel_no_pin( @@ -907,7 +913,10 @@ def test_resolve_package_cooldown_enforced_toplevel_no_pin( result = resolver.resolve_package_cooldown( ctx, Requirement("test-pkg>=1.0"), req_type=RequirementType.TOP_LEVEL ) - assert result is _COOLDOWN + assert result is not None + assert result.min_age == _COOLDOWN.min_age + assert result.bootstrap_time == _COOLDOWN.bootstrap_time + assert result.exempt_versions == frozenset() def test_resolve_package_cooldown_none_req_type_not_exempt( @@ -918,7 +927,10 @@ def test_resolve_package_cooldown_none_req_type_not_exempt( result = resolver.resolve_package_cooldown( ctx, Requirement("test-pkg==1.3.2"), req_type=None ) - assert result is _COOLDOWN + assert result is not None + assert result.min_age == _COOLDOWN.min_age + assert result.bootstrap_time == _COOLDOWN.bootstrap_time + assert result.exempt_versions == frozenset() def test_resolve_package_cooldown_toplevel_wildcard_equality_not_exempt( @@ -929,7 +941,10 @@ def test_resolve_package_cooldown_toplevel_wildcard_equality_not_exempt( result = resolver.resolve_package_cooldown( ctx, Requirement("test-pkg==1.*"), req_type=RequirementType.TOP_LEVEL ) - assert result is _COOLDOWN + assert result is not None + assert result.min_age == _COOLDOWN.min_age + assert result.bootstrap_time == _COOLDOWN.bootstrap_time + assert result.exempt_versions == frozenset() def test_resolve_package_cooldown_toplevel_compound_specifier_not_exempt( @@ -940,4 +955,371 @@ def test_resolve_package_cooldown_toplevel_compound_specifier_not_exempt( result = resolver.resolve_package_cooldown( ctx, Requirement("test-pkg==1.0,>0.9"), req_type=RequirementType.TOP_LEVEL ) - assert result is _COOLDOWN + assert result is not None + assert result.min_age == _COOLDOWN.min_age + assert result.bootstrap_time == _COOLDOWN.bootstrap_time + assert result.exempt_versions == frozenset() + + +def test_get_toplevel_pinned_versions_empty(tmp_path: pathlib.Path) -> None: + """No top-level pin in the graph returns an empty frozenset.""" + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + result = resolver._get_toplevel_pinned_versions(ctx, Requirement("test-pkg")) + assert result == frozenset() + + +def test_get_toplevel_pinned_versions_ignores_wildcard_pin( + tmp_path: pathlib.Path, +) -> None: + """A top-level wildcard pin (==1.*) is not an exact pin and must be excluded.""" + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + ctx.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("test-pkg==1.*"), + req_version=Version("1.3.2"), + download_url="https://files.pythonhosted.org/packages/test_pkg-1.3.2-py3-none-any.whl", + pre_built=False, + ) + result = resolver._get_toplevel_pinned_versions(ctx, Requirement("test-pkg")) + assert result == frozenset() + + +def test_non_exact_toplevel_entry_does_not_exempt(tmp_path: pathlib.Path) -> None: + """A top-level >= entry is not an exact pin — no version is exempted.""" + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + ctx.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("test-pkg>=1.0"), + req_version=Version("1.3.2"), + download_url="https://files.pythonhosted.org/packages/test_pkg-1.3.2-py3-none-any.whl", + pre_built=False, + ) + result = resolver.resolve_package_cooldown( + ctx, Requirement("test-pkg>=1.0"), req_type=RequirementType.INSTALL + ) + assert result is not None + assert result.exempt_versions == frozenset() + + +def test_wildcard_toplevel_pin_does_not_exempt(tmp_path: pathlib.Path) -> None: + """A top-level ==1.* entry is not a true exact pin — no version is exempted.""" + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + ctx.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("test-pkg==1.*"), + req_version=Version("1.3.2"), + download_url="https://files.pythonhosted.org/packages/test_pkg-1.3.2-py3-none-any.whl", + pre_built=False, + ) + result = resolver.resolve_package_cooldown( + ctx, Requirement("test-pkg>=1.0"), req_type=RequirementType.INSTALL + ) + assert result is not None + assert result.exempt_versions == frozenset() + + +def test_name_normalization_across_requirement_and_graph( + tmp_path: pathlib.Path, +) -> None: + """Exemption works even when requirement and graph use different name forms. + + The transitive requirement uses ``Test_Pkg`` while the graph entry uses + the canonical ``test-pkg``. Name normalization in ``get_outgoing_edges`` + must handle this transparently. + """ + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + ctx.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("test-pkg==1.3.2"), + req_version=Version("1.3.2"), + download_url="https://files.pythonhosted.org/packages/test_pkg-1.3.2-py3-none-any.whl", + pre_built=False, + ) + result = resolver.resolve_package_cooldown( + ctx, Requirement("Test_Pkg>=1.0"), req_type=RequirementType.INSTALL + ) + assert result is not None + assert result.exempt_versions == frozenset({Version("1.3.2")}) + + +def test_toplevel_pin_takes_precedence_over_per_package_override( + tmp_path: pathlib.Path, +) -> None: + """Top-level == pin bypasses cooldown even with a per-package min_release_age. + + The per-package setting (30 days) is a weaker signal than an explicit + top-level pin. The pin should win and disable cooldown entirely. + """ + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN, min_release_age=30) + result = resolver.resolve_package_cooldown( + ctx, Requirement("test-pkg==1.3.2"), req_type=RequirementType.TOP_LEVEL + ) + assert result is None + + +def test_transitive_dep_cooldown_blocks_non_pinned_version( + tmp_path: pathlib.Path, +) -> None: + """Cooldown must block non-pinned versions even when a top-level pin exists. + + Pin test-pkg==1.3.2 top-level. A transitive dep asks for test-pkg>=1.0. + Version 2.0.0 (2 days old) is within the 7-day cooldown window and is NOT + the pinned version, so cooldown must block it. The resolver should select + 1.3.2 (the pinned version, 11 days old, outside cooldown). + + This is the scenario where PR #1154's blanket bypass was too broad — it + disabled cooldown for all versions of the package instead of only the + pinned version. + """ + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + + ctx.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("test-pkg==1.3.2"), + req_version=Version("1.3.2"), + download_url="https://files.pythonhosted.org/packages/test_pkg-1.3.2-py3-none-any.whl", + pre_built=False, + ) + + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/test-pkg/", + json=_cooldown_json_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + + _, version = resolver.resolve( + ctx=ctx, + req=Requirement("test-pkg>=1.0"), + sdist_server_url="https://pypi.org/simple/", + include_sdists=True, + include_wheels=True, + req_type=RequirementType.INSTALL, + ) + assert str(version) == "1.3.2" + + +def test_transitive_dep_cooldown_not_bypassed_for_all_versions( + tmp_path: pathlib.Path, +) -> None: + """Cooldown for transitive deps must not be fully bypassed by a top-level pin. + + When a top-level pin exists, `resolve_package_cooldown` should still return + a cooldown (not None) for transitive deps so that non-pinned versions + remain subject to cooldown filtering. + """ + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + + ctx.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("test-pkg==2.0.0"), + req_version=Version("2.0.0"), + download_url="https://files.pythonhosted.org/packages/test_pkg-2.0.0-py3-none-any.whl", + pre_built=False, + ) + + result = resolver.resolve_package_cooldown( + ctx, Requirement("test-pkg>=1.0"), req_type=RequirementType.INSTALL + ) + assert result is not None + assert result.min_age == _COOLDOWN.min_age + assert result.bootstrap_time == _COOLDOWN.bootstrap_time + assert result.exempt_versions == frozenset({Version("2.0.0")}) + + +def test_transitive_dep_cooldown_unpinned_transitive_spec( + tmp_path: pathlib.Path, +) -> None: + """Transitive dep with no version spec still respects cooldown. + + Top-level pins test-pkg==1.3.2. A transitive dep asks for bare + ``test-pkg`` (no specifier). Version 2.0.0 (within cooldown) must be + blocked; 1.3.2 (outside cooldown) should be selected. + """ + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + + ctx.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("test-pkg==1.3.2"), + req_version=Version("1.3.2"), + download_url="https://files.pythonhosted.org/packages/test_pkg-1.3.2-py3-none-any.whl", + pre_built=False, + ) + + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/test-pkg/", + json=_cooldown_json_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + + _, version = resolver.resolve( + ctx=ctx, + req=Requirement("test-pkg"), + sdist_server_url="https://pypi.org/simple/", + include_sdists=True, + include_wheels=True, + req_type=RequirementType.INSTALL, + ) + assert str(version) == "1.3.2" + + +def test_transitive_dep_cooldown_lower_bound_matches_pin( + tmp_path: pathlib.Path, +) -> None: + """Transitive dep whose lower bound matches the pin still respects cooldown. + + Top-level pins test-pkg==1.3.2. A transitive dep asks for + test-pkg>=1.3.2. Version 2.0.0 (within cooldown) must be blocked; + 1.3.2 (outside cooldown, matches the pin) should be selected. + """ + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + + ctx.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("test-pkg==1.3.2"), + req_version=Version("1.3.2"), + download_url="https://files.pythonhosted.org/packages/test_pkg-1.3.2-py3-none-any.whl", + pre_built=False, + ) + + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/test-pkg/", + json=_cooldown_json_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + + _, version = resolver.resolve( + ctx=ctx, + req=Requirement("test-pkg>=1.3.2"), + sdist_server_url="https://pypi.org/simple/", + include_sdists=True, + include_wheels=True, + req_type=RequirementType.INSTALL, + ) + assert str(version) == "1.3.2" + + +def test_transitive_dep_cooldown_conflict_with_pin( + tmp_path: pathlib.Path, +) -> None: + """Transitive dep that conflicts with the pin fails regardless of cooldown. + + Top-level pins test-pkg==1.3.2. A transitive dep asks for + test-pkg>=2.0. No version satisfies both — 2.0.0 is blocked by cooldown + and nothing else matches >=2.0. Resolution should fail. + """ + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + + ctx.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("test-pkg==1.3.2"), + req_version=Version("1.3.2"), + download_url="https://files.pythonhosted.org/packages/test_pkg-1.3.2-py3-none-any.whl", + pre_built=False, + ) + + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/test-pkg/", + json=_cooldown_json_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + + with pytest.raises(resolvelib.resolvers.ResolverException): + resolver.resolve( + ctx=ctx, + req=Requirement("test-pkg>=2.0"), + sdist_server_url="https://pypi.org/simple/", + include_sdists=True, + include_wheels=True, + req_type=RequirementType.INSTALL, + ) + + +def test_transitive_dep_exempts_pinned_version_from_cooldown( + tmp_path: pathlib.Path, +) -> None: + """Transitive dep should exempt only the pinned version from cooldown. + + If a requirements file pins test-pkg==2.0.0 (top-level) and another + top-level package depends on test-pkg>=1.0 (transitive), cooldown should + remain active but exempt version 2.0.0 — the user already explicitly + approved that version via the pin. + """ + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + + ctx.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("test-pkg==2.0.0"), + req_version=Version("2.0.0"), + download_url="https://files.pythonhosted.org/packages/test_pkg-2.0.0-py3-none-any.whl", + pre_built=False, + ) + + result = resolver.resolve_package_cooldown( + ctx, Requirement("test-pkg>=1.0"), req_type=RequirementType.INSTALL + ) + assert result is not None + assert result.exempt_versions == frozenset({Version("2.0.0")}) + + +def test_transitive_dep_resolves_to_toplevel_pinned_version( + tmp_path: pathlib.Path, +) -> None: + """End-to-end: transitive dep selects the top-level pinned version, not an older one. + + With cooldown active, test-pkg 2.0.0 (2 days old) is within the cooldown + window. A top-level pin test-pkg==2.0.0 exempts 2.0.0 from cooldown. + When the same package appears as a transitive dependency (test-pkg>=1.0), + it should resolve to 2.0.0 — not fall back to 1.3.2. + """ + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + + ctx.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("test-pkg==2.0.0"), + req_version=Version("2.0.0"), + download_url="https://files.pythonhosted.org/packages/test_pkg-2.0.0-py3-none-any.whl", + pre_built=False, + ) + + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/test-pkg/", + json=_cooldown_json_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + + _, version = resolver.resolve( + ctx=ctx, + req=Requirement("test-pkg>=1.0"), + sdist_server_url="https://pypi.org/simple/", + include_sdists=True, + include_wheels=True, + req_type=RequirementType.INSTALL, + ) + assert str(version) == "2.0.0"