From f96f5b35a3264f1a36bc454201cae629a2ba9d64 Mon Sep 17 00:00:00 2001 From: Lucas Delvoye Date: Mon, 19 Oct 2026 09:51:39 -0700 Subject: [PATCH] feat(subscriptions): add postgres connection and alembic migrations Sets up the Postgres side of the subscription store: settings for the app and migrations URLs, a pgbouncer-safe engine, the subscriptions table, and a migrate command. The devserver and CI get a local Postgres. --- .envrc | 3 + .github/workflows/ci.yml | 3 + devservices/config.yml | 8 ++- docker-compose.gcb.yml | 13 ++++ pyproject.toml | 3 + snuba/cli/devserver.py | 8 +++ snuba/cli/subscriptions_postgres.py | 14 ++++ snuba/settings/__init__.py | 4 ++ snuba/settings/settings_test.py | 6 ++ snuba/subscriptions/postgres/__init__.py | 0 snuba/subscriptions/postgres/alembic.ini | 3 + snuba/subscriptions/postgres/database.py | 41 ++++++++++++ .../postgres/migrations/__init__.py | 0 .../subscriptions/postgres/migrations/env.py | 18 +++++ .../postgres/migrations/script.py.mako | 26 ++++++++ .../versions/0001_create_subscriptions.py | 38 +++++++++++ .../postgres/migrations/versions/__init__.py | 0 tests/subscriptions/test_postgres.py | 49 ++++++++++++++ uv.lock | 66 +++++++++++++++++++ 19 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 snuba/cli/subscriptions_postgres.py create mode 100644 snuba/subscriptions/postgres/__init__.py create mode 100644 snuba/subscriptions/postgres/alembic.ini create mode 100644 snuba/subscriptions/postgres/database.py create mode 100644 snuba/subscriptions/postgres/migrations/__init__.py create mode 100644 snuba/subscriptions/postgres/migrations/env.py create mode 100644 snuba/subscriptions/postgres/migrations/script.py.mako create mode 100644 snuba/subscriptions/postgres/migrations/versions/0001_create_subscriptions.py create mode 100644 snuba/subscriptions/postgres/migrations/versions/__init__.py create mode 100644 tests/subscriptions/test_postgres.py diff --git a/.envrc b/.envrc index e110aef7365..e9bebc5a28c 100644 --- a/.envrc +++ b/.envrc @@ -25,4 +25,7 @@ fi export VIRTUAL_ENV="${PWD}/.venv" PATH_add "${PWD}/.venv/bin" +export SUBSCRIPTIONS_POSTGRES_URL="postgresql+psycopg://postgres@127.0.0.1:5432/snuba_subscriptions" +export SUBSCRIPTIONS_POSTGRES_MIGRATIONS_URL="${SUBSCRIPTIONS_POSTGRES_URL}" + . scripts/rust-envvars diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 115159f91cb..404a90e61da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -262,6 +262,7 @@ jobs: "ghcr.io/getsentry/image-mirror-confluentinc-cp-kafka:6.2.0" "ghcr.io/getsentry/image-mirror-confluentinc-cp-zookeeper:6.2.0" "ghcr.io/getsentry/docker-redis-cluster:7.0.10" + "ghcr.io/getsentry/image-mirror-library-postgres:18-alpine" ) pulled=0 for image in "${images[@]}"; do @@ -334,6 +335,7 @@ jobs: "ghcr.io/getsentry/image-mirror-confluentinc-cp-kafka:6.2.0" "ghcr.io/getsentry/image-mirror-confluentinc-cp-zookeeper:6.2.0" "ghcr.io/getsentry/docker-redis-cluster:7.0.10" + "ghcr.io/getsentry/image-mirror-library-postgres:18-alpine" ) for image in "${images[@]}"; do if ! docker image inspect "$image" &>/dev/null; then @@ -528,6 +530,7 @@ jobs: "ghcr.io/getsentry/image-mirror-confluentinc-cp-kafka:6.2.0" "ghcr.io/getsentry/image-mirror-confluentinc-cp-zookeeper:6.2.0" "ghcr.io/getsentry/docker-redis-cluster:7.0.10" + "ghcr.io/getsentry/image-mirror-library-postgres:18-alpine" ) for image in "${images[@]}"; do if ! docker image inspect "$image" &>/dev/null; then diff --git a/devservices/config.yml b/devservices/config.yml index e6c036d90dc..02aec8ee486 100644 --- a/devservices/config.yml +++ b/devservices/config.yml @@ -17,6 +17,12 @@ x-sentry-service-config: repo_name: sentry-shared-kafka branch: main repo_link: https://github.com/getsentry/sentry-shared-kafka.git + postgres: + description: Shared instance of postgres used by sentry services + remote: + repo_name: sentry-shared-postgres + branch: main + repo_link: https://github.com/getsentry/sentry-shared-postgres.git snuba: description: Service that provides a rich data model on top of Clickhouse together with a fast ingestion consumer and a query optimizer profiles-consumer: @@ -28,7 +34,7 @@ x-sentry-service-config: metrics-consumer: description: Consumer for metrics modes: - default: [redis, kafka, clickhouse] + default: [redis, kafka, clickhouse, postgres] containerized: [clickhouse, redis, kafka, snuba] containerized-profiles: [clickhouse, redis, kafka, snuba, profiles-consumer, profile-chunks-consumer, functions-consumer, functions-consumer] containerized-metrics-dev: [clickhouse, redis, kafka, snuba, metrics-consumer] diff --git a/docker-compose.gcb.yml b/docker-compose.gcb.yml index 388ea28afe2..9615d49d791 100644 --- a/docker-compose.gcb.yml +++ b/docker-compose.gcb.yml @@ -11,6 +11,8 @@ x-test-common: &test-common condition: service_started zookeeper: condition: service_started + postgres: + condition: service_healthy image: "$SNUBA_IMAGE" profiles: ["run_test"] volumes: @@ -22,6 +24,7 @@ x-test-common: &test-common REDIS_HOST: "redis-cluster" REDIS_PORT: 7000 REDIS_DB: 0 + POSTGRES_HOST: postgres DEFAULT_BROKERS: "kafka:9092" SNUBA_TEST_SHARD: "${SNUBA_TEST_SHARD:-0}" SNUBA_TEST_SHARD_TOTAL: "${SNUBA_TEST_SHARD_TOTAL:-1}" @@ -189,6 +192,16 @@ services: soft: 262144 hard: 262144 + postgres: + image: ghcr.io/getsentry/image-mirror-library-postgres:18-alpine + environment: + POSTGRES_HOST_AUTH_METHOD: trust + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 3 + # This is the same thing as https://github.com/Grokzen/docker-redis-cluster but mirrored redis-cluster: image: ghcr.io/getsentry/docker-redis-cluster:7.0.10 diff --git a/pyproject.toml b/pyproject.toml index 1eb681ef2b8..5b2d1c18210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ version = "26.11.0.dev0" # does not resolve future Python versions that our pinned wheels do not support. requires-python = ">=3.13,<3.14" dependencies = [ + "alembic>=1.20.0", "blinker>=1.9", "click>=8.1.7", "clickhouse-connect>=1.6.0", @@ -26,6 +27,7 @@ dependencies = [ "progressbar2>=4.2.0", "proto-plus>=1.24.0", "protobuf>=5.29.5", + "psycopg[binary]>=3.3.6", "pyasn1>=0.6.4", "python-dateutil>=2.8.2", "pyjwt[crypto]>=2.8.0", @@ -44,6 +46,7 @@ dependencies = [ "simplejson>=3.17.6", "snuba-sdk>=3.0.39", "sql-metadata>=3.0.1", + "sqlalchemy>=2.1.4", "sqlparse>=0.6.0", "structlog>=22.3.0", "structlog-sentry>=2.0.0", diff --git a/snuba/cli/devserver.py b/snuba/cli/devserver.py index d23d99318dd..55d9900539d 100644 --- a/snuba/cli/devserver.py +++ b/snuba/cli/devserver.py @@ -8,6 +8,7 @@ import click from snuba import settings +from snuba.subscriptions.postgres.database import create_database_if_missing # Match honcho: SIGTERM, then SIGKILL if children do not exit (avoids indefinite # hang when a child ignores SIGTERM; see also PEP 475 / wait() retry after signals). @@ -52,6 +53,13 @@ def devserver(*, bootstrap: bool, workers: bool, log_level: str) -> None: if returncode > 0: sys.exit(returncode) + migrations_url = settings.SUBSCRIPTIONS_POSTGRES_MIGRATIONS_URL + if migrations_url is not None: + create_database_if_missing(migrations_url) + returncode = call(["snuba", "subscriptions-postgres", "migrate"]) + if returncode > 0: + sys.exit(returncode) + daemons = [("api", ["snuba", "api"])] if not workers: diff --git a/snuba/cli/subscriptions_postgres.py b/snuba/cli/subscriptions_postgres.py new file mode 100644 index 00000000000..b886d4eb30b --- /dev/null +++ b/snuba/cli/subscriptions_postgres.py @@ -0,0 +1,14 @@ +import click + +from snuba.subscriptions.postgres.database import migrate_to_head + + +@click.group() +def subscriptions_postgres() -> None: + pass + + +@subscriptions_postgres.command() +def migrate() -> None: + """Upgrade the subscriptions Postgres database to the latest migration.""" + migrate_to_head() diff --git a/snuba/settings/__init__.py b/snuba/settings/__init__.py index 49acb0d4dbe..f8951d83c1d 100644 --- a/snuba/settings/__init__.py +++ b/snuba/settings/__init__.py @@ -181,6 +181,10 @@ class RedisClusterConfig(TypedDict): # default redis command timeout in seconds for redis commands (e.g. configs, rate limits) which are meant to be quick and fail-open REDIS_SOCKET_TIMEOUT = 0.1 +# The app connects as snuba through pgbouncer; migrations connect as snuba-migrations. +SUBSCRIPTIONS_POSTGRES_URL = os.environ.get("SUBSCRIPTIONS_POSTGRES_URL") +SUBSCRIPTIONS_POSTGRES_MIGRATIONS_URL = os.environ.get("SUBSCRIPTIONS_POSTGRES_MIGRATIONS_URL") + class RedisClusters(TypedDict): cache: RedisClusterConfig | None diff --git a/snuba/settings/settings_test.py b/snuba/settings/settings_test.py index a96b68884f1..fd5679574b6 100644 --- a/snuba/settings/settings_test.py +++ b/snuba/settings/settings_test.py @@ -64,3 +64,9 @@ VALIDATE_DATASET_YAMLS_ON_STARTUP = True LOG_MIGRATIONS = False + +POSTGRES_HOST = os.environ.get("POSTGRES_HOST", "127.0.0.1") +SUBSCRIPTIONS_POSTGRES_URL = ( + f"postgresql+psycopg://postgres@{POSTGRES_HOST}:5432/snuba_subscriptions_test" +) +SUBSCRIPTIONS_POSTGRES_MIGRATIONS_URL = SUBSCRIPTIONS_POSTGRES_URL diff --git a/snuba/subscriptions/postgres/__init__.py b/snuba/subscriptions/postgres/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/snuba/subscriptions/postgres/alembic.ini b/snuba/subscriptions/postgres/alembic.ini new file mode 100644 index 00000000000..d7e0b766cf6 --- /dev/null +++ b/snuba/subscriptions/postgres/alembic.ini @@ -0,0 +1,3 @@ +[alembic] +script_location = %(here)s/migrations +file_template = %%(rev)s_%%(slug)s diff --git a/snuba/subscriptions/postgres/database.py b/snuba/subscriptions/postgres/database.py new file mode 100644 index 00000000000..07b4d87ce4a --- /dev/null +++ b/snuba/subscriptions/postgres/database.py @@ -0,0 +1,41 @@ +from pathlib import Path +from typing import Any + +from alembic import command +from alembic.config import Config +from sqlalchemy import Engine, create_engine, make_url, text + +ALEMBIC_INI = Path(__file__).parent / "alembic.ini" + +CONNECT_ARGS: dict[str, Any] = { + # pgbouncer runs the app role in transaction mode, which breaks prepared statements. + "prepare_threshold": None, + "connect_timeout": 5, + # Detect a dead connection in under a minute instead of the OS default of 2 hours. + "keepalives_idle": 10, + "tcp_user_timeout": 30000, +} + + +def build_engine(url: str) -> Engine: + return create_engine(url, pool_pre_ping=True, connect_args=CONNECT_ARGS) + + +def create_database_if_missing(url: str) -> None: + """Create the URL's database if absent; dev and test only, prod's comes from alloydb-init.""" + target = make_url(url) + maintenance_url = target.set(database="postgres") + engine = create_engine(maintenance_url, isolation_level="AUTOCOMMIT") + query = text("SELECT 1 FROM pg_database WHERE datname = :name") + with engine.connect() as connection: + result = connection.execute(query, {"name": target.database}) + exists = result.scalar() + if exists is None: + create = text(f'CREATE DATABASE "{target.database}"') + connection.execute(create) + engine.dispose() + + +def migrate_to_head() -> None: + config = Config(str(ALEMBIC_INI)) + command.upgrade(config, "head") diff --git a/snuba/subscriptions/postgres/migrations/__init__.py b/snuba/subscriptions/postgres/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/snuba/subscriptions/postgres/migrations/env.py b/snuba/subscriptions/postgres/migrations/env.py new file mode 100644 index 00000000000..a3927340125 --- /dev/null +++ b/snuba/subscriptions/postgres/migrations/env.py @@ -0,0 +1,18 @@ +from alembic import context + +from snuba import settings +from snuba.subscriptions.postgres.database import build_engine + +if context.is_offline_mode(): + raise RuntimeError("Offline migrations are not supported") + +url = settings.SUBSCRIPTIONS_POSTGRES_MIGRATIONS_URL +if url is None: + raise RuntimeError("SUBSCRIPTIONS_POSTGRES_MIGRATIONS_URL is not set") + +engine = build_engine(url) +with engine.connect() as connection: + context.configure(connection=connection, target_metadata=None) + with context.begin_transaction(): + context.run_migrations() +engine.dispose() diff --git a/snuba/subscriptions/postgres/migrations/script.py.mako b/snuba/subscriptions/postgres/migrations/script.py.mako new file mode 100644 index 00000000000..186af0d33a3 --- /dev/null +++ b/snuba/subscriptions/postgres/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: str | Sequence[str] | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/snuba/subscriptions/postgres/migrations/versions/0001_create_subscriptions.py b/snuba/subscriptions/postgres/migrations/versions/0001_create_subscriptions.py new file mode 100644 index 00000000000..81c80fd375d --- /dev/null +++ b/snuba/subscriptions/postgres/migrations/versions/0001_create_subscriptions.py @@ -0,0 +1,38 @@ +"""Create the subscriptions table. + +Revision ID: 0001 +Revises: +Create Date: 2026-10-16 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0001" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "subscriptions", + sa.Column("id", postgresql.UUID(), primary_key=True), + sa.Column("entity", sa.Text(), nullable=False), + sa.Column("partition", sa.Integer(), nullable=False), + sa.Column("data", postgresql.JSONB(), nullable=False), + ) + op.create_index( + "subscriptions_entity_partition_idx", + "subscriptions", + ["entity", "partition"], + ) + + +def downgrade() -> None: + op.drop_index("subscriptions_entity_partition_idx", table_name="subscriptions") + op.drop_table("subscriptions") diff --git a/snuba/subscriptions/postgres/migrations/versions/__init__.py b/snuba/subscriptions/postgres/migrations/versions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/subscriptions/test_postgres.py b/tests/subscriptions/test_postgres.py new file mode 100644 index 00000000000..90532365b76 --- /dev/null +++ b/tests/subscriptions/test_postgres.py @@ -0,0 +1,49 @@ +from collections.abc import Generator + +import psycopg +import pytest +from sqlalchemy import Engine, inspect + +from snuba import settings +from snuba.subscriptions.postgres.database import ( + build_engine, + create_database_if_missing, + migrate_to_head, +) + + +@pytest.fixture(scope="module") +def engine() -> Generator[Engine]: + url = settings.SUBSCRIPTIONS_POSTGRES_URL + assert url is not None + create_database_if_missing(url) + migrate_to_head() + engine = build_engine(url) + yield engine + engine.dispose() + + +def test_subscriptions_table_schema(engine: Engine) -> None: + inspector = inspect(engine) + columns = inspector.get_columns("subscriptions") + shapes = {column["name"]: (str(column["type"]), column["nullable"]) for column in columns} + assert shapes == { + "id": ("UUID", False), + "entity": ("TEXT", False), + "partition": ("INTEGER", False), + "data": ("JSONB", False), + } + + primary_key = inspector.get_pk_constraint("subscriptions") + assert primary_key["constrained_columns"] == ["id"] + + indexes = inspector.get_indexes("subscriptions") + index_columns = {index["name"]: index["column_names"] for index in indexes} + assert index_columns == {"subscriptions_entity_partition_idx": ["entity", "partition"]} + + +def test_engine_disables_prepared_statements(engine: Engine) -> None: + with engine.connect() as connection: + dbapi_connection = connection.connection.dbapi_connection + assert isinstance(dbapi_connection, psycopg.Connection) + assert dbapi_connection.prepare_threshold is None diff --git a/uv.lock b/uv.lock index 18696db8983..364721c2f63 100644 --- a/uv.lock +++ b/uv.lock @@ -28,6 +28,19 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b" }, ] +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d" }, +] + [[package]] name = "ast-serialize" version = "0.3.0" @@ -577,6 +590,17 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64" }, ] +[[package]] +name = "mako" +version = "1.4.3" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +dependencies = [ + { name = "markupsafe" }, +] +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -778,6 +802,29 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9" }, ] +[[package]] +name = "psycopg" +version = "3.3.6" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.6" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f" }, + { url = "https://pypi.devinfra.sentry.io/wheels/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269" }, + { url = "https://pypi.devinfra.sentry.io/wheels/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef" }, +] + [[package]] name = "pyasn1" version = "0.6.4" @@ -1180,6 +1227,7 @@ name = "snuba" version = "26.11.0.dev0" source = { editable = "." } dependencies = [ + { name = "alembic" }, { name = "blinker" }, { name = "click" }, { name = "clickhouse-connect" }, @@ -1201,6 +1249,7 @@ dependencies = [ { name = "progressbar2" }, { name = "proto-plus" }, { name = "protobuf" }, + { name = "psycopg", extra = ["binary"] }, { name = "pyasn1" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-dateutil" }, @@ -1220,6 +1269,7 @@ dependencies = [ { name = "simplejson" }, { name = "snuba-sdk" }, { name = "sql-metadata" }, + { name = "sqlalchemy" }, { name = "sqlparse" }, { name = "structlog" }, { name = "structlog-sentry" }, @@ -1256,6 +1306,7 @@ docs = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.20.0" }, { name = "blinker", specifier = ">=1.9" }, { name = "click", specifier = ">=8.1.7" }, { name = "clickhouse-connect", specifier = ">=1.6.0" }, @@ -1277,6 +1328,7 @@ requires-dist = [ { name = "progressbar2", specifier = ">=4.2.0" }, { name = "proto-plus", specifier = ">=1.24.0" }, { name = "protobuf", specifier = ">=5.29.5" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" }, { name = "pyasn1", specifier = ">=0.6.4" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" }, { name = "python-dateutil", specifier = ">=2.8.2" }, @@ -1296,6 +1348,7 @@ requires-dist = [ { name = "simplejson", specifier = ">=3.17.6" }, { name = "snuba-sdk", specifier = ">=3.0.39" }, { name = "sql-metadata", specifier = ">=3.0.1" }, + { name = "sqlalchemy", specifier = ">=2.1.4" }, { name = "sqlparse", specifier = ">=0.6.0" }, { name = "structlog", specifier = ">=22.3.0" }, { name = "structlog-sentry", specifier = ">=2.0.0" }, @@ -1426,6 +1479,19 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/sql_metadata-3.0.1-py3-none-any.whl", hash = "sha256:33b17891a149bfd851f5927d38fe6f133c63fca5955c016925dbc67113a07f7d" }, ] +[[package]] +name = "sqlalchemy" +version = "2.1.4" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/sqlalchemy-2.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:70006e9e6157200b795beeee04bd5cb15bccb40a14de595eb9f5dcf5945ed244" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sqlalchemy-2.1.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3341ddc430733cd961bc064889f42712a0b4056733a21c83176842aad67d12a6" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sqlalchemy-2.1.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98f7a4bfeaed3722804f737ae2bd4077b35e57d6f4531fe612bac8160cda5acd" }, +] + [[package]] name = "sqlglot" version = "30.16.0"