From 89c2455139e02f0dd50e9b212a5503628d61380d Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 19 Aug 2026 09:07:24 -0400 Subject: [PATCH 01/21] fix(sqlalchemy-spanner): isolate test database names and extend stale cleanup cutoff --- .../create_test_database.py | 35 ++++++++++++------- .../sqlalchemy-spanner/drop_test_database.py | 33 +++++++++++++++-- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/packages/sqlalchemy-spanner/create_test_database.py b/packages/sqlalchemy-spanner/create_test_database.py index 804bd2936ad2..775d9ac86a2a 100644 --- a/packages/sqlalchemy-spanner/create_test_database.py +++ b/packages/sqlalchemy-spanner/create_test_database.py @@ -14,18 +14,19 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import os import re import time +import uuid +from create_test_config import set_test_config from google.api_core import datetime_helpers from google.api_core.exceptions import AlreadyExists, ResourceExhausted from google.cloud.spanner_v1 import Client from google.cloud.spanner_v1.database import Database from google.cloud.spanner_v1.instance import Instance -from create_test_config import set_test_config - USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None PROJECT = os.getenv( @@ -69,26 +70,26 @@ def delete_stale_test_instances(): def delete_stale_test_databases(): - """Delete test databases that are older than 10 minutes. - - In this test suite, active databases typically finish running in ~5 minutes. - To prevent concurrent Kokoro runs from accidentally deleting each other's - active databases we use a 10-minute safety threshold. Without an aggressive - cutoff we quickly bump up against Cloud Spanner's limit of 100 databases per instance. + """Delete test databases that are older than 30 minutes. + + In this test suite, compliance and system test sessions can run for 15-20 minutes. + To prevent concurrent Kokoro runs or long compliance test suites from accidentally + deleting each other's active databases while still cleaning up orphaned databases + to respect Spanner's 100-database limit, we use a 30-minute safety threshold. """ - cutoff = (int(time.time()) - 10 * 60) * 1000 + cutoff = (int(time.time()) - 30 * 60) * 1000 instance = CLIENT.instance("sqlalchemy-dialect-test") if not instance.exists(): return database_pbs = instance.list_databases() for database_pb in database_pbs: database = Database.from_pb(database_pb, instance) - # Parse creation time from database ID first (e.g. "sqlalchemy-test-1779989493809") + # Parse creation time from database ID first (e.g. "sqlalchemy-test-177998949-a1b2") # to be 100% independent of emulator metadata or GCP Client API create_time gaps! create_time = None match = re.match(r"sqlalchemy-test-(\d+)", database.database_id) if match: - create_time = int(match.group(1)) + create_time = int(match.group(1)) * 1000 elif database_pb.create_time is not None: create_time = datetime_helpers.to_milliseconds(database_pb.create_time) @@ -123,8 +124,11 @@ def create_test_instance(): except AlreadyExists: pass # instance was already created - unique_resource_id = "%s%d" % ("-", 1000 * time.time()) - database_id = "sqlalchemy-test" + unique_resource_id + # Generate a session-isolated unique database ID within Spanner 30-char limit + creation_timestamp = time.time() + timestamp_part = str(int(creation_timestamp))[-9:] + rand_part = uuid.uuid4().hex[:4] + database_id = f"sqlalchemy-test-{timestamp_part}-{rand_part}" try: database = instance.database(database_id) @@ -135,6 +139,11 @@ def create_test_instance(): set_test_config(PROJECT, instance_id, database_id) + # Record metadata for duration tracking on teardown + meta_path = os.path.join(os.path.dirname(__file__), ".db_session_info.json") + with open(meta_path, "w") as f: + json.dump({"database_id": database_id, "creation_time": creation_timestamp}, f) + delete_stale_test_databases() create_test_instance() diff --git a/packages/sqlalchemy-spanner/drop_test_database.py b/packages/sqlalchemy-spanner/drop_test_database.py index b7facf1f9ea1..9da95e0cd0b5 100644 --- a/packages/sqlalchemy-spanner/drop_test_database.py +++ b/packages/sqlalchemy-spanner/drop_test_database.py @@ -15,18 +15,18 @@ # limitations under the License. import configparser +import json import os import re import time +from create_test_config import set_test_config from google.api_core import datetime_helpers from google.api_core.exceptions import AlreadyExists, ResourceExhausted from google.cloud.spanner_v1 import Client from google.cloud.spanner_v1.database import Database from google.cloud.spanner_v1.instance import Instance -from create_test_config import set_test_config - USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None PROJECT = os.getenv( @@ -43,6 +43,15 @@ CLIENT = Client(project=PROJECT) +def format_duration(seconds): + mins = int(seconds // 60) + secs = int(seconds % 60) + if mins > 0: + return f"{mins} minutes and {secs} seconds" + else: + return f"{secs} seconds" + + def delete_test_database(): """Delete the currently configured test database.""" config = configparser.ConfigParser() @@ -56,8 +65,26 @@ def delete_test_database(): database_id = re.findall(r"databases(.*?)$", db_url) instance = CLIENT.instance(instance_id="".join(instance_id).replace("/", "")) - database = instance.database("".join(database_id).replace("/", "")) + database_id_str = "".join(database_id).replace("/", "") + database = instance.database(database_id_str) database.drop() + # Calculate and report active duration + meta_path = os.path.join(os.path.dirname(__file__), ".db_session_info.json") + if os.path.exists(meta_path): + try: + with open(meta_path, "r") as f: + meta = json.load(f) + creation_time = meta.get("creation_time", time.time()) + db_name = meta.get("database_id", database_id_str) + elapsed_seconds = time.time() - creation_time + duration_str = format_duration(elapsed_seconds) + print(f"[Spanner DB] Database {db_name} was active for {duration_str} before teardown.") + except Exception: + pass + finally: + if os.path.exists(meta_path): + os.remove(meta_path) + delete_test_database() From ec9200fb6f98e224ba602050faa1c32dcb594f1e Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 19 Aug 2026 09:37:45 -0400 Subject: [PATCH 02/21] fix(sqlalchemy-spanner): retain full 10-digit timestamp in db name and validate metadata dict type --- .../sqlalchemy-spanner/create_test_database.py | 16 +++++++++++----- .../sqlalchemy-spanner/drop_test_database.py | 13 +++++++------ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/sqlalchemy-spanner/create_test_database.py b/packages/sqlalchemy-spanner/create_test_database.py index 775d9ac86a2a..2b93e0799b83 100644 --- a/packages/sqlalchemy-spanner/create_test_database.py +++ b/packages/sqlalchemy-spanner/create_test_database.py @@ -84,12 +84,17 @@ def delete_stale_test_databases(): database_pbs = instance.list_databases() for database_pb in database_pbs: database = Database.from_pb(database_pb, instance) - # Parse creation time from database ID first (e.g. "sqlalchemy-test-177998949-a1b2") - # to be 100% independent of emulator metadata or GCP Client API create_time gaps! + # Parse creation time from database ID first (e.g. "sqlalchemy-test-1787069488-a3f") + # handling both 10-digit (seconds) and 13-digit (milliseconds) timestamps robustly. create_time = None match = re.match(r"sqlalchemy-test-(\d+)", database.database_id) if match: - create_time = int(match.group(1)) * 1000 + ts_str = match.group(1) + ts_val = int(ts_str) + if len(ts_str) == 10: + create_time = ts_val * 1000 + else: + create_time = ts_val elif database_pb.create_time is not None: create_time = datetime_helpers.to_milliseconds(database_pb.create_time) @@ -125,9 +130,10 @@ def create_test_instance(): pass # instance was already created # Generate a session-isolated unique database ID within Spanner 30-char limit + # Format: sqlalchemy-test-{timestamp_in_seconds}-{rand_hex3} (exactly 30 characters total) creation_timestamp = time.time() - timestamp_part = str(int(creation_timestamp))[-9:] - rand_part = uuid.uuid4().hex[:4] + timestamp_part = str(int(creation_timestamp)) + rand_part = uuid.uuid4().hex[:3] database_id = f"sqlalchemy-test-{timestamp_part}-{rand_part}" try: diff --git a/packages/sqlalchemy-spanner/drop_test_database.py b/packages/sqlalchemy-spanner/drop_test_database.py index 9da95e0cd0b5..35bb17f9cd29 100644 --- a/packages/sqlalchemy-spanner/drop_test_database.py +++ b/packages/sqlalchemy-spanner/drop_test_database.py @@ -69,17 +69,18 @@ def delete_test_database(): database = instance.database(database_id_str) database.drop() - # Calculate and report active duration + # Calculate and report active duration with type-validation for compliance meta_path = os.path.join(os.path.dirname(__file__), ".db_session_info.json") if os.path.exists(meta_path): try: with open(meta_path, "r") as f: meta = json.load(f) - creation_time = meta.get("creation_time", time.time()) - db_name = meta.get("database_id", database_id_str) - elapsed_seconds = time.time() - creation_time - duration_str = format_duration(elapsed_seconds) - print(f"[Spanner DB] Database {db_name} was active for {duration_str} before teardown.") + if isinstance(meta, dict): + creation_time = meta.get("creation_time", time.time()) + db_name = meta.get("database_id", database_id_str) + elapsed_seconds = time.time() - creation_time + duration_str = format_duration(elapsed_seconds) + print(f"[Spanner DB] Database {db_name} was active for {duration_str} before teardown.") except Exception: pass finally: From fa9ca91c91d5c5f44a8b98545972b8b7e2afbadc Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 19 Aug 2026 10:04:10 -0400 Subject: [PATCH 03/21] fix(sqlalchemy-spanner): protect active test database from accidental garbage collection --- .../create_test_database.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/sqlalchemy-spanner/create_test_database.py b/packages/sqlalchemy-spanner/create_test_database.py index 2b93e0799b83..7b986ebf4492 100644 --- a/packages/sqlalchemy-spanner/create_test_database.py +++ b/packages/sqlalchemy-spanner/create_test_database.py @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import configparser import json import os import re @@ -70,13 +71,22 @@ def delete_stale_test_instances(): def delete_stale_test_databases(): - """Delete test databases that are older than 30 minutes. - - In this test suite, compliance and system test sessions can run for 15-20 minutes. - To prevent concurrent Kokoro runs or long compliance test suites from accidentally - deleting each other's active databases while still cleaning up orphaned databases - to respect Spanner's 100-database limit, we use a 30-minute safety threshold. + """Delete test databases that are older than 30 minutes, + excluding the currently active test database. """ + # Read currently configured active database from test.cfg if it exists + active_db_id = None + config_path = os.path.join(os.path.dirname(__file__), "test.cfg") + if os.path.exists(config_path): + try: + cfg = configparser.ConfigParser() + cfg.read(config_path) + db_url = cfg.get("db", "default", fallback="") + if "databases/" in db_url: + active_db_id = db_url.split("databases/")[-1].split("?")[0] + except Exception: + pass + cutoff = (int(time.time()) - 30 * 60) * 1000 instance = CLIENT.instance("sqlalchemy-dialect-test") if not instance.exists(): @@ -84,6 +94,11 @@ def delete_stale_test_databases(): database_pbs = instance.list_databases() for database_pb in database_pbs: database = Database.from_pb(database_pb, instance) + + # NEVER drop the currently active database configured in test.cfg + if active_db_id and database.database_id == active_db_id: + continue + # Parse creation time from database ID first (e.g. "sqlalchemy-test-1787069488-a3f") # handling both 10-digit (seconds) and 13-digit (milliseconds) timestamps robustly. create_time = None From 774d4f414d0ef18159dc2356979a548a876df507 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 19 Aug 2026 10:23:55 -0400 Subject: [PATCH 04/21] fix(sqlalchemy-spanner): remove test.cfg lookup in cleanup and use generous 1-hour cutoff --- .../create_test_database.py | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/packages/sqlalchemy-spanner/create_test_database.py b/packages/sqlalchemy-spanner/create_test_database.py index 7b986ebf4492..3d49c2083c4a 100644 --- a/packages/sqlalchemy-spanner/create_test_database.py +++ b/packages/sqlalchemy-spanner/create_test_database.py @@ -14,7 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import configparser import json import os import re @@ -71,23 +70,15 @@ def delete_stale_test_instances(): def delete_stale_test_databases(): - """Delete test databases that are older than 30 minutes, - excluding the currently active test database. + """Delete test databases that are older than 1 hour. + + Since each test run creates a 100% session-isolated database with a unique + timestamp in its name (`sqlalchemy-test-{timestamp}-{rand}`), concurrent runs + never collide. We use a generous 1-hour safety cutoff to clean up orphaned + databases from crashed/abandoned runs while ensuring active test runs + (even long compliance suites) are naturally protected by their recent timestamps. """ - # Read currently configured active database from test.cfg if it exists - active_db_id = None - config_path = os.path.join(os.path.dirname(__file__), "test.cfg") - if os.path.exists(config_path): - try: - cfg = configparser.ConfigParser() - cfg.read(config_path) - db_url = cfg.get("db", "default", fallback="") - if "databases/" in db_url: - active_db_id = db_url.split("databases/")[-1].split("?")[0] - except Exception: - pass - - cutoff = (int(time.time()) - 30 * 60) * 1000 + cutoff = (int(time.time()) - 60 * 60) * 1000 instance = CLIENT.instance("sqlalchemy-dialect-test") if not instance.exists(): return @@ -95,10 +86,6 @@ def delete_stale_test_databases(): for database_pb in database_pbs: database = Database.from_pb(database_pb, instance) - # NEVER drop the currently active database configured in test.cfg - if active_db_id and database.database_id == active_db_id: - continue - # Parse creation time from database ID first (e.g. "sqlalchemy-test-1787069488-a3f") # handling both 10-digit (seconds) and 13-digit (milliseconds) timestamps robustly. create_time = None From 1bad94e404678fd43e517fecb5673923c36def71 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 19 Aug 2026 12:02:14 -0400 Subject: [PATCH 05/21] fix(sqlalchemy-spanner): align database cleanup cutoff with 4-hour instance threshold --- .../sqlalchemy-spanner/create_test_database.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/sqlalchemy-spanner/create_test_database.py b/packages/sqlalchemy-spanner/create_test_database.py index 3d49c2083c4a..67025682ec92 100644 --- a/packages/sqlalchemy-spanner/create_test_database.py +++ b/packages/sqlalchemy-spanner/create_test_database.py @@ -70,15 +70,16 @@ def delete_stale_test_instances(): def delete_stale_test_databases(): - """Delete test databases that are older than 1 hour. - - Since each test run creates a 100% session-isolated database with a unique - timestamp in its name (`sqlalchemy-test-{timestamp}-{rand}`), concurrent runs - never collide. We use a generous 1-hour safety cutoff to clean up orphaned - databases from crashed/abandoned runs while ensuring active test runs - (even long compliance suites) are naturally protected by their recent timestamps. + """Delete test databases that are older than 4 hours. + + Compliance test suites can take up to 2 hours to run fully. To prevent long-running + compliance test sessions from hitting their own cleanup threshold mid-test, we align + the database cleanup cutoff with test instances at 4 hours. Combined with + session-isolated names (`sqlalchemy-test-{timestamp}-{rand}`), active test runs + are completely safe while abandoned databases are still cleaned up well before + reaching Spanner's 100-database limit. """ - cutoff = (int(time.time()) - 60 * 60) * 1000 + cutoff = (int(time.time()) - 4 * 60 * 60) * 1000 instance = CLIENT.instance("sqlalchemy-dialect-test") if not instance.exists(): return From c95ec89e0f0b220cbddb3db9ac5f6d8549e4d2c2 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 19 Aug 2026 12:09:40 -0400 Subject: [PATCH 06/21] fix(sqlalchemy-spanner): eliminate startup stale database cleanup to prevent cross-session drops --- .../create_test_database.py | 48 ------------------- 1 file changed, 48 deletions(-) diff --git a/packages/sqlalchemy-spanner/create_test_database.py b/packages/sqlalchemy-spanner/create_test_database.py index 67025682ec92..8fdff5010cd7 100644 --- a/packages/sqlalchemy-spanner/create_test_database.py +++ b/packages/sqlalchemy-spanner/create_test_database.py @@ -16,15 +16,12 @@ import json import os -import re import time import uuid from create_test_config import set_test_config -from google.api_core import datetime_helpers from google.api_core.exceptions import AlreadyExists, ResourceExhausted from google.cloud.spanner_v1 import Client -from google.cloud.spanner_v1.database import Database from google.cloud.spanner_v1.instance import Instance USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None @@ -69,50 +66,6 @@ def delete_stale_test_instances(): ) -def delete_stale_test_databases(): - """Delete test databases that are older than 4 hours. - - Compliance test suites can take up to 2 hours to run fully. To prevent long-running - compliance test sessions from hitting their own cleanup threshold mid-test, we align - the database cleanup cutoff with test instances at 4 hours. Combined with - session-isolated names (`sqlalchemy-test-{timestamp}-{rand}`), active test runs - are completely safe while abandoned databases are still cleaned up well before - reaching Spanner's 100-database limit. - """ - cutoff = (int(time.time()) - 4 * 60 * 60) * 1000 - instance = CLIENT.instance("sqlalchemy-dialect-test") - if not instance.exists(): - return - database_pbs = instance.list_databases() - for database_pb in database_pbs: - database = Database.from_pb(database_pb, instance) - - # Parse creation time from database ID first (e.g. "sqlalchemy-test-1787069488-a3f") - # handling both 10-digit (seconds) and 13-digit (milliseconds) timestamps robustly. - create_time = None - match = re.match(r"sqlalchemy-test-(\d+)", database.database_id) - if match: - ts_str = match.group(1) - ts_val = int(ts_str) - if len(ts_str) == 10: - create_time = ts_val * 1000 - else: - create_time = ts_val - elif database_pb.create_time is not None: - create_time = datetime_helpers.to_milliseconds(database_pb.create_time) - - if create_time is None or create_time > cutoff: - continue - try: - database.drop() - except ResourceExhausted: - print( - "Unable to drop stale database '{}'. May need manual delete.".format( - database.database_id - ) - ) - - def create_test_instance(): instance_id = "sqlalchemy-dialect-test" instance = CLIENT.instance(instance_id) @@ -154,5 +107,4 @@ def create_test_instance(): json.dump({"database_id": database_id, "creation_time": creation_timestamp}, f) -delete_stale_test_databases() create_test_instance() From 0ff675477d044ac240f965c6680919ccdc9c00b4 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 19 Aug 2026 13:08:11 -0400 Subject: [PATCH 07/21] fix(sqlalchemy-spanner): add sentinel-guarded 4-hour stale database cleanup --- .../create_test_database.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/sqlalchemy-spanner/create_test_database.py b/packages/sqlalchemy-spanner/create_test_database.py index 8fdff5010cd7..9de84456847f 100644 --- a/packages/sqlalchemy-spanner/create_test_database.py +++ b/packages/sqlalchemy-spanner/create_test_database.py @@ -16,12 +16,16 @@ import json import os +import pathlib +import re import time import uuid from create_test_config import set_test_config +from google.api_core import datetime_helpers from google.api_core.exceptions import AlreadyExists, ResourceExhausted from google.cloud.spanner_v1 import Client +from google.cloud.spanner_v1.database import Database from google.cloud.spanner_v1.instance import Instance USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None @@ -66,6 +70,55 @@ def delete_stale_test_instances(): ) +def delete_stale_test_databases(): + """Delete test databases that are older than 4 hours. + + Uses a .stale_cleanup_done sentinel file gate to ensure this global sweep + runs exactly once at the start of a test run across parallel/parametrized sessions, + preventing concurrent sessions from interfering with each other. + """ + marker = ".stale_cleanup_done" + if os.path.exists(marker): + return + + try: + pathlib.Path(marker).touch(exist_ok=False) + except FileExistsError: + return # Another parallel process already performed cleanup + + cutoff = (int(time.time()) - 4 * 60 * 60) * 1000 + instance = CLIENT.instance("sqlalchemy-dialect-test") + if not instance.exists(): + return + database_pbs = instance.list_databases() + for database_pb in database_pbs: + database = Database.from_pb(database_pb, instance) + + # Parse creation time from database ID first (e.g. "sqlalchemy-test-1787069488-a3f") + create_time = None + match = re.match(r"sqlalchemy-test-(\d+)", database.database_id) + if match: + ts_str = match.group(1) + ts_val = int(ts_str) + if len(ts_str) == 10: + create_time = ts_val * 1000 + else: + create_time = ts_val + elif database_pb.create_time is not None: + create_time = datetime_helpers.to_milliseconds(database_pb.create_time) + + if create_time is None or create_time > cutoff: + continue + try: + database.drop() + except ResourceExhausted: + print( + "Unable to drop stale database '{}'. May need manual delete.".format( + database.database_id + ) + ) + + def create_test_instance(): instance_id = "sqlalchemy-dialect-test" instance = CLIENT.instance(instance_id) @@ -107,4 +160,5 @@ def create_test_instance(): json.dump({"database_id": database_id, "creation_time": creation_timestamp}, f) +delete_stale_test_databases() create_test_instance() From 8057ab53121ae50b2156b1b148e34443ea366db1 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 19 Aug 2026 13:54:08 -0400 Subject: [PATCH 08/21] fix(sqlalchemy-spanner): isolate test config per nox session to prevent cross-session database drops --- .../sqlalchemy-spanner/create_test_config.py | 4 +- .../sqlalchemy-spanner/drop_test_database.py | 12 ++- packages/sqlalchemy-spanner/noxfile.py | 98 +++++++++++-------- packages/sqlalchemy-spanner/tests/_helpers.py | 7 +- 4 files changed, 76 insertions(+), 45 deletions(-) diff --git a/packages/sqlalchemy-spanner/create_test_config.py b/packages/sqlalchemy-spanner/create_test_config.py index 3ca1ab9bce88..1917e199a90d 100644 --- a/packages/sqlalchemy-spanner/create_test_config.py +++ b/packages/sqlalchemy-spanner/create_test_config.py @@ -15,6 +15,7 @@ # limitations under the License. import configparser +import os import sys @@ -41,7 +42,8 @@ def set_test_config( config.add_section("db") config["db"]["default"] = url - with open("test.cfg", "w") as configfile: + config_filename = os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg") + with open(config_filename, "w") as configfile: config.write(configfile) diff --git a/packages/sqlalchemy-spanner/drop_test_database.py b/packages/sqlalchemy-spanner/drop_test_database.py index 35bb17f9cd29..399d33abcb47 100644 --- a/packages/sqlalchemy-spanner/drop_test_database.py +++ b/packages/sqlalchemy-spanner/drop_test_database.py @@ -55,7 +55,10 @@ def format_duration(seconds): def delete_test_database(): """Delete the currently configured test database.""" config = configparser.ConfigParser() - if os.path.exists("test.cfg"): + config_filename = os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg") + if os.path.exists(config_filename): + config.read(config_filename) + elif os.path.exists("test.cfg"): config.read("test.cfg") else: config.read("setup.cfg") @@ -87,5 +90,12 @@ def delete_test_database(): if os.path.exists(meta_path): os.remove(meta_path) + # Clean up session-specific config file + if os.path.exists(config_filename) and config_filename != "setup.cfg": + try: + os.remove(config_filename) + except Exception: + pass + delete_test_database() diff --git a/packages/sqlalchemy-spanner/noxfile.py b/packages/sqlalchemy-spanner/noxfile.py index a69e9bb2b89b..38119c687fed 100644 --- a/packages/sqlalchemy-spanner/noxfile.py +++ b/packages/sqlalchemy-spanner/noxfile.py @@ -179,6 +179,8 @@ def lint_setup_py(session): @nox.session(python=UNIT_TEST_PYTHON_VERSIONS[0]) def compliance_test_14(session): """Run SQLAlchemy dialect compliance test suite.""" + config_file = f"test_compliance_test_14_{session.python}.cfg" + os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_file # Check the value of `RUN_COMPLIANCE_TESTS` env var. It defaults to true. if os.environ.get("RUN_COMPLIANCE_TESTS", "true") == "false": @@ -191,34 +193,42 @@ def compliance_test_14(session): "Credentials or emulator host must be set via environment variable" ) - session.install(*SYSTEM_TEST_STANDARD_DEPENDENCIES) - session.install(".[tracing]") - session.run( - "pip", - "install", - *SQLALCHEMY_14_DEPENDENCIES, - "--force-reinstall", - ) - session.run("python", "create_test_database.py") - session.run( - "py.test", - "--cov=google.cloud.sqlalchemy_spanner", - "--cov=tests", - "--cov-append", - "--cov-config=.coveragerc", - "--cov-report=", - "--cov-fail-under=0", - "--asyncio-mode=auto", - "tests/test_suite_14.py", - *session.posargs, - # Silence SQLAlchemy 2.0 transition warnings for this 1.4 compatibility session. - env={"SQLALCHEMY_SILENCE_UBER_WARNING": "1"}, - ) + try: + session.install(*SYSTEM_TEST_STANDARD_DEPENDENCIES) + session.install(".[tracing]") + session.run( + "pip", + "install", + *SQLALCHEMY_14_DEPENDENCIES, + "--force-reinstall", + ) + session.run("python", "create_test_database.py") + session.run( + "py.test", + "--cov=google.cloud.sqlalchemy_spanner", + "--cov=tests", + "--cov-append", + "--cov-config=.coveragerc", + "--cov-report=", + "--cov-fail-under=0", + "--asyncio-mode=auto", + "tests/test_suite_14.py", + *session.posargs, + # Silence SQLAlchemy 2.0 transition warnings for this 1.4 compatibility session. + env={"SQLALCHEMY_SILENCE_UBER_WARNING": "1"}, + ) + finally: + if os.path.exists(config_file): + session.run("python", "drop_test_database.py", success_codes=[0, 1]) + elif os.path.exists("test.cfg"): + session.run("python", "drop_test_database.py", success_codes=[0, 1]) @nox.session(python=DEFAULT_PYTHON_VERSION_FOR_SQLALCHEMY_20) def compliance_test_20(session): """Run SQLAlchemy dialect compliance test suite.""" + config_file = f"test_compliance_test_20_{session.python}.cfg" + os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_file # Check the value of `RUN_COMPLIANCE_TESTS` env var. It defaults to true. if os.environ.get("RUN_COMPLIANCE_TESTS", "true") == "false": @@ -232,24 +242,30 @@ def compliance_test_20(session): "Credentials or emulator host must be set via environment variable" ) - session.install(*SYSTEM_TEST_STANDARD_DEPENDENCIES) - session.install("-e", ".", "--force-reinstall") - session.run("python", "create_test_database.py") - - session.install(*SQLALCHEMY_20_DEPENDENCIES) - - session.run( - "py.test", - "--cov=google.cloud.sqlalchemy_spanner", - "--cov=tests", - "--cov-append", - "--cov-config=.coveragerc", - "--cov-report=", - "--cov-fail-under=0", - "--asyncio-mode=auto", - "tests/test_suite_20.py", - *session.posargs, - ) + try: + session.install(*SYSTEM_TEST_STANDARD_DEPENDENCIES) + session.install("-e", ".", "--force-reinstall") + session.run("python", "create_test_database.py") + + session.install(*SQLALCHEMY_20_DEPENDENCIES) + + session.run( + "py.test", + "--cov=google.cloud.sqlalchemy_spanner", + "--cov=tests", + "--cov-append", + "--cov-config=.coveragerc", + "--cov-report=", + "--cov-fail-under=0", + "--asyncio-mode=auto", + "tests/test_suite_20.py", + *session.posargs, + ) + finally: + if os.path.exists(config_file): + session.run("python", "drop_test_database.py", success_codes=[0, 1]) + elif os.path.exists("test.cfg"): + session.run("python", "drop_test_database.py", success_codes=[0, 1]) @nox.session(python=DEFAULT_PYTHON_VERSION_FOR_SQLALCHEMY_20) diff --git a/packages/sqlalchemy-spanner/tests/_helpers.py b/packages/sqlalchemy-spanner/tests/_helpers.py index 40673b2607cb..8feea107acc0 100644 --- a/packages/sqlalchemy-spanner/tests/_helpers.py +++ b/packages/sqlalchemy-spanner/tests/_helpers.py @@ -44,9 +44,12 @@ def get_db_url(): config = configparser.ConfigParser() - if os.path.exists("test.cfg"): + config_filename = os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg") + if os.path.exists(config_filename): + config.read(config_filename) + elif os.path.exists("test.cfg"): config.read("test.cfg") - else: + elif os.path.exists("setup.cfg"): config.read("setup.cfg") return config.get("db", "default", fallback=DB_URL) From a7cfa9cd81872bd30a73d6cfd6cb262872c37f48 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 19 Aug 2026 14:40:56 -0400 Subject: [PATCH 09/21] fix(sqlalchemy-spanner): explicitly pass --dburi to pytest to prevent fallback to sqlite --- packages/sqlalchemy-spanner/noxfile.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/sqlalchemy-spanner/noxfile.py b/packages/sqlalchemy-spanner/noxfile.py index 38119c687fed..1f5995541186 100644 --- a/packages/sqlalchemy-spanner/noxfile.py +++ b/packages/sqlalchemy-spanner/noxfile.py @@ -203,8 +203,12 @@ def compliance_test_14(session): "--force-reinstall", ) session.run("python", "create_test_database.py") + config = configparser.ConfigParser() + config.read(config_file) + db_url = config.get("db", "default") session.run( "py.test", + f"--dburi={db_url}", "--cov=google.cloud.sqlalchemy_spanner", "--cov=tests", "--cov-append", @@ -246,11 +250,15 @@ def compliance_test_20(session): session.install(*SYSTEM_TEST_STANDARD_DEPENDENCIES) session.install("-e", ".", "--force-reinstall") session.run("python", "create_test_database.py") + config = configparser.ConfigParser() + config.read(config_file) + db_url = config.get("db", "default") session.install(*SQLALCHEMY_20_DEPENDENCIES) session.run( "py.test", + f"--dburi={db_url}", "--cov=google.cloud.sqlalchemy_spanner", "--cov=tests", "--cov-append", @@ -440,9 +448,12 @@ def system(session, test_type): session.install(".[tracing]") session.install(*SYSTEM_TEST_EXTERNAL_DEPENDENCIES) session.run("python", "create_test_database.py") + config = configparser.ConfigParser() + config.read(config_file) + db_url = config.get("db", "default") session.install(*SQLALCHEMY_20_DEPENDENCIES) session.run( - "py.test", "--quiet", os.path.join("tests", "system"), *session.posargs + "py.test", f"--dburi={db_url}", "--quiet", os.path.join("tests", "system"), *session.posargs ) elif test_type == "compliance_14": compliance_test_14(session) @@ -453,7 +464,9 @@ def system(session, test_type): elif test_type == "migration_20": _migration_test(session) finally: - if os.path.exists("test.cfg"): + if os.path.exists(config_file): + session.run("python", "drop_test_database.py", success_codes=[0, 1]) + elif os.path.exists("test.cfg"): session.run("python", "drop_test_database.py", success_codes=[0, 1]) From d7df95e458b9d3317ee985932dfb09c9f82721d2 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 19 Aug 2026 16:12:02 -0400 Subject: [PATCH 10/21] fix(sqlalchemy-spanner): define config_file in system session scope to avoid NameError --- packages/sqlalchemy-spanner/noxfile.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/sqlalchemy-spanner/noxfile.py b/packages/sqlalchemy-spanner/noxfile.py index 1f5995541186..447bbed3f2ea 100644 --- a/packages/sqlalchemy-spanner/noxfile.py +++ b/packages/sqlalchemy-spanner/noxfile.py @@ -413,6 +413,9 @@ def unit(session, test_type): def system(session, test_type): """Run SQLAlchemy dialect system test suite.""" + config_file = f"test_{test_type}_{session.python}.cfg" + os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_file + if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "") and not os.environ.get( "SPANNER_EMULATOR_HOST", "" ): From b2fbe29b044b9d9eba0bc58485e59808e7e60c9c Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 19 Aug 2026 17:06:11 -0400 Subject: [PATCH 11/21] fix(sqlalchemy-spanner): use Spanner-compliant database ID format with underscores instead of hyphens --- packages/sqlalchemy-spanner/create_test_database.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sqlalchemy-spanner/create_test_database.py b/packages/sqlalchemy-spanner/create_test_database.py index 9de84456847f..4fbb2851d808 100644 --- a/packages/sqlalchemy-spanner/create_test_database.py +++ b/packages/sqlalchemy-spanner/create_test_database.py @@ -94,9 +94,9 @@ def delete_stale_test_databases(): for database_pb in database_pbs: database = Database.from_pb(database_pb, instance) - # Parse creation time from database ID first (e.g. "sqlalchemy-test-1787069488-a3f") + # Parse creation time from database ID first (e.g. "sp_test_1787069488_a3f") create_time = None - match = re.match(r"sqlalchemy-test-(\d+)", database.database_id) + match = re.match(r"sp_test_(\d+)", database.database_id) if match: ts_str = match.group(1) ts_val = int(ts_str) @@ -139,11 +139,11 @@ def create_test_instance(): pass # instance was already created # Generate a session-isolated unique database ID within Spanner 30-char limit - # Format: sqlalchemy-test-{timestamp_in_seconds}-{rand_hex3} (exactly 30 characters total) + # Format: sp_test_{timestamp_in_seconds}_{rand_hex3} (compliant with Spanner naming: ^[a-z][a-z0-9_]{1,29}$) creation_timestamp = time.time() timestamp_part = str(int(creation_timestamp)) rand_part = uuid.uuid4().hex[:3] - database_id = f"sqlalchemy-test-{timestamp_part}-{rand_part}" + database_id = f"sp_test_{timestamp_part}_{rand_part}" try: database = instance.database(database_id) From 3e0edae5b0610776d7aeb735888a8c34f1413525 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 20 Aug 2026 04:57:53 -0400 Subject: [PATCH 12/21] fix(sqlalchemy-spanner): make _migration_test respect SQLALCHEMY_SPANNER_CONFIG instead of falling back to sqlite --- packages/sqlalchemy-spanner/noxfile.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/sqlalchemy-spanner/noxfile.py b/packages/sqlalchemy-spanner/noxfile.py index 447bbed3f2ea..22d4ca23959f 100644 --- a/packages/sqlalchemy-spanner/noxfile.py +++ b/packages/sqlalchemy-spanner/noxfile.py @@ -324,13 +324,17 @@ def _migration_test(session): import os import shutil + config_file = os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg") + session.install(*MIGRATION_TEST_DEPENDENCIES) session.install(".") session.run("python", "create_test_database.py") config = configparser.ConfigParser() - if os.path.exists("test.cfg"): + if os.path.exists(config_file): + config.read(config_file) + elif os.path.exists("test.cfg"): config.read("test.cfg") else: config.read("setup.cfg") @@ -366,7 +370,9 @@ def _migration_test(session): os.remove("alembic.ini") shutil.rmtree("test_migration") session.run("python", "migration_test_cleanup.py", db_url) - if os.path.exists("test.cfg"): + if os.path.exists(config_file) and config_file != "setup.cfg": + os.remove(config_file) + elif os.path.exists("test.cfg"): os.remove("test.cfg") From e6399286ddc789756697fd593b693010696a8769 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 20 Aug 2026 05:02:38 -0400 Subject: [PATCH 13/21] style(sqlalchemy-spanner): format noxfile.py with ruff --- packages/sqlalchemy-spanner/noxfile.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/sqlalchemy-spanner/noxfile.py b/packages/sqlalchemy-spanner/noxfile.py index 22d4ca23959f..b04f44c61268 100644 --- a/packages/sqlalchemy-spanner/noxfile.py +++ b/packages/sqlalchemy-spanner/noxfile.py @@ -462,7 +462,11 @@ def system(session, test_type): db_url = config.get("db", "default") session.install(*SQLALCHEMY_20_DEPENDENCIES) session.run( - "py.test", f"--dburi={db_url}", "--quiet", os.path.join("tests", "system"), *session.posargs + "py.test", + f"--dburi={db_url}", + "--quiet", + os.path.join("tests", "system"), + *session.posargs, ) elif test_type == "compliance_14": compliance_test_14(session) From bbeabf52f17e3f6285df1140cc7eca1e2ed35b41 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 20 Aug 2026 06:42:55 -0400 Subject: [PATCH 14/21] fix(tests): improve database isolation and cleanup in nox sessions --- .../sqlalchemy-spanner/drop_test_database.py | 19 +++- packages/sqlalchemy-spanner/noxfile.py | 93 ++++++++++--------- 2 files changed, 64 insertions(+), 48 deletions(-) diff --git a/packages/sqlalchemy-spanner/drop_test_database.py b/packages/sqlalchemy-spanner/drop_test_database.py index 399d33abcb47..aa27569a575c 100644 --- a/packages/sqlalchemy-spanner/drop_test_database.py +++ b/packages/sqlalchemy-spanner/drop_test_database.py @@ -55,14 +55,23 @@ def format_duration(seconds): def delete_test_database(): """Delete the currently configured test database.""" config = configparser.ConfigParser() - config_filename = os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg") - if os.path.exists(config_filename): - config.read(config_filename) + config_env_val = os.getenv("SQLALCHEMY_SPANNER_CONFIG") + if config_env_val: + config_filename = config_env_val + if not os.path.exists(config_filename): + print(f"[Spanner DB] Config file {config_filename} specified in SQLALCHEMY_SPANNER_CONFIG does not exist. Skipping database drop.") + return elif os.path.exists("test.cfg"): - config.read("test.cfg") + config_filename = "test.cfg" else: - config.read("setup.cfg") + config_filename = "setup.cfg" + + config.read(config_filename) + db_url = config.get("db", "default") + if not db_url.startswith("spanner"): + print(f"[Spanner DB] Database URL {db_url} is not a Spanner URL. Skipping database drop.") + return instance_id = re.findall(r"instances(.*?)databases", db_url) database_id = re.findall(r"databases(.*?)$", db_url) diff --git a/packages/sqlalchemy-spanner/noxfile.py b/packages/sqlalchemy-spanner/noxfile.py index b04f44c61268..c385da703cd9 100644 --- a/packages/sqlalchemy-spanner/noxfile.py +++ b/packages/sqlalchemy-spanner/noxfile.py @@ -179,8 +179,9 @@ def lint_setup_py(session): @nox.session(python=UNIT_TEST_PYTHON_VERSIONS[0]) def compliance_test_14(session): """Run SQLAlchemy dialect compliance test suite.""" - config_file = f"test_compliance_test_14_{session.python}.cfg" - os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_file + config_file = os.environ.setdefault( + "SQLALCHEMY_SPANNER_CONFIG", f"test_compliance_14_{session.python}.cfg" + ) # Check the value of `RUN_COMPLIANCE_TESTS` env var. It defaults to true. if os.environ.get("RUN_COMPLIANCE_TESTS", "true") == "false": @@ -231,8 +232,9 @@ def compliance_test_14(session): @nox.session(python=DEFAULT_PYTHON_VERSION_FOR_SQLALCHEMY_20) def compliance_test_20(session): """Run SQLAlchemy dialect compliance test suite.""" - config_file = f"test_compliance_test_20_{session.python}.cfg" - os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_file + config_file = os.environ.setdefault( + "SQLALCHEMY_SPANNER_CONFIG", f"test_compliance_20_{session.python}.cfg" + ) # Check the value of `RUN_COMPLIANCE_TESTS` env var. It defaults to true. if os.environ.get("RUN_COMPLIANCE_TESTS", "true") == "false": @@ -324,56 +326,60 @@ def _migration_test(session): import os import shutil - config_file = os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg") + config_file = os.environ.setdefault( + "SQLALCHEMY_SPANNER_CONFIG", f"test_migration_{session.python}.cfg" + ) session.install(*MIGRATION_TEST_DEPENDENCIES) session.install(".") - session.run("python", "create_test_database.py") + try: + session.run("python", "create_test_database.py") - config = configparser.ConfigParser() - if os.path.exists(config_file): - config.read(config_file) - elif os.path.exists("test.cfg"): - config.read("test.cfg") - else: - config.read("setup.cfg") - db_url = config.get("db", "default") + config = configparser.ConfigParser() + if os.path.exists(config_file): + config.read(config_file) + else: + config.read("setup.cfg") + db_url = config.get("db", "default") - session.run("alembic", "init", "test_migration") + session.run("alembic", "init", "test_migration") - # setting testing configurations - os.remove("alembic.ini") - with open("alembic.ini", "w") as f: - f.write(ALEMBIC_CONF.format(db_url)) + # setting testing configurations + if os.path.exists("alembic.ini"): + os.remove("alembic.ini") + with open("alembic.ini", "w") as f: + f.write(ALEMBIC_CONF.format(db_url)) - session.run("alembic", "revision", "-m", "migration_for_test") - files = glob.glob("test_migration/versions/*.py") + session.run("alembic", "revision", "-m", "migration_for_test") + files = glob.glob("test_migration/versions/*.py") - # updating the upgrade-script code - with open(files[0], "rb") as f: - script_code = f.read().decode() + # updating the upgrade-script code + with open(files[0], "rb") as f: + script_code = f.read().decode() - script_code = script_code.replace( - """def upgrade() -> None:\n pass""", UPGRADE_CODE - ) - with open(files[0], "wb") as f: - f.write(script_code.encode()) + script_code = script_code.replace( + """def upgrade() -> None:\n pass""", UPGRADE_CODE + ) + with open(files[0], "wb") as f: + f.write(script_code.encode()) - os.remove("test_migration/env.py") - shutil.copyfile("test_migration_env.py", "test_migration/env.py") + if os.path.exists("test_migration/env.py"): + os.remove("test_migration/env.py") + shutil.copyfile("test_migration_env.py", "test_migration/env.py") - # running the test migration - session.run("alembic", "upgrade", "head") + # running the test migration + session.run("alembic", "upgrade", "head") - # clearing the migration data - os.remove("alembic.ini") - shutil.rmtree("test_migration") - session.run("python", "migration_test_cleanup.py", db_url) - if os.path.exists(config_file) and config_file != "setup.cfg": - os.remove(config_file) - elif os.path.exists("test.cfg"): - os.remove("test.cfg") + finally: + # clearing the migration data + if os.path.exists("alembic.ini"): + os.remove("alembic.ini") + if os.path.exists("test_migration"): + shutil.rmtree("test_migration") + + if os.path.exists(config_file): + session.run("python", "drop_test_database.py", success_codes=[0, 1]) @nox.session(python=ALL_PYTHON) @@ -419,8 +425,9 @@ def unit(session, test_type): def system(session, test_type): """Run SQLAlchemy dialect system test suite.""" - config_file = f"test_{test_type}_{session.python}.cfg" - os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_file + config_file = os.environ.setdefault( + "SQLALCHEMY_SPANNER_CONFIG", f"test_{test_type}_{session.python}.cfg" + ) if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "") and not os.environ.get( "SPANNER_EMULATOR_HOST", "" From 4324daf90c2188e12b5dcad46c1b6e21daee8d1b Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 20 Aug 2026 07:22:38 -0400 Subject: [PATCH 15/21] fix(tests): eliminate race conditions in parallel database testing --- packages/sqlalchemy-spanner/create_test_database.py | 2 +- packages/sqlalchemy-spanner/noxfile.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/sqlalchemy-spanner/create_test_database.py b/packages/sqlalchemy-spanner/create_test_database.py index 4fbb2851d808..33d838bce334 100644 --- a/packages/sqlalchemy-spanner/create_test_database.py +++ b/packages/sqlalchemy-spanner/create_test_database.py @@ -142,7 +142,7 @@ def create_test_instance(): # Format: sp_test_{timestamp_in_seconds}_{rand_hex3} (compliant with Spanner naming: ^[a-z][a-z0-9_]{1,29}$) creation_timestamp = time.time() timestamp_part = str(int(creation_timestamp)) - rand_part = uuid.uuid4().hex[:3] + rand_part = uuid.uuid4().hex[:8] database_id = f"sp_test_{timestamp_part}_{rand_part}" try: diff --git a/packages/sqlalchemy-spanner/noxfile.py b/packages/sqlalchemy-spanner/noxfile.py index c385da703cd9..4681db029e19 100644 --- a/packages/sqlalchemy-spanner/noxfile.py +++ b/packages/sqlalchemy-spanner/noxfile.py @@ -21,6 +21,7 @@ import pathlib import re import shutil +import uuid import nox @@ -180,7 +181,8 @@ def lint_setup_py(session): def compliance_test_14(session): """Run SQLAlchemy dialect compliance test suite.""" config_file = os.environ.setdefault( - "SQLALCHEMY_SPANNER_CONFIG", f"test_compliance_14_{session.python}.cfg" + "SQLALCHEMY_SPANNER_CONFIG", + f"test_compliance_14_{session.python}_{uuid.uuid4().hex[:6]}.cfg", ) # Check the value of `RUN_COMPLIANCE_TESTS` env var. It defaults to true. @@ -233,7 +235,8 @@ def compliance_test_14(session): def compliance_test_20(session): """Run SQLAlchemy dialect compliance test suite.""" config_file = os.environ.setdefault( - "SQLALCHEMY_SPANNER_CONFIG", f"test_compliance_20_{session.python}.cfg" + "SQLALCHEMY_SPANNER_CONFIG", + f"test_compliance_20_{session.python}_{uuid.uuid4().hex[:6]}.cfg", ) # Check the value of `RUN_COMPLIANCE_TESTS` env var. It defaults to true. @@ -327,7 +330,8 @@ def _migration_test(session): import shutil config_file = os.environ.setdefault( - "SQLALCHEMY_SPANNER_CONFIG", f"test_migration_{session.python}.cfg" + "SQLALCHEMY_SPANNER_CONFIG", + f"test_migration_{session.python}_{uuid.uuid4().hex[:6]}.cfg", ) session.install(*MIGRATION_TEST_DEPENDENCIES) @@ -426,7 +430,8 @@ def system(session, test_type): """Run SQLAlchemy dialect system test suite.""" config_file = os.environ.setdefault( - "SQLALCHEMY_SPANNER_CONFIG", f"test_{test_type}_{session.python}.cfg" + "SQLALCHEMY_SPANNER_CONFIG", + f"test_{test_type}_{session.python}_{uuid.uuid4().hex[:6]}.cfg", ) if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "") and not os.environ.get( From 6221d4cfdacd1aa41cbf51ad06ec6d5b89a31ed8 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 20 Aug 2026 07:35:21 -0400 Subject: [PATCH 16/21] chore(tests): remove obsolete migration_test_cleanup.py --- .../migration_test_cleanup.py | 40 ------------------- 1 file changed, 40 deletions(-) delete mode 100644 packages/sqlalchemy-spanner/migration_test_cleanup.py diff --git a/packages/sqlalchemy-spanner/migration_test_cleanup.py b/packages/sqlalchemy-spanner/migration_test_cleanup.py deleted file mode 100644 index b6efb15910e0..000000000000 --- a/packages/sqlalchemy-spanner/migration_test_cleanup.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2021 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import re -import sys - -from google.cloud import spanner - - -def main(argv): - db_url = argv[0] - - project = re.findall(r"projects(.*?)instances", db_url) - instance_id = re.findall(r"instances(.*?)databases", db_url) - database_id = re.findall(r"databases(.*?)$", db_url) - - client = spanner.Client(project="".join(project).replace("/", "")) - instance = client.instance(instance_id="".join(instance_id).replace("/", "")) - database = instance.database("".join(database_id).replace("/", "")) - - database.update_ddl( - ["DROP TABLE IF EXISTS account", "DROP TABLE IF EXISTS alembic_version"] - ).result(120) - - -if __name__ == "__main__": - main(sys.argv[1:]) From 7bce8acae7945cf427eb45f9cf0b1b5efd8c43fc Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 20 Aug 2026 08:08:17 -0400 Subject: [PATCH 17/21] fix(tests): use Spanner-specific fixture in SQLAlchemy 1.4 NumericTest --- packages/sqlalchemy-spanner/tests/test_suite_14.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/sqlalchemy-spanner/tests/test_suite_14.py b/packages/sqlalchemy-spanner/tests/test_suite_14.py index 6494c414069a..9643f898e540 100644 --- a/packages/sqlalchemy-spanner/tests/test_suite_14.py +++ b/packages/sqlalchemy-spanner/tests/test_suite_14.py @@ -1576,7 +1576,7 @@ def run(type_, input_, output, filter_=None, check_scale=False): return run @emits_warning(r".*does \*not\* support Decimal objects natively") - def test_render_literal_numeric(self, literal_round_trip): + def test_render_literal_numeric(self, literal_round_trip_spanner): """ SPANNER OVERRIDE: @@ -1585,14 +1585,14 @@ def test_render_literal_numeric(self, literal_round_trip): following insertions will fail with `Row [] already exists". Overriding the test to avoid the same failure. """ - literal_round_trip( + literal_round_trip_spanner( Numeric(precision=8, scale=4), [decimal.Decimal("15.7563")], [decimal.Decimal("15.7563")], ) @emits_warning(r".*does \*not\* support Decimal objects natively") - def test_render_literal_numeric_asfloat(self, literal_round_trip): + def test_render_literal_numeric_asfloat(self, literal_round_trip_spanner): """ SPANNER OVERRIDE: @@ -1601,13 +1601,13 @@ def test_render_literal_numeric_asfloat(self, literal_round_trip): following insertions will fail with `Row [] already exists". Overriding the test to avoid the same failure. """ - literal_round_trip( + literal_round_trip_spanner( Numeric(precision=8, scale=4, asdecimal=False), [decimal.Decimal("15.7563")], [15.7563], ) - def test_render_literal_float(self, literal_round_trip): + def test_render_literal_float(self, literal_round_trip_spanner): """ SPANNER OVERRIDE: @@ -1616,7 +1616,7 @@ def test_render_literal_float(self, literal_round_trip): following insertions will fail with `Row [] already exists". Overriding the test to avoid the same failure. """ - literal_round_trip( + literal_round_trip_spanner( Float(4), [decimal.Decimal("15.7563")], [15.7563], From 965ef97301ea7167cc8bb607f1c3e6ccb7933c07 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 20 Aug 2026 09:41:50 -0400 Subject: [PATCH 18/21] fix(sqlalchemy-spanner): use literal_round_trip fixture to avoid literal_execute TypeError --- packages/sqlalchemy-spanner/tests/test_suite_14.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/sqlalchemy-spanner/tests/test_suite_14.py b/packages/sqlalchemy-spanner/tests/test_suite_14.py index 9643f898e540..6494c414069a 100644 --- a/packages/sqlalchemy-spanner/tests/test_suite_14.py +++ b/packages/sqlalchemy-spanner/tests/test_suite_14.py @@ -1576,7 +1576,7 @@ def run(type_, input_, output, filter_=None, check_scale=False): return run @emits_warning(r".*does \*not\* support Decimal objects natively") - def test_render_literal_numeric(self, literal_round_trip_spanner): + def test_render_literal_numeric(self, literal_round_trip): """ SPANNER OVERRIDE: @@ -1585,14 +1585,14 @@ def test_render_literal_numeric(self, literal_round_trip_spanner): following insertions will fail with `Row [] already exists". Overriding the test to avoid the same failure. """ - literal_round_trip_spanner( + literal_round_trip( Numeric(precision=8, scale=4), [decimal.Decimal("15.7563")], [decimal.Decimal("15.7563")], ) @emits_warning(r".*does \*not\* support Decimal objects natively") - def test_render_literal_numeric_asfloat(self, literal_round_trip_spanner): + def test_render_literal_numeric_asfloat(self, literal_round_trip): """ SPANNER OVERRIDE: @@ -1601,13 +1601,13 @@ def test_render_literal_numeric_asfloat(self, literal_round_trip_spanner): following insertions will fail with `Row [] already exists". Overriding the test to avoid the same failure. """ - literal_round_trip_spanner( + literal_round_trip( Numeric(precision=8, scale=4, asdecimal=False), [decimal.Decimal("15.7563")], [15.7563], ) - def test_render_literal_float(self, literal_round_trip_spanner): + def test_render_literal_float(self, literal_round_trip): """ SPANNER OVERRIDE: @@ -1616,7 +1616,7 @@ def test_render_literal_float(self, literal_round_trip_spanner): following insertions will fail with `Row [] already exists". Overriding the test to avoid the same failure. """ - literal_round_trip_spanner( + literal_round_trip( Float(4), [decimal.Decimal("15.7563")], [15.7563], From f1b034ad60fd0c996aaa61aacaae35d32afd26f5 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 20 Aug 2026 09:55:59 -0400 Subject: [PATCH 19/21] fix(sqlalchemy-spanner): use unique table name t_literal_round_trip_spanner without literal_execute --- packages/sqlalchemy-spanner/tests/conftest.py | 5 +---- packages/sqlalchemy-spanner/tests/test_suite_14.py | 12 ++++++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/sqlalchemy-spanner/tests/conftest.py b/packages/sqlalchemy-spanner/tests/conftest.py index b949e1a4c47e..ec16ad532dde 100644 --- a/packages/sqlalchemy-spanner/tests/conftest.py +++ b/packages/sqlalchemy-spanner/tests/conftest.py @@ -75,7 +75,7 @@ def run( t.create(connection) for value in input_: - ins = t.insert().values(x=literal(value, type_, literal_execute=True)) + ins = t.insert().values(x=literal(value, type_)) connection.execute(ins) if support_whereclause: @@ -85,13 +85,11 @@ def run( == literal( compare, type_, - literal_execute=True, ), t.c.x == literal( input_[0], type_, - literal_execute=True, ), ) else: @@ -100,7 +98,6 @@ def run( == literal( compare if compare is not None else input_[0], type_, - literal_execute=True, ) ) else: diff --git a/packages/sqlalchemy-spanner/tests/test_suite_14.py b/packages/sqlalchemy-spanner/tests/test_suite_14.py index 6494c414069a..9643f898e540 100644 --- a/packages/sqlalchemy-spanner/tests/test_suite_14.py +++ b/packages/sqlalchemy-spanner/tests/test_suite_14.py @@ -1576,7 +1576,7 @@ def run(type_, input_, output, filter_=None, check_scale=False): return run @emits_warning(r".*does \*not\* support Decimal objects natively") - def test_render_literal_numeric(self, literal_round_trip): + def test_render_literal_numeric(self, literal_round_trip_spanner): """ SPANNER OVERRIDE: @@ -1585,14 +1585,14 @@ def test_render_literal_numeric(self, literal_round_trip): following insertions will fail with `Row [] already exists". Overriding the test to avoid the same failure. """ - literal_round_trip( + literal_round_trip_spanner( Numeric(precision=8, scale=4), [decimal.Decimal("15.7563")], [decimal.Decimal("15.7563")], ) @emits_warning(r".*does \*not\* support Decimal objects natively") - def test_render_literal_numeric_asfloat(self, literal_round_trip): + def test_render_literal_numeric_asfloat(self, literal_round_trip_spanner): """ SPANNER OVERRIDE: @@ -1601,13 +1601,13 @@ def test_render_literal_numeric_asfloat(self, literal_round_trip): following insertions will fail with `Row [] already exists". Overriding the test to avoid the same failure. """ - literal_round_trip( + literal_round_trip_spanner( Numeric(precision=8, scale=4, asdecimal=False), [decimal.Decimal("15.7563")], [15.7563], ) - def test_render_literal_float(self, literal_round_trip): + def test_render_literal_float(self, literal_round_trip_spanner): """ SPANNER OVERRIDE: @@ -1616,7 +1616,7 @@ def test_render_literal_float(self, literal_round_trip): following insertions will fail with `Row [] already exists". Overriding the test to avoid the same failure. """ - literal_round_trip( + literal_round_trip_spanner( Float(4), [decimal.Decimal("15.7563")], [15.7563], From ef4f8a343e5c2931bff5d95002877783449106f1 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 20 Aug 2026 10:21:11 -0400 Subject: [PATCH 20/21] fix(sqlalchemy-spanner): pass config filename explicitly as command line argument to create and drop database scripts --- .../create_test_database.py | 37 ++++++++++++++++++- .../sqlalchemy-spanner/drop_test_database.py | 10 ++++- packages/sqlalchemy-spanner/noxfile.py | 30 ++++++++++----- 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/packages/sqlalchemy-spanner/create_test_database.py b/packages/sqlalchemy-spanner/create_test_database.py index 33d838bce334..4c004fb05c97 100644 --- a/packages/sqlalchemy-spanner/create_test_database.py +++ b/packages/sqlalchemy-spanner/create_test_database.py @@ -160,5 +160,38 @@ def create_test_instance(): json.dump({"database_id": database_id, "creation_time": creation_timestamp}, f) -delete_stale_test_databases() -create_test_instance() +def main(argv): + config_filename = argv[0] if argv else os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg") + os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_filename + + delete_stale_test_databases() + create_test_instance() + + instance_id = "sqlalchemy-dialect-test" + instance = CLIENT.instance(instance_id) + + # Generate a session-isolated unique database ID within Spanner 30-char limit + # Format: sp_test_{timestamp_in_seconds}_{rand_hex8} (compliant with Spanner naming: ^[a-z][a-z0-9_]{1,29}$) + creation_timestamp = time.time() + timestamp_part = str(int(creation_timestamp)) + rand_part = uuid.uuid4().hex[:8] + database_id = f"sp_test_{timestamp_part}_{rand_part}" + + try: + database = instance.database(database_id) + created_op = database.create() + created_op.result(1800) + except AlreadyExists: + pass # database was already created + + set_test_config(PROJECT, instance_id, database_id) + + # Record metadata for duration tracking on teardown + meta_path = os.path.join(os.path.dirname(__file__), ".db_session_info.json") + with open(meta_path, "w") as f: + json.dump({"database_id": database_id, "creation_time": creation_timestamp}, f) + + +if __name__ == "__main__": + import sys + main(sys.argv[1:]) diff --git a/packages/sqlalchemy-spanner/drop_test_database.py b/packages/sqlalchemy-spanner/drop_test_database.py index aa27569a575c..c9cee6b06635 100644 --- a/packages/sqlalchemy-spanner/drop_test_database.py +++ b/packages/sqlalchemy-spanner/drop_test_database.py @@ -107,4 +107,12 @@ def delete_test_database(): pass -delete_test_database() +def main(argv): + config_filename = argv[0] if argv else os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg") + os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_filename + delete_test_database() + + +if __name__ == "__main__": + import sys + main(sys.argv[1:]) diff --git a/packages/sqlalchemy-spanner/noxfile.py b/packages/sqlalchemy-spanner/noxfile.py index 4681db029e19..b1887b3ec082 100644 --- a/packages/sqlalchemy-spanner/noxfile.py +++ b/packages/sqlalchemy-spanner/noxfile.py @@ -205,7 +205,7 @@ def compliance_test_14(session): *SQLALCHEMY_14_DEPENDENCIES, "--force-reinstall", ) - session.run("python", "create_test_database.py") + session.run("python", "create_test_database.py", config_file) config = configparser.ConfigParser() config.read(config_file) db_url = config.get("db", "default") @@ -226,9 +226,13 @@ def compliance_test_14(session): ) finally: if os.path.exists(config_file): - session.run("python", "drop_test_database.py", success_codes=[0, 1]) + session.run( + "python", "drop_test_database.py", config_file, success_codes=[0, 1] + ) elif os.path.exists("test.cfg"): - session.run("python", "drop_test_database.py", success_codes=[0, 1]) + session.run( + "python", "drop_test_database.py", "test.cfg", success_codes=[0, 1] + ) @nox.session(python=DEFAULT_PYTHON_VERSION_FOR_SQLALCHEMY_20) @@ -254,7 +258,7 @@ def compliance_test_20(session): try: session.install(*SYSTEM_TEST_STANDARD_DEPENDENCIES) session.install("-e", ".", "--force-reinstall") - session.run("python", "create_test_database.py") + session.run("python", "create_test_database.py", config_file) config = configparser.ConfigParser() config.read(config_file) db_url = config.get("db", "default") @@ -276,9 +280,13 @@ def compliance_test_20(session): ) finally: if os.path.exists(config_file): - session.run("python", "drop_test_database.py", success_codes=[0, 1]) + session.run( + "python", "drop_test_database.py", config_file, success_codes=[0, 1] + ) elif os.path.exists("test.cfg"): - session.run("python", "drop_test_database.py", success_codes=[0, 1]) + session.run( + "python", "drop_test_database.py", "test.cfg", success_codes=[0, 1] + ) @nox.session(python=DEFAULT_PYTHON_VERSION_FOR_SQLALCHEMY_20) @@ -468,7 +476,7 @@ def system(session, test_type): session.install(*SYSTEM_TEST_STANDARD_DEPENDENCIES) session.install(".[tracing]") session.install(*SYSTEM_TEST_EXTERNAL_DEPENDENCIES) - session.run("python", "create_test_database.py") + session.run("python", "create_test_database.py", config_file) config = configparser.ConfigParser() config.read(config_file) db_url = config.get("db", "default") @@ -490,9 +498,13 @@ def system(session, test_type): _migration_test(session) finally: if os.path.exists(config_file): - session.run("python", "drop_test_database.py", success_codes=[0, 1]) + session.run( + "python", "drop_test_database.py", config_file, success_codes=[0, 1] + ) elif os.path.exists("test.cfg"): - session.run("python", "drop_test_database.py", success_codes=[0, 1]) + session.run( + "python", "drop_test_database.py", "test.cfg", success_codes=[0, 1] + ) @nox.session(python=DEFAULT_PYTHON_VERSION) From 9469195780e8bcbc26dfad4f963478f6dd89c01b Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Thu, 20 Aug 2026 10:36:41 -0400 Subject: [PATCH 21/21] fix(sqlalchemy-spanner): pass config_file explicitly in _migration_test session as well --- packages/sqlalchemy-spanner/noxfile.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/sqlalchemy-spanner/noxfile.py b/packages/sqlalchemy-spanner/noxfile.py index b1887b3ec082..a09b96f44052 100644 --- a/packages/sqlalchemy-spanner/noxfile.py +++ b/packages/sqlalchemy-spanner/noxfile.py @@ -346,7 +346,7 @@ def _migration_test(session): session.install(".") try: - session.run("python", "create_test_database.py") + session.run("python", "create_test_database.py", config_file) config = configparser.ConfigParser() if os.path.exists(config_file): @@ -391,7 +391,9 @@ def _migration_test(session): shutil.rmtree("test_migration") if os.path.exists(config_file): - session.run("python", "drop_test_database.py", success_codes=[0, 1]) + session.run( + "python", "drop_test_database.py", config_file, success_codes=[0, 1] + ) @nox.session(python=ALL_PYTHON)