This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-releases-client.git
The following commit(s) were added to refs/heads/main by this push:
new cb45b9d Update models
cb45b9d is described below
commit cb45b9def0a98f8fa05339a1b78e812ba504bb5e
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Apr 6 17:49:27 2026 +0100
Update models
---
pyproject.toml | 4 +-
src/atrclient/client.py | 58 +--
src/atrclient/models/__init__.py | 17 +-
src/atrclient/models/api.py | 140 ++++---
src/atrclient/models/attestable.py | 29 +-
src/atrclient/models/distribution.py | 36 +-
src/atrclient/models/github.py | 77 ++++
src/atrclient/models/results.py | 41 +-
src/atrclient/models/safe.py | 324 +++++++++++++++
src/atrclient/models/schema.py | 6 +-
src/atrclient/models/sql.py | 478 ++++++++++++++++------
src/atrclient/models/tabulate.py | 60 ++-
src/atrclient/models/{attestable.py => unsafe.py} | 23 +-
uv.lock | 4 +-
14 files changed, 1019 insertions(+), 278 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index acf5669..b62eeac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ build-backend = "hatchling.build"
[project]
name = "apache-trusted-releases"
-version = "0.20260406.1633"
+version = "0.20260406.1643"
description = "ATR CLI and Python API"
readme = "README.md"
requires-python = ">=3.12"
@@ -86,4 +86,4 @@ filterwarnings = [
]
[tool.uv]
-exclude-newer = "2026-04-06T16:33:00Z"
+exclude-newer = "2026-04-06T16:43:00Z"
diff --git a/src/atrclient/client.py b/src/atrclient/client.py
index ddfaecf..be7bdfd 100755
--- a/src/atrclient/client.py
+++ b/src/atrclient/client.py
@@ -93,12 +93,12 @@ def app_announce(
path_suffix: Annotated[str | None, cyclopts.Parameter(alias="-p",
name="--path-suffix")] = None,
) -> None:
announce_args = models.api.ReleaseAnnounceArgs(
- project=project,
- version=version,
- revision=revision,
+ project=models.safe.ProjectKey(project),
+ version=models.safe.VersionKey(version),
+ revision=models.safe.RevisionNumber(revision),
email_to=mailing_list,
body=body or f"Release {project} {version} has been announced.",
- path_suffix=path_suffix or "",
+ path_suffix=models.safe.RelPath(path_suffix) if path_suffix else None,
)
announce = api.release_announce(announce_args)
if not announce.success:
@@ -247,7 +247,9 @@ def app_config_path() -> None:
@APP_DEV.command(name="delete", help="Delete a release.")
def app_dev_delete(project: str, version: str, /) -> None:
- releases_delete_args = models.api.ReleaseDeleteArgs(project=project,
version=version)
+ releases_delete_args = models.api.ReleaseDeleteArgs(
+ project=models.safe.ProjectKey(project),
version=models.safe.VersionKey(version)
+ )
api.release_delete(releases_delete_args)
print(f"{project}-{version}")
@@ -403,7 +405,9 @@ def app_dev_user() -> None:
@APP_DRAFT.command(name="delete", help="Delete a draft release.")
def app_draft_delete(project: str, version: str, /) -> None:
- draft_delete_args = models.api.ReleaseDraftDeleteArgs(project=project,
version=version)
+ draft_delete_args = models.api.ReleaseDraftDeleteArgs(
+ project=models.safe.ProjectKey(project),
version=models.safe.VersionKey(version)
+ )
draft_delete = api.release_draft_delete(draft_delete_args)
print(draft_delete.success)
@@ -425,12 +429,14 @@ def app_distribution_record(
show.error_and_exit(f"Invalid platform: {platform}")
platform_member = models.sql.DistributionPlatform[platform]
distribution_record_args = models.api.DistributionRecordArgs(
- project=project,
- version=version,
+ project=models.safe.ProjectKey(project),
+ version=models.safe.VersionKey(version),
platform=platform_member,
- distribution_owner_namespace=distribution_owner_namespace,
- distribution_package=distribution_package,
- distribution_version=distribution_version,
+
distribution_owner_namespace=models.safe.Alphanumeric(distribution_owner_namespace)
+ if distribution_owner_namespace
+ else None,
+ distribution_package=models.safe.Alphanumeric(distribution_package),
+ distribution_version=models.safe.VersionKey(distribution_version),
staging=staging,
details=details,
)
@@ -476,9 +482,9 @@ def app_ignore_add(
message: str | None = None,
) -> None:
args = models.api.IgnoreAddArgs(
- project_name=project,
+ project_key=models.safe.ProjectKey(project),
release_glob=release,
- revision_number=revision,
+ revision_number=models.safe.RevisionNumber(revision) if revision else
None,
checker_glob=checker,
primary_rel_path_glob=primary_rel_path,
member_rel_path_glob=member_rel_path,
@@ -503,7 +509,7 @@ def app_ignore_delete(
id: int,
/,
) -> None:
- args = models.api.IgnoreDeleteArgs(project_name=project, id=id)
+ args =
models.api.IgnoreDeleteArgs(project_key=models.safe.ProjectKey(project), id=id)
api.ignore_delete(args)
print("Check ignore deleted for:")
print(f" Project: {project}")
@@ -640,7 +646,9 @@ def app_release_list(project: str, /) -> None:
@APP_RELEASE.command(name="start", help="Start a release.")
def app_release_start(project: str, version: str, /) -> None:
- releases_create_args = models.api.ReleaseCreateArgs(project=project,
version=version)
+ releases_create_args = models.api.ReleaseCreateArgs(
+ project=models.safe.ProjectKey(project),
version=models.safe.VersionKey(version)
+ )
releases_create = api.release_create(releases_create_args)
print(releases_create.release.model_dump_json(indent=None))
@@ -738,9 +746,9 @@ def app_upload(project: str, version: str, path: str,
filepath: str, /) -> None:
content = fh.read()
upload_args = models.api.ReleaseUploadArgs(
- project=project,
- version=version,
- relpath=path,
+ project=models.safe.ProjectKey(project),
+ version=models.safe.VersionKey(version),
+ relpath=models.safe.RelPath(path),
content=base64.b64encode(content).decode("utf-8"),
)
@@ -822,8 +830,8 @@ def app_vote_resolve(
resolution: Literal["passed", "failed"],
) -> None:
vote_resolve_args = models.api.VoteResolveArgs(
- project=project,
- version=version,
+ project=models.safe.ProjectKey(project),
+ version=models.safe.VersionKey(version),
resolution=resolution,
)
api.vote_resolve(vote_resolve_args)
@@ -847,9 +855,9 @@ def app_vote_start(
body_text = fh.read()
vote_start_args = models.api.VoteStartArgs(
- project=project,
- version=version,
- revision=revision,
+ project=models.safe.ProjectKey(project),
+ version=models.safe.VersionKey(version),
+ revision=models.safe.RevisionNumber(revision),
email_to=mailing_list,
vote_duration=duration,
subject=subject or f"[VOTE] Release {project} {version}",
@@ -861,7 +869,9 @@ def app_vote_start(
@APP_VOTE.command(name="tabulate", help="Tabulate a vote.")
def app_vote_tabulate(project: str, version: str, /) -> None:
- vote_tabulate_args = models.api.VoteTabulateArgs(project=project,
version=version)
+ vote_tabulate_args = models.api.VoteTabulateArgs(
+ project=models.safe.ProjectKey(project),
version=models.safe.VersionKey(version)
+ )
vote_tabulate = api.vote_tabulate(vote_tabulate_args)
print(vote_tabulate.model_dump_json(indent=2))
diff --git a/src/atrclient/models/__init__.py b/src/atrclient/models/__init__.py
index 3d7d9fd..20d8698 100644
--- a/src/atrclient/models/__init__.py
+++ b/src/atrclient/models/__init__.py
@@ -15,7 +15,20 @@
# specific language governing permissions and limitations
# under the License.
-from . import api, basic, distribution, helpers, results, schema, session,
sql, tabulate, validation
+from . import api, basic, distribution, github, helpers, results, safe,
schema, session, sql, tabulate, validation
# If we use .__name__, pyright gives a warning
-__all__ = ["api", "basic", "distribution", "helpers", "results", "schema",
"session", "sql", "tabulate", "validation"]
+__all__ = [
+ "api",
+ "basic",
+ "distribution",
+ "github",
+ "helpers",
+ "results",
+ "safe",
+ "schema",
+ "session",
+ "sql",
+ "tabulate",
+ "validation",
+]
diff --git a/src/atrclient/models/api.py b/src/atrclient/models/api.py
index 9464a63..841849f 100644
--- a/src/atrclient/models/api.py
+++ b/src/atrclient/models/api.py
@@ -21,7 +21,7 @@ from typing import Annotated, Any, Literal, TypeVar
import pydantic
-from . import schema, sql, tabulate, validation
+from . import safe, schema, sql, tabulate, validation
T = TypeVar("T")
@@ -73,14 +73,14 @@ class DistributeSshRegisterArgs(schema.Strict):
ssh_key: str = schema.example("ssh-ed25519
AAAAC3NzaC1lZDI1NTEgH5C9okWi0dh25AAAAIOMqqnkVzrm0SdG6UOoqKLsabl9GKJl")
phase: str = schema.Field(strict=False, default="compose",
json_schema_extra={"examples": ["compose", "finish"]})
asf_uid: str = schema.example("user")
- project_name: str = schema.example("tooling")
- version: str = schema.example("0.0.1")
+ project_key: safe.ProjectKey = schema.example("tooling")
+ version: safe.VersionKey = schema.example("0.0.1")
class DistributeSshRegisterResults(schema.Strict):
endpoint: Literal["/distribute/ssh/register"] = schema.alias("endpoint")
fingerprint: str =
schema.example("SHA256:0123456789abcdef0123456789abcdef01234567")
- project: str = schema.example("example")
+ project: safe.ProjectKey = schema.example("example")
expires: int = schema.example(1713547200)
@@ -89,7 +89,7 @@ class DistributeStatusUpdateArgs(schema.Strict):
jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
workflow: str = schema.description("Workflow name")
run_id: str = schema.description("Workflow run ID")
- project_name: str = schema.description("Project name in ATR")
+ project_key: safe.ProjectKey = schema.description("Project name in ATR")
status: str = schema.description("Workflow status")
message: str = schema.description("Workflow message")
@@ -100,12 +100,12 @@ class DistributeStatusUpdateResults(schema.Strict):
class DistributionRecordArgs(schema.Strict):
- project: str = schema.example("example")
- version: str = schema.example("0.0.1")
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("0.0.1")
platform: sql.DistributionPlatform =
schema.example(sql.DistributionPlatform.ARTIFACT_HUB)
- distribution_owner_namespace: str | None = schema.default_example(None,
"example")
- distribution_package: str = schema.example("example")
- distribution_version: str = schema.example("0.0.1")
+ distribution_owner_namespace: safe.Alphanumeric | None =
schema.default_example(None, "example")
+ distribution_package: safe.Alphanumeric = schema.example("example")
+ distribution_version: safe.VersionKey = schema.example("0.0.1")
staging: bool = schema.example(False)
details: bool = schema.example(False)
@@ -128,12 +128,12 @@ class DistributionRecordFromWorkflowArgs(schema.Strict):
asf_uid: str = schema.example("user")
publisher: str = schema.example("user")
jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
- project: str = schema.example("example")
- version: str = schema.example("0.0.1")
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("0.0.1")
platform: sql.DistributionPlatform =
schema.example(sql.DistributionPlatform.ARTIFACT_HUB)
- distribution_owner_namespace: str | None = schema.default_example(None,
"example")
- distribution_package: str = schema.example("example")
- distribution_version: str = schema.example("0.0.1")
+ distribution_owner_namespace: safe.Alphanumeric | None =
schema.default_example(None, "example")
+ distribution_package: safe.Alphanumeric = schema.example("example")
+ distribution_version: safe.VersionKey = schema.example("0.0.1")
phase: str = schema.Field(strict=False, default="compose",
json_schema_extra={"examples": ["compose", "finish"]})
staging: bool = schema.example(False)
details: bool = schema.example(False)
@@ -164,9 +164,9 @@ class DistributionRecordResults(schema.Strict):
class IgnoreAddArgs(schema.Strict):
- project_name: str = schema.example("example")
+ project_key: safe.ProjectKey = schema.example("example")
release_glob: str | None = schema.default_example(None, "example-0.0.*")
- revision_number: str | None = schema.default_example(None, "00001")
+ revision_number: safe.RevisionNumber | None = schema.default_example(None,
"00001")
checker_glob: str | None = schema.default_example(None,
"atr.tasks.checks.license.files")
primary_rel_path_glob: str | None = schema.default_example(None,
"apache-example-0.0.1-*.tar.gz")
member_rel_path_glob: str | None = schema.default_example(None,
"apache-example-0.0.1/*.xml")
@@ -194,7 +194,7 @@ class IgnoreAddResults(schema.Strict):
class IgnoreDeleteArgs(schema.Strict):
- project_name: str = schema.example("example")
+ project_key: safe.ProjectKey = schema.example("example")
id: int = schema.example(1)
@@ -287,9 +287,9 @@ class ProjectGetResults(schema.Strict):
project: sql.Project
-class ProjectPolicyResults(schema.Strict):
- endpoint: Literal["/project/policy"] = schema.alias("endpoint")
- project_name: str
+class PolicyGetResults(schema.Strict):
+ endpoint: Literal["/policy/get"] = schema.alias("endpoint")
+ project_key: safe.ProjectKey
policy_announce_release_subject: str
policy_announce_release_template: str
policy_binary_artifact_paths: list[str]
@@ -302,16 +302,44 @@ class ProjectPolicyResults(schema.Strict):
policy_mailto_addresses: list[str]
policy_manual_vote: bool
policy_min_hours: int
- policy_pause_for_rm: bool
policy_preserve_download_files: bool
policy_release_checklist: str
policy_source_artifact_paths: list[str]
policy_start_vote_subject: str
policy_start_vote_template: str
- policy_strict_checking: bool
policy_vote_comment_template: str
+class PolicyUpdateArgs(schema.Strict):
+ project: safe.ProjectKey = schema.example("example")
+ announce_release_subject: str | None = None
+ announce_release_template: str | None = None
+ binary_artifact_paths: list[str] | None = None
+ file_tag_mappings: dict[str, list[str]] | None = None
+ github_compose_workflow_path: list[str] | None = None
+ github_finish_workflow_path: list[str] | None = None
+ github_repository_branch: str | None = None
+ github_repository_name: str | None = None
+ github_vote_workflow_path: list[str] | None = None
+ license_check_mode: sql.LicenseCheckMode | None = None
+ mailto_addresses: list[str] | None = None
+ manual_vote: bool | None = None
+ min_hours: int | None = None
+ preserve_download_files: bool | None = None
+ release_checklist: str | None = None
+ source_artifact_paths: list[str] | None = None
+ source_excludes_lightweight: list[str] | None = None
+ source_excludes_rat: list[str] | None = None
+ start_vote_subject: str | None = None
+ start_vote_template: str | None = None
+ vote_comment_template: str | None = None
+
+
+class PolicyUpdateResults(schema.Strict):
+ endpoint: Literal["/policy/update"] = schema.alias("endpoint")
+ success: Literal[True] = schema.example(True)
+
+
class ProjectReleasesResults(schema.Strict):
endpoint: Literal["/project/releases"] = schema.alias("endpoint")
releases: Sequence[sql.Release]
@@ -325,11 +353,11 @@ class ProjectsListResults(schema.Strict):
class PublisherDistributionRecordArgs(schema.Strict):
publisher: str = schema.example("user")
jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
- version: str = schema.example("0.0.1")
+ version: safe.VersionKey = schema.example("0.0.1")
platform: sql.DistributionPlatform =
schema.example(sql.DistributionPlatform.ARTIFACT_HUB)
- distribution_owner_namespace: str | None = schema.default_example(None,
"example")
- distribution_package: str = schema.example("example")
- distribution_version: str = schema.example("0.0.1")
+ distribution_owner_namespace: safe.Alphanumeric | None =
schema.default_example(None, "example")
+ distribution_package: safe.Alphanumeric = schema.example("example")
+ distribution_version: safe.VersionKey = schema.example("0.0.1")
staging: bool = schema.example(False)
details: bool = schema.example(False)
@@ -356,11 +384,11 @@ class PublisherDistributionRecordResults(schema.Strict):
class PublisherReleaseAnnounceArgs(schema.Strict):
publisher: str = schema.example("user")
jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
- version: str = schema.example("0.0.1")
- revision: str = schema.example("00005")
+ version: safe.VersionKey = schema.example("0.0.1")
+ revision: safe.RevisionNumber = schema.example("00005")
email_to: str = schema.example("[email protected]")
body: str = schema.example("The Apache Example team is pleased to announce
the release of Example 1.0.0...")
- path_suffix: str = schema.example("example/1.0.0")
+ path_suffix: safe.OptionalRelPath = schema.example("example/1.0.0")
class PublisherReleaseAnnounceResults(schema.Strict):
@@ -377,15 +405,15 @@ class PublisherSshRegisterArgs(schema.Strict):
class PublisherSshRegisterResults(schema.Strict):
endpoint: Literal["/publisher/ssh/register"] = schema.alias("endpoint")
fingerprint: str =
schema.example("SHA256:0123456789abcdef0123456789abcdef01234567")
- project: str = schema.example("example")
+ project: safe.ProjectKey = schema.example("example")
expires: int = schema.example(1713547200)
class PublisherVoteResolveArgs(schema.Strict):
publisher: str = schema.example("user")
jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
- version: str = schema.example("0.0.1")
- resolution: Literal["passed", "failed"] = schema.example("passed")
+ version: safe.VersionKey = schema.example("0.0.1")
+ resolution: Literal["passed", "failed", "cancelled"] =
schema.example("passed")
class PublisherVoteResolveResults(schema.Strict):
@@ -394,12 +422,12 @@ class PublisherVoteResolveResults(schema.Strict):
class ReleaseAnnounceArgs(schema.Strict):
- project: str = schema.example("example")
- version: str = schema.example("1.0.0")
- revision: str = schema.example("00005")
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("1.0.0")
+ revision: safe.RevisionNumber = schema.example("00005")
email_to: str = schema.example("[email protected]")
body: str = schema.example("The Apache Example team is pleased to announce
the release of Example 1.0.0...")
- path_suffix: str = schema.example("example/1.0.0")
+ path_suffix: safe.OptionalRelPath = schema.example("example/1.0.0")
class ReleaseAnnounceResults(schema.Strict):
@@ -408,8 +436,8 @@ class ReleaseAnnounceResults(schema.Strict):
class ReleaseDraftDeleteArgs(schema.Strict):
- project: str = schema.example("example")
- version: str = schema.example("0.0.1")
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("0.0.1")
class ReleaseDraftDeleteResults(schema.Strict):
@@ -418,8 +446,8 @@ class ReleaseDraftDeleteResults(schema.Strict):
class ReleaseCreateArgs(schema.Strict):
- project: str = schema.example("example")
- version: str = schema.example("0.0.1")
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("0.0.1")
class ReleaseCreateResults(schema.Strict):
@@ -428,8 +456,8 @@ class ReleaseCreateResults(schema.Strict):
class ReleaseDeleteArgs(schema.Strict):
- project: str = schema.example("example")
- version: str = schema.example("0.0.1")
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("0.0.1")
class ReleaseDeleteResults(schema.Strict):
@@ -466,9 +494,9 @@ class ReleaseRevisionsResults(schema.Strict):
class ReleaseUploadArgs(schema.Strict):
- project: str = schema.example("example")
- version: str = schema.example("0.0.1")
- relpath: str = schema.example("example/0.0.1/example-0.0.1-bin.tar.gz")
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("0.0.1")
+ relpath: safe.RelPath =
schema.example("example/0.0.1/example-0.0.1-bin.tar.gz")
content: str = schema.example("This is the content of the file.")
@@ -558,8 +586,8 @@ class TasksListResults(schema.Strict):
class UserInfoResults(schema.Strict):
endpoint: Literal["/user/info"] = schema.alias("endpoint")
- participant_of: list[str] = schema.example(["committee_name_a",
"committee_name_b"])
- member_of: list[str] = schema.example(["committee_name_a"])
+ participant_of: list[str] = schema.example(["committee_key_a",
"committee_key_b"])
+ member_of: list[str] = schema.example(["committee_key_a"])
class UsersListResults(schema.Strict):
@@ -568,9 +596,9 @@ class UsersListResults(schema.Strict):
class VoteResolveArgs(schema.Strict):
- project: str = schema.example("example")
- version: str = schema.example("0.0.1")
- resolution: Literal["passed", "failed"] = schema.example("passed")
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("0.0.1")
+ resolution: Literal["passed", "failed", "cancelled"] =
schema.example("passed")
class VoteResolveResults(schema.Strict):
@@ -579,9 +607,9 @@ class VoteResolveResults(schema.Strict):
class VoteStartArgs(schema.Strict):
- project: str = schema.example("example")
- version: str = schema.example("0.0.1")
- revision: str = schema.example("00005")
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("0.0.1")
+ revision: safe.RevisionNumber = schema.example("00005")
email_to: str = schema.example("[email protected]")
vote_duration: int = schema.example(10)
subject: str = schema.example("[VOTE] Apache Example 0.0.1 release")
@@ -594,8 +622,8 @@ class VoteStartResults(schema.Strict):
class VoteTabulateArgs(schema.Strict):
- project: str = schema.example("example")
- version: str = schema.example("0.0.1")
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("0.0.1")
class VoteTabulateResults(schema.Strict):
diff --git a/src/atrclient/models/attestable.py
b/src/atrclient/models/attestable.py
index 3cff655..510a105 100644
--- a/src/atrclient/models/attestable.py
+++ b/src/atrclient/models/attestable.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-from typing import Annotated, Literal
+from typing import Annotated, Any, Literal
import pydantic
@@ -25,9 +25,36 @@ from . import schema
class HashEntry(schema.Strict):
size: int
uploaders: list[Annotated[tuple[str, str],
pydantic.BeforeValidator(tuple)]]
+ basenames: list[str] = schema.factory(list)
+
+
+class AttestableChecksV1(schema.Strict):
+ version: Literal[1] = 1
+ checks: list[int] = schema.factory(list)
+
+
+class AttestableChecksV2(schema.Strict):
+ version: Literal[2] = 2
+ checks: dict[str, dict[str, str]] = schema.factory(dict)
class AttestableV1(schema.Strict):
version: Literal[1] = 1
paths: dict[str, str] = schema.factory(dict)
hashes: dict[str, HashEntry] = schema.factory(dict)
+ policy: dict[str, Any] = schema.factory(dict)
+
+
+class PathEntryV2(schema.Strict):
+ content_hash: str
+ classification: str
+
+
+class AttestableV2(schema.Strict):
+ version: Literal[2] = 2
+ hashes: dict[str, HashEntry] = schema.factory(dict)
+ paths: dict[str, PathEntryV2] = schema.factory(dict)
+ policy: dict[str, Any] = schema.factory(dict)
+
+
+type Attestable = AttestableV1 | AttestableV2
diff --git a/src/atrclient/models/distribution.py
b/src/atrclient/models/distribution.py
index 802ddde..c805a89 100644
--- a/src/atrclient/models/distribution.py
+++ b/src/atrclient/models/distribution.py
@@ -19,23 +19,23 @@ import datetime
import pydantic
-from . import basic, schema, sql
+from . import basic, safe, schema, sql
-class ArtifactHubAvailableVersion(schema.Lax):
+class ArtifactHubAvailableVersion(schema.Subset):
ts: int
-class ArtifactHubLink(schema.Lax):
+class ArtifactHubLink(schema.Subset):
url: str | None = None
name: str | None = None
-class ArtifactHubRepository(schema.Lax):
+class ArtifactHubRepository(schema.Subset):
name: str | None = None
-class ArtifactHubResponse(schema.Lax):
+class ArtifactHubResponse(schema.Subset):
available_versions: list[ArtifactHubAvailableVersion] =
pydantic.Field(default_factory=list)
home_url: str | None = None
links: list[ArtifactHubLink] = pydantic.Field(default_factory=list)
@@ -44,45 +44,45 @@ class ArtifactHubResponse(schema.Lax):
repository: ArtifactHubRepository | None = None
-class DockerResponse(schema.Lax):
+class DockerResponse(schema.Subset):
tag_last_pushed: str | None = None
-class GitHubResponse(schema.Lax):
+class GitHubResponse(schema.Subset):
published_at: str | None = None
html_url: str | None = None
-class MavenDoc(schema.Lax):
+class MavenDoc(schema.Subset):
timestamp: int | None = None
-class MavenResponseBody(schema.Lax):
+class MavenResponseBody(schema.Subset):
start: int | None = None
docs: list[MavenDoc] = pydantic.Field(default_factory=list)
-class MavenResponse(schema.Lax):
+class MavenResponse(schema.Subset):
response: MavenResponseBody =
pydantic.Field(default_factory=MavenResponseBody)
-class NpmResponse(schema.Lax):
+class NpmResponse(schema.Subset):
name: str | None = None
time: dict[str, str] = pydantic.Field(default_factory=dict)
homepage: str | None = None
-class PyPIUrl(schema.Lax):
+class PyPIUrl(schema.Subset):
upload_time_iso_8601: str | None = None
url: str | None = None
-class PyPIInfo(schema.Lax):
+class PyPIInfo(schema.Subset):
release_url: str | None = None
project_url: str | None = None
-class PyPIResponse(schema.Lax):
+class PyPIResponse(schema.Subset):
urls: list[PyPIUrl] = pydantic.Field(default_factory=list)
info: PyPIInfo = pydantic.Field(default_factory=PyPIInfo)
@@ -91,11 +91,11 @@ class PyPIResponse(schema.Lax):
# Our previous forms implementation typed platform as Any, which was
insufficient
# And this way we also get nice JSON from the Pydantic model dump
# Including all of the enum properties
-class Data(schema.Lax):
+class Data(schema.Subset):
platform: sql.DistributionPlatform
- owner_namespace: str | None = None
- package: str
- version: str
+ owner_namespace: safe.Alphanumeric | None = None
+ package: safe.Alphanumeric
+ version: safe.VersionKey
details: bool
@pydantic.field_validator("owner_namespace", mode="before")
diff --git a/src/atrclient/models/github.py b/src/atrclient/models/github.py
new file mode 100644
index 0000000..4a4d67e
--- /dev/null
+++ b/src/atrclient/models/github.py
@@ -0,0 +1,77 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import time
+
+import pydantic
+
+from . import schema
+
+
+class TrustedPublisherPayload(schema.Subset):
+ actor: str
+ actor_id: int
+ aud: str
+ base_ref: str
+ check_run_id: str
+ enterprise: str
+ enterprise_id: str
+ event_name: str
+ exp: int | None = None
+ head_ref: str
+ iat: int
+ iss: str
+ job_workflow_ref: str
+ job_workflow_sha: str
+ jti: str
+ nbf: int | None = None
+ ref: str
+ ref_protected: str
+ ref_type: str
+ repository: str
+ repository_owner: str
+ repository_visibility: str
+ run_attempt: str
+ run_number: str
+ runner_environment: str
+ sha: str
+ sub: str
+ workflow: str
+ workflow_ref: str
+ workflow_sha: str
+
+ @pydantic.field_validator("exp")
+ @classmethod
+ def _validate_exp(cls, value: int | None) -> int | None:
+ if value is None:
+ return value
+ now = int(time.time())
+ if now > value:
+ raise ValueError("Token has expired")
+ return value
+
+ @pydantic.field_validator("nbf")
+ @classmethod
+ def _validate_nbf(cls, value: int | None) -> int | None:
+ if value is None:
+ return value
+ now = int(time.time())
+ if value and (now < value):
+ raise ValueError("Token not yet valid")
+ return value
diff --git a/src/atrclient/models/results.py b/src/atrclient/models/results.py
index eeb73b9..cd04e22 100644
--- a/src/atrclient/models/results.py
+++ b/src/atrclient/models/results.py
@@ -19,7 +19,7 @@ from typing import Annotated, Any, Literal
import pydantic
-from . import schema
+from . import safe, schema
class DistributionStatusCheck(schema.Strict):
@@ -105,14 +105,14 @@ class OSVComponent(schema.Strict):
class SBOMOSVScan(schema.Strict):
kind: Literal["sbom_osv_scan"] = schema.Field(alias="kind")
- project_name: str = schema.description("Project name")
- version_name: str = schema.description("Version name")
- revision_number: str = schema.description("Revision number")
+ project_key: safe.ProjectKey = schema.description("Project name")
+ version_key: safe.VersionKey = schema.description("Version name")
+ revision_number: safe.RevisionNumber = schema.description("Revision
number")
bom_version: int | None = schema.Field(
default=None, strict=False, description="BOM Version produced with
scan results"
)
- file_path: str = schema.description("Relative path to the scanned SBOM
file")
- new_file_path: str = schema.Field(default="", strict=False,
description="Relative path to the updated SBOM file")
+ file_path: str = schema.description("Absolute path to the scanned SBOM
file")
+ new_file_path: str = schema.Field(default="", strict=False,
description="Absolute path to the updated SBOM file")
components: list[OSVComponent] = schema.description("Components with
vulnerabilities")
ignored: list[str] = schema.description("Components ignored")
@@ -163,25 +163,35 @@ class SBOMAugment(schema.Strict):
)
+class SBOMConvert(schema.Strict):
+ kind: Literal["sbom_convert"] = schema.Field(alias="kind")
+ path: str = schema.description("The path to the converted SBOM file")
+ bom_version: int | None = schema.Field(
+ default=None,
+ strict=False,
+ description="BOM Version produced by the convert task",
+ )
+
+
class SBOMQsScore(schema.Strict):
kind: Literal["sbom_qs_score"] = schema.Field(alias="kind")
- project_name: str = schema.description("Project name")
- version_name: str = schema.description("Version name")
- revision_number: str = schema.description("Revision number")
- file_path: str = schema.description("Relative path to the scored SBOM
file")
+ project_key: safe.ProjectKey = schema.description("Project name")
+ version_key: safe.VersionKey = schema.description("Version name")
+ revision_number: safe.RevisionNumber = schema.description("Revision
number")
+ file_path: safe.RelPath = schema.description("Relative path to the scored
SBOM file")
report: SbomQsReport
class SBOMToolScore(schema.Strict):
kind: Literal["sbom_tool_score"] = schema.Field(alias="kind")
- project_name: str = schema.description("Project name")
- version_name: str = schema.description("Version name")
- revision_number: str = schema.description("Revision number")
+ project_key: safe.ProjectKey = schema.description("Project name")
+ version_key: safe.VersionKey = schema.description("Version name")
+ revision_number: safe.RevisionNumber = schema.description("Revision
number")
bom_version: int | None = schema.Field(default=None, strict=False,
description="BOM Version scanned")
prev_bom_version: int | None = schema.Field(
default=None, strict=False, description="BOM Version from previous
release"
)
- file_path: str = schema.description("Relative path to the scored SBOM
file")
+ file_path: safe.RelPath = schema.description("Relative path to the scored
SBOM file")
warnings: list[str] = schema.description("Warnings from the SBOM tool")
errors: list[str] = schema.description("Errors from the SBOM tool")
outdated: list[str] | str | None = schema.description("Outdated tool(s)
from the SBOM tool")
@@ -218,7 +228,7 @@ class VoteInitiate(schema.Strict):
kind: Literal["vote_initiate"] = schema.Field(alias="kind")
message: str = schema.description("The message from the vote initiation")
- email_to: str = schema.description("The email address the vote was sent
to")
+ email_to: str = schema.description("The email To address the vote was sent
to")
vote_end: str = schema.description("The date and time the vote ends")
subject: str = schema.description("The subject of the vote email")
mid: str | None = schema.description("The message ID of the vote email")
@@ -241,6 +251,7 @@ Results = Annotated[
| MessageSend
| MetadataUpdate
| SBOMAugment
+ | SBOMConvert
| SBOMGenerateCycloneDX
| SBOMOSVScan
| SBOMQsScore
diff --git a/src/atrclient/models/safe.py b/src/atrclient/models/safe.py
new file mode 100644
index 0000000..3e86f3f
--- /dev/null
+++ b/src/atrclient/models/safe.py
@@ -0,0 +1,324 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import pathlib
+import string
+import unicodedata
+from typing import Annotated, Any, Final
+
+import pydantic
+
+_ALPHANUM: Final = frozenset(string.ascii_letters + string.digits + "-")
+_NUMERIC: Final = frozenset(string.digits)
+_PATH_CHARS: Final = frozenset(string.ascii_letters + string.digits +
"-._+~/()")
+_VERSION_CHARS: Final = _ALPHANUM | frozenset(".+")
+
+
+class SafeType:
+ __slots__ = ("_value",)
+
+ @classmethod
+ def _valid_chars(cls) -> frozenset[str]:
+ # default is the base set; subclasses can override this method
+ return frozenset()
+
+ def _additional_validations(self, value: str):
+ pass
+
+ def __init__(self, value: str) -> None:
+ if not value:
+ raise ValueError("Value cannot be empty")
+
+ _assert_standard_safe_syntax(value)
+
+ if not all(c in self._valid_chars() for c in value):
+ raise ValueError("Value contains invalid characters")
+
+ self._additional_validations(value)
+
+ self._value = value
+
+ def __bool__(self) -> bool:
+ return True
+
+ def __fspath__(self) -> str:
+ return self._value
+
+ def __eq__(self, other: object) -> bool:
+ if isinstance(other, self.__class__):
+ return self._value == other._value
+ return NotImplemented
+
+ def __hash__(self) -> int:
+ return hash(self._value)
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self._value!r})"
+
+ def __str__(self) -> str:
+ return self._value
+
+ @classmethod
+ def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) ->
Any:
+ import pydantic_core.core_schema as core_schema
+
+ return core_schema.no_info_plain_validator_function(
+ lambda v: cls(v) if isinstance(v, str) else v,
+ serialization=core_schema.to_string_ser_schema(),
+ )
+
+ @classmethod
+ def __get_pydantic_json_schema__(cls, _core_schema: Any, _handler: Any) ->
dict[str, Any]:
+ return {"type": "string"}
+
+
+class StatePath:
+ """An absolute path within the managed storage system.
+
+ Tracks the managed root directory and ensures all derived paths remain
within it.
+ The initial path (from get_*_dir) is the root; paths created via / carry
it forward.
+ """
+
+ __slots__ = ("_path", "_root")
+
+ def __init__(self, path: pathlib.Path, root: pathlib.Path | None = None)
-> None:
+ if not path.is_absolute():
+ raise ValueError("Path must be absolute")
+ resolved = path.resolve()
+ managed_root = (root or path).resolve()
+ if not resolved.is_relative_to(managed_root):
+ raise ValueError(f"Path {resolved} is not within managed root
{managed_root}")
+ self._path = path
+ self._root = root or path
+
+ def __fspath__(self) -> str:
+ return str(self._path)
+
+ def __str__(self) -> str:
+ return str(self._path)
+
+ def __truediv__(self, other: str | pathlib.Path | SafeType) -> StatePath:
+ validated = other if isinstance(other, SafeType) else
RelPath(str(other))
+ return StatePath(self._path / validated, self._root)
+
+ def __eq__(self, other: object) -> bool:
+ if isinstance(other, self.__class__):
+ return self._path == other._path
+ return NotImplemented
+
+ @property
+ def parent(self) -> StatePath:
+ """Root-safe parent - cannot traverse outside the original root path"""
+ return StatePath(self._path.parent, self._root)
+
+ @property
+ def path(self) -> pathlib.Path:
+ return self._path
+
+ @property
+ def root(self) -> pathlib.Path:
+ return self._root
+
+ @property
+ def name(self) -> str:
+ return self._path.name
+
+ @classmethod
+ def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) ->
Any:
+ import pydantic_core.core_schema as core_schema
+
+ def _validate(v: Any) -> Any:
+ if isinstance(v, str):
+ return cls(pathlib.Path(v))
+ if isinstance(v, dict) and v.get("__type__") == "StatePath":
+ return cls(pathlib.Path(v["path"]), pathlib.Path(v["root"]))
+ return v
+
+ def _serialize(v: Any) -> Any:
+ return {"__type__": "StatePath", "path": str(v.path), "root":
str(v.root)}
+
+ return core_schema.no_info_plain_validator_function(
+ _validate,
+
serialization=core_schema.plain_serializer_function_ser_schema(_serialize),
+ )
+
+ @classmethod
+ def __get_pydantic_json_schema__(cls, _core_schema: Any, _handler: Any) ->
dict[str, Any]:
+ return {
+ "type": "object",
+ "properties": {
+ "__type__": {"type": "string", "const": "StatePath"},
+ "path": {"type": "string", "format": "path"},
+ "root": {"type": "string", "format": "path"},
+ },
+ "required": ["__type__", "path", "root"],
+ }
+
+
+class Alphanumeric(SafeType):
+ @classmethod
+ def _valid_chars(cls) -> frozenset[str]:
+ # default is the base set; subclasses can override this method
+ return _ALPHANUM
+
+
+class CommitteeKey(Alphanumeric):
+ pass
+
+
+class Numeric(SafeType):
+ @classmethod
+ def _valid_chars(cls) -> frozenset[str]:
+ # default is the base set; subclasses can override this method
+ return _NUMERIC
+
+
+class ProjectKey(Alphanumeric):
+ """A project name that has been validated for safety."""
+
+
+class ReleaseKey(Alphanumeric):
+ """A release name composed from a validated ProjectKey and VersionKey."""
+
+ @classmethod
+ def _valid_chars(cls) -> frozenset[str]:
+ return _VERSION_CHARS
+
+
+class RelPath(SafeType):
+ """A relative file path that has been validated for safety."""
+
+ @classmethod
+ def _valid_chars(cls) -> frozenset[str]:
+ return _PATH_CHARS
+
+ def _additional_validations(self, value: str) -> None:
+ posix_path = pathlib.PurePosixPath(value)
+ windows_path = pathlib.PureWindowsPath(value)
+ if posix_path.is_absolute() or windows_path.is_absolute():
+ raise ValueError("Absolute paths are not allowed")
+ if "//" in value:
+ raise ValueError("Path cannot contain empty segments")
+ for segment in pathlib.Path(value).parts:
+ if segment in (".", ".."):
+ raise ValueError("Path cannot contain directory traversal")
+ if segment in (".git", ".svn"):
+ raise ValueError("Path cannot contain SCM directories")
+ if segment.startswith(".") and not segment.startswith(".atr") and
segment != ".gitkeep":
+ raise ValueError("Path cannot contain dotfiles")
+
+ def as_path(self) -> pathlib.Path:
+ """Return the validated path as a pathlib.Path."""
+ return pathlib.Path(self._value)
+
+ @classmethod
+ def from_path(cls, value: pathlib.Path) -> RelPath:
+ return cls(str(value))
+
+ def append(self, path: str | pathlib.Path) -> RelPath:
+ return RelPath(f"{self!s}/{path!s}")
+
+ def prepend(self, path: str | pathlib.Path) -> RelPath:
+ return RelPath(f"{path!s}/{self!s}")
+
+ def removeprefix(self, prefix: str):
+ return RelPath(self._value.removeprefix(prefix))
+
+ def __lt__(self, other):
+ if not isinstance(other, RelPath):
+ return NotImplemented
+ return self.as_path() < other.as_path()
+
+ def __le__(self, other):
+ if not isinstance(other, RelPath):
+ return NotImplemented
+ return self.as_path() <= other.as_path()
+
+ def __gt__(self, other):
+ if not isinstance(other, RelPath):
+ return NotImplemented
+ return self.as_path() > other.as_path()
+
+ def __ge__(self, other):
+ if not isinstance(other, RelPath):
+ return NotImplemented
+ return self.as_path() >= other.as_path()
+
+
+class RevisionNumber(Numeric):
+ """A revision number that has been validated for safety."""
+
+
+class VersionKey(Alphanumeric):
+ """A version name that has been validated for safety"""
+
+ @classmethod
+ def _valid_chars(cls) -> frozenset[str]:
+ return _VERSION_CHARS
+
+ def _additional_validations(self, value: str):
+ if value[0] not in _ALPHANUM:
+ raise ValueError("A version should start with an alphanumeric
character")
+ if value[-1] not in _ALPHANUM:
+ raise ValueError("A version should end with an alphanumeric
character")
+
+
+def _empty_to_none(v: object) -> object:
+ if isinstance(v, str) and (not v):
+ return None
+ return v
+
+
+def _strip_slashes_or_none(v: object) -> object:
+ """Strip leading/trailing slashes from a path string; return None if only
slashes."""
+ if isinstance(v, str):
+ stripped = v.strip("/")
+ if not stripped:
+ return None
+ return stripped
+ return v
+
+
+type OptionalAlphanumeric = Annotated[
+ Alphanumeric | None,
+ pydantic.BeforeValidator(_strip_slashes_or_none),
+]
+
+type OptionalRelPath = Annotated[
+ RelPath | None,
+ pydantic.BeforeValidator(_strip_slashes_or_none),
+]
+
+type OptionalRevisionNumber = Annotated[
+ RevisionNumber | None,
+ pydantic.BeforeValidator(_empty_to_none),
+]
+
+
+def _assert_standard_safe_syntax(value: str) -> None:
+ if unicodedata.normalize("NFC", value) != value:
+ raise ValueError("Value must be NFC-normalized")
+
+ for c in value:
+ cat = unicodedata.category(c)
+ if cat[0] == "C":
+ raise ValueError("Value contains disallowed control/format
character")
+
+ if cat[0] == "M":
+ raise ValueError("Value contains disallowed combining mark")
diff --git a/src/atrclient/models/schema.py b/src/atrclient/models/schema.py
index f7ac483..13658ca 100644
--- a/src/atrclient/models/schema.py
+++ b/src/atrclient/models/schema.py
@@ -32,6 +32,10 @@ class Strict(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra="forbid", strict=True,
validate_assignment=True)
+class Subset(pydantic.BaseModel):
+ model_config = pydantic.ConfigDict(extra="ignore", strict=False,
validate_assignment=True, validate_by_name=True)
+
+
class Form(pydantic.BaseModel):
model_config = pydantic.ConfigDict(
extra="forbid",
@@ -41,7 +45,7 @@ class Form(pydantic.BaseModel):
str_strip_whitespace=True,
)
- csrf_token: str | None = None
+ csrf_token: str
def alias(alias_name: str) -> Any:
diff --git a/src/atrclient/models/sql.py b/src/atrclient/models/sql.py
index a6e9aed..6346743 100644
--- a/src/atrclient/models/sql.py
+++ b/src/atrclient/models/sql.py
@@ -24,7 +24,7 @@
import dataclasses
import datetime
import enum
-from typing import Any, Final, Literal, Optional, TypeVar
+from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeVar,
overload
import pydantic
import sqlalchemy
@@ -34,7 +34,10 @@ import sqlalchemy.orm as orm
import sqlalchemy.sql.expression as expression
import sqlmodel
-from . import results, schema
+from . import results, safe, schema
+
+if TYPE_CHECKING:
+ from . import distribution
T = TypeVar("T")
@@ -121,7 +124,7 @@ class DistributionPlatform(enum.Enum):
# )
MAVEN = DistributionPlatformValue(
name="Maven Central",
- gh_slug="maven",
+ gh_slug="mavencentral",
template_url="https://repo1.maven.org/maven2/{owner_namespace}/{package}/maven-metadata.xml",
# Below is the old template using the maven search API - but the index
isn't updated quickly enough for us
#
template_url="https://search.maven.org/solrsearch/select?q=g:{owner_namespace}+AND+a:{package}+AND+v:{version}&core=gav&rows=20&wt=json",
@@ -164,6 +167,13 @@ class ProjectStatus(enum.StrEnum):
STANDING = "standing"
+class QuarantineStatus(enum.Enum):
+ STAGING = "STAGING"
+ PENDING = "PENDING"
+ FAILED = "FAILED"
+ ACKNOWLEDGED = "ACKNOWLEDGED"
+
+
class ReleasePhase(enum.StrEnum):
# TODO: Rename these to the UI names?
# COMPOSE, VOTE, FINISH, "DISTRIBUTE"
@@ -201,8 +211,10 @@ class TaskType(enum.StrEnum):
MESSAGE_SEND = "message_send"
METADATA_UPDATE = "metadata_update"
PATHS_CHECK = "paths_check"
+ QUARANTINE_VALIDATE = "quarantine_validate"
RAT_CHECK = "rat_check"
SBOM_AUGMENT = "sbom_augment"
+ SBOM_CONVERT = "sbom_convert"
SBOM_GENERATE_CYCLONEDX = "sbom_generate_cyclonedx"
SBOM_OSV_SCAN = "sbom_osv_scan"
SBOM_QS_SCORE = "sbom_qs_score"
@@ -233,6 +245,14 @@ def pydantic_example(value: Any) ->
dict[Literal["json_schema_extra"], dict[str,
return {"json_schema_extra": {"example": value}}
+class QuarantineFileEntryV1(schema.Strict):
+ version: Literal[1] = 1
+ rel_path: str
+ size_bytes: int
+ content_hash: str
+ errors: list[str] = schema.factory(list)
+
+
class VoteEntry(schema.Strict):
result: bool = schema.Field(alias="result", **pydantic_example(True))
summary: str = schema.Field(alias="summary", **pydantic_example("This is a
summary"))
@@ -284,6 +304,67 @@ class UTCDateTime(sqlalchemy.types.TypeDecorator):
return value
+class SafeJSON(sqlalchemy.types.TypeDecorator):
+ """JSON column that serialises SafeType and StatePath values.
+
+ Use instead of sqlalchemy.JSON whenever the stored value may contain
+ atr.models.safe.SafeType instances (which are not JSON-serialisable by
+ the standard library encoder) or atr.models.safe.StatePath instances
+ (which include a managed root that must survive the round-trip).
+ """
+
+ impl = sqlalchemy.JSON
+ cache_ok = True
+
+ def process_bind_param(self, value, dialect):
+ if value is None:
+ return None
+ if hasattr(value, "model_dump"):
+ return value.model_dump(mode="json")
+ return _safe_json_encode(value)
+
+ def process_result_value(self, value, dialect):
+ if value is None:
+ return None
+ return _safe_json_decode(value)
+
+
+def _safe_json_encode(value: Any) -> Any:
+ """Recursively convert SafeType/StatePath instances to JSON-serialisable
form."""
+ from . import safe
+
+ if isinstance(value, safe.StatePath):
+ return {"__type__": "StatePath", "path": str(value.path), "root":
str(value.root)}
+ if isinstance(value, safe.SafeType):
+ return str(value)
+ if isinstance(value, dict):
+ for k in value:
+ if not isinstance(k, str):
+ raise TypeError(f"Dict key must be str, got
{type(k).__name__!r}: {k!r}")
+ return {k: _safe_json_encode(v) for k, v in value.items()}
+ if isinstance(value, list):
+ return [_safe_json_encode(v) for v in value]
+ return value
+
+
+def _safe_json_decode(value: Any) -> Any:
+ """
+ Reconstruct StatePath instances from tagged dicts produced by
_safe_json_encode.
+ Other types are handled cleanly by Pydantic so just return the value
+ """
+ import pathlib
+
+ from . import safe
+
+ if isinstance(value, dict):
+ if value.get("__type__") == "StatePath":
+ return safe.StatePath(pathlib.Path(value["path"]),
pathlib.Path(value["root"]))
+ return {k: _safe_json_decode(v) for k, v in value.items()}
+ if isinstance(value, list):
+ return [_safe_json_decode(v) for v in value]
+ return value
+
+
class ResultsJSON(sqlalchemy.types.TypeDecorator):
impl = sqlalchemy.JSON
cache_ok = True
@@ -292,7 +373,7 @@ class ResultsJSON(sqlalchemy.types.TypeDecorator):
if value is None:
return None
if hasattr(value, "model_dump"):
- return value.model_dump()
+ return value.model_dump(mode="json")
if isinstance(value, dict):
return value
raise ValueError("Unsupported value for Results column")
@@ -307,6 +388,24 @@ class ResultsJSON(sqlalchemy.types.TypeDecorator):
return None
+_QUARANTINE_FILE_METADATA_ADAPTER: Final =
pydantic.TypeAdapter(list[QuarantineFileEntryV1])
+
+
+class QuarantineFileMetadataJSON(sqlalchemy.types.TypeDecorator):
+ impl = sqlalchemy.JSON
+ cache_ok = True
+
+ def process_bind_param(self, value, dialect):
+ if value is None:
+ return None
+ return _QUARANTINE_FILE_METADATA_ADAPTER.dump_python(value,
mode="json")
+
+ def process_result_value(self, value, dialect):
+ if value is None:
+ return None
+ return _QUARANTINE_FILE_METADATA_ADAPTER.validate_python(value)
+
+
# SQL models
@@ -319,7 +418,7 @@ def example(value: Any) -> dict[Literal["schema_extra"],
dict[str, Any]]:
# KeyLink:
class KeyLink(sqlmodel.SQLModel, table=True):
- committee_name: str = sqlmodel.Field(foreign_key="committee.name",
primary_key=True)
+ committee_key: str = sqlmodel.Field(foreign_key="committee.key",
primary_key=True)
key_fingerprint: str =
sqlmodel.Field(foreign_key="publicsigningkey.fingerprint", primary_key=True)
@@ -329,16 +428,17 @@ class PersonalAccessToken(sqlmodel.SQLModel, table=True):
asfuid: str = sqlmodel.Field(index=True)
token_hash: str = sqlmodel.Field(unique=True)
created: datetime.datetime = sqlmodel.Field(
- default_factory=lambda: datetime.datetime.now(datetime.UTC),
sa_column=sqlalchemy.Column(UTCDateTime)
+ default_factory=lambda: datetime.datetime.now(datetime.UTC),
+ sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
)
- expires: datetime.datetime =
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+ expires: datetime.datetime =
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime, nullable=False))
last_used: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
label: str | None = None
# RevisionCounter:
class RevisionCounter(sqlmodel.SQLModel, table=True):
- release_name: str = sqlmodel.Field(primary_key=True)
+ release_key: str = sqlmodel.Field(primary_key=True)
last_allocated_number: int = sqlmodel.Field(default=0)
@@ -356,11 +456,17 @@ class Task(sqlmodel.SQLModel, table=True):
id: int = sqlmodel.Field(default=None, primary_key=True)
status: TaskStatus = sqlmodel.Field(default=TaskStatus.QUEUED, index=True)
task_type: TaskType
- task_args: Any =
sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+ task_args: Any = sqlmodel.Field(sa_column=sqlalchemy.Column(SafeJSON))
+ inputs_hash: str | None = sqlmodel.Field(
+ default=None,
+ **example("blake3:7f83b1657ff1fc..."),
+ unique=True,
+ index=True,
+ )
asf_uid: str
added: datetime.datetime = sqlmodel.Field(
default_factory=lambda: datetime.datetime.now(datetime.UTC),
- sa_column=sqlalchemy.Column(UTCDateTime, index=True),
+ sa_column=sqlalchemy.Column(UTCDateTime, index=True, nullable=False),
)
scheduled: datetime.datetime | None = sqlmodel.Field(
default=None,
@@ -382,8 +488,8 @@ class Task(sqlmodel.SQLModel, table=True):
# Used for check tasks
# We don't put these in task_args because we want to query them efficiently
- project_name: str | None = sqlmodel.Field(default=None,
foreign_key="project.name")
- version_name: str | None = sqlmodel.Field(default=None, index=True)
+ project_key: str | None = sqlmodel.Field(default=None,
foreign_key="project.key")
+ version_key: str | None = sqlmodel.Field(default=None, index=True)
revision_number: str | None = sqlmodel.Field(default=None, index=True)
primary_rel_path: str | None = sqlmodel.Field(default=None, index=True)
@@ -403,6 +509,11 @@ class Task(sqlmodel.SQLModel, table=True):
if isinstance(self.completed, str):
self.completed =
datetime.datetime.fromisoformat(self.completed.rstrip("Z"))
+ @property
+ def safe_primary_rel_path(self) -> safe.RelPath | None:
+ """Get the typesafe validated relative path for the task, if set."""
+ return safe.RelPath(self.primary_rel_path) if self.primary_rel_path
else None
+
# Create an index on status and added for efficient task claiming
__table_args__ = (
sqlalchemy.Index("ix_task_status_added", "status", "added"),
@@ -440,7 +551,7 @@ class TextValue(sqlmodel.SQLModel, table=True):
class WorkflowSSHKey(sqlmodel.SQLModel, table=True):
fingerprint: str = sqlmodel.Field(primary_key=True, index=True)
key: str = sqlmodel.Field()
- project_name: str = sqlmodel.Field(index=True)
+ project_key: str = sqlmodel.Field(index=True)
asf_uid: str = sqlmodel.Field(index=True)
github_uid: str = sqlmodel.Field(index=True)
github_nid: int = sqlmodel.Field(index=True)
@@ -455,10 +566,8 @@ class WorkflowSSHKey(sqlmodel.SQLModel, table=True):
# Committee: Committee Project PublicSigningKey
class Committee(sqlmodel.SQLModel, table=True):
- # TODO: Consider using key or label for primary string keys
- # Then we can use simply "name" for full_name, and make it str rather than
str | None
- name: str = sqlmodel.Field(unique=True, primary_key=True,
**example("example"))
- full_name: str | None = sqlmodel.Field(default=None, **example("Example"))
+ key: str = sqlmodel.Field(unique=True, primary_key=True,
**example("example"))
+ name: str | None = sqlmodel.Field(default=None, **example("Example"))
# True only if this is an incubator podling with a PPMC
is_podling: bool = sqlmodel.Field(default=False)
@@ -466,13 +575,13 @@ class Committee(sqlmodel.SQLModel, table=True):
# M-1: Committee -> Committee
child_committees: list["Committee"] = sqlmodel.Relationship(
sa_relationship_kwargs=dict(
- backref=orm.backref("parent_committee",
remote_side="Committee.name"),
+ backref=orm.backref("parent_committee",
remote_side="Committee.key"),
),
)
# M-1: Committee -> Committee
# 1-M: Committee -> [Committee]
- parent_committee_name: str | None = sqlmodel.Field(default=None,
foreign_key="committee.name")
+ parent_committee_key: str | None = sqlmodel.Field(default=None,
foreign_key="committee.key")
# parent_committee: Optional["Committee"]
# 1-M: Committee -> [Project]
@@ -480,13 +589,17 @@ class Committee(sqlmodel.SQLModel, table=True):
projects: list["Project"] =
sqlmodel.Relationship(back_populates="committee")
committee_members: list[str] = sqlmodel.Field(
- default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON),
**example(["sbp", "tn", "wave"])
+ default_factory=list,
+ sa_column=sqlalchemy.Column(sqlalchemy.JSON, nullable=False),
+ **example(["sbp", "arm", "wave"]),
)
committers: list[str] = sqlmodel.Field(
- default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON),
**example(["sbp", "tn", "wave"])
+ default_factory=list,
+ sa_column=sqlalchemy.Column(sqlalchemy.JSON, nullable=False),
+ **example(["sbp", "arm", "wave"]),
)
release_managers: list[str] = sqlmodel.Field(
- default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON),
**example(["wave"])
+ default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON,
nullable=False), **example(["wave"])
)
# M-M: Committee -> [PublicSigningKey]
@@ -498,8 +611,8 @@ class Committee(sqlmodel.SQLModel, table=True):
@property
def display_name(self) -> str:
"""Get the display name for the committee."""
- name = self.full_name or self.name.title()
- return f"{name} (PPMC)" if self.is_podling else name
+ name = self.name or self.key.title()
+ return f"{name} (Incubating)" if self.is_podling else name
def see_also(arg: Any) -> None:
@@ -508,18 +621,16 @@ def see_also(arg: Any) -> None:
# Project: Project Committee Release DistributionChannel ReleasePolicy
class Project(sqlmodel.SQLModel, table=True):
- # TODO: Consider using key or label for primary string keys
- # Then we can use simply "name" for full_name, and make it str rather than
str | None
- name: str = sqlmodel.Field(unique=True, primary_key=True,
**example("example"))
+ key: str = sqlmodel.Field(primary_key=True, unique=True,
**example("example"))
# TODO: Ideally full_name would be unique for str only, but that's complex
# We always include "Apache" in the full_name
- full_name: str | None = sqlmodel.Field(default=None, **example("Apache
Example"))
+ name: str | None = sqlmodel.Field(default=None, **example("Apache
Example"))
status: ProjectStatus = sqlmodel.Field(default=ProjectStatus.ACTIVE,
**example(ProjectStatus.ACTIVE))
# M-1: Project -> Project
# 1-M: (Project.child_project is missing, would be Project -> [Project])
- super_project_name: str | None = sqlmodel.Field(default=None,
foreign_key="project.name")
+ super_project_key: str | None = sqlmodel.Field(default=None,
foreign_key="project.key")
# NOTE: Neither "Project" | None nor "Project | None" works
super_project: Optional["Project"] = sqlmodel.Relationship()
@@ -529,7 +640,7 @@ class Project(sqlmodel.SQLModel, table=True):
# M-1: Project -> Committee
# 1-M: Committee -> [Project]
- committee_name: str | None = sqlmodel.Field(default=None,
foreign_key="committee.name", **example("example"))
+ committee_key: str | None = sqlmodel.Field(default=None,
foreign_key="committee.key", **example("example"))
committee: Committee | None =
sqlmodel.Relationship(back_populates="projects")
see_also(Committee.projects)
@@ -551,7 +662,7 @@ class Project(sqlmodel.SQLModel, table=True):
created: datetime.datetime = sqlmodel.Field(
default_factory=lambda: datetime.datetime.now(datetime.UTC),
- sa_column=sqlalchemy.Column(UTCDateTime),
+ sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
**example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
)
created_by: str | None = sqlmodel.Field(default=None, **example("user"))
@@ -559,11 +670,16 @@ class Project(sqlmodel.SQLModel, table=True):
@property
def display_name(self) -> str:
"""Get the display name for the Project."""
- base = self.full_name or self.name
+ base = self.name or str(self.key)
if self.committee and self.committee.is_podling:
return f"{base} (Incubating)"
return base
+ @property
+ def safe_key(self) -> safe.ProjectKey:
+ """Get the typesafe validated name for the Project"""
+ return safe.ProjectKey(self.key)
+
@property
def short_display_name(self) -> str:
"""Get the short display name for the Project."""
@@ -646,10 +762,10 @@ Thanks,
def policy_mailto_addresses(self) -> list[str]:
if ((policy := self.release_policy) is None) or (not
policy.mailto_addresses):
if self.committee is not None:
- return [f"dev@{self.committee.name}.apache.org",
f"private@{self.committee.name}.apache.org"]
+ return [f"dev@{self.committee.key}.apache.org",
f"private@{self.committee.key}.apache.org"]
else:
# TODO: Or raise an error?
- return [f"dev@{self.name}.apache.org",
f"private@{self.name}.apache.org"]
+ return [f"dev@{self.key}.apache.org",
f"private@{self.key}.apache.org"]
return policy.mailto_addresses
@property
@@ -665,12 +781,6 @@ Thanks,
return self.policy_default_min_hours
return policy.min_hours
- @property
- def policy_pause_for_rm(self) -> bool:
- if (policy := self.release_policy) is None:
- return False
- return policy.pause_for_rm
-
@property
def policy_release_checklist(self) -> str:
if ((policy := self.release_policy) is None) or
(policy.release_checklist == ""):
@@ -730,12 +840,10 @@ Thanks,
return policy.source_excludes_rat or []
@property
- def policy_strict_checking(self) -> bool:
- # This is bool, so it should never be None
- # TODO: Should we make it nullable for defaulting?
+ def policy_tagging_spec(self) -> dict[str, Any] | None:
if (policy := self.release_policy) is None:
- return False
- return policy.strict_checking
+ return None
+ return policy.file_tag_mappings
@property
def policy_github_repository_name(self) -> str:
@@ -784,12 +892,13 @@ Thanks,
class Release(sqlmodel.SQLModel, table=True):
# model_config = compat.SQLModelConfig(extra="forbid",
from_attributes=True)
- # We guarantee that "{project.name}-{version}" is unique
- # Therefore we can use that for the name
- name: str = sqlmodel.Field(default="", primary_key=True, unique=True,
**example("example-0.0.1"))
+ # We guarantee that "{project.key}-{version}" is unique
+ # Therefore we can use that for the key
+ key: str = sqlmodel.Field(default="", primary_key=True, unique=True,
**example("example-0.0.1"))
phase: ReleasePhase =
sqlmodel.Field(**example(ReleasePhase.RELEASE_CANDIDATE_DRAFT))
created: datetime.datetime = sqlmodel.Field(
- sa_column=sqlalchemy.Column(UTCDateTime),
**example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC))
+ sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
+ **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
)
released: datetime.datetime | None = sqlmodel.Field(
default=None,
@@ -797,21 +906,25 @@ class Release(sqlmodel.SQLModel, table=True):
**example(datetime.datetime(2025, 6, 1, 1, 2, 3, tzinfo=datetime.UTC)),
)
+ check_cache_key: str | None = sqlmodel.Field(default=None,
**example("ef0ccb0a-3514-4b65-abcd-879850349f74"))
+
# M-1: Release -> Project
# 1-M: Project -> [Release]
- project_name: str = sqlmodel.Field(foreign_key="project.name",
**example("example"))
+ project_key: str = sqlmodel.Field(foreign_key="project.key",
**example("example"))
project: Project = sqlmodel.Relationship(back_populates="releases")
see_also(Project.releases)
package_managers: list[str] = sqlmodel.Field(
- default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON),
**example([])
+ default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON,
nullable=False), **example([])
)
# TODO: Not all releases have a version
# We could either make this str | None, or we could require version to be
set on packages only
# For example, Apache Airflow Providers do not have an overall version
# They have one version per package, i.e. per provider
version: str = sqlmodel.Field(**example("0.0.1"))
- sboms: list[str] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON), **example([]))
+ sboms: list[str] = sqlmodel.Field(
+ default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON,
nullable=False), **example([])
+ )
# 1-1: Release -C-> ReleasePolicy
# 1-1: ReleasePolicy -> Release
@@ -821,7 +934,9 @@ class Release(sqlmodel.SQLModel, table=True):
)
# VoteEntry is a Pydantic model, not a SQL model
- votes: list[VoteEntry] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+ votes: list[VoteEntry] = sqlmodel.Field(
+ default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON,
nullable=False)
+ )
vote_manual: bool = sqlmodel.Field(default=False, **example(False))
vote_started: datetime.datetime | None = sqlmodel.Field(
default=None,
@@ -841,7 +956,7 @@ class Release(sqlmodel.SQLModel, table=True):
back_populates="release",
sa_relationship_kwargs={
"order_by": "Revision.seq",
- "foreign_keys": "[Revision.release_name]",
+ "foreign_keys": "[Revision.release_key]",
"cascade": "all, delete-orphan",
},
)
@@ -858,8 +973,8 @@ class Release(sqlmodel.SQLModel, table=True):
back_populates="release", sa_relationship_kwargs={"cascade": "all,
delete-orphan"}
)
- # The combination of project_name and version must be unique
- __table_args__ = (sqlmodel.UniqueConstraint("project_name", "version",
name="unique_project_version"),)
+ # The combination of key and version must be unique
+ __table_args__ = (sqlmodel.UniqueConstraint("project_key", "version",
name="unique_project_version"),)
@property
def committee(self) -> Committee | None:
@@ -870,6 +985,26 @@ class Release(sqlmodel.SQLModel, table=True):
# return None
return project.committee
+ @property
+ def safe_latest_revision_number(self) -> safe.RevisionNumber:
+ """Get the typesafe validated name for the Release"""
+ return safe.RevisionNumber(self.unwrap_revision_number)
+
+ @property
+ def safe_key(self) -> safe.ReleaseKey:
+ """Get the typesafe validated name for the Release"""
+ return safe.ReleaseKey(self.key)
+
+ @property
+ def safe_project_key(self) -> safe.ProjectKey:
+ """Get the typesafe validated name for the release project"""
+ return safe.ProjectKey(self.project_key)
+
+ @property
+ def safe_version_key(self) -> safe.VersionKey:
+ """Get the typesafe validated name for the release version"""
+ return safe.VersionKey(self.version)
+
@property
def short_display_name(self) -> str:
"""Get the short display name for the release."""
@@ -908,7 +1043,7 @@ class Release(sqlmodel.SQLModel, table=True):
# def latest_revision_number_query(self) -> expression.ScalarSelect[str]:
# return (
# sqlmodel.select(validate_instrumented_attribute(Revision.number))
- # .where(validate_instrumented_attribute(Revision.release_name) ==
Release.name)
+ # .where(validate_instrumented_attribute(Revision.release_key) ==
Release.name)
# .order_by(validate_instrumented_attribute(Revision.seq).desc())
# .limit(1)
# .scalar_subquery()
@@ -925,20 +1060,22 @@ class CheckResult(sqlmodel.SQLModel, table=True):
# M-1: CheckResult -> Release
# 1-M: Release -C-> [CheckResult]
- release_name: str = sqlmodel.Field(
- foreign_key="release.name", ondelete="CASCADE", index=True,
**example("example-0.0.1")
+ release_key: str = sqlmodel.Field(
+ foreign_key="release.key", ondelete="CASCADE", index=True,
**example("example-0.0.1")
)
release: Release = sqlmodel.Relationship(back_populates="check_results")
# We don't call this latest_revision_number, because it might not be the
latest
revision_number: str | None = sqlmodel.Field(default=None, index=True,
**example("00005"))
checker: str = sqlmodel.Field(**example("atr.tasks.checks.license.files"))
+ checker_version: str | None = sqlmodel.Field(default=None, **example("2"))
+
primary_rel_path: str | None = sqlmodel.Field(
default=None, index=True,
**example("apache-example-0.0.1-source.tar.gz")
)
member_rel_path: str | None = sqlmodel.Field(default=None, index=True,
**example("apache-example-0.0.1/pom.xml"))
created: datetime.datetime = sqlmodel.Field(
- sa_column=sqlalchemy.Column(UTCDateTime),
+ sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
**example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
)
status: CheckResultStatus =
sqlmodel.Field(default=CheckResultStatus.SUCCESS,
**example(CheckResultStatus.SUCCESS))
@@ -946,18 +1083,23 @@ class CheckResult(sqlmodel.SQLModel, table=True):
data: Any = sqlmodel.Field(
sa_column=sqlalchemy.Column(sqlalchemy.JSON), **example({"expected":
"...", "found": "..."})
)
- input_hash: str | None = sqlmodel.Field(default=None, index=True,
**example("blake3:7f83b1657ff1fc..."))
+ inputs_hash: str | None = sqlmodel.Field(default=None, index=True,
**example("blake3:7f83b1657ff1fc..."))
cached: bool = sqlmodel.Field(default=False, **example(False))
+ @property
+ def safe_primary_rel_path(self) -> safe.RelPath | None:
+ """Get the typesafe validated relative path for the check result, if
set."""
+ return safe.RelPath(self.primary_rel_path) if self.primary_rel_path
else None
+
class CheckResultIgnore(sqlmodel.SQLModel, table=True):
id: int = sqlmodel.Field(default=None, primary_key=True, **example(123))
asf_uid: str = sqlmodel.Field(**example("user"))
created: datetime.datetime = sqlmodel.Field(
- sa_column=sqlalchemy.Column(UTCDateTime),
+ sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
**example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
)
- project_name: str = sqlmodel.Field(foreign_key="project.name",
**example("example"))
+ project_key: str = sqlmodel.Field(foreign_key="project.key",
**example("example"))
release_glob: str | None = sqlmodel.Field(**example("example-0.0.*"))
revision_number: str | None = sqlmodel.Field(**example("00001"))
checker_glob: str | None =
sqlmodel.Field(**example("atr.tasks.checks.license.files"))
@@ -976,7 +1118,7 @@ class CheckResultIgnore(sqlmodel.SQLModel, table=True):
# Distribution: Release
class Distribution(sqlmodel.SQLModel, table=True):
- release_name: str = sqlmodel.Field(primary_key=True, index=True,
foreign_key="release.name", ondelete="CASCADE")
+ release_key: str = sqlmodel.Field(foreign_key="release.key",
ondelete="CASCADE", primary_key=True, index=True)
release: Release = sqlmodel.Relationship(back_populates="distributions")
platform: DistributionPlatform = sqlmodel.Field(primary_key=True,
index=True)
owner_namespace: str = sqlmodel.Field(primary_key=True, index=True,
default="")
@@ -993,6 +1135,18 @@ class Distribution(sqlmodel.SQLModel, table=True):
# So we do not store it in the database
# api_response: Any =
sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+ def distribution_data(self, details: bool = False) -> "distribution.Data":
+ """Get a distribution data object"""
+ from . import distribution
+
+ return distribution.Data(
+ platform=self.platform,
+ owner_namespace=safe.Alphanumeric(self.owner_namespace),
+ package=safe.Alphanumeric(self.package),
+ version=safe.VersionKey(self.version),
+ details=details,
+ )
+
@property
def identifier(self) -> str:
def normal(text: str) -> str:
@@ -1003,6 +1157,11 @@ class Distribution(sqlmodel.SQLModel, table=True):
version = normal(self.version)
return f"{name}-{package}-{version}"
+ @property
+ def safe_release_key(self) -> safe.ReleaseKey:
+ """Get the typesafe validated name for the distribution release"""
+ return safe.ReleaseKey(self.release_key)
+
@property
def title(self) -> str:
return f"{self.platform.value.name} {self.package} {self.version}"
@@ -1017,7 +1176,7 @@ class Distribution(sqlmodel.SQLModel, table=True):
# is_test: bool = sqlmodel.Field(default=False)
# automation_endpoint: str
#
-# project_name: str = sqlmodel.Field(foreign_key="project.name")
+# project_key: str = sqlmodel.Field(foreign_key="project.name")
#
# # M-1: DistributionChannel -> Project
# # 1-M: Project -> [DistributionChannel]
@@ -1037,7 +1196,8 @@ class PublicSigningKey(sqlmodel.SQLModel, table=True):
length: int = sqlmodel.Field(**example(4096))
# Creation date
created: datetime.datetime = sqlmodel.Field(
- sa_column=sqlalchemy.Column(UTCDateTime),
**example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC))
+ sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
+ **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
)
# Latest self signature
latest_self_signature: datetime.datetime | None = sqlmodel.Field(
@@ -1049,7 +1209,9 @@ class PublicSigningKey(sqlmodel.SQLModel, table=True):
primary_declared_uid: str | None = sqlmodel.Field(**example("User
<[email protected]>"))
# The secondary UIDs declared in the key
secondary_declared_uids: list[str] = sqlmodel.Field(
- default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON),
**example(["User <[email protected]>"])
+ default_factory=list,
+ sa_column=sqlalchemy.Column(sqlalchemy.JSON, nullable=False),
+ **example(["User <[email protected]>"]),
)
# The UID used by Apache, if available
apache_uid: str | None = sqlmodel.Field(**example("user"))
@@ -1073,24 +1235,100 @@ class PublicSigningKey(sqlmodel.SQLModel, table=True):
self.expires =
datetime.datetime.fromisoformat(self.expires.rstrip("Z"))
+# Quarantined: Release
+class Quarantined(sqlmodel.SQLModel, table=True):
+ id: int | None = sqlmodel.Field(default=None, primary_key=True)
+
+ # M-1: Quarantined -> Release
+ release_key: str = sqlmodel.Field(
+ foreign_key="release.key", ondelete="CASCADE", index=True,
**example("example-0.0.1")
+ )
+ release: Release = sqlmodel.Relationship(
+ sa_relationship_kwargs={
+ "foreign_keys": "[Quarantined.release_key]",
+ },
+ )
+
+ asf_uid: str = sqlmodel.Field(**example("user"))
+ prior_revision_key: str | None = sqlmodel.Field(default=None,
**example("example-0.0.1 00005"))
+ status: QuarantineStatus = sqlmodel.Field(
+ default=QuarantineStatus.PENDING, index=True,
**example(QuarantineStatus.PENDING)
+ )
+ token: str = sqlmodel.Field(**example("0123456789abcdef0123456789abcdef"))
+ created: datetime.datetime = sqlmodel.Field(
+ default_factory=lambda: datetime.datetime.now(datetime.UTC),
+ sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
+ **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
+ )
+ completed: datetime.datetime | None = sqlmodel.Field(
+ default=None,
+ sa_column=sqlalchemy.Column(UTCDateTime, nullable=True),
+ **example(datetime.datetime(2025, 5, 1, 1, 32, 3,
tzinfo=datetime.UTC)),
+ )
+ file_metadata: list[QuarantineFileEntryV1] | None = sqlmodel.Field(
+ default=None, sa_column=sqlalchemy.Column(QuarantineFileMetadataJSON)
+ )
+ use_check_cache: bool = sqlmodel.Field(default=True, **example(True))
+ description: str | None = sqlmodel.Field(default=None, **example("Upload
from web compose flow"))
+
+ def model_post_init(self, _context):
+ if isinstance(self.created, str):
+ self.created =
datetime.datetime.fromisoformat(self.created.rstrip("Z"))
+
+ if isinstance(self.completed, str):
+ self.completed =
datetime.datetime.fromisoformat(self.completed.rstrip("Z"))
+
+ if isinstance(self.status, str):
+ self.status = QuarantineStatus(self.status)
+
+
+# ReleaseFileState: Revision
+class ReleaseFileState(sqlmodel.SQLModel, table=True):
+ release_key: str = sqlmodel.Field(primary_key=True,
**example("example-0.0.1"))
+ path: str = sqlmodel.Field(primary_key=True,
**example("apache-example-0.0.1-src.tar.gz"))
+ since_revision_seq: int = sqlmodel.Field(primary_key=True, **example(1))
+ present: bool = sqlmodel.Field(**example(True))
+ content_hash: str | None = sqlmodel.Field(default=None,
**example("blake3:7f83b1657ff1fc..."))
+ classification: str | None = sqlmodel.Field(default=None,
**example("source"))
+
+ __table_args__ = (
+ sqlalchemy.ForeignKeyConstraint(
+ ["release_key", "since_revision_seq"],
+ ["revision.release_key", "revision.seq"],
+ ondelete="CASCADE",
+ ),
+ sqlalchemy.CheckConstraint(
+ """
+ (
+ (present = 1 AND content_hash IS NOT NULL AND classification
IS NOT NULL)
+ OR
+ (present = 0 AND content_hash IS NULL AND classification IS
NULL)
+ )
+ """,
+ name="valid_release_file_state",
+ ),
+ )
+
+
# ReleasePolicy: Project
class ReleasePolicy(sqlmodel.SQLModel, table=True):
id: int = sqlmodel.Field(default=None, primary_key=True)
- mailto_addresses: list[str] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+ mailto_addresses: list[str] = sqlmodel.Field(
+ default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON,
nullable=False)
+ )
manual_vote: bool = sqlmodel.Field(default=False)
min_hours: int | None = sqlmodel.Field(default=None)
release_checklist: str = sqlmodel.Field(default="")
vote_comment_template: str = sqlmodel.Field(default="")
- pause_for_rm: bool = sqlmodel.Field(default=False)
start_vote_subject: str = sqlmodel.Field(default="")
start_vote_template: str = sqlmodel.Field(default="")
announce_release_subject: str = sqlmodel.Field(default="")
announce_release_template: str = sqlmodel.Field(default="")
binary_artifact_paths: list[str] = sqlmodel.Field(
- default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+ default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON,
nullable=False)
)
source_artifact_paths: list[str] = sqlmodel.Field(
- default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+ default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON,
nullable=False)
)
license_check_mode: LicenseCheckMode =
sqlmodel.Field(default=LicenseCheckMode.BOTH)
source_excludes_lightweight: list[str] = sqlmodel.Field(
@@ -1099,7 +1337,6 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
source_excludes_rat: list[str] = sqlmodel.Field(
default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON,
nullable=False)
)
- strict_checking: bool = sqlmodel.Field(default=False)
github_repository_name: str = sqlmodel.Field(default="")
github_repository_branch: str = sqlmodel.Field(default="")
github_compose_workflow_path: list[str] = sqlmodel.Field(
@@ -1128,7 +1365,6 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
min_hours=self.min_hours,
release_checklist=self.release_checklist,
vote_comment_template=self.vote_comment_template,
- pause_for_rm=self.pause_for_rm,
start_vote_subject=self.start_vote_subject,
start_vote_template=self.start_vote_template,
announce_release_subject=self.announce_release_subject,
@@ -1138,7 +1374,6 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
license_check_mode=self.license_check_mode,
source_excludes_lightweight=list(self.source_excludes_lightweight),
source_excludes_rat=list(self.source_excludes_rat),
- strict_checking=self.strict_checking,
github_repository_name=self.github_repository_name,
github_repository_branch=self.github_repository_branch,
github_compose_workflow_path=list(self.github_compose_workflow_path),
@@ -1150,15 +1385,15 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
# Revision: Release
class Revision(sqlmodel.SQLModel, table=True):
- name: str = sqlmodel.Field(default="", primary_key=True, unique=True,
**example("example-0.0.1 00002"))
+ key: str = sqlmodel.Field(default="", primary_key=True, unique=True,
**example("example-0.0.1 00002"))
# M-1: Revision -> Release
# 1-M: Release -C-> [Revision]
- release_name: str | None = sqlmodel.Field(default=None,
foreign_key="release.name", **example("example-0.0.1"))
+ release_key: str | None = sqlmodel.Field(default=None,
foreign_key="release.key", **example("example-0.0.1"))
release: Release = sqlmodel.Relationship(
back_populates="revisions",
sa_relationship_kwargs={
- "foreign_keys": "[Revision.release_name]",
+ "foreign_keys": "[Revision.release_key]",
},
)
@@ -1169,21 +1404,19 @@ class Revision(sqlmodel.SQLModel, table=True):
asfuid: str = sqlmodel.Field(**example("user"))
created: datetime.datetime = sqlmodel.Field(
default_factory=lambda: datetime.datetime.now(datetime.UTC),
- sa_column=sqlalchemy.Column(UTCDateTime),
+ sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
**example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
)
phase: ReleasePhase =
sqlmodel.Field(**example(ReleasePhase.RELEASE_CANDIDATE_DRAFT))
# 1-1: Revision -> Revision
# 1-1: Revision -> Revision
- parent_name: str | None = sqlmodel.Field(
- default=None, foreign_key="revision.name", **example("example-0.0.1
00001")
- )
+ parent_key: str | None = sqlmodel.Field(default=None,
foreign_key="revision.key", **example("example-0.0.1 00001"))
parent: Optional["Revision"] = sqlmodel.Relationship(
sa_relationship_kwargs=dict(
- remote_side=lambda: Revision.name,
+ remote_side=lambda: Revision.key,
uselist=False,
- primaryjoin=lambda: Revision.parent_name == Revision.name,
+ primaryjoin=lambda: Revision.parent_key == Revision.key,
back_populates="child",
)
)
@@ -1193,8 +1426,14 @@ class Revision(sqlmodel.SQLModel, table=True):
child: Optional["Revision"] =
sqlmodel.Relationship(back_populates="parent")
description: str | None = sqlmodel.Field(default=None, **example("This is
a description"))
+ merge_base_revision_key: str | None = sqlmodel.Field(default=None,
**example("example-0.0.1 00001"))
tag: str | None = sqlmodel.Field(default=None, **example("rc1"))
- use_check_cache: bool = sqlmodel.Field(default=True, **example(True))
+ was_quarantined: bool = sqlmodel.Field(default=False, **example(False))
+
+ @property
+ def safe_number(self) -> safe.RevisionNumber:
+ """Get the typesafe validated number for the revision"""
+ return safe.RevisionNumber(self.number)
def model_post_init(self, _context):
if isinstance(self.created, str):
@@ -1204,8 +1443,8 @@ class Revision(sqlmodel.SQLModel, table=True):
self.phase = ReleasePhase(self.phase)
__table_args__ = (
- sqlmodel.UniqueConstraint("release_name", "seq",
name="uq_revision_release_seq"),
- sqlmodel.UniqueConstraint("release_name", "number",
name="uq_revision_release_number"),
+ sqlmodel.UniqueConstraint("release_key", "seq",
name="uq_revision_release_seq"),
+ sqlmodel.UniqueConstraint("release_key", "number",
name="uq_revision_release_number"),
)
@@ -1213,35 +1452,35 @@ class Revision(sqlmodel.SQLModel, table=True):
class WorkflowStatus(sqlmodel.SQLModel, table=True):
workflow_id: str = sqlmodel.Field(primary_key=True, index=True)
run_id: int = sqlmodel.Field(primary_key=True, index=True)
- project_name: str = sqlmodel.Field(index=True)
+ project_key: str = sqlmodel.Field(index=True)
task_id: int | None = sqlmodel.Field(default=None, foreign_key="task.id",
ondelete="SET NULL")
task: Task = sqlmodel.Relationship(back_populates="workflow")
status: str = sqlmodel.Field()
message: str | None = sqlmodel.Field(default=None)
-def revision_name(release_name: str, number: str) -> str:
- return f"{release_name} {number}"
+def revision_key(release_key: safe.ReleaseKey | str, number: str) -> str:
+ return f"{release_key} {number}"
@event.listens_for(Revision, "before_insert")
-def populate_revision_sequence_and_name(
+def populate_revision_sequence_and_key(
_mapper: orm.Mapper, connection: sqlalchemy.engine.Connection, revision:
Revision
) -> None:
- # We require Revision.release_name to have been set
- if not revision.release_name:
+ # We require Revision.release_key to have been set
+ if not revision.release_key:
# Raise an exception
- # Otherwise, Revision.name would be "", Revision.seq 0, and
Revision.number ""
- raise RuntimeError("Cannot populate revision sequence and name without
release_name")
+ # Otherwise, Revision.key would be "", Revision.seq 0, and
Revision.number ""
+ raise RuntimeError("Cannot populate revision sequence and key without
release_key")
# Allocate the next sequence number from the counter table
# This ensures that sequence numbers are never reused, even after release
deletion
# Uses ON CONFLICT DO UPDATE with RETURNING
upsert_stmt = (
sqlite.insert(RevisionCounter)
- .values(release_name=revision.release_name, last_allocated_number=1)
+ .values(release_key=revision.release_key, last_allocated_number=1)
.on_conflict_do_update(
- index_elements=["release_name"],
+ index_elements=["release_key"],
set_={"last_allocated_number":
sqlalchemy.text("last_allocated_number + 1")},
)
.returning(sqlalchemy.literal_column("last_allocated_number"))
@@ -1251,49 +1490,60 @@ def populate_revision_sequence_and_name(
revision.seq = new_seq
revision.number = str(new_seq).zfill(5)
- revision.name = revision_name(revision.release_name, revision.number)
+ revision.key = revision_key(revision.release_key, revision.number)
# Find the actual parent for the parent_name foreign key
# We cannot assume that the parent exists
parent_stmt = (
- sqlmodel.select(validate_instrumented_attribute(Revision.name))
- .where(validate_instrumented_attribute(Revision.release_name) ==
revision.release_name)
+ sqlmodel.select(validate_instrumented_attribute(Revision.key))
+ .where(validate_instrumented_attribute(Revision.release_key) ==
revision.release_key)
.order_by(sqlalchemy.desc(validate_instrumented_attribute(Revision.seq)))
.limit(1)
)
parent_row = connection.execute(parent_stmt).fetchone()
if parent_row is not None:
- revision.parent_name = parent_row[0]
+ revision.parent_key = parent_row[0]
@event.listens_for(Release, "before_insert")
-def check_release_name(_mapper: orm.Mapper, _connection:
sqlalchemy.Connection, release: Release) -> None:
- if release.name == "":
+def check_release_key(_mapper: orm.Mapper, _connection: sqlalchemy.Connection,
release: Release) -> None:
+ if release.key == "":
# Quiet the type checker
- project_name = getattr(release, "project_name", None)
+ project_key = getattr(release, "project_key", None)
version = getattr(release, "version", None)
- if (project_name is None) or (version is None):
- raise ValueError("Cannot generate release name without
project_name and version")
- release.name = release_name(project_name, version)
+ if (project_key is None) or (version is None):
+ raise ValueError("Cannot generate release key without project_key
and version")
+ release.key = release_key(project_key, version)
-def latest_revision_number_query(release_name: str | None = None) ->
expression.ScalarSelect[str]:
- if release_name is None:
- query_release_name = Release.name
+def latest_revision_number_query(release_key: str | None = None) ->
expression.ScalarSelect[str]:
+ if release_key is None:
+ query_release_key = Release.key
else:
- query_release_name = release_name
+ query_release_key = release_key
return (
sqlmodel.select(validate_instrumented_attribute(Revision.number))
- .where(validate_instrumented_attribute(Revision.release_name) ==
query_release_name)
+ .where(validate_instrumented_attribute(Revision.release_key) ==
query_release_key)
.order_by(validate_instrumented_attribute(Revision.seq).desc())
.limit(1)
.scalar_subquery()
)
-def release_name(project_name: str, version_name: str) -> str:
+@overload
+def release_key(project_key: safe.ProjectKey, version_key: safe.VersionKey) ->
safe.ReleaseKey: ...
+
+
+@overload
+def release_key(project_key: str, version_key: str) -> str: ...
+
+
+def release_key(project_key: safe.ProjectKey | str, version_key:
safe.VersionKey | str) -> safe.ReleaseKey | str:
"""Return the release name for a given project and version."""
- return f"{project_name}-{version_name}"
+ key = f"{project_key}-{version_key}"
+ if isinstance(project_key, safe.ProjectKey) and isinstance(version_key,
safe.VersionKey):
+ return safe.ReleaseKey(key)
+ return key
def validate_instrumented_attribute(obj: Any) -> orm.InstrumentedAttribute:
@@ -1305,7 +1555,7 @@ def validate_instrumented_attribute(obj: Any) ->
orm.InstrumentedAttribute:
RELEASE_LATEST_REVISION_NUMBER: Final = (
sqlalchemy.select(validate_instrumented_attribute(Revision.number))
- .where(validate_instrumented_attribute(Revision.release_name) ==
Release.name)
+ .where(validate_instrumented_attribute(Revision.release_key) ==
Release.key)
.order_by(validate_instrumented_attribute(Revision.seq).desc())
.limit(1)
.correlate_except(Revision)
diff --git a/src/atrclient/models/tabulate.py b/src/atrclient/models/tabulate.py
index fdd0ff1..6790ce1 100644
--- a/src/atrclient/models/tabulate.py
+++ b/src/atrclient/models/tabulate.py
@@ -16,7 +16,6 @@
# under the License.
import enum
-from typing import Any, Literal
import pydantic
@@ -37,19 +36,16 @@ class VoteStatus(enum.Enum):
UNKNOWN = "Unknown"
-def example(value: Any) -> dict[Literal["json_schema_extra"], dict[str, Any]]:
- return {"json_schema_extra": {"example": value}}
-
-
class VoteEmail(schema.Strict):
- asf_uid_or_email: str = schema.Field(..., **example("user"))
- from_email: str = schema.Field(..., **example("[email protected]"))
- status: VoteStatus = schema.Field(..., **example(VoteStatus.BINDING))
- asf_eid: str = schema.Field(...,
**example("[email protected]"))
- iso_datetime: str = schema.Field(..., **example("2025-05-01T12:00:00Z"))
- vote: Vote = schema.Field(..., **example(Vote.YES))
- quotation: str = schema.Field(..., **example("+1 (Binding)"))
- updated: bool = schema.Field(..., **example(True))
+ name: str = schema.example("Example User")
+ asf_uid_or_email: str = schema.example("user")
+ from_email: str = schema.example("[email protected]")
+ status: VoteStatus = schema.example(VoteStatus.BINDING)
+ asf_eid: str =
schema.example("[email protected]")
+ iso_datetime: str = schema.example("2025-05-01T12:00:00Z")
+ vote: Vote = schema.example(Vote.YES)
+ quotation: str = schema.example("+1 (Binding)")
+ updated: bool = schema.example(True)
@pydantic.field_validator("status", mode="before")
@classmethod
@@ -63,24 +59,22 @@ class VoteEmail(schema.Strict):
class VoteDetails(schema.Strict):
- start_unixtime: int | None = schema.Field(..., **example(1714435200))
- votes: dict[str, VoteEmail] = schema.Field(
- ...,
- **example(
- {
- "user": VoteEmail(
- asf_uid_or_email="user",
- from_email="[email protected]",
- status=VoteStatus.BINDING,
- asf_eid="[email protected]",
- iso_datetime="2025-05-01T12:00:00Z",
- vote=Vote.YES,
- quotation="+1 (Binding)",
- updated=True,
- )
- }
- ),
+ start_unixtime: int | None = schema.example(1714435200)
+ votes: dict[str, VoteEmail] = schema.example(
+ {
+ "user": VoteEmail(
+ name="Example User",
+ asf_uid_or_email="user",
+ from_email="[email protected]",
+ status=VoteStatus.BINDING,
+ asf_eid="[email protected]",
+ iso_datetime="2025-05-01T12:00:00Z",
+ vote=Vote.YES,
+ quotation="+1 (Binding)",
+ updated=True,
+ )
+ }
)
- summary: dict[str, int] = schema.Field(..., **example({"user": 1}))
- passed: bool = schema.Field(..., **example(True))
- outcome: str = schema.Field(..., **example("The vote passed."))
+ summary: dict[str, int] = schema.example({"user": 1})
+ passed: bool = schema.example(True)
+ outcome: str = schema.example("The vote passed.")
diff --git a/src/atrclient/models/attestable.py b/src/atrclient/models/unsafe.py
similarity index 63%
copy from src/atrclient/models/attestable.py
copy to src/atrclient/models/unsafe.py
index 3cff655..417f845 100644
--- a/src/atrclient/models/attestable.py
+++ b/src/atrclient/models/unsafe.py
@@ -14,20 +14,23 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+from typing import NewType
-from typing import Annotated, Literal
-import pydantic
+class UnsafeStr:
+ """A raw string from URL routing that has not been validated."""
-from . import schema
+ __slots__ = ("_value",)
+ def __init__(self, value: str) -> None:
+ self._value = value
-class HashEntry(schema.Strict):
- size: int
- uploaders: list[Annotated[tuple[str, str],
pydantic.BeforeValidator(tuple)]]
+ def __repr__(self) -> str:
+ return f"UnsafeStr({self._value!r})"
+ def __str__(self) -> str:
+ return self._value
-class AttestableV1(schema.Strict):
- version: Literal[1] = 1
- paths: dict[str, str] = schema.factory(dict)
- hashes: dict[str, HashEntry] = schema.factory(dict)
+
+# The Path type exists so we can give Quart a hint for type conversions
+Path = NewType("Path", UnsafeStr)
diff --git a/uv.lock b/uv.lock
index 9cd0820..956f741 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12"
[options]
-exclude-newer = "2026-04-06T16:33:00Z"
+exclude-newer = "2026-04-06T16:43:00Z"
[[package]]
name = "aiohappyeyeballs"
@@ -136,7 +136,7 @@ wheels = [
[[package]]
name = "apache-trusted-releases"
-version = "0.20260406.1633"
+version = "0.20260406.1643"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]