diff --git a/8Knot/cache_manager/alembic.ini b/8Knot/cache_manager/alembic.ini new file mode 100644 index 00000000..428cf3b8 --- /dev/null +++ b/8Knot/cache_manager/alembic.ini @@ -0,0 +1,15 @@ +# Alembic config for the Postgres cache schema. +# +# The database URL is NOT set here; env.py builds it from the same +# CACHE_* environment variables as cx_common, so there's one source of +# connection truth. To create a new revision during development: +# +# cd 8Knot/cache_manager +# alembic revision -m "describe change" +# +# (the CACHE_*/AUGUR_* env vars cx_common reads must be set in your shell). +# In production, db_init.py drives "alembic upgrade head" programmatically. + +[alembic] +script_location = migrations +prepend_sys_path = . diff --git a/8Knot/cache_manager/cx_common.py b/8Knot/cache_manager/cx_common.py index 253139be..5ffa4e6f 100644 --- a/8Knot/cache_manager/cx_common.py +++ b/8Knot/cache_manager/cx_common.py @@ -6,6 +6,8 @@ import logging import time +from psycopg2.extensions import make_dsn + # credentials to access database from environment try: env_augur_user = os.environ["AUGUR_USERNAME"] @@ -32,20 +34,28 @@ # purely initial startup string # psycopg2 connection string for cache pg instance, initialization only -init_cx_string = "dbname={} user={} password={} host={} port={}".format( - "postgres", env_user, env_password, env_host, env_port +init_cx_string = make_dsn( + dbname="postgres", + user=env_user, + password=env_password, + host=env_host, + port=env_port, ) # psycopg2 connection string for cache pg instance -cache_cx_string = "dbname={} user={} password={} host={} port={}".format( - env_dbname, env_user, env_password, env_host, env_port +cache_cx_string = make_dsn( + dbname=env_dbname, + user=env_user, + password=env_password, + host=env_host, + port=env_port, ) # psycopg2 connection string for augur db -db_cx_string = "dbname={} user={} password={} host={} port={}".format( - env_augur_database, - env_augur_user, - env_augur_password, - env_augur_host, - env_augur_port, +db_cx_string = make_dsn( + dbname=env_augur_database, + user=env_augur_user, + password=env_augur_password, + host=env_augur_host, + port=env_augur_port, ) diff --git a/8Knot/cache_manager/db_init.py b/8Knot/cache_manager/db_init.py index f1483de0..8a88c1bb 100644 --- a/8Knot/cache_manager/db_init.py +++ b/8Knot/cache_manager/db_init.py @@ -5,15 +5,21 @@ It's typically easiest and best-practice to use a db migration tool instead of doing error-prone manual administration like this. -However, using sqlalchemy and alembic (Python db migration stack) -would be a bit of a steep learning curve for people who just want to -create a table in the cache. Most people who would be working on a -project like this will know enough SQL to read the existing table -definitions and create a new table as needed from those examples. - -Our data model is fairly simple, so for now the overhead of proper -db migration tooling is mostly bloaty. We can return to this decision -in the future if necessary. +Base tables are still created here with raw CREATE UNLOGGED TABLE IF NOT +EXISTS blocks (see below), so adding a brand-new table stays as simple as +copying an existing block - no ORM or SQLAlchemy models to learn. Anyone +who can read SQL can add a table. + +What raw CREATE blocks can't do is change a table that already exists: +CREATE TABLE IF NOT EXISTS is a no-op once the table is there, so a column +added to a block below never reaches a cache that predates it. Those +changes to existing tables are versioned with alembic (migrations/ next to +this file), which db_init applies on startup. We keep the raw CREATE blocks +as the definition of a fresh cache and layer alembic on top for schema +evolution, so the cache can check its own version and upgrade itself +automatically - the groundwork for making the cache persistent rather than +rebuilt-on-boot. Migrations are hand-written raw SQL (no autogenerate), +matching the raw-SQL style of the table definitions here. """ """ @@ -48,6 +54,23 @@ as the query function. Name the columns of the table, and give their types, and everything should work! +To change a table that already exists (add/drop/rename a column, change a +type), add an alembic migration: + + cd 8Knot/cache_manager + alembic revision -m "describe change" # writes migrations/versions/.py + +Fill in upgrade()/downgrade() with raw SQL via op.execute(...). If the new +column should also exist on a fresh cache, add it to the CREATE block below +as well - the block defines a fresh cache, the migration patches existing +ones. db_init runs "alembic upgrade head" on every boot (see +_run_cache_migrations), so the change is applied automatically. Never edit +a migration that has already shipped; add a new one. + +Note _ensure_repo_id_indexes() below is intentionally NOT a migration: it +discovers tables at runtime and CREATE INDEX IF NOT EXISTS is always safe to +re-run, so it just runs unconditionally on every boot. + Here's a list of types that postgres defines: https://www.postgresql.org/docs/current/datatype.html @@ -60,11 +83,16 @@ """ import logging -import sys import os +import sys +import time +from contextlib import contextmanager + import psycopg2 as pg import redis -import time +from alembic import command +from alembic.config import Config +from psycopg2 import sql as pg_sql def _env_int(var: str, default: int) -> int: @@ -99,7 +127,7 @@ def _env_int(var: str, default: int) -> int: # doesn't use relative import syntax "import .cx_common" because # cx_common is a neighbor of script, thus is available in PYTHON_PATH -from cx_common import init_cx_string, cache_cx_string +from cx_common import cache_cx_string, env_dbname, init_cx_string def _connect_with_retry(connection_string, max_retries=5, retry_delay=3): @@ -139,6 +167,10 @@ def _create_application_database() -> None: This function creates the 'augur_cache' database, which will contain all of the tables where we'll cache data for visualization. + + Concurrent initializers serialize the existence check and creation on + the root database. The configured CACHE_DB_NAME is used consistently + for creation and all subsequent cache connections. """ # Connect to the dbms at top-level @@ -151,19 +183,16 @@ def _create_application_database() -> None: # required so that we can create a database conn.autocommit = True - # check if application db already exists - cur = conn.cursor() - cur.execute("SELECT 1 FROM pg_catalog.pg_database WHERE datname = 'augur_cache'") - exists = cur.fetchone() - - # create application db if it doesn't already exist - if not exists: - logging.warning("CREATING augur_cache DATABASE") - cur.execute("CREATE DATABASE augur_cache") - - conn.commit() - cur.close() - conn.close() + try: + with conn.cursor() as cur: + cur.execute("SELECT pg_advisory_lock(hashtext(%s))", ("8knot-cache-database-init",)) + cur.execute("SELECT 1 FROM pg_catalog.pg_database WHERE datname = %s", (env_dbname,)) + if not cur.fetchone(): + logging.warning(f"CREATING {env_dbname} DATABASE") + cur.execute(pg_sql.SQL("CREATE DATABASE {}").format(pg_sql.Identifier(env_dbname))) + finally: + # Session-level advisory locks are released when the connection closes. + conn.close() def _create_application_tables() -> None: @@ -428,33 +457,183 @@ def _create_application_tables() -> None: logging.warning("ALL TABLES COMMITTED SUCCESSFULLY") -def _flush_redis_broker() -> None: +def _ensure_repo_id_indexes() -> None: + """Ensure every cache table with a repo_id column has an index on it. + + retrieve_from_cache() and get_uncached() both filter on repo_id, and + UNLOGGED tables don't auto-index, so a missing index means a full + sequential scan (issue #1198 / PR #1158). Rather than hardcode the + table list, we ask Postgres which tables currently have a repo_id + column, so any table added later (via a new CREATE IF NOT EXISTS block + above) gets indexed the next time this runs - nothing to remember to + update here. + + CREATE INDEX IF NOT EXISTS is always safely re-appliable, so unlike a + real schema change (which goes through an alembic migration, see + _run_cache_migrations below), this doesn't need to be versioned - it + just runs unconditionally on every boot, the same way + _create_application_tables() does. + + cache_bookkeeping also has a repo_id column but is queried by + (cache_func, repo_id) instead, so it's excluded here and given its own + composite index. """ - Flush the Redis broker so it stays in sync with postgres-cache. + conn = _connect_with_retry(cache_cx_string) + conn.autocommit = True + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT DISTINCT columns.table_name + FROM information_schema.columns AS columns + JOIN information_schema.tables AS tables + ON tables.table_catalog = columns.table_catalog + AND tables.table_schema = columns.table_schema + AND tables.table_name = columns.table_name + WHERE columns.table_schema = 'public' + AND columns.column_name = 'repo_id' + AND columns.table_name != 'cache_bookkeeping' + AND tables.table_type = 'BASE TABLE' + """ + ) + tables = [row[0] for row in cur.fetchall()] + + indexes = [(f"{table}_repo_id_idx", table, ("repo_id",)) for table in tables] + [ + ("cache_bookkeeping_func_repo_idx", "cache_bookkeeping", ("cache_func", "repo_id")) + ] + cur.execute( + """ + SELECT index_class.relname + FROM pg_catalog.pg_index AS index_info + JOIN pg_catalog.pg_class AS index_class ON index_class.oid = index_info.indexrelid + JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = index_class.relnamespace + WHERE namespace.nspname = 'public' AND NOT index_info.indisvalid + """ + ) + invalid_indexes = {row[0] for row in cur.fetchall()} + + for index_name, table, columns in indexes: + if index_name in invalid_indexes: + cur.execute( + pg_sql.SQL("DROP INDEX CONCURRENTLY IF EXISTS {}.{}").format( + pg_sql.Identifier("public"), pg_sql.Identifier(index_name) + ) + ) + cur.execute( + pg_sql.SQL("CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {}.{} ({})").format( + pg_sql.Identifier(index_name), + pg_sql.Identifier("public"), + pg_sql.Identifier(table), + pg_sql.SQL(", ").join(map(pg_sql.Identifier, columns)), + ) + ) + finally: + conn.close() + logging.warning(f"db_init: ensured repo_id indexes ({len(tables)} tables)") + + +@contextmanager +def _cache_schema_lock(): + """Serialize schema initialization across app pods.""" + conn = _connect_with_retry(cache_cx_string) + conn.autocommit = True + try: + with conn.cursor() as cur: + cur.execute("SELECT pg_advisory_lock(hashtext(%s))", ("8knot-cache-schema-init",)) + yield + finally: + # Session-level advisory locks are released when the connection closes. + conn.close() + + +def _cache_schema_exists() -> bool: + """Return whether this database already contains an 8Knot cache schema.""" + conn = _connect_with_retry(cache_cx_string) + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + AND table_name != 'alembic_version' + ) + """ + ) + return cur.fetchone()[0] + finally: + conn.close() + + +def _alembic_config() -> Config: + """Build an alembic Config pointing at migrations/ next to this file. + + Paths are resolved from __file__ so it works no matter what directory + db_init is launched from. The database URL isn't set here - env.py + builds it from the same CACHE_* env vars cx_common uses. + """ + here = os.path.dirname(os.path.abspath(__file__)) + cfg = Config(os.path.join(here, "alembic.ini")) + cfg.set_main_option("script_location", os.path.join(here, "migrations")) + return cfg + + +def _stamp_cache_schema() -> None: + """Mark a new, empty cache as current before creating its tables.""" + command.stamp(_alembic_config(), "head") + logging.warning("db_init: stamped fresh cache at alembic head") + + +def _run_cache_migrations() -> None: + """Upgrade a pre-existing cache to the latest alembic revision.""" + command.upgrade(_alembic_config(), "head") + logging.warning("db_init: cache schema upgraded to alembic head") + + +def _cache_generation_id() -> str: + """Identify the current database instance, server start, and schema revision.""" + conn = _connect_with_retry(cache_cx_string) + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT oid::text, pg_postmaster_start_time()::text + FROM pg_catalog.pg_database + WHERE datname = current_database() + """ + ) + database_oid, postgres_start = cur.fetchone() + cur.execute("SELECT version_num FROM alembic_version ORDER BY version_num") + revisions = ",".join(row[0] for row in cur.fetchall()) + return f"{database_oid}:{postgres_start}:{revisions}" + finally: + conn.close() + + +def _synchronize_redis_broker(cache_generation_id: str) -> None: + """Reset stale broker state once per cache startup or schema change. postgres-cache uses UNLOGGED tables, so all cached data is lost on - restart. If Redis still holds stale Celery task messages or results - from a previous run, workers pick them up and poll for data that no - longer exists, causing deadlocks. Flushing here guarantees a clean - broker state every time the cache is (re)initialized. + crash recovery. The durable generation marker also changes after schema + migration or database recreation, and prevents concurrent app initializers + from erasing work queued after the first one completes. """ broker_host = os.getenv("REDIS_SERVICE_HOST", "redis-broker") broker_port = _env_int("REDIS_SERVICE_PORT", 6379) broker_password = os.getenv("REDIS_PASSWORD", "") + marker_key = f"8knot:cache:{env_dbname}:generation" - users_host = os.getenv("REDIS_SERVICE_USERS_HOST", "redis-users") - users_port = _env_int("REDIS_SERVICE_USERS_PORT", 6379) - - for name, host, port in [ - ("redis-broker", broker_host, broker_port), - ("redis-users", users_host, users_port), - ]: - try: - r = redis.StrictRedis(host=host, port=port, password=broker_password) - r.flushall() - logging.warning(f"db_init: FLUSHED {name} ({host}:{port})") - except Exception as e: - logging.warning(f"db_init: could not flush {name}: {e}") + r = redis.StrictRedis(host=broker_host, port=broker_port, password=broker_password) + if r.get(marker_key) == cache_generation_id.encode(): + logging.warning("db_init: redis-broker already synchronized") + return + with r.pipeline(transaction=True) as pipeline: + pipeline.flushall() + pipeline.set(marker_key, cache_generation_id) + pipeline.execute() + logging.warning(f"db_init: FLUSHED redis-broker ({broker_host}:{broker_port})") def db_init() -> int: @@ -462,21 +641,36 @@ def db_init() -> int: # don't need to check return values- errors propogate as exceptions, # which will halt init altogether. - # create augur_cache db if it doesn't already exist. + # create the configured cache database if it doesn't already exist. _create_application_database() - # add tables to augur_cache db if they don't already exist. - _create_application_tables() + with _cache_schema_lock(): + schema_exists = _cache_schema_exists() + + # Stamp before table creation so an interrupted fresh bootstrap + # can safely retry without replaying historical migrations over + # the current CREATE definitions. + if not schema_exists: + _stamp_cache_schema() + + _create_application_tables() + + if schema_exists: + _run_cache_migrations() + + # Reconcile indexes after migrations so they reflect the final schema. + _ensure_repo_id_indexes() - # flush redis so broker state matches the fresh postgres-cache. - _flush_redis_broker() + # Only the first initializer for a PostgreSQL start or schema + # change resets stale Celery state. User sessions are separate. + _synchronize_redis_broker(_cache_generation_id()) logging.warning("db_init: POSTGRES CACHE SUCCESSFULLY INITIALIZED") return 0 except Exception as e: - logging.critical(f"POSTGRES ERROR: {e}") + logging.critical(f"INITIALIZATION ERROR: {e}") return 1 diff --git a/8Knot/cache_manager/migrations/env.py b/8Knot/cache_manager/migrations/env.py new file mode 100644 index 00000000..ed965cd5 --- /dev/null +++ b/8Knot/cache_manager/migrations/env.py @@ -0,0 +1,58 @@ +"""Alembic environment for the Postgres cache schema. + +The connection URL is built from the same CACHE_* environment variables +that cx_common uses, so Alembic and the running app always target the +same cache database. We deliberately do NOT use autogenerate / ORM +metadata: the cache schema is defined with raw SQL (see db_init.py), so +migrations are hand-written raw SQL too, and target_metadata stays None. +""" + +import os +import sys + +from alembic import context +from sqlalchemy import URL, create_engine, pool + +# cx_common lives one directory up (8Knot/cache_manager); make it importable +# whether Alembic is invoked via the CLI from migrations/ or programmatically. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from cx_common import env_dbname, env_host, env_password, env_port, env_user + +# Keep credentials as a URL object. Rendering into Alembic's Config would +# make percent-encoded passwords subject to ConfigParser interpolation. +database_url = URL.create( + "postgresql+psycopg2", + username=env_user, + password=env_password, + host=env_host, + port=int(env_port), + database=env_dbname, +) + +# No ORM models in this project, so no autogenerate support. +target_metadata = None + + +def run_migrations_offline() -> None: + context.configure( + url=database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = create_engine(database_url, poolclass=pool.NullPool) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/8Knot/cache_manager/migrations/script.py.mako b/8Knot/cache_manager/migrations/script.py.mako new file mode 100644 index 00000000..ba43f136 --- /dev/null +++ b/8Knot/cache_manager/migrations/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/8Knot/cache_manager/migrations/versions/0001_add_labels_to_issues_query.py b/8Knot/cache_manager/migrations/versions/0001_add_labels_to_issues_query.py new file mode 100644 index 00000000..746e022d --- /dev/null +++ b/8Knot/cache_manager/migrations/versions/0001_add_labels_to_issues_query.py @@ -0,0 +1,38 @@ +"""add labels column to issues_query + +Backfills the `labels` column that PR #1189 added to the issues_query +CREATE block. CREATE TABLE IF NOT EXISTS never alters a table that +already exists, so caches created before #1189 are missing the column +(which is why the issues visualizations guard with +`if "labels" not in df.columns`). This migration adds it to those caches. + +Fresh caches already get the column from db_init's CREATE block and are +stamped straight to head, so this migration only runs against a +pre-existing cache. Existing issue rows must be invalidated because their +new labels value is NULL, while cache_bookkeeping would otherwise prevent +those repositories from being collected again. + +Revision ID: 0001_add_labels +Revises: +Create Date: 2026-08-26 +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0001_add_labels" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("ALTER TABLE issues_query ADD COLUMN IF NOT EXISTS labels text") + op.execute("TRUNCATE TABLE issues_query") + op.execute("DELETE FROM cache_bookkeeping WHERE cache_func = 'issues_query'") + + +def downgrade() -> None: + op.execute("TRUNCATE TABLE issues_query") + op.execute("DELETE FROM cache_bookkeeping WHERE cache_func = 'issues_query'") + op.execute("ALTER TABLE issues_query DROP COLUMN IF EXISTS labels") diff --git a/pyproject.toml b/pyproject.toml index 03cf89b9..69b4d0a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "Dash app built by Red Hat's Open Source Program Office to analyze readme = "README.md" requires-python = ">=3.9" dependencies = [ + "alembic~=1.13", "celery~=5.5", "dash~=3.2.0", "dash-bootstrap-components~=2.0", diff --git a/uv.lock b/uv.lock index 9919f261..1fe3dfb1 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,8 @@ resolution-markers = [ name = "8knot" source = { editable = "." } dependencies = [ + { name = "alembic", version = "1.16.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "alembic", version = "1.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "celery" }, { name = "dash" }, { name = "dash-bootstrap-components" }, @@ -36,6 +38,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = "~=1.13" }, { name = "celery", specifier = "~=5.5" }, { name = "dash", specifier = "~=3.2.0" }, { name = "dash-bootstrap-components", specifier = "~=2.0" }, @@ -55,6 +58,44 @@ requires-dist = [ { name = "sqlalchemy", specifier = "~=2.0" }, ] +[[package]] +name = "alembic" +version = "1.16.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mako", version = "1.3.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sqlalchemy", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" }, +] + +[[package]] +name = "alembic" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "mako", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sqlalchemy", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816, upload-time = "2026-08-08T16:32:01.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/89/e62cc37b69ad357cc8ecd6e7367f5245f523d3cbb338a66197212bdf6749/alembic-1.19.1-py3-none-any.whl", hash = "sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be", size = 265946, upload-time = "2026-08-08T16:32:03.153Z" }, +] + [[package]] name = "amqp" version = "5.3.1" @@ -514,6 +555,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, ] +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "markupsafe", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "markupsafe", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -1330,6 +1403,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/9e/3f86cf00c2245afb236205ab0fbdf7b77ac4ee931603e18b7e192315a514/SQLAlchemy-2.0.25-py3-none-any.whl", hash = "sha256:a86b4240e67d4753dc3092d9511886795b3c2852abe599cffe108952f7af7ac3", size = 1865129, upload-time = "2024-01-03T02:26:11.385Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"