diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index 748378ab..0f219787 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -126,6 +126,30 @@ jobs: SPANNER_EMULATOR_HOST: localhost:9010 GOOGLE_CLOUD_PROJECT: appdev-soda-spanner-staging + system: + runs-on: ubuntu-latest + + services: + emulator-0: + image: gcr.io/cloud-spanner-emulator/emulator:latest + ports: + - 9010:9010 + + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: 3.12 + - name: Install nox + run: python -m pip install nox + - name: Run System Tests + run: nox -s system + env: + SPANNER_EMULATOR_HOST: localhost:9010 + GOOGLE_CLOUD_PROJECT: appdev-soda-spanner-staging + migration_tests: runs-on: ubuntu-latest diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 54514682..0985afd0 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -45,4 +45,5 @@ if [[ -n "${NOX_SESSION:-}" ]]; then python3 -m nox -s ${NOX_SESSION:-} else python3 -m nox -s unit + python3 -m nox -s system fi diff --git a/create_test_config.py b/create_test_config.py index 7ef6f255..388cba86 100644 --- a/create_test_config.py +++ b/create_test_config.py @@ -18,18 +18,18 @@ import sys -def set_test_config(project, instance, user=None, password=None, host=None, port=None): +def set_test_config(project, instance, database, user=None, password=None, host=None, port=None): config = configparser.ConfigParser() if user is not None and password is not None and host is not None and port is not None: url = ( f"spanner+spanner://{user}:{password}@{host}:{port}" f"/projects/{project}/instances/{instance}/" - "databases/compliance-test" + f"databases/{database}" ) else: url = ( f"spanner+spanner:///projects/{project}/instances/{instance}/" - "databases/compliance-test" + f"databases/{database}" ) config.add_section("db") config["db"]["default"] = url @@ -41,17 +41,18 @@ def set_test_config(project, instance, user=None, password=None, host=None, port def main(argv): project = argv[0] instance = argv[1] - if len(argv) == 6: - user = argv[2] - password = argv[3] - host = argv[4] - port = argv[5] + database = argv[2] + if len(argv) == 7: + user = argv[3] + password = argv[4] + host = argv[5] + port = argv[6] else: user = None password = None host = None port = None - set_test_config(project, instance, user, password, host, port) + set_test_config(project, instance, database, user, password, host, port) if __name__ == "__main__": diff --git a/create_test_database.py b/create_test_database.py index 1e29d44f..5938eb82 100644 --- a/create_test_database.py +++ b/create_test_database.py @@ -14,14 +14,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -import configparser import os 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.instance import Instance +from google.cloud.spanner_v1.database import Database USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None @@ -66,43 +67,62 @@ def delete_stale_test_instances(): ) -def create_test_instance(): - configs = list(CLIENT.list_instance_configs()) - if not USE_EMULATOR: - # Filter out non "us" locations - configs = [config for config in configs if "asia-southeast1" in config.name] +def delete_stale_test_databases(): + """Delete test databases that are older than four hours.""" + 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) + # The emulator does not return a create_time for databases. + if database.create_time is None: + continue + create_time = datetime_helpers.to_milliseconds(database_pb.create_time) + if create_time > cutoff: + continue + try: + database.drop() + except ResourceExhausted: + print( + "Unable to drop stale database '{}'. May need manual delete.".format( + database.database_id + ) + ) - instance_config = configs[0].name - create_time = str(int(time.time())) - unique_resource_id = "%s%d" % ("-", 1000 * time.time()) - instance_id = ( - "sqlalchemy-dialect-test" - if USE_EMULATOR - else "sqlalchemy-test" + unique_resource_id - ) - labels = {"python-spanner-sqlalchemy-systest": "true", "created": create_time} - instance = CLIENT.instance(instance_id, instance_config, labels=labels) +def create_test_instance(): + instance_id = "sqlalchemy-dialect-test" + instance = CLIENT.instance(instance_id) + if not instance.exists(): + instance_config = f"projects/{PROJECT}/instanceConfigs/regional-us-east1" + if USE_EMULATOR: + configs = list(CLIENT.list_instance_configs()) + instance_config = configs[0].name + create_time = str(int(time.time())) + labels = {"python-spanner-sqlalchemy-systest": "true", "created": create_time} + + instance = CLIENT.instance(instance_id, instance_config, labels=labels) - try: - created_op = instance.create() - created_op.result(1800) # block until completion - except AlreadyExists: - pass # instance was already created + try: + created_op = instance.create() + created_op.result(1800) # block until completion + except AlreadyExists: + pass # instance was already created - if USE_EMULATOR: - database = instance.database("compliance-test") - database.drop() + unique_resource_id = "%s%d" % ("-", 1000 * time.time()) + database_id = "sqlalchemy-test" + unique_resource_id try: - database = instance.database("compliance-test") + database = instance.database(database_id) created_op = database.create() created_op.result(1800) except AlreadyExists: - pass # instance was already created + pass # database was already created - set_test_config(PROJECT, instance_id) + set_test_config(PROJECT, instance_id, database_id) -delete_stale_test_instances() +delete_stale_test_databases() create_test_instance() diff --git a/drop_test_database.py b/drop_test_database.py new file mode 100644 index 00000000..3f2b25c2 --- /dev/null +++ b/drop_test_database.py @@ -0,0 +1,63 @@ +# -*- 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 configparser +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.instance import Instance +from google.cloud.spanner_v1.database import Database + + +USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None + +PROJECT = os.getenv( + "GOOGLE_CLOUD_PROJECT", + os.getenv("PROJECT_ID", "emulator-test-project"), +) +CLIENT = None + +if USE_EMULATOR: + from google.auth.credentials import AnonymousCredentials + + CLIENT = Client(project=PROJECT, credentials=AnonymousCredentials()) +else: + CLIENT = Client(project=PROJECT) + + +def delete_test_database(): + """Delete the currently configured test database.""" + config = configparser.ConfigParser() + if os.path.exists("test.cfg"): + config.read("test.cfg") + else: + config.read("setup.cfg") + db_url = config.get("db", "default") + + instance_id = re.findall(r"instances(.*?)databases", db_url) + 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.drop() + +delete_test_database() diff --git a/migration_test_cleanup.py b/migration_test_cleanup.py index 62266359..c56b10d0 100644 --- a/migration_test_cleanup.py +++ b/migration_test_cleanup.py @@ -25,10 +25,11 @@ def main(argv): 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("compliance-test") + database = instance.database("".join(database_id).replace("/", "")) database.update_ddl(["DROP TABLE account", "DROP TABLE alembic_version"]).result(120) diff --git a/noxfile.py b/noxfile.py index eff6bfc9..2c4b21bc 100644 --- a/noxfile.py +++ b/noxfile.py @@ -252,6 +252,43 @@ def compliance_test_20(session): ) +@nox.session() +def system(session): + """Run SQLAlchemy dialect system test suite.""" + + # Sanity check: Only run tests if the environment variable is set. + if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "") and not os.environ.get( + "SPANNER_EMULATOR_HOST", "" + ): + session.skip( + "Credentials or emulator host must be set via environment variable" + ) + + if os.environ.get("RUN_COMPLIANCE_TESTS", "true") == "false" and not os.environ.get( + "SPANNER_EMULATOR_HOST", "" + ): + session.skip("RUN_COMPLIANCE_TESTS is set to false, skipping") + + session.install( + "pytest", + "pytest-cov", + "pytest-asyncio", + ) + + session.install("mock") + session.install(".[tracing]") + session.install("opentelemetry-api==1.27.0") + session.install("opentelemetry-sdk==1.27.0") + session.install("opentelemetry-instrumentation==0.48b0") + session.run("python", "create_test_database.py") + + session.install("sqlalchemy>=2.0") + + session.run("py.test", "--quiet", os.path.join("test", "system"), *session.posargs) + + session.run("python", "drop_test_database.py") + + @nox.session(python=DEFAULT_PYTHON_VERSION) def unit(session): """Run unit tests.""" @@ -263,7 +300,9 @@ def unit(session): session.install("opentelemetry-api==1.27.0") session.install("opentelemetry-sdk==1.27.0") session.install("opentelemetry-instrumentation==0.48b0") - session.run("python", "create_test_config.py", "my-project", "my-instance") + session.run( + "python", "create_test_config.py", "my-project", "my-instance", "my-database" + ) session.run("py.test", "--quiet", os.path.join("test/unit"), *session.posargs) @@ -281,6 +320,7 @@ def mockserver(session): "create_test_config.py", "my-project", "my-instance", + "my-database", "none", "AnonymousCredentials", "localhost", @@ -323,21 +363,12 @@ def _migration_test(session): session.run("python", "create_test_database.py") - project = os.getenv( - "GOOGLE_CLOUD_PROJECT", - os.getenv("PROJECT_ID", "emulator-test-project"), - ) - db_url = ( - f"spanner+spanner:///projects/{project}/instances/" - "sqlalchemy-dialect-test/databases/compliance-test" - ) - config = configparser.ConfigParser() if os.path.exists("test.cfg"): config.read("test.cfg") else: config.read("setup.cfg") - db_url = config.get("db", "default", fallback=db_url) + db_url = config.get("db", "default") session.run("alembic", "init", "test_migration") diff --git a/test/system/test_basics.py b/test/system/test_basics.py new file mode 100644 index 00000000..6a53b234 --- /dev/null +++ b/test/system/test_basics.py @@ -0,0 +1,39 @@ +# Copyright 2024 Google LLC All rights reserved. +# +# 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 +# +# http://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. +from sqlalchemy import text, Table, Column, Integer, PrimaryKeyConstraint, String +from sqlalchemy.testing import eq_ +from sqlalchemy.testing.plugin.plugin_base import fixtures + + +class TestBasics(fixtures.TablesTest): + @classmethod + def define_tables(cls, metadata): + Table( + "numbers", + metadata, + Column("number", Integer), + Column("name", String(20)), + PrimaryKeyConstraint("number"), + ) + + def test_hello_world(self, connection): + greeting = connection.execute(text("select 'Hello World'")) + eq_("Hello World", greeting.fetchone()[0]) + + def test_insert_number(self, connection): + connection.execute( + text("insert or update into numbers(number, name) values (1, 'One')") + ) + name = connection.execute(text("select name from numbers where number=1")) + eq_("One", name.fetchone()[0])