diff --git a/Makefile b/Makefile index 02f3b54..0f4cccd 100644 --- a/Makefile +++ b/Makefile @@ -46,9 +46,7 @@ dev: $(VENV)/pyvenv.cfg $(VENV)/pyvenv.cfg: pyproject.toml # Create our Python 3 virtual environment python3 -m venv env - # NOTE(ekilmer): interrogate v1.5.0 needs setuptools when using Python 3.12+. - # This should be fixed when the next release is made - $(VENV_BIN)/python -m pip install --upgrade pip setuptools + $(VENV_BIN)/python -m pip install --upgrade pip $(VENV_BIN)/python -m pip install -e .[$(INSTALL_EXTRA)] .PHONY: lint diff --git a/src/pypi_attestations/__init__.py b/src/pypi_attestations/__init__.py index 3c43bbd..a57fbae 100644 --- a/src/pypi_attestations/__init__.py +++ b/src/pypi_attestations/__init__.py @@ -10,6 +10,8 @@ ConversionError, Distribution, Envelope, + GitHubPublisher, + GitLabPublisher, Provenance, Publisher, TransparencyLogEntry, @@ -25,6 +27,8 @@ "ConversionError", "Distribution", "Envelope", + "GitHubPublisher", + "GitLabPublisher", "Provenance", "Publisher", "TransparencyLogEntry", diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index 021d044..b84d9df 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -14,7 +14,8 @@ from cryptography import x509 from cryptography.hazmat.primitives import serialization from packaging.utils import parse_sdist_filename, parse_wheel_filename -from pydantic import Base64Bytes, BaseModel, field_validator +from pydantic import Base64Bytes, BaseModel, ConfigDict, Field, field_validator +from pydantic.alias_generators import to_snake from pydantic_core import ValidationError from sigstore._utils import _sha256_streaming from sigstore.dsse import Envelope as DsseEnvelope @@ -333,20 +334,58 @@ def _ultranormalize_dist_filename(dist: str) -> str: raise ValueError(f"unknown distribution format: {dist}") -class Publisher(BaseModel): - """Publisher as defined in PEP 740.""" +class _PublisherBase(BaseModel): + model_config = ConfigDict(alias_generator=to_snake) kind: str + claims: dict[str, Any] | None = None + + +class GitHubPublisher(_PublisherBase): + """A GitHub-based Trusted Publisher.""" + + kind: Literal["GitHub"] = "GitHub" + + repository: str + """ + The fully qualified publishing repository slug, e.g. `foo/bar` for + repository `bar` owned by `foo`. + """ + + workflow: str + """ + The filename of the GitHub Actions workflow that performed the publishing + action. + """ + + environment: str | None = None + """ + The optional name GitHub Actions environment that the publishing + action was performed from. """ - The kind of Trusted Publisher. + + +class GitLabPublisher(_PublisherBase): + """A GitLab-based Trusted Publisher.""" + + kind: Literal["GitLab"] = "GitLab" + + repository: str + """ + The fully qualified publishing repository slug, e.g. `foo/bar` for + repository `bar` owned by `foo` or `foo/baz/bar` for repository + `bar` owned by group `foo` and subgroup `baz`. """ - claims: dict[str, Any] | None + environment: str | None = None """ - Claims specified by the publisher. + The optional environment that the publishing action was performed from. """ +Publisher = Annotated[GitHubPublisher | GitLabPublisher, Field(discriminator="kind")] + + class AttestationBundle(BaseModel): """AttestationBundle object as defined in PEP 740.""" @@ -364,7 +403,7 @@ class AttestationBundle(BaseModel): class Provenance(BaseModel): """Provenance object as defined in PEP 740.""" - version: Literal[1] + version: Literal[1] = 1 """ The provenance object's version, which is always 1. """ @@ -373,12 +412,3 @@ class Provenance(BaseModel): """ One or more attestation "bundles". """ - - @classmethod - def construct_simple(cls, publisher: Publisher, attestations: list[Attestation]) -> Provenance: - """Construct a simple Provenance object.""" - attestation_bundle = AttestationBundle( - publisher=publisher, - attestations=attestations, - ) - return cls(version=1, attestation_bundles=[attestation_bundle]) diff --git a/test/test_impl.py b/test/test_impl.py index 84d78ec..5157285 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -1,5 +1,6 @@ """Internal implementation tests.""" +import json import os from hashlib import sha256 from pathlib import Path @@ -8,7 +9,7 @@ import pypi_attestations._impl as impl import pytest import sigstore -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from sigstore.dsse import _DigestSet, _StatementBuilder, _Subject from sigstore.models import Bundle from sigstore.oidc import IdentityToken @@ -464,17 +465,54 @@ def test_ultranormalize_dist_filename_invalid(input: str) -> None: impl._ultranormalize_dist_filename(input) -def test_construct_provenance() -> None: - attestation = impl.Attestation.model_validate_json(dist_attestation_path.read_bytes()) +class TestPublisher: + def test_discriminator(self) -> None: + gh_raw = {"kind": "GitHub", "repository": "foo/bar", "workflow": "publish.yml"} + gh = TypeAdapter(impl.Publisher).validate_python(gh_raw) + + assert isinstance(gh, impl.GitHubPublisher) + assert gh.repository == "foo/bar" + assert gh.workflow == "publish.yml" + assert TypeAdapter(impl.Publisher).validate_json(json.dumps(gh_raw)) == gh - publisher = impl.Publisher(kind="simple-publisher-url", claims=None) - provenance = impl.Provenance.construct_simple(publisher=publisher, attestations=[attestation]) + gl_raw = {"kind": "GitLab", "repository": "foo/bar/baz", "environment": "publish"} + gl = TypeAdapter(impl.Publisher).validate_python(gl_raw) + assert isinstance(gl, impl.GitLabPublisher) + assert gl.repository == "foo/bar/baz" + assert gl.environment == "publish" + assert TypeAdapter(impl.Publisher).validate_json(json.dumps(gl_raw)) == gl - assert provenance.version == 1 - assert len(provenance.attestation_bundles) == 1 + def test_wrong_kind(self) -> None: + with pytest.raises(ValueError, match="Input should be 'GitHub'"): + impl.GitHubPublisher(kind="wrong", repository="foo/bar", workflow="publish.yml") - bundle = provenance.attestation_bundles[0] - assert bundle.publisher.claims is None - assert bundle.publisher.kind == "simple-publisher-url" + with pytest.raises(ValueError, match="Input should be 'GitLab'"): + impl.GitLabPublisher(kind="GitHub", repository="foo/bar") - assert bundle.attestations == [attestation] + +class TestProvenance: + def test_version(self) -> None: + attestation = impl.Attestation.model_validate_json(dist_attestation_path.read_bytes()) + provenance = impl.Provenance( + attestation_bundles=[ + impl.AttestationBundle( + publisher=impl.GitHubPublisher(repository="foo/bar", workflow="publish.yml"), + attestations=[attestation], + ) + ] + ) + assert provenance.version == 1 + + # Setting any other version doesn't work. + with pytest.raises(ValueError): + provenance = impl.Provenance( + version=2, + attestation_bundles=[ + impl.AttestationBundle( + publisher=impl.GitHubPublisher( + repository="foo/bar", workflow="publish.yml" + ), + attestations=[attestation], + ) + ], + )