diff --git a/.librarian/state.yaml b/.librarian/state.yaml index 381824b372..7dd193bf5b 100644 --- a/.librarian/state.yaml +++ b/.librarian/state.yaml @@ -1,7 +1,7 @@ image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:b8058df4c45e9a6e07f6b4d65b458d0d059241dd34c814f151c8bf6b89211209 libraries: - id: google-cloud-spanner - version: 3.61.0 + version: 3.62.0 last_generated_commit: a17b84add8318f780fcc8a027815d5fee644b9f7 apis: - path: google/spanner/admin/instance/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b4a8d8d3..d29a945636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ [1]: https://pypi.org/project/google-cloud-spanner/#history +## [3.62.0](https://github.com/googleapis/python-spanner/compare/v3.61.0...v3.62.0) (2026-01-14) + + +### Features + +* add uuid support (#1310) ([3b1792aad1d046b6ae1e5c982f5047289dffd95c](https://github.com/googleapis/python-spanner/commit/3b1792aad1d046b6ae1e5c982f5047289dffd95c)) + + +### Bug Fixes + +* handle errors during stream restart in snapshot (#1471) ([c0668735cb69532f4c852bb7678f63e54da2d34e](https://github.com/googleapis/python-spanner/commit/c0668735cb69532f4c852bb7678f63e54da2d34e)) +* resolve pre-release dependency failures and sqlparse recursion (#1472) ([9ec95b7df5e921112bd58b820722103177e0e5b6](https://github.com/googleapis/python-spanner/commit/9ec95b7df5e921112bd58b820722103177e0e5b6)) +* transaction_tag should be set on BeginTransactionRequest (#1463) ([3d3cea0b5afb414a506ab08eebae733d803f17ac](https://github.com/googleapis/python-spanner/commit/3d3cea0b5afb414a506ab08eebae733d803f17ac)) + ## [3.61.0](https://github.com/googleapis/python-spanner/compare/v3.60.0...v3.61.0) (2025-12-16) diff --git a/google/cloud/spanner_admin_database_v1/gapic_version.py b/google/cloud/spanner_admin_database_v1/gapic_version.py index 89cb359ff2..b548ea04d7 100644 --- a/google/cloud/spanner_admin_database_v1/gapic_version.py +++ b/google/cloud/spanner_admin_database_v1/gapic_version.py @@ -13,4 +13,4 @@ # See the License for the specific language governing permissions and # limitations under the License. # -__version__ = "3.61.0" # {x-release-please-version} +__version__ = "3.62.0" # {x-release-please-version} diff --git a/google/cloud/spanner_admin_instance_v1/gapic_version.py b/google/cloud/spanner_admin_instance_v1/gapic_version.py index 89cb359ff2..b548ea04d7 100644 --- a/google/cloud/spanner_admin_instance_v1/gapic_version.py +++ b/google/cloud/spanner_admin_instance_v1/gapic_version.py @@ -13,4 +13,4 @@ # See the License for the specific language governing permissions and # limitations under the License. # -__version__ = "3.61.0" # {x-release-please-version} +__version__ = "3.62.0" # {x-release-please-version} diff --git a/google/cloud/spanner_dbapi/parse_utils.py b/google/cloud/spanner_dbapi/parse_utils.py index 66741eb264..d99caa7e8c 100644 --- a/google/cloud/spanner_dbapi/parse_utils.py +++ b/google/cloud/spanner_dbapi/parse_utils.py @@ -233,6 +233,11 @@ def classify_statement(query, args=None): :rtype: ParsedStatement :returns: parsed statement attributes. """ + # Check for RUN PARTITION command to avoid sqlparse processing it. + # sqlparse fails with "Maximum grouping depth exceeded" on long partition IDs. + if re.match(r"^\s*RUN\s+PARTITION\s+.+", query, re.IGNORECASE): + return client_side_statement_parser.parse_stmt(query.strip()) + # sqlparse will strip Cloud Spanner comments, # still, special commenting styles, like # PostgreSQL dollar quoted comments are not diff --git a/google/cloud/spanner_dbapi/version.py b/google/cloud/spanner_dbapi/version.py index 86252a8635..96cdcb4e8e 100644 --- a/google/cloud/spanner_dbapi/version.py +++ b/google/cloud/spanner_dbapi/version.py @@ -15,6 +15,6 @@ import platform PY_VERSION = platform.python_version() -__version__ = "3.61.0" +__version__ = "3.62.0" VERSION = __version__ DEFAULT_USER_AGENT = "gl-dbapi/" + VERSION diff --git a/google/cloud/spanner_v1/_helpers.py b/google/cloud/spanner_v1/_helpers.py index aa58c59199..8a200fe812 100644 --- a/google/cloud/spanner_v1/_helpers.py +++ b/google/cloud/spanner_v1/_helpers.py @@ -21,6 +21,7 @@ import base64 import threading import logging +import uuid from google.protobuf.struct_pb2 import ListValue from google.protobuf.struct_pb2 import Value @@ -298,6 +299,8 @@ def _make_value_pb(value): return Value(string_value=base64.b64encode(value)) if isinstance(value, Interval): return Value(string_value=str(value)) + if isinstance(value, uuid.UUID): + return Value(string_value=str(value)) raise ValueError("Unknown type: %s" % (value,)) @@ -399,6 +402,8 @@ def _get_type_decoder(field_type, field_name, column_info=None): return _parse_numeric elif type_code == TypeCode.JSON: return _parse_json + elif type_code == TypeCode.UUID: + return _parse_uuid elif type_code == TypeCode.PROTO: return lambda value_pb: _parse_proto(value_pb, column_info, field_name) elif type_code == TypeCode.ENUM: @@ -481,6 +486,10 @@ def _parse_json(value_pb): return JsonObject.from_str(value_pb.string_value) +def _parse_uuid(value_pb): + return uuid.UUID(value_pb.string_value) + + def _parse_proto(value_pb, column_info, field_name): bytes_value = base64.b64decode(value_pb.string_value) if column_info is not None and column_info.get(field_name) is not None: diff --git a/google/cloud/spanner_v1/gapic_version.py b/google/cloud/spanner_v1/gapic_version.py index 89cb359ff2..b548ea04d7 100644 --- a/google/cloud/spanner_v1/gapic_version.py +++ b/google/cloud/spanner_v1/gapic_version.py @@ -13,4 +13,4 @@ # See the License for the specific language governing permissions and # limitations under the License. # -__version__ = "3.61.0" # {x-release-please-version} +__version__ = "3.62.0" # {x-release-please-version} diff --git a/google/cloud/spanner_v1/param_types.py b/google/cloud/spanner_v1/param_types.py index 72127c0e0b..a5da41601a 100644 --- a/google/cloud/spanner_v1/param_types.py +++ b/google/cloud/spanner_v1/param_types.py @@ -33,6 +33,7 @@ TIMESTAMP = Type(code=TypeCode.TIMESTAMP) NUMERIC = Type(code=TypeCode.NUMERIC) JSON = Type(code=TypeCode.JSON) +UUID = Type(code=TypeCode.UUID) PG_NUMERIC = Type(code=TypeCode.NUMERIC, type_annotation=TypeAnnotationCode.PG_NUMERIC) PG_JSONB = Type(code=TypeCode.JSON, type_annotation=TypeAnnotationCode.PG_JSONB) PG_OID = Type(code=TypeCode.INT64, type_annotation=TypeAnnotationCode.PG_OID) diff --git a/google/cloud/spanner_v1/snapshot.py b/google/cloud/spanner_v1/snapshot.py index 46b0f5af8d..9fa5123119 100644 --- a/google/cloud/spanner_v1/snapshot.py +++ b/google/cloud/spanner_v1/snapshot.py @@ -146,27 +146,12 @@ def _restart_on_unavailable( except ServiceUnavailable: del item_buffer[:] - with trace_call( - trace_name, - session, - attributes, - observability_options=observability_options, - metadata=metadata, - ) as span, MetricsCapture(): - request.resume_token = resume_token - if transaction is not None: - transaction_selector = transaction._build_transaction_selector_pb() - request.transaction = transaction_selector - attempt += 1 - iterator = method( - request=request, - metadata=request_id_manager.metadata_with_request_id( - nth_request, - attempt, - metadata, - span, - ), - ) + request.resume_token = resume_token + if transaction is not None: + transaction_selector = transaction._build_transaction_selector_pb() + request.transaction = transaction_selector + attempt += 1 + iterator = None continue except InternalServerError as exc: @@ -177,27 +162,12 @@ def _restart_on_unavailable( if not resumable_error: raise del item_buffer[:] - with trace_call( - trace_name, - session, - attributes, - observability_options=observability_options, - metadata=metadata, - ) as span, MetricsCapture(): - request.resume_token = resume_token - if transaction is not None: - transaction_selector = transaction._build_transaction_selector_pb() - attempt += 1 - request.transaction = transaction_selector - iterator = method( - request=request, - metadata=request_id_manager.metadata_with_request_id( - nth_request, - attempt, - metadata, - span, - ), - ) + request.resume_token = resume_token + if transaction is not None: + transaction_selector = transaction._build_transaction_selector_pb() + attempt += 1 + request.transaction = transaction_selector + iterator = None continue if len(item_buffer) == 0: @@ -901,13 +871,19 @@ def attempt_tracking_method(): return [partition.partition_token for partition in response.partitions] - def _begin_transaction(self, mutation: Mutation = None) -> bytes: + def _begin_transaction( + self, mutation: Mutation = None, transaction_tag: str = None + ) -> bytes: """Begins a transaction on the database. :type mutation: :class:`~google.cloud.spanner_v1.mutation.Mutation` :param mutation: (Optional) Mutation to include in the begin transaction request. Required for mutation-only transactions with multiplexed sessions. + :type transaction_tag: str + :param transaction_tag: (Optional) Transaction tag to include in the begin transaction + request. + :rtype: bytes :returns: identifier for the transaction. @@ -931,6 +907,17 @@ def _begin_transaction(self, mutation: Mutation = None) -> bytes: (_metadata_with_leader_aware_routing(database._route_to_leader_enabled)) ) + begin_request_kwargs = { + "session": session.name, + "options": self._build_transaction_selector_pb().begin, + "mutation_key": mutation, + } + + if transaction_tag: + begin_request_kwargs["request_options"] = RequestOptions( + transaction_tag=transaction_tag + ) + with trace_call( name=f"CloudSpanner.{type(self).__name__}.begin", session=session, @@ -942,9 +929,7 @@ def _begin_transaction(self, mutation: Mutation = None) -> bytes: def wrapped_method(): begin_transaction_request = BeginTransactionRequest( - session=session.name, - options=self._build_transaction_selector_pb().begin, - mutation_key=mutation, + **begin_request_kwargs ) begin_transaction_method = functools.partial( api.begin_transaction, diff --git a/google/cloud/spanner_v1/streamed.py b/google/cloud/spanner_v1/streamed.py index c41e65d39f..e0002141f9 100644 --- a/google/cloud/spanner_v1/streamed.py +++ b/google/cloud/spanner_v1/streamed.py @@ -394,6 +394,7 @@ def _merge_struct(lhs, rhs, type_): TypeCode.PROTO: _merge_string, TypeCode.INTERVAL: _merge_string, TypeCode.ENUM: _merge_string, + TypeCode.UUID: _merge_string, } diff --git a/google/cloud/spanner_v1/transaction.py b/google/cloud/spanner_v1/transaction.py index b9e14a0040..de8b421840 100644 --- a/google/cloud/spanner_v1/transaction.py +++ b/google/cloud/spanner_v1/transaction.py @@ -714,7 +714,9 @@ def _begin_transaction(self, mutation: Mutation = None) -> bytes: if self.rolled_back: raise ValueError("Transaction is already rolled back") - return super(Transaction, self)._begin_transaction(mutation=mutation) + return super(Transaction, self)._begin_transaction( + mutation=mutation, transaction_tag=self.transaction_tag + ) def _begin_mutations_only_transaction(self) -> None: """Begins a mutations-only transaction on the database.""" diff --git a/noxfile.py b/noxfile.py index 62d67d0be1..e85fba3c54 100644 --- a/noxfile.py +++ b/noxfile.py @@ -555,6 +555,9 @@ def prerelease_deps(session, protobuf_implementation, database_dialect): "google-cloud-testutils", # dependencies of google-cloud-testutils" "click", + # dependency of google-auth + "cffi", + "cryptography", ] for dep in prerel_deps: diff --git a/samples/generated_samples/snippet_metadata_google.spanner.admin.database.v1.json b/samples/generated_samples/snippet_metadata_google.spanner.admin.database.v1.json index 4fd6fa5396..6d18fe5c95 100644 --- a/samples/generated_samples/snippet_metadata_google.spanner.admin.database.v1.json +++ b/samples/generated_samples/snippet_metadata_google.spanner.admin.database.v1.json @@ -8,7 +8,7 @@ ], "language": "PYTHON", "name": "google-cloud-spanner-admin-database", - "version": "3.61.0" + "version": "3.62.0" }, "snippets": [ { diff --git a/samples/generated_samples/snippet_metadata_google.spanner.admin.instance.v1.json b/samples/generated_samples/snippet_metadata_google.spanner.admin.instance.v1.json index bae057d766..ee24f85498 100644 --- a/samples/generated_samples/snippet_metadata_google.spanner.admin.instance.v1.json +++ b/samples/generated_samples/snippet_metadata_google.spanner.admin.instance.v1.json @@ -8,7 +8,7 @@ ], "language": "PYTHON", "name": "google-cloud-spanner-admin-instance", - "version": "3.61.0" + "version": "3.62.0" }, "snippets": [ { diff --git a/samples/generated_samples/snippet_metadata_google.spanner.v1.json b/samples/generated_samples/snippet_metadata_google.spanner.v1.json index 5148cfa6df..ba41673ed3 100644 --- a/samples/generated_samples/snippet_metadata_google.spanner.v1.json +++ b/samples/generated_samples/snippet_metadata_google.spanner.v1.json @@ -8,7 +8,7 @@ ], "language": "PYTHON", "name": "google-cloud-spanner", - "version": "3.61.0" + "version": "3.62.0" }, "snippets": [ { diff --git a/tests/system/test_session_api.py b/tests/system/test_session_api.py index 2b0caba4e1..96f5cd76dc 100644 --- a/tests/system/test_session_api.py +++ b/tests/system/test_session_api.py @@ -20,6 +20,7 @@ import struct import threading import time +import uuid import pytest import grpc @@ -3056,6 +3057,18 @@ def test_execute_sql_returning_transfinite_floats(sessions_database, not_postgre assert math.isnan(float_array[2]) +def test_execute_sql_w_uuid_bindings(sessions_database, database_dialect): + if database_dialect == DatabaseDialect.POSTGRESQL: + pytest.skip("UUID parameter type is not yet supported in PostgreSQL dialect.") + _bind_test_helper( + sessions_database, + database_dialect, + spanner_v1.param_types.UUID, + uuid.uuid4(), + [uuid.uuid4(), uuid.uuid4()], + ) + + def test_partition_query(sessions_database, not_emulator, not_experimental_host): row_count = 40 sql = f"SELECT * FROM {_sample_data.TABLE}" diff --git a/tests/unit/spanner_dbapi/test_parse_utils.py b/tests/unit/spanner_dbapi/test_parse_utils.py index f63dbb78e4..ec612d9ebd 100644 --- a/tests/unit/spanner_dbapi/test_parse_utils.py +++ b/tests/unit/spanner_dbapi/test_parse_utils.py @@ -200,6 +200,29 @@ def test_run_partition_classify_stmt(self): ), ) + def test_run_partition_classify_stmt_long_id(self): + # Regression test for "Maximum grouping depth exceeded" with sqlparse + long_id = "a" * 5000 + query = f"RUN PARTITION {long_id}" + parsed_statement = classify_statement(query) + self.assertEqual( + parsed_statement, + ParsedStatement( + StatementType.CLIENT_SIDE, + Statement(query), + ClientSideStatementType.RUN_PARTITION, + [long_id], + ), + ) + + def test_run_partition_classify_stmt_incomplete(self): + # "RUN PARTITION" without ID should be classified as UNKNOWN (not None) + # because it falls through the specific check and sqlparse handles it. + query = "RUN PARTITION" + parsed_statement = classify_statement(query) + self.assertEqual(parsed_statement.statement_type, StatementType.UNKNOWN) + self.assertEqual(parsed_statement.statement.sql, query) + def test_run_partitioned_query_classify_stmt(self): parsed_statement = classify_statement( " RUN PARTITIONED QUERY SELECT s.SongName FROM Songs AS s " diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index 40db14607c..8140ecb1be 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -14,6 +14,7 @@ import unittest +import uuid import mock from opentelemetry.sdk.resources import Resource @@ -786,6 +787,18 @@ def test_w_proto_enum(self): self._callFUT(value_pb, field_type, field_name, column_info), VALUE ) + def test_w_uuid(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + + VALUE = uuid.uuid4() + field_type = Type(code=TypeCode.UUID) + field_name = "uuid_column" + value_pb = Value(string_value=str(VALUE)) + + self.assertEqual(self._callFUT(value_pb, field_type, field_name), VALUE) + class Test_parse_list_value_pbs(unittest.TestCase): def _callFUT(self, *args, **kw): diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index bfbd6edd5e..8026c50c24 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -2005,9 +2005,12 @@ def unit_of_work(txn, *args, **kw): self.assertEqual(kw, {"some_arg": "def"}) expected_options = TransactionOptions(read_write=TransactionOptions.ReadWrite()) + expected_request_options = RequestOptions(transaction_tag=transaction_tag) gax_api.begin_transaction.assert_called_once_with( request=BeginTransactionRequest( - session=self.SESSION_NAME, options=expected_options + session=self.SESSION_NAME, + options=expected_options, + request_options=expected_request_options, ), metadata=[ ("google-cloud-resource-prefix", database.name), diff --git a/tests/unit/test_snapshot.py b/tests/unit/test_snapshot.py index 974cc8e75e..f09bd06d1f 100644 --- a/tests/unit/test_snapshot.py +++ b/tests/unit/test_snapshot.py @@ -405,6 +405,56 @@ def test_iteration_w_raw_raising_unavailable_after_token(self): self.assertEqual(request.resume_token, RESUME_TOKEN) self.assertNoSpans() + def test_iteration_w_raw_raising_unavailable_during_restart(self): + from google.api_core.exceptions import ServiceUnavailable + + FIRST = (self._make_item(0), self._make_item(1, resume_token=RESUME_TOKEN)) + LAST = (self._make_item(2),) + before = _MockIterator( + *FIRST, fail_after=True, error=ServiceUnavailable("testing") + ) + after = _MockIterator(*LAST) + request = mock.Mock(test="test", spec=["test", "resume_token"]) + # The second call (the first retry) raises ServiceUnavailable immediately. + # The third call (the second retry) succeeds. + restart = mock.Mock( + spec=[], + side_effect=[before, ServiceUnavailable("retry failed"), after], + ) + database = _Database() + database.spanner_api = build_spanner_api() + session = _Session(database) + derived = _build_snapshot_derived(session) + resumable = self._call_fut(derived, restart, request, session=session) + self.assertEqual(list(resumable), list(FIRST + LAST)) + self.assertEqual(len(restart.mock_calls), 3) + self.assertEqual(request.resume_token, RESUME_TOKEN) + self.assertNoSpans() + + def test_iteration_w_raw_raising_resumable_internal_error_during_restart(self): + FIRST = (self._make_item(0), self._make_item(1, resume_token=RESUME_TOKEN)) + LAST = (self._make_item(2),) + before = _MockIterator( + *FIRST, + fail_after=True, + error=INTERNAL_SERVER_ERROR_UNEXPECTED_EOS, + ) + after = _MockIterator(*LAST) + request = mock.Mock(test="test", spec=["test", "resume_token"]) + restart = mock.Mock( + spec=[], + side_effect=[before, INTERNAL_SERVER_ERROR_UNEXPECTED_EOS, after], + ) + database = _Database() + database.spanner_api = build_spanner_api() + session = _Session(database) + derived = _build_snapshot_derived(session) + resumable = self._call_fut(derived, restart, request, session=session) + self.assertEqual(list(resumable), list(FIRST + LAST)) + self.assertEqual(len(restart.mock_calls), 3) + self.assertEqual(request.resume_token, RESUME_TOKEN) + self.assertNoSpans() + def test_iteration_w_raw_w_multiuse(self): from google.cloud.spanner_v1 import ( ReadRequest, diff --git a/tests/unit/test_transaction.py b/tests/unit/test_transaction.py index 39656cb8d1..510251656e 100644 --- a/tests/unit/test_transaction.py +++ b/tests/unit/test_transaction.py @@ -467,6 +467,7 @@ def _commit_helper( session=session.name, options=TransactionOptions(read_write=TransactionOptions.ReadWrite()), mutation_key=expected_begin_mutation, + request_options=RequestOptions(transaction_tag=TRANSACTION_TAG), ) expected_begin_metadata = base_metadata.copy()