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-trusted-release.git
The following commit(s) were added to refs/heads/main by this push:
new 64bdecc Organise and document the database models
64bdecc is described below
commit 64bdecc7ed39ca3eba069c75a5e88ac132f36ba9
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Jul 11 18:53:09 2025 +0100
Organise and document the database models
---
atr/db/models.py | 760 ++++++++++++++++++++++++++++++-------------------------
1 file changed, 422 insertions(+), 338 deletions(-)
diff --git a/atr/db/models.py b/atr/db/models.py
index f7db4b0..bf2ef45 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -18,6 +18,8 @@
"""The data models to be persisted in the database."""
# NOTE: We can't use symbolic annotations here because sqlmodel doesn't
support them
+# https://github.com/fastapi/sqlmodel/issues/196
+# https://github.com/fastapi/sqlmodel/pull/778/files
# from __future__ import annotations
import datetime
@@ -45,6 +47,85 @@ sqlmodel.SQLModel.metadata = sqlalchemy.MetaData(
)
+# Enumerations
+
+
+class CheckResultStatus(str, enum.Enum):
+ EXCEPTION = "exception"
+ FAILURE = "failure"
+ SUCCESS = "success"
+ WARNING = "warning"
+
+
+class ProjectStatus(str, enum.Enum):
+ ACTIVE = "active"
+ DORMANT = "dormant"
+ RETIRED = "retired"
+ STANDING = "standing"
+
+
+class ReleasePhase(str, enum.Enum):
+ # Step 1: The candidate files are added from external sources and checked
by ATR
+ RELEASE_CANDIDATE_DRAFT = "release_candidate_draft"
+ # Step 2: The project members are voting on the candidate release
+ RELEASE_CANDIDATE = "release_candidate"
+ # Step 3: The release files are being put in place
+ RELEASE_PREVIEW = "release_preview"
+ # Step 4: The release has been announced
+ RELEASE = "release"
+
+
+class TaskStatus(str, enum.Enum):
+ """Status of a task in the task queue."""
+
+ QUEUED = "queued"
+ ACTIVE = "active"
+ COMPLETED = "completed"
+ FAILED = "failed"
+
+
+class TaskType(str, enum.Enum):
+ HASHING_CHECK = "hashing_check"
+ KEYS_IMPORT_FILE = "keys_import_file"
+ LICENSE_FILES = "license_files"
+ LICENSE_HEADERS = "license_headers"
+ MESSAGE_SEND = "message_send"
+ PATHS_CHECK = "paths_check"
+ RAT_CHECK = "rat_check"
+ SBOM_GENERATE_CYCLONEDX = "sbom_generate_cyclonedx"
+ SIGNATURE_CHECK = "signature_check"
+ SVN_IMPORT_FILES = "svn_import_files"
+ TARGZ_INTEGRITY = "targz_integrity"
+ TARGZ_STRUCTURE = "targz_structure"
+ VOTE_INITIATE = "vote_initiate"
+ ZIPFORMAT_INTEGRITY = "zipformat_integrity"
+ ZIPFORMAT_STRUCTURE = "zipformat_structure"
+
+
+class UserRole(str, enum.Enum):
+ COMMITTEE_MEMBER = "committee_member"
+ RELEASE_MANAGER = "release_manager"
+ COMMITTER = "committer"
+ VISITOR = "visitor"
+ ASF_MEMBER = "asf_member"
+ SYSADMIN = "sysadmin"
+
+
+# Pydantic models
+
+
+class VoteEntry(schema.Strict):
+ result: bool
+ summary: str
+ binding_votes: int
+ community_votes: int
+ start: datetime.datetime
+ end: datetime.datetime
+
+
+# Type decorators
+
+
class UTCDateTime(sqlalchemy.types.TypeDecorator):
"""
A custom column type to store datetime in sqlite.
@@ -100,70 +181,105 @@ class ResultsJSON(sqlalchemy.types.TypeDecorator):
return None
-class UserRole(str, enum.Enum):
- COMMITTEE_MEMBER = "committee_member"
- RELEASE_MANAGER = "release_manager"
- COMMITTER = "committer"
- VISITOR = "visitor"
- ASF_MEMBER = "asf_member"
- SYSADMIN = "sysadmin"
+# SQL models
+
+# SQL models with no dependencies
+# KeyLink:
class KeyLink(sqlmodel.SQLModel, table=True):
committee_name: str = sqlmodel.Field(foreign_key="committee.name",
primary_key=True)
key_fingerprint: str =
sqlmodel.Field(foreign_key="publicsigningkey.fingerprint", primary_key=True)
-class PublicSigningKey(sqlmodel.SQLModel, table=True):
- # The fingerprint must be stored as lowercase hex
- fingerprint: str = sqlmodel.Field(primary_key=True, unique=True)
- # The algorithm is an RFC 4880 algorithm ID
- algorithm: int
- # Key length in bits
- length: int
- # Creation date
- created: datetime.datetime =
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
- # Latest self signature
- latest_self_signature: datetime.datetime | None = sqlmodel.Field(
- default=None, sa_column=sqlalchemy.Column(UTCDateTime)
- )
- # Expiration date
- expires: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
- # The primary UID declared in the key
- primary_declared_uid: str | None
- # The secondary UIDs declared in the key
- secondary_declared_uids: list[str] = sqlmodel.Field(
- default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+# PersonalAccessToken:
+class PersonalAccessToken(sqlmodel.SQLModel, table=True):
+ id: int | None = sqlmodel.Field(default=None, primary_key=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)
)
- # The UID used by Apache, if available
- apache_uid: str | None
- # The ASCII armored key
- ascii_armored_key: str
- # The committees that use this key
- committees: list["Committee"] =
sqlmodel.Relationship(back_populates="public_signing_keys", link_model=KeyLink)
+ expires: datetime.datetime =
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+ last_used: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
+ label: str | None = None
-class ReleasePolicy(sqlmodel.SQLModel, table=True):
+# SSHKey:
+class SSHKey(sqlmodel.SQLModel, table=True):
+ fingerprint: str = sqlmodel.Field(primary_key=True)
+ key: str
+ asf_uid: str
+
+
+# Task:
+class Task(sqlmodel.SQLModel, table=True):
+ """A task in the task queue."""
+
id: int = sqlmodel.Field(default=None, primary_key=True)
- mailto_addresses: list[str] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
- manual_vote: bool = sqlmodel.Field(default=False)
- min_hours: int | None = sqlmodel.Field(default=None)
- release_checklist: str = sqlmodel.Field(default="")
- pause_for_rm: bool = sqlmodel.Field(default=False)
- start_vote_template: 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)
+ status: TaskStatus = sqlmodel.Field(default=TaskStatus.QUEUED, index=True)
+ task_type: TaskType
+ task_args: Any =
sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+ added: datetime.datetime = sqlmodel.Field(
+ default_factory=lambda: datetime.datetime.now(datetime.UTC),
+ sa_column=sqlalchemy.Column(UTCDateTime, index=True),
)
- source_artifact_paths: list[str] = sqlmodel.Field(
- default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+ started: datetime.datetime | None = sqlmodel.Field(
+ default=None,
+ sa_column=sqlalchemy.Column(UTCDateTime),
)
- strict_checking: bool = sqlmodel.Field(default=False)
+ pid: int | None = None
+ completed: datetime.datetime | None = sqlmodel.Field(
+ default=None,
+ sa_column=sqlalchemy.Column(UTCDateTime),
+ )
+ result: results.Results | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(ResultsJSON))
+ error: str | None = None
+
+ # 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)
+ revision_number: str | None = sqlmodel.Field(default=None, index=True)
+ primary_rel_path: str | None = sqlmodel.Field(default=None, index=True)
+
+ # Create an index on status and added for efficient task claiming
+ __table_args__ = (
+ sqlalchemy.Index("ix_task_status_added", "status", "added"),
+ # Ensure valid status transitions:
+ # - QUEUED can transition to ACTIVE
+ # - ACTIVE can transition to COMPLETED or FAILED
+ # - COMPLETED and FAILED are terminal states
+ sqlalchemy.CheckConstraint(
+ """
+ (
+ -- Initial state is always valid
+ status = 'QUEUED'
+ -- QUEUED -> ACTIVE requires setting started time and pid
+ OR (status = 'ACTIVE' AND started IS NOT NULL AND pid IS NOT
NULL)
+ -- ACTIVE -> COMPLETED requires setting completed time and
result
+ OR (status = 'COMPLETED' AND completed IS NOT NULL AND result
IS NOT NULL)
+ -- ACTIVE -> FAILED requires setting completed time and error
(result optional)
+ OR (status = 'FAILED' AND completed IS NOT NULL AND error IS
NOT NULL)
+ )
+ """,
+ name="valid_task_status_transitions",
+ ),
+ )
+
+
+# TextValue:
+class TextValue(sqlmodel.SQLModel, table=True):
+ # Composite primary key, automatically handled by SQLModel
+ ns: str = sqlmodel.Field(primary_key=True, index=True)
+ key: str = sqlmodel.Field(primary_key=True, index=True)
+ value: str = sqlmodel.Field()
+
- # One-to-One: A release policy is associated with a project
- project: "Project" = sqlmodel.Relationship(back_populates="release_policy")
+# SQL core models
+# 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
@@ -172,22 +288,32 @@ class Committee(sqlmodel.SQLModel, table=True):
# True only if this is an incubator podling with a PPMC
is_podling: bool = sqlmodel.Field(default=False)
- # One-to-many: A committee can have multiple child committees, each child
committee belongs to one parent committee
+ # 1-M: Committee -> [Committee]
+ # M-1: Committee -> Committee
child_committees: list["Committee"] = sqlmodel.Relationship(
sa_relationship_kwargs=dict(
backref=orm.backref("parent_committee",
remote_side="Committee.name"),
),
)
+
+ # M-1: Committee -> Committee
+ # 1-M: Committee -> [Committee]
parent_committee_name: str | None = sqlmodel.Field(default=None,
foreign_key="committee.name")
- # One-to-many: A committee can have multiple projects, each project
belongs to one committee
+ # parent_committee: Optional["Committee"]
+
+ # 1-M: Committee -> [Project]
+ # M-1: Project -> Committee
projects: list["Project"] =
sqlmodel.Relationship(back_populates="committee")
committee_members: list[str] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
committers: list[str] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
release_managers: list[str] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
- # Many-to-many: A committee can have multiple signing keys, and a signing
key can belong to multiple committees
- public_signing_keys: list[PublicSigningKey] =
sqlmodel.Relationship(back_populates="committees", link_model=KeyLink)
+ # M-M: Committee -> [PublicSigningKey]
+ # M-M: PublicSigningKey -> [Committee]
+ public_signing_keys: list["PublicSigningKey"] = sqlmodel.Relationship(
+ back_populates="committees", link_model=KeyLink
+ )
@property
def display_name(self) -> str:
@@ -196,13 +322,11 @@ class Committee(sqlmodel.SQLModel, table=True):
return f"{name} (PPMC)" if self.is_podling else name
-class ProjectStatus(str, enum.Enum):
- ACTIVE = "active"
- DORMANT = "dormant"
- RETIRED = "retired"
- STANDING = "standing"
+def see_also(arg: Any) -> None:
+ pass
+# 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
@@ -212,6 +336,9 @@ class Project(sqlmodel.SQLModel, table=True):
full_name: str | None = sqlmodel.Field(default=None)
status: ProjectStatus = sqlmodel.Field(default=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")
# NOTE: Neither "Project" | None nor "Project | None" works
super_project: Optional["Project"] = sqlmodel.Relationship()
@@ -220,19 +347,25 @@ class Project(sqlmodel.SQLModel, table=True):
category: str | None = sqlmodel.Field(default=None)
programming_languages: str | None = sqlmodel.Field(default=None)
- # Many-to-one: A project belongs to one committee, a committee can have
multiple projects
+ # M-1: Project -> Committee
+ # 1-M: Committee -> [Project]
committee_name: str | None = sqlmodel.Field(default=None,
foreign_key="committee.name")
committee: Committee | None =
sqlmodel.Relationship(back_populates="projects")
+ see_also(Committee.projects)
- # One-to-many: A project can have multiple releases, each release belongs
to one project
+ # 1-M: Project -> [Release]
+ # M-1: Release -> Project
+ # see_also(Release.project)
releases: list["Release"] = sqlmodel.Relationship(back_populates="project")
- # One-to-many: A project can have multiple distribution channels, each
channel belongs to one project
+ # 1-M: Project -> [DistributionChannel]
+ # M-1: DistributionChannel -> Project
distribution_channels: list["DistributionChannel"] =
sqlmodel.Relationship(back_populates="project")
- # Many-to-one: A Project can have one release policy, a release policy can
be used by multiple entities
+ # 1-1: Project -C-> ReleasePolicy
+ # 1-1: ReleasePolicy -> Project
release_policy_id: int | None = sqlmodel.Field(default=None,
foreign_key="releasepolicy.id", ondelete="CASCADE")
- release_policy: ReleasePolicy | None = sqlmodel.Relationship(
+ release_policy: Optional["ReleasePolicy"] = sqlmodel.Relationship(
cascade_delete=True, sa_relationship_kwargs={"cascade": "all,
delete-orphan", "single_parent": True}
)
@@ -254,7 +387,7 @@ class Project(sqlmodel.SQLModel, table=True):
"""Get the short display name for the Project."""
return self.display_name.removeprefix("Apache ")
- async def releases_by_phase(self, phase: "ReleasePhase") ->
list["Release"]:
+ async def releases_by_phase(self, phase: ReleasePhase) -> list["Release"]:
"""Get the releases for the project by phase."""
import atr.db as db
@@ -425,52 +558,219 @@ Thanks,
return policy.strict_checking
-class DistributionChannel(sqlmodel.SQLModel, table=True):
- id: int = sqlmodel.Field(default=None, primary_key=True)
- name: str = sqlmodel.Field(index=True, unique=True)
- url: str
- credentials: str
- is_test: bool = sqlmodel.Field(default=False)
- automation_endpoint: str
-
- # Many-to-one: A distribution channel belongs to one project, a project
can have multiple channels
- project_name: str = sqlmodel.Field(foreign_key="project.name")
- project: Project =
sqlmodel.Relationship(back_populates="distribution_channels")
+# Release: Project ReleasePolicy Revision CheckResult
+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)
+ phase: ReleasePhase
+ created: datetime.datetime =
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+ released: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
-class VoteEntry(schema.Strict):
- result: bool
- summary: str
- binding_votes: int
- community_votes: int
- start: datetime.datetime
- end: datetime.datetime
+ # M-1: Release -> Project
+ # 1-M: Project -> [Release]
+ project_name: str = sqlmodel.Field(foreign_key="project.name")
+ 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))
+ # 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
+ sboms: list[str] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-class ReleasePhase(str, enum.Enum):
- # Step 1: The candidate files are added from external sources and checked
by ATR
- RELEASE_CANDIDATE_DRAFT = "release_candidate_draft"
- # Step 2: The project members are voting on the candidate release
- RELEASE_CANDIDATE = "release_candidate"
- # Step 3: The release files are being put in place
- RELEASE_PREVIEW = "release_preview"
- # Step 4: The release has been announced
- RELEASE = "release"
+ # 1-1: Release -C-> ReleasePolicy
+ # 1-1: ReleasePolicy -> Release
+ release_policy_id: int | None = sqlmodel.Field(default=None,
foreign_key="releasepolicy.id")
+ release_policy: Optional["ReleasePolicy"] = sqlmodel.Relationship(
+ cascade_delete=True, sa_relationship_kwargs={"cascade": "all,
delete-orphan", "single_parent": True}
+ )
+ # VoteEntry is a Pydantic model, not a SQL model
+ votes: list[VoteEntry] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+ vote_manual: bool = sqlmodel.Field(default=False)
+ vote_started: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
+ vote_resolved: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
+ podling_thread_id: str | None = sqlmodel.Field(default=None)
-def revision_name(release_name: str, number: str) -> str:
- return f"{release_name} {number}"
+ # 1-M: Release -C-> [Revision]
+ # M-1: Revision -> Release
+ revisions: list["Revision"] = sqlmodel.Relationship(
+ back_populates="release",
+ sa_relationship_kwargs={
+ "order_by": "Revision.seq",
+ "foreign_keys": "[Revision.release_name]",
+ "cascade": "all, delete-orphan",
+ },
+ )
+
+ # 1-M: Release -C-> [CheckResult]
+ # M-1: CheckResult -> Release
+ check_results: list["CheckResult"] = sqlmodel.Relationship(
+ 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"),)
+
+ @property
+ def committee(self) -> Committee | None:
+ """Get the committee for the release."""
+ project = self.project
+ if project is None:
+ return None
+ return project.committee
+
+ @property
+ def short_display_name(self) -> str:
+ """Get the short display name for the release."""
+ return f"{self.project.short_display_name} {self.version}"
+
+ @property
+ def unwrap_revision_number(self) -> str:
+ """Get the revision number for the release, or raise an exception."""
+ number = self.latest_revision_number
+ if number is None:
+ raise ValueError("Release has no revisions")
+ return number
+
+ @pydantic.computed_field # type: ignore[prop-decorator]
+ @property
+ def latest_revision_number(self) -> str | None:
+ """Get the latest revision number for the release."""
+ # The session must still be active for this to work
+ number = getattr(self, "_latest_revision_number", None)
+ if not (isinstance(number, str) or (number is None)):
+ raise ValueError("Latest revision number is not a str or None")
+ return number
+
+ # NOTE: This does not work
+ # But it we set it with Release.latest_revision_number_query = ..., it
might work
+ # Not clear that we'd want to do that, though
+ # @property
+ # 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)
+ # .order_by(validate_instrumented_attribute(Revision.seq).desc())
+ # .limit(1)
+ # .scalar_subquery()
+ # )
+
+
+# SQL models referencing Committee, Project, or Release
+
+
+# CheckResult: Release
+class CheckResult(sqlmodel.SQLModel, table=True):
+ id: int = sqlmodel.Field(default=None, primary_key=True)
+
+ # M-1: CheckResult -> Release
+ # 1-M: Release -C-> [CheckResult]
+ release_name: str = sqlmodel.Field(foreign_key="release.name",
ondelete="CASCADE")
+ 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)
+ checker: str
+ primary_rel_path: str | None = sqlmodel.Field(default=None, index=True)
+ member_rel_path: str | None = sqlmodel.Field(default=None, index=True)
+ created: datetime.datetime =
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+ status: CheckResultStatus
+ message: str
+ data: Any = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+
+# DistributionChannel: Project
+class DistributionChannel(sqlmodel.SQLModel, table=True):
+ id: int = sqlmodel.Field(default=None, primary_key=True)
+ name: str = sqlmodel.Field(index=True, unique=True)
+ url: str
+ credentials: str
+ is_test: bool = sqlmodel.Field(default=False)
+ automation_endpoint: str
+
+ project_name: str = sqlmodel.Field(foreign_key="project.name")
+
+ # M-1: DistributionChannel -> Project
+ # 1-M: Project -> [DistributionChannel]
+ project: Project =
sqlmodel.Relationship(back_populates="distribution_channels")
+ see_also(Project.distribution_channels)
+
+
+# PublicSigningKey: Committee
+class PublicSigningKey(sqlmodel.SQLModel, table=True):
+ # The fingerprint must be stored as lowercase hex
+ fingerprint: str = sqlmodel.Field(primary_key=True, unique=True)
+ # The algorithm is an RFC 4880 algorithm ID
+ algorithm: int
+ # Key length in bits
+ length: int
+ # Creation date
+ created: datetime.datetime =
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+ # Latest self signature
+ latest_self_signature: datetime.datetime | None = sqlmodel.Field(
+ default=None, sa_column=sqlalchemy.Column(UTCDateTime)
+ )
+ # Expiration date
+ expires: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
+ # The primary UID declared in the key
+ primary_declared_uid: str | None
+ # The secondary UIDs declared in the key
+ secondary_declared_uids: list[str] = sqlmodel.Field(
+ default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+ )
+ # The UID used by Apache, if available
+ apache_uid: str | None
+ # The ASCII armored key
+ ascii_armored_key: str
+
+ # M-M: PublicSigningKey -> [Committee]
+ # M-M: Committee -> [PublicSigningKey]
+ committees: list[Committee] =
sqlmodel.Relationship(back_populates="public_signing_keys", link_model=KeyLink)
+
+
+# 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))
+ manual_vote: bool = sqlmodel.Field(default=False)
+ min_hours: int | None = sqlmodel.Field(default=None)
+ release_checklist: str = sqlmodel.Field(default="")
+ pause_for_rm: bool = sqlmodel.Field(default=False)
+ start_vote_template: 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)
+ )
+ source_artifact_paths: list[str] = sqlmodel.Field(
+ default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+ )
+ strict_checking: bool = sqlmodel.Field(default=False)
+
+ # 1-1: ReleasePolicy -> Project
+ # 1-1: Project -C-> ReleasePolicy
+ project: Project = sqlmodel.Relationship(back_populates="release_policy")
+
+
+# Revision: Release
class Revision(sqlmodel.SQLModel, table=True):
name: str = sqlmodel.Field(default="", primary_key=True, unique=True)
+
+ # M-1: Revision -> Release
+ # 1-M: Release -C-> [Revision]
release_name: str | None = sqlmodel.Field(default=None,
foreign_key="release.name")
- release: "Release" = sqlmodel.Relationship(
+ release: Release = sqlmodel.Relationship(
back_populates="revisions",
sa_relationship_kwargs={
"foreign_keys": "[Revision.release_name]",
},
)
+
seq: int = sqlmodel.Field(default=0)
# This was designed as a property, but it's better for it to be a column
# That way, we can do dynamic Release.latest_revision_number construction
easier
@@ -481,6 +781,8 @@ class Revision(sqlmodel.SQLModel, table=True):
)
phase: ReleasePhase
+ # 1-1: Revision -> Revision
+ # 1-1: Revision -> Revision
parent_name: str | None = sqlmodel.Field(default=None,
foreign_key="revision.name")
parent: Optional["Revision"] = sqlmodel.Relationship(
sa_relationship_kwargs=dict(
@@ -490,6 +792,9 @@ class Revision(sqlmodel.SQLModel, table=True):
back_populates="child",
)
)
+
+ # 1-1: Revision -> Revision
+ # 1-1: Revision -> Revision
child: Optional["Revision"] =
sqlmodel.Relationship(back_populates="parent")
description: str | None = sqlmodel.Field(default=None)
@@ -500,6 +805,10 @@ class Revision(sqlmodel.SQLModel, table=True):
)
+def revision_name(release_name: str, number: str) -> str:
+ return f"{release_name} {number}"
+
+
@event.listens_for(Revision, "before_insert")
def populate_revision_sequence_and_name(
mapper: orm.Mapper, connection: sqlalchemy.engine.Connection, revision:
Revision
@@ -542,86 +851,31 @@ def populate_revision_sequence_and_name(
revision.name = revision_name(revision.release_name, revision.number)
-class TaskStatus(str, enum.Enum):
- """Status of a task in the task queue."""
-
- QUEUED = "queued"
- ACTIVE = "active"
- COMPLETED = "completed"
- FAILED = "failed"
-
-
-class TaskType(str, enum.Enum):
- HASHING_CHECK = "hashing_check"
- KEYS_IMPORT_FILE = "keys_import_file"
- LICENSE_FILES = "license_files"
- LICENSE_HEADERS = "license_headers"
- MESSAGE_SEND = "message_send"
- PATHS_CHECK = "paths_check"
- RAT_CHECK = "rat_check"
- SBOM_GENERATE_CYCLONEDX = "sbom_generate_cyclonedx"
- SIGNATURE_CHECK = "signature_check"
- SVN_IMPORT_FILES = "svn_import_files"
- TARGZ_INTEGRITY = "targz_integrity"
- TARGZ_STRUCTURE = "targz_structure"
- VOTE_INITIATE = "vote_initiate"
- ZIPFORMAT_INTEGRITY = "zipformat_integrity"
- ZIPFORMAT_STRUCTURE = "zipformat_structure"
-
[email protected]_for(Release, "before_insert")
+def check_release_name(_mapper: orm.Mapper, _connection:
sqlalchemy.Connection, release: Release) -> None:
+ if release.name == "":
+ if (release.project_name is None) or (release.version is None):
+ raise ValueError("Cannot generate release name without
project_name and version")
+ release.name = release_name(release.project_name, release.version)
-class Task(sqlmodel.SQLModel, table=True):
- """A task in the task queue."""
- 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))
- added: datetime.datetime = sqlmodel.Field(
- default_factory=lambda: datetime.datetime.now(datetime.UTC),
- sa_column=sqlalchemy.Column(UTCDateTime, index=True),
- )
- started: datetime.datetime | None = sqlmodel.Field(
- default=None,
- sa_column=sqlalchemy.Column(UTCDateTime),
- )
- pid: int | None = None
- completed: datetime.datetime | None = sqlmodel.Field(
- default=None,
- sa_column=sqlalchemy.Column(UTCDateTime),
+def latest_revision_number_query(release_name: str | None = None) ->
expression.ScalarSelect[str]:
+ if release_name is None:
+ query_release_name = Release.name
+ else:
+ query_release_name = release_name
+ return (
+ sqlmodel.select(validate_instrumented_attribute(Revision.number))
+ .where(validate_instrumented_attribute(Revision.release_name) ==
query_release_name)
+ .order_by(validate_instrumented_attribute(Revision.seq).desc())
+ .limit(1)
+ .scalar_subquery()
)
- result: results.Results | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(ResultsJSON))
- error: str | None = None
- # 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)
- revision_number: str | None = sqlmodel.Field(default=None, index=True)
- primary_rel_path: str | None = sqlmodel.Field(default=None, index=True)
- # Create an index on status and added for efficient task claiming
- __table_args__ = (
- sqlalchemy.Index("ix_task_status_added", "status", "added"),
- # Ensure valid status transitions:
- # - QUEUED can transition to ACTIVE
- # - ACTIVE can transition to COMPLETED or FAILED
- # - COMPLETED and FAILED are terminal states
- sqlalchemy.CheckConstraint(
- """
- (
- -- Initial state is always valid
- status = 'QUEUED'
- -- QUEUED -> ACTIVE requires setting started time and pid
- OR (status = 'ACTIVE' AND started IS NOT NULL AND pid IS NOT
NULL)
- -- ACTIVE -> COMPLETED requires setting completed time and
result
- OR (status = 'COMPLETED' AND completed IS NOT NULL AND result
IS NOT NULL)
- -- ACTIVE -> FAILED requires setting completed time and error
(result optional)
- OR (status = 'FAILED' AND completed IS NOT NULL AND error IS
NOT NULL)
- )
- """,
- name="valid_task_status_transitions",
- ),
- )
+def release_name(project_name: str, version_name: str) -> str:
+ """Return the release name for a given project and version."""
+ return f"{project_name}-{version_name}"
def validate_instrumented_attribute(obj: Any) -> orm.InstrumentedAttribute:
@@ -631,102 +885,6 @@ def validate_instrumented_attribute(obj: Any) ->
orm.InstrumentedAttribute:
return obj
-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)
- phase: ReleasePhase
- created: datetime.datetime =
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
- released: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
-
- # Many-to-one: A release belongs to one project, a project can have
multiple releases
- project_name: str = sqlmodel.Field(foreign_key="project.name")
- project: Project = sqlmodel.Relationship(back_populates="releases")
-
- package_managers: list[str] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
- # 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
- sboms: list[str] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-
- # Many-to-one: A release can have one release policy, a release policy can
be used by multiple releases
- release_policy_id: int | None = sqlmodel.Field(default=None,
foreign_key="releasepolicy.id")
- release_policy: ReleasePolicy | None = sqlmodel.Relationship(
- cascade_delete=True, sa_relationship_kwargs={"cascade": "all,
delete-orphan", "single_parent": True}
- )
-
- votes: list[VoteEntry] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
- vote_manual: bool = sqlmodel.Field(default=False)
- vote_started: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
- vote_resolved: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
- podling_thread_id: str | None = sqlmodel.Field(default=None)
-
- revisions: list["Revision"] = sqlmodel.Relationship(
- back_populates="release",
- sa_relationship_kwargs={
- "order_by": "Revision.seq",
- "foreign_keys": "[Revision.release_name]",
- "cascade": "all, delete-orphan",
- },
- )
-
- # One-to-many: A release can have multiple check results
- check_results: list["CheckResult"] = sqlmodel.Relationship(
- 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"),)
-
- @property
- def committee(self) -> Committee | None:
- """Get the committee for the release."""
- project = self.project
- if project is None:
- return None
- return project.committee
-
- @property
- def short_display_name(self) -> str:
- """Get the short display name for the release."""
- return f"{self.project.short_display_name} {self.version}"
-
- @property
- def unwrap_revision_number(self) -> str:
- """Get the revision number for the release, or raise an exception."""
- number = self.latest_revision_number
- if number is None:
- raise ValueError("Release has no revisions")
- return number
-
- @pydantic.computed_field # type: ignore[prop-decorator]
- @property
- def latest_revision_number(self) -> str | None:
- """Get the latest revision number for the release."""
- # The session must still be active for this to work
- number = getattr(self, "_latest_revision_number", None)
- if not (isinstance(number, str) or (number is None)):
- raise ValueError("Latest revision number is not a str or None")
- return number
-
- # NOTE: This does not work
- # But it we set it with Release.latest_revision_number_query = ..., it
might work
- # Not clear that we'd want to do that, though
- # @property
- # 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)
- # .order_by(validate_instrumented_attribute(Revision.seq).desc())
- # .limit(1)
- # .scalar_subquery()
- # )
-
-
# https://github.com/fastapi/sqlmodel/issues/240#issuecomment-2074161775
Release._latest_revision_number = orm.column_property(
sqlalchemy.select(validate_instrumented_attribute(Revision.number))
@@ -736,77 +894,3 @@ Release._latest_revision_number = orm.column_property(
.correlate_except(Revision)
.scalar_subquery(),
)
-
-
-def latest_revision_number_query(release_name: str | None = None) ->
expression.ScalarSelect[str]:
- if release_name is None:
- query_release_name = Release.name
- else:
- query_release_name = release_name
- return (
- sqlmodel.select(validate_instrumented_attribute(Revision.number))
- .where(validate_instrumented_attribute(Revision.release_name) ==
query_release_name)
- .order_by(validate_instrumented_attribute(Revision.seq).desc())
- .limit(1)
- .scalar_subquery()
- )
-
-
-class SSHKey(sqlmodel.SQLModel, table=True):
- fingerprint: str = sqlmodel.Field(primary_key=True)
- key: str
- asf_uid: str
-
-
-class CheckResultStatus(str, enum.Enum):
- EXCEPTION = "exception"
- FAILURE = "failure"
- SUCCESS = "success"
- WARNING = "warning"
-
-
-class CheckResult(sqlmodel.SQLModel, table=True):
- id: int = sqlmodel.Field(default=None, primary_key=True)
- release_name: str = sqlmodel.Field(foreign_key="release.name",
ondelete="CASCADE")
- 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)
- checker: str
- primary_rel_path: str | None = sqlmodel.Field(default=None, index=True)
- member_rel_path: str | None = sqlmodel.Field(default=None, index=True)
- created: datetime.datetime =
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
- status: CheckResultStatus
- message: str
- data: Any = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-
-
-class TextValue(sqlmodel.SQLModel, table=True):
- # Composite primary key, automatically handled by SQLModel
- ns: str = sqlmodel.Field(primary_key=True, index=True)
- key: str = sqlmodel.Field(primary_key=True, index=True)
- value: str = sqlmodel.Field()
-
-
[email protected]_for(Release, "before_insert")
-def check_release_name(_mapper: orm.Mapper, _connection:
sqlalchemy.Connection, release: Release) -> None:
- if release.name == "":
- if (release.project_name is None) or (release.version is None):
- raise ValueError("Cannot generate release name without
project_name and version")
- release.name = release_name(release.project_name, release.version)
-
-
-def release_name(project_name: str, version_name: str) -> str:
- """Return the release name for a given project and version."""
- return f"{project_name}-{version_name}"
-
-
-class PersonalAccessToken(sqlmodel.SQLModel, table=True):
- id: int | None = sqlmodel.Field(default=None, primary_key=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)
- )
- expires: datetime.datetime =
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
- last_used: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
- label: str | None = None
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]