diff --git a/README.md b/README.md index 9c73f34..8003ed6 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ --- ### Connect -Supports all major databases: SQL Server, PostgreSQL, MySQL, SQLite, MariaDB, FirebirdSQL, Oracle, DuckDB, CockroachDB, ClickHouse, Snowflake, Supabase, CloudFlare D1, Turso, Athena, BigQuery, RedShift, IBM Db2, SAP HANA, Teradata, Trino, Presto and Apache Flight SQL. +Supports all major databases: SQL Server, PostgreSQL, MySQL, SQLite, MariaDB, FirebirdSQL, Oracle, DuckDB, CockroachDB, ClickHouse, Snowflake, Supabase, CloudFlare D1, Turso, Athena, BigQuery, Spanner, RedShift, IBM Db2, SAP HANA, Teradata, Trino, Presto and Apache Flight SQL. ![Database Providers](docs/demos/demo-providers.gif) @@ -281,6 +281,8 @@ Most of the time you can just run `sqlit` and connect. If a Python driver is mis | Snowflake | `snowflake-connector-python` | `pipx inject sqlit-tui snowflake-connector-python` | `python -m pip install snowflake-connector-python` | | Firebird | `firebirdsql` | `pipx inject sqlit-tui firebirdsql` | `python -m pip install firebirdsql` | | Athena | `pyathena` | `pipx inject sqlit-tui pyathena` | `python -m pip install pyathena` | +| BigQuery | `google-cloud-bigquery` | `pipx inject sqlit-tui google-cloud-bigquery` | `python -m pip install google-cloud-bigquery` | +| Spanner | `google-cloud-spanner` | `pipx inject sqlit-tui google-cloud-spanner` | `python -m pip install google-cloud-spanner` | | Apache Arrow Flight SQL | `adbc-driver-flightsql` | `pipx inject sqlit-tui adbc-driver-flightsql` | `python -m pip install adbc-driver-flightsql` | ### SSH Tunnel Support diff --git a/pyproject.toml b/pyproject.toml index 3f21e3d..68a0cfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ all = [ "trino>=0.329.0", "presto-python-client>=0.8.4", "google-cloud-bigquery", + "google-cloud-spanner>=3.0.0", "duckdb>=1.1.0", # min avoids known CVEs "clickhouse-connect>=0.7.0", "requests>=2.32.4", # min avoids known CVEs @@ -72,6 +73,7 @@ teradata = ["teradatasql>=20.0.0"] trino = ["trino>=0.329.0"] presto = ["presto-python-client>=0.8.4"] bigquery = ["google-cloud-bigquery"] +spanner = ["google-cloud-spanner>=3.0.0"] redshift = ["redshift-connector"] duckdb = ["duckdb>=1.1.0"] # min avoids known CVEs clickhouse = ["clickhouse-connect>=0.7.0"] @@ -187,6 +189,7 @@ markers = [ "firebird: Firebird database tests", "clickhouse: ClickHouse database tests", "flight: Apache Arrow Flight SQL database tests", + "spanner: Google Cloud Spanner database tests", "asyncio: async tests", "integration: integration tests (may require external services)", ] @@ -241,7 +244,18 @@ module = [ "adbc_driver_flightsql", "adbc_driver_flightsql.dbapi", "adbc_driver_manager", - "textual_fastdatatable" + "textual_fastdatatable", + "google.cloud", + "google.cloud.bigquery", + "google.cloud.bigquery.dbapi", + "google.cloud.spanner", + "google.cloud.spanner_dbapi", + "google.api_core", + "google.api_core.client_options", + "google.auth", + "google.auth.credentials", + "google.oauth2", + "google.oauth2.service_account", ] ignore_missing_imports = true diff --git a/sqlit/domains/connections/domain/config.py b/sqlit/domains/connections/domain/config.py index c2eba8f..01e9670 100644 --- a/sqlit/domains/connections/domain/config.py +++ b/sqlit/domains/connections/domain/config.py @@ -29,6 +29,7 @@ class DatabaseType(str, Enum): PRESTO = "presto" REDSHIFT = "redshift" SNOWFLAKE = "snowflake" + SPANNER = "spanner" SQLITE = "sqlite" SUPABASE = "supabase" TERADATA = "teradata" @@ -50,6 +51,7 @@ class DatabaseType(str, Enum): DatabaseType.TERADATA, DatabaseType.SNOWFLAKE, DatabaseType.BIGQUERY, + DatabaseType.SPANNER, DatabaseType.TRINO, DatabaseType.PRESTO, DatabaseType.DUCKDB, diff --git a/sqlit/domains/connections/providers/spanner/__init__.py b/sqlit/domains/connections/providers/spanner/__init__.py new file mode 100644 index 0000000..e5cb314 --- /dev/null +++ b/sqlit/domains/connections/providers/spanner/__init__.py @@ -0,0 +1 @@ +"""Google Cloud Spanner provider package.""" diff --git a/sqlit/domains/connections/providers/spanner/adapter.py b/sqlit/domains/connections/providers/spanner/adapter.py new file mode 100644 index 0000000..f5ed784 --- /dev/null +++ b/sqlit/domains/connections/providers/spanner/adapter.py @@ -0,0 +1,378 @@ +"""Google Cloud Spanner adapter using google-cloud-spanner DB-API. + +Note on INFORMATION_SCHEMA queries: + Spanner's DB-API uses read-write transactions by default, but + INFORMATION_SCHEMA queries require read-only mode. All metadata + introspection methods use _execute_readonly() which temporarily + enables autocommit mode to work around this limitation. + +Note on DNS resolution: + Some networks have issues with gRPC's default c-ares DNS resolver. + The adapter sets GRPC_DNS_RESOLVER=native if not already set. + +Note on dialect support: + Spanner supports two SQL dialects: GoogleSQL and PostgreSQL. + The adapter detects the dialect on connect and adjusts identifier + quoting accordingly (backticks for GoogleSQL, double quotes for PostgreSQL). + PostgreSQL dialect support is experimental and untested. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlit.domains.connections.providers.adapters.base import ( + ColumnInfo, + CursorBasedAdapter, + IndexInfo, + SequenceInfo, + TableInfo, + TriggerInfo, +) + +if TYPE_CHECKING: + from sqlit.domains.connections.domain.config import ConnectionConfig + +# Dialect constants +DIALECT_GOOGLESQL = "GOOGLE_STANDARD_SQL" +DIALECT_POSTGRESQL = "POSTGRESQL" + + +class SpannerAdapter(CursorBasedAdapter): + """Adapter for Google Cloud Spanner.""" + + @property + def name(self) -> str: + return "Spanner" + + @property + def install_extra(self) -> str: + return "spanner" + + @property + def install_package(self) -> str: + return "google-cloud-spanner" + + @property + def driver_import_names(self) -> tuple[str, ...]: + return ("google.cloud.spanner_dbapi",) + + @property + def supports_multiple_databases(self) -> bool: + # Spanner connects to a single database per connection + return False + + @property + def supports_cross_database_queries(self) -> bool: + # Each connection is scoped to one database + return False + + @property + def supports_stored_procedures(self) -> bool: + return False + + @property + def supports_triggers(self) -> bool: + return False + + @property + def supports_indexes(self) -> bool: + # Spanner has indexes but they're exposed via INFORMATION_SCHEMA + return True + + @property + def supports_sequences(self) -> bool: + return False + + @property + def default_schema(self) -> str: + # Spanner doesn't have schemas + return "" + + def _get_option(self, config: ConnectionConfig, key: str) -> str: + """Get a string option from config.""" + value = config.options.get(key, "") + return str(value) if value else "" + + def connect(self, config: ConnectionConfig) -> Any: + """Connect to Google Cloud Spanner using the DB-API connector.""" + import os + + # Use native DNS resolver to avoid c-ares DNS issues on some networks + if "GRPC_DNS_RESOLVER" not in os.environ: + os.environ["GRPC_DNS_RESOLVER"] = "native" + + spanner_dbapi = self._import_driver_module( + "google.cloud.spanner_dbapi", + driver_name=self.name, + extra_name=self.install_extra, + package_name=self.install_package, + ) + + project = self._get_option(config, "spanner_project") + instance = self._get_option(config, "spanner_instance") + database = config.tcp_endpoint.database if config.tcp_endpoint else "" + if not database: + database = self._get_option(config, "database") + database_role = self._get_option(config, "spanner_database_role") or None + emulator_host = self._get_option(config, "spanner_emulator_host") + + credentials = None + auth_method = self._get_option(config, "spanner_auth_method") or "default" + + if emulator_host: + # For emulator, we need to set the environment variable + os.environ["SPANNER_EMULATOR_HOST"] = emulator_host + elif auth_method == "service_account": + credentials_path = self._get_option(config, "spanner_credentials_path") + if credentials_path: + from google.oauth2 import service_account + credentials = service_account.Credentials.from_service_account_file(credentials_path) + + connect_kwargs: dict[str, Any] = { + "instance_id": instance, + "database_id": database, + "project": project, + } + if credentials: + connect_kwargs["credentials"] = credentials + if database_role: + connect_kwargs["database_role"] = database_role + + conn = spanner_dbapi.connect(**connect_kwargs) + + # Store config for later use + conn._sqlit_spanner_database = database + + # Detect and store the database dialect (GoogleSQL or PostgreSQL) + conn._sqlit_spanner_dialect = self._detect_dialect(conn) + + return conn + + def _detect_dialect(self, conn: Any) -> str: + """Detect the database dialect (GoogleSQL or PostgreSQL). + + Queries INFORMATION_SCHEMA.DATABASE_OPTIONS to determine which SQL + dialect the database uses. This affects identifier quoting. + """ + query = """ + SELECT OPTION_VALUE + FROM INFORMATION_SCHEMA.DATABASE_OPTIONS + WHERE OPTION_NAME = 'database_dialect' + """ + rows = self._execute_readonly(conn, query) + if rows and rows[0]: + return str(rows[0][0]) + # If we can't detect, raise an error (no fallback) + msg = "Could not detect Spanner database dialect" + raise ValueError(msg) + + def _get_dialect(self, conn: Any) -> str: + """Get the cached dialect for a connection.""" + dialect = getattr(conn, "_sqlit_spanner_dialect", None) + if dialect is None: + msg = "Spanner dialect not detected on connection" + raise ValueError(msg) + return dialect + + def get_databases(self, conn: Any) -> list[str]: + """Return the connected database (Spanner is single-database per connection).""" + database = getattr(conn, "_sqlit_spanner_database", None) + if database: + return [database] + return [] + + def _execute_readonly(self, conn: Any, query: str, params: dict[str, Any] | None = None) -> list[Any]: + """Execute a read-only query (required for INFORMATION_SCHEMA). + + Spanner's DB-API uses read-write transactions by default, which don't + support INFORMATION_SCHEMA queries. We temporarily enable autocommit + mode to use single-use read-only transactions. + """ + original_autocommit = conn.autocommit + try: + conn.autocommit = True + cursor = conn.cursor() + if params: + cursor.execute(query, params) + else: + cursor.execute(query) + return cursor.fetchall() + finally: + conn.autocommit = original_autocommit + + def get_tables(self, conn: Any, database: str | None = None) -> list[TableInfo]: + """Get list of tables from INFORMATION_SCHEMA.""" + query = """ + SELECT TABLE_SCHEMA, TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_TYPE = 'BASE TABLE' + AND TABLE_SCHEMA = '' + ORDER BY TABLE_NAME + """ + rows = self._execute_readonly(conn, query) + return [(row[0] or "", row[1]) for row in rows] + + def get_views(self, conn: Any, database: str | None = None) -> list[TableInfo]: + """Get list of views from INFORMATION_SCHEMA.""" + query = """ + SELECT TABLE_SCHEMA, TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_TYPE = 'VIEW' + AND TABLE_SCHEMA = '' + ORDER BY TABLE_NAME + """ + rows = self._execute_readonly(conn, query) + return [(row[0] or "", row[1]) for row in rows] + + def get_columns( + self, conn: Any, table: str, database: str | None = None, schema: str | None = None + ) -> list[ColumnInfo]: + """Get columns for a table from INFORMATION_SCHEMA.""" + # Get primary key columns first + pk_query = """ + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.INDEX_COLUMNS + WHERE TABLE_NAME = @table_name + AND INDEX_NAME = 'PRIMARY_KEY' + ORDER BY ORDINAL_POSITION + """ + pk_rows = self._execute_readonly(conn, pk_query, {"table_name": table}) + pk_columns = {row[0] for row in pk_rows} + + # Get all columns + query = """ + SELECT COLUMN_NAME, SPANNER_TYPE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = @table_name + ORDER BY ORDINAL_POSITION + """ + rows = self._execute_readonly(conn, query, {"table_name": table}) + return [ + ColumnInfo( + name=row[0], + data_type=row[1], + is_primary_key=row[0] in pk_columns, + ) + for row in rows + ] + + def get_procedures(self, conn: Any, database: str | None = None) -> list[str]: + """Spanner doesn't support stored procedures.""" + return [] + + def get_indexes(self, conn: Any, database: str | None = None) -> list[IndexInfo]: + """Get list of indexes from INFORMATION_SCHEMA.""" + query = """ + SELECT INDEX_NAME, TABLE_NAME, IS_UNIQUE + FROM INFORMATION_SCHEMA.INDEXES + WHERE INDEX_TYPE != 'PRIMARY_KEY' + AND TABLE_SCHEMA = '' + ORDER BY TABLE_NAME, INDEX_NAME + """ + rows = self._execute_readonly(conn, query) + return [ + IndexInfo( + name=row[0], + table_name=row[1], + is_unique=row[2], + ) + for row in rows + ] + + def get_index_definition( + self, conn: Any, index_name: str, table_name: str, database: str | None = None + ) -> dict[str, Any]: + """Get detailed information about an index.""" + # Get index info + query = """ + SELECT INDEX_NAME, TABLE_NAME, IS_UNIQUE, INDEX_STATE + FROM INFORMATION_SCHEMA.INDEXES + WHERE INDEX_NAME = @index_name + AND TABLE_NAME = @table_name + """ + rows = self._execute_readonly(conn, query, {"index_name": index_name, "table_name": table_name}) + row = rows[0] if rows else None + + if not row: + return { + "name": index_name, + "table_name": table_name, + "columns": [], + "is_unique": False, + "definition": None, + } + + # Get index columns + columns_query = """ + SELECT COLUMN_NAME, COLUMN_ORDERING + FROM INFORMATION_SCHEMA.INDEX_COLUMNS + WHERE INDEX_NAME = @index_name + AND TABLE_NAME = @table_name + ORDER BY ORDINAL_POSITION + """ + col_rows = self._execute_readonly(conn, columns_query, {"index_name": index_name, "table_name": table_name}) + columns = [f"{col[0]} {col[1] or 'ASC'}".strip() for col in col_rows] + + return { + "name": row[0], + "table_name": row[1], + "columns": columns, + "is_unique": row[2], + "index_state": row[3], + "definition": None, + } + + def get_triggers(self, conn: Any, database: str | None = None) -> list[TriggerInfo]: + """Spanner doesn't support triggers.""" + return [] + + def get_sequences(self, conn: Any, database: str | None = None) -> list[SequenceInfo]: + """Spanner doesn't support traditional sequences.""" + return [] + + def _quote_identifier_for_dialect(self, dialect: str, name: str) -> str: + """Quote an identifier based on dialect. + + - GoogleSQL: `identifier` (backticks) + - PostgreSQL: "identifier" (double quotes) + """ + if dialect == DIALECT_POSTGRESQL: + # PostgreSQL dialect uses double quotes + escaped = name.replace('"', '""') + return f'"{escaped}"' + # GoogleSQL uses backticks + escaped = name.replace("`", "\\`") + return f"`{escaped}`" + + def _quote_identifier_for_conn(self, conn: Any, name: str) -> str: + """Quote an identifier using the connection's dialect.""" + dialect = self._get_dialect(conn) + return self._quote_identifier_for_dialect(dialect, name) + + def quote_identifier(self, name: str) -> str: + """Quote an identifier for GoogleSQL (backticks). + + Note: This method doesn't have access to the connection, so it always + uses GoogleSQL syntax. For connection-aware quoting, use + _quote_identifier_for_conn() instead. + """ + return self._quote_identifier_for_dialect(DIALECT_GOOGLESQL, name) + + def build_select_query( + self, table: str, limit: int, database: str | None = None, schema: str | None = None + ) -> str: + """Build SELECT query with LIMIT. + + Note: This method doesn't have access to the connection, so it always + uses GoogleSQL syntax for identifier quoting. + """ + quoted = self._quote_identifier_for_dialect(DIALECT_GOOGLESQL, table) + return f"SELECT * FROM {quoted} LIMIT {limit}" + + def build_select_query_for_conn( + self, conn: Any, table: str, limit: int, database: str | None = None, schema: str | None = None + ) -> str: + """Build SELECT query with LIMIT using connection-aware quoting.""" + quoted = self._quote_identifier_for_conn(conn, table) + return f"SELECT * FROM {quoted} LIMIT {limit}" diff --git a/sqlit/domains/connections/providers/spanner/provider.py b/sqlit/domains/connections/providers/spanner/provider.py new file mode 100644 index 0000000..fd86376 --- /dev/null +++ b/sqlit/domains/connections/providers/spanner/provider.py @@ -0,0 +1,36 @@ +"""Provider registration for Google Cloud Spanner.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlit.domains.connections.providers.adapter_provider import build_adapter_provider +from sqlit.domains.connections.providers.catalog import register_provider +from sqlit.domains.connections.providers.model import DatabaseProvider, ProviderSpec +from sqlit.domains.connections.providers.spanner.schema import SCHEMA + +if TYPE_CHECKING: + pass + + +def _provider_factory(spec: ProviderSpec) -> DatabaseProvider: + from sqlit.domains.connections.providers.spanner.adapter import SpannerAdapter + + return build_adapter_provider(spec, SCHEMA, SpannerAdapter()) + + +SPEC = ProviderSpec( + db_type="spanner", + display_name="Google Cloud Spanner", + schema_path=("sqlit.domains.connections.providers.spanner.schema", "SCHEMA"), + supports_ssh=False, + is_file_based=False, + has_advanced_auth=True, + default_port="", + requires_auth=False, + badge_label="Spanner", + url_schemes=("spanner",), + provider_factory=_provider_factory, +) + +register_provider(SPEC) diff --git a/sqlit/domains/connections/providers/spanner/schema.py b/sqlit/domains/connections/providers/spanner/schema.py new file mode 100644 index 0000000..36ef8b7 --- /dev/null +++ b/sqlit/domains/connections/providers/spanner/schema.py @@ -0,0 +1,83 @@ +"""Connection schema for Google Cloud Spanner.""" + +from __future__ import annotations + +from sqlit.domains.connections.providers.schema_helpers import ( + ConnectionSchema, + FieldType, + SchemaField, + SelectOption, + _get_str_option, +) + + +def _get_spanner_auth_options() -> tuple[SelectOption, ...]: + return ( + SelectOption("default", "Application Default"), + SelectOption("service_account", "Service Account"), + ) + + +def _spanner_auth_is_service_account(config: dict[str, str]) -> bool: + return _get_str_option(config, "spanner_auth_method") == "service_account" + + +SCHEMA = ConnectionSchema( + db_type="spanner", + display_name="Google Cloud Spanner", + fields=( + SchemaField( + name="spanner_project", + label="Project ID", + placeholder="my-gcp-project", + required=True, + description="GCP Project ID", + ), + SchemaField( + name="spanner_instance", + label="Instance ID", + placeholder="my-instance", + required=True, + description="Spanner instance ID", + ), + SchemaField( + name="database", + label="Database ID", + placeholder="my-database", + required=True, + description="Spanner database ID", + ), + SchemaField( + name="spanner_auth_method", + label="Authentication", + field_type=FieldType.DROPDOWN, + options=_get_spanner_auth_options(), + default="default", + ), + SchemaField( + name="spanner_credentials_path", + label="Service Account Key", + placeholder="/path/to/service-account.json", + required=True, + visible_when=_spanner_auth_is_service_account, + description="Path to service account JSON key file", + ), + SchemaField( + name="spanner_database_role", + label="Database Role", + placeholder="(optional)", + required=False, + description="Fine-grained access control role (optional)", + ), + SchemaField( + name="spanner_emulator_host", + label="Emulator Host", + placeholder="localhost:9010", + required=False, + description="Spanner emulator host:port (leave empty for real Spanner)", + ), + ), + supports_ssh=False, + has_advanced_auth=True, + requires_auth=False, +) diff --git a/tests/conftest.py b/tests/conftest.py index ff4c52c..a4e3fb0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ from tests.fixtures.postgres import * from tests.fixtures.trino import * from tests.fixtures.ssh import * +from tests.fixtures.spanner import * from tests.fixtures.sqlite import * from tests.fixtures.turso import * from tests.fixtures.utils import * diff --git a/tests/fixtures/spanner.py b/tests/fixtures/spanner.py new file mode 100644 index 0000000..b154d2d --- /dev/null +++ b/tests/fixtures/spanner.py @@ -0,0 +1,225 @@ +"""Google Cloud Spanner fixtures (using spanner-emulator).""" + +from __future__ import annotations + +import os +import time + +import pytest + +from tests.fixtures.utils import cleanup_connection, is_port_open, run_cli + +SPANNER_HOST = os.environ.get("SPANNER_HOST", "localhost") +SPANNER_PORT = int(os.environ.get("SPANNER_PORT", "9010")) +SPANNER_PROJECT = os.environ.get("SPANNER_PROJECT", "test-project") +SPANNER_INSTANCE = os.environ.get("SPANNER_INSTANCE", "test-instance") +SPANNER_DATABASE = os.environ.get("SPANNER_DATABASE", "test-database") +SPANNER_EMULATOR_HOST = os.environ.get( + "SPANNER_EMULATOR_HOST", f"{SPANNER_HOST}:{SPANNER_PORT}" +) + +os.environ.setdefault("SPANNER_EMULATOR_HOST", SPANNER_EMULATOR_HOST) + + +def spanner_available() -> bool: + """Check if Spanner emulator is available.""" + return is_port_open(SPANNER_HOST, SPANNER_PORT) + + +@pytest.fixture(scope="session") +def spanner_server_ready() -> bool: + """Check if Spanner emulator is ready and return True/False.""" + if not spanner_available(): + return False + + time.sleep(1) + return True + + +def _create_instance_and_database(): + """Create instance and database in emulator using the admin client.""" + from google.cloud import spanner + + client = spanner.Client(project=SPANNER_PROJECT) + + # Create instance + instance = client.instance(SPANNER_INSTANCE) + if not instance.exists(): + config_name = f"projects/{SPANNER_PROJECT}/instanceConfigs/emulator-config" + operation = instance.create( + display_name="Test Instance", + configuration_name=config_name, + node_count=1, + ) + operation.result() + + # Create database + database = instance.database(SPANNER_DATABASE) + if not database.exists(): + operation = database.create() + operation.result() + + return database + + +def _table_exists(database, table_name: str) -> bool: + """Check if a table exists in Spanner.""" + with database.snapshot() as snapshot: + results = snapshot.execute_sql( + """ + SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_NAME = @table_name + """, + params={"table_name": table_name}, + param_types={"table_name": "STRING"}, + ) + return len(list(results)) > 0 + + +@pytest.fixture(scope="function") +def spanner_db(spanner_server_ready: bool) -> str: + """Set up Spanner database and tables.""" + if not spanner_server_ready: + pytest.skip("Spanner emulator is not available") + + try: + from google.cloud import spanner # noqa: F401 + except ImportError: + pytest.skip("google-cloud-spanner is not installed") + + try: + database = _create_instance_and_database() + + # Create tables using DDL + ddl_statements = [] + + if not _table_exists(database, "test_users"): + ddl_statements.append(""" + CREATE TABLE test_users ( + id INT64 NOT NULL, + name STRING(100), + email STRING(100) + ) PRIMARY KEY (id) + """) + + if not _table_exists(database, "test_products"): + ddl_statements.append(""" + CREATE TABLE test_products ( + id INT64 NOT NULL, + name STRING(100), + price FLOAT64, + stock INT64 + ) PRIMARY KEY (id) + """) + + if ddl_statements: + operation = database.update_ddl(ddl_statements) + operation.result() + + # Insert test data using mutations + with database.batch() as batch: + # Check if data exists first + with database.snapshot() as snapshot: + results = snapshot.execute_sql("SELECT COUNT(*) FROM test_users") + count = next(iter(results))[0] + if count == 0: + batch.insert( + "test_users", + columns=["id", "name", "email"], + values=[ + (1, "Alice", "alice@example.com"), + (2, "Bob", "bob@example.com"), + (3, "Charlie", "charlie@example.com"), + ], + ) + + with database.snapshot() as snapshot: + results = snapshot.execute_sql("SELECT COUNT(*) FROM test_products") + count = next(iter(results))[0] + if count == 0: + batch.insert( + "test_products", + columns=["id", "name", "price", "stock"], + values=[ + (1, "Widget", 9.99, 100), + (2, "Gadget", 19.99, 50), + (3, "Gizmo", 29.99, 25), + ], + ) + + # Create view (if not exists) - Spanner uses INFORMATION_SCHEMA for views + try: + if not _table_exists(database, "test_user_emails"): + operation = database.update_ddl([""" + CREATE VIEW test_user_emails SQL SECURITY INVOKER AS + SELECT id, name, email FROM test_users + """]) + operation.result() + except Exception: + # Views might not be fully supported in emulator, skip silently + pass + + # Create index + try: + with database.snapshot() as snapshot: + results = snapshot.execute_sql(""" + SELECT INDEX_NAME FROM INFORMATION_SCHEMA.INDEXES + WHERE INDEX_NAME = 'idx_test_users_email' + """) + if len(list(results)) == 0: + operation = database.update_ddl([ + "CREATE INDEX idx_test_users_email ON test_users(email)" + ]) + operation.result() + except Exception: + pass # Index might already exist + + except Exception as exc: + pytest.skip(f"Failed to setup Spanner database: {exc}") + + yield SPANNER_DATABASE + + +@pytest.fixture(scope="function") +def spanner_connection(spanner_db: str) -> str: + """Create a sqlit CLI connection for Spanner and clean up after test.""" + connection_name = f"test_spanner_{os.getpid()}" + + cleanup_connection(connection_name) + + args = [ + "connections", + "add", + "spanner", + "--name", + connection_name, + "--spanner-project", + SPANNER_PROJECT, + "--spanner-instance", + SPANNER_INSTANCE, + "--database", + spanner_db, + "--spanner-emulator-host", + SPANNER_EMULATOR_HOST, + ] + + run_cli(*args) + + yield connection_name + + cleanup_connection(connection_name) + + +__all__ = [ + "SPANNER_DATABASE", + "SPANNER_EMULATOR_HOST", + "SPANNER_HOST", + "SPANNER_INSTANCE", + "SPANNER_PORT", + "SPANNER_PROJECT", + "spanner_available", + "spanner_connection", + "spanner_db", + "spanner_server_ready", +] diff --git a/tests/test_spanner.py b/tests/test_spanner.py new file mode 100644 index 0000000..ba0300c --- /dev/null +++ b/tests/test_spanner.py @@ -0,0 +1,164 @@ +"""Integration tests for Google Cloud Spanner database operations.""" + +from __future__ import annotations + +import pytest + +from tests.test_database_base import BaseDatabaseTestsWithLimit, DatabaseTestConfig + + +class TestSpannerIntegration(BaseDatabaseTestsWithLimit): + """Integration tests for Google Cloud Spanner database operations via CLI.""" + + @property + def config(self) -> DatabaseTestConfig: + from tests.fixtures.spanner import ( + SPANNER_INSTANCE, + SPANNER_PROJECT, + ) + + return DatabaseTestConfig( + db_type="spanner", + display_name="Spanner", + connection_fixture="spanner_connection", + db_fixture="spanner_db", + create_connection_args=lambda db: [ + "--spanner-project", + SPANNER_PROJECT, + "--spanner-instance", + SPANNER_INSTANCE, + "--database", + db, + ], + ) + + def test_create_spanner_connection(self, spanner_db, cli_runner): + """Test creating a Spanner connection via CLI.""" + from tests.fixtures.spanner import ( + SPANNER_EMULATOR_HOST, + SPANNER_INSTANCE, + SPANNER_PROJECT, + ) + + connection_name = "test_create_spanner" + + try: + # Create connection + result = cli_runner( + "connections", + "add", + "spanner", + "--name", + connection_name, + "--spanner-project", + SPANNER_PROJECT, + "--spanner-instance", + SPANNER_INSTANCE, + "--database", + spanner_db, + "--spanner-emulator-host", + SPANNER_EMULATOR_HOST, + ) + assert result.returncode == 0 + assert "created successfully" in result.stdout + + # Verify it appears in list + result = cli_runner("connection", "list") + assert connection_name in result.stdout + assert "Spanner" in result.stdout + + finally: + # Cleanup + cli_runner("connection", "delete", connection_name, check=False) + + def test_query_spanner_aggregate(self, spanner_connection, cli_runner): + """Test aggregate query on Spanner.""" + result = cli_runner( + "query", + "-c", + spanner_connection, + "-q", + "SELECT COUNT(*) as cnt FROM test_users", + ) + assert result.returncode == 0 + assert "3" in result.stdout + + def test_query_spanner_order_by(self, spanner_connection, cli_runner): + """Test ORDER BY query on Spanner.""" + result = cli_runner( + "query", + "-c", + spanner_connection, + "-q", + "SELECT name FROM test_users ORDER BY name", + ) + assert result.returncode == 0 + assert "Alice" in result.stdout + assert "Bob" in result.stdout + assert "Charlie" in result.stdout + + def test_delete_spanner_connection(self, spanner_db, cli_runner): + """Test deleting a Spanner connection.""" + from tests.fixtures.spanner import ( + SPANNER_EMULATOR_HOST, + SPANNER_INSTANCE, + SPANNER_PROJECT, + ) + + connection_name = "test_delete_spanner" + + # Create connection first + cli_runner( + "connections", + "add", + "spanner", + "--name", + connection_name, + "--spanner-project", + SPANNER_PROJECT, + "--spanner-instance", + SPANNER_INSTANCE, + "--database", + spanner_db, + "--spanner-emulator-host", + SPANNER_EMULATOR_HOST, + ) + + # Delete it + result = cli_runner("connection", "delete", connection_name) + assert result.returncode == 0 + assert "deleted successfully" in result.stdout + + # Verify it's gone + result = cli_runner("connection", "list") + assert connection_name not in result.stdout + + def test_query_spanner_invalid_query(self, spanner_connection, cli_runner): + """Test handling of invalid SQL query.""" + result = cli_runner( + "query", + "-c", + spanner_connection, + "-q", + "SELECT * FROM nonexistent_table", + check=False, + ) + assert result.returncode != 0 + assert "error" in result.stdout.lower() or "error" in result.stderr.lower() + + # Skip some base tests that don't apply to Spanner + @pytest.mark.skip(reason="Spanner doesn't support triggers") + def test_get_triggers(self, request): + pass + + @pytest.mark.skip(reason="Spanner doesn't support trigger definitions") + def test_get_trigger_definition(self, request): + pass + + @pytest.mark.skip(reason="Spanner doesn't support sequences") + def test_get_sequences(self, request): + pass + + @pytest.mark.skip(reason="Spanner doesn't support sequence definitions") + def test_get_sequence_definition(self, request): + pass diff --git a/uv.lock b/uv.lock index 4501e3e..645ea73 100644 --- a/uv.lock +++ b/uv.lock @@ -886,6 +886,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, ] +[[package]] +name = "google-cloud-monitoring" +version = "2.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/a1/a1a0c678569f2a7b1fa65ef71ff528650231a298fc2b89ad49c9991eab94/google_cloud_monitoring-2.29.0.tar.gz", hash = "sha256:eedb8afd1c4e80e8c62435f05c448e9e65be907250a66d81e6af5909778267b6", size = 404769, upload-time = "2026-01-15T13:04:01.597Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/63/b1f6e86ddde8548a0cade2edf3c8ec2183e57f002ea4301b3890a6717190/google_cloud_monitoring-2.29.0-py3-none-any.whl", hash = "sha256:93aa264da0f57f3de2900b0250a37ca27068984f6d94e54175d27aea12a4637f", size = 387988, upload-time = "2026-01-15T13:03:23.528Z" }, +] + +[[package]] +name = "google-cloud-spanner" +version = "3.62.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-cloud-core" }, + { name = "google-cloud-monitoring" }, + { name = "grpc-google-iam-v1" }, + { name = "grpc-interceptor" }, + { name = "mmh3" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-resourcedetector-gcp" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "sqlparse" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/80/86e152f887cdddab5b8268c93d18c671a3653545be2ea2babab6b6ad635f/google_cloud_spanner-3.62.0.tar.gz", hash = "sha256:a25bdbfda84bc7a819f04e45473187d8670711fd5ec827cf442e3664661d1d23", size = 722967, upload-time = "2026-01-16T06:33:29.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/a3/27c0af7f4350757f449e601733d960fc6e2717fa25d3d826ad29b694de68/google_cloud_spanner-3.62.0-py3-none-any.whl", hash = "sha256:b59d7b731463ce998439c1998730760e36f3d699510608d896f2ca8bc57613a9", size = 516156, upload-time = "2026-01-16T06:33:28.173Z" }, +] + [[package]] name = "google-crc32c" version = "1.8.0" @@ -945,6 +985,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, ] +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, +] + +[[package]] +name = "grpc-interceptor" +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/28/57449d5567adf4c1d3e216aaca545913fbc21a915f2da6790d6734aac76e/grpc-interceptor-0.15.4.tar.gz", hash = "sha256:1f45c0bcb58b6f332f37c637632247c9b02bc6af0fdceb7ba7ce8d2ebbfb0926", size = 19322, upload-time = "2023-11-16T02:05:42.459Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/ac/8d53f230a7443401ce81791ec50a3b0e54924bf615ad287654fa4a2f5cdc/grpc_interceptor-0.15.4-py3-none-any.whl", hash = "sha256:0035f33228693ed3767ee49d937bac424318db173fef4d2d0170b3215f254d9d", size = 20848, upload-time = "2023-11-16T02:05:40.913Z" }, +] + [[package]] name = "grpcio" version = "1.76.0" @@ -1102,7 +1173,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1508,6 +1579,118 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mmh3" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/af/f28c2c2f51f31abb4725f9a64bc7863d5f491f6539bd26aee2a1d21a649e/mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8", size = 33582, upload-time = "2025-07-29T07:43:48.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/2b/870f0ff5ecf312c58500f45950751f214b7068665e66e9bfd8bc2595587c/mmh3-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:81c504ad11c588c8629536b032940f2a359dda3b6cbfd4ad8f74cb24dcd1b0bc", size = 56119, upload-time = "2025-07-29T07:41:39.117Z" }, + { url = "https://files.pythonhosted.org/packages/3b/88/eb9a55b3f3cf43a74d6bfa8db0e2e209f966007777a1dc897c52c008314c/mmh3-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b898cecff57442724a0f52bf42c2de42de63083a91008fb452887e372f9c328", size = 40634, upload-time = "2025-07-29T07:41:40.626Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/8e4b3878bf8435c697d7ce99940a3784eb864521768069feaccaff884a17/mmh3-5.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:be1374df449465c9f2500e62eee73a39db62152a8bdfbe12ec5b5c1cd451344d", size = 40080, upload-time = "2025-07-29T07:41:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/0a254402c8c5ca424a0a9ebfe870f5665922f932830f0a11a517b6390a09/mmh3-5.2.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0d753ad566c721faa33db7e2e0eddd74b224cdd3eaf8481d76c926603c7a00e", size = 95321, upload-time = "2025-07-29T07:41:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/39/8e/29306d5eca6dfda4b899d22c95b5420db4e0ffb7e0b6389b17379654ece5/mmh3-5.2.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dfbead5575f6470c17e955b94f92d62a03dfc3d07f2e6f817d9b93dc211a1515", size = 101220, upload-time = "2025-07-29T07:41:43.572Z" }, + { url = "https://files.pythonhosted.org/packages/49/f7/0dd1368e531e52a17b5b8dd2f379cce813bff2d0978a7748a506f1231152/mmh3-5.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7434a27754049144539d2099a6d2da5d88b8bdeedf935180bf42ad59b3607aa3", size = 103991, upload-time = "2025-07-29T07:41:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/35/06/abc7122c40f4abbfcef01d2dac6ec0b77ede9757e5be8b8a40a6265b1274/mmh3-5.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cadc16e8ea64b5d9a47363013e2bea469e121e6e7cb416a7593aeb24f2ad122e", size = 110894, upload-time = "2025-07-29T07:41:45.849Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2f/837885759afa4baccb8e40456e1cf76a4f3eac835b878c727ae1286c5f82/mmh3-5.2.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d765058da196f68dc721116cab335e696e87e76720e6ef8ee5a24801af65e63d", size = 118327, upload-time = "2025-07-29T07:41:47.224Z" }, + { url = "https://files.pythonhosted.org/packages/40/cc/5683ba20a21bcfb3f1605b1c474f46d30354f728a7412201f59f453d405a/mmh3-5.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8b0c53fe0994beade1ad7c0f13bd6fec980a0664bfbe5a6a7d64500b9ab76772", size = 101701, upload-time = "2025-07-29T07:41:48.259Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/99ab3fb940150aec8a26dbdfc39b200b5592f6aeb293ec268df93e054c30/mmh3-5.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:49037d417419863b222ae47ee562b2de9c3416add0a45c8d7f4e864be8dc4f89", size = 96712, upload-time = "2025-07-29T07:41:49.467Z" }, + { url = "https://files.pythonhosted.org/packages/61/04/d7c4cb18f1f001ede2e8aed0f9dbbfad03d161c9eea4fffb03f14f4523e5/mmh3-5.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6ecb4e750d712abde046858ee6992b65c93f1f71b397fce7975c3860c07365d2", size = 110302, upload-time = "2025-07-29T07:41:50.387Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bf/4dac37580cfda74425a4547500c36fa13ef581c8a756727c37af45e11e9a/mmh3-5.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:382a6bb3f8c6532ea084e7acc5be6ae0c6effa529240836d59352398f002e3fc", size = 111929, upload-time = "2025-07-29T07:41:51.348Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b1/49f0a582c7a942fb71ddd1ec52b7d21d2544b37d2b2d994551346a15b4f6/mmh3-5.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7733ec52296fc1ba22e9b90a245c821adbb943e98c91d8a330a2254612726106", size = 100111, upload-time = "2025-07-29T07:41:53.139Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/ccec09f438caeb2506f4c63bb3b99aa08a9e09880f8fc047295154756210/mmh3-5.2.0-cp310-cp310-win32.whl", hash = "sha256:127c95336f2a98c51e7682341ab7cb0be3adb9df0819ab8505a726ed1801876d", size = 40783, upload-time = "2025-07-29T07:41:54.463Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f4/8d39a32c8203c1cdae88fdb04d1ea4aa178c20f159df97f4c5a2eaec702c/mmh3-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:419005f84ba1cab47a77465a2a843562dadadd6671b8758bf179d82a15ca63eb", size = 41549, upload-time = "2025-07-29T07:41:55.295Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/30efb1cd945e193f62574144dd92a0c9ee6463435e4e8ffce9b9e9f032f0/mmh3-5.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:d22c9dcafed659fadc605538946c041722b6d1104fe619dbf5cc73b3c8a0ded8", size = 39335, upload-time = "2025-07-29T07:41:56.194Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/399567b3796e134352e11a8b973cd470c06b2ecfad5468fe580833be442b/mmh3-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7901c893e704ee3c65f92d39b951f8f34ccf8e8566768c58103fb10e55afb8c1", size = 56107, upload-time = "2025-07-29T07:41:57.07Z" }, + { url = "https://files.pythonhosted.org/packages/c3/09/830af30adf8678955b247d97d3d9543dd2fd95684f3cd41c0cd9d291da9f/mmh3-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5f5536b1cbfa72318ab3bfc8a8188b949260baed186b75f0abc75b95d8c051", size = 40635, upload-time = "2025-07-29T07:41:57.903Z" }, + { url = "https://files.pythonhosted.org/packages/07/14/eaba79eef55b40d653321765ac5e8f6c9ac38780b8a7c2a2f8df8ee0fb72/mmh3-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cedac4f4054b8f7859e5aed41aaa31ad03fce6851901a7fdc2af0275ac533c10", size = 40078, upload-time = "2025-07-29T07:41:58.772Z" }, + { url = "https://files.pythonhosted.org/packages/bb/26/83a0f852e763f81b2265d446b13ed6d49ee49e1fc0c47b9655977e6f3d81/mmh3-5.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eb756caf8975882630ce4e9fbbeb9d3401242a72528230422c9ab3a0d278e60c", size = 97262, upload-time = "2025-07-29T07:41:59.678Z" }, + { url = "https://files.pythonhosted.org/packages/00/7d/b7133b10d12239aeaebf6878d7eaf0bf7d3738c44b4aba3c564588f6d802/mmh3-5.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:097e13c8b8a66c5753c6968b7640faefe85d8e38992703c1f666eda6ef4c3762", size = 103118, upload-time = "2025-07-29T07:42:01.197Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3e/62f0b5dce2e22fd5b7d092aba285abd7959ea2b17148641e029f2eab1ffa/mmh3-5.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7c0c7845566b9686480e6a7e9044db4afb60038d5fabd19227443f0104eeee4", size = 106072, upload-time = "2025-07-29T07:42:02.601Z" }, + { url = "https://files.pythonhosted.org/packages/66/84/ea88bb816edfe65052c757a1c3408d65c4201ddbd769d4a287b0f1a628b2/mmh3-5.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61ac226af521a572700f863d6ecddc6ece97220ce7174e311948ff8c8919a363", size = 112925, upload-time = "2025-07-29T07:42:03.632Z" }, + { url = "https://files.pythonhosted.org/packages/2e/13/c9b1c022807db575fe4db806f442d5b5784547e2e82cff36133e58ea31c7/mmh3-5.2.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:582f9dbeefe15c32a5fa528b79b088b599a1dfe290a4436351c6090f90ddebb8", size = 120583, upload-time = "2025-07-29T07:42:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/0e2dfe1a38f6a78788b7eb2b23432cee24623aeabbc907fed07fc17d6935/mmh3-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ebfc46b39168ab1cd44670a32ea5489bcbc74a25795c61b6d888c5c2cf654ed", size = 99127, upload-time = "2025-07-29T07:42:05.929Z" }, + { url = "https://files.pythonhosted.org/packages/77/27/aefb7d663b67e6a0c4d61a513c83e39ba2237e8e4557fa7122a742a23de5/mmh3-5.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1556e31e4bd0ac0c17eaf220be17a09c171d7396919c3794274cb3415a9d3646", size = 98544, upload-time = "2025-07-29T07:42:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/a21cc9b1a7c6e92205a1b5fa030cdf62277d177570c06a239eca7bd6dd32/mmh3-5.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81df0dae22cd0da87f1c978602750f33d17fb3d21fb0f326c89dc89834fea79b", size = 106262, upload-time = "2025-07-29T07:42:07.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/18/db19ae82ea63c8922a880e1498a75342311f8aa0c581c4dd07711473b5f7/mmh3-5.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:eba01ec3bd4a49b9ac5ca2bc6a73ff5f3af53374b8556fcc2966dd2af9eb7779", size = 109824, upload-time = "2025-07-29T07:42:08.735Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f5/41dcf0d1969125fc6f61d8618b107c79130b5af50b18a4651210ea52ab40/mmh3-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9a011469b47b752e7d20de296bb34591cdfcbe76c99c2e863ceaa2aa61113d2", size = 97255, upload-time = "2025-07-29T07:42:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/cce9eaa0efac1f0e735bb178ef9d1d2887b4927fe0ec16609d5acd492dda/mmh3-5.2.0-cp311-cp311-win32.whl", hash = "sha256:bc44fc2b886243d7c0d8daeb37864e16f232e5b56aaec27cc781d848264cfd28", size = 40779, upload-time = "2025-07-29T07:42:10.546Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e9/3fa0290122e6d5a7041b50ae500b8a9f4932478a51e48f209a3879fe0b9b/mmh3-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8ebf241072cf2777a492d0e09252f8cc2b3edd07dfdb9404b9757bffeb4f2cee", size = 41549, upload-time = "2025-07-29T07:42:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/3a/54/c277475b4102588e6f06b2e9095ee758dfe31a149312cdbf62d39a9f5c30/mmh3-5.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5f317a727bba0e633a12e71228bc6a4acb4f471a98b1c003163b917311ea9a9", size = 39336, upload-time = "2025-07-29T07:42:12.209Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6a/d5aa7edb5c08e0bd24286c7d08341a0446f9a2fbbb97d96a8a6dd81935ee/mmh3-5.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:384eda9361a7bf83a85e09447e1feafe081034af9dd428893701b959230d84be", size = 56141, upload-time = "2025-07-29T07:42:13.456Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/131d0fae6447bc4a7299ebdb1a6fb9d08c9f8dcf97d75ea93e8152ddf7ab/mmh3-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c9da0d568569cc87315cb063486d761e38458b8ad513fedd3dc9263e1b81bcd", size = 40681, upload-time = "2025-07-29T07:42:14.306Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6f/9221445a6bcc962b7f5ff3ba18ad55bba624bacdc7aa3fc0a518db7da8ec/mmh3-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86d1be5d63232e6eb93c50881aea55ff06eb86d8e08f9b5417c8c9b10db9db96", size = 40062, upload-time = "2025-07-29T07:42:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d4/6bb2d0fef81401e0bb4c297d1eb568b767de4ce6fc00890bc14d7b51ecc4/mmh3-5.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf7bee43e17e81671c447e9c83499f53d99bf440bc6d9dc26a841e21acfbe094", size = 97333, upload-time = "2025-07-29T07:42:16.436Z" }, + { url = "https://files.pythonhosted.org/packages/44/e0/ccf0daff8134efbb4fbc10a945ab53302e358c4b016ada9bf97a6bdd50c1/mmh3-5.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7aa18cdb58983ee660c9c400b46272e14fa253c675ed963d3812487f8ca42037", size = 103310, upload-time = "2025-07-29T07:42:17.796Z" }, + { url = "https://files.pythonhosted.org/packages/02/63/1965cb08a46533faca0e420e06aff8bbaf9690a6f0ac6ae6e5b2e4544687/mmh3-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9d032488fcec32d22be6542d1a836f00247f40f320844dbb361393b5b22773", size = 106178, upload-time = "2025-07-29T07:42:19.281Z" }, + { url = "https://files.pythonhosted.org/packages/c2/41/c883ad8e2c234013f27f92061200afc11554ea55edd1bcf5e1accd803a85/mmh3-5.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1861fb6b1d0453ed7293200139c0a9011eeb1376632e048e3766945b13313c5", size = 113035, upload-time = "2025-07-29T07:42:20.356Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/1ccade8b1fa625d634a18bab7bf08a87457e09d5ec8cf83ca07cbea9d400/mmh3-5.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99bb6a4d809aa4e528ddfe2c85dd5239b78b9dd14be62cca0329db78505e7b50", size = 120784, upload-time = "2025-07-29T07:42:21.377Z" }, + { url = "https://files.pythonhosted.org/packages/77/1c/919d9171fcbdcdab242e06394464ccf546f7d0f3b31e0d1e3a630398782e/mmh3-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f8d8b627799f4e2fcc7c034fed8f5f24dc7724ff52f69838a3d6d15f1ad4765", size = 99137, upload-time = "2025-07-29T07:42:22.344Z" }, + { url = "https://files.pythonhosted.org/packages/66/8a/1eebef5bd6633d36281d9fc83cf2e9ba1ba0e1a77dff92aacab83001cee4/mmh3-5.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5995088dd7023d2d9f310a0c67de5a2b2e06a570ecfd00f9ff4ab94a67cde43", size = 98664, upload-time = "2025-07-29T07:42:23.269Z" }, + { url = "https://files.pythonhosted.org/packages/13/41/a5d981563e2ee682b21fb65e29cc0f517a6734a02b581359edd67f9d0360/mmh3-5.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1a5f4d2e59d6bba8ef01b013c472741835ad961e7c28f50c82b27c57748744a4", size = 106459, upload-time = "2025-07-29T07:42:24.238Z" }, + { url = "https://files.pythonhosted.org/packages/24/31/342494cd6ab792d81e083680875a2c50fa0c5df475ebf0b67784f13e4647/mmh3-5.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fd6e6c3d90660d085f7e73710eab6f5545d4854b81b0135a3526e797009dbda3", size = 110038, upload-time = "2025-07-29T07:42:25.629Z" }, + { url = "https://files.pythonhosted.org/packages/28/44/efda282170a46bb4f19c3e2b90536513b1d821c414c28469a227ca5a1789/mmh3-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4a2f3d83879e3de2eb8cbf562e71563a8ed15ee9b9c2e77ca5d9f73072ac15c", size = 97545, upload-time = "2025-07-29T07:42:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/534ae319c6e05d714f437e7206f78c17e66daca88164dff70286b0e8ea0c/mmh3-5.2.0-cp312-cp312-win32.whl", hash = "sha256:2421b9d665a0b1ad724ec7332fb5a98d075f50bc51a6ff854f3a1882bd650d49", size = 40805, upload-time = "2025-07-29T07:42:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f6/f6abdcfefcedab3c964868048cfe472764ed358c2bf6819a70dd4ed4ed3a/mmh3-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d80005b7634a3a2220f81fbeb94775ebd12794623bb2e1451701ea732b4aa3", size = 41597, upload-time = "2025-07-29T07:42:28.894Z" }, + { url = "https://files.pythonhosted.org/packages/15/fd/f7420e8cbce45c259c770cac5718badf907b302d3a99ec587ba5ce030237/mmh3-5.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:3d6bfd9662a20c054bc216f861fa330c2dac7c81e7fb8307b5e32ab5b9b4d2e0", size = 39350, upload-time = "2025-07-29T07:42:29.794Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/27f6ab93995ef6ad9f940e96593c5dd24744d61a7389532b0fec03745607/mmh3-5.2.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e79c00eba78f7258e5b354eccd4d7907d60317ced924ea4a5f2e9d83f5453065", size = 40874, upload-time = "2025-07-29T07:42:30.662Z" }, + { url = "https://files.pythonhosted.org/packages/11/9c/03d13bcb6a03438bc8cac3d2e50f80908d159b31a4367c2e1a7a077ded32/mmh3-5.2.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:956127e663d05edbeec54df38885d943dfa27406594c411139690485128525de", size = 42012, upload-time = "2025-07-29T07:42:31.539Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/0865d9765408a7d504f1789944e678f74e0888b96a766d578cb80b040999/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c3dca4cb5b946ee91b3d6bb700d137b1cd85c20827f89fdf9c16258253489044", size = 39197, upload-time = "2025-07-29T07:42:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/3e/12/76c3207bd186f98b908b6706c2317abb73756d23a4e68ea2bc94825b9015/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e651e17bfde5840e9e4174b01e9e080ce49277b70d424308b36a7969d0d1af73", size = 39840, upload-time = "2025-07-29T07:42:33.227Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0d/574b6cce5555c9f2b31ea189ad44986755eb14e8862db28c8b834b8b64dc/mmh3-5.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:9f64bf06f4bf623325fda3a6d02d36cd69199b9ace99b04bb2d7fd9f89688504", size = 40644, upload-time = "2025-07-29T07:42:34.099Z" }, + { url = "https://files.pythonhosted.org/packages/52/82/3731f8640b79c46707f53ed72034a58baad400be908c87b0088f1f89f986/mmh3-5.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ddc63328889bcaee77b743309e5c7d2d52cee0d7d577837c91b6e7cc9e755e0b", size = 56153, upload-time = "2025-07-29T07:42:35.031Z" }, + { url = "https://files.pythonhosted.org/packages/4f/34/e02dca1d4727fd9fdeaff9e2ad6983e1552804ce1d92cc796e5b052159bb/mmh3-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb0fdc451fb6d86d81ab8f23d881b8d6e37fc373a2deae1c02d27002d2ad7a05", size = 40684, upload-time = "2025-07-29T07:42:35.914Z" }, + { url = "https://files.pythonhosted.org/packages/8f/36/3dee40767356e104967e6ed6d102ba47b0b1ce2a89432239b95a94de1b89/mmh3-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b29044e1ffdb84fe164d0a7ea05c7316afea93c00f8ed9449cf357c36fc4f814", size = 40057, upload-time = "2025-07-29T07:42:36.755Z" }, + { url = "https://files.pythonhosted.org/packages/31/58/228c402fccf76eb39a0a01b8fc470fecf21965584e66453b477050ee0e99/mmh3-5.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58981d6ea9646dbbf9e59a30890cbf9f610df0e4a57dbfe09215116fd90b0093", size = 97344, upload-time = "2025-07-29T07:42:37.675Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/fc5ce89006389a6426ef28e326fc065b0fbaaed230373b62d14c889f47ea/mmh3-5.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e5634565367b6d98dc4aa2983703526ef556b3688ba3065edb4b9b90ede1c54", size = 103325, upload-time = "2025-07-29T07:42:38.591Z" }, + { url = "https://files.pythonhosted.org/packages/09/8c/261e85777c6aee1ebd53f2f17e210e7481d5b0846cd0b4a5c45f1e3761b8/mmh3-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0271ac12415afd3171ab9a3c7cbfc71dee2c68760a7dc9d05bf8ed6ddfa3a7a", size = 106240, upload-time = "2025-07-29T07:42:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/70/73/2f76b3ad8a3d431824e9934403df36c0ddacc7831acf82114bce3c4309c8/mmh3-5.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:45b590e31bc552c6f8e2150ff1ad0c28dd151e9f87589e7eaf508fbdd8e8e908", size = 113060, upload-time = "2025-07-29T07:42:40.585Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/7ea61a34e90e50a79a9d87aa1c0b8139a7eaf4125782b34b7d7383472633/mmh3-5.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bdde97310d59604f2a9119322f61b31546748499a21b44f6715e8ced9308a6c5", size = 120781, upload-time = "2025-07-29T07:42:41.618Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5b/ae1a717db98c7894a37aeedbd94b3f99e6472a836488f36b6849d003485b/mmh3-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc9c5f280438cf1c1a8f9abb87dc8ce9630a964120cfb5dd50d1e7ce79690c7a", size = 99174, upload-time = "2025-07-29T07:42:42.587Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/000cce1d799fceebb6d4487ae29175dd8e81b48e314cba7b4da90bcf55d7/mmh3-5.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c903e71fd8debb35ad2a4184c1316b3cb22f64ce517b4e6747f25b0a34e41266", size = 98734, upload-time = "2025-07-29T07:42:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/79/19/0dc364391a792b72fbb22becfdeacc5add85cc043cd16986e82152141883/mmh3-5.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:eed4bba7ff8a0d37106ba931ab03bdd3915fbb025bcf4e1f0aa02bc8114960c5", size = 106493, upload-time = "2025-07-29T07:42:45.07Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b1/bc8c28e4d6e807bbb051fefe78e1156d7f104b89948742ad310612ce240d/mmh3-5.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1fdb36b940e9261aff0b5177c5b74a36936b902f473180f6c15bde26143681a9", size = 110089, upload-time = "2025-07-29T07:42:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a2/d20f3f5c95e9c511806686c70d0a15479cc3941c5f322061697af1c1ff70/mmh3-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7303aab41e97adcf010a09efd8f1403e719e59b7705d5e3cfed3dd7571589290", size = 97571, upload-time = "2025-07-29T07:42:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/7b/23/665296fce4f33488deec39a750ffd245cfc07aafb0e3ef37835f91775d14/mmh3-5.2.0-cp313-cp313-win32.whl", hash = "sha256:03e08c6ebaf666ec1e3d6ea657a2d363bb01effd1a9acfe41f9197decaef0051", size = 40806, upload-time = "2025-07-29T07:42:48.166Z" }, + { url = "https://files.pythonhosted.org/packages/59/b0/92e7103f3b20646e255b699e2d0327ce53a3f250e44367a99dc8be0b7c7a/mmh3-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:7fddccd4113e7b736706e17a239a696332360cbaddf25ae75b57ba1acce65081", size = 41600, upload-time = "2025-07-29T07:42:49.371Z" }, + { url = "https://files.pythonhosted.org/packages/99/22/0b2bd679a84574647de538c5b07ccaa435dbccc37815067fe15b90fe8dad/mmh3-5.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa0c966ee727aad5406d516375593c5f058c766b21236ab8985693934bb5085b", size = 39349, upload-time = "2025-07-29T07:42:50.268Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/a20db059a8a47048aaf550da14a145b56e9c7386fb8280d3ce2962dcebf7/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5015f0bb6eb50008bed2d4b1ce0f2a294698a926111e4bb202c0987b4f89078", size = 39209, upload-time = "2025-07-29T07:42:51.559Z" }, + { url = "https://files.pythonhosted.org/packages/98/dd/e5094799d55c7482d814b979a0fd608027d0af1b274bfb4c3ea3e950bfd5/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0f3ed828d709f5b82d8bfe14f8856120718ec4bd44a5b26102c3030a1e12501", size = 39843, upload-time = "2025-07-29T07:42:52.536Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/7844d7f832c85400e7cc89a1348e4e1fdd38c5a38415bb5726bbb8fcdb6c/mmh3-5.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:f35727c5118aba95f0397e18a1a5b8405425581bfe53e821f0fb444cbdc2bc9b", size = 40648, upload-time = "2025-07-29T07:42:53.392Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bf/71f791f48a21ff3190ba5225807cbe4f7223360e96862c376e6e3fb7efa7/mmh3-5.2.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bc244802ccab5220008cb712ca1508cb6a12f0eb64ad62997156410579a1770", size = 56164, upload-time = "2025-07-29T07:42:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/70/1f/f87e3d34d83032b4f3f0f528c6d95a98290fcacf019da61343a49dccfd51/mmh3-5.2.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ff3d50dc3fe8a98059f99b445dfb62792b5d006c5e0b8f03c6de2813b8376110", size = 40692, upload-time = "2025-07-29T07:42:55.234Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e2/db849eaed07117086f3452feca8c839d30d38b830ac59fe1ce65af8be5ad/mmh3-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:37a358cc881fe796e099c1db6ce07ff757f088827b4e8467ac52b7a7ffdca647", size = 40068, upload-time = "2025-07-29T07:42:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/df/6b/209af927207af77425b044e32f77f49105a0b05d82ff88af6971d8da4e19/mmh3-5.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b9a87025121d1c448f24f27ff53a5fe7b6ef980574b4a4f11acaabe702420d63", size = 97367, upload-time = "2025-07-29T07:42:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/78adf4104c425606a9ce33fb351f790c76a6c2314969c4a517d1ffc92196/mmh3-5.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ba55d6ca32eeef8b2625e1e4bfc3b3db52bc63014bd7e5df8cc11bf2b036b12", size = 103306, upload-time = "2025-07-29T07:42:58.522Z" }, + { url = "https://files.pythonhosted.org/packages/a3/79/c2b89f91b962658b890104745b1b6c9ce38d50a889f000b469b91eeb1b9e/mmh3-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9ff37ba9f15637e424c2ab57a1a590c52897c845b768e4e0a4958084ec87f22", size = 106312, upload-time = "2025-07-29T07:42:59.552Z" }, + { url = "https://files.pythonhosted.org/packages/4b/14/659d4095528b1a209be90934778c5ffe312177d51e365ddcbca2cac2ec7c/mmh3-5.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a094319ec0db52a04af9fdc391b4d39a1bc72bc8424b47c4411afb05413a44b5", size = 113135, upload-time = "2025-07-29T07:43:00.745Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6f/cd7734a779389a8a467b5c89a48ff476d6f2576e78216a37551a97e9e42a/mmh3-5.2.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5584061fd3da584659b13587f26c6cad25a096246a481636d64375d0c1f6c07", size = 120775, upload-time = "2025-07-29T07:43:02.124Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ca/8256e3b96944408940de3f9291d7e38a283b5761fe9614d4808fcf27bd62/mmh3-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecbfc0437ddfdced5e7822d1ce4855c9c64f46819d0fdc4482c53f56c707b935", size = 99178, upload-time = "2025-07-29T07:43:03.182Z" }, + { url = "https://files.pythonhosted.org/packages/8a/32/39e2b3cf06b6e2eb042c984dab8680841ac2a0d3ca6e0bea30db1f27b565/mmh3-5.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7b986d506a8e8ea345791897ba5d8ba0d9d8820cd4fc3e52dbe6de19388de2e7", size = 98738, upload-time = "2025-07-29T07:43:04.207Z" }, + { url = "https://files.pythonhosted.org/packages/61/d3/7bbc8e0e8cf65ebbe1b893ffa0467b7ecd1bd07c3bbf6c9db4308ada22ec/mmh3-5.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:38d899a156549da8ef6a9f1d6f7ef231228d29f8f69bce2ee12f5fba6d6fd7c5", size = 106510, upload-time = "2025-07-29T07:43:05.656Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/b97e53724b52374e2f3859046f0eb2425192da356cb19784d64bc17bb1cf/mmh3-5.2.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d86651fa45799530885ba4dab3d21144486ed15285e8784181a0ab37a4552384", size = 110053, upload-time = "2025-07-29T07:43:07.204Z" }, + { url = "https://files.pythonhosted.org/packages/ac/62/3688c7d975ed195155671df68788c83fed6f7909b6ec4951724c6860cb97/mmh3-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c463d7c1c4cfc9d751efeaadd936bbba07b5b0ed81a012b3a9f5a12f0872bd6e", size = 97546, upload-time = "2025-07-29T07:43:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3b/c6153250f03f71a8b7634cded82939546cdfba02e32f124ff51d52c6f991/mmh3-5.2.0-cp314-cp314-win32.whl", hash = "sha256:bb4fe46bdc6104fbc28db7a6bacb115ee6368ff993366bbd8a2a7f0076e6f0c0", size = 41422, upload-time = "2025-07-29T07:43:09.216Z" }, + { url = "https://files.pythonhosted.org/packages/74/01/a27d98bab083a435c4c07e9d1d720d4c8a578bf4c270bae373760b1022be/mmh3-5.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c7f0b342fd06044bedd0b6e72177ddc0076f54fd89ee239447f8b271d919d9b", size = 42135, upload-time = "2025-07-29T07:43:10.183Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c9/dbba5507e95429b8b380e2ba091eff5c20a70a59560934dff0ad8392b8c8/mmh3-5.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:3193752fc05ea72366c2b63ff24b9a190f422e32d75fdeae71087c08fff26115", size = 39879, upload-time = "2025-07-29T07:43:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/c8c0ef839c17258b9de41b84f663574fabcf8ac2007b7416575e0f65ff6e/mmh3-5.2.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:69fc339d7202bea69ef9bd7c39bfdf9fdabc8e6822a01eba62fb43233c1b3932", size = 57696, upload-time = "2025-07-29T07:43:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/95e2b9ff201e89f9fe37036037ab61a6c941942b25cdb7b6a9df9b931993/mmh3-5.2.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:12da42c0a55c9d86ab566395324213c319c73ecb0c239fad4726324212b9441c", size = 41421, upload-time = "2025-07-29T07:43:13.269Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/9be23ad0b7001a4b22752e7693be232428ecc0a35068a4ff5c2f14ef8b20/mmh3-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7f9034c7cf05ddfaac8d7a2e63a3c97a840d4615d0a0e65ba8bdf6f8576e3be", size = 40853, upload-time = "2025-07-29T07:43:14.888Z" }, + { url = "https://files.pythonhosted.org/packages/ac/1b/96b32058eda1c1dee8264900c37c359a7325c1f11f5ff14fd2be8e24eff9/mmh3-5.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11730eeb16dfcf9674fdea9bb6b8e6dd9b40813b7eb839bc35113649eef38aeb", size = 109694, upload-time = "2025-07-29T07:43:15.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6f/a2ae44cd7dad697b6dea48390cbc977b1e5ca58fda09628cbcb2275af064/mmh3-5.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:932a6eec1d2e2c3c9e630d10f7128d80e70e2d47fe6b8c7ea5e1afbd98733e65", size = 117438, upload-time = "2025-07-29T07:43:16.865Z" }, + { url = "https://files.pythonhosted.org/packages/a0/08/bfb75451c83f05224a28afeaf3950c7b793c0b71440d571f8e819cfb149a/mmh3-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca975c51c5028947bbcfc24966517aac06a01d6c921e30f7c5383c195f87991", size = 120409, upload-time = "2025-07-29T07:43:18.207Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/8b118b69b2ff8df568f742387d1a159bc654a0f78741b31437dd047ea28e/mmh3-5.2.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b0b58215befe0f0e120b828f7645e97719bbba9f23b69e268ed0ac7adde8645", size = 125909, upload-time = "2025-07-29T07:43:19.39Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/168cc0b6a30650032e351a3b89b8a47382da541993a03af91e1ba2501234/mmh3-5.2.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29c2b9ce61886809d0492a274a5a53047742dea0f703f9c4d5d223c3ea6377d3", size = 135331, upload-time = "2025-07-29T07:43:20.435Z" }, + { url = "https://files.pythonhosted.org/packages/31/05/e3a9849b1c18a7934c64e831492c99e67daebe84a8c2f2c39a7096a830e3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a367d4741ac0103f8198c82f429bccb9359f543ca542b06a51f4f0332e8de279", size = 110085, upload-time = "2025-07-29T07:43:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d5/a96bcc306e3404601418b2a9a370baec92af84204528ba659fdfe34c242f/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5a5dba98e514fb26241868f6eb90a7f7ca0e039aed779342965ce24ea32ba513", size = 111195, upload-time = "2025-07-29T07:43:23.066Z" }, + { url = "https://files.pythonhosted.org/packages/af/29/0fd49801fec5bff37198684e0849b58e0dab3a2a68382a357cfffb0fafc3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:941603bfd75a46023807511c1ac2f1b0f39cccc393c15039969806063b27e6db", size = 116919, upload-time = "2025-07-29T07:43:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/2d/04/4f3c32b0a2ed762edca45d8b46568fc3668e34f00fb1e0a3b5451ec1281c/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:132dd943451a7c7546978863d2f5a64977928410782e1a87d583cb60eb89e667", size = 123160, upload-time = "2025-07-29T07:43:25.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/76/3d29eaa38821730633d6a240d36fa8ad2807e9dfd432c12e1a472ed211eb/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f698733a8a494466432d611a8f0d1e026f5286dee051beea4b3c3146817e35d5", size = 110206, upload-time = "2025-07-29T07:43:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/44/1c/ccf35892684d3a408202e296e56843743e0b4fb1629e59432ea88cdb3909/mmh3-5.2.0-cp314-cp314t-win32.whl", hash = "sha256:6d541038b3fc360ec538fc116de87462627944765a6750308118f8b509a8eec7", size = 41970, upload-time = "2025-07-29T07:43:27.666Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/b9e4f1e5adb5e21eb104588fcee2cd1eaa8308255173481427d5ecc4284e/mmh3-5.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e912b19cf2378f2967d0c08e86ff4c6c360129887f678e27e4dde970d21b3f4d", size = 43063, upload-time = "2025-07-29T07:43:28.582Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0e61d9a4e29c8679356795a40e48f647b4aad58d71bfc969f0f8f56fb912/mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9", size = 40455, upload-time = "2025-07-29T07:43:29.563Z" }, +] + [[package]] name = "more-itertools" version = "10.8.0" @@ -1801,6 +1984,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/73/edeacba3167b1ca66d51b1a5a14697c2c40098b5ffa01811c67b1785a5ab/numpy-2.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a39fb973a726e63223287adc6dafe444ce75af952d711e400f3bf2b36ef55a7b", size = 12489376, upload-time = "2025-12-20T16:18:16.524Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-resourcedetector-gcp" +version = "1.11.0a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/5d/2b3240d914b87b6dd9cd5ca2ef1ccaf1d0626b897d4c06877e22c8c10fcf/opentelemetry_resourcedetector_gcp-1.11.0a0.tar.gz", hash = "sha256:915a1d6fd15daca9eedd3fc52b0f705375054f2ef140e2e7a6b4cca95a47cdb1", size = 18796, upload-time = "2025-11-04T19:32:16.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6c/1e13fe142a7ca3dc6489167203a1209d32430cca12775e1df9c9a41c54b2/opentelemetry_resourcedetector_gcp-1.11.0a0-py3-none-any.whl", hash = "sha256:5d65a2a039b1d40c6f41421dbb08d5f441368275ac6de6e76a8fccd1f6acb67e", size = 18798, upload-time = "2025-11-04T19:32:10.915Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + [[package]] name = "oracledb" version = "3.4.1" @@ -2870,6 +3108,7 @@ all = [ { name = "duckdb" }, { name = "firebirdsql" }, { name = "google-cloud-bigquery" }, + { name = "google-cloud-spanner" }, { name = "hdbcli" }, { name = "ibm-db" }, { name = "libsql" }, @@ -2942,6 +3181,9 @@ redshift = [ snowflake = [ { name = "snowflake-connector-python" }, ] +spanner = [ + { name = "google-cloud-spanner" }, +] ssh = [ { name = "paramiko" }, { name = "sshtunnel" }, @@ -2991,6 +3233,8 @@ requires-dist = [ { name = "firebirdsql", marker = "extra == 'firebird'", specifier = ">=1.3.5" }, { name = "google-cloud-bigquery", marker = "extra == 'all'" }, { name = "google-cloud-bigquery", marker = "extra == 'bigquery'" }, + { name = "google-cloud-spanner", marker = "extra == 'all'", specifier = ">=3.0.0" }, + { name = "google-cloud-spanner", marker = "extra == 'spanner'", specifier = ">=3.0.0" }, { name = "hdbcli", marker = "extra == 'all'", specifier = ">=2.20.0" }, { name = "hdbcli", marker = "extra == 'hana'", specifier = ">=2.20.0" }, { name = "ibm-db", marker = "extra == 'all'", specifier = ">=3.2.0" }, @@ -3032,7 +3276,7 @@ requires-dist = [ { name = "trino", marker = "extra == 'all'", specifier = ">=0.329.0" }, { name = "trino", marker = "extra == 'trino'", specifier = ">=0.329.0" }, ] -provides-extras = ["all", "athena", "bigquery", "clickhouse", "cockroachdb", "d1", "db2", "duckdb", "firebird", "flight", "hana", "mariadb", "mssql", "mysql", "oracle", "postgres", "presto", "redshift", "snowflake", "ssh", "teradata", "trino", "turso"] +provides-extras = ["all", "athena", "bigquery", "clickhouse", "cockroachdb", "d1", "db2", "duckdb", "firebird", "flight", "hana", "mariadb", "mssql", "mysql", "oracle", "postgres", "presto", "redshift", "snowflake", "spanner", "ssh", "teradata", "trino", "turso"] [package.metadata.requires-dev] dev = [