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 23edcd5  Add a better ORM interface for committees, packages, and 
releases
23edcd5 is described below

commit 23edcd5fd450c175315dacddd4d4af8f0c6cf61a
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Mar 13 21:07:42 2025 +0200

    Add a better ORM interface for committees, packages, and releases
---
 atr/db/__init__.py              | 229 ++++++++++++++++++++++++++++++++++++----
 atr/routes/candidate.py         |  16 ++-
 atr/routes/download.py          |  16 ++-
 atr/routes/project.py           |  19 +---
 atr/templates/project-view.html |   2 +-
 pyproject.toml                  |   1 +
 6 files changed, 228 insertions(+), 55 deletions(-)

diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index 6d2cbba..d522752 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -15,22 +15,28 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from __future__ import annotations
+
 import logging
 import os
-from typing import Any, Final
+from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar
 
-import alembic.config as config
 import quart
 import sqlalchemy
 import sqlalchemy.ext.asyncio
 import sqlalchemy.orm as orm
 import sqlalchemy.sql as sql
 import sqlmodel
+import sqlmodel.sql.expression as expression
 
-import atr.config
+import atr.config as config
+import atr.db.models as models
 import atr.util as util
-from asfquart.base import QuartApp
-from atr.config import AppConfig
+
+if TYPE_CHECKING:
+    from collections.abc import Sequence
+
+    import asfquart.base as base
 
 _LOGGER: Final = logging.getLogger(__name__)
 
@@ -38,7 +44,183 @@ _global_async_sessionmaker: 
sqlalchemy.ext.asyncio.async_sessionmaker | None = N
 _global_sync_engine: sqlalchemy.Engine | None = None
 
 
-def init_database(app: QuartApp) -> None:
+class _DEFAULT: ...
+
+
+DEFAULT = _DEFAULT()
+
+
+T = TypeVar("T")
+
+
+class Query(Generic[T]):
+    def __init__(self, session: Session, query: expression.SelectOfScalar[T]):
+        self.query = query
+        self.session = session
+
+    async def one(self, error: Exception | None = None) -> T | None:
+        result = await self.session.execute(self.query)
+        item = result.scalar_one_or_none()
+        if (item is None) and (error is not None):
+            raise error
+        return item
+
+    async def all(self) -> Sequence[T]:
+        result = await self.session.execute(self.query)
+        return result.scalars().all()
+
+    # async def execute(self) -> sqlalchemy.Result[tuple[T]]:
+    #     return await self.session.execute(self.query)
+
+
+class Session(sqlalchemy.ext.asyncio.AsyncSession):
+    def committee(
+        self,
+        id: Any = DEFAULT,
+        name: Any = DEFAULT,
+        full_name: Any = DEFAULT,
+        is_podling: Any = DEFAULT,
+        parent_pmc_id: Any = DEFAULT,
+        pmc_members: Any = DEFAULT,
+        committers: Any = DEFAULT,
+        release_managers: Any = DEFAULT,
+        vote_policy_id: Any = DEFAULT,
+        _public_signing_keys: bool = False,
+        _vote_policy: bool = False,
+    ) -> Query[models.PMC]:
+        query = sqlmodel.select(models.PMC)
+
+        if id is not DEFAULT:
+            query = query.where(models.PMC.id == id)
+        if name is not DEFAULT:
+            query = query.where(models.PMC.name == name)
+        if full_name is not DEFAULT:
+            query = query.where(models.PMC.full_name == full_name)
+        if is_podling is not DEFAULT:
+            query = query.where(models.PMC.is_podling == is_podling)
+        if parent_pmc_id is not DEFAULT:
+            query = query.where(models.PMC.parent_pmc_id == parent_pmc_id)
+        if pmc_members is not DEFAULT:
+            query = query.where(models.PMC.pmc_members == pmc_members)
+        if committers is not DEFAULT:
+            query = query.where(models.PMC.committers == committers)
+        if release_managers is not DEFAULT:
+            query = query.where(models.PMC.release_managers == 
release_managers)
+        if vote_policy_id is not DEFAULT:
+            query = query.where(models.PMC.vote_policy_id == vote_policy_id)
+
+        if _public_signing_keys:
+            query = 
query.options(select_in_load(models.PMC.public_signing_keys))
+        if _vote_policy:
+            query = query.options(select_in_load(models.PMC.vote_policy))
+
+        return Query(self, query)
+
+    def release(
+        self,
+        storage_key: Any = DEFAULT,
+        stage: Any = DEFAULT,
+        phase: Any = DEFAULT,
+        created: Any = DEFAULT,
+        product_id: Any = DEFAULT,
+        package_managers: Any = DEFAULT,
+        version: Any = DEFAULT,
+        sboms: Any = DEFAULT,
+        vote_policy_id: Any = DEFAULT,
+        votes: Any = DEFAULT,
+        _product: bool = False,
+        _packages: bool = False,
+        _vote_policy: bool = False,
+        _product_project_pmc: bool = False,
+        _packages_tasks: bool = False,
+    ) -> Query[models.Release]:
+        query = sqlmodel.select(models.Release)
+
+        if storage_key is not DEFAULT:
+            query = query.where(models.Release.storage_key == storage_key)
+        if stage is not DEFAULT:
+            query = query.where(models.Release.stage == stage)
+        if phase is not DEFAULT:
+            query = query.where(models.Release.phase == phase)
+        if created is not DEFAULT:
+            query = query.where(models.Release.created == created)
+        if product_id is not DEFAULT:
+            query = query.where(models.Release.product_id == product_id)
+        if package_managers is not DEFAULT:
+            query = query.where(models.Release.package_managers == 
package_managers)
+        if version is not DEFAULT:
+            query = query.where(models.Release.version == version)
+        if sboms is not DEFAULT:
+            query = query.where(models.Release.sboms == sboms)
+        if vote_policy_id is not DEFAULT:
+            query = query.where(models.Release.vote_policy_id == 
vote_policy_id)
+        if votes is not DEFAULT:
+            query = query.where(models.Release.votes == votes)
+
+        if _product:
+            query = query.options(select_in_load(models.Release.product))
+        if _packages:
+            query = query.options(select_in_load(models.Release.packages))
+        if _vote_policy:
+            query = query.options(select_in_load(models.Release.vote_policy))
+        if _product_project_pmc:
+            query = query.options(
+                select_in_load_nested(models.Release.product, 
models.Product.project, models.Project.pmc)
+            )
+        if _packages_tasks:
+            query = 
query.options(select_in_load_nested(models.Release.packages, 
models.Package.tasks))
+
+        return Query(self, query)
+
+    def package(
+        self,
+        artifact_sha3: Any = DEFAULT,
+        artifact_type: Any = DEFAULT,
+        filename: Any = DEFAULT,
+        sha512: Any = DEFAULT,
+        signature_sha3: Any = DEFAULT,
+        uploaded: Any = DEFAULT,
+        bytes_size: Any = DEFAULT,
+        release_key: Any = DEFAULT,
+        _release: bool = False,
+        _tasks: bool = False,
+        _release_product: bool = False,
+        _release_pmc: bool = False,
+    ) -> Query[models.Package]:
+        query = sqlmodel.select(models.Package)
+
+        if artifact_sha3 is not DEFAULT:
+            query = query.where(models.Package.artifact_sha3 == artifact_sha3)
+        if artifact_type is not DEFAULT:
+            query = query.where(models.Package.artifact_type == artifact_type)
+        if filename is not DEFAULT:
+            query = query.where(models.Package.filename == filename)
+        if sha512 is not DEFAULT:
+            query = query.where(models.Package.sha512 == sha512)
+        if signature_sha3 is not DEFAULT:
+            query = query.where(models.Package.signature_sha3 == 
signature_sha3)
+        if uploaded is not DEFAULT:
+            query = query.where(models.Package.uploaded == uploaded)
+        if bytes_size is not DEFAULT:
+            query = query.where(models.Package.bytes_size == bytes_size)
+        if release_key is not DEFAULT:
+            query = query.where(models.Package.release_key == release_key)
+        if _release:
+            query = query.options(select_in_load(models.Package.release))
+        if _tasks:
+            query = query.options(select_in_load(models.Package.tasks))
+        if _release_product:
+            query = query.options(select_in_load(models.Package.release, 
models.Release.product))
+        if _release_pmc:
+            query = query.options(
+                select_in_load_nested(
+                    models.Package.release, models.Release.product, 
models.Product.project, models.Project.pmc
+                )
+            )
+        return Query(self, query)
+
+
+def init_database(app: base.QuartApp) -> None:
     """
     Creates and initializes the database for a QuartApp.
 
@@ -48,12 +230,15 @@ def init_database(app: QuartApp) -> None:
 
     @app.before_serving
     async def create() -> None:
-        app_config = atr.config.get()
+        app_config = config.get()
         engine = create_async_engine(app_config)
 
         app.extensions["async_session"] = 
sqlalchemy.ext.asyncio.async_sessionmaker(
             bind=engine, class_=sqlalchemy.ext.asyncio.AsyncSession, 
expire_on_commit=False
         )
+        app.extensions["atr_db_session"] = 
sqlalchemy.ext.asyncio.async_sessionmaker(
+            bind=engine, class_=Session, expire_on_commit=False
+        )
 
         # Set SQLite pragmas for better performance
         # Use 64 MB for the cache_size, and 5000ms for busy_timeout
@@ -68,15 +253,15 @@ def init_database(app: QuartApp) -> None:
         # In dev we'd do this first:
         # poetry run alembic revision --autogenerate -m "description"
         # Then review the generated migration in migrations/versions/ and 
commit it
-        project_root = app_config.PROJECT_ROOT
-        alembic_ini_path = os.path.join(project_root, "alembic.ini")
-        alembic_cfg = config.Config(alembic_ini_path)
-        # Override the migrations directory location to use project root
-        # TODO: Is it possible to set this in alembic.ini?
-        alembic_cfg.set_main_option("script_location", 
os.path.join(project_root, "migrations"))
-        # Set the database URL in the config
-        alembic_cfg.set_main_option("sqlalchemy.url", str(engine.url))
-        # command.upgrade(alembic_cfg, "head")
+        # project_root = app_config.PROJECT_ROOT
+        # alembic_ini_path = os.path.join(project_root, "alembic.ini")
+        # alembic_cfg = config.Config(alembic_ini_path)
+        # # Override the migrations directory location to use project root
+        # # TODO: Is it possible to set this in alembic.ini?
+        # alembic_cfg.set_main_option("script_location", 
os.path.join(project_root, "migrations"))
+        # # Set the database URL in the config
+        # alembic_cfg.set_main_option("sqlalchemy.url", str(engine.url))
+        # # command.upgrade(alembic_cfg, "head")
 
         # Create any tables that might be missing
         async with engine.begin() as conn:
@@ -87,13 +272,13 @@ def init_database_for_worker() -> None:
     global _global_async_sessionmaker
 
     _LOGGER.info(f"Creating database for worker {os.getpid()}")
-    engine = create_async_engine(atr.config.get())
+    engine = create_async_engine(config.get())
     _global_async_sessionmaker = sqlalchemy.ext.asyncio.async_sessionmaker(
         bind=engine, class_=sqlalchemy.ext.asyncio.AsyncSession, 
expire_on_commit=False
     )
 
 
-def create_async_engine(app_config: type[AppConfig]) -> 
sqlalchemy.ext.asyncio.AsyncEngine:
+def create_async_engine(app_config: type[config.AppConfig]) -> 
sqlalchemy.ext.asyncio.AsyncEngine:
     sqlite_url = f"sqlite+aiosqlite://{app_config.SQLITE_DB_PATH}"
     # Use aiosqlite for async SQLite access
     engine = sqlalchemy.ext.asyncio.create_async_engine(
@@ -118,13 +303,19 @@ def create_async_db_session() -> 
sqlalchemy.ext.asyncio.AsyncSession:
         return util.validate_as_type(_global_async_sessionmaker(), 
sqlalchemy.ext.asyncio.AsyncSession)
 
 
+def session() -> Session:
+    """Create a new asynchronous database session."""
+    extensions = quart.current_app.extensions
+    return util.validate_as_type(extensions["atr_db_session"](), Session)
+
+
 # FIXME: this method is deprecated and should be removed
 def create_sync_db_engine() -> None:
     """Create a synchronous database engine."""
 
     global _global_sync_engine
 
-    conf = atr.config.get()
+    conf = config.get()
     sqlite_url = f"sqlite://{conf.SQLITE_DB_PATH}"
     _LOGGER.debug(f"Creating sync database engine in process {os.getpid()}")
     _global_sync_engine = sqlalchemy.create_engine(sqlite_url, echo=False)
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 1e5ed79..dd96f6b 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -169,20 +169,16 @@ async def root_candidate_review() -> str:
     if web_session is None:
         raise base.ASFQuartException("Not authenticated", errorcode=401)
 
-    async with db.create_async_db_session() as db_session:
+    async with db.session() as data:
         # Get all releases where the user is a PMC member or committer
         # TODO: We don't actually record who uploaded the release candidate
         # We should probably add that information!
         # TODO: This duplicates code in root_package_add
-        statement = (
-            sqlmodel.select(models.Release)
-            .options(
-                db.select_in_load_nested(models.Release.product, 
models.Product.project, models.Project.pmc),
-                db.select_in_load_nested(models.Release.packages, 
models.Package.tasks),
-            )
-            .where(models.Release.stage == models.ReleaseStage.CANDIDATE)
-        )
-        releases = (await db_session.execute(statement)).scalars().all()
+        releases = await data.release(
+            stage=models.ReleaseStage.CANDIDATE,
+            _product_project_pmc=True,
+            _packages_tasks=True,
+        ).all()
 
         # Filter to only show releases for PMCs or PPMCs where the user is a 
member or committer
         user_releases = []
diff --git a/atr/routes/download.py b/atr/routes/download.py
index b6431fe..9b850ba 100644
--- a/atr/routes/download.py
+++ b/atr/routes/download.py
@@ -44,17 +44,13 @@ async def root_download_artifact(release_key: str, 
artifact_sha3: str) -> respon
     if (web_session is None) or (web_session.uid is None):
         raise base.ASFQuartException("Not authenticated", errorcode=401)
 
-    async with db.create_async_db_session() as db_session:
+    async with db.session() as data:
         # Find the package
-        package_release = db.select_in_load(models.Package.release)
-        release_pmc = db.select_in_load(models.Release.pmc)
-        package_statement = (
-            sqlmodel.select(models.Package)
-            .where(models.Package.artifact_sha3 == artifact_sha3, 
models.Package.release_key == release_key)
-            .options(package_release, release_pmc)
-        )
-        result = await db_session.execute(package_statement)
-        package = result.scalar_one_or_none()
+        package = await data.package(
+            artifact_sha3=artifact_sha3,
+            release_key=release_key,
+            _release_pmc=True,
+        ).one()
 
         if not package:
             await quart.flash("Artifact not found", "error")
diff --git a/atr/routes/project.py b/atr/routes/project.py
index 3e8bd8c..c43e181 100644
--- a/atr/routes/project.py
+++ b/atr/routes/project.py
@@ -44,22 +44,11 @@ async def root_project_directory() -> str:
 
 @routes.app_route("/projects/<name>")
 async def root_project_view(name: str) -> str:
-    async with db.create_async_db_session() as db_session:
-        statement = (
-            sqlmodel.select(models.PMC)
-            .where(models.PMC.name == name)
-            .options(
-                db.select_in_load(models.PMC.public_signing_keys),
-                db.select_in_load(models.PMC.vote_policy),
-            )
+    async with db.session() as data:
+        pmc = await data.committee(name=name, _public_signing_keys=True, 
_vote_policy=True).one(
+            error=http.client.HTTPException(404)
         )
-
-        project = (await db_session.execute(statement)).scalar_one_or_none()
-
-        if not project:
-            raise http.client.HTTPException(404)
-
-        return await quart.render_template("project-view.html", 
project=project, algorithms=routes.algorithms)
+        return await quart.render_template("project-view.html", project=pmc, 
algorithms=routes.algorithms)
 
 
 @routes.app_route("/projects/<name>/voting/create", methods=["GET", "POST"])
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index 6842ee5..adfcd58 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -172,7 +172,7 @@
     <div class="actions">
       <!-- TODO: This is a PMC, not a project -->
       <a class="add-link"
-         href="{{ url_for('root_project_voting_policy_add', 
project_name=project.name) }}"><i class="fa-solid fa-plus"></i></a>
+         href="{{ url_for('root_project_voting_policy_add', name=project.name) 
}}"><i class="fa-solid fa-plus"></i></a>
     </div>
     <!--    <div class="card-meta"></div>-->
     <div class="card-body">
diff --git a/pyproject.toml b/pyproject.toml
index e4b8a98..4ad1daf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -127,6 +127,7 @@ select = [
 
 [tool.ruff.lint.per-file-ignores]
 "atr/apache.py" = ["N815", "TC003"]
+"atr/db/__init__.py" = ["C901"]
 
 [tool.mypy]
 python_version = "3.13"


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

Reply via email to