From 97621f192fcd81e3e2d5536c788015dc55eae65b Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 5 Oct 2026 08:12:20 -0400 Subject: [PATCH 1/7] fix(sqlalchemy-spanner): resolve compliance and migration failures with Alembic 1.20 and SQLAlchemy 2.1 --- .../sqlalchemy_spanner/sqlalchemy_spanner.py | 133 ++++++++++-------- packages/sqlalchemy-spanner/noxfile.py | 22 +-- .../sqlalchemy-spanner/tests/test_suite_20.py | 10 +- .../tests/unit/test_alembic.py | 36 ++++- 4 files changed, 124 insertions(+), 77 deletions(-) diff --git a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index ee8e72eb5665..620ce0be9b53 100644 --- a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -15,14 +15,6 @@ import re import sqlalchemy -from alembic.ddl.base import ( - ColumnNullable, - ColumnType, - alter_column, - alter_table, - format_server_default, - format_type, -) from google.api_core.client_options import ClientOptions from google.auth.credentials import AnonymousCredentials from google.cloud.spanner_v1 import Client, TransactionOptions @@ -51,6 +43,26 @@ from google.cloud.sqlalchemy_spanner import version as sqlalchemy_spanner_version from google.cloud.sqlalchemy_spanner._opentelemetry_tracing import trace_call +# Guard the Alembic import so customers who only use SQLAlchemy Core/ORM (without +# running Alembic migrations) can still import the Spanner dialect even if Alembic +# is not installed or if an environment has a version mismatch (e.g., Alembic 1.20+ +# requires SQLAlchemy>=2.0, while older environments may pin SQLAlchemy 1.4.x). +try: + from alembic.ddl.base import ( + ColumnNullable, + ColumnType, + alter_column, + alter_table, + format_server_default, + format_type, + ) + + HAS_ALEMBIC_INSTALLED = True +# Disable coverage checks for the fallback branch when running suites with +# Alembic installed. +except ImportError: # pragma: NO COVER + HAS_ALEMBIC_INSTALLED = False + USING_SQLACLCHEMY_20 = False if sqlalchemy.__version__.split(".")[0] == "2": USING_SQLACLCHEMY_20 = True @@ -1882,55 +1894,60 @@ def do_execute_no_params(self, cursor, statement, context=None): cursor.execute(statement) -# Alembic ALTER operation override -@compiles(ColumnNullable, "spanner+spanner") -def visit_column_nullable( - element: "ColumnNullable", compiler: "SpannerDDLCompiler", **kw -) -> str: - return _format_alter_column( - compiler, - element.table_name, - element.schema, - element.column_name, - element.existing_type, - element.nullable, - element.existing_server_default, - ) - - -# Alembic ALTER operation override -@compiles(ColumnType, "spanner+spanner") -def visit_column_type( - element: "ColumnType", compiler: "SpannerDDLCompiler", **kw -) -> str: - return _format_alter_column( - compiler, - element.table_name, - element.schema, - element.column_name, - element.type_, - element.existing_nullable, - element.existing_server_default, - ) +# Cloud Spanner requires ALTER TABLE ... ALTER COLUMN statements to specify the +# complete column definition (type, nullability, and default expression), whereas +# Alembic's default DDL compiler emits partial clauses (e.g., only SET NOT NULL +# or TYPE). Because the @compiles decorators reference Alembic's ColumnNullable +# and ColumnType classes at module import time, we only register these overrides +# when Alembic is available in the environment. +if HAS_ALEMBIC_INSTALLED: + # Alembic ALTER operation override + @compiles(ColumnNullable, "spanner+spanner") + def visit_column_nullable( + element: "ColumnNullable", compiler: "SpannerDDLCompiler", **kw + ) -> str: + return _format_alter_column( + compiler, + element.table_name, + element.schema, + element.column_name, + element.existing_type, + element.nullable, + element.existing_server_default, + ) + # Alembic ALTER operation override + @compiles(ColumnType, "spanner+spanner") + def visit_column_type( + element: "ColumnType", compiler: "SpannerDDLCompiler", **kw + ) -> str: + return _format_alter_column( + compiler, + element.table_name, + element.schema, + element.column_name, + element.type_, + element.existing_nullable, + element.existing_server_default, + ) -def _format_alter_column( - compiler, table_name, schema, column_name, type_, nullable, server_default -): - # Older versions of SQLAlchemy pass in a boolean to indicate whether there - # is an existing DEFAULT constraint, instead of the actual DEFAULT constraint - # expression. In those cases, we do not want to explicitly include the DEFAULT - # constraint in the expression that is generated here. - if isinstance(server_default, bool): - server_default = None - return "%s %s %s%s%s" % ( - alter_table(compiler, table_name, schema), - alter_column(compiler, column_name), - format_type(compiler, type_), - "" if nullable else " NOT NULL", - ( - "" - if server_default is None - else f" DEFAULT {format_server_default(compiler, server_default)}" - ), - ) + def _format_alter_column( + compiler, table_name, schema, column_name, type_, nullable, server_default + ): + # Older versions of SQLAlchemy pass in a boolean to indicate whether there + # is an existing DEFAULT constraint, instead of the actual DEFAULT constraint + # expression. In those cases, we do not want to explicitly include the DEFAULT + # constraint in the expression that is generated here. + if isinstance(server_default, bool): + server_default = None + return "%s %s %s%s%s" % ( + alter_table(compiler, table_name, schema), + alter_column(compiler, column_name), + format_type(compiler, type_), + "" if nullable else " NOT NULL", + ( + "" + if server_default is None + else f" DEFAULT {format_server_default(compiler, server_default)}" + ), + ) diff --git a/packages/sqlalchemy-spanner/noxfile.py b/packages/sqlalchemy-spanner/noxfile.py index 56884d95296d..4d43860ee419 100644 --- a/packages/sqlalchemy-spanner/noxfile.py +++ b/packages/sqlalchemy-spanner/noxfile.py @@ -120,6 +120,7 @@ class = StreamHandler SQLALCHEMY_14_DEPENDENCIES = [ "sqlalchemy>=1.4,<2.0", + "alembic<1.20.0", ] SQLALCHEMY_20_DEPENDENCIES = [ @@ -206,13 +207,7 @@ def compliance_test_14(session): try: session.install(*SYSTEM_TEST_STANDARD_DEPENDENCIES) - session.install(".[tracing]") - session.run( - "pip", - "install", - *SQLALCHEMY_14_DEPENDENCIES, - "--force-reinstall", - ) + session.install(".[tracing]", *SQLALCHEMY_14_DEPENDENCIES) session.run( "python", "create_test_database.py", @@ -338,17 +333,11 @@ def mockserver(session): @nox.session(python=SYSTEM_COMPLIANCE_MIGRATION_TEST_PYTHON_VERSIONS[0]) def migration_test(session): """Test migrations with SQLAlchemy v1.4 and Alembic""" - session.run( - "pip", - "install", - *SQLALCHEMY_14_DEPENDENCIES, - "--force-reinstall", - ) - _migration_test(session) + _migration_test(session, extra_dependencies=SQLALCHEMY_14_DEPENDENCIES) @nox.session(python=SYSTEM_COMPLIANCE_MIGRATION_TEST_PYTHON_VERSIONS[-1]) -def _migration_test(session): +def _migration_test(session, extra_dependencies=()): """Migrate with SQLAlchemy and Alembic and check the result.""" import glob import os @@ -356,8 +345,7 @@ def _migration_test(session): config_file = f"test_migration_{session.python}_{uuid.uuid4().hex[:6]}.cfg" - session.install(*MIGRATION_TEST_DEPENDENCIES) - session.install(".") + session.install(*MIGRATION_TEST_DEPENDENCIES, *extra_dependencies, ".") try: session.run( diff --git a/packages/sqlalchemy-spanner/tests/test_suite_20.py b/packages/sqlalchemy-spanner/tests/test_suite_20.py index 6c975004a8c4..74d92069b692 100644 --- a/packages/sqlalchemy-spanner/tests/test_suite_20.py +++ b/packages/sqlalchemy-spanner/tests/test_suite_20.py @@ -78,7 +78,6 @@ LongNameBlowoutTest as _LongNameBlowoutTest, ) from sqlalchemy.testing.suite.test_ddl import TableDDLTest as _TableDDLTest -from sqlalchemy.testing.suite.test_deprecations import * # noqa: F401, F403 from sqlalchemy.testing.suite.test_dialect import * # noqa: F401, F403 from sqlalchemy.testing.suite.test_dialect import ( DifficultParametersTest as _DifficultParametersTest, @@ -225,6 +224,15 @@ get_project, ) +try: + # SQLAlchemy 2.1+ removed test_deprecations from sqlalchemy.testing.suite. + # Guard this import so the test suite remains compatible with both 2.0.x and 2.1+. + # Tell flake8 to ignore F401 (unused import) and F403 (wildcard import) since + # pytest discovers the imported SQLAlchemy compliance test classes at module scope. + from sqlalchemy.testing.suite.test_deprecations import * # noqa: F401, F403 +except ModuleNotFoundError: + pass + config.test_schema = "" diff --git a/packages/sqlalchemy-spanner/tests/unit/test_alembic.py b/packages/sqlalchemy-spanner/tests/unit/test_alembic.py index 298326497daf..7aa40fce09f5 100644 --- a/packages/sqlalchemy-spanner/tests/unit/test_alembic.py +++ b/packages/sqlalchemy-spanner/tests/unit/test_alembic.py @@ -12,8 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +import importlib.util +import sys +from unittest import mock + from alembic.ddl import base as ddl_base -from sqlalchemy import String, TextClause +from sqlalchemy import String, TextClause, event from sqlalchemy.testing import eq_ from sqlalchemy.testing.plugin.plugin_base import fixtures @@ -96,3 +100,33 @@ def test_visit_column_type_with_default(self): "ALTER COLUMN col " "STRING(256) NOT NULL DEFAULT (GENERATE_UUID())", ) + + def test_dialect_import_without_alembic(self): + """Verify that sqlalchemy_spanner imports cleanly when Alembic is unavailable. + + Why we test this way instead of calling importlib.reload(sqlalchemy_spanner): + 1. Setting sys.modules["alembic.ddl.base"] = None via mock.patch.dict causes + Python to raise ModuleNotFoundError (a subclass of ImportError) when + sqlalchemy_spanner attempts to import from alembic.ddl.base. + 2. Calling importlib.reload(sqlalchemy_spanner) would re-execute the module + in-place inside the existing sqlalchemy_spanner.__dict__. That has two + undesirable side effects: + - Functions defined during the initial import (like visit_column_nullable) + remain in sqlalchemy_spanner.__dict__ even if skipped on reload. + - Classes (like SpannerIdentifierPreparer) are recreated with new class + identities in sqlalchemy_spanner.__dict__, which breaks other test + modules (such as test_dialect.py) that already imported SpannerDialect + before the reload and rely on super(SpannerIdentifierPreparer, self). + 3. Creating a fresh module object via importlib.util.module_from_spec and + executing the loader into that isolated namespace tests a clean import + without mutating the shared sqlalchemy_spanner module in sys.modules. + """ + with mock.patch.dict(sys.modules, {"alembic.ddl.base": None}): + module = importlib.util.module_from_spec(sqlalchemy_spanner.__spec__) + sqlalchemy_spanner.__spec__.loader.exec_module(module) + # Executing the module registers module.reset_connection on the shared + # SQLAlchemy Pool class via @listens_for(Pool, "reset"); remove that + # temporary listener so global Pool state stays clean for other tests. + event.remove(sqlalchemy_spanner.Pool, "reset", module.reset_connection) + assert not module.HAS_ALEMBIC_INSTALLED + assert not hasattr(module, "visit_column_nullable") From 149b4a9a758e635968dbca4059db38774461c790 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 5 Oct 2026 09:18:53 -0400 Subject: [PATCH 2/7] refactor(sqlalchemy-spanner): parametrize migration_test and extract _run_migration_test helper --- packages/sqlalchemy-spanner/noxfile.py | 34 +++++++++++++++++++------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/sqlalchemy-spanner/noxfile.py b/packages/sqlalchemy-spanner/noxfile.py index 4d43860ee419..c05f20d74300 100644 --- a/packages/sqlalchemy-spanner/noxfile.py +++ b/packages/sqlalchemy-spanner/noxfile.py @@ -140,7 +140,6 @@ class = StreamHandler "compliance_test_14", "compliance_test_20", "migration_test", - "_migration_test", "mockserver", ] @@ -330,14 +329,27 @@ def mockserver(session): ) -@nox.session(python=SYSTEM_COMPLIANCE_MIGRATION_TEST_PYTHON_VERSIONS[0]) -def migration_test(session): - """Test migrations with SQLAlchemy v1.4 and Alembic""" - _migration_test(session, extra_dependencies=SQLALCHEMY_14_DEPENDENCIES) +@nox.session +@nox.parametrize( + ("python", "extra_dependencies"), + [ + ( + SYSTEM_COMPLIANCE_MIGRATION_TEST_PYTHON_VERSIONS[0], + SQLALCHEMY_14_DEPENDENCIES, + ), + ( + DEFAULT_PYTHON_VERSION_FOR_SQLALCHEMY_20, + SQLALCHEMY_20_DEPENDENCIES, + ), + ], + ids=["14", "20"], +) +def migration_test(session, extra_dependencies): + """Test migrations with SQLAlchemy and Alembic.""" + _run_migration_test(session, extra_dependencies=extra_dependencies) -@nox.session(python=SYSTEM_COMPLIANCE_MIGRATION_TEST_PYTHON_VERSIONS[-1]) -def _migration_test(session, extra_dependencies=()): +def _run_migration_test(session, extra_dependencies=()): """Migrate with SQLAlchemy and Alembic and check the result.""" import glob import os @@ -470,9 +482,13 @@ def system(session, test_type): elif test_type == "compliance_20": return compliance_test_20(session) elif test_type == "migration_14": - return migration_test(session) + return _run_migration_test( + session, extra_dependencies=SQLALCHEMY_14_DEPENDENCIES + ) elif test_type == "migration_20": - return _migration_test(session) + return _run_migration_test( + session, extra_dependencies=SQLALCHEMY_20_DEPENDENCIES + ) config_file = f"test_{test_type}_{session.python}_{uuid.uuid4().hex[:6]}.cfg" From 38e9d11927e6e37782aa7eb11fa1511adee19942 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Mon, 5 Oct 2026 15:20:01 -0400 Subject: [PATCH 3/7] fix(sqlalchemy-spanner): resolve SQLAlchemy 2.1 Float MRO and schema test failures in compliance suite --- packages/sqlalchemy-spanner/tests/test_suite_20.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/sqlalchemy-spanner/tests/test_suite_20.py b/packages/sqlalchemy-spanner/tests/test_suite_20.py index 74d92069b692..2f26fe94c842 100644 --- a/packages/sqlalchemy-spanner/tests/test_suite_20.py +++ b/packages/sqlalchemy-spanner/tests/test_suite_20.py @@ -729,6 +729,7 @@ def test_get_columns(self, connection, use_views, use_schema): [ types.Integer, types.Numeric, + types.Float, types.DateTime, types.Date, types.Time, @@ -2924,6 +2925,10 @@ def test_has_table_nonexistent_schema(self): def test_has_table_schema(self): pass + @pytest.mark.skip("Not supported by Cloud Spanner") + def test_has_multi_table_schema(self): + pass + @pytest.mark.skip("Not supported by Cloud Spanner") def test_has_table_cache(self): pass From 34f5c028ec1f31e81d060807344b2fc34f00a7cf Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 6 Oct 2026 04:56:41 -0400 Subject: [PATCH 4/7] fix(sqlalchemy-spanner): skip test_index_cross_casts on Spanner in SQLAlchemy 2.0 compliance suite --- packages/sqlalchemy-spanner/tests/test_suite_20.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/sqlalchemy-spanner/tests/test_suite_20.py b/packages/sqlalchemy-spanner/tests/test_suite_20.py index 2f26fe94c842..a07169869564 100644 --- a/packages/sqlalchemy-spanner/tests/test_suite_20.py +++ b/packages/sqlalchemy-spanner/tests/test_suite_20.py @@ -3092,6 +3092,13 @@ def test_index_typed_comparison(self): def test_path_typed_comparison(self): pass + @pytest.mark.skip( + "Spanner JSON_VALUE() always returns STRING," + "thus, this test case can't be executed." + ) + def test_index_cross_casts(self): + pass + @pytest.mark.skip("Custom JSON de-/serializers are not supported.") def test_round_trip_custom_json(self): pass From 81a2a58cb170d77c267a7dc055f111c23e44bbbc Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 6 Oct 2026 07:13:38 -0400 Subject: [PATCH 5/7] style(sqlalchemy-spanner): add missing space in skip reason string concatenation --- packages/sqlalchemy-spanner/tests/test_suite_20.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/sqlalchemy-spanner/tests/test_suite_20.py b/packages/sqlalchemy-spanner/tests/test_suite_20.py index a07169869564..2142fef94358 100644 --- a/packages/sqlalchemy-spanner/tests/test_suite_20.py +++ b/packages/sqlalchemy-spanner/tests/test_suite_20.py @@ -3079,21 +3079,21 @@ def test_eval_none_flag_orm(self): pass @pytest.mark.skip( - "Spanner JSON_VALUE() always returns STRING," + "Spanner JSON_VALUE() always returns STRING, " "thus, this test case can't be executed." ) def test_index_typed_comparison(self): pass @pytest.mark.skip( - "Spanner JSON_VALUE() always returns STRING," + "Spanner JSON_VALUE() always returns STRING, " "thus, this test case can't be executed." ) def test_path_typed_comparison(self): pass @pytest.mark.skip( - "Spanner JSON_VALUE() always returns STRING," + "Spanner JSON_VALUE() always returns STRING, " "thus, this test case can't be executed." ) def test_index_cross_casts(self): From dcc07be1533af361303e207b2cd4296f752c4063 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 6 Oct 2026 07:37:13 -0400 Subject: [PATCH 6/7] refactor(sqlalchemy-spanner): remove redundant pragma NO COVER on Alembic fallback branch --- .../google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index 620ce0be9b53..930aff82c4cf 100644 --- a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -58,9 +58,7 @@ ) HAS_ALEMBIC_INSTALLED = True -# Disable coverage checks for the fallback branch when running suites with -# Alembic installed. -except ImportError: # pragma: NO COVER +except ImportError: HAS_ALEMBIC_INSTALLED = False USING_SQLACLCHEMY_20 = False From a1555977c95ef4198ada74f5a11d25f7165155ce Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 6 Oct 2026 15:57:58 -0400 Subject: [PATCH 7/7] fix(sqlalchemy-spanner): add upper bound version guards and document defensive Alembic decoupling --- .../cloud/sqlalchemy_spanner/sqlalchemy_spanner.py | 10 ++++++---- packages/sqlalchemy-spanner/setup.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index 930aff82c4cf..6c6e1fbfec0c 100644 --- a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -43,10 +43,12 @@ from google.cloud.sqlalchemy_spanner import version as sqlalchemy_spanner_version from google.cloud.sqlalchemy_spanner._opentelemetry_tracing import trace_call -# Guard the Alembic import so customers who only use SQLAlchemy Core/ORM (without -# running Alembic migrations) can still import the Spanner dialect even if Alembic -# is not installed or if an environment has a version mismatch (e.g., Alembic 1.20+ -# requires SQLAlchemy>=2.0, while older environments may pin SQLAlchemy 1.4.x). +# Defensively decouple the Alembic import so the Spanner dialect does not +# hard-depend on Alembic at runtime. A database dialect does not inherently +# require a schema migration tool, and consumers using only SQLAlchemy Core +# or ORM (or managing DDL outside of Alembic) can still import and use the +# dialect even if Alembic is omitted or unavailable in the environment +# (see #18584). try: from alembic.ddl.base import ( ColumnNullable, diff --git a/packages/sqlalchemy-spanner/setup.py b/packages/sqlalchemy-spanner/setup.py index 91ae4b47d9dc..2968c746907a 100644 --- a/packages/sqlalchemy-spanner/setup.py +++ b/packages/sqlalchemy-spanner/setup.py @@ -22,9 +22,9 @@ name = "sqlalchemy-spanner" description = "SQLAlchemy dialect integrated into Cloud Spanner database" dependencies = [ - "sqlalchemy>=1.1.13", + "sqlalchemy>=1.1.13,<3.0.0", "google-cloud-spanner>=3.55.0", - "alembic", + "alembic>=1.0.0,<2.0.0", ] extras = { "tracing": [