diff --git a/docs/reference/config-reference.rst b/docs/reference/config-reference.rst index 6c3dfb38..3dd5c196 100644 --- a/docs/reference/config-reference.rst +++ b/docs/reference/config-reference.rst @@ -41,6 +41,10 @@ Source Resolver .. autopydantic_model:: PyPIGitResolver :inherited-members: AbstractPyPIResolver, CooldownMixin +.. autopydantic_model:: VersionMapGitResolver + +.. versionadded:: 0.79.0 + .. autopydantic_model:: GitHubTagDownloadResolver :inherited-members: AbstractGitSourceResolver, CooldownMixin diff --git a/src/fromager/packagesettings/__init__.py b/src/fromager/packagesettings/__init__.py index 2e023925..38afbbf0 100644 --- a/src/fromager/packagesettings/__init__.py +++ b/src/fromager/packagesettings/__init__.py @@ -25,6 +25,7 @@ PyPIGitResolver, PyPIPrebuiltResolver, PyPISDistResolver, + VersionMapGitResolver, pep440_tag_matcher, ) from ._settings import Settings, SettingsFile @@ -82,6 +83,7 @@ "Variant", "VariantChangelog", "VariantInfo", + "VersionMapGitResolver", "default_update_extra_environ", "get_extra_environ", "pep440_tag_matcher", diff --git a/src/fromager/packagesettings/_resolver.py b/src/fromager/packagesettings/_resolver.py index a7ae076c..46fcbadd 100644 --- a/src/fromager/packagesettings/_resolver.py +++ b/src/fromager/packagesettings/_resolver.py @@ -482,6 +482,63 @@ def resolver_provider( ) +class VersionMapGitResolver(AbstractResolver): + """Resolve version from a version map, build sdist from git clone. + + The ``versionmap-git`` provider maps known version numbers to known git + refs (commit SHAs or ref paths such as ``refs/tags/1.1``). It clones a + git repo at the configured ref and builds an sdist with PEP 517. + + .. versionadded:: 0.79.0 + + Example:: + + provider: versionmap-git + clone_url: https://git.test/project/repo.git + build_sdist: pep517 + versionmap: + '1.0': abad1dea + '1.1': refs/tags/1.1 + """ + + provider: typing.Literal["versionmap-git"] + + clone_url: pydantic.AnyUrl + """Git clone URL (``https`` or ``ssh`` scheme).""" + + build_sdist: BuildSDist = BuildSDist.pep517 + """Source distribution build method.""" + + versionmap: dict[str, str] + """Mapping of version strings to git refs.""" + + @pydantic.field_validator("clone_url", mode="after") + @classmethod + def validate_clone_url(cls, value: pydantic.AnyUrl) -> pydantic.AnyUrl: + if value.scheme not in {"https", "ssh"}: + raise ValueError(f"invalid scheme in url {value}") + if not value.path: + raise ValueError(f"url {value} has an empty path") + return value + + def resolver_provider( + self, ctx: context.WorkContext, req_type: requirements_file.RequirementType + ) -> resolver.VersionMapProvider: + from ..versionmap import VersionMap + + clone_url = str(self.clone_url) + url_map = { + ver: f"git+{clone_url}@{ref}" for ver, ref in self.versionmap.items() + } + version_map = VersionMap(url_map) # type: ignore[arg-type] + return resolver.VersionMapProvider( + version_map=version_map, + package_name=None, + constraints=ctx.constraints, + req_type=req_type, + ) + + class NotAvailableResolver(AbstractResolver): """Prevent resolve and download""" @@ -510,6 +567,7 @@ def resolver_provider( | PyPIPrebuiltResolver | PyPIDownloadResolver | PyPIGitResolver + | VersionMapGitResolver | GitHubTagCloneResolver | GitHubTagDownloadResolver | GitLabTagCloneResolver diff --git a/tests/test_packagesettings_resolver.py b/tests/test_packagesettings_resolver.py index d2dab831..0f592cd3 100644 --- a/tests/test_packagesettings_resolver.py +++ b/tests/test_packagesettings_resolver.py @@ -25,6 +25,7 @@ PyPIPrebuiltResolver, PyPISDistResolver, SourceResolver, + VersionMapGitResolver, ) from fromager.packagesettings._typedefs import MODEL_CONFIG from fromager.requirements_file import RequirementType @@ -426,6 +427,50 @@ def test_resolver_provider(self, tmp_context: WorkContext) -> None: # -- Special resolvers -------------------------------------------------------- +class TestVersionMapGitResolver: + YAML = """\ + source: + provider: versionmap-git + clone_url: https://git.test/project/repo.git + build_sdist: pep517 + versionmap: + '1.0': abad1dea + '1.1': refs/tags/1.1 + """ + + def test_parse(self) -> None: + r = _parse(self.YAML) + assert isinstance(r, VersionMapGitResolver) + assert r.provider == "versionmap-git" + assert str(r.clone_url) == "https://git.test/project/repo.git" + assert r.build_sdist == BuildSDist.pep517 + assert r.versionmap == {"1.0": "abad1dea", "1.1": "refs/tags/1.1"} + + def test_resolver_provider(self, tmp_context: WorkContext) -> None: + r = _parse(self.YAML) + p = r.resolver_provider(tmp_context, _REQ_TYPE) + assert isinstance(p, resolver.VersionMapProvider) + clone_url = "https://git.test/project/repo.git" + assert p.version_map["1.0"] == f"git+{clone_url}@abad1dea" + assert p.version_map["1.1"] == f"git+{clone_url}@refs/tags/1.1" + + def test_clone_url_rejects_http(self) -> None: + with pytest.raises(pydantic.ValidationError): + VersionMapGitResolver( + provider="versionmap-git", + clone_url="http://git.test/project/repo.git", # type: ignore[arg-type] + versionmap={"1.0": "abc123"}, + ) + + def test_clone_url_rejects_empty_path(self) -> None: + with pytest.raises(pydantic.ValidationError): + VersionMapGitResolver( + provider="versionmap-git", + clone_url="https://git.test", # type: ignore[arg-type] + versionmap={"1.0": "abc123"}, + ) + + class TestNotAvailableResolver: YAML = """\ source: diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 690a1288..e2c3ea81 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -920,6 +920,64 @@ def test_resolve_versionmap_no_match() -> None: rslvr.resolve([Requirement("testpkg>=2.0")]) +def test_resolve_versionmap_git() -> None: + from fromager.versionmap import VersionMap + + clone_url = "https://git.test/project/repo.git" + version_map = VersionMap( + { + "1.0": f"git+{clone_url}@abad1dea", + "1.1": f"git+{clone_url}@refs/tags/1.1", + "1.2": f"git+{clone_url}@d3adb33f", + } + ) + + provider = resolver.VersionMapProvider( + version_map=version_map, + package_name="testpkg", + ) + reporter: resolvelib.BaseReporter = resolvelib.BaseReporter() + rslvr = resolvelib.Resolver(provider, reporter) + + result = rslvr.resolve([Requirement("testpkg")]) + assert "testpkg" in result.mapping + + candidate = result.mapping["testpkg"] + assert str(candidate.version) == "1.2" + assert candidate.url == f"git+{clone_url}@d3adb33f" + + +def test_resolve_versionmap_git_with_constraint() -> None: + from fromager.versionmap import VersionMap + + clone_url = "https://git.test/project/repo.git" + version_map = VersionMap( + { + "1.0": f"git+{clone_url}@abad1dea", + "1.1": f"git+{clone_url}@refs/tags/1.1", + "1.2": f"git+{clone_url}@d3adb33f", + } + ) + + c = constraints.Constraints() + c.add_constraint("testpkg<1.2") + + provider = resolver.VersionMapProvider( + version_map=version_map, + package_name="testpkg", + constraints=c, + ) + reporter: resolvelib.BaseReporter = resolvelib.BaseReporter() + rslvr = resolvelib.Resolver(provider, reporter) + + result = rslvr.resolve([Requirement("testpkg")]) + assert "testpkg" in result.mapping + + candidate = result.mapping["testpkg"] + assert str(candidate.version) == "1.1" + assert candidate.url == f"git+{clone_url}@refs/tags/1.1" + + _gitlab_submodlib_repo_response = """ [ {