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]