From df919bedb48b6ee4c1942384d614abe9ea19e4df Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Tue, 6 Apr 2021 15:41:48 +0530 Subject: [PATCH 1/8] fix: lint_setup_py was failing in Kokoro is not fixed --- README.rst | 3 +- code-of-conduct.md | 63 --------------------------------- django_spanner/functions.py | 1 + django_spanner/introspection.py | 1 + django_spanner/operations.py | 5 ++- django_spanner/schema.py | 1 + docs/conf.py | 3 -- noxfile.py | 20 ++++++----- 8 files changed, 20 insertions(+), 77 deletions(-) delete mode 100644 code-of-conduct.md diff --git a/README.rst b/README.rst index 91be439d9e..8f1ef0440d 100644 --- a/README.rst +++ b/README.rst @@ -134,8 +134,7 @@ Contributing Contributions to this library are always welcome and highly encouraged. -See `CONTRIBUTING `_ for more information on how to get -started. +See [CONTRIBUTING][contributing] for more information on how to get started. Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms. See the `Code diff --git a/code-of-conduct.md b/code-of-conduct.md deleted file mode 100644 index b24eed38ad..0000000000 --- a/code-of-conduct.md +++ /dev/null @@ -1,63 +0,0 @@ -# Google Open Source Community Guidelines - -At Google, we recognize and celebrate the creativity and collaboration of open -source contributors and the diversity of skills, experiences, cultures, and -opinions they bring to the projects and communities they participate in. - -Every one of Google's open source projects and communities are inclusive -environments, based on treating all individuals respectfully, regardless of -gender identity and expression, sexual orientation, disabilities, -neurodiversity, physical appearance, body size, ethnicity, nationality, race, -age, religion, or similar personal characteristic. - -We value diverse opinions, but we value respectful behavior more. - -Respectful behavior includes: - -* Being considerate, kind, constructive, and helpful. -* Not engaging in demeaning, discriminatory, harassing, hateful, sexualized, or - physically threatening behavior, speech, and imagery. -* Not engaging in unwanted physical contact. - -Some Google open source projects [may adopt][] an explicit project code of -conduct, which may have additional detailed expectations for participants. Most -of those projects will use our [modified Contributor Covenant][]. - -[may adopt]: https://opensource.google/docs/releasing/preparing/#conduct -[modified Contributor Covenant]: https://opensource.google/docs/releasing/template/CODE_OF_CONDUCT/ - -## Resolve peacefully - -We do not believe that all conflict is necessarily bad; healthy debate and -disagreement often yields positive results. However, it is never okay to be -disrespectful. - -If you see someone behaving disrespectfully, you are encouraged to address the -behavior directly with those involved. Many issues can be resolved quickly and -easily, and this gives people more control over the outcome of their dispute. -If you are unable to resolve the matter for any reason, or if the behavior is -threatening or harassing, report it. We are dedicated to providing an -environment where participants feel welcome and safe. - -## Reporting problems - -Some Google open source projects may adopt a project-specific code of conduct. -In those cases, a Google employee will be identified as the Project Steward, -who will receive and handle reports of code of conduct violations. In the event -that a project hasn’t identified a Project Steward, you can report problems by -emailing opensource@google.com. - -We will investigate every complaint, but you may not receive a direct response. -We will use our discretion in determining when and how to follow up on reported -incidents, which may range from not taking action to permanent expulsion from -the project and project-sponsored spaces. We will notify the accused of the -report and provide them an opportunity to discuss it before any action is -taken. The identity of the reporter will be omitted from the details of the -report supplied to the accused. In potentially harmful situations, such as -ongoing harassment or threats to anyone's safety, we may take action without -notice. - -*This document was adapted from the [IndieWeb Code of Conduct][] and can also -be found at .* - -[IndieWeb Code of Conduct]: https://indieweb.org/code-of-conduct \ No newline at end of file diff --git a/django_spanner/functions.py b/django_spanner/functions.py index bc02d0b5d8..3cf3ec73b9 100644 --- a/django_spanner/functions.py +++ b/django_spanner/functions.py @@ -28,6 +28,7 @@ class IfNull(Func): """Represent SQL `IFNULL` function.""" + function = "IFNULL" arity = 2 diff --git a/django_spanner/introspection.py b/django_spanner/introspection.py index 2dd7341972..9cefd0687f 100644 --- a/django_spanner/introspection.py +++ b/django_spanner/introspection.py @@ -15,6 +15,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): """A Spanner-specific version of Django introspection utilities.""" + data_types_reverse = { TypeCode.BOOL: "BooleanField", TypeCode.BYTES: "BinaryField", diff --git a/django_spanner/operations.py b/django_spanner/operations.py index 6ce0260c81..e3ff7471ec 100644 --- a/django_spanner/operations.py +++ b/django_spanner/operations.py @@ -25,6 +25,7 @@ class DatabaseOperations(BaseDatabaseOperations): """A Spanner-specific version of Django database operations.""" + cast_data_types = {"CharField": "STRING", "TextField": "STRING"} cast_char_field_without_max_length = "STRING" compiler_module = "django_spanner.compiler" @@ -108,7 +109,9 @@ def bulk_insert_sql(self, fields, placeholder_rows): values_sql = ", ".join("(%s)" % sql for sql in placeholder_rows_sql) return "VALUES " + values_sql - def sql_flush(self, style, tables, reset_sequences=False, allow_cascade=False): + def sql_flush( + self, style, tables, reset_sequences=False, allow_cascade=False + ): """ Override the base class method. Returns a list of SQL statements required to remove all data from the given database tables (without diff --git a/django_spanner/schema.py b/django_spanner/schema.py index b6c859c466..6d71f31673 100644 --- a/django_spanner/schema.py +++ b/django_spanner/schema.py @@ -13,6 +13,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): The database abstraction layer that turns things like “create a model” or “delete a field” into SQL. """ + sql_create_table = ( "CREATE TABLE %(table)s (%(definition)s) PRIMARY KEY(%(primary_key)s)" ) diff --git a/docs/conf.py b/docs/conf.py index d26c0698e6..1cffc0625d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -100,9 +100,6 @@ # directories to ignore when looking for source files. exclude_patterns = [ "_build", - "samples/AUTHORING_GUIDE.md", - "samples/CONTRIBUTING.md", - "samples/snippets/README.rst", ] # The reST default role (used for this markup: `text`) to use for all diff --git a/noxfile.py b/noxfile.py index 2c1edbe573..7bea0b8dda 100644 --- a/noxfile.py +++ b/noxfile.py @@ -17,13 +17,18 @@ BLACK_VERSION = "black==19.10b0" BLACK_PATHS = [ "docs", + "django_spanner", "tests", "noxfile.py", "setup.py", ] +DEFAULT_PYTHON_VERSION = "3.8" +SYSTEM_TEST_PYTHON_VERSIONS = ["3.8"] +UNIT_TEST_PYTHON_VERSIONS = ["3.6", "3.7", "3.8"] -@nox.session(python="3.8") + +@nox.session(python=DEFAULT_PYTHON_VERSION) def lint(session): """Run linters. @@ -35,7 +40,7 @@ def lint(session): session.run("flake8", "django_spanner", "tests") -@nox.session(python="3.8") +@nox.session(python="3.6") def blacken(session): """Run black. @@ -49,7 +54,7 @@ def blacken(session): session.run("black", *BLACK_PATHS) -@nox.session(python="3.8") +@nox.session(python=DEFAULT_PYTHON_VERSION) def lint_setup_py(session): """Verify that setup.py is valid (including RST check).""" session.install("docutils", "pygments") @@ -70,23 +75,22 @@ def default(session): "py.test", "--quiet", "--cov=django_spanner", - "--cov=google.cloud", "--cov=tests.unit", "--cov-append", "--cov-config=.coveragerc", "--cov-report=", - "--cov-fail-under=60", + "--cov-fail-under=20", os.path.join("tests", "unit"), *session.posargs ) -@nox.session(python="3.8") +@nox.session(python=DEFAULT_PYTHON_VERSION) def docs(session): """Build the docs for this library.""" - session.install("-e", ".") - session.install("sphinx<3.0.0", "alabaster", "recommonmark") + session.install("-e", ".[tracing]") + session.install("sphinx", "alabaster", "recommonmark") shutil.rmtree(os.path.join("docs", "_build"), ignore_errors=True) session.run( From 5af63f50c6240cba313a5b4e49c34ee718ca193e Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Wed, 21 Apr 2021 19:48:40 +0530 Subject: [PATCH 2/8] feat: adding opentelemetry tracing --- django_spanner/_opentelemetry_tracing.py | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 django_spanner/_opentelemetry_tracing.py diff --git a/django_spanner/_opentelemetry_tracing.py b/django_spanner/_opentelemetry_tracing.py new file mode 100644 index 0000000000..9a155d8d07 --- /dev/null +++ b/django_spanner/_opentelemetry_tracing.py @@ -0,0 +1,63 @@ +# Copyright 2020 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +"""Manages OpenTelemetry trace creation and handling""" + +from contextlib import contextmanager + +from google.api_core.exceptions import GoogleAPICallError +from google.cloud.spanner_v1 import SpannerClient + +try: + from opentelemetry import trace + from opentelemetry.trace.status import Status, StatusCanonicalCode + from opentelemetry.instrumentation.utils import ( + http_status_to_canonical_code, + ) + + HAS_OPENTELEMETRY_INSTALLED = True +except ImportError: + HAS_OPENTELEMETRY_INSTALLED = False + + +@contextmanager +def trace_call(name, session, extra_attributes=None): + if not HAS_OPENTELEMETRY_INSTALLED or not session: + # Empty context manager. Users will have to check if the generated value is None or a span + yield None + return + + tracer = trace.get_tracer(__name__) + + # Set base attributes that we know for every trace created + attributes = { + "db.type": "spanner", + "db.url": SpannerClient.DEFAULT_ENDPOINT, + "db.instance": session._database.name, + "net.host.name": SpannerClient.DEFAULT_ENDPOINT, + } + + if extra_attributes: + attributes.update(extra_attributes) + + with tracer.start_as_current_span( + name, kind=trace.SpanKind.CLIENT, attributes=attributes + ) as span: + try: + yield span + except GoogleAPICallError as error: + if error.code is not None: + span.set_status( + Status(http_status_to_canonical_code(error.code)) + ) + elif error.grpc_status_code is not None: + span.set_status( + # OpenTelemetry's StatusCanonicalCode maps 1-1 with grpc status codes + Status( + StatusCanonicalCode(error.grpc_status_code.value[0]) + ) + ) + raise From c2f3be7e9d25937e8352bee599822fa9a958ed17 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Fri, 23 Apr 2021 23:26:20 +0530 Subject: [PATCH 3/8] feat: added opentelemetry support --- .gitignore | 3 ++- django_spanner/_opentelemetry_tracing.py | 6 +++--- django_spanner/schema.py | 11 ++++++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index efe8469b33..4a39372126 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ bin MANIFEST django_tests __pycache__ - +# The directory into which Django has been cloned to run the test suite. +django_tests_dir # Unit test / coverage reports .coverage .nox diff --git a/django_spanner/_opentelemetry_tracing.py b/django_spanner/_opentelemetry_tracing.py index 9a155d8d07..909211814c 100644 --- a/django_spanner/_opentelemetry_tracing.py +++ b/django_spanner/_opentelemetry_tracing.py @@ -24,8 +24,8 @@ @contextmanager -def trace_call(name, session, extra_attributes=None): - if not HAS_OPENTELEMETRY_INSTALLED or not session: +def trace_call(name, connection, extra_attributes=None): + if not HAS_OPENTELEMETRY_INSTALLED or not connection: # Empty context manager. Users will have to check if the generated value is None or a span yield None return @@ -36,7 +36,7 @@ def trace_call(name, session, extra_attributes=None): attributes = { "db.type": "spanner", "db.url": SpannerClient.DEFAULT_ENDPOINT, - "db.instance": session._database.name, + "db.instance": connection.get_connection_params()["db"], "net.host.name": SpannerClient.DEFAULT_ENDPOINT, } diff --git a/django_spanner/schema.py b/django_spanner/schema.py index 6d71f31673..1a271acfa0 100644 --- a/django_spanner/schema.py +++ b/django_spanner/schema.py @@ -6,6 +6,7 @@ from django.db import NotSupportedError from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django_spanner._opentelemetry_tracing import trace_call class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): @@ -119,7 +120,15 @@ def create_model(self, model): sql += " " + tablespace_sql # Prevent using [] as params, in the case a literal '%' is used in the # definition - self.execute(sql, params or None) + trace_attributes = { + "model_name": self.quote_name(model._meta.db_table) + } + with trace_call( + "CloudSpannerDjango.create_model", + self.connection, + trace_attributes, + ): + self.execute(sql, params or None) # Add any field index and index_together's (deferred as SQLite # _remake_table needs it) From 01a781cc90370aa9a6b1bc38f0145099f2b1b184 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Thu, 10 Jun 2021 13:06:36 +0530 Subject: [PATCH 4/8] feat: added open telemetry tracing support and tests --- django_spanner/_opentelemetry_tracing.py | 30 +- django_spanner/schema.py | 108 +++++-- setup.py | 8 +- testing/constraints-3.6.txt | 5 +- tests/_helpers.py | 73 +++++ tests/unit/django_spanner/simple_test.py | 7 +- .../test__opentelemetry_tracing.py | 134 ++++++++ tests/unit/django_spanner/test_schema.py | 286 +++++++++++++++++- 8 files changed, 608 insertions(+), 43 deletions(-) create mode 100644 tests/_helpers.py create mode 100644 tests/unit/django_spanner/test__opentelemetry_tracing.py diff --git a/django_spanner/_opentelemetry_tracing.py b/django_spanner/_opentelemetry_tracing.py index 909211814c..b8b05047c7 100644 --- a/django_spanner/_opentelemetry_tracing.py +++ b/django_spanner/_opentelemetry_tracing.py @@ -1,4 +1,4 @@ -# Copyright 2020 Google LLC +# Copyright 2021 Google LLC # # Use of this source code is governed by a BSD-style # license that can be found in the LICENSE file or at @@ -9,14 +9,10 @@ from contextlib import contextmanager from google.api_core.exceptions import GoogleAPICallError -from google.cloud.spanner_v1 import SpannerClient try: from opentelemetry import trace - from opentelemetry.trace.status import Status, StatusCanonicalCode - from opentelemetry.instrumentation.utils import ( - http_status_to_canonical_code, - ) + from opentelemetry.trace.status import Status, StatusCode HAS_OPENTELEMETRY_INSTALLED = True except ImportError: @@ -35,9 +31,11 @@ def trace_call(name, connection, extra_attributes=None): # Set base attributes that we know for every trace created attributes = { "db.type": "spanner", - "db.url": SpannerClient.DEFAULT_ENDPOINT, - "db.instance": connection.get_connection_params()["db"], - "net.host.name": SpannerClient.DEFAULT_ENDPOINT, + "db.engine": "django_spanner", + "db.project": connection.settings_dict["PROJECT"], + "db.instance": connection.settings_dict["INSTANCE"], + "db.name": connection.settings_dict["NAME"], + "db.user_agent": connection.settings_dict["user_agent"], } if extra_attributes: @@ -47,17 +45,9 @@ def trace_call(name, connection, 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: - if error.code is not None: - span.set_status( - Status(http_status_to_canonical_code(error.code)) - ) - elif error.grpc_status_code is not None: - span.set_status( - # OpenTelemetry's StatusCanonicalCode maps 1-1 with grpc status codes - Status( - StatusCanonicalCode(error.grpc_status_code.value[0]) - ) - ) + span.set_status(Status(StatusCode.ERROR)) + span.record_exception(error) raise diff --git a/django_spanner/schema.py b/django_spanner/schema.py index 1a271acfa0..d9f5f8b81d 100644 --- a/django_spanner/schema.py +++ b/django_spanner/schema.py @@ -153,8 +153,25 @@ def delete_model(self, model): model, index=True, primary_key=False ) for index_name in index_names: - self.execute(self._delete_index_sql(model, index_name)) - super().delete_model(model) + trace_attributes = { + "model_name": self.quote_name(model._meta.db_table), + "index_name": index_name, + } + with trace_call( + "CloudSpannerDjango.delete_model.delete_index", + self.connection, + trace_attributes, + ): + self.execute(self._delete_index_sql(model, index_name)) + trace_attributes = { + "model_name": self.quote_name(model._meta.db_table) + } + with trace_call( + "CloudSpannerDjango.delete_model", + self.connection, + trace_attributes, + ): + super().delete_model(model) def add_field(self, model, field): """ @@ -259,8 +276,28 @@ def remove_field(self, model, field): # column. index_names = self._constraint_names(model, [field.column], index=True) for index_name in index_names: - self.execute(self._delete_index_sql(model, index_name)) - super().remove_field(model, field) + trace_attributes = { + "model_name": self.quote_name(model._meta.db_table), + "field": field.column, + "index_name": index_name, + } + with trace_call( + "CloudSpannerDjango.remove_field.delete_index", + self.connection, + trace_attributes, + ): + self.execute(self._delete_index_sql(model, index_name)) + + trace_attributes = { + "model_name": self.quote_name(model._meta.db_table), + "field": field.column, + } + with trace_call( + "CloudSpannerDjango.remove_field", + self.connection, + trace_attributes, + ): + super().remove_field(model, field) def column_sql( self, model, field, include_default=False, exclude_not_null=False @@ -329,7 +366,16 @@ def add_index(self, model, index): (field_name, " DESC" if order == "DESC" else "") for field_name, order in index.fields_orders ] - super().add_index(model, index) + trace_attributes = { + "model_name": self.quote_name(model._meta.db_table), + "index": "|".join(index.fields), + } + with trace_call( + "CloudSpannerDjango.add_index", + self.connection, + trace_attributes, + ): + super().add_index(model, index) def quote_value(self, value): # A more complete implementation isn't currently required. @@ -364,20 +410,48 @@ def _alter_field( "index isn't yet supported." ) for index_name in index_names: - self.execute(self._delete_index_sql(model, index_name)) - super()._alter_field( - model, - old_field, - new_field, - old_type, - new_type, - old_db_params, - new_db_params, - strict=False, - ) + trace_attributes = { + "model_name": self.quote_name(model._meta.db_table), + "alter_field": old_field.column, + "index_name": index_name, + } + with trace_call( + "CloudSpannerDjango.alter_field.delete_index", + self.connection, + trace_attributes, + ): + self.execute(self._delete_index_sql(model, index_name)) + trace_attributes = { + "model_name": self.quote_name(model._meta.db_table), + "alter_field": old_field.column, + } + with trace_call( + "CloudSpannerDjango.alter_field", + self.connection, + trace_attributes, + ): + super()._alter_field( + model, + old_field, + new_field, + old_type, + new_type, + old_db_params, + new_db_params, + strict=False, + ) # Recreate the index that was dropped earlier. if nullability_changed and new_field.db_index: - self.execute(self._create_index_sql(model, [new_field])) + trace_attributes = { + "model_name": self.quote_name(model._meta.db_table), + "alter_field": new_field.column, + } + with trace_call( + "CloudSpannerDjango.alter_field.recreate_index", + self.connection, + trace_attributes, + ): + self.execute(self._create_index_sql(model, [new_field])) def _alter_column_type_sql(self, model, old_field, new_field, new_type): # Spanner needs to use sql_alter_column_not_null if the field is diff --git a/setup.py b/setup.py index f37e04b0c1..c310fda167 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,13 @@ # 'Development Status :: 5 - Production/Stable' release_status = "Development Status :: 3 - Alpha" dependencies = ["sqlparse >= 0.3.0", "google-cloud-spanner >= 3.0.0"] -extras = {} +extras = { + "tracing": [ + "opentelemetry-api >= 1.1.0", + "opentelemetry-sdk >= 1.1.0", + "opentelemetry-instrumentation >= 0.20b0", + ] +} BASE_DIR = os.path.dirname(__file__) VERSION_FILENAME = os.path.join(BASE_DIR, "version.py") diff --git a/testing/constraints-3.6.txt b/testing/constraints-3.6.txt index 0136bc8c30..309b568227 100644 --- a/testing/constraints-3.6.txt +++ b/testing/constraints-3.6.txt @@ -6,4 +6,7 @@ # e.g., if setup.py has "foo >= 1.14.0, < 2.0.0dev", # Then this file should have foo==1.14.0 sqlparse==0.3.0 -google-cloud-spanner==2.1.0 \ No newline at end of file +google-cloud-spanner==3.4.0 +opentelemetry-api==1.1.0 +opentelemetry-sdk==1.1.0 +opentelemetry-instrumentation==0.20b0 diff --git a/tests/_helpers.py b/tests/_helpers.py new file mode 100644 index 0000000000..42178fd439 --- /dev/null +++ b/tests/_helpers.py @@ -0,0 +1,73 @@ +import unittest +import mock + +try: + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + from opentelemetry.trace.status import StatusCode + + trace.set_tracer_provider(TracerProvider()) + + HAS_OPENTELEMETRY_INSTALLED = True +except ImportError: + HAS_OPENTELEMETRY_INSTALLED = False + + StatusCode = mock.Mock() + +_TEST_OT_EXPORTER = None +_TEST_OT_PROVIDER_INITIALIZED = False + + +def get_test_ot_exporter(): + global _TEST_OT_EXPORTER + + if _TEST_OT_EXPORTER is None: + _TEST_OT_EXPORTER = InMemorySpanExporter() + return _TEST_OT_EXPORTER + + +def use_test_ot_exporter(): + global _TEST_OT_PROVIDER_INITIALIZED + + if _TEST_OT_PROVIDER_INITIALIZED: + return + + provider = trace.get_tracer_provider() + if not hasattr(provider, "add_span_processor"): + return + provider.add_span_processor(SimpleSpanProcessor(get_test_ot_exporter())) + _TEST_OT_PROVIDER_INITIALIZED = True + + +class OpenTelemetryBase(unittest.TestCase): + @classmethod + def setUpClass(cls): + if HAS_OPENTELEMETRY_INSTALLED: + use_test_ot_exporter() + cls.ot_exporter = get_test_ot_exporter() + + def tearDown(self): + if HAS_OPENTELEMETRY_INSTALLED: + self.ot_exporter.clear() + + def assertNoSpans(self): + if HAS_OPENTELEMETRY_INSTALLED: + span_list = self.ot_exporter.get_finished_spans() + self.assertEqual(len(span_list), 0) + + def assertSpanAttributes( + self, name, status=StatusCode.OK, attributes=None, span=None + ): + if HAS_OPENTELEMETRY_INSTALLED: + if not span: + span_list = self.ot_exporter.get_finished_spans() + self.assertEqual(len(span_list), 1) + span = span_list[0] + + self.assertEqual(span.name, name) + self.assertEqual(span.status.status_code, status) + self.assertEqual(dict(span.attributes), attributes) diff --git a/tests/unit/django_spanner/simple_test.py b/tests/unit/django_spanner/simple_test.py index 1fcb92bd29..0c66d04dfe 100644 --- a/tests/unit/django_spanner/simple_test.py +++ b/tests/unit/django_spanner/simple_test.py @@ -7,13 +7,16 @@ from django_spanner.client import DatabaseClient from django_spanner.base import DatabaseWrapper from django_spanner.operations import DatabaseOperations -from unittest import TestCase + +# from unittest import TestCase +from tests._helpers import OpenTelemetryBase import os -class SpannerSimpleTestClass(TestCase): +class SpannerSimpleTestClass(OpenTelemetryBase): @classmethod def setUpClass(cls): + super(SpannerSimpleTestClass, cls).setUpClass() cls.PROJECT = os.environ["GOOGLE_CLOUD_PROJECT"] cls.INSTANCE_ID = "instance_id" diff --git a/tests/unit/django_spanner/test__opentelemetry_tracing.py b/tests/unit/django_spanner/test__opentelemetry_tracing.py new file mode 100644 index 0000000000..90e99427e1 --- /dev/null +++ b/tests/unit/django_spanner/test__opentelemetry_tracing.py @@ -0,0 +1,134 @@ +import importlib +import mock +import unittest +import sys +import os + +try: + from opentelemetry import trace as trace_api + from opentelemetry.trace.status import StatusCode +except ImportError: + pass + +from google.api_core.exceptions import GoogleAPICallError +from django_spanner import _opentelemetry_tracing + +from tests._helpers import OpenTelemetryBase, HAS_OPENTELEMETRY_INSTALLED + +PROJECT = os.environ["GOOGLE_CLOUD_PROJECT"] +INSTANCE_ID = "instance_id" +DATABASE_ID = "database_id" +USER_AGENT = "django_spanner/2.2.0a1" +OPTIONS = {"option": "dummy"} + + +def _make_rpc_error(error_cls, trailing_metadata=None): + import grpc + + grpc_error = mock.create_autospec(grpc.Call, instance=True) + grpc_error.trailing_metadata.return_value = trailing_metadata + return error_cls("error", errors=(grpc_error,)) + + +def _make_connection(): + # from django_spanner.client import DatabaseClient + from django_spanner.base import DatabaseWrapper + + settings_dict = { + "PROJECT": PROJECT, + "INSTANCE": INSTANCE_ID, + "NAME": DATABASE_ID, + "user_agent": USER_AGENT, + "OPTIONS": OPTIONS, + } + # db_client = DatabaseClient(settings_dict) + return DatabaseWrapper(settings_dict) + # from google.cloud.spanner_v1.session import Session + + # return mock.Mock(autospec=Session, instance=True) + + +# Skip all of these tests if we don't have OpenTelemetry +if HAS_OPENTELEMETRY_INSTALLED: + + class TestNoTracing(unittest.TestCase): + def setUp(self): + self._temp_opentelemetry = sys.modules["opentelemetry"] + + sys.modules["opentelemetry"] = None + importlib.reload(_opentelemetry_tracing) + + def tearDown(self): + sys.modules["opentelemetry"] = self._temp_opentelemetry + importlib.reload(_opentelemetry_tracing) + + def test_no_trace_call(self): + conn = _make_connection() + with _opentelemetry_tracing.trace_call( + "Test", _make_connection() + ) as no_span: + self.assertIsNone(no_span) + + class TestTracing(OpenTelemetryBase): + def test_trace_call(self): + extra_attributes = { + "attribute1": "value1", + # Since our database is mocked, we have to override the db.instance parameter so it is a string + "db.instance": "database_name", + } + + expected_attributes = { + "db.type": "spanner", + "db.engine": "django_spanner", + "db.project": PROJECT, + "db.instance": INSTANCE_ID, + "db.name": DATABASE_ID, + "db.user_agent": USER_AGENT, + } + expected_attributes.update(extra_attributes) + + with _opentelemetry_tracing.trace_call( + "CloudSpannerDjango.Test", _make_connection(), extra_attributes + ) as span: + span.set_attribute("after_setup_attribute", 1) + + expected_attributes["after_setup_attribute"] = 1 + + span_list = self.ot_exporter.get_finished_spans() + self.assertEqual(len(span_list), 1) + span = span_list[0] + self.assertEqual(span.kind, trace_api.SpanKind.CLIENT) + self.assertEqual(span.attributes, expected_attributes) + self.assertEqual(span.name, "CloudSpannerDjango.Test") + self.assertEqual(span.status.status_code, StatusCode.OK) + + def test_trace_error(self): + extra_attributes = {"db.instance": "database_name"} + + expected_attributes = { + "db.type": "spanner", + "db.engine": "django_spanner", + "db.project": os.environ["GOOGLE_CLOUD_PROJECT"], + "db.instance": "instance_id", + "db.name": "database_id", + "db.user_agent": "django_spanner/2.2.0a1", + } + expected_attributes.update(extra_attributes) + + with self.assertRaises(GoogleAPICallError): + with _opentelemetry_tracing.trace_call( + "CloudSpannerDjango.Test", + _make_connection(), + extra_attributes, + ) as span: + from google.api_core.exceptions import InvalidArgument + + raise _make_rpc_error(InvalidArgument) + + span_list = self.ot_exporter.get_finished_spans() + self.assertEqual(len(span_list), 1) + span = span_list[0] + self.assertEqual(span.kind, trace_api.SpanKind.CLIENT) + self.assertEqual(dict(span.attributes), expected_attributes) + self.assertEqual(span.name, "CloudSpannerDjango.Test") + self.assertEqual(span.status.status_code, StatusCode.ERROR) diff --git a/tests/unit/django_spanner/test_schema.py b/tests/unit/django_spanner/test_schema.py index 8c8ee35f0d..799abe3ed2 100644 --- a/tests/unit/django_spanner/test_schema.py +++ b/tests/unit/django_spanner/test_schema.py @@ -5,12 +5,30 @@ # https://developers.google.com/open-source/licenses/bsd +from .models import Author +from django.db import NotSupportedError from django.db.models import Index from django.db.models.fields import IntegerField from django_spanner.schema import DatabaseSchemaEditor +from tests._helpers import HAS_OPENTELEMETRY_INSTALLED from tests.unit.django_spanner.simple_test import SpannerSimpleTestClass from unittest import mock -from .models import Author +from tests.unit.django_spanner.test__opentelemetry_tracing import ( + PROJECT, + INSTANCE_ID, + DATABASE_ID, + USER_AGENT, +) + + +BASE_ATTRIBUTES = { + "db.type": "spanner", + "db.engine": "django_spanner", + "db.project": PROJECT, + "db.instance": INSTANCE_ID, + "db.name": DATABASE_ID, + "db.user_agent": USER_AGENT, +} class TestUtils(SpannerSimpleTestClass): @@ -43,6 +61,14 @@ def test_create_model(self): + "PRIMARY KEY(id)", None, ) + if HAS_OPENTELEMETRY_INSTALLED: + span_list = self.ot_exporter.get_finished_spans() + self.assertEqual(len(span_list), 1) + self.assertSpanAttributes( + "CloudSpannerDjango.create_model", + attributes=dict(BASE_ATTRIBUTES, model_name="tests_author"), + span=span_list[0], + ) def test_delete_model(self): """ @@ -57,6 +83,61 @@ def test_delete_model(self): "DROP TABLE tests_author", ) + if HAS_OPENTELEMETRY_INSTALLED: + span_list = self.ot_exporter.get_finished_spans() + self.assertEqual(len(span_list), 1) + self.assertSpanAttributes( + "CloudSpannerDjango.delete_model", + attributes=dict(BASE_ATTRIBUTES, model_name="tests_author"), + span=span_list[0], + ) + + def test_delete_model_with_index(self): + """ + Tests deleting a model with index + """ + with DatabaseSchemaEditor(self.connection) as schema_editor: + schema_editor.execute = mock.MagicMock() + + def delete_index_sql(*args, **kwargs): + # Overriding Statement creation with sql string. + return "DROP INDEX num_unique" + + def constraint_names(*args, **kwargs): + return ["num_unique"] + + schema_editor._delete_index_sql = delete_index_sql + schema_editor._constraint_names = constraint_names + schema_editor.delete_model(Author) + + calls = [ + mock.call("DROP INDEX num_unique"), + mock.call("DROP TABLE tests_author"), + ] + + schema_editor.execute.assert_has_calls(calls) + + if HAS_OPENTELEMETRY_INSTALLED: + span_list = self.ot_exporter.get_finished_spans() + self.assertEqual(len(span_list), 2) + self.assertSpanAttributes( + "CloudSpannerDjango.delete_model.delete_index", + attributes=dict( + BASE_ATTRIBUTES, + model_name="tests_author", + index_name="num_unique", + ), + span=span_list[0], + ) + self.assertSpanAttributes( + "CloudSpannerDjango.delete_model", + attributes=dict( + BASE_ATTRIBUTES, + model_name="tests_author", + ), + span=span_list[1], + ) + def test_add_field(self): """ Tests adding fields to models @@ -71,6 +152,84 @@ def test_add_field(self): "ALTER TABLE tests_author ADD COLUMN age INT64", [] ) + def test_remove_field(self): + """ + Tests remove fields from models + """ + with DatabaseSchemaEditor(self.connection) as schema_editor: + schema_editor.execute = mock.MagicMock() + schema_editor._constraint_names = mock.MagicMock() + remove_field = IntegerField(unique=True) + remove_field.set_attributes_from_name("num") + schema_editor.remove_field(Author, remove_field) + + schema_editor.execute.assert_called_once_with( + "ALTER TABLE tests_author DROP COLUMN num" + ) + + if HAS_OPENTELEMETRY_INSTALLED: + span_list = self.ot_exporter.get_finished_spans() + self.assertEqual(len(span_list), 1) + self.assertSpanAttributes( + "CloudSpannerDjango.remove_field", + attributes=dict( + BASE_ATTRIBUTES, + model_name="tests_author", + field="num", + ), + span=span_list[0], + ) + + def test_remove_field_with_index(self): + """ + Tests remove fields from models + """ + with DatabaseSchemaEditor(self.connection) as schema_editor: + schema_editor.execute = mock.MagicMock() + + def delete_index_sql(*args, **kwargs): + # Overriding Statement creation with sql string. + return "DROP INDEX num_unique" + + def constraint_names(*args, **kwargs): + return ["num_unique"] + + schema_editor._delete_index_sql = delete_index_sql + schema_editor._constraint_names = constraint_names + + remove_field = IntegerField(unique=True) + remove_field.set_attributes_from_name("num") + schema_editor.remove_field(Author, remove_field) + + calls = [ + mock.call("DROP INDEX num_unique"), + mock.call("ALTER TABLE tests_author DROP COLUMN num"), + ] + schema_editor.execute.assert_has_calls(calls) + + if HAS_OPENTELEMETRY_INSTALLED: + span_list = self.ot_exporter.get_finished_spans() + self.assertEqual(len(span_list), 2) + self.assertSpanAttributes( + "CloudSpannerDjango.remove_field.delete_index", + attributes=dict( + BASE_ATTRIBUTES, + model_name="tests_author", + field="num", + index_name="num_unique", + ), + span=span_list[0], + ) + self.assertSpanAttributes( + "CloudSpannerDjango.remove_field", + attributes=dict( + BASE_ATTRIBUTES, + model_name="tests_author", + field="num", + ), + span=span_list[1], + ) + def test_column_sql_not_null_field(self): """ Tests column sql for not null field @@ -81,6 +240,7 @@ def test_column_sql_not_null_field(self): new_field.set_attributes_from_name("num") sql, params = schema_editor.column_sql(Author, new_field) self.assertEqual(sql, "INT64 NOT NULL") + self.assertEqual(params, []) def test_column_sql_nullable_field(self): """ @@ -92,6 +252,7 @@ def test_column_sql_nullable_field(self): new_field.set_attributes_from_name("num") sql, params = schema_editor.column_sql(Author, new_field) self.assertEqual(sql, "INT64") + self.assertEqual(params, []) def test_column_add_index(self): """ @@ -102,12 +263,24 @@ def test_column_add_index(self): index = Index(name="test_author_index_num", fields=["num"]) schema_editor.add_index(Author, index) name, args, kwargs = schema_editor.execute.mock_calls[0] - self.assertEqual( str(args[0]), "CREATE INDEX test_author_index_num ON tests_author (num)", ) self.assertEqual(kwargs["params"], None) + self.assertEqual(name, "") + if HAS_OPENTELEMETRY_INSTALLED: + span_list = self.ot_exporter.get_finished_spans() + self.assertEqual(len(span_list), 1) + self.assertSpanAttributes( + "CloudSpannerDjango.add_index", + attributes=dict( + BASE_ATTRIBUTES, + model_name="tests_author", + index="num", + ), + span=span_list[0], + ) def test_alter_field(self): """ @@ -124,3 +297,112 @@ def test_alter_field(self): schema_editor.execute.assert_called_once_with( "ALTER TABLE tests_author RENAME COLUMN num TO author_num" ) + + def test_alter_field_change_null_with_single_index(self): + """ + Tests altering nullability of field with single index + """ + with DatabaseSchemaEditor(self.connection) as schema_editor: + schema_editor.execute = mock.MagicMock() + + def delete_index_sql(*args, **kwargs): + # Overriding Statement creation with sql string. + return "DROP INDEX num_unique" + + def create_index_sql(*args, **kwargs): + # Overriding Statement creation with sql string. + return "CREATE INDEX tests_author ON tests_author (author_num)" + + def constraint_names(*args, **kwargs): + return ["num_unique"] + + schema_editor._delete_index_sql = delete_index_sql + schema_editor._create_index_sql = create_index_sql + schema_editor._constraint_names = constraint_names + old_field = IntegerField(null=True, db_index=True) + old_field.set_attributes_from_name("num") + new_field = IntegerField(db_index=True) + new_field.set_attributes_from_name("author_num") + schema_editor.alter_field(Author, old_field, new_field) + + calls = [ + mock.call("DROP INDEX num_unique"), + mock.call( + "ALTER TABLE tests_author RENAME COLUMN num TO author_num" + ), + mock.call( + "ALTER TABLE tests_author ALTER COLUMN author_num INT64 NOT NULL", + [], + ), + mock.call( + "CREATE INDEX tests_author ON tests_author (author_num)" + ), + ] + schema_editor.execute.assert_has_calls(calls) + if HAS_OPENTELEMETRY_INSTALLED: + span_list = self.ot_exporter.get_finished_spans() + self.assertEqual(len(span_list), 3) + self.assertSpanAttributes( + "CloudSpannerDjango.alter_field.delete_index", + attributes=dict( + BASE_ATTRIBUTES, + model_name="tests_author", + index_name="num_unique", + alter_field="num", + ), + span=span_list[0], + ) + self.assertSpanAttributes( + "CloudSpannerDjango.alter_field", + attributes=dict( + BASE_ATTRIBUTES, + model_name="tests_author", + alter_field="num", + ), + span=span_list[1], + ) + self.assertSpanAttributes( + "CloudSpannerDjango.alter_field.recreate_index", + attributes=dict( + BASE_ATTRIBUTES, + model_name="tests_author", + alter_field="author_num", + ), + span=span_list[2], + ) + + def test_alter_field_nullability_change_raise_not_support_error(self): + """ + Tests altering nullability of existing field in table + """ + with DatabaseSchemaEditor(self.connection) as schema_editor: + schema_editor.execute = mock.MagicMock() + + def constraint_names(*args, **kwargs): + return ["num_unique"] + + schema_editor._constraint_names = constraint_names + old_field = IntegerField(null=True) + old_field.set_attributes_from_name("num") + new_field = IntegerField() + new_field.set_attributes_from_name("author_num") + with self.assertRaises(NotSupportedError): + schema_editor.alter_field(Author, old_field, new_field) + + def test_alter_field_change_null_with_multiple_index_error(self): + """ + Tests altering nullability of field with multiple index not supported + """ + with DatabaseSchemaEditor(self.connection) as schema_editor: + schema_editor.execute = mock.MagicMock() + + def constraint_names(*args, **kwargs): + return ["num_unique", "dummy_index"] + + schema_editor._constraint_names = constraint_names + old_field = IntegerField(null=True, db_index=True) + old_field.set_attributes_from_name("num") + new_field = IntegerField() + new_field.set_attributes_from_name("author_num") + with self.assertRaises(NotSupportedError): + schema_editor.alter_field(Author, old_field, new_field) From 8a672b3241a0bd31ebbeb572df84a995d5241916 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Thu, 10 Jun 2021 13:13:19 +0530 Subject: [PATCH 5/8] refactor: lint fixes --- django_spanner/_opentelemetry_tracing.py | 1 - django_spanner/schema.py | 4 +--- .../test__opentelemetry_tracing.py | 4 ---- tests/unit/django_spanner/test_schema.py | 19 ++++--------------- 4 files changed, 5 insertions(+), 23 deletions(-) diff --git a/django_spanner/_opentelemetry_tracing.py b/django_spanner/_opentelemetry_tracing.py index b8b05047c7..45fc1ff18e 100644 --- a/django_spanner/_opentelemetry_tracing.py +++ b/django_spanner/_opentelemetry_tracing.py @@ -35,7 +35,6 @@ def trace_call(name, connection, extra_attributes=None): "db.project": connection.settings_dict["PROJECT"], "db.instance": connection.settings_dict["INSTANCE"], "db.name": connection.settings_dict["NAME"], - "db.user_agent": connection.settings_dict["user_agent"], } if extra_attributes: diff --git a/django_spanner/schema.py b/django_spanner/schema.py index d9f5f8b81d..d28dcc4f6e 100644 --- a/django_spanner/schema.py +++ b/django_spanner/schema.py @@ -371,9 +371,7 @@ def add_index(self, model, index): "index": "|".join(index.fields), } with trace_call( - "CloudSpannerDjango.add_index", - self.connection, - trace_attributes, + "CloudSpannerDjango.add_index", self.connection, trace_attributes, ): super().add_index(model, index) diff --git a/tests/unit/django_spanner/test__opentelemetry_tracing.py b/tests/unit/django_spanner/test__opentelemetry_tracing.py index 90e99427e1..76270be9a9 100644 --- a/tests/unit/django_spanner/test__opentelemetry_tracing.py +++ b/tests/unit/django_spanner/test__opentelemetry_tracing.py @@ -18,7 +18,6 @@ PROJECT = os.environ["GOOGLE_CLOUD_PROJECT"] INSTANCE_ID = "instance_id" DATABASE_ID = "database_id" -USER_AGENT = "django_spanner/2.2.0a1" OPTIONS = {"option": "dummy"} @@ -38,7 +37,6 @@ def _make_connection(): "PROJECT": PROJECT, "INSTANCE": INSTANCE_ID, "NAME": DATABASE_ID, - "user_agent": USER_AGENT, "OPTIONS": OPTIONS, } # db_client = DatabaseClient(settings_dict) @@ -83,7 +81,6 @@ def test_trace_call(self): "db.project": PROJECT, "db.instance": INSTANCE_ID, "db.name": DATABASE_ID, - "db.user_agent": USER_AGENT, } expected_attributes.update(extra_attributes) @@ -111,7 +108,6 @@ def test_trace_error(self): "db.project": os.environ["GOOGLE_CLOUD_PROJECT"], "db.instance": "instance_id", "db.name": "database_id", - "db.user_agent": "django_spanner/2.2.0a1", } expected_attributes.update(extra_attributes) diff --git a/tests/unit/django_spanner/test_schema.py b/tests/unit/django_spanner/test_schema.py index 799abe3ed2..570bcc572a 100644 --- a/tests/unit/django_spanner/test_schema.py +++ b/tests/unit/django_spanner/test_schema.py @@ -17,7 +17,6 @@ PROJECT, INSTANCE_ID, DATABASE_ID, - USER_AGENT, ) @@ -27,7 +26,6 @@ "db.project": PROJECT, "db.instance": INSTANCE_ID, "db.name": DATABASE_ID, - "db.user_agent": USER_AGENT, } @@ -131,10 +129,7 @@ def constraint_names(*args, **kwargs): ) self.assertSpanAttributes( "CloudSpannerDjango.delete_model", - attributes=dict( - BASE_ATTRIBUTES, - model_name="tests_author", - ), + attributes=dict(BASE_ATTRIBUTES, model_name="tests_author",), span=span_list[1], ) @@ -173,9 +168,7 @@ def test_remove_field(self): self.assertSpanAttributes( "CloudSpannerDjango.remove_field", attributes=dict( - BASE_ATTRIBUTES, - model_name="tests_author", - field="num", + BASE_ATTRIBUTES, model_name="tests_author", field="num", ), span=span_list[0], ) @@ -223,9 +216,7 @@ def constraint_names(*args, **kwargs): self.assertSpanAttributes( "CloudSpannerDjango.remove_field", attributes=dict( - BASE_ATTRIBUTES, - model_name="tests_author", - field="num", + BASE_ATTRIBUTES, model_name="tests_author", field="num", ), span=span_list[1], ) @@ -275,9 +266,7 @@ def test_column_add_index(self): self.assertSpanAttributes( "CloudSpannerDjango.add_index", attributes=dict( - BASE_ATTRIBUTES, - model_name="tests_author", - index="num", + BASE_ATTRIBUTES, model_name="tests_author", index="num", ), span=span_list[0], ) From 300696afc93b26f60f08a207a1b1e041a6ed8202 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Thu, 10 Jun 2021 13:55:36 +0530 Subject: [PATCH 6/8] refactor: lint fixes --- tests/unit/django_spanner/test__opentelemetry_tracing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/django_spanner/test__opentelemetry_tracing.py b/tests/unit/django_spanner/test__opentelemetry_tracing.py index 76270be9a9..0f462e0879 100644 --- a/tests/unit/django_spanner/test__opentelemetry_tracing.py +++ b/tests/unit/django_spanner/test__opentelemetry_tracing.py @@ -61,7 +61,6 @@ def tearDown(self): importlib.reload(_opentelemetry_tracing) def test_no_trace_call(self): - conn = _make_connection() with _opentelemetry_tracing.trace_call( "Test", _make_connection() ) as no_span: From b409d7af09561dffc5d2ee399390b9eaf44d16be Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Thu, 10 Jun 2021 15:09:20 +0530 Subject: [PATCH 7/8] refactor: added license text --- tests/_helpers.py | 6 ++++++ tests/unit/django_spanner/test__opentelemetry_tracing.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/_helpers.py b/tests/_helpers.py index 42178fd439..4faff45f2c 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -1,3 +1,9 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + import unittest import mock diff --git a/tests/unit/django_spanner/test__opentelemetry_tracing.py b/tests/unit/django_spanner/test__opentelemetry_tracing.py index 0f462e0879..400a533468 100644 --- a/tests/unit/django_spanner/test__opentelemetry_tracing.py +++ b/tests/unit/django_spanner/test__opentelemetry_tracing.py @@ -1,3 +1,9 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + import importlib import mock import unittest From 850c879e0dfcc02c344e1427ad72b8014b48e1d1 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Thu, 10 Jun 2021 17:37:54 +0530 Subject: [PATCH 8/8] ci: corrrected version for google-cloud-spanner --- noxfile.py | 5 +++++ testing/constraints-3.6.txt | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index b2d42a5174..a5c05e7a02 100644 --- a/noxfile.py +++ b/noxfile.py @@ -75,6 +75,11 @@ def default(session): "pytest", "pytest-cov", "coverage", + "sqlparse==0.3.0", + "google-cloud-spanner==3.0.0", + "opentelemetry-api==1.1.0", + "opentelemetry-sdk==1.1.0", + "opentelemetry-instrumentation==0.20b0", ) session.install("-e", ".") diff --git a/testing/constraints-3.6.txt b/testing/constraints-3.6.txt index 309b568227..7573802344 100644 --- a/testing/constraints-3.6.txt +++ b/testing/constraints-3.6.txt @@ -6,7 +6,7 @@ # e.g., if setup.py has "foo >= 1.14.0, < 2.0.0dev", # Then this file should have foo==1.14.0 sqlparse==0.3.0 -google-cloud-spanner==3.4.0 +google-cloud-spanner==3.0.0 opentelemetry-api==1.1.0 opentelemetry-sdk==1.1.0 opentelemetry-instrumentation==0.20b0