Skip to content

Commit c2cdfee

Browse files
smoparthclaude
authored andcommitted
feat(sbom): use annotated data types in Pydantic config
Replace generic `str` fields in SbomSettings and PurlConfig with Pydantic's built-in types annotated validators to validate input and make the schema self-documenting. Added pydantic>=2.12 minimum version for url_preserve_empty_path support. Changes: - PurlType: StringConstraints with strip, lowercase, min_length - UpstreamPurl: AfterValidator with PackageURL.from_string() - URL fields (namespace, repository_url): use pydantic.AnyUrl directly - supplier/creators: remain plain str per SPDX 2.3 JSON schema The @field_validator on PurlConfig.upstream is replaced by the UpstreamPurl annotated type, moving validation into the type itself. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Closes: #1072 Signed-off-by: Shanmukh Pawan <smoparth@redhat.com>
1 parent 63e9021 commit c2cdfee

6 files changed

Lines changed: 78 additions & 27 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ dependencies = [
3737
"packaging",
3838
"packageurl-python",
3939
"psutil",
40-
"pydantic",
40+
"pydantic>=2.12",
4141
"pypi_simple",
4242
"pyproject_hooks>=1.0.0,!=1.1.0",
4343
"PyYAML",

src/fromager/packagesettings/_models.py

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,20 @@
1010

1111
import pydantic
1212
import yaml
13-
from packageurl import PackageURL
1413
from packaging.requirements import Requirement
1514
from packaging.utils import canonicalize_name
16-
from pydantic import Field
15+
from pydantic import AnyUrl, Field
1716
from pydantic_core import core_schema
1817

1918
from ._typedefs import (
2019
MODEL_CONFIG,
2120
BuildDirectory,
2221
EnvVars,
2322
Package,
23+
PurlType,
2424
RawAnnotations,
2525
Template,
26+
UpstreamPurl,
2627
Variant,
2728
VariantChangelog,
2829
)
@@ -49,7 +50,7 @@ class SbomSettings(pydantic.BaseModel):
4950
supplier: str = "NOASSERTION"
5051
"""SPDX supplier field for the wheel package (e.g. ``Organization: ExampleCo``)"""
5152

52-
namespace: str = "https://spdx.org/spdxdocs"
53+
namespace: AnyUrl = AnyUrl("https://spdx.org/spdxdocs")
5354
"""Base URL for the SPDX documentNamespace"""
5455

5556
creators: list[str] = Field(default_factory=list)
@@ -58,10 +59,10 @@ class SbomSettings(pydantic.BaseModel):
5859
The fromager tool creator entry is always added automatically.
5960
"""
6061

61-
purl_type: str = "pypi"
62+
purl_type: PurlType = "pypi"
6263
"""Default purl type for all packages (e.g. ``pypi``, ``generic``)"""
6364

64-
repository_url: str | None = None
65+
repository_url: AnyUrl | None = None
6566
"""Default purl ``repository_url`` qualifier for all packages
6667
6768
When set, this URL is added to every purl as a qualifier
@@ -89,7 +90,7 @@ class PurlConfig(pydantic.BaseModel):
8990

9091
model_config = MODEL_CONFIG
9192

92-
type: str | None = None
93+
type: PurlType | None = None
9394
"""Override the purl type (e.g. ``generic`` instead of ``pypi``)"""
9495

9596
namespace: str | None = None
@@ -101,13 +102,13 @@ class PurlConfig(pydantic.BaseModel):
101102
version: str | None = None
102103
"""Override the purl version component (defaults to the resolved version)"""
103104

104-
repository_url: str | None = None
105+
repository_url: AnyUrl | None = None
105106
"""Per-package override for the purl ``repository_url`` qualifier.
106107
107108
Overrides the global ``sbom.repository_url`` setting for this package.
108109
"""
109110

110-
upstream: str | None = None
111+
upstream: UpstreamPurl | None = None
111112
"""Full purl string identifying the upstream source package.
112113
113114
When set, this is used as the upstream identity in the SBOM's
@@ -118,18 +119,6 @@ class PurlConfig(pydantic.BaseModel):
118119
purl without the ``repository_url`` qualifier.
119120
"""
120121

121-
@pydantic.field_validator("upstream")
122-
@classmethod
123-
def validate_upstream_purl(cls, v: str | None) -> str | None:
124-
"""Validate that upstream is a valid purl string."""
125-
if v is None:
126-
return v
127-
try:
128-
PackageURL.from_string(v)
129-
except ValueError as err:
130-
raise ValueError(f"invalid upstream purl {v!r}") from err
131-
return v
132-
133122

134123
class ResolverDist(pydantic.BaseModel):
135124
"""Packages resolver dist

src/fromager/packagesettings/_typedefs.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
from collections.abc import Mapping
88

99
import pydantic
10+
from packageurl import PackageURL
1011
from packaging.utils import NormalizedName, canonicalize_name
1112
from packaging.version import Version
13+
from pydantic import StringConstraints
1214
from pydantic_core import CoreSchema, core_schema
1315

1416
# common settings
@@ -19,6 +21,8 @@
1921
frozen=True,
2022
# read inline doc strings
2123
use_attribute_docstrings=True,
24+
# preserve URLs as-is without trailing slash normalization
25+
url_preserve_empty_path=True,
2226
)
2327

2428

@@ -98,6 +102,31 @@ def _validate_envkey(v: typing.Any) -> str:
98102
GlobalChangelog = Mapping[Variant, list[str]]
99103
VariantChangelog = Mapping[PackageVersion, list[str]]
100104

105+
106+
# purl type (e.g. "pypi", "generic", "github")
107+
PurlType = typing.Annotated[
108+
str,
109+
StringConstraints(strip_whitespace=True, to_lower=True, min_length=1),
110+
]
111+
112+
113+
# full purl string identifying an upstream source package
114+
def _validate_upstream_purl(v: str) -> str:
115+
"""Validate that *v* is a well-formed purl string."""
116+
try:
117+
PackageURL.from_string(v)
118+
except ValueError as err:
119+
raise ValueError(f"invalid upstream purl {v!r}: {err}") from err
120+
return v
121+
122+
123+
UpstreamPurl = typing.Annotated[
124+
str,
125+
StringConstraints(strip_whitespace=True, min_length=1),
126+
pydantic.AfterValidator(_validate_upstream_purl),
127+
]
128+
129+
101130
# Annotations
102131
RawAnnotations = Mapping[str, str]
103132

src/fromager/sbom.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def _build_downstream_purl(
4444
qualifiers: dict[str, str] = {}
4545
repo_url = (pc.repository_url if pc else None) or sbom_settings.repository_url
4646
if repo_url:
47-
qualifiers["repository_url"] = repo_url
47+
qualifiers["repository_url"] = str(repo_url)
4848

4949
return PackageURL(
5050
type=purl_type,
@@ -109,7 +109,7 @@ def generate_sbom(
109109
creators = list(sbom_settings.creators)
110110
creators.append(f"Tool: fromager-{fromager_version}")
111111

112-
namespace = f"{sbom_settings.namespace}/{name}-{version}.spdx.json"
112+
namespace = f"{sbom_settings.namespace!s}/{name}-{version}.spdx.json"
113113

114114
downstream = _build_downstream_purl(
115115
name=name,

tests/test_packagesettings.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
Variant,
2424
substitute_template,
2525
)
26+
from fromager.packagesettings._typedefs import PurlType, UpstreamPurl
2627

2728
TEST_PKG = "test-pkg"
2829
TEST_EMPTY_PKG = "test-empty-pkg"
@@ -491,6 +492,32 @@ def test_type_builddirectory() -> None:
491492
ta.validate_python("/absolute/path")
492493

493494

495+
def test_type_purl_type() -> None:
496+
"""Verify PurlType normalizes and rejects empty strings."""
497+
ta = pydantic.TypeAdapter(PurlType)
498+
assert ta.validate_python("pypi") == "pypi"
499+
assert ta.validate_python(" Generic ") == "generic"
500+
assert ta.validate_python("GITHUB") == "github"
501+
with pytest.raises(ValueError):
502+
ta.validate_python("")
503+
with pytest.raises(ValueError):
504+
ta.validate_python(" ")
505+
506+
507+
def test_type_upstream_purl() -> None:
508+
"""Verify UpstreamPurl accepts valid purls and rejects invalid strings."""
509+
ta = pydantic.TypeAdapter(UpstreamPurl)
510+
assert ta.validate_python("pkg:pypi/flask@2.0") == "pkg:pypi/flask@2.0"
511+
assert (
512+
ta.validate_python("pkg:github/vllm-project/bart-plugin@v0.2.0")
513+
== "pkg:github/vllm-project/bart-plugin@v0.2.0"
514+
)
515+
with pytest.raises(ValueError):
516+
ta.validate_python("invalid-not-purl")
517+
with pytest.raises(ValueError):
518+
ta.validate_python("")
519+
520+
494521
def test_global_settings(testdata_path: pathlib.Path) -> None:
495522
filename = testdata_path / "context/overrides/settings.yaml"
496523
gs = SettingsFile.from_file(filename)

tests/test_sbom.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ def test_generate_sbom_default_purls(tmp_path: pathlib.Path) -> None:
6161

6262
def test_generate_sbom_repository_url_qualifier(tmp_path: pathlib.Path) -> None:
6363
"""Verify global repository_url adds qualifier to downstream but not upstream."""
64-
settings = SbomSettings(repository_url="https://packages.redhat.com")
64+
settings = SbomSettings(
65+
repository_url="https://packages.redhat.com", # type: ignore[arg-type]
66+
)
6567
ctx = make_sbom_ctx(tmp_path, sbom_settings=settings)
6668
doc = sbom.generate_sbom(
6769
ctx=ctx,
@@ -82,7 +84,7 @@ def test_generate_sbom_custom_settings(tmp_path: pathlib.Path) -> None:
8284
"""Verify custom supplier, namespace, and creators are used."""
8385
settings = SbomSettings(
8486
supplier="Organization: ExampleCo",
85-
namespace="https://www.example.com",
87+
namespace="https://www.example.com", # type: ignore[arg-type]
8688
creators=["Organization: ExampleCo"],
8789
)
8890
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) -
132134
"""Verify per-package repository_url overrides the global value."""
133135
ctx = make_sbom_ctx(
134136
tmp_path,
135-
sbom_settings=SbomSettings(repository_url="https://packages.redhat.com"),
137+
sbom_settings=SbomSettings(
138+
repository_url="https://packages.redhat.com", # type: ignore[arg-type]
139+
),
136140
package_overrides={
137141
"purl": {"repository_url": "https://mirror.example.com/simple"},
138142
},
@@ -157,7 +161,9 @@ def test_generate_sbom_upstream_purl_override(tmp_path: pathlib.Path) -> None:
157161
"""Verify upstream purl override for GitHub-sourced packages."""
158162
ctx = make_sbom_ctx(
159163
tmp_path,
160-
sbom_settings=SbomSettings(repository_url="https://packages.redhat.com"),
164+
sbom_settings=SbomSettings(
165+
repository_url="https://packages.redhat.com", # type: ignore[arg-type]
166+
),
161167
package_overrides={
162168
"purl": {"upstream": "pkg:github/vllm-project/bart-plugin@v0.2.0"},
163169
},

0 commit comments

Comments
 (0)