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 6bffcc9  Use models from the ATR server
6bffcc9 is described below

commit 6bffcc97e02e833fbee5a98603996240d1dcef39
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Jul 11 20:33:21 2025 +0100

    Use models from the ATR server
---
 pyproject.toml                                     |  28 +-
 src/atrclient/client.py                            |  77 +-
 .../atrclient/models/__init__.py                   |  14 +-
 src/atrclient/models/api.py                        |  70 ++
 src/atrclient/models/helpers.py                    | 105 +++
 src/atrclient/models/results.py                    |  75 ++
 src/atrclient/models/schema.py                     |  53 ++
 src/atrclient/models/sql.py                        | 845 +++++++++++++++++++++
 tests/conftest.py                                  |   4 +-
 tests/test_all.py                                  |  61 +-
 uv.lock                                            | 141 +++-
 11 files changed, 1360 insertions(+), 113 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index d8ad600..d6d474a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ build-backend = "hatchling.build"
 
 [project]
 name            = "apache-trusted-releases"
-version         = "0.20250711.1511"
+version         = "0.20250711.1932"
 description     = "ATR CLI and Python API"
 readme          = "README.md"
 requires-python = ">=3.13"
@@ -28,7 +28,10 @@ dependencies = [
   "cyclopts",
   "filelock",
   "platformdirs",
+  "pydantic",
   "pyjwt",
+  "sqlalchemy",
+  "sqlmodel",
   "strictyaml",
 ]
 
@@ -47,5 +50,26 @@ atr = "atrclient.client:main"
 [tool.hatch.build.targets.wheel]
 packages = ["src/atrclient"]
 
+[tool.ruff]
+line-length = 120
+extend-exclude = [
+    "node_modules",
+]
+
+[tool.ruff.lint]
+ignore = []
+select = [
+  "C90",
+  "E",
+  "F",
+  "I",   # isort
+  "N",   # pep8-naming
+  "RUF", # ruff-checks
+  "TC",  # flake8-type-checking
+  "TID", # flake8-tidy-imports
+  "UP",  # pyupgrade
+  "W"
+]
+
 [tool.uv]
-exclude-newer = "2025-07-11T15:11:00Z"
+exclude-newer = "2025-07-11T19:32:00Z"
diff --git a/src/atrclient/client.py b/src/atrclient/client.py
index f02b96a..815e11f 100755
--- a/src/atrclient/client.py
+++ b/src/atrclient/client.py
@@ -42,6 +42,8 @@ import jwt
 import platformdirs
 import strictyaml
 
+import atrclient.models as models
+
 if TYPE_CHECKING:
     from collections.abc import Generator
 
@@ -57,12 +59,8 @@ VERSION: str = metadata.version("apache-trusted-releases")
 YAML_DEFAULTS: dict[str, Any] = {"asf": {}, "atr": {}, "tokens": {}}
 YAML_SCHEMA: strictyaml.Map = strictyaml.Map(
     {
-        strictyaml.Optional("atr"): strictyaml.Map(
-            {strictyaml.Optional("host"): strictyaml.Str()}
-        ),
-        strictyaml.Optional("asf"): strictyaml.Map(
-            {strictyaml.Optional("uid"): strictyaml.Str()}
-        ),
+        strictyaml.Optional("atr"): 
strictyaml.Map({strictyaml.Optional("host"): strictyaml.Str()}),
+        strictyaml.Optional("asf"): 
strictyaml.Map({strictyaml.Optional("uid"): strictyaml.Str()}),
         strictyaml.Optional("tokens"): strictyaml.Map(
             {
                 strictyaml.Optional("pat"): strictyaml.Str(),
@@ -75,9 +73,7 @@ YAML_SCHEMA: strictyaml.Map = strictyaml.Map(
 JSON = dict[str, Any] | list[Any] | str | int | float | bool | None
 
 
-@APP_CHECKS.command(
-    name="exceptions", help="Get check exceptions for a release revision."
-)
+@APP_CHECKS.command(name="exceptions", help="Get check exceptions for a 
release revision.")
 def app_checks_exceptions(
     project: str,
     version: str,
@@ -206,9 +202,7 @@ def app_dev_pat() -> None:
     print(text)
 
 
-@APP_DEV.command(
-    name="stamp", help="Update version and exclude-newer in pyproject.toml."
-)
+@APP_DEV.command(name="stamp", help="Update version and exclude-newer in 
pyproject.toml.")
 def app_dev_stamp() -> None:
     path = pathlib.Path("pyproject.toml")
     if not path.exists():
@@ -226,11 +220,7 @@ def app_dev_stamp() -> None:
 
     if version_updated or exclude_newer_updated:
         path.write_text(text_v3, "utf-8")
-    print(
-        "Updated exclude-newer."
-        if exclude_newer_updated
-        else "Did not update exclude-newer."
-    )
+    print("Updated exclude-newer." if exclude_newer_updated else "Did not 
update exclude-newer.")
     print("Updated version." if version_updated else "Did not update version.")
 
     path = pathlib.Path("tests/cli_version.t")
@@ -331,9 +321,7 @@ def app_jwt_info() -> None:
     print("\n".join(lines))
 
 
-@APP_JWT.command(
-    name="refresh", help="Fetch a JWT using the stored PAT and store it in 
config."
-)
+@APP_JWT.command(name="refresh", help="Fetch a JWT using the stored PAT and 
store it in config.")
 def app_jwt_refresh(asf_uid: str | None = None) -> None:
     jwt_value = config_jwt_refresh(asf_uid)
     print(jwt_value)
@@ -360,7 +348,8 @@ def app_release_info(project: str, version: str, /) -> None:
     host, verify_ssl = config_host_get()
     url = f"https://{host}/api/releases/{project}/{version}";
     result = asyncio.run(web_get_public(url, verify_ssl))
-    print(result)
+    release = models.sql.Release.model_validate(result)
+    print(release.model_dump_json(indent=None))
 
 
 @APP_RELEASE.command(name="list", help="List releases for a project.")
@@ -455,9 +444,7 @@ def app_vote_start(
     /,
     mailing_list: str,
     duration: Annotated[int, cyclopts.Parameter(alias="-d", 
name="--duration")] = 72,
-    subject: Annotated[
-        str | None, cyclopts.Parameter(alias="-s", name="--subject")
-    ] = None,
+    subject: Annotated[str | None, cyclopts.Parameter(alias="-s", 
name="--subject")] = None,
     body: Annotated[str | None, cyclopts.Parameter(alias="-b", name="--body")] 
= None,
 ) -> None:
     jwt_value = config_jwt_usable()
@@ -465,7 +452,7 @@ def app_vote_start(
     url = f"https://{host}/api/vote/start";
     body_text = None
     if body:
-        with open(body, "r", encoding="utf-8") as f:
+        with open(body, encoding="utf-8") as f:
             body_text = f.read()
     payload: dict[str, Any] = {
         "project_name": project,
@@ -494,9 +481,7 @@ def checks_display(results: list[dict[str, JSON]], verbose: 
bool = False) -> Non
     checks_display_details(by_status, verbose)
 
 
-def checks_display_details(
-    by_status: dict[str, list[dict[str, JSON]]], verbose: bool
-) -> None:
+def checks_display_details(by_status: dict[str, list[dict[str, JSON]]], 
verbose: bool) -> None:
     if not verbose:
         return
     for status_key in by_status.keys():
@@ -544,9 +529,7 @@ def checks_display_status(
         print()
 
 
-def checks_display_summary(
-    by_status: dict[str, list[dict[str, JSON]]], verbose: bool, total: int
-) -> None:
+def checks_display_summary(by_status: dict[str, list[dict[str, JSON]]], 
verbose: bool, total: int) -> None:
     print(f"Total checks: {total}")
     for status, checks in by_status.items():
         if verbose and status.upper() in ["FAILURE", "EXCEPTION", "WARNING"]:
@@ -648,10 +631,7 @@ def config_jwt_usable() -> str:
             # The user probably just changed their configuration
             # But we will refresh the JWT anyway
             # It will still fail if the PAT is not valid
-            show_warning(
-                f"JWT ASF UID {payload_asf_uid} does not "
-                f"match configuration ASF UID {config_asf_uid}"
-            )
+            show_warning(f"JWT ASF UID {payload_asf_uid} does not match 
configuration ASF UID {config_asf_uid}")
         return config_jwt_refresh(payload_asf_uid)
     return jwt_value
 
@@ -745,6 +725,7 @@ def documentation_to_markdown(
     seen: set[str] | None = None,
 ) -> str:
     import io
+
     import rich.console as console
 
     seen = seen or set()
@@ -758,11 +739,7 @@ def documentation_to_markdown(
 
     exported_text = rich_console.export_text()
     if not subcommands:
-        subcommands = [
-            " ".join(app.name)
-            if isinstance(app.name, (list, tuple))
-            else (app.name or "atr")
-        ]
+        subcommands = [" ".join(app.name) if isinstance(app.name, list | 
tuple) else (app.name or "atr")]
     level = len(subcommands)
     markdown = f"""
 {"#" * level} {" ".join(subcommands)}
@@ -910,9 +887,7 @@ def timestamp_format(ts: int | str | None) -> str | None:
         return str(ts)
 
 
-async def web_fetch(
-    url: str, asfuid: str, pat_token: str, verify_ssl: bool = True
-) -> str:
+async def web_fetch(url: str, asfuid: str, pat_token: str, verify_ssl: bool = 
True) -> str:
     # TODO: This is PAT request specific
     # Should give this a more specific name, e.g. web_post_pat
     connector = None if verify_ssl else aiohttp.TCPConnector(ssl=False)
@@ -921,9 +896,7 @@ async def web_fetch(
         async with session.post(url, json=payload) as resp:
             if resp.status != 200:
                 text = await resp.text()
-                show_error_and_exit(
-                    f"JWT fetch failed: {payload!r} {resp.status} {text!r}"
-                )
+                show_error_and_exit(f"JWT fetch failed: {payload!r} 
{resp.status} {text!r}")
 
             data = await resp.json()
             if not is_json(data):
@@ -979,18 +952,14 @@ async def web_get_public(url: str, verify_ssl: bool = 
True) -> JSON:
             return data
 
 
-async def web_post(
-    url: str, payload: dict[str, Any], jwt_token: str, verify_ssl: bool = True
-) -> JSON:
+async def web_post(url: str, payload: dict[str, Any], jwt_token: str, 
verify_ssl: bool = True) -> JSON:
     connector = None if verify_ssl else aiohttp.TCPConnector(ssl=False)
     headers = {"Authorization": f"Bearer {jwt_token}"}
     async with aiohttp.ClientSession(connector=connector, headers=headers) as 
session:
         async with session.post(url, json=payload) as resp:
             if resp.status not in (200, 201):
                 text = await resp.text()
-                show_error_and_exit(
-                    f"Error message from the API:\n{resp.status} {url}\n{text}"
-                )
+                show_error_and_exit(f"Error message from the 
API:\n{resp.status} {url}\n{text}")
 
             try:
                 data = await resp.json()
@@ -998,6 +967,4 @@ async def web_post(
                     show_error_and_exit(f"Unexpected API response: {data}")
                 return data
             except Exception as e:
-                show_error_and_exit(
-                    f"Python error getting API response:\n{resp.status} 
{url}\n{e}"
-                )
+                show_error_and_exit(f"Python error getting API 
response:\n{resp.status} {url}\n{e}")
diff --git a/tests/conftest.py b/src/atrclient/models/__init__.py
similarity index 75%
copy from tests/conftest.py
copy to src/atrclient/models/__init__.py
index 78b38ae..63e3704 100644
--- a/tests/conftest.py
+++ b/src/atrclient/models/__init__.py
@@ -15,15 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import pathlib
+from . import api, helpers, results, schema, sql
 
-import pytest
-
-
[email protected]
-def fixture_config_env(
-    monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
-) -> pathlib.Path:
-    path = tmp_path / "atr.yaml"
-    monkeypatch.setenv("ATR_CLIENT_CONFIG_PATH", str(path))
-    return path
+# If we use .__name__, pyright gives a warning
+__all__ = ["api", "helpers", "results", "schema", "sql"]
diff --git a/src/atrclient/models/api.py b/src/atrclient/models/api.py
new file mode 100644
index 0000000..66be28c
--- /dev/null
+++ b/src/atrclient/models/api.py
@@ -0,0 +1,70 @@
+# 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.
+
+import dataclasses
+
+from . import schema
+
+
[email protected]
+class Pagination:
+    offset: int = 0
+    limit: int = 20
+
+
+# TODO: ReleasesPagination?
[email protected]
+class Releases(Pagination):
+    phase: str | None = None
+
+
+# TODO: TaskPagination?
[email protected]
+class Task(Pagination):
+    status: str | None = None
+
+
+class DraftDeleteRequest(schema.Strict):
+    project_name: str
+    version: str
+
+
+class FileUploadRequest(schema.Strict):
+    project_name: str
+    version: str
+    rel_path: str
+    content: str
+
+
+class PATJWTRequest(schema.Strict):
+    asfuid: str
+    pat: str
+
+
+class ReleaseCreateRequest(schema.Strict):
+    project_name: str
+    version: str
+
+
+class VoteStartRequest(schema.Strict):
+    project_name: str
+    version: str
+    revision: str
+    email_to: str
+    vote_duration: int
+    subject: str
+    body: str
diff --git a/src/atrclient/models/helpers.py b/src/atrclient/models/helpers.py
new file mode 100644
index 0000000..04321e6
--- /dev/null
+++ b/src/atrclient/models/helpers.py
@@ -0,0 +1,105 @@
+# 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.
+
+import dataclasses
+from collections.abc import Generator, ItemsView, Mapping
+from typing import Annotated, Any, TypeVar
+
+import pydantic
+import pydantic.fields as fields
+import pydantic_core
+
+VT = TypeVar("VT")
+
+
+class DictRoot(pydantic.RootModel[dict[str, VT]]):
+    def __iter__(self) -> Generator[tuple[str, VT]]:
+        yield from self.root.items()
+
+    def items(self) -> ItemsView[str, VT]:
+        return self.root.items()
+
+    def get(self, key: str) -> VT | None:
+        return self.root.get(key)
+
+    def __len__(self) -> int:
+        return len(self.root)
+
+
+# from 
https://github.com/pydantic/pydantic/discussions/8755#discussioncomment-8417979
[email protected]
+class DictToList:
+    key: str
+
+    def __get_pydantic_core_schema__(
+        self,
+        source_type: Any,
+        handler: pydantic.GetCoreSchemaHandler,
+    ) -> pydantic_core.CoreSchema:
+        adapter = _get_dict_to_list_inner_type_adapter(source_type, self.key)
+
+        return pydantic_core.core_schema.no_info_before_validator_function(
+            _get_dict_to_list_validator(adapter, self.key),
+            handler(source_type),
+        )
+
+
+def _get_dict_to_list_inner_type_adapter(source_type: Any, key: str) -> 
pydantic.TypeAdapter[dict[Any, Any]]:
+    root_adapter = pydantic.TypeAdapter(source_type)
+    schema = root_adapter.core_schema
+
+    # support further nesting of model classes
+    if schema["type"] == "definitions":
+        schema = schema["schema"]
+
+    assert schema["type"] == "list"
+    assert (item_schema := schema["items_schema"])  # pyright: 
ignore[reportTypedDictNotRequiredAccess, reportGeneralTypeIssues]
+    assert item_schema["type"] == "model"  # pyright: 
ignore[reportTypedDictNotRequiredAccess, reportGeneralTypeIssues, 
reportCallIssue, reportArgumentType]
+    assert (cls := item_schema["cls"])  # pyright: 
ignore[reportTypedDictNotRequiredAccess, reportGeneralTypeIssues, 
reportCallIssue, reportArgumentType] # noqa: RUF018
+
+    fields = cls.model_fields
+
+    assert (key_field := fields.get(key))  # noqa: RUF018
+    assert (other_fields := {k: v for k, v in fields.items() if k != key})  # 
noqa: RUF018
+
+    model_name = f"{cls.__name__}Inner"
+
+    # Create proper field definitions for create_model
+    kargs = {k: (v.annotation, v) for k, v in other_fields.items()}
+    inner_model = pydantic.create_model(model_name, **kargs)  # type: ignore
+    return pydantic.TypeAdapter(dict[Annotated[str, key_field], inner_model])  
# type: ignore
+
+
+def _get_dict_to_list_validator(inner_adapter: pydantic.TypeAdapter[dict[Any, 
Any]], key: str) -> Any:
+    def validator(val: Any) -> Any:
+        if isinstance(val, dict):
+            validated = inner_adapter.validate_python(val)
+
+            # need to get the alias of the field in the nested model
+            # as this will be fed into the actual model class
+            def get_alias(field_name: str, field_infos: Mapping[str, 
fields.FieldInfo]) -> Any:
+                field = field_infos[field_name]
+                return field.alias if field.alias else field_name
+
+            return [
+                {key: k, **{get_alias(f, type(v).model_fields): getattr(v, f) 
for f in type(v).model_fields}}
+                for k, v in validated.items()
+            ]
+
+        return val
+
+    return validator
diff --git a/src/atrclient/models/results.py b/src/atrclient/models/results.py
new file mode 100644
index 0000000..735b4e6
--- /dev/null
+++ b/src/atrclient/models/results.py
@@ -0,0 +1,75 @@
+# 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 typing import Annotated, Literal
+
+from pydantic import TypeAdapter
+
+from . import schema
+
+# TODO: If we put this in atr.tasks.results, we get a circular import error
+
+
+class HashingCheck(schema.Strict):
+    """Result of the task to check the hash of a file."""
+
+    kind: Literal["hashing_check"] = schema.Field(alias="kind")
+    hash_algorithm: str = schema.description("The hash algorithm used")
+    hash_value: str = schema.description("The hash value of the file")
+    hash_file_path: str = schema.description("The path to the hash file")
+
+
+class MessageSend(schema.Strict):
+    """Result of the task to send an email."""
+
+    kind: Literal["message_send"] = schema.Field(alias="kind")
+    mid: str = schema.description("The message ID of the email")
+    mail_send_warnings: list[str] = schema.description("Warnings from the mail 
server")
+
+
+class SBOMGenerateCycloneDX(schema.Strict):
+    """Result of the task to generate a CycloneDX SBOM."""
+
+    kind: Literal["sbom_generate_cyclonedx"] = schema.Field(alias="kind")
+    msg: str = schema.description("The message from the SBOM generation")
+
+
+class SvnImportFiles(schema.Strict):
+    """Result of the task to import files from SVN."""
+
+    kind: Literal["svn_import"] = schema.Field(alias="kind")
+    msg: str = schema.description("The message from the SVN import")
+
+
+class VoteInitiate(schema.Strict):
+    """Result of the task to initiate a vote."""
+
+    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")
+    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")
+    mail_send_warnings: list[str] = schema.description("Warnings from the mail 
server")
+
+
+Results = Annotated[
+    HashingCheck | MessageSend | SBOMGenerateCycloneDX | SvnImportFiles | 
VoteInitiate,
+    schema.Field(discriminator="kind"),
+]
+
+ResultsAdapter = TypeAdapter(Results)
diff --git a/src/atrclient/models/schema.py b/src/atrclient/models/schema.py
new file mode 100644
index 0000000..37427f0
--- /dev/null
+++ b/src/atrclient/models/schema.py
@@ -0,0 +1,53 @@
+# 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 collections.abc import Callable
+from typing import Any
+
+import pydantic
+
+# For convenience
+Field = pydantic.Field
+
+
+class Strict(pydantic.BaseModel):
+    model_config = pydantic.ConfigDict(extra="forbid", strict=True, 
validate_assignment=True)
+
+
+def alias(alias_name: str) -> Any:
+    """Helper to create a Pydantic FieldInfo object with only an alias."""
+    return Field(alias=alias_name)
+
+
+def alias_opt(alias_name: str) -> Any:
+    """Helper to create a Pydantic FieldInfo object with only an alias."""
+    return Field(alias=alias_name, default=None)
+
+
+def default(default_value: Any) -> Any:
+    """Helper to create a Pydantic FieldInfo object with only a default 
value."""
+    return Field(default=default_value)
+
+
+def description(desc_text: str) -> Any:
+    """Helper to create a Pydantic FieldInfo object with only a description."""
+    return Field(description=desc_text)
+
+
+def factory(cls: Callable[[], Any]) -> Any:
+    """Helper to create a Pydantic FieldInfo object with only a description."""
+    return Field(default_factory=cls)
diff --git a/src/atrclient/models/sql.py b/src/atrclient/models/sql.py
new file mode 100644
index 0000000..03fb6ad
--- /dev/null
+++ b/src/atrclient/models/sql.py
@@ -0,0 +1,845 @@
+# 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.
+
+"""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
+import enum
+from typing import Any, Optional
+
+import pydantic
+import sqlalchemy
+import sqlalchemy.event as event
+import sqlalchemy.orm as orm
+import sqlalchemy.sql.expression as expression
+import sqlmodel
+
+from . import results, schema
+
+sqlmodel.SQLModel.metadata = sqlalchemy.MetaData(
+    naming_convention={
+        "ix": "ix_%(table_name)s_%(column_0_N_name)s",
+        "uq": "uq_%(table_name)s_%(column_0_N_name)s",
+        "ck": "ck_%(table_name)s_%(constraint_name)s",
+        "fk": "fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s",
+        "pk": "pk_%(table_name)s",
+    }
+)
+
+
+# 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
+# TODO: Possibly move these to a separate module
+# That way, we can more easily track Alembic's dependence on them
+
+
+class UTCDateTime(sqlalchemy.types.TypeDecorator):
+    """
+    A custom column type to store datetime in sqlite.
+
+    As sqlite does not have timezone support, we ensure that all datetimes 
stored
+    within sqlite are converted to UTC. When retrieved, the datetimes are 
constructed
+    as offset-aware datetime with UTC as their timezone.
+    """
+
+    impl = sqlalchemy.types.TIMESTAMP(timezone=True)
+
+    cache_ok = True
+
+    def process_bind_param(self, value, dialect):  # type: ignore
+        if value:
+            if not isinstance(value, datetime.datetime):
+                raise ValueError(f"Unexpected value type {type(value)}")
+
+            if value.tzinfo is None:
+                raise ValueError("Unexpected offset-naive datetime")
+
+            # store the datetime in UTC in sqlite as it does not support 
timezones
+            return value.astimezone(datetime.UTC)
+        else:
+            return value
+
+    def process_result_value(self, value, dialect):  # type: ignore
+        if isinstance(value, datetime.datetime):
+            return value.replace(tzinfo=datetime.UTC)
+        else:
+            return value
+
+
+class ResultsJSON(sqlalchemy.types.TypeDecorator):
+    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()
+        if isinstance(value, dict):
+            return value
+        raise ValueError("Unsupported value for Results column")
+
+    def process_result_value(self, value, dialect):
+        if value is None:
+            return None
+        try:
+            return results.ResultsAdapter.validate_python(value)
+        except pydantic.ValidationError:
+            return None
+
+
+# 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)
+
+
+# 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)
+    )
+    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
+
+
+# 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)
+    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),
+    )
+    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()
+
+
+# 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
+    name: str = sqlmodel.Field(unique=True, primary_key=True)
+    full_name: str | None = sqlmodel.Field(default=None)
+    # True only if this is an incubator podling with a PPMC
+    is_podling: bool = sqlmodel.Field(default=False)
+
+    # 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")
+    # 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))
+
+    # 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:
+        """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
+
+
+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
+    name: str = sqlmodel.Field(unique=True, primary_key=True)
+    # 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)
+
+    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()
+
+    description: str | None = sqlmodel.Field(default=None)
+    category: str | None = sqlmodel.Field(default=None)
+    programming_languages: str | None = sqlmodel.Field(default=None)
+
+    # 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)
+
+    # 1-M: Project -> [Release]
+    # M-1: Release -> Project
+    # see_also(Release.project)
+    releases: list["Release"] = sqlmodel.Relationship(back_populates="project")
+
+    # 1-M: Project -> [DistributionChannel]
+    # M-1: DistributionChannel -> Project
+    distribution_channels: list["DistributionChannel"] = 
sqlmodel.Relationship(back_populates="project")
+
+    # 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: Optional["ReleasePolicy"] = sqlmodel.Relationship(
+        cascade_delete=True, sa_relationship_kwargs={"cascade": "all, 
delete-orphan", "single_parent": True}
+    )
+
+    created: datetime.datetime = sqlmodel.Field(
+        default_factory=lambda: datetime.datetime.now(datetime.UTC), 
sa_column=sqlalchemy.Column(UTCDateTime)
+    )
+    created_by: str | None = sqlmodel.Field(default=None)
+
+    @property
+    def display_name(self) -> str:
+        """Get the display name for the Project."""
+        base = self.full_name or self.name
+        if self.committee and self.committee.is_podling:
+            return f"{base} (Incubating)"
+        return base
+
+    @property
+    def short_display_name(self) -> str:
+        """Get the short display name for the Project."""
+        return self.display_name.removeprefix("Apache ")
+
+    @property
+    def policy_announce_release_default(self) -> str:
+        return """\
+The Apache [COMMITTEE] project team is pleased to announce the
+release of [PROJECT] [VERSION].
+
+This is a stable release available for production use.
+
+Downloads are available from the following URL:
+
+[DOWNLOAD_URL]
+
+On behalf of the Apache [COMMITTEE] project team,
+
+[YOUR_FULL_NAME] ([YOUR_ASF_ID])
+"""
+
+    @property
+    def policy_start_vote_default(self) -> str:
+        return """Hello [COMMITTEE],
+
+I'd like to call a vote on releasing the following artifacts as
+Apache [PROJECT] [VERSION].
+
+The release candidate page, including downloads, can be found at:
+
+  [REVIEW_URL]
+
+The release artifacts are signed with one or more OpenPGP keys from:
+
+  [KEYS_FILE]
+
+Please review the release candidate and vote accordingly.
+
+[ ] +1 Release this package
+[ ] +0 Abstain
+[ ] -1 Do not release this package (please provide specific comments)
+
+You can vote on ATR at the URL above, or manually by replying to this email.
+
+This vote will remain open for [DURATION] hours.
+
+[RELEASE_CHECKLIST]
+Thanks,
+[YOUR_FULL_NAME] ([YOUR_ASF_ID])
+"""
+
+    @property
+    def policy_default_min_hours(self) -> int:
+        return 72
+
+    @property
+    def policy_announce_release_template(self) -> str:
+        if ((policy := self.release_policy) is None) or 
(policy.announce_release_template == ""):
+            return self.policy_announce_release_default
+        return policy.announce_release_template
+
+    @property
+    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"]
+            else:
+                # TODO: Or raise an error?
+                return [f"dev@{self.name}.apache.org", 
f"private@{self.name}.apache.org"]
+        return policy.mailto_addresses
+
+    @property
+    def policy_manual_vote(self) -> bool:
+        if (policy := self.release_policy) is None:
+            return False
+        return policy.manual_vote
+
+    @property
+    def policy_min_hours(self) -> int:
+        if ((policy := self.release_policy) is None) or (policy.min_hours is 
None):
+            # TODO: Not sure what the default should be
+            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 == ""):
+            return ""
+        return policy.release_checklist
+
+    @property
+    def policy_start_vote_template(self) -> str:
+        if ((policy := self.release_policy) is None) or 
(policy.start_vote_template == ""):
+            return self.policy_start_vote_default
+        return policy.start_vote_template
+
+    @property
+    def policy_binary_artifact_paths(self) -> list[str]:
+        if (policy := self.release_policy) is None:
+            return []
+        return policy.binary_artifact_paths
+
+    @property
+    def policy_source_artifact_paths(self) -> list[str]:
+        if (policy := self.release_policy) is None:
+            return []
+        return policy.source_artifact_paths
+
+    @property
+    def policy_strict_checking(self) -> bool:
+        # This is bool, so it should never be None
+        # TODO: Should we make it nullable for defaulting?
+        if (policy := self.release_policy) is None:
+            return False
+        return policy.strict_checking
+
+
+# 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))
+
+    # 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))
+
+    # 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)
+
+    # 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(
+        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
+    number: str = sqlmodel.Field(default="")
+    asfuid: str
+    created: datetime.datetime = sqlmodel.Field(
+        default_factory=lambda: datetime.datetime.now(datetime.UTC), 
sa_column=sqlalchemy.Column(UTCDateTime)
+    )
+    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(
+            remote_side=lambda: Revision.name,
+            uselist=False,
+            primaryjoin=lambda: Revision.parent_name == Revision.name,
+            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)
+
+    __table_args__ = (
+        sqlmodel.UniqueConstraint("release_name", "seq", 
name="uq_revision_release_seq"),
+        sqlmodel.UniqueConstraint("release_name", "number", 
name="uq_revision_release_number"),
+    )
+
+
+def revision_name(release_name: str, number: str) -> str:
+    return f"{release_name} {number}"
+
+
[email protected]_for(Revision, "before_insert")
+def populate_revision_sequence_and_name(
+    mapper: orm.Mapper, connection: sqlalchemy.engine.Connection, revision: 
Revision
+) -> None:
+    # We require Revision.release_name to have been set
+    if not revision.release_name:
+        # 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")
+
+    # Get the Revision with the maximum existing Revision.seq and the same 
Revision.release_name
+    stmt = (
+        sqlmodel.select(Revision.seq, Revision.name)
+        .where(Revision.release_name == revision.release_name)
+        
.order_by(sqlalchemy.desc(validate_instrumented_attribute(Revision.seq)))
+        .limit(1)
+    )
+    parent_row = connection.execute(stmt).fetchone()
+
+    # We cannot happy path this, because we must recalculate the Revision.name 
afterwards
+    if parent_row is None:
+        # This is the first Revision for this Revision.release_name
+        # Revision.seq is 0, but we use a 1-based system
+        revision.seq = 1
+        revision.number = str(revision.seq).zfill(5)
+    else:
+        # We don't have the ORM available in this event listener
+        # Therefore we must construct a new Revision object from the database 
row
+        parent_row_seq = parent_row.seq
+        parent_row_name = parent_row.name
+        # Compute the next sequence number
+        revision.seq = parent_row_seq + 1
+        revision.number = str(revision.seq).zfill(5)
+        # Set the parent_name foreign key. SQLAlchemy will handle the 
relationship.
+        revision.parent_name = parent_row_name
+        # Do NOT set revision.parent directly here
+
+    # Recalculate the Revision.name
+    # This field has a unique constraint, which eliminates the potential for 
race conditions
+    revision.name = revision_name(revision.release_name, revision.number)
+
+
[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 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()
+    )
+
+
+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:
+    """Check if the given object is an InstrumentedAttribute."""
+    if not isinstance(obj, orm.InstrumentedAttribute):
+        raise ValueError(f"Object must be an orm.InstrumentedAttribute, got: 
{type(obj)}")
+    return obj
+
+
+# https://github.com/fastapi/sqlmodel/issues/240#issuecomment-2074161775
+Release._latest_revision_number = orm.column_property(
+    sqlalchemy.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)
+    .correlate_except(Revision)
+    .scalar_subquery(),
+)
diff --git a/tests/conftest.py b/tests/conftest.py
index 78b38ae..8d30e46 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -21,9 +21,7 @@ import pytest
 
 
 @pytest.fixture
-def fixture_config_env(
-    monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
-) -> pathlib.Path:
+def fixture_config_env(monkeypatch: pytest.MonkeyPatch, tmp_path: 
pathlib.Path) -> pathlib.Path:
     path = tmp_path / "atr.yaml"
     monkeypatch.setenv("ATR_CLIENT_CONFIG_PATH", str(path))
     return path
diff --git a/tests/test_all.py b/tests/test_all.py
index a06c32a..51d59b3 100755
--- a/tests/test_all.py
+++ b/tests/test_all.py
@@ -18,21 +18,23 @@
 # TODO: Use transcript style script testing
 
 from __future__ import annotations
+
 import os
+import pathlib
 import re
 import shlex
-
-import atrclient.client as client
-import pathlib
-from typing import Any, Final
+from typing import TYPE_CHECKING, Any, Final
 
 import aioresponses
 import pytest
-import pytest_console_scripts
 
-REGEX_CAPTURE: Final[re.Pattern[str]] = re.compile(
-    r"<\?([A-Za-z_]+)\?>|<.(skip).>|(.+?)"
-)
+import atrclient.client as client
+
+if TYPE_CHECKING:
+    import pytest_console_scripts
+
+
+REGEX_CAPTURE: Final[re.Pattern[str]] = 
re.compile(r"<\?([A-Za-z_]+)\?>|<.(skip).>|(.+?)")
 REGEX_COMMENT: Final[re.Pattern[str]] = re.compile(r"<#.*?#>")
 REGEX_USE: Final[re.Pattern[str]] = re.compile(r"<!([A-Za-z_]+)!>")
 
@@ -75,9 +77,7 @@ def test_app_checks_status_non_draft_phase(
         assert "Checks are only performed during the draft phase." in 
captured.out
 
 
-def test_app_checks_status_verbose(
-    capsys: pytest.CaptureFixture[str], fixture_config_env: pathlib.Path
-) -> None:
+def test_app_checks_status_verbose(capsys: pytest.CaptureFixture[str], 
fixture_config_env: pathlib.Path) -> None:
     client.app_set("atr.host", "example.invalid")
     client.app_set("tokens.jwt", "dummy_jwt_token")
 
@@ -132,9 +132,7 @@ def test_app_checks_status_verbose(
         assert "test_checker1 → file1.txt : Test failure 1" in captured.out
 
 
-def test_app_release_list_not_found(
-    capsys: pytest.CaptureFixture[str], fixture_config_env: pathlib.Path
-) -> None:
+def test_app_release_list_not_found(capsys: pytest.CaptureFixture[str], 
fixture_config_env: pathlib.Path) -> None:
     client.app_set("atr.host", "example.invalid")
 
     releases_url = "https://example.invalid/api/releases/nonexistent-project";
@@ -146,9 +144,7 @@ def test_app_release_list_not_found(
             client.app_release_list("nonexistent-project")
 
 
-def test_app_release_list_success(
-    capsys: pytest.CaptureFixture[str], fixture_config_env: pathlib.Path
-) -> None:
+def test_app_release_list_success(capsys: pytest.CaptureFixture[str], 
fixture_config_env: pathlib.Path) -> None:
     client.app_set("atr.host", "example.invalid")
 
     releases_url = "https://example.invalid/api/releases/test-project";
@@ -182,15 +178,10 @@ def test_app_release_list_success(
         assert "2.3.0" in captured.out
 
 
-def test_app_set_show(
-    capsys: pytest.CaptureFixture[str], fixture_config_env: pathlib.Path
-) -> None:
+def test_app_set_show(capsys: pytest.CaptureFixture[str], fixture_config_env: 
pathlib.Path) -> None:
     client.app_set("atr.host", "example.invalid")
     client.app_show("atr.host")
-    assert (
-        capsys.readouterr().out
-        == 'Set atr.host to "example.invalid".\nexample.invalid\n'
-    )
+    assert capsys.readouterr().out == 'Set atr.host to 
"example.invalid".\nexample.invalid\n'
 
 
 def test_cli_version(script_runner: pytest_console_scripts.ScriptRunner) -> 
None:
@@ -200,9 +191,7 @@ def test_cli_version(script_runner: 
pytest_console_scripts.ScriptRunner) -> None
     assert result.stderr == ""
 
 
[email protected](
-    "transcript_path", decorator_transcript_file_paths(), ids=lambda p: p.name
-)
[email protected]("transcript_path", decorator_transcript_file_paths(), 
ids=lambda p: p.name)
 def test_cli_transcripts(
     transcript_path: pathlib.Path,
     script_runner: pytest_console_scripts.ScriptRunner,
@@ -267,20 +256,16 @@ def transcript_capture(
     env = os.environ.copy()
 
     env["ATR_CLIENT_CONFIG_PATH"] = str(transcript_config_path)
-    with open(transcript_path, "r", encoding="utf-8") as f:
+    with open(transcript_path, encoding="utf-8") as f:
         for line in f:
             line = line.rstrip("\n")
             if captures:
                 line = REGEX_USE.sub(lambda m: captures[m.group(1)], line)
             line = REGEX_COMMENT.sub("", line)
             if line.startswith("$ ") or line.startswith("! ") or 
line.startswith("* "):
-                actual_output = transcript_execute(
-                    actual_output, line, script_runner, env
-                )
+                actual_output = transcript_execute(actual_output, line, 
script_runner, env)
             elif actual_output:
-                captures, actual_output = transcript_match(
-                    captures, actual_output, line
-                )
+                captures, actual_output = transcript_match(captures, 
actual_output, line)
             elif line:
                 pytest.fail(f"Unexpected line: {line!r}")
         assert not actual_output
@@ -307,9 +292,7 @@ def transcript_execute(
     print(f"Running: {command}")
     result = script_runner.run(shlex.split(command), env=env)
     if expected_code is not None:
-        assert result.returncode == expected_code, (
-            f"Command {command!r} returned {result.returncode}"
-        )
+        assert result.returncode == expected_code, f"Command {command!r} 
returned {result.returncode}"
     actual_output[:] = result.stdout.splitlines()
     if result.stderr:
         actual_output.append("<.stderr.>")
@@ -317,9 +300,7 @@ def transcript_execute(
     return actual_output
 
 
-def transcript_match(
-    captures: dict[str, str], actual_output: list[str], line: str
-) -> tuple[dict[str, str], list[str]]:
+def transcript_match(captures: dict[str, str], actual_output: list[str], line: 
str) -> tuple[dict[str, str], list[str]]:
     if line == "<.etc.>":
         actual_output[:] = []
         return captures, actual_output
diff --git a/uv.lock b/uv.lock
index c8b3aba..21381e3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,7 +2,7 @@ version = 1
 requires-python = ">=3.13"
 
 [options]
-exclude-newer = "2025-07-11T15:11:00Z"
+exclude-newer = "2025-07-11T19:32:00Z"
 
 [[package]]
 name = "aiohappyeyeballs"
@@ -72,16 +72,28 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl";,
 hash = 
"sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size 
= 7490 },
 ]
 
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz";,
 hash = 
"sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size 
= 16081 }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl";,
 hash = 
"sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size 
= 13643 },
+]
+
 [[package]]
 name = "apache-trusted-releases"
-version = "0.20250711.1511"
+version = "0.20250711.1932"
 source = { editable = "." }
 dependencies = [
     { name = "aiohttp" },
     { name = "cyclopts" },
     { name = "filelock" },
     { name = "platformdirs" },
+    { name = "pydantic" },
     { name = "pyjwt" },
+    { name = "sqlalchemy" },
+    { name = "sqlmodel" },
     { name = "strictyaml" },
 ]
 
@@ -100,7 +112,10 @@ requires-dist = [
     { name = "cyclopts" },
     { name = "filelock" },
     { name = "platformdirs" },
+    { name = "pydantic" },
     { name = "pyjwt" },
+    { name = "sqlalchemy" },
+    { name = "sqlmodel" },
     { name = "strictyaml" },
 ]
 
@@ -234,6 +249,30 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl";,
 hash = 
"sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size 
= 13106 },
 ]
 
+[[package]]
+name = "greenlet"
+version = "3.2.3"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz";,
 hash = 
"sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size 
= 185752 }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl";,
 hash = 
"sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size 
= 270732 },
+    { url = 
"https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl";,
 hash = 
"sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size 
= 639033 },
+    { url = 
"https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl";,
 hash = 
"sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size 
= 652999 },
+    { url = 
"https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl";,
 hash = 
"sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size 
= 647368 },
+    { url = 
"https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl";,
 hash = 
"sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size 
= 650037 },
+    { url = 
"https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl";,
 hash = 
"sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size 
= 608402 },
+    { url = 
"https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl";,
 hash = 
"sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size 
= 1119577 },
+    { url = 
"https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl";,
 hash = 
"sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size 
= 1147121 },
+    { url = 
"https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl";,
 hash = 
"sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size 
= 297603 },
+    { url = 
"https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl";,
 hash = 
"sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size 
= 271479 },
+    { url = 
"https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl";,
 hash = 
"sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size 
= 683952 },
+    { url = 
"https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl";,
 hash = 
"sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size 
= 696917 },
+    { url = 
"https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl";,
 hash = 
"sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size 
= 692443 },
+    { url = 
"https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl";,
 hash = 
"sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size 
= 692995 },
+    { url = 
"https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl";,
 hash = 
"sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size 
= 655320 },
+    { url = 
"https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl";,
 hash = 
"sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size 
= 301236 },
+]
+
 [[package]]
 name = "identify"
 version = "2.6.12"
@@ -420,6 +459,49 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl";,
 hash = 
"sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size 
= 12663 },
 ]
 
+[[package]]
+name = "pydantic"
+version = "2.11.7"
+source = { registry = "https://pypi.org/simple"; }
+dependencies = [
+    { name = "annotated-types" },
+    { name = "pydantic-core" },
+    { name = "typing-extensions" },
+    { name = "typing-inspection" },
+]
+sdist = { url = 
"https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz";,
 hash = 
"sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size 
= 788350 }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl";,
 hash = 
"sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size 
= 444782 },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.33.2"
+source = { registry = "https://pypi.org/simple"; }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = 
"https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz";,
 hash = 
"sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size 
= 435195 }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl";,
 hash = 
"sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size 
= 2015688 },
+    { url = 
"https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl";,
 hash = 
"sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size 
= 1844808 },
+    { url = 
"https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl";,
 hash = 
"sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size 
= 1885580 },
+    { url = 
"https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl";,
 hash = 
"sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size 
= 1973859 },
+    { url = 
"https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl";,
 hash = 
"sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size 
= 2120810 },
+    { url = 
"https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl";,
 hash = 
"sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size 
= 2676498 },
+    { url = 
"https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl";,
 hash = 
"sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size 
= 2000611 },
+    { url = 
"https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl";,
 hash = 
"sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size 
= 2107924 },
+    { url = 
"https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl";,
 hash = 
"sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size 
= 2063196 },
+    { url = 
"https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl";,
 hash = 
"sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size 
= 2236389 },
+    { url = 
"https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl";,
 hash = 
"sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size 
= 2239223 },
+    { url = 
"https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl";,
 hash = 
"sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size 
= 1900473 },
+    { url = 
"https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl";,
 hash = 
"sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size 
= 1955269 },
+    { url = 
"https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl";,
 hash = 
"sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size 
= 1893921 },
+    { url = 
"https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl";,
 hash = 
"sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size 
= 1806162 },
+    { url = 
"https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl";,
 hash = 
"sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size 
= 1981560 },
+    { url = 
"https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl";,
 hash = 
"sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size 
= 1935777 },
+]
+
 [[package]]
 name = "pygments"
 version = "2.19.2"
@@ -542,6 +624,40 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl";,
 hash = 
"sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size 
= 11050 },
 ]
 
+[[package]]
+name = "sqlalchemy"
+version = "2.0.41"
+source = { registry = "https://pypi.org/simple"; }
+dependencies = [
+    { name = "greenlet", marker = "(python_full_version < '3.14' and 
platform_machine == 'AMD64') or (python_full_version < '3.14' and 
platform_machine == 'WIN32') or (python_full_version < '3.14' and 
platform_machine == 'aarch64') or (python_full_version < '3.14' and 
platform_machine == 'amd64') or (python_full_version < '3.14' and 
platform_machine == 'ppc64le') or (python_full_version < '3.14' and 
platform_machine == 'win32') or (python_full_version < '3.14' and 
platform_machine == 'x8 [...]
+    { name = "typing-extensions" },
+]
+sdist = { url = 
"https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz";,
 hash = 
"sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size 
= 9689424 }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl";,
 hash = 
"sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size 
= 2115491 },
+    { url = 
"https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl";,
 hash = 
"sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size 
= 2102827 },
+    { url = 
"https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl";,
 hash = 
"sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size 
= 3225224 },
+    { url = 
"https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl";,
 hash = 
"sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size 
= 3230045 },
+    { url = 
"https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl";,
 hash = 
"sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size 
= 3159357 },
+    { url = 
"https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl";,
 hash = 
"sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size 
= 3197511 },
+    { url = 
"https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl";,
 hash = 
"sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size 
= 2082420 },
+    { url = 
"https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl";,
 hash = 
"sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size 
= 2108329 },
+    { url = 
"https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl";,
 hash = 
"sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size 
= 1911224 },
+]
+
+[[package]]
+name = "sqlmodel"
+version = "0.0.24"
+source = { registry = "https://pypi.org/simple"; }
+dependencies = [
+    { name = "pydantic" },
+    { name = "sqlalchemy" },
+]
+sdist = { url = 
"https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz";,
 hash = 
"sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size 
= 116780 }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl";,
 hash = 
"sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size 
= 28622 },
+]
+
 [[package]]
 name = "strictyaml"
 version = "1.7.3"
@@ -554,6 +670,27 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/96/7c/a81ef5ef10978dd073a854e0fa93b5d8021d0594b639cc8f6453c3c78a1d/strictyaml-1.7.3-py3-none-any.whl";,
 hash = 
"sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7", size 
= 123917 },
 ]
 
+[[package]]
+name = "typing-extensions"
+version = "4.14.1"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz";,
 hash = 
"sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size 
= 107673 }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl";,
 hash = 
"sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size 
= 43906 },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple"; }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = 
"https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz";,
 hash = 
"sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size 
= 75726 }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl";,
 hash = 
"sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size 
= 14552 },
+]
+
 [[package]]
 name = "virtualenv"
 version = "20.31.2"


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to