diff --git a/alembic.ini b/alembic.ini index 7d0469d9d..7b3787c4d 100644 --- a/alembic.ini +++ b/alembic.ini @@ -68,7 +68,7 @@ sqlalchemy.url = driver://user:pass@localhost/dbname # Logging configuration [loggers] -keys = root,sqlalchemy,alembic +keys = root,sqlalchemy,alembic,alembic_utils_extended [handlers] keys = console @@ -100,3 +100,9 @@ formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S + + +[logger_alembic_utils_extended] +level = INFO +handlers = +qualname = alembic_utils_extended \ No newline at end of file diff --git a/collectoss/application/db/materialized_views.py b/collectoss/application/db/materialized_views.py index 5d2923636..7bd4a2e6e 100644 --- a/collectoss/application/db/materialized_views.py +++ b/collectoss/application/db/materialized_views.py @@ -6,54 +6,109 @@ immutable snapshots), but this registry is the canonical reference for what views exist and how to refresh them. -alembic_utils autogenerate is wired up (phase 2 complete). Running +alembic_utils_extended autogenerate is wired up (phase 2 complete). Running `alembic revision --autogenerate` will detect changes to view SQL definitions. -alembic_utils uses a round-trip comparison: it creates the Python-defined view +alembic_utils_extended uses a round-trip comparison: it creates the Python-defined view in a savepoint, reads back what PostgreSQL stored, and diffs that against the live database. Manual SQL normalization is not required. WARNING: ALL views must be registered here or autogenerate will propose dropping unregistered ones. Keep this list complete. - -alembic_utils does NOT manage indexes on materialized views. See the developer -note in collectoss/application/schema/alembic/env.py for the full post-replace -procedure (index recreation + non-concurrent refresh). """ -from dataclasses import dataclass +from alembic_utils_extended.pg_materialized_view import PGMaterializedView +from sqlalchemy import Index + -from alembic_utils.pg_materialized_view import PGMaterializedView +class MaterializedView(PGMaterializedView): + """Thin subclass of ``PGMaterializedView`` with CollectOSS-specific helpers. + Indexes are declared as full SQLAlchemy ``Index`` objects (strings for + plain column references), giving complete flexibility (uniqueness, + expressions, opclasses) instead of a bare column list. A unique index is + what makes ``REFRESH MATERIALIZED VIEW ... CONCURRENTLY`` possible; see + ``has_unique_index``. -@dataclass(frozen=True) -class MaterializedView: - name: str - schema: str - sql: str - unique_index_columns: tuple[str, ...] = () + Instances must be converted via ``to_pg_view()`` before registration with + ``register_entities``: ``alembic_utils_extended`` performs exact-class + checks that mis-handle subclasses (spurious ``DropOp``s). + """ + + def __init__( + self, + name: str, + schema: str, + sql: str, + with_data: bool = False, + indexes: list[Index] = [], + ) -> None: + super().__init__( + schema=schema, + signature=name, + definition=sql, + with_data=with_data, + indexes=indexes, + ) @property def fqn(self) -> str: return f"{self.schema}.{self.name}" - def refresh_sql(self, concurrently: bool = True) -> str: + @property + def unique_indexes(self) -> list[Index]: + """The view's unique indexes (the prerequisite for CONCURRENTLY refresh).""" + return [index for index in (self.indexes or []) if index.unique] + + @property + def has_unique_index(self) -> bool: + """Whether the view can be refreshed CONCURRENTLY.""" + return bool(self.unique_indexes) + + def refresh_sql(self, concurrently: bool = True, with_data: bool | None = None) -> str: + """Return the SQL to refresh the view. + + Args: + concurrently: Whether to refresh the view CONCURRENTLY. + with_data: Whether to refresh the view WITH DATA. If None, uses the + view's ``with_data`` attribute. + """ + if with_data is None: + with_data = self.with_data mode = "CONCURRENTLY " if concurrently else "" - return f"REFRESH MATERIALIZED VIEW {mode}{self.fqn} WITH DATA;" + data_present = "DATA" if with_data else "NO DATA" + return f"REFRESH MATERIALIZED VIEW {mode}{self.fqn} WITH {data_present};" + + def to_pg_view(self, with_data: bool = None) -> PGMaterializedView: + """Return a plain ``PGMaterializedView`` for use with alembic_utils registration. + + We cannot return *self* here even though ``MaterializedView`` is a + ``PGMaterializedView`` subclass. ``alembic_utils_extended`` uses two + exact-class checks that both fail with a subclass and cause spurious + ``DropOp``s for all materialized views. + + Returning a genuine ``PGMaterializedView`` sidesteps both issues while + all runtime helpers (``fqn``, ``refresh_sql``, ``has_unique_index``) + remain accessible on *self*. - def to_pg_view(self, with_data: bool = False) -> PGMaterializedView: + Parameters: + with_data: Optional override for ``with_data`` in the returned view. If None, uses the + view's ``with_data`` attribute. + """ + if with_data is None: + with_data = self.with_data return PGMaterializedView( schema=self.schema, - signature=self.name, - definition=self.sql, + signature=self.signature, + definition=self.definition, with_data=with_data, + indexes=self.indexes, ) def __repr__(self) -> str: - # Default dataclass repr would dump the full SQL string (some views - # are 100+ lines), making logs and debug output unreadable. + # Avoid dumping the full SQL string (some views are 100+ lines) in logs. return ( f"MaterializedView(name={self.name!r}, schema={self.schema!r}, " - f"unique_index_columns={self.unique_index_columns!r})" + f"indexes={[index.name for index in self.indexes or []]!r})" ) @@ -731,94 +786,96 @@ def __repr__(self) -> str: name="issue_reporter_created_at", schema="data", sql=_ISSUE_REPORTER_CREATED_AT, - unique_index_columns=(), # only a non-unique btree on repo_id + # Non-unique btree only: this view cannot be refreshed CONCURRENTLY + # (the refresh task falls back to non-concurrent). + indexes=[Index("issue_reporter_created_at_repo_id_idx", "repo_id")], ), # --- Views 2-6: from migration 4, indexes from migration 25 --- MaterializedView( name="api_get_all_repo_prs", schema="data", sql=_API_GET_ALL_REPO_PRS, - unique_index_columns=("repo_id",), + indexes=[Index("api_get_all_repo_prs_repo_id_idx", "repo_id", unique=True)], ), MaterializedView( name="explorer_entry_list", schema="data", sql=_EXPLORER_ENTRY_LIST, - unique_index_columns=("repo_id",), + indexes=[Index("explorer_entry_list_repo_id_idx", "repo_id", unique=True)], ), MaterializedView( name="explorer_commits_and_committers_daily_count", schema="data", sql=_EXPLORER_COMMITS_AND_COMMITTERS_DAILY_COUNT, - unique_index_columns=("repo_id", "cmt_committer_date",), + indexes=[Index("explorer_commits_and_committers__repo_id_cmt_committer_date_idx", "repo_id", "cmt_committer_date", unique=True)], ), MaterializedView( name="api_get_all_repos_commits", schema="data", sql=_API_GET_ALL_REPOS_COMMITS, - unique_index_columns=("repo_id",), + indexes=[Index("api_get_all_repos_commits_repo_id_idx", "repo_id", unique=True)], ), MaterializedView( name="api_get_all_repos_issues", schema="data", sql=_API_GET_ALL_REPOS_ISSUES, - unique_index_columns=("repo_id",), + indexes=[Index("api_get_all_repos_issues_repo_id_idx", "repo_id", unique=True)], ), # --- Views 6-8: from migration 25, recreated --- MaterializedView( name="augur_new_contributors", schema="data", sql=_AUGUR_NEW_CONTRIBUTORS, - unique_index_columns=("cntrb_id", "created_at", "repo_id", "repo_name", "login", "rank",), + indexes=[Index("augur_new_contributors_cntrb_id_created_at_repo_id_repo_nam_idx", "cntrb_id", "created_at", "repo_id", "repo_name", "login", "rank", unique=True)], ), MaterializedView( name="explorer_contributor_actions", schema="data", sql=_EXPLORER_CONTRIBUTOR_ACTIONS, - unique_index_columns=("cntrb_id", "created_at", "repo_id", "action", "repo_name", "login", "rank",), + indexes=[Index("explorer_contributor_actions_cntrb_id_created_at_repo_id_ac_idx", "cntrb_id", "created_at", "repo_id", "action", "repo_name", "login", "rank", unique=True)], ), MaterializedView( name="explorer_new_contributors", schema="data", sql=_EXPLORER_NEW_CONTRIBUTORS, - unique_index_columns=("cntrb_id", "created_at", "month", "year", "repo_id", "full_name", "repo_name", "login", "rank",), + indexes=[Index("explorer_new_contributors_cntrb_id_created_at_month_year_re_idx", "cntrb_id", "created_at", "month", "year", "repo_id", "full_name", "repo_name", "login", "rank", unique=True)], ), # --- Views 9-13: from migration 26 --- MaterializedView( name="explorer_pr_assignments", schema="data", sql=_EXPLORER_PR_ASSIGNMENTS, - unique_index_columns=("pull_request_id", "id", "node_id",), + indexes=[Index("explorer_pr_assignments_pull_request_id_id_node_id_idx", "pull_request_id", "id", "node_id", unique=True)], ), MaterializedView( name="explorer_pr_response", schema="data", sql=_EXPLORER_PR_RESPONSE, - unique_index_columns=("pull_request_id", "id", "cntrb_id", "msg_cntrb_id", "msg_timestamp",), + indexes=[Index("explorer_pr_response_pull_request_id_id_cntrb_id_msg_cntrb__idx", "pull_request_id", "id", "cntrb_id", "msg_cntrb_id", "msg_timestamp", unique=True)], ), MaterializedView( name="explorer_user_repos", schema="data", sql=_EXPLORER_USER_REPOS, - unique_index_columns=("login_name", "user_id", "group_id", "repo_id",), + indexes=[Index("explorer_user_repos_login_name_user_id_group_id_repo_id_idx", "login_name", "user_id", "group_id", "repo_id", unique=True)], ), MaterializedView( name="explorer_pr_response_times", schema="data", sql=_EXPLORER_PR_RESPONSE_TIMES, - unique_index_columns=("repo_id", "pr_src_id", "pr_src_meta_label",), + indexes=[Index("explorer_pr_response_times_repo_id_pr_src_id_pr_src_meta_la_idx", "repo_id", "pr_src_id", "pr_src_meta_label", unique=True)], ), MaterializedView( name="explorer_issue_assignments", schema="data", sql=_EXPLORER_ISSUE_ASSIGNMENTS, - unique_index_columns=("issue_id", "id", "node_id",), + indexes=[Index("explorer_issue_assignments_issue_id_id_node_id_idx", "issue_id", "id", "node_id", unique=True)], ), # --- View 15: from migration 28 --- MaterializedView( name="explorer_repo_languages", schema="data", sql=_EXPLORER_REPO_LANGUAGES, - unique_index_columns=("repo_id", "programming_language",), + indexes=[Index("explorer_repo_languages_repo_id_programming_language_idx", "repo_id", "programming_language", unique=True)], ), ] diff --git a/collectoss/application/db/matview_index_comparator.py b/collectoss/application/db/matview_index_comparator.py new file mode 100644 index 000000000..3169b13cf --- /dev/null +++ b/collectoss/application/db/matview_index_comparator.py @@ -0,0 +1,328 @@ +"""Autogenerate comparator for materialized view *index* changes. + +``alembic_utils_extended``'s entity comparator detects view definition changes +and renders index creation alongside create/replace ops +(``render_post_create_entity``), but the registry's ``indexes`` list plays no +role in change *detection*: an index-only change produces an empty migration +(confirmed with the fork maintainer for v1.5.0). + +This module closes that gap as a separate Alembic comparator — the approach +the maintainer suggested — emitting bare ``CreateIndexOp`` / ``DropIndexOp`` +instead of rebuilding the view, so a populated matview keeps its data +(``CREATE INDEX`` on an existing matview is legal; no ``WITH NO DATA`` / +first-refresh-must-be-non-concurrent dance). + +Comparison uses structured signatures — ``(name, unique, ordered column +names)`` — the same approach stock Alembic uses for table indexes. Indexes +bearing expressions (anything other than plain column references) compare by +name only: their DB-side rendering (``pg_get_indexdef``) is not reliably +comparable, so a content change to one requires renaming it or a hand-written +migration. Newly-added expression indexes are still rendered faithfully (via +Alembic's ``render_ddl_sql_expr``, the same mechanism the fork's table index +comparator uses). + +Import this module from the alembic ``env.py`` *after* ``register_entities`` +has been called: the entity comparator must be registered first so that, when +a view's definition also changed, its pending CreateOp/ReplaceOp (which +already renders index creation) suppresses this comparator for that view. +Importing the module registers the comparator; there is nothing to call. +""" + +from __future__ import annotations + +import logging +import re +from collections import defaultdict +from dataclasses import dataclass + +from alembic.autogenerate import comparators +from alembic.autogenerate.api import AutogenContext +from alembic.operations import ops +from sqlalchemy import Column, text +from sqlalchemy.engine import Connection +from sqlalchemy.sql.elements import TextClause + +from alembic_utils_extended.pg_materialized_view import PGMaterializedView +from alembic_utils_extended.reversible_op import CreateOp, ReplaceOp + +from collectoss.application.db.materialized_views import MATERIALIZED_VIEWS, MaterializedView + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _IndexSig: + """Comparison signature for one index. + + ``columns`` is ``None`` for expression-bearing indexes, which compare by + name only (see module docstring). + """ + + name: str + unique: bool + columns: tuple[str, ...] | None + + +_PLAIN_IDENTIFIER = re.compile(r'^"?(\w+)"?$') + +# "CREATE [UNIQUE] INDEX name ON [schema.]table USING method (cols) ..." +# (pg_get_indexdef always emits the USING clause) +_COL_LIST = re.compile(r"\bON\s+(?:[\w\"]+\.)?[\w\"]+\s+USING\s+\w+\s*\(", re.IGNORECASE) + + +def _local_index_sigs(view: MaterializedView) -> dict[str, _IndexSig]: + """Signatures for a registry view's ``Index`` declarations, by name.""" + sigs: dict[str, _IndexSig] = {} + for index in view.indexes or []: + if not index.name: + raise ValueError( + f"Unnamed index on materialized view '{view.schema}.{view.signature}'. " + "All indexes must have a name for autogenerate support." + ) + columns: list[str] | None = [] + for expr in index.expressions: + if isinstance(expr, str): + columns.append(expr.lower()) + elif isinstance(expr, Column): + columns.append(expr.name.lower()) + else: + # Expression index: not reliably comparable, name-only. + columns = None + break + sigs[index.name] = _IndexSig(index.name, bool(index.unique), tuple(columns) if columns else None) + return sigs + + +def _index_columns_for_op(index, autogen_context: AutogenContext) -> list[str | TextClause]: + """Render an ``Index``'s elements as ``CreateIndexOp`` columns. + + Plain strings and Columns pass through by name; expressions are rendered + via Alembic's ``render_ddl_sql_expr`` — the same mechanism the fork's + table index comparator uses — so newly-declared expression indexes come + out as ``text(...)`` in the generated migration. + """ + columns: list[str | TextClause] = [] + for expr in index.expressions: + if isinstance(expr, str): + columns.append(expr) + elif isinstance(expr, Column): + columns.append(expr.name) + else: + columns.append( + text(autogen_context.migration_context.impl.render_ddl_sql_expr(expr, is_index=True)) + ) + return columns + + +def _parse_indexdef_columns(indexdef: str) -> tuple[str, ...] | None: + """Extract the ordered plain-column names from a ``pg_get_indexdef`` string. + + Returns ``None`` when the index bears expressions (any element that is + not a plain identifier) or does not parse. + """ + match = _COL_LIST.search(indexdef) + if match is None: + return None + + # Walk from just past the opening paren to its matching close. + start = match.end() + depth = 1 + i = start + while i < len(indexdef) and depth > 0: + if indexdef[i] == "(": + depth += 1 + elif indexdef[i] == ")": + depth -= 1 + i += 1 + col_list = indexdef[start : i - 1] + + # Split on top-level commas only (expressions may contain commas). + elements: list[str] = [] + buf = "" + depth = 0 + for char in col_list: + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if char == "," and depth == 0: + elements.append(buf.strip()) + buf = "" + else: + buf += char + if buf.strip(): + elements.append(buf.strip()) + + columns: list[str] = [] + for element in elements: + ident = _PLAIN_IDENTIFIER.match(element) + if ident is None: + return None + columns.append(ident.group(1).lower()) + return tuple(columns) + + +# Matviews cannot have PRIMARY KEYs or table constraints, so — unlike the +# fork's table index comparator — no pg_constraint exclusion is needed. +_DB_INDEX_SQL = text( + """ + SELECT t.relname AS table_name, + c.relname AS index_name, + pg_get_indexdef(i.indexrelid) AS indexdef, + i.indisunique AS is_unique + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_class t ON t.oid = i.indrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = :schema + AND t.relname = ANY(:view_names) + """ +) + + +def _db_index_sigs( + connection: Connection, schema: str, view_names: list[str] +) -> dict[str, dict[str, _IndexSig]]: + """Live index signatures per view, from ``pg_index`` directly. + + Returns ``{view_name: {index_name: sig}}``. Views absent from the result + do not exist in the database (the entity comparator handles those). + """ + rows = connection.execute(_DB_INDEX_SQL, {"schema": schema, "view_names": view_names}).fetchall() + + result: dict[str, dict[str, _IndexSig]] = defaultdict(dict) + for row in rows: + result[row.table_name][row.index_name] = _IndexSig( + name=row.index_name, + unique=bool(row.is_unique), + columns=_parse_indexdef_columns(row.indexdef), + ) + return dict(result) + + +@comparators.dispatch_for("schema") +def compare_matview_indexes( + autogen_context: AutogenContext, + upgrade_ops: ops.UpgradeOps, + _schemas: list[str | None], +) -> None: + """Diff registered matview indexes against the live database. + + Emits CreateIndexOp / DropIndexOp for added, removed, renamed, and + changed indexes. A changed index under a stable name is emitted as a + drop + create pair. Views with a pending CreateOp/ReplaceOp from the + entity comparator are skipped: those ops already render index creation + (see module docstring for the import-order requirement this relies on). + """ + connection = autogen_context.connection + if connection is None: + # Offline autogenerate has no live database to diff against. + return + + # Views whose create/replace is already pending from the entity + # comparator — emitting index ops for them here would duplicate the ops + # its rendering produces. + pending_entity_views = { + op.target.signature + for op in upgrade_ops.ops + if isinstance(op, (CreateOp, ReplaceOp)) and isinstance(op.target, PGMaterializedView) + } + views = [view for view in MATERIALIZED_VIEWS if view.signature not in pending_entity_views] + + by_schema: dict[str, list[MaterializedView]] = defaultdict(list) + for view in views: + by_schema[view.schema].append(view) + + for schema, schema_views in by_schema.items(): + db_views = _db_index_sigs(connection, schema, [v.signature for v in schema_views]) + + for view in schema_views: + db_sigs = db_views.get(view.signature) + if db_sigs is None: + # View missing from the database and no pending entity op: + # creating indexes now would fail, so leave it alone. + continue + + local_sigs = _local_index_sigs(view) + local_indexes_by_name = {index.name: index for index in (view.indexes or [])} + + for name, local in local_sigs.items(): + db = db_sigs.get(name) + if db is None: + # Added (or renamed-to) index. + logger.info( + "Detected CreateIndexOp for %s.%s", + schema, + name, + ) + upgrade_ops.ops.append( + ops.CreateIndexOp( + index_name=name, + table_name=view.signature, + columns=_index_columns_for_op(local_indexes_by_name[name], autogen_context), + schema=schema, + unique=local.unique, + ) + ) + elif (local.unique, local.columns) != (db.unique, db.columns) and not ( + local.columns is None or db.columns is None + ): + # Changed under a stable name (unique flag or column + # list): CREATE INDEX cannot overwrite, so drop + create. + # (If either side is expression-bearing — columns is None + # — the comparison above is name-only; skip.) + logger.info( + "Detected changed index %s.%s (drop + create)", + schema, + name, + ) + upgrade_ops.ops.append( + ops.DropIndexOp( + index_name=name, + table_name=view.signature, + schema=schema, + _reverse=ops.CreateIndexOp( + index_name=name, + table_name=view.signature, + columns=list(db.columns), + schema=schema, + unique=db.unique, + ), + ) + ) + upgrade_ops.ops.append( + ops.CreateIndexOp( + index_name=name, + table_name=view.signature, + columns=_index_columns_for_op(local_indexes_by_name[name], autogen_context), + schema=schema, + unique=local.unique, + ) + ) + + for name, db in db_sigs.items(): + if name not in local_sigs: + # Removed (or renamed-from) index. Columns are attached + # to the reverse op only when parseable, so the + # downgrade recreates it faithfully. + logger.info( + "Detected DropIndexOp for %s.%s", + schema, + name, + ) + upgrade_ops.ops.append( + ops.DropIndexOp( + index_name=name, + table_name=view.signature, + schema=schema, + _reverse=ops.CreateIndexOp( + index_name=name, + table_name=view.signature, + columns=list(db.columns), + schema=schema, + unique=db.unique, + ) + if db.columns is not None + else None, + ) + ) diff --git a/collectoss/application/schema/alembic/env.py b/collectoss/application/schema/alembic/env.py index 77f2bd2e4..ff26a3ae6 100644 --- a/collectoss/application/schema/alembic/env.py +++ b/collectoss/application/schema/alembic/env.py @@ -41,12 +41,18 @@ # # WARNING: If MATERIALIZED_VIEWS is ever emptied, autogenerate will propose # dropping all registered views. Keep the list complete. -from alembic_utils.pg_materialized_view import PGMaterializedView -from alembic_utils.replaceable_entity import register_entities +from alembic_utils_extended.pg_materialized_view import PGMaterializedView +from alembic_utils_extended.replaceable_entity import register_entities from collectoss.application.db.materialized_views import MATERIALIZED_VIEWS _materialized_view_entities = [view.to_pg_view() for view in MATERIALIZED_VIEWS] register_entities(_materialized_view_entities, entity_types=[PGMaterializedView]) +# Index-only change detection for materialized views, as a separate autogen +# comparator. MUST be imported after register_entities above: the entity +# comparator registers first, so views with a pending create/replace op +# (which already render index creation) are skipped by the index comparator. +from collectoss.application.db import matview_index_comparator # noqa: E402,F401 + # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") diff --git a/collectoss/application/schema/alembic/versions/44_remove_redundant_pr_constraint.py b/collectoss/application/schema/alembic/versions/44_remove_redundant_pr_constraint.py index 770a1701b..06bfce979 100644 --- a/collectoss/application/schema/alembic/versions/44_remove_redundant_pr_constraint.py +++ b/collectoss/application/schema/alembic/versions/44_remove_redundant_pr_constraint.py @@ -7,7 +7,6 @@ """ from alembic import op import sqlalchemy as sa -from alembic_utils.pg_materialized_view import PGMaterializedView from sqlalchemy import text as sql_text # revision identifiers, used by Alembic. diff --git a/collectoss/tasks/db/refresh_materialized_views.py b/collectoss/tasks/db/refresh_materialized_views.py index 0febe34b8..7d1916ae8 100644 --- a/collectoss/tasks/db/refresh_materialized_views.py +++ b/collectoss/tasks/db/refresh_materialized_views.py @@ -20,7 +20,7 @@ def refresh_materialized_views(self): for view in MATERIALIZED_VIEWS: logger.info(f"Refreshing materialized view: {view.fqn}") - if len(view.unique_index_columns) > 0: + if view.has_unique_index: try: conn.execute(s.sql.text(view.refresh_sql(concurrently=True))) continue diff --git a/pyproject.toml b/pyproject.toml index 9d354c0a0..14277283a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ classifiers = [ ] dependencies = [ "alembic>=1.17.1", - "alembic-utils==0.8.8", "Beaker==1.11.0", "boto3==1.17.57", "bs4==0.0.1", @@ -75,6 +74,7 @@ dependencies = [ "xgboost==3.0.2", "xlrd==2.0.1", "xlsxwriter==1.3.7", + "alembic-utils-extended>=1.5.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index b58078ea0..6038ebf47 100644 --- a/uv.lock +++ b/uv.lock @@ -44,8 +44,8 @@ wheels = [ ] [[package]] -name = "alembic-utils" -version = "0.8.8" +name = "alembic-utils-extended" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alembic" }, @@ -54,9 +54,9 @@ dependencies = [ { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/7a/eae622a97ba1721fd7e420c60060a74852b800ac1fecbaa2e67a35941d6d/alembic_utils-0.8.8.tar.gz", hash = "sha256:99de5d13194f26536bc0322f0c1660020a305015700d8447ccfc20e7d1494e5b", size = 21638, upload-time = "2025-04-10T18:58:13.212Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/b0/5187a03dcea41dca1f684a815309689680a987db56e0d777d9a608d0cfc3/alembic_utils_extended-1.5.0.tar.gz", hash = "sha256:636963c932458d52c4f489830140c677755038ef4dc2ad49f86789b98a1224b8", size = 42298, upload-time = "2026-08-13T00:12:13.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/01/d55bd80997df2ec1ff2fd40cd3eeadec93c4b3c5492df3c6852b29f9e393/alembic_utils-0.8.8-py3-none-any.whl", hash = "sha256:2c2545dc545833c5deb63bce2c3cde01c1807bf99da5efab2497bc8d817cb86e", size = 31044, upload-time = "2025-04-10T18:58:12.247Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3f/70b7b79b4738b4c85c3a5af1fd9357009d169b331872d92b16b4d37d0392/alembic_utils_extended-1.5.0-py3-none-any.whl", hash = "sha256:9a53a9767291239fd29126d99d5c2c7ffe7c4a8120bc7be69dc89f5cdd978995", size = 53262, upload-time = "2026-08-13T00:12:11.806Z" }, ] [[package]] @@ -396,7 +396,7 @@ name = "collectoss" source = { editable = "." } dependencies = [ { name = "alembic" }, - { name = "alembic-utils" }, + { name = "alembic-utils-extended" }, { name = "beaker" }, { name = "boto3" }, { name = "bs4" }, @@ -501,7 +501,7 @@ test = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.17.1" }, - { name = "alembic-utils", specifier = "==0.8.8" }, + { name = "alembic-utils-extended", specifier = ">=1.5.0" }, { name = "beaker", specifier = "==1.11.0" }, { name = "boto3", specifier = "==1.17.57" }, { name = "bs4", specifier = "==0.0.1" },