diff --git a/pyproject.toml b/pyproject.toml index df579722..241c0738 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "packaging", "packageurl-python", "psutil", - "pydantic", + "pydantic>=2.12", "pypi_simple", "pyproject_hooks>=1.0.0,!=1.1.0", "PyYAML", diff --git a/src/fromager/packagesettings/_models.py b/src/fromager/packagesettings/_models.py index d0a06790..64bc3e07 100644 --- a/src/fromager/packagesettings/_models.py +++ b/src/fromager/packagesettings/_models.py @@ -10,10 +10,9 @@ import pydantic import yaml -from packageurl import PackageURL from packaging.requirements import Requirement from packaging.utils import canonicalize_name -from pydantic import Field +from pydantic import AnyUrl, Field from pydantic_core import core_schema from ._typedefs import ( @@ -21,8 +20,10 @@ BuildDirectory, EnvVars, Package, + PurlType, RawAnnotations, Template, + UpstreamPurl, Variant, VariantChangelog, ) @@ -49,7 +50,7 @@ class SbomSettings(pydantic.BaseModel): supplier: str = "NOASSERTION" """SPDX supplier field for the wheel package (e.g. ``Organization: ExampleCo``)""" - namespace: str = "https://spdx.org/spdxdocs" + namespace: AnyUrl = AnyUrl("https://spdx.org/spdxdocs") """Base URL for the SPDX documentNamespace""" creators: list[str] = Field(default_factory=list) @@ -58,10 +59,10 @@ class SbomSettings(pydantic.BaseModel): The fromager tool creator entry is always added automatically. """ - purl_type: str = "pypi" + purl_type: PurlType = "pypi" """Default purl type for all packages (e.g. ``pypi``, ``generic``)""" - repository_url: str | None = None + repository_url: AnyUrl | None = None """Default purl ``repository_url`` qualifier for all packages When set, this URL is added to every purl as a qualifier @@ -89,7 +90,7 @@ class PurlConfig(pydantic.BaseModel): model_config = MODEL_CONFIG - type: str | None = None + type: PurlType | None = None """Override the purl type (e.g. ``generic`` instead of ``pypi``)""" namespace: str | None = None @@ -101,13 +102,13 @@ class PurlConfig(pydantic.BaseModel): version: str | None = None """Override the purl version component (defaults to the resolved version)""" - repository_url: str | None = None + repository_url: AnyUrl | None = None """Per-package override for the purl ``repository_url`` qualifier. Overrides the global ``sbom.repository_url`` setting for this package. """ - upstream: str | None = None + upstream: UpstreamPurl | None = None """Full purl string identifying the upstream source package. When set, this is used as the upstream identity in the SBOM's @@ -118,18 +119,6 @@ class PurlConfig(pydantic.BaseModel): purl without the ``repository_url`` qualifier. """ - @pydantic.field_validator("upstream") - @classmethod - def validate_upstream_purl(cls, v: str | None) -> str | None: - """Validate that upstream is a valid purl string.""" - if v is None: - return v - try: - PackageURL.from_string(v) - except ValueError as err: - raise ValueError(f"invalid upstream purl {v!r}") from err - return v - class ResolverDist(pydantic.BaseModel): """Packages resolver dist diff --git a/src/fromager/packagesettings/_typedefs.py b/src/fromager/packagesettings/_typedefs.py index aeae5909..0b5e2cdb 100644 --- a/src/fromager/packagesettings/_typedefs.py +++ b/src/fromager/packagesettings/_typedefs.py @@ -7,8 +7,10 @@ from collections.abc import Mapping import pydantic +from packageurl import PackageURL from packaging.utils import NormalizedName, canonicalize_name from packaging.version import Version +from pydantic import StringConstraints from pydantic_core import CoreSchema, core_schema # common settings @@ -19,6 +21,8 @@ frozen=True, # read inline doc strings use_attribute_docstrings=True, + # preserve URLs as-is without trailing slash normalization + url_preserve_empty_path=True, ) @@ -98,6 +102,31 @@ def _validate_envkey(v: typing.Any) -> str: GlobalChangelog = Mapping[Variant, list[str]] VariantChangelog = Mapping[PackageVersion, list[str]] + +# purl type (e.g. "pypi", "generic", "github") +PurlType = typing.Annotated[ + str, + StringConstraints(strip_whitespace=True, to_lower=True, min_length=1), +] + + +# full purl string identifying an upstream source package +def _validate_upstream_purl(v: str) -> str: + """Validate that *v* is a well-formed purl string.""" + try: + PackageURL.from_string(v) + except ValueError as err: + raise ValueError(f"invalid upstream purl {v!r}: {err}") from err + return v + + +UpstreamPurl = typing.Annotated[ + str, + StringConstraints(strip_whitespace=True, min_length=1), + pydantic.AfterValidator(_validate_upstream_purl), +] + + # Annotations RawAnnotations = Mapping[str, str] diff --git a/src/fromager/sbom.py b/src/fromager/sbom.py index 2e935dd6..724aa7e2 100644 --- a/src/fromager/sbom.py +++ b/src/fromager/sbom.py @@ -44,7 +44,7 @@ def _build_downstream_purl( qualifiers: dict[str, str] = {} repo_url = (pc.repository_url if pc else None) or sbom_settings.repository_url if repo_url: - qualifiers["repository_url"] = repo_url + qualifiers["repository_url"] = str(repo_url) return PackageURL( type=purl_type, @@ -109,7 +109,7 @@ def generate_sbom( creators = list(sbom_settings.creators) creators.append(f"Tool: fromager-{fromager_version}") - namespace = f"{sbom_settings.namespace}/{name}-{version}.spdx.json" + namespace = f"{sbom_settings.namespace!s}/{name}-{version}.spdx.json" downstream = _build_downstream_purl( name=name, diff --git a/tests/test_packagesettings.py b/tests/test_packagesettings.py index f8cdee7b..673b1c6b 100644 --- a/tests/test_packagesettings.py +++ b/tests/test_packagesettings.py @@ -23,6 +23,7 @@ Variant, substitute_template, ) +from fromager.packagesettings._typedefs import PurlType, UpstreamPurl TEST_PKG = "test-pkg" TEST_EMPTY_PKG = "test-empty-pkg" @@ -490,6 +491,32 @@ def test_type_builddirectory() -> None: ta.validate_python("/absolute/path") +def test_type_purl_type() -> None: + """Verify PurlType normalizes and rejects empty strings.""" + ta = pydantic.TypeAdapter(PurlType) + assert ta.validate_python("pypi") == "pypi" + assert ta.validate_python(" Generic ") == "generic" + assert ta.validate_python("GITHUB") == "github" + with pytest.raises(ValueError): + ta.validate_python("") + with pytest.raises(ValueError): + ta.validate_python(" ") + + +def test_type_upstream_purl() -> None: + """Verify UpstreamPurl accepts valid purls and rejects invalid strings.""" + ta = pydantic.TypeAdapter(UpstreamPurl) + assert ta.validate_python("pkg:pypi/flask@2.0") == "pkg:pypi/flask@2.0" + assert ( + ta.validate_python("pkg:github/vllm-project/bart-plugin@v0.2.0") + == "pkg:github/vllm-project/bart-plugin@v0.2.0" + ) + with pytest.raises(ValueError): + ta.validate_python("invalid-not-purl") + with pytest.raises(ValueError): + ta.validate_python("") + + def test_global_settings(testdata_path: pathlib.Path) -> None: filename = testdata_path / "context/overrides/settings.yaml" gs = SettingsFile.from_file(filename) diff --git a/tests/test_sbom.py b/tests/test_sbom.py index 3db57362..7a307d53 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -61,7 +61,9 @@ def test_generate_sbom_default_purls(tmp_path: pathlib.Path) -> None: def test_generate_sbom_repository_url_qualifier(tmp_path: pathlib.Path) -> None: """Verify global repository_url adds qualifier to downstream but not upstream.""" - settings = SbomSettings(repository_url="https://packages.redhat.com") + settings = SbomSettings( + repository_url="https://packages.redhat.com", # type: ignore[arg-type] + ) ctx = make_sbom_ctx(tmp_path, sbom_settings=settings) doc = sbom.generate_sbom( ctx=ctx, @@ -82,7 +84,7 @@ def test_generate_sbom_custom_settings(tmp_path: pathlib.Path) -> None: """Verify custom supplier, namespace, and creators are used.""" settings = SbomSettings( supplier="Organization: ExampleCo", - namespace="https://www.example.com", + namespace="https://www.example.com", # type: ignore[arg-type] creators=["Organization: ExampleCo"], ) ctx = make_sbom_ctx(tmp_path, sbom_settings=settings) @@ -132,7 +134,9 @@ def test_generate_sbom_package_repository_url_override(tmp_path: pathlib.Path) - """Verify per-package repository_url overrides the global value.""" ctx = make_sbom_ctx( tmp_path, - sbom_settings=SbomSettings(repository_url="https://packages.redhat.com"), + sbom_settings=SbomSettings( + repository_url="https://packages.redhat.com", # type: ignore[arg-type] + ), package_overrides={ "purl": {"repository_url": "https://mirror.example.com/simple"}, }, @@ -157,7 +161,9 @@ def test_generate_sbom_upstream_purl_override(tmp_path: pathlib.Path) -> None: """Verify upstream purl override for GitHub-sourced packages.""" ctx = make_sbom_ctx( tmp_path, - sbom_settings=SbomSettings(repository_url="https://packages.redhat.com"), + sbom_settings=SbomSettings( + repository_url="https://packages.redhat.com", # type: ignore[arg-type] + ), package_overrides={ "purl": {"upstream": "pkg:github/vllm-project/bart-plugin@v0.2.0"}, },