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 <t...@apache.org> 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"} + +@APP.route("/") +async def root() -> str: + """Main page.""" + return await render_template("index.html") + + +@APP.route("/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() - - -@APP.route("/") -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: ) -@APP.route("/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: dev-unsubscr...@tooling.apache.org For additional commands, e-mail: dev-h...@tooling.apache.org