From 7cb68db98b611e6c0feb2a265ad246ab3ea8abf7 Mon Sep 17 00:00:00 2001 From: larkee <31196561+larkee@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:14:20 +1200 Subject: [PATCH 1/6] test: enable emulator tests for POSTGRESQL dialect (#1201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: enable emulator tests for POSTGRESQL dialect * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * test: update test fixture to not depend on other fixtures --------- Co-authored-by: larkee Co-authored-by: Owl Bot --- google/cloud/spanner_v1/database.py | 4 +++- tests/system/conftest.py | 9 +++++++++ tests/system/test_instance_api.py | 1 - tests/system/test_session_api.py | 8 +++++--- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/google/cloud/spanner_v1/database.py b/google/cloud/spanner_v1/database.py index 6bd4f3703e..f6c4ceb667 100644 --- a/google/cloud/spanner_v1/database.py +++ b/google/cloud/spanner_v1/database.py @@ -525,7 +525,9 @@ def reload(self): self._encryption_config = response.encryption_config self._encryption_info = response.encryption_info self._default_leader = response.default_leader - self._database_dialect = response.database_dialect + # Only update if the data is specific to avoid losing specificity. + if response.database_dialect != DatabaseDialect.DATABASE_DIALECT_UNSPECIFIED: + self._database_dialect = response.database_dialect self._enable_drop_protection = response.enable_drop_protection self._reconciling = response.reconciling diff --git a/tests/system/conftest.py b/tests/system/conftest.py index bf939cfa99..1337de4972 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -65,6 +65,15 @@ def not_google_standard_sql(database_dialect): ) +@pytest.fixture(scope="session") +def not_postgres_emulator(database_dialect): + if database_dialect == DatabaseDialect.POSTGRESQL and _helpers.USE_EMULATOR: + pytest.skip( + f"{_helpers.DATABASE_DIALECT_ENVVAR} set to POSTGRESQL and {_helpers.USE_EMULATOR_ENVVAR} set in " + "environment." + ) + + @pytest.fixture(scope="session") def database_dialect(): return ( diff --git a/tests/system/test_instance_api.py b/tests/system/test_instance_api.py index 6825e50721..fe962d2ccb 100644 --- a/tests/system/test_instance_api.py +++ b/tests/system/test_instance_api.py @@ -84,7 +84,6 @@ def test_create_instance( def test_create_instance_with_processing_units( - not_emulator, if_create_instance, spanner_client, instance_config, diff --git a/tests/system/test_session_api.py b/tests/system/test_session_api.py index 31e38f967a..d0421d3a70 100644 --- a/tests/system/test_session_api.py +++ b/tests/system/test_session_api.py @@ -1195,7 +1195,9 @@ def unit_of_work(transaction): assert span.parent.span_id == span_list[-1].context.span_id -def test_execute_partitioned_dml(sessions_database, database_dialect): +def test_execute_partitioned_dml( + not_postgres_emulator, sessions_database, database_dialect +): # [START spanner_test_dml_partioned_dml_update] sd = _sample_data param_types = spanner_v1.param_types @@ -2420,7 +2422,7 @@ def test_execute_sql_w_json_bindings( def test_execute_sql_w_jsonb_bindings( - not_emulator, not_google_standard_sql, sessions_database, database_dialect + not_google_standard_sql, sessions_database, database_dialect ): _bind_test_helper( sessions_database, @@ -2432,7 +2434,7 @@ def test_execute_sql_w_jsonb_bindings( def test_execute_sql_w_oid_bindings( - not_emulator, not_google_standard_sql, sessions_database, database_dialect + not_google_standard_sql, sessions_database, database_dialect ): _bind_test_helper( sessions_database, From e30d23d707b43911180b96df9cd527aaf3c2ca12 Mon Sep 17 00:00:00 2001 From: "gcf-owl-bot[bot]" <78513119+gcf-owl-bot[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 18:22:13 -0400 Subject: [PATCH 2/6] build(python): release script update (#1205) Source-Link: https://github.com/googleapis/synthtool/commit/71a72973dddbc66ea64073b53eda49f0d22e0942 Post-Processor: gcr.io/cloud-devrel-public-resources/owlbot-python:latest@sha256:e8dcfd7cbfd8beac3a3ff8d3f3185287ea0625d859168cc80faccfc9a7a00455 Co-authored-by: Owl Bot --- .github/.OwlBot.lock.yaml | 4 ++-- .kokoro/docker/docs/Dockerfile | 9 ++++----- .kokoro/publish-docs.sh | 20 ++++++++++---------- .kokoro/release.sh | 2 +- .kokoro/release/common.cfg | 2 +- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index f30cb3775a..597e0c3261 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,5 +13,5 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - digest: sha256:52210e0e0559f5ea8c52be148b33504022e1faef4e95fbe4b32d68022af2fa7e -# created: 2024-07-08T19:25:35.862283192Z + digest: sha256:e8dcfd7cbfd8beac3a3ff8d3f3185287ea0625d859168cc80faccfc9a7a00455 +# created: 2024-09-16T21:04:09.091105552Z diff --git a/.kokoro/docker/docs/Dockerfile b/.kokoro/docker/docs/Dockerfile index 5205308b33..e5410e296b 100644 --- a/.kokoro/docker/docs/Dockerfile +++ b/.kokoro/docker/docs/Dockerfile @@ -72,19 +72,18 @@ RUN tar -xvf Python-3.10.14.tgz RUN ./Python-3.10.14/configure --enable-optimizations RUN make altinstall -RUN python3.10 -m venv /venv -ENV PATH /venv/bin:$PATH +ENV PATH /usr/local/bin/python3.10:$PATH ###################### Install pip RUN wget -O /tmp/get-pip.py 'https://bootstrap.pypa.io/get-pip.py' \ - && python3 /tmp/get-pip.py \ + && python3.10 /tmp/get-pip.py \ && rm /tmp/get-pip.py # Test pip -RUN python3 -m pip +RUN python3.10 -m pip # Install build requirements COPY requirements.txt /requirements.txt -RUN python3 -m pip install --require-hashes -r requirements.txt +RUN python3.10 -m pip install --require-hashes -r requirements.txt CMD ["python3.10"] diff --git a/.kokoro/publish-docs.sh b/.kokoro/publish-docs.sh index 38f083f05a..233205d580 100755 --- a/.kokoro/publish-docs.sh +++ b/.kokoro/publish-docs.sh @@ -21,18 +21,18 @@ export PYTHONUNBUFFERED=1 export PATH="${HOME}/.local/bin:${PATH}" # Install nox -python3 -m pip install --require-hashes -r .kokoro/requirements.txt -python3 -m nox --version +python3.10 -m pip install --require-hashes -r .kokoro/requirements.txt +python3.10 -m nox --version # build docs nox -s docs # create metadata -python3 -m docuploader create-metadata \ +python3.10 -m docuploader create-metadata \ --name=$(jq --raw-output '.name // empty' .repo-metadata.json) \ - --version=$(python3 setup.py --version) \ + --version=$(python3.10 setup.py --version) \ --language=$(jq --raw-output '.language // empty' .repo-metadata.json) \ - --distribution-name=$(python3 setup.py --name) \ + --distribution-name=$(python3.10 setup.py --name) \ --product-page=$(jq --raw-output '.product_documentation // empty' .repo-metadata.json) \ --github-repository=$(jq --raw-output '.repo // empty' .repo-metadata.json) \ --issue-tracker=$(jq --raw-output '.issue_tracker // empty' .repo-metadata.json) @@ -40,18 +40,18 @@ python3 -m docuploader create-metadata \ cat docs.metadata # upload docs -python3 -m docuploader upload docs/_build/html --metadata-file docs.metadata --staging-bucket "${STAGING_BUCKET}" +python3.10 -m docuploader upload docs/_build/html --metadata-file docs.metadata --staging-bucket "${STAGING_BUCKET}" # docfx yaml files nox -s docfx # create metadata. -python3 -m docuploader create-metadata \ +python3.10 -m docuploader create-metadata \ --name=$(jq --raw-output '.name // empty' .repo-metadata.json) \ - --version=$(python3 setup.py --version) \ + --version=$(python3.10 setup.py --version) \ --language=$(jq --raw-output '.language // empty' .repo-metadata.json) \ - --distribution-name=$(python3 setup.py --name) \ + --distribution-name=$(python3.10 setup.py --name) \ --product-page=$(jq --raw-output '.product_documentation // empty' .repo-metadata.json) \ --github-repository=$(jq --raw-output '.repo // empty' .repo-metadata.json) \ --issue-tracker=$(jq --raw-output '.issue_tracker // empty' .repo-metadata.json) @@ -59,4 +59,4 @@ python3 -m docuploader create-metadata \ cat docs.metadata # upload docs -python3 -m docuploader upload docs/_build/html/docfx_yaml --metadata-file docs.metadata --destination-prefix docfx --staging-bucket "${V2_STAGING_BUCKET}" +python3.10 -m docuploader upload docs/_build/html/docfx_yaml --metadata-file docs.metadata --destination-prefix docfx --staging-bucket "${V2_STAGING_BUCKET}" diff --git a/.kokoro/release.sh b/.kokoro/release.sh index a0c05f4a6e..0b16dec307 100755 --- a/.kokoro/release.sh +++ b/.kokoro/release.sh @@ -23,7 +23,7 @@ python3 -m releasetool publish-reporter-script > /tmp/publisher-script; source / export PYTHONUNBUFFERED=1 # Move into the package, build the distribution and upload. -TWINE_PASSWORD=$(cat "${KOKORO_KEYSTORE_DIR}/73713_google-cloud-pypi-token-keystore-1") +TWINE_PASSWORD=$(cat "${KOKORO_KEYSTORE_DIR}/73713_google-cloud-pypi-token-keystore-2") cd github/python-spanner python3 setup.py sdist bdist_wheel twine upload --username __token__ --password "${TWINE_PASSWORD}" dist/* diff --git a/.kokoro/release/common.cfg b/.kokoro/release/common.cfg index 8b9a3e9df9..351e701429 100644 --- a/.kokoro/release/common.cfg +++ b/.kokoro/release/common.cfg @@ -28,7 +28,7 @@ before_action { fetch_keystore { keystore_resource { keystore_config_id: 73713 - keyname: "google-cloud-pypi-token-keystore-1" + keyname: "google-cloud-pypi-token-keystore-2" } } } From 03b3e86865c7cbb5bc7ad4efc40f17234d88aefd Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Tue, 17 Sep 2024 08:43:12 +0200 Subject: [PATCH 3/6] chore(deps): update all dependencies (#1183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): update all dependencies * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --------- Co-authored-by: Owl Bot Co-authored-by: Sri Harsha CH <57220027+harshachinta@users.noreply.github.com> --- .devcontainer/requirements.txt | 24 ++++++++++++------------ samples/samples/requirements-test.txt | 2 +- samples/samples/requirements.txt | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt index 8f8ce39776..5a7134670e 100644 --- a/.devcontainer/requirements.txt +++ b/.devcontainer/requirements.txt @@ -4,9 +4,9 @@ # # pip-compile --generate-hashes requirements.in # -argcomplete==3.4.0 \ - --hash=sha256:69a79e083a716173e5532e0fa3bef45f793f4e61096cf52b5a42c0211c8b8aa5 \ - --hash=sha256:c2abcdfe1be8ace47ba777d4fce319eb13bf8ad9dace8d085dcad6eded88057f +argcomplete==3.5.0 \ + --hash=sha256:4349400469dccfb7950bb60334a680c58d88699bff6159df61251878dc6bf74b \ + --hash=sha256:d4bcf3ff544f51e16e54228a7ac7f486ed70ebf2ecfe49a63a91171c76bf029b # via nox colorlog==6.8.2 \ --hash=sha256:3e3e079a41feb5a1b64f978b5ea4f46040a94f11f0e8bbb8261e3dbbeca64d44 \ @@ -16,9 +16,9 @@ distlib==0.3.8 \ --hash=sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784 \ --hash=sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64 # via virtualenv -filelock==3.15.4 \ - --hash=sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb \ - --hash=sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7 +filelock==3.16.0 \ + --hash=sha256:81de9eb8453c769b63369f87f11131a7ab04e367f8d97ad39dc230daa07e3bec \ + --hash=sha256:f6ed4c963184f4c84dd5557ce8fece759a3724b37b80c6c4f20a2f63a4dc6609 # via virtualenv nox==2024.4.15 \ --hash=sha256:6492236efa15a460ecb98e7b67562a28b70da006ab0be164e8821177577c0565 \ @@ -28,11 +28,11 @@ packaging==24.1 \ --hash=sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002 \ --hash=sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 # via nox -platformdirs==4.2.2 \ - --hash=sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee \ - --hash=sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3 +platformdirs==4.3.3 \ + --hash=sha256:50a5450e2e84f44539718293cbb1da0a0885c9d14adf21b77bae4e66fc99d9b5 \ + --hash=sha256:d4e0b7d8ec176b341fb03cb11ca12d0276faa8c485f9cd218f613840463fc2c0 # via virtualenv -virtualenv==20.26.3 \ - --hash=sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a \ - --hash=sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589 +virtualenv==20.26.4 \ + --hash=sha256:48f2695d9809277003f30776d155615ffc11328e6a0a8c1f0ec80188d7874a55 \ + --hash=sha256:c17f4e0f3e6036e9f26700446f85c76ab11df65ff6d8a9cbfad9f71aabfcf23c # via nox diff --git a/samples/samples/requirements-test.txt b/samples/samples/requirements-test.txt index afed94c76a..8aa23a8189 100644 --- a/samples/samples/requirements-test.txt +++ b/samples/samples/requirements-test.txt @@ -1,4 +1,4 @@ -pytest==8.3.2 +pytest==8.3.3 pytest-dependency==0.6.0 mock==5.1.0 google-cloud-testutils==1.4.0 diff --git a/samples/samples/requirements.txt b/samples/samples/requirements.txt index 516abe7f8b..5a108d39ef 100644 --- a/samples/samples/requirements.txt +++ b/samples/samples/requirements.txt @@ -1,2 +1,2 @@ -google-cloud-spanner==3.48.0 +google-cloud-spanner==3.49.1 futures==3.4.0; python_version < "3" From 2415049e908448130c4bcf29a9084688a31293ce Mon Sep 17 00:00:00 2001 From: alkatrivedi <58396306+alkatrivedi@users.noreply.github.com> Date: Tue, 17 Sep 2024 13:01:11 +0000 Subject: [PATCH 4/6] chore(samples): add sample for spanner edition (#1196) * chore(samples): add sample for spanner edition * refactor * refactor editions samples * refactor test --------- Co-authored-by: Sri Harsha CH <57220027+harshachinta@users.noreply.github.com> --- samples/samples/snippets.py | 33 ++++++++++++++++++++++++++++++++ samples/samples/snippets_test.py | 11 ++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/samples/samples/snippets.py b/samples/samples/snippets.py index 93c8de4148..8a3764e9a5 100644 --- a/samples/samples/snippets.py +++ b/samples/samples/snippets.py @@ -62,6 +62,7 @@ def create_instance(instance_id): "sample_name": "snippets-create_instance-explicit", "created": str(int(time.time())), }, + edition=spanner_instance_admin.Instance.Edition.STANDARD, # Optional ), ) @@ -73,6 +74,35 @@ def create_instance(instance_id): # [END spanner_create_instance] +# [START spanner_update_instance] +def update_instance(instance_id): + """Updates an instance.""" + from google.cloud.spanner_admin_instance_v1.types import \ + spanner_instance_admin + + spanner_client = spanner.Client() + + name = "{}/instances/{}".format(spanner_client.project_name, instance_id) + + operation = spanner_client.instance_admin_api.update_instance( + instance=spanner_instance_admin.Instance( + name=name, + labels={ + "sample_name": "snippets-update_instance-explicit", + }, + edition=spanner_instance_admin.Instance.Edition.ENTERPRISE, # Optional + ), + field_mask=field_mask_pb2.FieldMask(paths=["labels", "edition"]), + ) + + print("Waiting for operation to complete...") + operation.result(OPERATION_TIMEOUT_SECONDS) + + print("Updated instance {}".format(instance_id)) + + +# [END spanner_update_instance] + # [START spanner_create_instance_with_processing_units] def create_instance_with_processing_units(instance_id, processing_units): @@ -3421,6 +3451,7 @@ def query_data_with_proto_types_parameter(instance_id, database_id): subparsers = parser.add_subparsers(dest="command") subparsers.add_parser("create_instance", help=create_instance.__doc__) + subparsers.add_parser("update_instance", help=update_instance.__doc__) subparsers.add_parser("create_database", help=create_database.__doc__) subparsers.add_parser("insert_data", help=insert_data.__doc__) subparsers.add_parser("batch_write", help=batch_write.__doc__) @@ -3571,6 +3602,8 @@ def query_data_with_proto_types_parameter(instance_id, database_id): if args.command == "create_instance": create_instance(args.instance_id) + if args.command == "update_instance": + update_instance(args.instance_id) elif args.command == "create_database": create_database(args.instance_id, args.database_id) elif args.command == "insert_data": diff --git a/samples/samples/snippets_test.py b/samples/samples/snippets_test.py index 6657703fd1..6938aa1cd7 100644 --- a/samples/samples/snippets_test.py +++ b/samples/samples/snippets_test.py @@ -15,10 +15,10 @@ import time import uuid -import pytest from google.api_core import exceptions from google.cloud import spanner from google.cloud.spanner_admin_database_v1.types.common import DatabaseDialect +import pytest from test_utils.retry import RetryErrors import snippets @@ -152,10 +152,13 @@ def base_instance_config_id(spanner_client): return "{}/instanceConfigs/{}".format(spanner_client.project_name, "nam7") -def test_create_instance_explicit(spanner_client, create_instance_id): +def test_create_and_update_instance_explicit(spanner_client, create_instance_id): # Rather than re-use 'sample_isntance', we create a new instance, to # ensure that the 'create_instance' snippet is tested. retry_429(snippets.create_instance)(create_instance_id) + # Rather than re-use 'sample_isntance', we are using created instance, to + # ensure that the 'update_instance' snippet is tested. + retry_429(snippets.update_instance)(create_instance_id) instance = spanner_client.instance(create_instance_id) retry_429(instance.delete)() @@ -195,7 +198,9 @@ def test_create_instance_with_autoscaling_config(capsys, lci_instance_id): def test_create_instance_partition(capsys, instance_partition_instance_id): - snippets.create_instance(instance_partition_instance_id) + # Unable to use create_instance since it has editions set where partitions are unsupported. + # The minimal requirement for editions is ENTERPRISE_PLUS for the paritions to get supported. + snippets.create_instance_with_processing_units(instance_partition_instance_id, 1000) retry_429(snippets.create_instance_partition)( instance_partition_instance_id, "my-instance-partition" ) From cb8a2b712aa1d3cdb2af34fa7cf11b90e3723fde Mon Sep 17 00:00:00 2001 From: Emmanuel T Odeke Date: Tue, 17 Sep 2024 19:33:06 -1000 Subject: [PATCH 5/6] tracing: update OpenTelemetry dependencies from 2021 to 2024 (#1199) This change non-invasively introduces dependencies of opentelemetry bringing in the latest dependencies and modernizing them. While here also brought in modern span attributes: * otel.scope.name * otel.scope.version Also added a modernized example to produce traces as well with gRPC-instrumentation enabled, and updated the docs. Updates #1170 Fixes #1173 Built from PR #1172 --- docs/opentelemetry-tracing.rst | 32 +++++--- examples/grpc_instrumentation_enabled.py | 73 +++++++++++++++++++ examples/trace.py | 66 +++++++++++++++++ .../spanner_v1/_opentelemetry_tracing.py | 35 +++++++-- setup.py | 6 +- testing/constraints-3.7.txt | 6 +- tests/_helpers.py | 21 ++++++ tests/system/test_session_api.py | 2 + tests/unit/test__opentelemetry_tracing.py | 54 ++++++++------ tests/unit/test_batch.py | 7 +- tests/unit/test_session.py | 34 ++------- tests/unit/test_snapshot.py | 17 +++-- tests/unit/test_transaction.py | 7 +- 13 files changed, 285 insertions(+), 75 deletions(-) create mode 100644 examples/grpc_instrumentation_enabled.py create mode 100644 examples/trace.py diff --git a/docs/opentelemetry-tracing.rst b/docs/opentelemetry-tracing.rst index 9b3dea276f..cb9a2b1350 100644 --- a/docs/opentelemetry-tracing.rst +++ b/docs/opentelemetry-tracing.rst @@ -8,10 +8,8 @@ To take advantage of these traces, we first need to install OpenTelemetry: .. code-block:: sh - pip install opentelemetry-api opentelemetry-sdk opentelemetry-instrumentation - - # [Optional] Installs the cloud monitoring exporter, however you can use any exporter of your choice - pip install opentelemetry-exporter-google-cloud + pip install opentelemetry-api opentelemetry-sdk + pip install opentelemetry-exporter-gcp-trace We also need to tell OpenTelemetry which exporter to use. To export Spanner traces to `Cloud Tracing `_, add the following lines to your application: @@ -19,21 +17,37 @@ We also need to tell OpenTelemetry which exporter to use. To export Spanner trac from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.trace.sampling import ProbabilitySampler + from opentelemetry.sdk.trace.sampling import TraceIdRatioBased from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter - # BatchExportSpanProcessor exports spans to Cloud Trace + # BatchSpanProcessor exports spans to Cloud Trace # in a seperate thread to not block on the main thread - from opentelemetry.sdk.trace.export import BatchExportSpanProcessor + from opentelemetry.sdk.trace.export import BatchSpanProcessor # Create and export one trace every 1000 requests - sampler = ProbabilitySampler(1/1000) + sampler = TraceIdRatioBased(1/1000) # Use the default tracer provider trace.set_tracer_provider(TracerProvider(sampler=sampler)) trace.get_tracer_provider().add_span_processor( # Initialize the cloud tracing exporter - BatchExportSpanProcessor(CloudTraceSpanExporter()) + BatchSpanProcessor(CloudTraceSpanExporter()) ) + +To get more fine-grained traces from gRPC, you can enable the gRPC instrumentation by the following + +.. code-block:: sh + + pip install opentelemetry-instrumentation opentelemetry-instrumentation-grpc + +and then in your Python code, please add the following lines: + +.. code:: python + + from opentelemetry.instrumentation.grpc import GrpcInstrumentorClient + grpc_client_instrumentor = GrpcInstrumentorClient() + grpc_client_instrumentor.instrument() + + Generated spanner traces should now be available on `Cloud Trace `_. Tracing is most effective when many libraries are instrumented to provide insight over the entire lifespan of a request. diff --git a/examples/grpc_instrumentation_enabled.py b/examples/grpc_instrumentation_enabled.py new file mode 100644 index 0000000000..c8bccd0a9d --- /dev/null +++ b/examples/grpc_instrumentation_enabled.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Copyright 2024 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 +# +# 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 + +import os +import time + +import google.cloud.spanner as spanner +from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.sdk.trace.sampling import ALWAYS_ON +from opentelemetry import trace + +# Enable the gRPC instrumentation if you'd like more introspection. +from opentelemetry.instrumentation.grpc import GrpcInstrumentorClient + +grpc_client_instrumentor = GrpcInstrumentorClient() +grpc_client_instrumentor.instrument() + + +def main(): + # Setup common variables that'll be used between Spanner and traces. + project_id = os.environ.get('SPANNER_PROJECT_ID', 'test-project') + + # Setup OpenTelemetry, trace and Cloud Trace exporter. + tracer_provider = TracerProvider(sampler=ALWAYS_ON) + trace_exporter = CloudTraceSpanExporter(project_id=project_id) + tracer_provider.add_span_processor(BatchSpanProcessor(trace_exporter)) + trace.set_tracer_provider(tracer_provider) + # Retrieve a tracer from the global tracer provider. + tracer = tracer_provider.get_tracer('MyApp') + + # Setup the Cloud Spanner Client. + spanner_client = spanner.Client(project_id) + + instance = spanner_client.instance('test-instance') + database = instance.database('test-db') + + # Now run our queries + with tracer.start_as_current_span('QueryInformationSchema'): + with database.snapshot() as snapshot: + with tracer.start_as_current_span('InformationSchema'): + info_schema = snapshot.execute_sql( + 'SELECT * FROM INFORMATION_SCHEMA.TABLES') + for row in info_schema: + print(row) + + with tracer.start_as_current_span('ServerTimeQuery'): + with database.snapshot() as snapshot: + # Purposefully issue a bad SQL statement to examine exceptions + # that get recorded and a ERROR span status. + try: + data = snapshot.execute_sql('SELECT CURRENT_TIMESTAMPx()') + for row in data: + print(row) + except Exception as e: + pass + + +if __name__ == '__main__': + main() diff --git a/examples/trace.py b/examples/trace.py new file mode 100644 index 0000000000..791b6cd20b --- /dev/null +++ b/examples/trace.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Copyright 2024 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 +# +# 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 + +import os +import time + +import google.cloud.spanner as spanner +from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.sdk.trace.sampling import ALWAYS_ON +from opentelemetry import trace + + +def main(): + # Setup common variables that'll be used between Spanner and traces. + project_id = os.environ.get('SPANNER_PROJECT_ID', 'test-project') + + # Setup OpenTelemetry, trace and Cloud Trace exporter. + tracer_provider = TracerProvider(sampler=ALWAYS_ON) + trace_exporter = CloudTraceSpanExporter(project_id=project_id) + tracer_provider.add_span_processor(BatchSpanProcessor(trace_exporter)) + trace.set_tracer_provider(tracer_provider) + # Retrieve a tracer from the global tracer provider. + tracer = tracer_provider.get_tracer('MyApp') + + # Setup the Cloud Spanner Client. + spanner_client = spanner.Client(project_id) + instance = spanner_client.instance('test-instance') + database = instance.database('test-db') + + # Now run our queries + with tracer.start_as_current_span('QueryInformationSchema'): + with database.snapshot() as snapshot: + with tracer.start_as_current_span('InformationSchema'): + info_schema = snapshot.execute_sql( + 'SELECT * FROM INFORMATION_SCHEMA.TABLES') + for row in info_schema: + print(row) + + with tracer.start_as_current_span('ServerTimeQuery'): + with database.snapshot() as snapshot: + # Purposefully issue a bad SQL statement to examine exceptions + # that get recorded and a ERROR span status. + try: + data = snapshot.execute_sql('SELECT CURRENT_TIMESTAMPx()') + for row in data: + print(row) + except Exception as e: + print(e) + + +if __name__ == '__main__': + main() diff --git a/google/cloud/spanner_v1/_opentelemetry_tracing.py b/google/cloud/spanner_v1/_opentelemetry_tracing.py index 8f9f8559ef..51501a07a3 100644 --- a/google/cloud/spanner_v1/_opentelemetry_tracing.py +++ b/google/cloud/spanner_v1/_opentelemetry_tracing.py @@ -16,17 +16,39 @@ from contextlib import contextmanager -from google.api_core.exceptions import GoogleAPICallError from google.cloud.spanner_v1 import SpannerClient +from google.cloud.spanner_v1 import gapic_version try: from opentelemetry import trace from opentelemetry.trace.status import Status, StatusCode + from opentelemetry.semconv.attributes.otel_attributes import ( + OTEL_SCOPE_NAME, + OTEL_SCOPE_VERSION, + ) HAS_OPENTELEMETRY_INSTALLED = True except ImportError: HAS_OPENTELEMETRY_INSTALLED = False +TRACER_NAME = "cloud.google.com/python/spanner" +TRACER_VERSION = gapic_version.__version__ + + +def get_tracer(tracer_provider=None): + """ + get_tracer is a utility to unify and simplify retrieval of the tracer, without + leaking implementation details given that retrieving a tracer requires providing + the full qualified library name and version. + When the tracer_provider is set, it'll retrieve the tracer from it, otherwise + it'll fall back to the global tracer provider and use this library's specific semantics. + """ + if not tracer_provider: + # Acquire the global tracer provider. + tracer_provider = trace.get_tracer_provider() + + return tracer_provider.get_tracer(TRACER_NAME, TRACER_VERSION) + @contextmanager def trace_call(name, session, extra_attributes=None): @@ -35,7 +57,7 @@ def trace_call(name, session, extra_attributes=None): yield None return - tracer = trace.get_tracer(__name__) + tracer = get_tracer() # Set base attributes that we know for every trace created attributes = { @@ -43,6 +65,8 @@ def trace_call(name, session, extra_attributes=None): "db.url": SpannerClient.DEFAULT_ENDPOINT, "db.instance": session._database.name, "net.host.name": SpannerClient.DEFAULT_ENDPOINT, + OTEL_SCOPE_NAME: TRACER_NAME, + OTEL_SCOPE_VERSION: TRACER_VERSION, } if extra_attributes: @@ -52,9 +76,10 @@ def trace_call(name, session, extra_attributes=None): name, kind=trace.SpanKind.CLIENT, attributes=attributes ) as span: try: - span.set_status(Status(StatusCode.OK)) yield span - except GoogleAPICallError as error: - span.set_status(Status(StatusCode.ERROR)) + except Exception as error: + span.set_status(Status(StatusCode.ERROR, str(error))) span.record_exception(error) raise + else: + span.set_status(Status(StatusCode.OK)) diff --git a/setup.py b/setup.py index 98b1a61748..544d117fd7 100644 --- a/setup.py +++ b/setup.py @@ -47,9 +47,9 @@ ] extras = { "tracing": [ - "opentelemetry-api >= 1.1.0", - "opentelemetry-sdk >= 1.1.0", - "opentelemetry-instrumentation >= 0.20b0, < 0.23dev", + "opentelemetry-api >= 1.22.0", + "opentelemetry-sdk >= 1.22.0", + "opentelemetry-semantic-conventions >= 0.43b0", ], "libcst": "libcst >= 0.2.5", } diff --git a/testing/constraints-3.7.txt b/testing/constraints-3.7.txt index 20170203f5..e468d57168 100644 --- a/testing/constraints-3.7.txt +++ b/testing/constraints-3.7.txt @@ -10,9 +10,9 @@ grpc-google-iam-v1==0.12.4 libcst==0.2.5 proto-plus==1.22.0 sqlparse==0.4.4 -opentelemetry-api==1.1.0 -opentelemetry-sdk==1.1.0 -opentelemetry-instrumentation==0.20b0 +opentelemetry-api==1.22.0 +opentelemetry-sdk==1.22.0 +opentelemetry-semantic-conventions==0.43b0 protobuf==3.20.2 deprecated==1.2.14 grpc-interceptor==0.15.4 diff --git a/tests/_helpers.py b/tests/_helpers.py index 42178fd439..5e514f2586 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -1,6 +1,10 @@ import unittest import mock +from google.cloud.spanner_v1 import gapic_version + +LIB_VERSION = gapic_version.__version__ + try: from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider @@ -8,6 +12,11 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( InMemorySpanExporter, ) + from opentelemetry.semconv.attributes.otel_attributes import ( + OTEL_SCOPE_NAME, + OTEL_SCOPE_VERSION, + ) + from opentelemetry.trace.status import StatusCode trace.set_tracer_provider(TracerProvider()) @@ -30,6 +39,18 @@ def get_test_ot_exporter(): return _TEST_OT_EXPORTER +def enrich_with_otel_scope(attrs): + """ + This helper enriches attrs with OTEL_SCOPE_NAME and OTEL_SCOPE_VERSION + for the purpose of avoiding cumbersome duplicated imports. + """ + if HAS_OPENTELEMETRY_INSTALLED: + attrs[OTEL_SCOPE_NAME] = "cloud.google.com/python/spanner" + attrs[OTEL_SCOPE_VERSION] = LIB_VERSION + + return attrs + + def use_test_ot_exporter(): global _TEST_OT_PROVIDER_INITIALIZED diff --git a/tests/system/test_session_api.py b/tests/system/test_session_api.py index d0421d3a70..5322527d12 100644 --- a/tests/system/test_session_api.py +++ b/tests/system/test_session_api.py @@ -346,6 +346,8 @@ def _make_attributes(db_instance, **kwargs): "net.host.name": "spanner.googleapis.com", "db.instance": db_instance, } + ot_helpers.enrich_with_otel_scope(attributes) + attributes.update(kwargs) return attributes diff --git a/tests/unit/test__opentelemetry_tracing.py b/tests/unit/test__opentelemetry_tracing.py index 25870227bf..20e31d9ea6 100644 --- a/tests/unit/test__opentelemetry_tracing.py +++ b/tests/unit/test__opentelemetry_tracing.py @@ -12,7 +12,11 @@ from google.api_core.exceptions import GoogleAPICallError from google.cloud.spanner_v1 import _opentelemetry_tracing -from tests._helpers import OpenTelemetryBase, HAS_OPENTELEMETRY_INSTALLED +from tests._helpers import ( + OpenTelemetryBase, + HAS_OPENTELEMETRY_INSTALLED, + enrich_with_otel_scope, +) def _make_rpc_error(error_cls, trailing_metadata=None): @@ -55,11 +59,13 @@ def test_trace_call(self): "db.instance": "database_name", } - expected_attributes = { - "db.type": "spanner", - "db.url": "spanner.googleapis.com", - "net.host.name": "spanner.googleapis.com", - } + expected_attributes = enrich_with_otel_scope( + { + "db.type": "spanner", + "db.url": "spanner.googleapis.com", + "net.host.name": "spanner.googleapis.com", + } + ) expected_attributes.update(extra_attributes) with _opentelemetry_tracing.trace_call( @@ -80,11 +86,13 @@ def test_trace_call(self): def test_trace_error(self): extra_attributes = {"db.instance": "database_name"} - expected_attributes = { - "db.type": "spanner", - "db.url": "spanner.googleapis.com", - "net.host.name": "spanner.googleapis.com", - } + expected_attributes = enrich_with_otel_scope( + { + "db.type": "spanner", + "db.url": "spanner.googleapis.com", + "net.host.name": "spanner.googleapis.com", + } + ) expected_attributes.update(extra_attributes) with self.assertRaises(GoogleAPICallError): @@ -106,11 +114,13 @@ def test_trace_error(self): def test_trace_grpc_error(self): extra_attributes = {"db.instance": "database_name"} - expected_attributes = { - "db.type": "spanner", - "db.url": "spanner.googleapis.com:443", - "net.host.name": "spanner.googleapis.com:443", - } + expected_attributes = enrich_with_otel_scope( + { + "db.type": "spanner", + "db.url": "spanner.googleapis.com:443", + "net.host.name": "spanner.googleapis.com:443", + } + ) expected_attributes.update(extra_attributes) with self.assertRaises(GoogleAPICallError): @@ -129,11 +139,13 @@ def test_trace_grpc_error(self): def test_trace_codeless_error(self): extra_attributes = {"db.instance": "database_name"} - expected_attributes = { - "db.type": "spanner", - "db.url": "spanner.googleapis.com:443", - "net.host.name": "spanner.googleapis.com:443", - } + expected_attributes = enrich_with_otel_scope( + { + "db.type": "spanner", + "db.url": "spanner.googleapis.com:443", + "net.host.name": "spanner.googleapis.com:443", + } + ) expected_attributes.update(extra_attributes) with self.assertRaises(GoogleAPICallError): diff --git a/tests/unit/test_batch.py b/tests/unit/test_batch.py index ee96decf5e..2f6b5e4ae9 100644 --- a/tests/unit/test_batch.py +++ b/tests/unit/test_batch.py @@ -14,7 +14,11 @@ import unittest -from tests._helpers import OpenTelemetryBase, StatusCode +from tests._helpers import ( + OpenTelemetryBase, + StatusCode, + enrich_with_otel_scope, +) from google.cloud.spanner_v1 import RequestOptions TABLE_NAME = "citizens" @@ -29,6 +33,7 @@ "db.instance": "testing", "net.host.name": "spanner.googleapis.com", } +enrich_with_otel_scope(BASE_ATTRIBUTES) class _BaseTest(unittest.TestCase): diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index d4052f0ae3..2ae0cb94b8 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -19,7 +19,7 @@ from tests._helpers import ( OpenTelemetryBase, StatusCode, - HAS_OPENTELEMETRY_INSTALLED, + enrich_with_otel_scope, ) @@ -31,11 +31,6 @@ def _make_rpc_error(error_cls, trailing_metadata=None): return error_cls("error", errors=(grpc_error,)) -class _ConstantTime: - def time(self): - return 1 - - class TestSession(OpenTelemetryBase): PROJECT_ID = "project-id" INSTANCE_ID = "instance-id" @@ -51,6 +46,7 @@ class TestSession(OpenTelemetryBase): "db.instance": DATABASE_NAME, "net.host.name": "spanner.googleapis.com", } + enrich_with_otel_scope(BASE_ATTRIBUTES) def _getTargetClass(self): from google.cloud.spanner_v1.session import Session @@ -1337,17 +1333,9 @@ def _time(_results=[1, 1.5]): return _results.pop(0) with mock.patch("time.time", _time): - if HAS_OPENTELEMETRY_INSTALLED: - with mock.patch("opentelemetry.util._time", _ConstantTime()): - with mock.patch("time.sleep") as sleep_mock: - with self.assertRaises(Aborted): - session.run_in_transaction( - unit_of_work, "abc", timeout_secs=1 - ) - else: - with mock.patch("time.sleep") as sleep_mock: - with self.assertRaises(Aborted): - session.run_in_transaction(unit_of_work, "abc", timeout_secs=1) + with mock.patch("time.sleep") as sleep_mock: + with self.assertRaises(Aborted): + session.run_in_transaction(unit_of_work, "abc", timeout_secs=1) sleep_mock.assert_not_called() @@ -1418,15 +1406,9 @@ def _time(_results=[1, 2, 4, 8]): return _results.pop(0) with mock.patch("time.time", _time): - if HAS_OPENTELEMETRY_INSTALLED: - with mock.patch("opentelemetry.util._time", _ConstantTime()): - with mock.patch("time.sleep") as sleep_mock: - with self.assertRaises(Aborted): - session.run_in_transaction(unit_of_work, timeout_secs=8) - else: - with mock.patch("time.sleep") as sleep_mock: - with self.assertRaises(Aborted): - session.run_in_transaction(unit_of_work, timeout_secs=8) + with mock.patch("time.sleep") as sleep_mock: + with self.assertRaises(Aborted): + session.run_in_transaction(unit_of_work, timeout_secs=8) # unpacking call args into list call_args = [call_[0][0] for call_ in sleep_mock.call_args_list] diff --git a/tests/unit/test_snapshot.py b/tests/unit/test_snapshot.py index bf5563dcfd..bf7363fef2 100644 --- a/tests/unit/test_snapshot.py +++ b/tests/unit/test_snapshot.py @@ -21,6 +21,7 @@ OpenTelemetryBase, StatusCode, HAS_OPENTELEMETRY_INSTALLED, + enrich_with_otel_scope, ) from google.cloud.spanner_v1.param_types import INT64 from google.api_core.retry import Retry @@ -46,6 +47,8 @@ "db.instance": "testing", "net.host.name": "spanner.googleapis.com", } +enrich_with_otel_scope(BASE_ATTRIBUTES) + DIRECTED_READ_OPTIONS = { "include_replicas": { "replica_selections": [ @@ -530,12 +533,14 @@ def test_iteration_w_multiple_span_creation(self): self.assertEqual(span.name, name) self.assertEqual( dict(span.attributes), - { - "db.type": "spanner", - "db.url": "spanner.googleapis.com", - "db.instance": "testing", - "net.host.name": "spanner.googleapis.com", - }, + enrich_with_otel_scope( + { + "db.type": "spanner", + "db.url": "spanner.googleapis.com", + "db.instance": "testing", + "net.host.name": "spanner.googleapis.com", + } + ), ) diff --git a/tests/unit/test_transaction.py b/tests/unit/test_transaction.py index b40ae8843f..d52fb61db1 100644 --- a/tests/unit/test_transaction.py +++ b/tests/unit/test_transaction.py @@ -21,7 +21,11 @@ from google.api_core.retry import Retry from google.api_core import gapic_v1 -from tests._helpers import OpenTelemetryBase, StatusCode +from tests._helpers import ( + OpenTelemetryBase, + StatusCode, + enrich_with_otel_scope, +) TABLE_NAME = "citizens" COLUMNS = ["email", "first_name", "last_name", "age"] @@ -58,6 +62,7 @@ class TestTransaction(OpenTelemetryBase): "db.instance": "testing", "net.host.name": "spanner.googleapis.com", } + enrich_with_otel_scope(BASE_ATTRIBUTES) def _getTargetClass(self): from google.cloud.spanner_v1.transaction import Transaction From b2e67626739c05e8dc2eb81e522ff4931f9fac45 Mon Sep 17 00:00:00 2001 From: Ketan Verma <9292653+ketanv3@users.noreply.github.com> Date: Fri, 20 Sep 2024 10:20:55 +0530 Subject: [PATCH 6/6] chore(samples): Add samples for Cloud Spanner Scheduled Backups (#1204) --- samples/samples/backup_schedule_samples.py | 303 ++++++++++++++++++ .../samples/backup_schedule_samples_test.py | 159 +++++++++ 2 files changed, 462 insertions(+) create mode 100644 samples/samples/backup_schedule_samples.py create mode 100644 samples/samples/backup_schedule_samples_test.py diff --git a/samples/samples/backup_schedule_samples.py b/samples/samples/backup_schedule_samples.py new file mode 100644 index 0000000000..621febf0fc --- /dev/null +++ b/samples/samples/backup_schedule_samples.py @@ -0,0 +1,303 @@ +# Copyright 2024 Google Inc. 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. + +""" +This application demonstrates how to create and manage backup schedules using +Cloud Spanner. +""" + +import argparse + +from enum import Enum + + +# [START spanner_create_full_backup_schedule] +def create_full_backup_schedule( + instance_id: str, + database_id: str, + schedule_id: str, +) -> None: + from datetime import timedelta + from google.cloud import spanner + from google.cloud.spanner_admin_database_v1.types import \ + backup_schedule as backup_schedule_pb + from google.cloud.spanner_admin_database_v1.types import \ + CreateBackupEncryptionConfig, FullBackupSpec + + client = spanner.Client() + database_admin_api = client.database_admin_api + + request = backup_schedule_pb.CreateBackupScheduleRequest( + parent=database_admin_api.database_path( + client.project, + instance_id, + database_id + ), + backup_schedule_id=schedule_id, + backup_schedule=backup_schedule_pb.BackupSchedule( + spec=backup_schedule_pb.BackupScheduleSpec( + cron_spec=backup_schedule_pb.CrontabSpec( + text="30 12 * * *", + ), + ), + retention_duration=timedelta(hours=24), + encryption_config=CreateBackupEncryptionConfig( + encryption_type=CreateBackupEncryptionConfig.EncryptionType.USE_DATABASE_ENCRYPTION, + ), + full_backup_spec=FullBackupSpec(), + ), + ) + + response = database_admin_api.create_backup_schedule(request) + print(f"Created full backup schedule: {response}") + +# [END spanner_create_full_backup_schedule] + + +# [START spanner_create_incremental_backup_schedule] +def create_incremental_backup_schedule( + instance_id: str, + database_id: str, + schedule_id: str, +) -> None: + from datetime import timedelta + from google.cloud import spanner + from google.cloud.spanner_admin_database_v1.types import \ + backup_schedule as backup_schedule_pb + from google.cloud.spanner_admin_database_v1.types import \ + CreateBackupEncryptionConfig, IncrementalBackupSpec + + client = spanner.Client() + database_admin_api = client.database_admin_api + + request = backup_schedule_pb.CreateBackupScheduleRequest( + parent=database_admin_api.database_path( + client.project, + instance_id, + database_id + ), + backup_schedule_id=schedule_id, + backup_schedule=backup_schedule_pb.BackupSchedule( + spec=backup_schedule_pb.BackupScheduleSpec( + cron_spec=backup_schedule_pb.CrontabSpec( + text="30 12 * * *", + ), + ), + retention_duration=timedelta(hours=24), + encryption_config=CreateBackupEncryptionConfig( + encryption_type=CreateBackupEncryptionConfig.EncryptionType.GOOGLE_DEFAULT_ENCRYPTION, + ), + incremental_backup_spec=IncrementalBackupSpec(), + ), + ) + + response = database_admin_api.create_backup_schedule(request) + print(f"Created incremental backup schedule: {response}") + +# [END spanner_create_incremental_backup_schedule] + + +# [START spanner_list_backup_schedules] +def list_backup_schedules(instance_id: str, database_id: str) -> None: + from google.cloud import spanner + from google.cloud.spanner_admin_database_v1.types import \ + backup_schedule as backup_schedule_pb + + client = spanner.Client() + database_admin_api = client.database_admin_api + + request = backup_schedule_pb.ListBackupSchedulesRequest( + parent=database_admin_api.database_path( + client.project, + instance_id, + database_id, + ), + ) + + for backup_schedule in database_admin_api.list_backup_schedules(request): + print(f"Backup schedule: {backup_schedule}") + +# [END spanner_list_backup_schedules] + + +# [START spanner_get_backup_schedule] +def get_backup_schedule( + instance_id: str, + database_id: str, + schedule_id: str, +) -> None: + from google.cloud import spanner + from google.cloud.spanner_admin_database_v1.types import \ + backup_schedule as backup_schedule_pb + + client = spanner.Client() + database_admin_api = client.database_admin_api + + request = backup_schedule_pb.GetBackupScheduleRequest( + name=database_admin_api.backup_schedule_path( + client.project, + instance_id, + database_id, + schedule_id, + ), + ) + + response = database_admin_api.get_backup_schedule(request) + print(f"Backup schedule: {response}") + +# [END spanner_get_backup_schedule] + + +# [START spanner_update_backup_schedule] +def update_backup_schedule( + instance_id: str, + database_id: str, + schedule_id: str, +) -> None: + from datetime import timedelta + from google.cloud import spanner + from google.cloud.spanner_admin_database_v1.types import \ + backup_schedule as backup_schedule_pb + from google.cloud.spanner_admin_database_v1.types import \ + CreateBackupEncryptionConfig + from google.protobuf.field_mask_pb2 import FieldMask + + client = spanner.Client() + database_admin_api = client.database_admin_api + + request = backup_schedule_pb.UpdateBackupScheduleRequest( + backup_schedule=backup_schedule_pb.BackupSchedule( + name=database_admin_api.backup_schedule_path( + client.project, + instance_id, + database_id, + schedule_id, + ), + spec=backup_schedule_pb.BackupScheduleSpec( + cron_spec=backup_schedule_pb.CrontabSpec( + text="45 15 * * *", + ), + ), + retention_duration=timedelta(hours=48), + encryption_config=CreateBackupEncryptionConfig( + encryption_type=CreateBackupEncryptionConfig.EncryptionType.USE_DATABASE_ENCRYPTION, + ), + ), + update_mask=FieldMask( + paths=[ + "spec.cron_spec.text", + "retention_duration", + "encryption_config", + ], + ), + ) + + response = database_admin_api.update_backup_schedule(request) + print(f"Updated backup schedule: {response}") + +# [END spanner_update_backup_schedule] + + +# [START spanner_delete_backup_schedule] +def delete_backup_schedule( + instance_id: str, + database_id: str, + schedule_id: str, +) -> None: + from google.cloud import spanner + from google.cloud.spanner_admin_database_v1.types import \ + backup_schedule as backup_schedule_pb + + client = spanner.Client() + database_admin_api = client.database_admin_api + + request = backup_schedule_pb.DeleteBackupScheduleRequest( + name=database_admin_api.backup_schedule_path( + client.project, + instance_id, + database_id, + schedule_id, + ), + ) + + database_admin_api.delete_backup_schedule(request) + print("Deleted backup schedule") + +# [END spanner_delete_backup_schedule] + + +class Command(Enum): + CREATE_FULL_BACKUP_SCHEDULE = "create-full-backup-schedule" + CREATE_INCREMENTAL_BACKUP_SCHEDULE = "create-incremental-backup-schedule" + LIST_BACKUP_SCHEDULES = "list-backup-schedules" + GET_BACKUP_SCHEDULE = "get-backup-schedule" + UPDATE_BACKUP_SCHEDULE = "update-backup-schedule" + DELETE_BACKUP_SCHEDULE = "delete-backup-schedule" + + def __str__(self): + return self.value + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--instance-id", required=True) + parser.add_argument("--database-id", required=True) + parser.add_argument("--schedule-id", required=False) + parser.add_argument( + "command", + type=Command, + choices=list(Command), + ) + args = parser.parse_args() + + if args.command == Command.CREATE_FULL_BACKUP_SCHEDULE: + create_full_backup_schedule( + args.instance_id, + args.database_id, + args.schedule_id, + ) + elif args.command == Command.CREATE_INCREMENTAL_BACKUP_SCHEDULE: + create_incremental_backup_schedule( + args.instance_id, + args.database_id, + args.schedule_id, + ) + elif args.command == Command.LIST_BACKUP_SCHEDULES: + list_backup_schedules( + args.instance_id, + args.database_id, + ) + elif args.command == Command.GET_BACKUP_SCHEDULE: + get_backup_schedule( + args.instance_id, + args.database_id, + args.schedule_id, + ) + elif args.command == Command.UPDATE_BACKUP_SCHEDULE: + update_backup_schedule( + args.instance_id, + args.database_id, + args.schedule_id, + ) + elif args.command == Command.DELETE_BACKUP_SCHEDULE: + delete_backup_schedule( + args.instance_id, + args.database_id, + args.schedule_id, + ) + else: + print(f"Unknown command: {args.command}") diff --git a/samples/samples/backup_schedule_samples_test.py b/samples/samples/backup_schedule_samples_test.py new file mode 100644 index 0000000000..eb4be96b43 --- /dev/null +++ b/samples/samples/backup_schedule_samples_test.py @@ -0,0 +1,159 @@ +# Copyright 2024 Google Inc. 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. + +import backup_schedule_samples as samples +import pytest +import uuid + + +__FULL_BACKUP_SCHEDULE_ID = "full-backup-schedule" +__INCREMENTAL_BACKUP_SCHEDULE_ID = "incremental-backup-schedule" + + +@pytest.fixture(scope="module") +def sample_name(): + return "backup_schedule" + + +@pytest.fixture(scope="module") +def database_id(): + return f"test-db-{uuid.uuid4().hex[:10]}" + + +@pytest.mark.dependency(name="create_full_backup_schedule") +def test_create_full_backup_schedule( + capsys, + sample_instance, + sample_database, +) -> None: + samples.create_full_backup_schedule( + sample_instance.instance_id, + sample_database.database_id, + __FULL_BACKUP_SCHEDULE_ID, + ) + out, _ = capsys.readouterr() + assert "Created full backup schedule" in out + assert ( + f"/instances/{sample_instance.instance_id}" + f"/databases/{sample_database.database_id}" + f"/backupSchedules/{__FULL_BACKUP_SCHEDULE_ID}" + ) in out + + +@pytest.mark.dependency(name="create_incremental_backup_schedule") +def test_create_incremental_backup_schedule( + capsys, + sample_instance, + sample_database, +) -> None: + samples.create_incremental_backup_schedule( + sample_instance.instance_id, + sample_database.database_id, + __INCREMENTAL_BACKUP_SCHEDULE_ID, + ) + out, _ = capsys.readouterr() + assert "Created incremental backup schedule" in out + assert ( + f"/instances/{sample_instance.instance_id}" + f"/databases/{sample_database.database_id}" + f"/backupSchedules/{__INCREMENTAL_BACKUP_SCHEDULE_ID}" + ) in out + + +@pytest.mark.dependency(depends=[ + "create_full_backup_schedule", + "create_incremental_backup_schedule", +]) +def test_list_backup_schedules( + capsys, + sample_instance, + sample_database, +) -> None: + samples.list_backup_schedules( + sample_instance.instance_id, + sample_database.database_id, + ) + out, _ = capsys.readouterr() + assert ( + f"/instances/{sample_instance.instance_id}" + f"/databases/{sample_database.database_id}" + f"/backupSchedules/{__FULL_BACKUP_SCHEDULE_ID}" + ) in out + assert ( + f"/instances/{sample_instance.instance_id}" + f"/databases/{sample_database.database_id}" + f"/backupSchedules/{__INCREMENTAL_BACKUP_SCHEDULE_ID}" + ) in out + + +@pytest.mark.dependency(depends=["create_full_backup_schedule"]) +def test_get_backup_schedule( + capsys, + sample_instance, + sample_database, +) -> None: + samples.get_backup_schedule( + sample_instance.instance_id, + sample_database.database_id, + __FULL_BACKUP_SCHEDULE_ID, + ) + out, _ = capsys.readouterr() + assert ( + f"/instances/{sample_instance.instance_id}" + f"/databases/{sample_database.database_id}" + f"/backupSchedules/{__FULL_BACKUP_SCHEDULE_ID}" + ) in out + + +@pytest.mark.dependency(depends=["create_full_backup_schedule"]) +def test_update_backup_schedule( + capsys, + sample_instance, + sample_database, +) -> None: + samples.update_backup_schedule( + sample_instance.instance_id, + sample_database.database_id, + __FULL_BACKUP_SCHEDULE_ID, + ) + out, _ = capsys.readouterr() + assert "Updated backup schedule" in out + assert ( + f"/instances/{sample_instance.instance_id}" + f"/databases/{sample_database.database_id}" + f"/backupSchedules/{__FULL_BACKUP_SCHEDULE_ID}" + ) in out + + +@pytest.mark.dependency(depends=[ + "create_full_backup_schedule", + "create_incremental_backup_schedule", +]) +def test_delete_backup_schedule( + capsys, + sample_instance, + sample_database, +) -> None: + samples.delete_backup_schedule( + sample_instance.instance_id, + sample_database.database_id, + __FULL_BACKUP_SCHEDULE_ID, + ) + samples.delete_backup_schedule( + sample_instance.instance_id, + sample_database.database_id, + __INCREMENTAL_BACKUP_SCHEDULE_ID, + ) + out, _ = capsys.readouterr() + assert "Deleted backup schedule" in out