From 6f734b6480574acb5bb0c6a4274522d135dce212 Mon Sep 17 00:00:00 2001 From: Christian Heimes Date: Tue, 7 Jul 2026 18:10:48 +0200 Subject: [PATCH] feat(resolver): add `LegacyResolver` for legacy resolution and download Add `LegacyResolver` class that preserves the existing resolution and download behavior as a typed resolver. When the `source` field is eventually enabled on `PackageSettings`, packages without an explicit typed resolver can use `provider: legacy` to keep the old behavior. `resolver_provider()` replicates `sources.get_source_provider()`: reads `resolver_dist` settings from per-package build info, invokes the `get_resolver_provider` override hook, and applies the per-package cooldown. `download()` checks `pbi.pre_built`: when true, delegates to the pre-built wheel downloader; otherwise invokes the `download_source` override hook via `overrides.find_and_invoke()`. Co-Authored-By: Claude Signed-off-by: Christian Heimes --- docs/proposals/new-resolver-config.md | 10 +++ docs/reference/config-reference.rst | 3 + src/fromager/packagesettings/__init__.py | 2 + src/fromager/packagesettings/_resolver.py | 102 +++++++++++++++++++++- tests/test_packagesettings_resolver.py | 65 ++++++++++++++ 5 files changed, 180 insertions(+), 2 deletions(-) diff --git a/docs/proposals/new-resolver-config.md b/docs/proposals/new-resolver-config.md index 070cab01..fb1631dc 100644 --- a/docs/proposals/new-resolver-config.md +++ b/docs/proposals/new-resolver-config.md @@ -151,6 +151,16 @@ variants: - The `hook-prebuilt` profile is similar to `hook-sdist` but the downloaded artifact must be a pre-built wheel. +- The `legacy` profile preserves the old resolution and download behavior + as a typed resolver. It reads `resolver_dist` settings from the per-package + build info, invokes the `resolver_provider` and `download_source` + [hooks](../reference/hooks.rst) (falling back to their defaults), and + applies the per-package release-age cooldown. When `pre_built` is true for + the variant, the download returns a pre-built wheel; otherwise it returns + a tarball via the `download_source` hook. This profile is useful for + gradual migration: packages without an explicit typed resolver can use + `provider: legacy` to keep the existing behavior. + - The `not-available` profile raises an error. It can be used to block a package and only enable it for a single variant. diff --git a/docs/reference/config-reference.rst b/docs/reference/config-reference.rst index d81a37fb..94730453 100644 --- a/docs/reference/config-reference.rst +++ b/docs/reference/config-reference.rst @@ -61,6 +61,9 @@ Source Resolver :inherited-members: AbstractGitSourceResolver, CooldownMixin :members: download_kinds, supports_override_hooks, resolves_prebuilt_wheel +.. autopydantic_model:: LegacyResolver + :members: download_kinds, supports_override_hooks + .. autopydantic_model:: NotAvailableResolver :members: download_kinds, supports_override_hooks, resolves_prebuilt_wheel diff --git a/src/fromager/packagesettings/__init__.py b/src/fromager/packagesettings/__init__.py index 6289d742..2ad9d278 100644 --- a/src/fromager/packagesettings/__init__.py +++ b/src/fromager/packagesettings/__init__.py @@ -22,6 +22,7 @@ GitLabTagDownloadResolver, HookPrebuiltResolver, HookSDistResolver, + LegacyResolver, NotAvailableResolver, PyPIDownloadResolver, PyPIGitResolver, @@ -65,6 +66,7 @@ "GlobalChangelog", "HookPrebuiltResolver", "HookSDistResolver", + "LegacyResolver", "NotAvailableResolver", "Package", "PackageBuildInfo", diff --git a/src/fromager/packagesettings/_resolver.py b/src/fromager/packagesettings/_resolver.py index 213cf073..92026abe 100644 --- a/src/fromager/packagesettings/_resolver.py +++ b/src/fromager/packagesettings/_resolver.py @@ -10,7 +10,7 @@ import pydantic -from .. import downloads, resolver +from .. import downloads, overrides, resolver from ..candidate import Cooldown from ._typedefs import MODEL_CONFIG @@ -796,6 +796,103 @@ class HookPrebuiltResolver(AbstractHookResolver): resolves_prebuilt_wheel: typing.ClassVar[bool] = True +class LegacyResolver(AbstractResolver): + """Preserve the legacy resolution and download behavior as a typed resolver. + + The ``legacy`` provider replicates the flow of + ``sources.get_source_provider()`` and ``sources.download_source()``: + it reads ``resolver_dist`` settings from the per-package build info, + invokes the ``get_resolver_provider`` and ``download_source`` override + hooks (falling back to their defaults), and applies the per-package + release-age cooldown. + + When ``pbi.pre_built`` is true the download returns a pre-built wheel; + otherwise it returns a tarball via the ``download_source`` hook. + + Example:: + + provider: legacy + """ + + provider: typing.Literal["legacy"] + + supports_override_hooks: typing.ClassVar[bool] = True + download_kinds: typing.ClassVar[DownloadKindSet] = frozenset( + {DownloadKind.tarball, DownloadKind.prebuilt_wheel} + ) + + def resolver_provider( + self, + ctx: context.WorkContext, + req: Requirement, + req_type: requirements_file.RequirementType, + ) -> resolver.BaseProvider: + """Return a resolver provider using the legacy resolution flow. + + Replicates ``sources.get_source_provider()``. + """ + pbi = ctx.package_build_info(req) + sdist_server_url = pbi.resolver_sdist_server_url(resolver.PYPI_SERVER_URL) + + provider = typing.cast( + resolver.BaseProvider, + overrides.find_and_invoke( + req.name, + "get_resolver_provider", + resolver.default_resolver_provider, + ctx=ctx, + req=req, + include_sdists=pbi.resolver_include_sdists, + include_wheels=pbi.resolver_include_wheels, + sdist_server_url=sdist_server_url, + req_type=req_type, + ignore_platform=pbi.resolver_ignore_platform, + ), + ) + provider.cooldown = resolver.resolve_package_cooldown( + ctx, req, req_type=req_type + ) + return provider + + def download( + self, + ctx: context.WorkContext, + req: Requirement, + candidate: Candidate, + ) -> tuple[pathlib.Path, DownloadKind]: + """Download using the legacy flow. + + Delegates to the pre-built wheel downloader when the package + variant is configured as pre-built (see + ``bootstrapper._bg_prepare_prebuilt()``), otherwise invokes the + ``download_source`` override hook (see + ``sources.download_source()``). + """ + pbi = ctx.package_build_info(req) + if pbi.pre_built: + return self._download(ctx, req, candidate, DownloadKind.prebuilt_wheel) + + # Local import required to avoid circular import: + # _resolver -> sources -> packagesettings -> _resolver + from fromager.sources import default_download_source + + source_path = overrides.find_and_invoke( + req.name, + "download_source", + default_download_source, + ctx=ctx, + req=req, + version=candidate.version, + download_url=candidate.url, + sdists_downloads_dir=ctx.sdists_downloads, + ) + if not isinstance(source_path, pathlib.Path): + raise ValueError( + f"expected a Path back to downloaded source, got {source_path}" + ) + return source_path, DownloadKind.tarball + + SourceResolver = typing.Annotated[ PyPISDistResolver | PyPIPrebuiltResolver @@ -807,6 +904,7 @@ class HookPrebuiltResolver(AbstractHookResolver): | GitLabTagDownloadResolver | NotAvailableResolver | HookSDistResolver - | HookPrebuiltResolver, + | HookPrebuiltResolver + | LegacyResolver, pydantic.Field(..., discriminator="provider"), ] diff --git a/tests/test_packagesettings_resolver.py b/tests/test_packagesettings_resolver.py index 8132b4a4..3885a1d2 100644 --- a/tests/test_packagesettings_resolver.py +++ b/tests/test_packagesettings_resolver.py @@ -24,6 +24,7 @@ GitLabTagDownloadResolver, HookPrebuiltResolver, HookSDistResolver, + LegacyResolver, NotAvailableResolver, PyPIDownloadResolver, PyPIGitResolver, @@ -715,6 +716,70 @@ def test_download(self, tmp_context: WorkContext) -> None: r.download(tmp_context, _REQ, _CANDIDATE_WHEEL) +class TestLegacyResolver: + YAML = """\ + source: + provider: legacy + """ + + def test_parse(self) -> None: + r = _parse(self.YAML) + assert isinstance(r, LegacyResolver) + assert r.provider == "legacy" + assert r.supports_override_hooks is True + assert r.resolves_prebuilt_wheel is False + assert r.download_kinds == frozenset( + {DownloadKind.tarball, DownloadKind.prebuilt_wheel} + ) + + def test_resolver_provider(self, tmp_context: WorkContext) -> None: + r = _parse(self.YAML) + p = r.resolver_provider(tmp_context, _REQ, _REQ_TYPE) + assert isinstance(p, resolver.PyPIProvider) + assert p.include_sdists is True + assert p.include_wheels is False + assert p.sdist_server_url == "https://pypi.org/simple" + assert p.ignore_platform is False + + @mock.patch("fromager.overrides.find_and_invoke") + def test_download_source( + self, mock_invoke: mock.MagicMock, tmp_context: WorkContext + ) -> None: + expected = tmp_context.sdists_downloads / "test-pkg-1.2.3.tar.gz" + mock_invoke.return_value = expected + r = _parse(self.YAML) + path, kind = r.download(tmp_context, _REQ, _CANDIDATE_SDIST) + assert path == expected + assert kind is DownloadKind.tarball + mock_invoke.assert_called_once() + call_args = mock_invoke.call_args + assert call_args[0][0] == _REQ.name + assert call_args[0][1] == "download_source" + assert call_args[1]["version"] == _VERSION + assert call_args[1]["download_url"] == _CANDIDATE_SDIST.url + assert call_args[1]["sdists_downloads_dir"] == tmp_context.sdists_downloads + + @mock.patch("fromager.downloads.download_wheel") + def test_download_prebuilt( + self, mock_dl: mock.MagicMock, tmp_context: WorkContext + ) -> None: + expected = tmp_context.wheels_prebuilt / "test_pkg-1.2.3-py3-none-any.whl" + mock_dl.return_value = expected + # Configure pbi.pre_built to return True + pbi = tmp_context.package_build_info(_REQ) + with mock.patch.object( + type(pbi), "pre_built", new_callable=lambda: property(lambda self: True) + ): + r = _parse(self.YAML) + path, kind = r.download(tmp_context, _REQ, _CANDIDATE_WHEEL) + assert path == expected + assert kind is DownloadKind.prebuilt_wheel + mock_dl.assert_called_once_with( + destination_dir=tmp_context.wheels_prebuilt, + url=_CANDIDATE_WHEEL.url, + ) + + # -- Discriminated union validation -------------------------------------------