This is an automated email from the ASF dual-hosted git repository.
tn pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-atr-experiments.git
The following commit(s) were added to refs/heads/main by this push:
new dd5203e move database specific code to db module, add service module,
use get_session() method, logout shall redirect to home, add debug / production
config mechanism, rework server initialization code
dd5203e is described below
commit dd5203ef7a69a74e1db2439c7571ee8d85b1ec0e
Author: Thomas Neidhart <[email protected]>
AuthorDate: Mon Feb 17 21:35:57 2025 +0100
move database specific code to db module, add service module, use
get_session() method, logout shall redirect to home, add debug / production
config mechanism, rework server initialization code
---
.gitignore | 2 +
atr/blueprints/secret/__init__.py | 13 +--
atr/blueprints/secret/secret.py | 32 +++---
atr/config.py | 60 +++++++++++
atr/{server.py => db/__init__.py} | 81 ++-------------
atr/{ => db}/models.py | 2 +-
atr/db/service.py | 39 +++++++
atr/routes.py | 147 +++++++++++----------------
atr/server.py | 123 ++++++++--------------
atr/templates/includes/sidebar.html | 2 +-
atr/templates/{ => secret}/data-browser.html | 0
atr/templates/{ => secret}/update-pmcs.html | 0
atr/util.py | 45 ++++++++
poetry.lock | 14 ++-
pyproject.toml | 1 +
uv.lock | 15 ++-
16 files changed, 302 insertions(+), 274 deletions(-)
diff --git a/.gitignore b/.gitignore
index ca93b00..4ff39cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,5 @@
.vscode/
__pycache__/
state/
+
+apptoken.txt
diff --git a/atr/blueprints/secret/__init__.py
b/atr/blueprints/secret/__init__.py
index df80c2d..dfd4942 100644
--- a/atr/blueprints/secret/__init__.py
+++ b/atr/blueprints/secret/__init__.py
@@ -21,19 +21,10 @@ from asfquart.auth import Requirements as R
from asfquart.auth import require
from asfquart.base import ASFQuartException
from asfquart.session import read as session_read
+from atr.util import get_admin_users
blueprint = Blueprint("secret_blueprint", __name__, url_prefix="/secret")
-ALLOWED_USERS = {
- "cwells",
- "fluxo",
- "gmcdonald",
- "humbedooh",
- "sbp",
- "tn",
- "wave",
-}
-
@blueprint.before_request
async def before_request_func() -> None:
@@ -43,7 +34,7 @@ async def before_request_func() -> None:
if session is None:
raise ASFQuartException("Not authenticated", errorcode=401)
- if session.uid not in ALLOWED_USERS:
+ if session.uid not in get_admin_users():
raise ASFQuartException("You are not authorized to access the
admin interface", errorcode=403)
await check_logged_in()
diff --git a/atr/blueprints/secret/secret.py b/atr/blueprints/secret/secret.py
index d0daa5b..7ec78c5 100644
--- a/atr/blueprints/secret/secret.py
+++ b/atr/blueprints/secret/secret.py
@@ -22,8 +22,8 @@ from quart import current_app, render_template, request
from sqlmodel import select
from asfquart.base import ASFQuartException
-
-from ...models import (
+from atr.db import get_session
+from atr.db.models import (
PMC,
DistributionChannel,
Package,
@@ -33,13 +33,15 @@ from ...models import (
Release,
VotePolicy,
)
+from atr.db.service import get_pmcs
+
from . import blueprint
@blueprint.route("/data")
@blueprint.route("/data/<model>")
async def secret_data(model: str = "PMC") -> str:
- "Browse all records in the database."
+ """Browse all records in the database."""
# Map of model names to their classes
models = {
@@ -54,11 +56,9 @@ async def secret_data(model: str = "PMC") -> str:
}
if model not in models:
- # Default to PMC if invalid model specified
- model = "PMC"
+ raise ASFQuartException(f"Model type '{model}' not found", 404)
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
+ async with get_session() as db_session:
# Get all records for the selected model
statement = select(models[model])
records = (await db_session.execute(statement)).scalars().all()
@@ -79,12 +79,14 @@ async def secret_data(model: str = "PMC") -> str:
record_dict[key] = getattr(record, key)
records_dict.append(record_dict)
- return await render_template("data-browser.html",
models=list(models.keys()), model=model, records=records_dict)
+ return await render_template(
+ "secret/data-browser.html", models=list(models.keys()),
model=model, records=records_dict
+ )
@blueprint.route("/pmcs/update", methods=["GET", "POST"])
async def secret_pmcs_update() -> str:
- "Update PMCs from remote, authoritative committee-info.json."
+ """Update PMCs from remote, authoritative committee-info.json."""
if request.method == "POST":
# Fetch committee-info.json from Whimsy
@@ -100,8 +102,7 @@ async def secret_pmcs_update() -> str:
committees = data.get("committees", {})
updated_count = 0
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
+ async with get_session() as db_session:
async with db_session.begin():
for committee_id, info in committees.items():
# Skip non-PMC committees
@@ -147,14 +148,11 @@ async def secret_pmcs_update() -> str:
return f"Successfully updated {updated_count} PMCs from Whimsy"
# For GET requests, show the update form
- return await render_template("update-pmcs.html")
+ return await render_template("secret/update-pmcs.html")
@blueprint.route("/debug/database")
async def secret_debug_database() -> str:
"""Debug information about the database."""
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
- statement = select(PMC)
- pmcs = (await db_session.execute(statement)).scalars().all()
- return f"Database using {current_app.config['DATA_MODELS_FILE']} has
{len(pmcs)} PMCs"
+ pmcs = await get_pmcs()
+ return f"Database using {current_app.config['DATA_MODELS_FILE']} has
{len(pmcs)} PMCs"
diff --git a/atr/config.py b/atr/config.py
new file mode 100644
index 0000000..f2154ec
--- /dev/null
+++ b/atr/config.py
@@ -0,0 +1,60 @@
+# 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 os
+
+from decouple import config
+
+from atr.db.models import __file__ as data_models_file
+
+
+class AppConfig:
+ # Get the project root directory (where alembic.ini is)
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ STATE_DIR = os.path.join(PROJECT_ROOT, "state")
+
+ RELEASE_STORAGE_DIR = os.path.join(STATE_DIR, "releases")
+ DATA_MODELS_FILE = data_models_file
+
+ # Use aiosqlite for async SQLite access
+ SQLITE_URL = config("SQLITE_URL", default="sqlite+aiosqlite:///./atr.db")
+
+ ADMIN_USERS = {
+ "cwells",
+ "fluxo",
+ "gmcdonald",
+ "humbedooh",
+ "sbp",
+ "tn",
+ "wave",
+ }
+
+
+class ProductionConfig(AppConfig):
+ DEBUG = False
+
+
+class DebugConfig(AppConfig):
+ DEBUG = True
+ TEMPLATES_AUTO_RELOAD = True
+
+
+# Load all possible configurations
+config_dict = {
+ "Debug": DebugConfig,
+ "Production": ProductionConfig,
+}
diff --git a/atr/server.py b/atr/db/__init__.py
similarity index 54%
copy from atr/server.py
copy to atr/db/__init__.py
index 97314b3..21e200e 100644
--- a/atr/server.py
+++ b/atr/db/__init__.py
@@ -15,68 +15,25 @@
# specific language governing permissions and limitations
# under the License.
-"server.py"
-
import os
from alembic import command
from alembic.config import Config
+from quart import current_app
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker,
create_async_engine
from sqlalchemy.sql import text
from sqlmodel import SQLModel
-import asfquart
-import asfquart.generics
-import asfquart.session
from asfquart.base import QuartApp
-from atr.blueprints import register_blueprints
-
-from .models import __file__ as data_models_file
-
-# Avoid OIDC
-asfquart.generics.OAUTH_URL_INIT =
"https://oauth.apache.org/auth?state=%s&redirect_uri=%s"
-asfquart.generics.OAUTH_URL_CALLBACK = "https://oauth.apache.org/token?code=%s"
-
-
-def register_routes() -> str:
- from . import routes
-
- # Must do this otherwise ruff "fixes" this function by removing the import.
- return routes.__name__
-
-
-def create_app() -> QuartApp:
- if asfquart.construct is ...:
- raise ValueError("asfquart.construct is not set")
- app = asfquart.construct(__name__)
-
- # # Configure static folder path before changing working directory
- # app.static_folder =
os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
- @app.context_processor
- async def app_wide():
- return {"current_user": await asfquart.session.read()}
+def create_database(app: QuartApp) -> None:
@app.before_serving
- async def create_database() -> None:
- # Get the project root directory (where alembic.ini is)
- project_root =
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-
- # Change working directory to "./state"
- state_dir = os.path.join(project_root, "state")
- if not os.path.isdir(state_dir):
- raise RuntimeError(f"State directory not found: {state_dir}")
- os.chdir(state_dir)
- print(f"Working directory changed to: {os.getcwd()}")
-
- # Set up release storage directory
- release_storage = os.path.join(state_dir, "releases")
- os.makedirs(release_storage, exist_ok=True)
- app.config["RELEASE_STORAGE_DIR"] = release_storage
- app.config["DATA_MODELS_FILE"] = data_models_file
+ async def create() -> None:
+ project_root = app.config["PROJECT_ROOT"]
+ sqlite_url = app.config["SQLITE_URL"]
# Use aiosqlite for async SQLite access
- sqlite_url = "sqlite+aiosqlite:///./atr.db"
engine = create_async_engine(
sqlite_url,
connect_args={
@@ -87,7 +44,7 @@ def create_app() -> QuartApp:
# Create async session factory
async_session = async_sessionmaker(bind=engine, class_=AsyncSession,
expire_on_commit=False)
- app.config["async_session"] = async_session
+ app.async_session = async_session # type: ignore
# Set SQLite pragmas for better performance
# Use 64 MB for the cache_size, and 5000ms for busy_timeout
@@ -115,28 +72,6 @@ def create_app() -> QuartApp:
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
- app.config["engine"] = engine
- # TODO: apply this only for debug
- app.config["TEMPLATES_AUTO_RELOAD"] = True
-
- @app.after_serving
- async def shutdown() -> None:
- app.background_tasks.clear()
-
- register_routes()
- register_blueprints(app)
-
- return app
-
-
-def main() -> None:
- "Quart debug server"
- app = create_app()
- app.run(port=8080, ssl_keyfile="key.pem", ssl_certfile="cert.pem")
-
-app = None
-if __name__ == "__main__":
- main()
-else:
- app = create_app()
+def get_session() -> AsyncSession:
+ return current_app.async_session() # type: ignore
diff --git a/atr/models.py b/atr/db/models.py
similarity index 99%
rename from atr/models.py
rename to atr/db/models.py
index ea56aa6..ef44270 100644
--- a/atr/models.py
+++ b/atr/db/models.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-"models.py"
+"""models.py"""
import datetime
from enum import Enum
diff --git a/atr/db/service.py b/atr/db/service.py
new file mode 100644
index 0000000..5a1b06a
--- /dev/null
+++ b/atr/db/service.py
@@ -0,0 +1,39 @@
+# 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 Sequence
+
+from sqlmodel import select
+
+from atr.db.models import PMC
+
+from . import get_session
+
+
+async def get_pmc_by_name(project_name: str) -> PMC | None:
+ async with get_session() as db_session:
+ statement = select(PMC).where(PMC.project_name == project_name)
+ pmc = (await db_session.execute(statement)).scalar_one_or_none()
+ return pmc
+
+
+async def get_pmcs() -> Sequence[PMC]:
+ async with get_session() as db_session:
+ # Get all PMCs and their latest releases
+ statement = select(PMC)
+ pmcs = (await db_session.execute(statement)).scalars().all()
+ return pmcs
diff --git a/atr/routes.py b/atr/routes.py
index 4b8268a..0a77fb3 100644
--- a/atr/routes.py
+++ b/atr/routes.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-"routes.py"
+"""routes.py"""
import asyncio
import datetime
@@ -32,7 +32,7 @@ from typing import Any, cast
import aiofiles
import aiofiles.os
import gnupg
-from quart import Request, current_app, render_template, request
+from quart import Request, render_template, request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import InstrumentedAttribute
@@ -45,8 +45,7 @@ from asfquart.auth import require
from asfquart.base import ASFQuartException
from asfquart.session import ClientSession
from asfquart.session import read as session_read
-
-from .models import (
+from atr.db.models import (
PMC,
Package,
PMCKeyLink,
@@ -56,10 +55,24 @@ from .models import (
ReleaseStage,
)
+from .db import get_session
+from .db.service import get_pmc_by_name, get_pmcs
+from .util import compute_sha512, get_release_storage_dir
+
if APP is ...:
raise ValueError("APP is not set")
-ALLOWED_USERS = {"cwells", "fluxo", "gmcdonald", "humbedooh", "sbp", "tn",
"wave"}
+
[email protected]("/")
+async def root() -> str:
+ """Main page."""
+ return await render_template("index.html")
+
+
[email protected]("/pages")
+async def root_pages() -> str:
+ """List all pages on the website."""
+ return await render_template("pages.html")
async def add_release_candidate_post(session: ClientSession, request: Request)
-> str:
@@ -92,7 +105,7 @@ async def add_release_candidate_post(session: ClientSession,
request: Request) -
raise ASFQuartException("Signature file must have .asc extension",
errorcode=400)
# Save files using their hashes as filenames
- uploads_path = Path(current_app.config["RELEASE_STORAGE_DIR"])
+ uploads_path = Path(get_release_storage_dir())
artifact_hash = await save_file_by_hash(uploads_path, artifact_file)
# TODO: Do we need to do anything with the signature hash?
# These should be identical, but path might be absolute?
@@ -106,8 +119,7 @@ async def add_release_candidate_post(session:
ClientSession, request: Request) -
checksum_512 = compute_sha512(uploads_path / artifact_hash)
# Store in database
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
+ async with get_session() as db_session:
async with db_session.begin():
# Get PMC
statement = select(PMC).where(PMC.project_name == project_name)
@@ -153,30 +165,10 @@ async def ephemeral_gpg_home():
await asyncio.to_thread(shutil.rmtree, temp_dir)
-def compute_sha3_256(file_data: bytes) -> str:
- "Compute SHA3-256 hash of file data."
- return hashlib.sha3_256(file_data).hexdigest()
-
-
-def compute_sha512(file_path: Path) -> str:
- "Compute SHA-512 hash of a file."
- sha512 = hashlib.sha512()
- with open(file_path, "rb") as f:
- for chunk in iter(lambda: f.read(4096), b""):
- sha512.update(chunk)
- return sha512.hexdigest()
-
-
[email protected]("/")
-async def root() -> str:
- """Main page."""
- return await render_template("index.html")
-
-
@APP.route("/add-release-candidate", methods=["GET", "POST"])
@require(R.committer)
async def root_add_release_candidate() -> str:
- "Add a release candidate to the database."
+ """Add a release candidate to the database."""
session = await session_read()
if session is None:
raise ASFQuartException("Not authenticated", errorcode=401)
@@ -202,8 +194,7 @@ async def root_release_signatures_verify(release_key: str)
-> str:
if session is None:
raise ASFQuartException("Not authenticated", errorcode=401)
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
+ async with get_session() as db_session:
# Get the release and its packages, and PMC with its keys
release_packages =
selectinload(cast(InstrumentedAttribute[list[Package]], Release.packages))
release_pmc = selectinload(cast(InstrumentedAttribute[PMC],
Release.pmc))
@@ -229,7 +220,7 @@ async def root_release_signatures_verify(release_key: str)
-> str:
# Verify each package's signature
verification_results = []
- storage_dir = Path(current_app.config["RELEASE_STORAGE_DIR"])
+ storage_dir = Path(get_release_storage_dir())
for package in release.packages:
result = {"file": package.file}
@@ -253,30 +244,20 @@ async def root_release_signatures_verify(release_key:
str) -> str:
)
[email protected]("/pages")
-async def root_pages() -> str:
- "List all pages on the website."
- return await render_template("pages.html")
-
-
@APP.route("/pmc/<project_name>")
async def root_pmc_arg(project_name: str) -> dict:
- "Get a specific PMC by project name."
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
- statement = select(PMC).where(PMC.project_name == project_name)
- pmc = (await db_session.execute(statement)).scalar_one_or_none()
-
- if not pmc:
- raise ASFQuartException("PMC not found", errorcode=404)
+ """Get a specific PMC by project name."""
+ pmc = await get_pmc_by_name(project_name)
+ if not pmc:
+ raise ASFQuartException("PMC not found", errorcode=404)
- return {
- "id": pmc.id,
- "project_name": pmc.project_name,
- "pmc_members": pmc.pmc_members,
- "committers": pmc.committers,
- "release_managers": pmc.release_managers,
- }
+ return {
+ "id": pmc.id,
+ "project_name": pmc.project_name,
+ "pmc_members": pmc.pmc_members,
+ "committers": pmc.committers,
+ "release_managers": pmc.release_managers,
+ }
# @APP.route("/pmc/create/<project_name>")
@@ -289,8 +270,7 @@ async def root_pmc_arg(project_name: str) -> dict:
# release_managers=["alice"],
# )
-# async_session = current_app.config["async_session"]
-# async with async_session() as db_session:
+# async with get_session() as db_session:
# async with db_session.begin():
# try:
# db_session.add(pmc)
@@ -312,39 +292,32 @@ async def root_pmc_arg(project_name: str) -> dict:
@APP.route("/pmc/directory")
async def root_pmc_directory() -> str:
- "Main PMC directory page."
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
- # Get all PMCs and their latest releases
- statement = select(PMC)
- pmcs = (await db_session.execute(statement)).scalars().all()
- return await render_template("pmc-directory.html", pmcs=pmcs)
+ """Main PMC directory page."""
+ pmcs = await get_pmcs()
+ return await render_template("pmc-directory.html", pmcs=pmcs)
@APP.route("/pmc/list")
async def root_pmc_list() -> list[dict]:
- "List all PMCs in the database."
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
- statement = select(PMC)
- pmcs = (await db_session.execute(statement)).scalars().all()
-
- return [
- {
- "id": pmc.id,
- "project_name": pmc.project_name,
- "pmc_members": pmc.pmc_members,
- "committers": pmc.committers,
- "release_managers": pmc.release_managers,
- }
- for pmc in pmcs
- ]
+ """List all PMCs in the database."""
+ pmcs = await get_pmcs()
+
+ return [
+ {
+ "id": pmc.id,
+ "project_name": pmc.project_name,
+ "pmc_members": pmc.pmc_members,
+ "committers": pmc.committers,
+ "release_managers": pmc.release_managers,
+ }
+ for pmc in pmcs
+ ]
@APP.route("/user/keys/add", methods=["GET", "POST"])
@require(R.committer)
async def root_user_keys_add() -> str:
- "Add a new GPG key to the user's account."
+ """Add a new GPG key to the user's account."""
session = await session_read()
if session is None:
raise ASFQuartException("Not authenticated", errorcode=401)
@@ -354,8 +327,7 @@ async def root_user_keys_add() -> str:
user_keys = []
# Get all existing keys for the user
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
+ async with get_session() as db_session:
statement = select(PublicSigningKey).where(PublicSigningKey.user_id ==
session.uid)
user_keys = (await db_session.execute(statement)).scalars().all()
@@ -380,13 +352,12 @@ async def root_user_keys_add() -> str:
@APP.route("/user/keys/delete")
@require(R.committer)
async def root_user_keys_delete() -> str:
- "Debug endpoint to delete all of a user's keys."
+ """Debug endpoint to delete all of a user's keys."""
session = await session_read()
if session is None:
raise ASFQuartException("Not authenticated", errorcode=401)
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
+ async with get_session() as db_session:
async with db_session.begin():
# Get all keys for the user
# TODO: Might be clearer if user_id were "asf_id"
@@ -405,13 +376,12 @@ async def root_user_keys_delete() -> str:
@APP.route("/user/uploads")
@require(R.committer)
async def root_user_uploads() -> str:
- "Show all release candidates uploaded by the current user."
+ """Show all release candidates uploaded by the current user."""
session = await session_read()
if session is None:
raise ASFQuartException("Not authenticated", errorcode=401)
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
+ async with get_session() as db_session:
# Get all releases where the user is a PMC member of the associated PMC
# TODO: We don't actually record who uploaded the release candidate
# We should probably add that information!
@@ -505,8 +475,7 @@ async def user_keys_add(session: ClientSession, public_key:
str) -> tuple[str, d
return ("Key is not long enough; must be at least 2048 bits", None)
# Store key in database
- async_session = current_app.config["async_session"]
- async with async_session() as db_session:
+ async with get_session() as db_session:
return await user_keys_add_session(session, public_key, key,
db_session)
diff --git a/atr/server.py b/atr/server.py
index 97314b3..738a12b 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -15,23 +15,20 @@
# specific language governing permissions and limitations
# under the License.
-"server.py"
+"""server.py"""
+import logging
import os
-from alembic import command
-from alembic.config import Config
-from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker,
create_async_engine
-from sqlalchemy.sql import text
-from sqlmodel import SQLModel
+from decouple import config
import asfquart
import asfquart.generics
import asfquart.session
from asfquart.base import QuartApp
from atr.blueprints import register_blueprints
-
-from .models import __file__ as data_models_file
+from atr.config import AppConfig, config_dict
+from atr.db import create_database
# Avoid OIDC
asfquart.generics.OAUTH_URL_INIT =
"https://oauth.apache.org/auth?state=%s&redirect_uri=%s"
@@ -45,98 +42,66 @@ def register_routes() -> str:
return routes.__name__
-def create_app() -> QuartApp:
+def create_app(app_config: type[AppConfig]) -> QuartApp:
if asfquart.construct is ...:
raise ValueError("asfquart.construct is not set")
app = asfquart.construct(__name__)
+ app.config.from_object(app_config)
# # Configure static folder path before changing working directory
# app.static_folder =
os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
+ create_database(app)
+ register_routes()
+ register_blueprints(app)
+
@app.context_processor
async def app_wide():
return {"current_user": await asfquart.session.read()}
- @app.before_serving
- async def create_database() -> None:
- # Get the project root directory (where alembic.ini is)
- project_root =
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-
- # Change working directory to "./state"
- state_dir = os.path.join(project_root, "state")
- if not os.path.isdir(state_dir):
- raise RuntimeError(f"State directory not found: {state_dir}")
- os.chdir(state_dir)
- print(f"Working directory changed to: {os.getcwd()}")
-
- # Set up release storage directory
- release_storage = os.path.join(state_dir, "releases")
- os.makedirs(release_storage, exist_ok=True)
- app.config["RELEASE_STORAGE_DIR"] = release_storage
- app.config["DATA_MODELS_FILE"] = data_models_file
-
- # Use aiosqlite for async SQLite access
- sqlite_url = "sqlite+aiosqlite:///./atr.db"
- engine = create_async_engine(
- sqlite_url,
- connect_args={
- "check_same_thread": False,
- "timeout": 30,
- },
- )
-
- # Create async session factory
- async_session = async_sessionmaker(bind=engine, class_=AsyncSession,
expire_on_commit=False)
- app.config["async_session"] = async_session
-
- # Set SQLite pragmas for better performance
- # Use 64 MB for the cache_size, and 5000ms for busy_timeout
- async with engine.begin() as conn:
- await conn.execute(text("PRAGMA journal_mode=WAL"))
- await conn.execute(text("PRAGMA synchronous=NORMAL"))
- await conn.execute(text("PRAGMA cache_size=-64000"))
- await conn.execute(text("PRAGMA foreign_keys=ON"))
- await conn.execute(text("PRAGMA busy_timeout=5000"))
-
- # Run any pending migrations
- # 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
- alembic_ini_path = os.path.join(project_root, "alembic.ini")
- alembic_cfg = 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", sqlite_url)
- command.upgrade(alembic_cfg, "head")
-
- # Create any tables that might be missing
- async with engine.begin() as conn:
- await conn.run_sync(SQLModel.metadata.create_all)
-
- app.config["engine"] = engine
- # TODO: apply this only for debug
- app.config["TEMPLATES_AUTO_RELOAD"] = True
-
@app.after_serving
async def shutdown() -> None:
app.background_tasks.clear()
- register_routes()
- register_blueprints(app)
-
return app
+# WARNING: Don't run with debug turned on in production!
+DEBUG: bool = config("DEBUG", default=True, cast=bool)
+
+# Determine which configuration to use
+config_mode = "Debug" if DEBUG else "Production"
+
+try:
+ app_config = config_dict[config_mode]
+except KeyError:
+ exit("Error: Invalid <config_mode>. Expected values [Debug, Production] ")
+
+if not os.path.isdir(app_config.STATE_DIR):
+ raise RuntimeError(f"State directory not found: {app_config.STATE_DIR}")
+os.chdir(app_config.STATE_DIR)
+print(f"Working directory changed to: {os.getcwd()}")
+
+os.makedirs(app_config.RELEASE_STORAGE_DIR, exist_ok=True)
+
+app = create_app(app_config)
+
+logging.basicConfig(
+ format="[%(asctime)s.%(msecs)03d ] [%(process)d] [%(levelname)s]
%(message)s",
+ level=logging.INFO,
+ datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+if DEBUG:
+ app.logger.info("DEBUG = " + str(DEBUG))
+ app.logger.info("ENVIRONMENT = " + config_mode)
+ app.logger.info("STATE_DIR = " + app_config.STATE_DIR)
+
+
def main() -> None:
- "Quart debug server"
- app = create_app()
+ """Quart debug server"""
app.run(port=8080, ssl_keyfile="key.pem", ssl_certfile="cert.pem")
-app = None
if __name__ == "__main__":
main()
-else:
- app = create_app()
diff --git a/atr/templates/includes/sidebar.html
b/atr/templates/includes/sidebar.html
index c9fdcc7..f197a4b 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -17,7 +17,7 @@
(<code>{{ current_user.uid }}</code>)
</div>
<a href="#"
- onclick="location.href='/auth?logout=' + window.location.pathname;"
+ onclick="location.href='/auth?logout=/';"
class="logout-link">Logout</a>
{% else %}
<a href="#"
diff --git a/atr/templates/data-browser.html
b/atr/templates/secret/data-browser.html
similarity index 100%
rename from atr/templates/data-browser.html
rename to atr/templates/secret/data-browser.html
diff --git a/atr/templates/update-pmcs.html
b/atr/templates/secret/update-pmcs.html
similarity index 100%
rename from atr/templates/update-pmcs.html
rename to atr/templates/secret/update-pmcs.html
diff --git a/atr/util.py b/atr/util.py
new file mode 100644
index 0000000..b3b5faf
--- /dev/null
+++ b/atr/util.py
@@ -0,0 +1,45 @@
+# 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 hashlib
+from functools import cache
+from pathlib import Path
+
+from quart import current_app
+
+
+@cache
+def get_admin_users() -> set[str]:
+ return set(current_app.config["ADMIN_USERS"])
+
+
+def get_release_storage_dir() -> str:
+ return str(current_app.config["RELEASE_STORAGE_DIR"])
+
+
+def compute_sha3_256(file_data: bytes) -> str:
+ """Compute SHA3-256 hash of file data."""
+ return hashlib.sha3_256(file_data).hexdigest()
+
+
+def compute_sha512(file_path: Path) -> str:
+ """Compute SHA-512 hash of a file."""
+ sha512 = hashlib.sha512()
+ with open(file_path, "rb") as f:
+ for chunk in iter(lambda: f.read(4096), b""):
+ sha512.update(chunk)
+ return sha512.hexdigest()
diff --git a/poetry.lock b/poetry.lock
index 4883676..163468d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1930,6 +1930,18 @@ pytest = ">=6.2.5"
[package.extras]
dev = ["pre-commit", "pytest-asyncio", "tox"]
+[[package]]
+name = "python-decouple"
+version = "3.8"
+description = "Strict separation of settings from code."
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "python-decouple-3.8.tar.gz", hash =
"sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f"},
+ {file = "python_decouple-3.8-py3-none-any.whl", hash =
"sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66"},
+]
+
[[package]]
name = "python-gnupg"
version = "0.5.4"
@@ -2635,4 +2647,4 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.1"
python-versions = "~=3.13"
-content-hash =
"83ebf1f0e0465eb2fdd013705e58ed06d047baa560bdfd0196d327ba015f30d1"
+content-hash =
"b0037bd47d793570a6513cf1b5d6303920af3aa7470c3a79525fb5ab1ad133d6"
diff --git a/pyproject.toml b/pyproject.toml
index 68774e9..95cb593 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,7 @@ dependencies = [
"hypercorn~=0.17",
"python-gnupg~=0.5",
"sqlmodel~=0.0",
+ "python-decouple~=3.8"
]
[dependency-groups]
diff --git a/uv.lock b/uv.lock
index 38f9404..2bfd4f6 100644
--- a/uv.lock
+++ b/uv.lock
@@ -255,7 +255,7 @@ name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "colorama", marker = "platform_system == 'Windows'" },
]
sdist = { url =
"https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz",
hash =
"sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size
= 226593 }
wheels = [
@@ -933,6 +933,15 @@ wheels = [
{ url =
"https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl",
hash =
"sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size
= 9863 },
]
+[[package]]
+name = "python-decouple"
+version = "3.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/e1/97/373dcd5844ec0ea5893e13c39a2c67e7537987ad8de3842fe078db4582fa/python-decouple-3.8.tar.gz",
hash =
"sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f", size
= 9612 }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/a2/d4/9193206c4563ec771faf2ccf54815ca7918529fe81f6adb22ee6d0e06622/python_decouple-3.8-py3-none-any.whl",
hash =
"sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66", size
= 9947 },
+]
+
[[package]]
name = "python-gnupg"
version = "0.5.4"
@@ -1107,6 +1116,7 @@ dependencies = [
{ name = "greenlet" },
{ name = "httpx" },
{ name = "hypercorn" },
+ { name = "python-decouple" },
{ name = "python-gnupg" },
{ name = "sqlmodel" },
]
@@ -1131,6 +1141,7 @@ requires-dist = [
{ name = "greenlet", specifier = ">=3.1.1,<4.0.0" },
{ name = "httpx", specifier = "~=0.27" },
{ name = "hypercorn", specifier = "~=0.17" },
+ { name = "python-decouple", specifier = "~=3.8" },
{ name = "python-gnupg", specifier = "~=0.5" },
{ name = "sqlmodel", specifier = "~=0.0" },
]
@@ -1150,7 +1161,7 @@ name = "tqdm"
version = "4.67.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "colorama", marker = "platform_system == 'Windows'" },
]
sdist = { url =
"https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz",
hash =
"sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size
= 169737 }
wheels = [
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]