Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies = [
"elfdeps>=0.2.0",
"license-expression",
"packaging",
"packageurl-python",
"psutil",
"pydantic",
"pypi_simple",
Expand Down
2 changes: 2 additions & 0 deletions src/fromager/packagesettings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
GitOptions,
PackageSettings,
ProjectOverride,
PurlConfig,
ResolverDist,
SbomSettings,
VariantInfo,
Expand Down Expand Up @@ -46,6 +47,7 @@
"PackageVersion",
"PatchMap",
"ProjectOverride",
"PurlConfig",
"RawAnnotations",
"ResolverDist",
"SbomSettings",
Expand Down
69 changes: 64 additions & 5 deletions src/fromager/packagesettings/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ class SbomSettings(pydantic.BaseModel):
sbom:
supplier: "Organization: ExampleCo"
namespace: "https://www.example.com"
purl_type: pypi
repository_url: "https://example.com/simple"
creators:
- "Organization: ExampleCo"
"""
Expand All @@ -55,6 +57,64 @@ class SbomSettings(pydantic.BaseModel):
The fromager tool creator entry is always added automatically.
"""

purl_type: str = "pypi"
"""Default purl type for all packages (e.g. ``pypi``, ``generic``)"""

repository_url: str | None = None
"""Default purl ``repository_url`` qualifier for all packages

When set, this URL is added to every purl as a qualifier
(e.g. ``pkg:pypi/flask@2.0?repository_url=https://example.com/simple``).
Can be overridden per-package in the package settings file.
"""


class PurlConfig(pydantic.BaseModel):
"""Per-package purl configuration for SBOM generation.

Allows overriding individual purl components or specifying an
upstream purl for packages sourced from GitHub/GitLab.

::

purl:
type: generic
name: custom-name
repository_url: "https://example.com/simple"
upstream: "pkg:github/org/repo@v1.0.0"
"""

model_config = MODEL_CONFIG

type: str | None = None
"""Override the purl type (e.g. ``generic`` instead of ``pypi``)"""

namespace: str | None = None
"""Override the purl namespace component"""

name: str | None = None
"""Override the purl name component (defaults to the package name)"""

version: str | None = None
"""Override the purl version component (defaults to the resolved version)"""

repository_url: str | 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
"""Full purl string identifying the upstream source package.

When set, this is used as the upstream identity in the SBOM's
GENERATED_FROM relationship. Used for packages sourced from
GitHub/GitLab rather than PyPI.

When absent, the upstream purl is auto-derived from the downstream
purl without the ``repository_url`` qualifier.
"""


class ResolverDist(pydantic.BaseModel):
"""Packages resolver dist
Expand Down Expand Up @@ -351,12 +411,11 @@ class PackageSettings(pydantic.BaseModel):
download_source: DownloadSource = Field(default_factory=DownloadSource)
"""Alternative source download settings"""

purl: str | None = None
"""Package URL (purl) override for SBOM generation
purl: PurlConfig | None = None
"""Purl configuration for SBOM generation.

When set, this value is used instead of the default ``pkg:pypi/<name>@<version>``
purl. Useful for packages that are not on PyPI or are midstream forks.
Supports ``{name}`` and ``{version}`` format substitution.
A ``PurlConfig`` object with individual field overrides and upstream
source identification.
"""

resolver_dist: ResolverDist = Field(default_factory=ResolverDist)
Expand Down
5 changes: 3 additions & 2 deletions src/fromager/packagesettings/_pbi.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
GitOptions,
PackageSettings,
ProjectOverride,
PurlConfig,
VariantInfo,
)
from ._templates import _resolve_template, substitute_template
Expand Down Expand Up @@ -70,8 +71,8 @@ def variant(self) -> Variant:
return self._variant

@property
def purl(self) -> str | None:
"""Package URL (purl) override for SBOM generation."""
def purl_config(self) -> PurlConfig | None:
"""Per-package purl configuration for SBOM generation."""
return self._ps.purl

@property
Expand Down
136 changes: 99 additions & 37 deletions src/fromager/sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,39 +13,73 @@
import typing
from datetime import UTC, datetime

from packageurl import PackageURL
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from packaging.version import Version

if typing.TYPE_CHECKING:
from . import context
from .packagesettings import PackageBuildInfo, SbomSettings

logger = logging.getLogger(__name__)

SBOM_FILENAME = "fromager.spdx.json"


def _build_purl(
def _build_downstream_purl(
*,
package_name: str,
package_version: Version,
purl_override: str | None,
) -> str:
"""Build a package URL for the SBOM.

Returns ``pkg:pypi/<name>@<version>`` by default. If a purl override
is set in per-package settings, it is used instead with
``str.format()`` substitution for ``{name}`` and ``{version}``.
name: str,
version: Version,
pbi: PackageBuildInfo,
sbom_settings: SbomSettings,
) -> PackageURL:
"""Build the downstream package URL for the wheel.

A purl is constructed from ``PurlConfig`` field overrides
(per-package) falling back to global defaults.
"""
if purl_override:
try:
return purl_override.format(name=package_name, version=package_version)
except (KeyError, ValueError) as err:
raise ValueError(
f"invalid purl template {purl_override!r}: "
"only {name} and {version} are supported"
) from err
return f"pkg:pypi/{package_name}@{package_version}"
pc = pbi.purl_config
purl_type = (pc.type if pc else None) or sbom_settings.purl_type
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

return PackageURL(
type=purl_type,
namespace=pc.namespace if pc else None,
name=(pc.name if pc else None) or name,
version=(pc.version if pc else None) or str(version),
qualifiers=qualifiers or None,
)


def _build_upstream_purl(
*,
name: str,
version: Version,
pbi: PackageBuildInfo,
sbom_settings: SbomSettings,
) -> PackageURL:
"""Build the upstream source package URL.

If ``upstream`` is set in the per-package ``PurlConfig``, it is
used as-is. Otherwise, the upstream purl is derived from the same
base as the downstream purl but without the ``repository_url``
qualifier.
"""
pc = pbi.purl_config
if pc and pc.upstream:
return PackageURL.from_string(pc.upstream)

purl_type = (pc.type if pc else None) or sbom_settings.purl_type
return PackageURL(
type=purl_type,
namespace=pc.namespace if pc else None,
name=(pc.name if pc else None) or name,
version=(pc.version if pc else None) or str(version),
)


def generate_sbom(
Expand All @@ -56,8 +90,9 @@ def generate_sbom(
) -> dict[str, typing.Any]:
"""Generate a minimal SPDX 2.3 JSON document for a wheel.

The document contains the wheel as the primary package and a
DESCRIBES relationship from the document to the package.
The document contains the downstream wheel as the primary package,
the upstream source as a second package, and DESCRIBES /
GENERATED_FROM relationships.
"""
sbom_settings = ctx.settings.sbom_settings
if sbom_settings is None:
Expand All @@ -73,26 +108,48 @@ def generate_sbom(

namespace = f"{sbom_settings.namespace}/{name}-{version}.spdx.json"

package_entry: dict[str, typing.Any] = {
downstream = _build_downstream_purl(
name=name,
version=version,
pbi=pbi,
sbom_settings=sbom_settings,
)
upstream = _build_upstream_purl(
name=name,
version=version,
pbi=pbi,
sbom_settings=sbom_settings,
)

wheel_entry: dict[str, typing.Any] = {
"SPDXID": "SPDXRef-wheel",
"name": name,
"versionInfo": str(version),
"name": downstream.name,
"versionInfo": downstream.version or str(version),
"downloadLocation": "NOASSERTION",
"supplier": sbom_settings.supplier,
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": downstream.to_string(),
}
],
}

purl = _build_purl(
package_name=name,
package_version=version,
purl_override=pbi.purl,
)
package_entry["externalRefs"] = [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": purl,
}
]
upstream_entry: dict[str, typing.Any] = {
"SPDXID": "SPDXRef-upstream",
"name": upstream.name,
"versionInfo": upstream.version or str(version),
"downloadLocation": "NOASSERTION",
"supplier": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": upstream.to_string(),
}
],
}

doc: dict[str, typing.Any] = {
"spdxVersion": "SPDX-2.3",
Expand All @@ -104,13 +161,18 @@ def generate_sbom(
"created": timestamp,
"creators": creators,
},
"packages": [package_entry],
"packages": [wheel_entry, upstream_entry],
"relationships": [
{
"spdxElementId": "SPDXRef-DOCUMENT",
"relationshipType": "DESCRIBES",
"relatedSpdxElement": "SPDXRef-wheel",
},
{
"spdxElementId": "SPDXRef-wheel",
"relationshipType": "GENERATED_FROM",
"relatedSpdxElement": "SPDXRef-upstream",
},
],
}
return doc
Expand Down
6 changes: 3 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def testdata_context(
def make_sbom_ctx(
tmp_path: pathlib.Path,
sbom_settings: SbomSettings | None = None,
purl: str | None = None,
package_overrides: dict[str, typing.Any] | None = None,
) -> context.WorkContext:
"""Create a minimal WorkContext with SBOM settings."""
settings_file = packagesettings.SettingsFile(sbom=sbom_settings)
Expand All @@ -97,10 +97,10 @@ def make_sbom_ctx(
variant="cpu",
max_jobs=None,
)
if purl is not None:
if package_overrides is not None:
ps = packagesettings.PackageSettings.from_mapping(
"test-pkg",
{"purl": purl},
package_overrides,
source="test",
has_config=True,
)
Expand Down
Loading
Loading