From c992fb64cb54bc7c549b43332ca35b02c592f907 Mon Sep 17 00:00:00 2001 From: rahul yadav Date: Wed, 23 Apr 2025 13:55:36 +0530 Subject: [PATCH 01/12] feat(spanner): add interval type support --- google/cloud/spanner_v1/_helpers.py | 173 +++++++ google/cloud/spanner_v1/param_types.py | 1 + google/cloud/spanner_v1/streamed.py | 1 + tests/system/test_session_api.py | 208 ++++++++ tests/unit/test__helpers.py | 668 +++++++++++++++++++++++++ 5 files changed, 1051 insertions(+) diff --git a/google/cloud/spanner_v1/_helpers.py b/google/cloud/spanner_v1/_helpers.py index d1f64db2d8..8b5ee144b7 100644 --- a/google/cloud/spanner_v1/_helpers.py +++ b/google/cloud/spanner_v1/_helpers.py @@ -20,6 +20,8 @@ import time import base64 import threading +import re +from dataclasses import dataclass from google.protobuf.struct_pb2 import ListValue from google.protobuf.struct_pb2 import Value @@ -196,6 +198,162 @@ def _datetime_to_rfc3339_nanoseconds(value): return "{}.{}Z".format(value.isoformat(sep="T", timespec="seconds"), nanos) +@dataclass +class Interval: + """Represents a Spanner INTERVAL type. + + An interval is a combination of months, days and nanoseconds. + Internally, Spanner supports Interval value with the following range of individual fields: + months: [-120000, 120000] + days: [-3660000, 3660000] + nanoseconds: [-316224000000000000000, 316224000000000000000] + """ + months: int = 0 + days: int = 0 + nanos: int = 0 + + def __str__(self) -> str: + """Returns the ISO8601 duration format string representation.""" + result = ["P"] + + # Handle years and months + if self.months: + is_negative = self.months < 0 + abs_months = abs(self.months) + years, months = divmod(abs_months, 12) + if years: + result.append(f"{'-' if is_negative else ''}{years}Y") + if months: + result.append(f"{'-' if is_negative else ''}{months}M") + + # Handle days + if self.days: + result.append(f"{self.days}D") + + # Handle time components + if self.nanos: + result.append("T") + nanos = abs(self.nanos) + is_negative = self.nanos < 0 + + # Convert to hours, minutes, seconds + nanos_per_hour = 3600000000000 + hours, nanos = divmod(nanos, nanos_per_hour) + if hours: + if is_negative: + result.append("-") + result.append(f"{hours}H") + + nanos_per_minute = 60000000000 + minutes, nanos = divmod(nanos, nanos_per_minute) + if minutes: + if is_negative: + result.append("-") + result.append(f"{minutes}M") + + nanos_per_second = 1000000000 + seconds, nanos_fraction = divmod(nanos, nanos_per_second) + + if seconds or nanos_fraction: + if is_negative: + result.append("-") + if seconds: + result.append(str(seconds)) + elif nanos_fraction: + result.append("0") + + if nanos_fraction: + nano_str = f"{nanos_fraction:09d}" + trimmed = nano_str.rstrip("0") + if len(trimmed) <= 3: + while len(trimmed) < 3: + trimmed += "0" + elif len(trimmed) <= 6: + while len(trimmed) < 6: + trimmed += "0" + else: + while len(trimmed) < 9: + trimmed += "0" + result.append(f".{trimmed}") + result.append("S") + + if len(result) == 1: + result.append("0Y") # Special case for zero interval + + return "".join(result) + + @classmethod + def from_str(cls, s: str) -> 'Interval': + """Parse an ISO8601 duration format string into an Interval.""" + pattern = r'^P(-?\d+Y)?(-?\d+M)?(-?\d+D)?(T(-?\d+H)?(-?\d+M)?(-?((\d+([.,]\d{1,9})?)|([.,]\d{1,9}))S)?)?$' + match = re.match(pattern, s) + if not match or len(s) == 1: + raise ValueError(f"Invalid interval format: {s}") + + parts = match.groups() + if not any(parts[:3]) and not parts[3]: + raise ValueError(f"Invalid interval format: at least one component (Y/M/D/H/M/S) is required: {s}") + + if parts[3] == "T" and not any(parts[4:7]): + raise ValueError(f"Invalid interval format: time designator 'T' present but no time components specified: {s}") + + def parse_num(s: str, suffix: str) -> int: + if not s: + return 0 + return int(s.rstrip(suffix)) + + years = parse_num(parts[0], "Y") + months = parse_num(parts[1], "M") + total_months = years * 12 + months + + days = parse_num(parts[2], "D") + + nanos = 0 + if parts[3]: # Has time component + # Convert hours to nanoseconds + hours = parse_num(parts[4], "H") + nanos += hours * 3600000000000 + + # Convert minutes to nanoseconds + minutes = parse_num(parts[5], "M") + nanos += minutes * 60000000000 + + # Handle seconds and fractional seconds + if parts[6]: + seconds = parts[6].rstrip("S") + if "," in seconds: + seconds = seconds.replace(",", ".") + + if "." in seconds: + sec_parts = seconds.split(".") + whole_seconds = sec_parts[0] if sec_parts[0] else "0" + nanos += int(whole_seconds) * 1000000000 + frac = sec_parts[1][:9].ljust(9, "0") + frac_nanos = int(frac) + if seconds.startswith("-"): + frac_nanos = -frac_nanos + nanos += frac_nanos + else: + nanos += int(seconds) * 1000000000 + + return cls(months=total_months, days=days, nanos=nanos) + + +@dataclass +class NullInterval: + """Represents a Spanner INTERVAL that may be NULL.""" + interval: Interval + valid: bool = True + + def is_null(self) -> bool: + return not self.valid + + def __str__(self) -> str: + if not self.valid: + return "NULL" + return str(self.interval) + + def _make_value_pb(value): """Helper for :func:`_make_list_value_pbs`. @@ -251,6 +409,12 @@ def _make_value_pb(value): return Value(null_value="NULL_VALUE") else: return Value(string_value=base64.b64encode(value)) + if isinstance(value, Interval): + return Value(string_value=str(value)) + if isinstance(value, NullInterval): + if value.is_null(): + return Value(null_value="NULL_VALUE") + return Value(string_value=str(value.interval)) raise ValueError("Unknown type: %s" % (value,)) @@ -367,6 +531,8 @@ def _get_type_decoder(field_type, field_name, column_info=None): for item_field in field_type.struct_type.fields ] return lambda value_pb: _parse_struct(value_pb, element_decoders) + elif type_code == TypeCode.INTERVAL: + return _parse_interval else: raise ValueError("Unknown type: %s" % (field_type,)) @@ -473,6 +639,13 @@ def _parse_nullable(value_pb, decoder): return decoder(value_pb) +def _parse_interval(value_pb): + """Parse a Value protobuf containing an interval.""" + if hasattr(value_pb, 'string_value'): + return Interval.from_str(value_pb.string_value) + return Interval.from_str(value_pb) + + class _SessionWrapper(object): """Base class for objects wrapping a session. diff --git a/google/cloud/spanner_v1/param_types.py b/google/cloud/spanner_v1/param_types.py index 5416a26d61..72127c0e0b 100644 --- a/google/cloud/spanner_v1/param_types.py +++ b/google/cloud/spanner_v1/param_types.py @@ -36,6 +36,7 @@ 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) +INTERVAL = Type(code=TypeCode.INTERVAL) def Array(element_type): diff --git a/google/cloud/spanner_v1/streamed.py b/google/cloud/spanner_v1/streamed.py index 7c067e97b6..5de843e103 100644 --- a/google/cloud/spanner_v1/streamed.py +++ b/google/cloud/spanner_v1/streamed.py @@ -391,6 +391,7 @@ def _merge_struct(lhs, rhs, type_): TypeCode.NUMERIC: _merge_string, TypeCode.JSON: _merge_string, TypeCode.PROTO: _merge_string, + TypeCode.INTERVAL: _merge_string, TypeCode.ENUM: _merge_string, } diff --git a/tests/system/test_session_api.py b/tests/system/test_session_api.py index 4de0e681f6..196320c3fc 100644 --- a/tests/system/test_session_api.py +++ b/tests/system/test_session_api.py @@ -2907,3 +2907,211 @@ def _check_batch_status(status_code, expected=code_pb2.OK): raise exceptions.from_grpc_status( grpc_status_code, "batch_update failed", errors=[call] ) + + +def get_param_info(param_names, database_dialect): + keys = [f"p{i + 1}" for i in range(len(param_names))] + if database_dialect == DatabaseDialect.POSTGRESQL: + placeholders = [f"${i+1}" for i in range(len(param_names))] + else: + placeholders = [f"@p{i+1}" for i in range(len(param_names))] + return keys, placeholders + + +def test_interval(sessions_database, database_dialect, not_emulator): + from google.cloud.spanner_v1._helpers import Interval + + def setup_table(): + if database_dialect == DatabaseDialect.POSTGRESQL: + sessions_database.update_ddl([ + """ + CREATE TABLE IntervalTable ( + key text primary key, + create_time timestamptz, + expiry_time timestamptz, + expiry_within_month bool GENERATED ALWAYS AS (expiry_time - create_time < INTERVAL '30' DAY) STORED, + interval_array_len bigint GENERATED ALWAYS AS (ARRAY_LENGTH(ARRAY[INTERVAL '1-2 3 4:5:6'], 1)) STORED + ) + """ + ]).result() + else: + sessions_database.update_ddl([ + """ + CREATE TABLE IntervalTable ( + key STRING(MAX), + create_time TIMESTAMP, + expiry_time TIMESTAMP, + expiry_within_month bool AS (expiry_time - create_time < INTERVAL 30 DAY), + interval_array_len INT64 AS (ARRAY_LENGTH(ARRAY[INTERVAL '1-2 3 4:5:6' YEAR TO SECOND])) + ) PRIMARY KEY (key) + """ + ]).result() + + def insert_test1(transaction): + keys, placeholders = get_param_info(["key", "create_time", "expiry_time"], database_dialect) + transaction.execute_sql( + f""" + INSERT INTO IntervalTable (key, create_time, expiry_time) + VALUES ({placeholders[0]}, {placeholders[1]}, {placeholders[2]}) + """, + params={ + keys[0]: "test1", + keys[1]: datetime.datetime(2004, 11, 30, 4, 53, 54, tzinfo=UTC), + keys[2]: datetime.datetime(2004, 12, 15, 4, 53, 54, tzinfo=UTC), + }, + param_types={ + keys[0]: spanner_v1.param_types.STRING, + keys[1]: spanner_v1.param_types.TIMESTAMP, + keys[2]: spanner_v1.param_types.TIMESTAMP, + }, + ) + + def test_computed_columns(transaction): + keys, placeholders = get_param_info(["key"], database_dialect) + results = list( + transaction.execute_sql( + f""" + SELECT expiry_within_month, interval_array_len + FROM IntervalTable + WHERE key = {placeholders[0]} + """, + params={keys[0]: "test1"}, + param_types={keys[0]: spanner_v1.param_types.STRING}, + ) + ) + assert len(results) == 1 + row = results[0] + assert row[0] is True # expiry_within_month + assert row[1] == 1 # interval_array_len + + def test_interval_arithmetic(transaction): + results = list( + transaction.execute_sql( + "SELECT INTERVAL '1' DAY + INTERVAL '1' MONTH AS Col1" + ) + ) + assert len(results) == 1 + row = results[0] + interval = row[0] + assert interval.months == 1 + assert interval.days == 1 + assert interval.nanos == 0 + + def insert_test2(transaction): + keys, placeholders = get_param_info(["key", "create_time", "expiry_time"], database_dialect) + transaction.execute_sql( + f""" + INSERT INTO IntervalTable (key, create_time, expiry_time) + VALUES ({placeholders[0]}, {placeholders[1]}, {placeholders[2]}) + """, + params={ + keys[0]: "test2", + keys[1]: datetime.datetime(2004, 8, 30, 4, 53, 54, tzinfo=UTC), + keys[2]: datetime.datetime(2004, 12, 15, 4, 53, 54, tzinfo=UTC), + }, + param_types={ + keys[0]: spanner_v1.param_types.STRING, + keys[1]: spanner_v1.param_types.TIMESTAMP, + keys[2]: spanner_v1.param_types.TIMESTAMP, + }, + ) + + def test_interval_timestamp_comparison(transaction): + timestamp = "2004-11-30T10:23:54+0530" + keys, placeholders = get_param_info(["interval"], database_dialect) + if database_dialect == DatabaseDialect.POSTGRESQL: + query = f"SELECT COUNT(*) FROM IntervalTable WHERE create_time < TIMESTAMPTZ %s - {placeholders[0]}" + else: + query = f"SELECT COUNT(*) FROM IntervalTable WHERE create_time < TIMESTAMP(%s) - {placeholders[0]}" + + results = list( + transaction.execute_sql( + query % timestamp, + params={keys[0]: Interval(days=30)}, + param_types={keys[0]: spanner_v1.param_types.INTERVAL}, + ) + ) + assert len(results) == 1 + assert results[0][0] == 1 + + def test_interval_array_param(transaction, database_dialect): + intervals = [ + Interval(months=14, days=3, nanos=14706000000000), + Interval(), + Interval(months=-14, days=-3, nanos=-14706000000000), + Interval(), + ] + keys, placeholders = get_param_info(["intervals"], database_dialect) + array_type = spanner_v1.Type( + code=spanner_v1.TypeCode.ARRAY, array_element_type=spanner_v1.param_types.INTERVAL + ) + results = list( + transaction.execute_sql( + f"SELECT {placeholders[0]}", + params={keys[0]: intervals}, + param_types={keys[0]: array_type}, + ) + ) + assert len(results) == 1 + row = results[0] + intervals = row[0] + assert len(intervals) == 4 + + # Check first interval + assert intervals[0].valid is True + assert intervals[0].interval.months == 14 + assert intervals[0].interval.days == 3 + assert intervals[0].interval.nanos == 14706000000000 + + assert intervals[1].valid is True + assert intervals[1].interval.months == 0 + assert intervals[1].interval.days == 0 + assert intervals[1].interval.nanos == 0 + + assert intervals[2].valid is True + assert intervals[2].interval.months == -14 + assert intervals[2].interval.days == -3 + assert intervals[2].interval.nanos == -14706000000000 + + assert intervals[3].valid is True + assert intervals[3].interval.months == 0 + assert intervals[3].interval.days == 0 + assert intervals[3].interval.nanos == 0 + + def test_interval_array_cast(transaction): + results = list( + transaction.execute_sql( + """ + SELECT ARRAY[ + CAST('P1Y2M3DT4H5M6.789123S' AS INTERVAL), + null, + CAST('P-1Y-2M-3DT-4H-5M-6.789123S' AS INTERVAL) + ] AS Col1 + """ + ) + ) + assert len(results) == 1 + row = results[0] + intervals = row[0] + assert len(intervals) == 3 + + assert intervals[0].valid is True + assert intervals[0].interval.months == 14 # 1 year + 2 months + assert intervals[0].interval.days == 3 + assert intervals[0].interval.nanos == 14706789123000 # 4h5m6.789123s in nanos + + assert intervals[1].valid is False + + assert intervals[2].valid is True + assert intervals[2].interval.months == -14 + assert intervals[2].interval.days == -3 + assert intervals[2].interval.nanos == -14706789123000 + + setup_table() + sessions_database.run_in_transaction(insert_test1) + sessions_database.run_in_transaction(test_computed_columns) + sessions_database.run_in_transaction(test_interval_arithmetic) + sessions_database.run_in_transaction(insert_test2) + sessions_database.run_in_transaction(test_interval_timestamp_comparison) + sessions_database.run_in_transaction(test_interval_array_param) + sessions_database.run_in_transaction(test_interval_array_cast) diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index bd861cc8eb..a2117a8e91 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -1036,3 +1036,671 @@ def test_default_isolation_and_merge_options_isolation_unspecified(self): ) result = self._callFUT(default, merge) self.assertEqual(result, expected) + + +class Test_interval(unittest.TestCase): + def _callFUT(self, *args, **kw): + from google.cloud.spanner_v1._helpers import _make_value_pb + + return _make_value_pb(*args, **kw) + + def test_basic_interval(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=14, days=3, nanos=43926789000123) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "P1Y2M3DT12H12M6.789000123S") + + def test_months_only(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=10, days=0, nanos=0) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "P10M") + + def test_days_only(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=0, days=10, nanos=0) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "P10D") + + def test_seconds_only(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=0, days=0, nanos=10000000000) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "PT10S") + + def test_milliseconds_only(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=0, days=0, nanos=10000000) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "PT0.010S") + + def test_microseconds_only(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=0, days=0, nanos=10000) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "PT0.000010S") + + def test_nanoseconds_only(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=0, days=0, nanos=10) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "PT0.000000010S") + + def test_mixed_components(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=10, days=20, nanos=1030) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "P10M20DT0.000001030S") + + def test_mixed_components_with_negative_nanos(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=10, days=20, nanos=-1030) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "P10M20DT-0.000001030S") + + def test_negative_interval(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=-14, days=-3, nanos=-43926789000123) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "P-1Y-2M-3DT-12H-12M-6.789000123S") + + def test_mixed_signs(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=10, days=3, nanos=-41401234000000) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "P10M3DT-11H-30M-1.234S") + + def test_large_values(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=25, days=15, nanos=316223999999999999999) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "P2Y1M15DT87839999H59M59.999999999S") + + def test_zero_interval(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import Interval + + interval = Interval(months=0, days=0, nanos=0) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertEqual(value_pb.string_value, "P0Y") + + def test_null_interval(self): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + from google.cloud.spanner_v1._helpers import NullInterval, Interval + + interval = NullInterval(interval=Interval(), valid=False) + field_type = Type(code=TypeCode.INTERVAL) + value_pb = self._callFUT(interval) + self.assertIsInstance(value_pb, Value) + self.assertTrue(value_pb.HasField("null_value")) + + +class Test_parse_interval(unittest.TestCase): + def _callFUT(self, *args, **kw): + from google.cloud.spanner_v1._helpers import _parse_interval + + return _parse_interval(*args, **kw) + + def test_full_interval_with_all_components(self): + from google.protobuf.struct_pb2 import Value + input_str = "P1Y2M3DT12H12M6.789000123S" + expected_months = 14 + expected_days = 3 + expected_nanos = 43926789000123 + value_pb = Value(string_value=input_str) + result = self._callFUT(value_pb) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_interval_with_negative_minutes(self): + input_str = "P1Y2M3DT13H-48M6S" + expected_months = 14 + expected_days = 3 + expected_nanos = 43926000000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_date_only_interval(self): + input_str = "P1Y2M3D" + expected_months = 14 + expected_days = 3 + expected_nanos = 0 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_years_and_months_only(self): + input_str = "P1Y2M" + expected_months = 14 + expected_days = 0 + expected_nanos = 0 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_years_only(self): + input_str = "P1Y" + expected_months = 12 + expected_days = 0 + expected_nanos = 0 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_months_only(self): + input_str = "P2M" + expected_months = 2 + expected_days = 0 + expected_nanos = 0 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_days_only(self): + input_str = "P3D" + expected_months = 0 + expected_days = 3 + expected_nanos = 0 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_time_components_with_fractional_seconds(self): + input_str = "PT4H25M6.7890001S" + expected_months = 0 + expected_days = 0 + expected_nanos = 15906789000100 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_time_components_without_fractional_seconds(self): + input_str = "PT4H25M6S" + expected_months = 0 + expected_days = 0 + expected_nanos = 15906000000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_hours_and_seconds_only(self): + input_str = "PT4H30S" + expected_months = 0 + expected_days = 0 + expected_nanos = 14430000000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_hours_and_minutes_only(self): + input_str = "PT4H1M" + expected_months = 0 + expected_days = 0 + expected_nanos = 14460000000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_minutes_only(self): + input_str = "PT5M" + expected_months = 0 + expected_days = 0 + expected_nanos = 300000000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_fractional_seconds_only(self): + input_str = "PT6.789S" + expected_months = 0 + expected_days = 0 + expected_nanos = 6789000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_small_fractional_seconds(self): + input_str = "PT0.123S" + expected_months = 0 + expected_days = 0 + expected_nanos = 123000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_very_small_fractional_seconds(self): + input_str = "PT.000000123S" + expected_months = 0 + expected_days = 0 + expected_nanos = 123 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_zero_years(self): + input_str = "P0Y" + expected_months = 0 + expected_days = 0 + expected_nanos = 0 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_all_negative_components(self): + input_str = "P-1Y-2M-3DT-12H-12M-6.789000123S" + expected_months = -14 + expected_days = -3 + expected_nanos = -43926789000123 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_mixed_signs_in_components(self): + input_str = "P1Y-2M3DT13H-51M6.789S" + expected_months = 10 + expected_days = 3 + expected_nanos = 43746789000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_negative_years_with_mixed_signs(self): + input_str = "P-1Y2M-3DT-13H49M-6.789S" + expected_months = -10 + expected_days = -3 + expected_nanos = -43866789000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_negative_time_components(self): + input_str = "P1Y2M3DT-4H25M-6.7890001S" + expected_months = 14 + expected_days = 3 + expected_nanos = -12906789000100 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_large_time_values(self): + input_str = "PT100H100M100.5S" + expected_months = 0 + expected_days = 0 + expected_nanos = 366100500000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_only_time_components_with_seconds(self): + input_str = "PT12H30M1S" + expected_months = 0 + expected_days = 0 + expected_nanos = 45001000000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_date_and_time_no_seconds(self): + input_str = "P1Y2M3DT12H30M" + expected_months = 14 + expected_days = 3 + expected_nanos = 45000000000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_fractional_seconds_with_max_digits(self): + input_str = "PT0.123456789S" + expected_months = 0 + expected_days = 0 + expected_nanos = 123456789 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_hours_and_fractional_seconds(self): + input_str = "PT1H0.5S" + expected_months = 0 + expected_days = 0 + expected_nanos = 3600500000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_years_and_months_to_months_with_fractional_seconds(self): + input_str = "P1Y2M3DT12H30M1.23456789S" + expected_months = 14 + expected_days = 3 + expected_nanos = 45001234567890 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_comma_as_decimal_point(self): + input_str = "P1Y2M3DT12H30M1,23456789S" + expected_months = 14 + expected_days = 3 + expected_nanos = 45001234567890 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_fractional_seconds_without_0_before_decimal(self): + input_str = "PT.5S" + expected_months = 0 + expected_days = 0 + expected_nanos = 500000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_mixed_signs(self): + input_str = "P-1Y2M3DT12H-30M1.234S" + expected_months = -10 + expected_days = 3 + expected_nanos = 41401234000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_more_mixed_signs(self): + input_str = "P1Y-2M3DT-12H30M-1.234S" + expected_months = 10 + expected_days = 3 + expected_nanos = -41401234000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_trailing_zeros_after_decimal(self): + input_str = "PT1.234000S" + expected_months = 0 + expected_days = 0 + expected_nanos = 1234000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_all_zeros_after_decimal(self): + input_str = "PT1.000S" + expected_months = 0 + expected_days = 0 + expected_nanos = 1000000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_large_positive_hours(self): + input_str = "PT87840000H" + expected_months = 0 + expected_days = 0 + expected_nanos = 316224000000000000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_large_negative_hours(self): + input_str = "PT-87840000H" + expected_months = 0 + expected_days = 0 + expected_nanos = -316224000000000000000 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_large_mixed_values_with_max_precision(self): + input_str = "P2Y1M15DT87839999H59M59.999999999S" + expected_months = 25 + expected_days = 15 + expected_nanos = 316223999999999999999 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_large_mixed_negative_values_with_max_precision(self): + input_str = "P2Y1M15DT-87839999H-59M-59.999999999S" + expected_months = 25 + expected_days = 15 + expected_nanos = -316223999999999999999 + result = self._callFUT(input_str) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_invalid_format(self): + with self.assertRaises(ValueError): + self._callFUT("invalid") + + def test_missing_duration_specifier(self): + with self.assertRaises(ValueError): + self._callFUT("P") + + def test_missing_time_components(self): + with self.assertRaises(ValueError): + self._callFUT("PT") + + def test_missing_unit_specifier(self): + with self.assertRaises(ValueError): + self._callFUT("P1YM") + + def test_missing_t_separator(self): + with self.assertRaises(ValueError): + self._callFUT("P1Y2M3D4H5M6S") + + def test_missing_decimal_value(self): + with self.assertRaises(ValueError): + self._callFUT("P1Y2M3DT4H5M6.S") + + def test_extra_unit_specifier(self): + with self.assertRaises(ValueError): + self._callFUT("P1Y2M3DT4H5M6.789SS") + + def test_missing_value_after_decimal(self): + with self.assertRaises(ValueError): + self._callFUT("P1Y2M3DT4H5M6.") + + def test_non_digit_after_decimal(self): + with self.assertRaises(ValueError): + self._callFUT("P1Y2M3DT4H5M6.ABC") + + def test_missing_unit(self): + with self.assertRaises(ValueError): + self._callFUT("P1Y2M3") + + def test_missing_time_value(self): + with self.assertRaises(ValueError): + self._callFUT("P1Y2M3DT") + + def test_invalid_negative_sign_position(self): + with self.assertRaises(ValueError): + self._callFUT("P-T1H") + + def test_trailing_negative_sign(self): + with self.assertRaises(ValueError): + self._callFUT("PT1H-") + + def test_too_many_decimal_places(self): + with self.assertRaises(ValueError): + self._callFUT("P1Y2M3DT4H5M6.789123456789S") + + def test_multiple_decimal_points(self): + with self.assertRaises(ValueError): + self._callFUT("P1Y2M3DT4H5M6.123.456S") + + def test_both_dot_and_comma_decimals(self): + with self.assertRaises(ValueError): + self._callFUT("P1Y2M3DT4H5M6.,789S") + + def test_interval_with_years_months(self): + from google.protobuf.struct_pb2 import Value + input_str = "P1Y2M" + expected_months = 14 + expected_days = 0 + expected_nanos = 0 + value_pb = Value(string_value=input_str) + result = self._callFUT(value_pb) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_interval_with_days(self): + from google.protobuf.struct_pb2 import Value + input_str = "P3D" + expected_months = 0 + expected_days = 3 + expected_nanos = 0 + value_pb = Value(string_value=input_str) + result = self._callFUT(value_pb) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_interval_with_time(self): + from google.protobuf.struct_pb2 import Value + input_str = "PT12H12M6.789000123S" + expected_months = 0 + expected_days = 0 + expected_nanos = 43926789000123 + value_pb = Value(string_value=input_str) + result = self._callFUT(value_pb) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_interval_with_negative_components(self): + from google.protobuf.struct_pb2 import Value + input_str = "P-1Y-2M-3DT-12H-12M-6.789000123S" + expected_months = -14 + expected_days = -3 + expected_nanos = -43926789000123 + value_pb = Value(string_value=input_str) + result = self._callFUT(value_pb) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) + + def test_interval_with_zero_components(self): + from google.protobuf.struct_pb2 import Value + input_str = "P0Y0M0DT0H0M0S" + expected_months = 0 + expected_days = 0 + expected_nanos = 0 + value_pb = Value(string_value=input_str) + result = self._callFUT(value_pb) + self.assertEqual(result.months, expected_months) + self.assertEqual(result.days, expected_days) + self.assertEqual(result.nanos, expected_nanos) From 3432870452f8ab104712a52826f0a5a7bb3be879 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Wed, 23 Apr 2025 08:28:40 +0000 Subject: [PATCH 02/12] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- google/cloud/spanner_v1/_helpers.py | 62 ++++++++++++++++------------- tests/system/test_session_api.py | 41 +++++++++++-------- tests/unit/test__helpers.py | 6 +++ 3 files changed, 65 insertions(+), 44 deletions(-) diff --git a/google/cloud/spanner_v1/_helpers.py b/google/cloud/spanner_v1/_helpers.py index 8b5ee144b7..b88efbf450 100644 --- a/google/cloud/spanner_v1/_helpers.py +++ b/google/cloud/spanner_v1/_helpers.py @@ -201,13 +201,14 @@ def _datetime_to_rfc3339_nanoseconds(value): @dataclass class Interval: """Represents a Spanner INTERVAL type. - + An interval is a combination of months, days and nanoseconds. Internally, Spanner supports Interval value with the following range of individual fields: months: [-120000, 120000] days: [-3660000, 3660000] nanoseconds: [-316224000000000000000, 316224000000000000000] """ + months: int = 0 days: int = 0 nanos: int = 0 @@ -215,7 +216,7 @@ class Interval: def __str__(self) -> str: """Returns the ISO8601 duration format string representation.""" result = ["P"] - + # Handle years and months if self.months: is_negative = self.months < 0 @@ -225,17 +226,17 @@ def __str__(self) -> str: result.append(f"{'-' if is_negative else ''}{years}Y") if months: result.append(f"{'-' if is_negative else ''}{months}M") - + # Handle days if self.days: result.append(f"{self.days}D") - + # Handle time components if self.nanos: result.append("T") nanos = abs(self.nanos) is_negative = self.nanos < 0 - + # Convert to hours, minutes, seconds nanos_per_hour = 3600000000000 hours, nanos = divmod(nanos, nanos_per_hour) @@ -243,17 +244,17 @@ def __str__(self) -> str: if is_negative: result.append("-") result.append(f"{hours}H") - + nanos_per_minute = 60000000000 minutes, nanos = divmod(nanos, nanos_per_minute) if minutes: if is_negative: result.append("-") result.append(f"{minutes}M") - + nanos_per_second = 1000000000 seconds, nanos_fraction = divmod(nanos, nanos_per_second) - + if seconds or nanos_fraction: if is_negative: result.append("-") @@ -261,7 +262,7 @@ def __str__(self) -> str: result.append(str(seconds)) elif nanos_fraction: result.append("0") - + if nanos_fraction: nano_str = f"{nanos_fraction:09d}" trimmed = nano_str.rstrip("0") @@ -276,54 +277,58 @@ def __str__(self) -> str: trimmed += "0" result.append(f".{trimmed}") result.append("S") - + if len(result) == 1: result.append("0Y") # Special case for zero interval - + return "".join(result) @classmethod - def from_str(cls, s: str) -> 'Interval': + def from_str(cls, s: str) -> "Interval": """Parse an ISO8601 duration format string into an Interval.""" - pattern = r'^P(-?\d+Y)?(-?\d+M)?(-?\d+D)?(T(-?\d+H)?(-?\d+M)?(-?((\d+([.,]\d{1,9})?)|([.,]\d{1,9}))S)?)?$' + pattern = r"^P(-?\d+Y)?(-?\d+M)?(-?\d+D)?(T(-?\d+H)?(-?\d+M)?(-?((\d+([.,]\d{1,9})?)|([.,]\d{1,9}))S)?)?$" match = re.match(pattern, s) if not match or len(s) == 1: raise ValueError(f"Invalid interval format: {s}") - + parts = match.groups() if not any(parts[:3]) and not parts[3]: - raise ValueError(f"Invalid interval format: at least one component (Y/M/D/H/M/S) is required: {s}") - + raise ValueError( + f"Invalid interval format: at least one component (Y/M/D/H/M/S) is required: {s}" + ) + if parts[3] == "T" and not any(parts[4:7]): - raise ValueError(f"Invalid interval format: time designator 'T' present but no time components specified: {s}") - + raise ValueError( + f"Invalid interval format: time designator 'T' present but no time components specified: {s}" + ) + def parse_num(s: str, suffix: str) -> int: if not s: return 0 return int(s.rstrip(suffix)) - + years = parse_num(parts[0], "Y") months = parse_num(parts[1], "M") total_months = years * 12 + months - + days = parse_num(parts[2], "D") - + nanos = 0 if parts[3]: # Has time component # Convert hours to nanoseconds hours = parse_num(parts[4], "H") nanos += hours * 3600000000000 - + # Convert minutes to nanoseconds minutes = parse_num(parts[5], "M") nanos += minutes * 60000000000 - + # Handle seconds and fractional seconds if parts[6]: seconds = parts[6].rstrip("S") if "," in seconds: seconds = seconds.replace(",", ".") - + if "." in seconds: sec_parts = seconds.split(".") whole_seconds = sec_parts[0] if sec_parts[0] else "0" @@ -335,19 +340,20 @@ def parse_num(s: str, suffix: str) -> int: nanos += frac_nanos else: nanos += int(seconds) * 1000000000 - + return cls(months=total_months, days=days, nanos=nanos) @dataclass class NullInterval: """Represents a Spanner INTERVAL that may be NULL.""" + interval: Interval valid: bool = True - + def is_null(self) -> bool: return not self.valid - + def __str__(self) -> str: if not self.valid: return "NULL" @@ -641,7 +647,7 @@ def _parse_nullable(value_pb, decoder): def _parse_interval(value_pb): """Parse a Value protobuf containing an interval.""" - if hasattr(value_pb, 'string_value'): + if hasattr(value_pb, "string_value"): return Interval.from_str(value_pb.string_value) return Interval.from_str(value_pb) diff --git a/tests/system/test_session_api.py b/tests/system/test_session_api.py index 196320c3fc..a16560c4b0 100644 --- a/tests/system/test_session_api.py +++ b/tests/system/test_session_api.py @@ -2923,8 +2923,9 @@ def test_interval(sessions_database, database_dialect, not_emulator): def setup_table(): if database_dialect == DatabaseDialect.POSTGRESQL: - sessions_database.update_ddl([ - """ + sessions_database.update_ddl( + [ + """ CREATE TABLE IntervalTable ( key text primary key, create_time timestamptz, @@ -2933,10 +2934,12 @@ def setup_table(): interval_array_len bigint GENERATED ALWAYS AS (ARRAY_LENGTH(ARRAY[INTERVAL '1-2 3 4:5:6'], 1)) STORED ) """ - ]).result() + ] + ).result() else: - sessions_database.update_ddl([ - """ + sessions_database.update_ddl( + [ + """ CREATE TABLE IntervalTable ( key STRING(MAX), create_time TIMESTAMP, @@ -2945,10 +2948,13 @@ def setup_table(): interval_array_len INT64 AS (ARRAY_LENGTH(ARRAY[INTERVAL '1-2 3 4:5:6' YEAR TO SECOND])) ) PRIMARY KEY (key) """ - ]).result() + ] + ).result() def insert_test1(transaction): - keys, placeholders = get_param_info(["key", "create_time", "expiry_time"], database_dialect) + keys, placeholders = get_param_info( + ["key", "create_time", "expiry_time"], database_dialect + ) transaction.execute_sql( f""" INSERT INTO IntervalTable (key, create_time, expiry_time) @@ -2998,7 +3004,9 @@ def test_interval_arithmetic(transaction): assert interval.nanos == 0 def insert_test2(transaction): - keys, placeholders = get_param_info(["key", "create_time", "expiry_time"], database_dialect) + keys, placeholders = get_param_info( + ["key", "create_time", "expiry_time"], database_dialect + ) transaction.execute_sql( f""" INSERT INTO IntervalTable (key, create_time, expiry_time) @@ -3043,7 +3051,8 @@ def test_interval_array_param(transaction, database_dialect): ] keys, placeholders = get_param_info(["intervals"], database_dialect) array_type = spanner_v1.Type( - code=spanner_v1.TypeCode.ARRAY, array_element_type=spanner_v1.param_types.INTERVAL + code=spanner_v1.TypeCode.ARRAY, + array_element_type=spanner_v1.param_types.INTERVAL, ) results = list( transaction.execute_sql( @@ -3056,23 +3065,23 @@ def test_interval_array_param(transaction, database_dialect): row = results[0] intervals = row[0] assert len(intervals) == 4 - + # Check first interval assert intervals[0].valid is True assert intervals[0].interval.months == 14 assert intervals[0].interval.days == 3 assert intervals[0].interval.nanos == 14706000000000 - + assert intervals[1].valid is True assert intervals[1].interval.months == 0 assert intervals[1].interval.days == 0 assert intervals[1].interval.nanos == 0 - + assert intervals[2].valid is True assert intervals[2].interval.months == -14 assert intervals[2].interval.days == -3 assert intervals[2].interval.nanos == -14706000000000 - + assert intervals[3].valid is True assert intervals[3].interval.months == 0 assert intervals[3].interval.days == 0 @@ -3094,14 +3103,14 @@ def test_interval_array_cast(transaction): row = results[0] intervals = row[0] assert len(intervals) == 3 - + assert intervals[0].valid is True assert intervals[0].interval.months == 14 # 1 year + 2 months assert intervals[0].interval.days == 3 assert intervals[0].interval.nanos == 14706789123000 # 4h5m6.789123s in nanos - + assert intervals[1].valid is False - + assert intervals[2].valid is True assert intervals[2].interval.months == -14 assert intervals[2].interval.days == -3 diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index a2117a8e91..6d2c87c663 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -1221,6 +1221,7 @@ def _callFUT(self, *args, **kw): def test_full_interval_with_all_components(self): from google.protobuf.struct_pb2 import Value + input_str = "P1Y2M3DT12H12M6.789000123S" expected_months = 14 expected_days = 3 @@ -1647,6 +1648,7 @@ def test_both_dot_and_comma_decimals(self): def test_interval_with_years_months(self): from google.protobuf.struct_pb2 import Value + input_str = "P1Y2M" expected_months = 14 expected_days = 0 @@ -1659,6 +1661,7 @@ def test_interval_with_years_months(self): def test_interval_with_days(self): from google.protobuf.struct_pb2 import Value + input_str = "P3D" expected_months = 0 expected_days = 3 @@ -1671,6 +1674,7 @@ def test_interval_with_days(self): def test_interval_with_time(self): from google.protobuf.struct_pb2 import Value + input_str = "PT12H12M6.789000123S" expected_months = 0 expected_days = 0 @@ -1683,6 +1687,7 @@ def test_interval_with_time(self): def test_interval_with_negative_components(self): from google.protobuf.struct_pb2 import Value + input_str = "P-1Y-2M-3DT-12H-12M-6.789000123S" expected_months = -14 expected_days = -3 @@ -1695,6 +1700,7 @@ def test_interval_with_negative_components(self): def test_interval_with_zero_components(self): from google.protobuf.struct_pb2 import Value + input_str = "P0Y0M0DT0H0M0S" expected_months = 0 expected_days = 0 From 14f18f33db3506254aea5b003b4cb4c223958688 Mon Sep 17 00:00:00 2001 From: rahul yadav Date: Wed, 23 Apr 2025 16:39:23 +0530 Subject: [PATCH 03/12] fix test --- google/cloud/spanner_v1/_helpers.py | 21 ------- tests/system/test_session_api.py | 94 +++++++++++++---------------- tests/unit/test__helpers.py | 12 ---- 3 files changed, 42 insertions(+), 85 deletions(-) diff --git a/google/cloud/spanner_v1/_helpers.py b/google/cloud/spanner_v1/_helpers.py index b88efbf450..2bd547fd32 100644 --- a/google/cloud/spanner_v1/_helpers.py +++ b/google/cloud/spanner_v1/_helpers.py @@ -343,23 +343,6 @@ def parse_num(s: str, suffix: str) -> int: return cls(months=total_months, days=days, nanos=nanos) - -@dataclass -class NullInterval: - """Represents a Spanner INTERVAL that may be NULL.""" - - interval: Interval - valid: bool = True - - def is_null(self) -> bool: - return not self.valid - - def __str__(self) -> str: - if not self.valid: - return "NULL" - return str(self.interval) - - def _make_value_pb(value): """Helper for :func:`_make_list_value_pbs`. @@ -417,10 +400,6 @@ 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, NullInterval): - if value.is_null(): - return Value(null_value="NULL_VALUE") - return Value(string_value=str(value.interval)) raise ValueError("Unknown type: %s" % (value,)) diff --git a/tests/system/test_session_api.py b/tests/system/test_session_api.py index a16560c4b0..8a2692241e 100644 --- a/tests/system/test_session_api.py +++ b/tests/system/test_session_api.py @@ -2955,7 +2955,7 @@ def insert_test1(transaction): keys, placeholders = get_param_info( ["key", "create_time", "expiry_time"], database_dialect ) - transaction.execute_sql( + transaction.execute_update( f""" INSERT INTO IntervalTable (key, create_time, expiry_time) VALUES ({placeholders[0]}, {placeholders[1]}, {placeholders[2]}) @@ -2972,6 +2972,25 @@ def insert_test1(transaction): }, ) + def insert_test2(transaction): + keys, placeholders = get_param_info(["key", "create_time", "expiry_time"], database_dialect) + transaction.execute_update( + f""" + INSERT INTO IntervalTable (key, create_time, expiry_time) + VALUES ({placeholders[0]}, {placeholders[1]}, {placeholders[2]}) + """, + params={ + keys[0]: "test2", + keys[1]: datetime.datetime(2004, 8, 30, 4, 53, 54, tzinfo=UTC), + keys[2]: datetime.datetime(2004, 12, 15, 4, 53, 54, tzinfo=UTC), + }, + param_types={ + keys[0]: spanner_v1.param_types.STRING, + keys[1]: spanner_v1.param_types.TIMESTAMP, + keys[2]: spanner_v1.param_types.TIMESTAMP, + }, + ) + def test_computed_columns(transaction): keys, placeholders = get_param_info(["key"], database_dialect) results = list( @@ -3003,34 +3022,13 @@ def test_interval_arithmetic(transaction): assert interval.days == 1 assert interval.nanos == 0 - def insert_test2(transaction): - keys, placeholders = get_param_info( - ["key", "create_time", "expiry_time"], database_dialect - ) - transaction.execute_sql( - f""" - INSERT INTO IntervalTable (key, create_time, expiry_time) - VALUES ({placeholders[0]}, {placeholders[1]}, {placeholders[2]}) - """, - params={ - keys[0]: "test2", - keys[1]: datetime.datetime(2004, 8, 30, 4, 53, 54, tzinfo=UTC), - keys[2]: datetime.datetime(2004, 12, 15, 4, 53, 54, tzinfo=UTC), - }, - param_types={ - keys[0]: spanner_v1.param_types.STRING, - keys[1]: spanner_v1.param_types.TIMESTAMP, - keys[2]: spanner_v1.param_types.TIMESTAMP, - }, - ) - def test_interval_timestamp_comparison(transaction): timestamp = "2004-11-30T10:23:54+0530" keys, placeholders = get_param_info(["interval"], database_dialect) if database_dialect == DatabaseDialect.POSTGRESQL: - query = f"SELECT COUNT(*) FROM IntervalTable WHERE create_time < TIMESTAMPTZ %s - {placeholders[0]}" + query = f"SELECT COUNT(*) FROM IntervalTable WHERE create_time < TIMESTAMPTZ '%s' - {placeholders[0]}" else: - query = f"SELECT COUNT(*) FROM IntervalTable WHERE create_time < TIMESTAMP(%s) - {placeholders[0]}" + query = f"SELECT COUNT(*) FROM IntervalTable WHERE create_time < TIMESTAMP('%s') - {placeholders[0]}" results = list( transaction.execute_sql( @@ -3042,12 +3040,12 @@ def test_interval_timestamp_comparison(transaction): assert len(results) == 1 assert results[0][0] == 1 - def test_interval_array_param(transaction, database_dialect): + def test_interval_array_param(transaction): intervals = [ Interval(months=14, days=3, nanos=14706000000000), Interval(), Interval(months=-14, days=-3, nanos=-14706000000000), - Interval(), + None, ] keys, placeholders = get_param_info(["intervals"], database_dialect) array_type = spanner_v1.Type( @@ -3066,26 +3064,20 @@ def test_interval_array_param(transaction, database_dialect): intervals = row[0] assert len(intervals) == 4 - # Check first interval - assert intervals[0].valid is True - assert intervals[0].interval.months == 14 - assert intervals[0].interval.days == 3 - assert intervals[0].interval.nanos == 14706000000000 + assert intervals[0].months == 14 + assert intervals[0].days == 3 + assert intervals[0].nanos == 14706000000000 + + assert intervals[1].months == 0 + assert intervals[1].days == 0 + assert intervals[1].nanos == 0 - assert intervals[1].valid is True - assert intervals[1].interval.months == 0 - assert intervals[1].interval.days == 0 - assert intervals[1].interval.nanos == 0 + assert intervals[2].months == -14 + assert intervals[2].days == -3 + assert intervals[2].nanos == -14706000000000 - assert intervals[2].valid is True - assert intervals[2].interval.months == -14 - assert intervals[2].interval.days == -3 - assert intervals[2].interval.nanos == -14706000000000 + assert intervals[3] is None - assert intervals[3].valid is True - assert intervals[3].interval.months == 0 - assert intervals[3].interval.days == 0 - assert intervals[3].interval.nanos == 0 def test_interval_array_cast(transaction): results = list( @@ -3104,17 +3096,15 @@ def test_interval_array_cast(transaction): intervals = row[0] assert len(intervals) == 3 - assert intervals[0].valid is True - assert intervals[0].interval.months == 14 # 1 year + 2 months - assert intervals[0].interval.days == 3 - assert intervals[0].interval.nanos == 14706789123000 # 4h5m6.789123s in nanos + assert intervals[0].months == 14 # 1 year + 2 months + assert intervals[0].days == 3 + assert intervals[0].nanos == 14706789123000 # 4h5m6.789123s in nanos - assert intervals[1].valid is False + assert intervals[1] is None - assert intervals[2].valid is True - assert intervals[2].interval.months == -14 - assert intervals[2].interval.days == -3 - assert intervals[2].interval.nanos == -14706789123000 + assert intervals[2].months == -14 + assert intervals[2].days == -3 + assert intervals[2].nanos == -14706789123000 setup_table() sessions_database.run_in_transaction(insert_test1) diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index 6d2c87c663..83f13869a3 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -1200,18 +1200,6 @@ def test_zero_interval(self): self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "P0Y") - def test_null_interval(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode - from google.cloud.spanner_v1._helpers import NullInterval, Interval - - interval = NullInterval(interval=Interval(), valid=False) - field_type = Type(code=TypeCode.INTERVAL) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertTrue(value_pb.HasField("null_value")) - class Test_parse_interval(unittest.TestCase): def _callFUT(self, *args, **kw): From af6bb560643d3ccf839221a216c0c2e16ad9e0ed Mon Sep 17 00:00:00 2001 From: rahul yadav Date: Wed, 23 Apr 2025 20:20:57 +0530 Subject: [PATCH 04/12] fix build --- google/cloud/spanner_v1/_helpers.py | 1 + tests/system/test_session_api.py | 12 ++++----- tests/unit/test__helpers.py | 39 ----------------------------- tests/unit/test_metrics.py | 1 - 4 files changed, 6 insertions(+), 47 deletions(-) diff --git a/google/cloud/spanner_v1/_helpers.py b/google/cloud/spanner_v1/_helpers.py index 2bd547fd32..90aa6ae012 100644 --- a/google/cloud/spanner_v1/_helpers.py +++ b/google/cloud/spanner_v1/_helpers.py @@ -343,6 +343,7 @@ def parse_num(s: str, suffix: str) -> int: return cls(months=total_months, days=days, nanos=nanos) + def _make_value_pb(value): """Helper for :func:`_make_list_value_pbs`. diff --git a/tests/system/test_session_api.py b/tests/system/test_session_api.py index 8a2692241e..cadb809656 100644 --- a/tests/system/test_session_api.py +++ b/tests/system/test_session_api.py @@ -2912,9 +2912,9 @@ def _check_batch_status(status_code, expected=code_pb2.OK): def get_param_info(param_names, database_dialect): keys = [f"p{i + 1}" for i in range(len(param_names))] if database_dialect == DatabaseDialect.POSTGRESQL: - placeholders = [f"${i+1}" for i in range(len(param_names))] + placeholders = [f"${i + 1}" for i in range(len(param_names))] else: - placeholders = [f"@p{i+1}" for i in range(len(param_names))] + placeholders = [f"@p{i + 1}" for i in range(len(param_names))] return keys, placeholders @@ -2996,10 +2996,9 @@ def test_computed_columns(transaction): results = list( transaction.execute_sql( f""" - SELECT expiry_within_month, interval_array_len - FROM IntervalTable - WHERE key = {placeholders[0]} - """, + SELECT expiry_within_month, interval_array_len + FROM IntervalTable + WHERE key = {placeholders[0]}""", params={keys[0]: "test1"}, param_types={keys[0]: spanner_v1.param_types.STRING}, ) @@ -3078,7 +3077,6 @@ def test_interval_array_param(transaction): assert intervals[3] is None - def test_interval_array_cast(transaction): results = list( transaction.execute_sql( diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index 83f13869a3..9052118738 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -1046,156 +1046,117 @@ def _callFUT(self, *args, **kw): def test_basic_interval(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=14, days=3, nanos=43926789000123) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "P1Y2M3DT12H12M6.789000123S") def test_months_only(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=10, days=0, nanos=0) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "P10M") def test_days_only(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=0, days=10, nanos=0) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "P10D") def test_seconds_only(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=0, days=0, nanos=10000000000) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "PT10S") def test_milliseconds_only(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=0, days=0, nanos=10000000) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "PT0.010S") def test_microseconds_only(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=0, days=0, nanos=10000) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "PT0.000010S") def test_nanoseconds_only(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=0, days=0, nanos=10) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "PT0.000000010S") def test_mixed_components(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=10, days=20, nanos=1030) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "P10M20DT0.000001030S") def test_mixed_components_with_negative_nanos(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=10, days=20, nanos=-1030) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "P10M20DT-0.000001030S") def test_negative_interval(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=-14, days=-3, nanos=-43926789000123) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "P-1Y-2M-3DT-12H-12M-6.789000123S") def test_mixed_signs(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=10, days=3, nanos=-41401234000000) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "P10M3DT-11H-30M-1.234S") def test_large_values(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=25, days=15, nanos=316223999999999999999) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "P2Y1M15DT87839999H59M59.999999999S") def test_zero_interval(self): from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1 import Type - from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1._helpers import Interval interval = Interval(months=0, days=0, nanos=0) - field_type = Type(code=TypeCode.INTERVAL) value_pb = self._callFUT(interval) self.assertIsInstance(value_pb, Value) self.assertEqual(value_pb.string_value, "P0Y") diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py index cd5ca2e6fc..bb2695553b 100644 --- a/tests/unit/test_metrics.py +++ b/tests/unit/test_metrics.py @@ -65,7 +65,6 @@ def mocked_call(*args, **kwargs): return _UnaryOutcome(MagicMock(), MagicMock()) def intercept_wrapper(invoked_method, request_or_iterator, call_details): - nonlocal original_intercept nonlocal first_attempt invoked_method = mocked_call if first_attempt: From cfdfbd06f4dd0edf198330adb3c3d8c61460b5d4 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Wed, 23 Apr 2025 14:55:16 +0000 Subject: [PATCH 05/12] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- tests/system/test_session_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/system/test_session_api.py b/tests/system/test_session_api.py index cadb809656..0c251f8797 100644 --- a/tests/system/test_session_api.py +++ b/tests/system/test_session_api.py @@ -2973,7 +2973,9 @@ def insert_test1(transaction): ) def insert_test2(transaction): - keys, placeholders = get_param_info(["key", "create_time", "expiry_time"], database_dialect) + keys, placeholders = get_param_info( + ["key", "create_time", "expiry_time"], database_dialect + ) transaction.execute_update( f""" INSERT INTO IntervalTable (key, create_time, expiry_time) From ffdc737f46ae099f3b3615288e4fee238de9904d Mon Sep 17 00:00:00 2001 From: rahul yadav Date: Wed, 23 Apr 2025 22:16:55 +0530 Subject: [PATCH 06/12] incorporate suggestions --- google/cloud/spanner_v1/__init__.py | 3 +- google/cloud/spanner_v1/_helpers.py | 150 +--- google/cloud/spanner_v1/data_types.py | 149 +++- tests/system/test_session_api.py | 2 +- tests/unit/test__helpers.py | 1094 +++++++++++-------------- 5 files changed, 643 insertions(+), 755 deletions(-) diff --git a/google/cloud/spanner_v1/__init__.py b/google/cloud/spanner_v1/__init__.py index beeed1dacf..48b11d9342 100644 --- a/google/cloud/spanner_v1/__init__.py +++ b/google/cloud/spanner_v1/__init__.py @@ -63,7 +63,7 @@ from .types.type import Type from .types.type import TypeAnnotationCode from .types.type import TypeCode -from .data_types import JsonObject +from .data_types import JsonObject, Interval from .transaction import BatchTransactionId, DefaultTransactionOptions from google.cloud.spanner_v1 import param_types @@ -145,6 +145,7 @@ "TypeCode", # Custom spanner related data types "JsonObject", + "Interval", # google.cloud.spanner_v1.services "SpannerClient", "SpannerAsyncClient", diff --git a/google/cloud/spanner_v1/_helpers.py b/google/cloud/spanner_v1/_helpers.py index 90aa6ae012..73a7679a6e 100644 --- a/google/cloud/spanner_v1/_helpers.py +++ b/google/cloud/spanner_v1/_helpers.py @@ -20,8 +20,6 @@ import time import base64 import threading -import re -from dataclasses import dataclass from google.protobuf.struct_pb2 import ListValue from google.protobuf.struct_pb2 import Value @@ -33,7 +31,7 @@ from google.cloud._helpers import _date_from_iso8601_date from google.cloud.spanner_v1 import TypeCode from google.cloud.spanner_v1 import ExecuteSqlRequest -from google.cloud.spanner_v1 import JsonObject +from google.cloud.spanner_v1 import JsonObject, Interval from google.cloud.spanner_v1 import TransactionOptions from google.cloud.spanner_v1.request_id_header import with_request_id from google.rpc.error_details_pb2 import RetryInfo @@ -198,152 +196,6 @@ def _datetime_to_rfc3339_nanoseconds(value): return "{}.{}Z".format(value.isoformat(sep="T", timespec="seconds"), nanos) -@dataclass -class Interval: - """Represents a Spanner INTERVAL type. - - An interval is a combination of months, days and nanoseconds. - Internally, Spanner supports Interval value with the following range of individual fields: - months: [-120000, 120000] - days: [-3660000, 3660000] - nanoseconds: [-316224000000000000000, 316224000000000000000] - """ - - months: int = 0 - days: int = 0 - nanos: int = 0 - - def __str__(self) -> str: - """Returns the ISO8601 duration format string representation.""" - result = ["P"] - - # Handle years and months - if self.months: - is_negative = self.months < 0 - abs_months = abs(self.months) - years, months = divmod(abs_months, 12) - if years: - result.append(f"{'-' if is_negative else ''}{years}Y") - if months: - result.append(f"{'-' if is_negative else ''}{months}M") - - # Handle days - if self.days: - result.append(f"{self.days}D") - - # Handle time components - if self.nanos: - result.append("T") - nanos = abs(self.nanos) - is_negative = self.nanos < 0 - - # Convert to hours, minutes, seconds - nanos_per_hour = 3600000000000 - hours, nanos = divmod(nanos, nanos_per_hour) - if hours: - if is_negative: - result.append("-") - result.append(f"{hours}H") - - nanos_per_minute = 60000000000 - minutes, nanos = divmod(nanos, nanos_per_minute) - if minutes: - if is_negative: - result.append("-") - result.append(f"{minutes}M") - - nanos_per_second = 1000000000 - seconds, nanos_fraction = divmod(nanos, nanos_per_second) - - if seconds or nanos_fraction: - if is_negative: - result.append("-") - if seconds: - result.append(str(seconds)) - elif nanos_fraction: - result.append("0") - - if nanos_fraction: - nano_str = f"{nanos_fraction:09d}" - trimmed = nano_str.rstrip("0") - if len(trimmed) <= 3: - while len(trimmed) < 3: - trimmed += "0" - elif len(trimmed) <= 6: - while len(trimmed) < 6: - trimmed += "0" - else: - while len(trimmed) < 9: - trimmed += "0" - result.append(f".{trimmed}") - result.append("S") - - if len(result) == 1: - result.append("0Y") # Special case for zero interval - - return "".join(result) - - @classmethod - def from_str(cls, s: str) -> "Interval": - """Parse an ISO8601 duration format string into an Interval.""" - pattern = r"^P(-?\d+Y)?(-?\d+M)?(-?\d+D)?(T(-?\d+H)?(-?\d+M)?(-?((\d+([.,]\d{1,9})?)|([.,]\d{1,9}))S)?)?$" - match = re.match(pattern, s) - if not match or len(s) == 1: - raise ValueError(f"Invalid interval format: {s}") - - parts = match.groups() - if not any(parts[:3]) and not parts[3]: - raise ValueError( - f"Invalid interval format: at least one component (Y/M/D/H/M/S) is required: {s}" - ) - - if parts[3] == "T" and not any(parts[4:7]): - raise ValueError( - f"Invalid interval format: time designator 'T' present but no time components specified: {s}" - ) - - def parse_num(s: str, suffix: str) -> int: - if not s: - return 0 - return int(s.rstrip(suffix)) - - years = parse_num(parts[0], "Y") - months = parse_num(parts[1], "M") - total_months = years * 12 + months - - days = parse_num(parts[2], "D") - - nanos = 0 - if parts[3]: # Has time component - # Convert hours to nanoseconds - hours = parse_num(parts[4], "H") - nanos += hours * 3600000000000 - - # Convert minutes to nanoseconds - minutes = parse_num(parts[5], "M") - nanos += minutes * 60000000000 - - # Handle seconds and fractional seconds - if parts[6]: - seconds = parts[6].rstrip("S") - if "," in seconds: - seconds = seconds.replace(",", ".") - - if "." in seconds: - sec_parts = seconds.split(".") - whole_seconds = sec_parts[0] if sec_parts[0] else "0" - nanos += int(whole_seconds) * 1000000000 - frac = sec_parts[1][:9].ljust(9, "0") - frac_nanos = int(frac) - if seconds.startswith("-"): - frac_nanos = -frac_nanos - nanos += frac_nanos - else: - nanos += int(seconds) * 1000000000 - - return cls(months=total_months, days=days, nanos=nanos) - - def _make_value_pb(value): """Helper for :func:`_make_list_value_pbs`. diff --git a/google/cloud/spanner_v1/data_types.py b/google/cloud/spanner_v1/data_types.py index 6b1ba5df49..6703f359e9 100644 --- a/google/cloud/spanner_v1/data_types.py +++ b/google/cloud/spanner_v1/data_types.py @@ -16,7 +16,8 @@ import json import types - +import re +from dataclasses import dataclass from google.protobuf.message import Message from google.protobuf.internal.enum_type_wrapper import EnumTypeWrapper @@ -97,6 +98,152 @@ def serialize(self): return json.dumps(self, sort_keys=True, separators=(",", ":")) +@dataclass +class Interval: + """Represents a Spanner INTERVAL type. + + An interval is a combination of months, days and nanoseconds. + Internally, Spanner supports Interval value with the following range of individual fields: + months: [-120000, 120000] + days: [-3660000, 3660000] + nanoseconds: [-316224000000000000000, 316224000000000000000] + """ + + months: int = 0 + days: int = 0 + nanos: int = 0 + + def __str__(self) -> str: + """Returns the ISO8601 duration format string representation.""" + result = ["P"] + + # Handle years and months + if self.months: + is_negative = self.months < 0 + abs_months = abs(self.months) + years, months = divmod(abs_months, 12) + if years: + result.append(f"{'-' if is_negative else ''}{years}Y") + if months: + result.append(f"{'-' if is_negative else ''}{months}M") + + # Handle days + if self.days: + result.append(f"{self.days}D") + + # Handle time components + if self.nanos: + result.append("T") + nanos = abs(self.nanos) + is_negative = self.nanos < 0 + + # Convert to hours, minutes, seconds + nanos_per_hour = 3600000000000 + hours, nanos = divmod(nanos, nanos_per_hour) + if hours: + if is_negative: + result.append("-") + result.append(f"{hours}H") + + nanos_per_minute = 60000000000 + minutes, nanos = divmod(nanos, nanos_per_minute) + if minutes: + if is_negative: + result.append("-") + result.append(f"{minutes}M") + + nanos_per_second = 1000000000 + seconds, nanos_fraction = divmod(nanos, nanos_per_second) + + if seconds or nanos_fraction: + if is_negative: + result.append("-") + if seconds: + result.append(str(seconds)) + elif nanos_fraction: + result.append("0") + + if nanos_fraction: + nano_str = f"{nanos_fraction:09d}" + trimmed = nano_str.rstrip("0") + if len(trimmed) <= 3: + while len(trimmed) < 3: + trimmed += "0" + elif len(trimmed) <= 6: + while len(trimmed) < 6: + trimmed += "0" + else: + while len(trimmed) < 9: + trimmed += "0" + result.append(f".{trimmed}") + result.append("S") + + if len(result) == 1: + result.append("0Y") # Special case for zero interval + + return "".join(result) + + @classmethod + def from_str(cls, s: str) -> "Interval": + """Parse an ISO8601 duration format string into an Interval.""" + pattern = r"^P(-?\d+Y)?(-?\d+M)?(-?\d+D)?(T(-?\d+H)?(-?\d+M)?(-?((\d+([.,]\d{1,9})?)|([.,]\d{1,9}))S)?)?$" + match = re.match(pattern, s) + if not match or len(s) == 1: + raise ValueError(f"Invalid interval format: {s}") + + parts = match.groups() + if not any(parts[:3]) and not parts[3]: + raise ValueError( + f"Invalid interval format: at least one component (Y/M/D/H/M/S) is required: {s}" + ) + + if parts[3] == "T" and not any(parts[4:7]): + raise ValueError( + f"Invalid interval format: time designator 'T' present but no time components specified: {s}" + ) + + def parse_num(s: str, suffix: str) -> int: + if not s: + return 0 + return int(s.rstrip(suffix)) + + years = parse_num(parts[0], "Y") + months = parse_num(parts[1], "M") + total_months = years * 12 + months + + days = parse_num(parts[2], "D") + + nanos = 0 + if parts[3]: # Has time component + # Convert hours to nanoseconds + hours = parse_num(parts[4], "H") + nanos += hours * 3600000000000 + + # Convert minutes to nanoseconds + minutes = parse_num(parts[5], "M") + nanos += minutes * 60000000000 + + # Handle seconds and fractional seconds + if parts[6]: + seconds = parts[6].rstrip("S") + if "," in seconds: + seconds = seconds.replace(",", ".") + + if "." in seconds: + sec_parts = seconds.split(".") + whole_seconds = sec_parts[0] if sec_parts[0] else "0" + nanos += int(whole_seconds) * 1000000000 + frac = sec_parts[1][:9].ljust(9, "0") + frac_nanos = int(frac) + if seconds.startswith("-"): + frac_nanos = -frac_nanos + nanos += frac_nanos + else: + nanos += int(seconds) * 1000000000 + + return cls(months=total_months, days=days, nanos=nanos) + + def _proto_message(bytes_val, proto_message_object): """Helper for :func:`get_proto_message`. parses serialized protocol buffer bytes data into proto message. diff --git a/tests/system/test_session_api.py b/tests/system/test_session_api.py index 0c251f8797..73b55b035d 100644 --- a/tests/system/test_session_api.py +++ b/tests/system/test_session_api.py @@ -2919,7 +2919,7 @@ def get_param_info(param_names, database_dialect): def test_interval(sessions_database, database_dialect, not_emulator): - from google.cloud.spanner_v1._helpers import Interval + from google.cloud.spanner_v1 import Interval def setup_table(): if database_dialect == DatabaseDialect.POSTGRESQL: diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index 9052118738..afaf20cb89 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -1039,623 +1039,511 @@ def test_default_isolation_and_merge_options_isolation_unspecified(self): class Test_interval(unittest.TestCase): + from google.protobuf.struct_pb2 import Value + from google.cloud.spanner_v1 import Interval + from google.cloud.spanner_v1 import Type + from google.cloud.spanner_v1 import TypeCode + def _callFUT(self, *args, **kw): from google.cloud.spanner_v1._helpers import _make_value_pb return _make_value_pb(*args, **kw) - def test_basic_interval(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=14, days=3, nanos=43926789000123) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "P1Y2M3DT12H12M6.789000123S") - - def test_months_only(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=10, days=0, nanos=0) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "P10M") - - def test_days_only(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=0, days=10, nanos=0) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "P10D") - - def test_seconds_only(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=0, days=0, nanos=10000000000) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "PT10S") - - def test_milliseconds_only(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=0, days=0, nanos=10000000) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "PT0.010S") - - def test_microseconds_only(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=0, days=0, nanos=10000) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "PT0.000010S") - - def test_nanoseconds_only(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=0, days=0, nanos=10) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "PT0.000000010S") - - def test_mixed_components(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=10, days=20, nanos=1030) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "P10M20DT0.000001030S") - - def test_mixed_components_with_negative_nanos(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=10, days=20, nanos=-1030) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "P10M20DT-0.000001030S") - - def test_negative_interval(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=-14, days=-3, nanos=-43926789000123) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "P-1Y-2M-3DT-12H-12M-6.789000123S") - - def test_mixed_signs(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=10, days=3, nanos=-41401234000000) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "P10M3DT-11H-30M-1.234S") - - def test_large_values(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval - - interval = Interval(months=25, days=15, nanos=316223999999999999999) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "P2Y1M15DT87839999H59M59.999999999S") - - def test_zero_interval(self): - from google.protobuf.struct_pb2 import Value - from google.cloud.spanner_v1._helpers import Interval + def test_interval_cases(self): + test_cases = [ + { + "name": "Basic interval", + "interval": self.Interval(months=14, days=3, nanos=43926789000123), + "expected": "P1Y2M3DT12H12M6.789000123S", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Months only", + "interval": self.Interval(months=10, days=0, nanos=0), + "expected": "P10M", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Days only", + "interval": self.Interval(months=0, days=10, nanos=0), + "expected": "P10D", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Seconds only", + "interval": self.Interval(months=0, days=0, nanos=10000000000), + "expected": "PT10S", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Milliseconds only", + "interval": self.Interval(months=0, days=0, nanos=10000000), + "expected": "PT0.010S", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Microseconds only", + "interval": self.Interval(months=0, days=0, nanos=10000), + "expected": "PT0.000010S", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Nanoseconds only", + "interval": self.Interval(months=0, days=0, nanos=10), + "expected": "PT0.000000010S", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Mixed components", + "interval": self.Interval(months=10, days=20, nanos=1030), + "expected": "P10M20DT0.000001030S", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Mixed components with negative nanos", + "interval": self.Interval(months=10, days=20, nanos=-1030), + "expected": "P10M20DT-0.000001030S", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Negative interval", + "interval": self.Interval(months=-14, days=-3, nanos=-43926789000123), + "expected": "P-1Y-2M-3DT-12H-12M-6.789000123S", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Mixed signs", + "interval": self.Interval(months=10, days=3, nanos=-41401234000000), + "expected": "P10M3DT-11H-30M-1.234S", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Large values", + "interval": self.Interval(months=25, days=15, nanos=316223999999999999999), + "expected": "P2Y1M15DT87839999H59M59.999999999S", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + }, + { + "name": "Zero interval", + "interval": self.Interval(months=0, days=0, nanos=0), + "expected": "P0Y", + "expected_type": self.Type(code=self.TypeCode.INTERVAL) + } + ] - interval = Interval(months=0, days=0, nanos=0) - value_pb = self._callFUT(interval) - self.assertIsInstance(value_pb, Value) - self.assertEqual(value_pb.string_value, "P0Y") + for case in test_cases: + with self.subTest(name=case["name"]): + value_pb = self._callFUT(case["interval"]) + self.assertIsInstance(value_pb, self.Value) + self.assertEqual(value_pb.string_value, case["expected"]) + # TODO: Add type checking once we have access to the type information class Test_parse_interval(unittest.TestCase): + from google.protobuf.struct_pb2 import Value + def _callFUT(self, *args, **kw): from google.cloud.spanner_v1._helpers import _parse_interval return _parse_interval(*args, **kw) - def test_full_interval_with_all_components(self): - from google.protobuf.struct_pb2 import Value - - input_str = "P1Y2M3DT12H12M6.789000123S" - expected_months = 14 - expected_days = 3 - expected_nanos = 43926789000123 - value_pb = Value(string_value=input_str) - result = self._callFUT(value_pb) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_interval_with_negative_minutes(self): - input_str = "P1Y2M3DT13H-48M6S" - expected_months = 14 - expected_days = 3 - expected_nanos = 43926000000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_date_only_interval(self): - input_str = "P1Y2M3D" - expected_months = 14 - expected_days = 3 - expected_nanos = 0 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_years_and_months_only(self): - input_str = "P1Y2M" - expected_months = 14 - expected_days = 0 - expected_nanos = 0 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_years_only(self): - input_str = "P1Y" - expected_months = 12 - expected_days = 0 - expected_nanos = 0 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_months_only(self): - input_str = "P2M" - expected_months = 2 - expected_days = 0 - expected_nanos = 0 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_days_only(self): - input_str = "P3D" - expected_months = 0 - expected_days = 3 - expected_nanos = 0 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_time_components_with_fractional_seconds(self): - input_str = "PT4H25M6.7890001S" - expected_months = 0 - expected_days = 0 - expected_nanos = 15906789000100 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_time_components_without_fractional_seconds(self): - input_str = "PT4H25M6S" - expected_months = 0 - expected_days = 0 - expected_nanos = 15906000000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_hours_and_seconds_only(self): - input_str = "PT4H30S" - expected_months = 0 - expected_days = 0 - expected_nanos = 14430000000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_hours_and_minutes_only(self): - input_str = "PT4H1M" - expected_months = 0 - expected_days = 0 - expected_nanos = 14460000000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_minutes_only(self): - input_str = "PT5M" - expected_months = 0 - expected_days = 0 - expected_nanos = 300000000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_fractional_seconds_only(self): - input_str = "PT6.789S" - expected_months = 0 - expected_days = 0 - expected_nanos = 6789000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_small_fractional_seconds(self): - input_str = "PT0.123S" - expected_months = 0 - expected_days = 0 - expected_nanos = 123000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_very_small_fractional_seconds(self): - input_str = "PT.000000123S" - expected_months = 0 - expected_days = 0 - expected_nanos = 123 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_zero_years(self): - input_str = "P0Y" - expected_months = 0 - expected_days = 0 - expected_nanos = 0 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_all_negative_components(self): - input_str = "P-1Y-2M-3DT-12H-12M-6.789000123S" - expected_months = -14 - expected_days = -3 - expected_nanos = -43926789000123 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_mixed_signs_in_components(self): - input_str = "P1Y-2M3DT13H-51M6.789S" - expected_months = 10 - expected_days = 3 - expected_nanos = 43746789000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_negative_years_with_mixed_signs(self): - input_str = "P-1Y2M-3DT-13H49M-6.789S" - expected_months = -10 - expected_days = -3 - expected_nanos = -43866789000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_negative_time_components(self): - input_str = "P1Y2M3DT-4H25M-6.7890001S" - expected_months = 14 - expected_days = 3 - expected_nanos = -12906789000100 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_large_time_values(self): - input_str = "PT100H100M100.5S" - expected_months = 0 - expected_days = 0 - expected_nanos = 366100500000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_only_time_components_with_seconds(self): - input_str = "PT12H30M1S" - expected_months = 0 - expected_days = 0 - expected_nanos = 45001000000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_date_and_time_no_seconds(self): - input_str = "P1Y2M3DT12H30M" - expected_months = 14 - expected_days = 3 - expected_nanos = 45000000000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_fractional_seconds_with_max_digits(self): - input_str = "PT0.123456789S" - expected_months = 0 - expected_days = 0 - expected_nanos = 123456789 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_hours_and_fractional_seconds(self): - input_str = "PT1H0.5S" - expected_months = 0 - expected_days = 0 - expected_nanos = 3600500000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_years_and_months_to_months_with_fractional_seconds(self): - input_str = "P1Y2M3DT12H30M1.23456789S" - expected_months = 14 - expected_days = 3 - expected_nanos = 45001234567890 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_comma_as_decimal_point(self): - input_str = "P1Y2M3DT12H30M1,23456789S" - expected_months = 14 - expected_days = 3 - expected_nanos = 45001234567890 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_fractional_seconds_without_0_before_decimal(self): - input_str = "PT.5S" - expected_months = 0 - expected_days = 0 - expected_nanos = 500000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_mixed_signs(self): - input_str = "P-1Y2M3DT12H-30M1.234S" - expected_months = -10 - expected_days = 3 - expected_nanos = 41401234000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_more_mixed_signs(self): - input_str = "P1Y-2M3DT-12H30M-1.234S" - expected_months = 10 - expected_days = 3 - expected_nanos = -41401234000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_trailing_zeros_after_decimal(self): - input_str = "PT1.234000S" - expected_months = 0 - expected_days = 0 - expected_nanos = 1234000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_all_zeros_after_decimal(self): - input_str = "PT1.000S" - expected_months = 0 - expected_days = 0 - expected_nanos = 1000000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_large_positive_hours(self): - input_str = "PT87840000H" - expected_months = 0 - expected_days = 0 - expected_nanos = 316224000000000000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_large_negative_hours(self): - input_str = "PT-87840000H" - expected_months = 0 - expected_days = 0 - expected_nanos = -316224000000000000000 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_large_mixed_values_with_max_precision(self): - input_str = "P2Y1M15DT87839999H59M59.999999999S" - expected_months = 25 - expected_days = 15 - expected_nanos = 316223999999999999999 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_large_mixed_negative_values_with_max_precision(self): - input_str = "P2Y1M15DT-87839999H-59M-59.999999999S" - expected_months = 25 - expected_days = 15 - expected_nanos = -316223999999999999999 - result = self._callFUT(input_str) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_invalid_format(self): - with self.assertRaises(ValueError): - self._callFUT("invalid") - - def test_missing_duration_specifier(self): - with self.assertRaises(ValueError): - self._callFUT("P") - - def test_missing_time_components(self): - with self.assertRaises(ValueError): - self._callFUT("PT") - - def test_missing_unit_specifier(self): - with self.assertRaises(ValueError): - self._callFUT("P1YM") - - def test_missing_t_separator(self): - with self.assertRaises(ValueError): - self._callFUT("P1Y2M3D4H5M6S") - - def test_missing_decimal_value(self): - with self.assertRaises(ValueError): - self._callFUT("P1Y2M3DT4H5M6.S") - - def test_extra_unit_specifier(self): - with self.assertRaises(ValueError): - self._callFUT("P1Y2M3DT4H5M6.789SS") - - def test_missing_value_after_decimal(self): - with self.assertRaises(ValueError): - self._callFUT("P1Y2M3DT4H5M6.") - - def test_non_digit_after_decimal(self): - with self.assertRaises(ValueError): - self._callFUT("P1Y2M3DT4H5M6.ABC") - - def test_missing_unit(self): - with self.assertRaises(ValueError): - self._callFUT("P1Y2M3") - - def test_missing_time_value(self): - with self.assertRaises(ValueError): - self._callFUT("P1Y2M3DT") - - def test_invalid_negative_sign_position(self): - with self.assertRaises(ValueError): - self._callFUT("P-T1H") - - def test_trailing_negative_sign(self): - with self.assertRaises(ValueError): - self._callFUT("PT1H-") - - def test_too_many_decimal_places(self): - with self.assertRaises(ValueError): - self._callFUT("P1Y2M3DT4H5M6.789123456789S") - - def test_multiple_decimal_points(self): - with self.assertRaises(ValueError): - self._callFUT("P1Y2M3DT4H5M6.123.456S") - - def test_both_dot_and_comma_decimals(self): - with self.assertRaises(ValueError): - self._callFUT("P1Y2M3DT4H5M6.,789S") - - def test_interval_with_years_months(self): - from google.protobuf.struct_pb2 import Value - - input_str = "P1Y2M" - expected_months = 14 - expected_days = 0 - expected_nanos = 0 - value_pb = Value(string_value=input_str) - result = self._callFUT(value_pb) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_interval_with_days(self): - from google.protobuf.struct_pb2 import Value - - input_str = "P3D" - expected_months = 0 - expected_days = 3 - expected_nanos = 0 - value_pb = Value(string_value=input_str) - result = self._callFUT(value_pb) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_interval_with_time(self): - from google.protobuf.struct_pb2 import Value + def test_parse_interval_cases(self): + test_cases = [ + { + "name": "full interval with all components", + "input": "P1Y2M3DT12H12M6.789000123S", + "expected_months": 14, + "expected_days": 3, + "expected_nanos": 43926789000123, + "want_err": False + }, + { + "name": "interval with negative minutes", + "input": "P1Y2M3DT13H-48M6S", + "expected_months": 14, + "expected_days": 3, + "expected_nanos": 43926000000000, + "want_err": False + }, + { + "name": "date only interval", + "input": "P1Y2M3D", + "expected_months": 14, + "expected_days": 3, + "expected_nanos": 0, + "want_err": False + }, + { + "name": "years and months only", + "input": "P1Y2M", + "expected_months": 14, + "expected_days": 0, + "expected_nanos": 0, + "want_err": False + }, + { + "name": "years only", + "input": "P1Y", + "expected_months": 12, + "expected_days": 0, + "expected_nanos": 0, + "want_err": False + }, + { + "name": "months only", + "input": "P2M", + "expected_months": 2, + "expected_days": 0, + "expected_nanos": 0, + "want_err": False + }, + { + "name": "days only", + "input": "P3D", + "expected_months": 0, + "expected_days": 3, + "expected_nanos": 0, + "want_err": False + }, + { + "name": "time components with fractional seconds", + "input": "PT4H25M6.7890001S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 15906789000100, + "want_err": False + }, + { + "name": "time components without fractional seconds", + "input": "PT4H25M6S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 15906000000000, + "want_err": False + }, + { + "name": "hours and seconds only", + "input": "PT4H30S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 14430000000000, + "want_err": False + }, + { + "name": "hours and minutes only", + "input": "PT4H1M", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 14460000000000, + "want_err": False + }, + { + "name": "minutes only", + "input": "PT5M", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 300000000000, + "want_err": False + }, + { + "name": "fractional seconds only", + "input": "PT6.789S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 6789000000, + "want_err": False + }, + { + "name": "small fractional seconds", + "input": "PT0.123S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 123000000, + "want_err": False + }, + { + "name": "very small fractional seconds", + "input": "PT.000000123S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 123, + "want_err": False + }, + { + "name": "zero years", + "input": "P0Y", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 0, + "want_err": False + }, + { + "name": "all negative components", + "input": "P-1Y-2M-3DT-12H-12M-6.789000123S", + "expected_months": -14, + "expected_days": -3, + "expected_nanos": -43926789000123, + "want_err": False + }, + { + "name": "mixed signs in components", + "input": "P1Y-2M3DT13H-51M6.789S", + "expected_months": 10, + "expected_days": 3, + "expected_nanos": 43746789000000, + "want_err": False + }, + { + "name": "negative years with mixed signs", + "input": "P-1Y2M-3DT-13H49M-6.789S", + "expected_months": -10, + "expected_days": -3, + "expected_nanos": -43866789000000, + "want_err": False + }, + { + "name": "negative time components", + "input": "P1Y2M3DT-4H25M-6.7890001S", + "expected_months": 14, + "expected_days": 3, + "expected_nanos": -12906789000100, + "want_err": False + }, + { + "name": "large time values", + "input": "PT100H100M100.5S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 366100500000000, + "want_err": False + }, + { + "name": "only time components with seconds", + "input": "PT12H30M1S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 45001000000000, + "want_err": False + }, + { + "name": "date and time no seconds", + "input": "P1Y2M3DT12H30M", + "expected_months": 14, + "expected_days": 3, + "expected_nanos": 45000000000000, + "want_err": False + }, + { + "name": "fractional seconds with max digits", + "input": "PT0.123456789S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 123456789, + "want_err": False + }, + { + "name": "hours and fractional seconds", + "input": "PT1H0.5S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 3600500000000, + "want_err": False + }, + { + "name": "years and months to months with fractional seconds", + "input": "P1Y2M3DT12H30M1.23456789S", + "expected_months": 14, + "expected_days": 3, + "expected_nanos": 45001234567890, + "want_err": False + }, + { + "name": "comma as decimal point", + "input": "P1Y2M3DT12H30M1,23456789S", + "expected_months": 14, + "expected_days": 3, + "expected_nanos": 45001234567890, + "want_err": False + }, + { + "name": "fractional seconds without 0 before decimal", + "input": "PT.5S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 500000000, + "want_err": False + }, + { + "name": "mixed signs", + "input": "P-1Y2M3DT12H-30M1.234S", + "expected_months": -10, + "expected_days": 3, + "expected_nanos": 41401234000000, + "want_err": False + }, + { + "name": "more mixed signs", + "input": "P1Y-2M3DT-12H30M-1.234S", + "expected_months": 10, + "expected_days": 3, + "expected_nanos": -41401234000000, + "want_err": False + }, + { + "name": "trailing zeros after decimal", + "input": "PT1.234000S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 1234000000, + "want_err": False + }, + { + "name": "all zeros after decimal", + "input": "PT1.000S", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 1000000000, + "want_err": False + }, + # Invalid cases + { + "name": "invalid format", + "input": "invalid", + "want_err": True + }, + { + "name": "missing duration specifier", + "input": "P", + "want_err": True + }, + { + "name": "missing time components", + "input": "PT", + "want_err": True + }, + { + "name": "missing unit specifier", + "input": "P1YM", + "want_err": True + }, + { + "name": "missing T separator", + "input": "P1Y2M3D4H5M6S", + "want_err": True + }, + { + "name": "missing decimal value", + "input": "P1Y2M3DT4H5M6.S", + "want_err": True + }, + { + "name": "extra unit specifier", + "input": "P1Y2M3DT4H5M6.789SS", + "want_err": True + }, + { + "name": "missing value after decimal", + "input": "P1Y2M3DT4H5M6.", + "want_err": True + }, + { + "name": "non-digit after decimal", + "input": "P1Y2M3DT4H5M6.ABC", + "want_err": True + }, + { + "name": "missing unit", + "input": "P1Y2M3", + "want_err": True + }, + { + "name": "missing time value", + "input": "P1Y2M3DT", + "want_err": True + }, + { + "name": "invalid negative sign position", + "input": "P-T1H", + "want_err": True + }, + { + "name": "trailing negative sign", + "input": "PT1H-", + "want_err": True + }, + { + "name": "too many decimal places", + "input": "P1Y2M3DT4H5M6.789123456789S", + "want_err": True + }, + { + "name": "multiple decimal points", + "input": "P1Y2M3DT4H5M6.123.456S", + "want_err": True + }, + { + "name": "both dot and comma decimals", + "input": "P1Y2M3DT4H5M6.,789S", + "want_err": True + } + ] - input_str = "PT12H12M6.789000123S" - expected_months = 0 - expected_days = 0 - expected_nanos = 43926789000123 - value_pb = Value(string_value=input_str) - result = self._callFUT(value_pb) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_interval_with_negative_components(self): - from google.protobuf.struct_pb2 import Value + for case in test_cases: + with self.subTest(name=case["name"]): + value_pb = self.Value(string_value=case["input"]) + if case.get("want_err", False): + with self.assertRaises(ValueError): + self._callFUT(value_pb) + else: + result = self._callFUT(value_pb) + self.assertEqual(result.months, case["expected_months"]) + self.assertEqual(result.days, case["expected_days"]) + self.assertEqual(result.nanos, case["expected_nanos"]) - input_str = "P-1Y-2M-3DT-12H-12M-6.789000123S" - expected_months = -14 - expected_days = -3 - expected_nanos = -43926789000123 - value_pb = Value(string_value=input_str) - result = self._callFUT(value_pb) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) - - def test_interval_with_zero_components(self): - from google.protobuf.struct_pb2 import Value + def test_large_values(self): + large_test_cases = [ + { + "name": "large positive hours", + "input": "PT87840000H", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": 316224000000000000000, + "want_err": False + }, + { + "name": "large negative hours", + "input": "PT-87840000H", + "expected_months": 0, + "expected_days": 0, + "expected_nanos": -316224000000000000000, + "want_err": False + }, + { + "name": "large mixed values with max precision", + "input": "P2Y1M15DT87839999H59M59.999999999S", + "expected_months": 25, + "expected_days": 15, + "expected_nanos": 316223999999999999999, + "want_err": False + }, + { + "name": "large mixed negative values with max precision", + "input": "P2Y1M15DT-87839999H-59M-59.999999999S", + "expected_months": 25, + "expected_days": 15, + "expected_nanos": -316223999999999999999, + "want_err": False + } + ] - input_str = "P0Y0M0DT0H0M0S" - expected_months = 0 - expected_days = 0 - expected_nanos = 0 - value_pb = Value(string_value=input_str) - result = self._callFUT(value_pb) - self.assertEqual(result.months, expected_months) - self.assertEqual(result.days, expected_days) - self.assertEqual(result.nanos, expected_nanos) + for case in large_test_cases: + with self.subTest(name=case["name"]): + value_pb = self.Value(string_value=case["input"]) + if case.get("want_err", False): + with self.assertRaises(ValueError): + self._callFUT(value_pb) + else: + result = self._callFUT(value_pb) + self.assertEqual(result.months, case["expected_months"]) + self.assertEqual(result.days, case["expected_days"]) + self.assertEqual(result.nanos, case["expected_nanos"]) From d6d5c4899582ef71f02276fe204c66a831d9b839 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Wed, 23 Apr 2025 16:49:29 +0000 Subject: [PATCH 07/12] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- tests/unit/test__helpers.py | 172 +++++++++++++++--------------------- 1 file changed, 71 insertions(+), 101 deletions(-) diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index afaf20cb89..7010affdd2 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -1055,80 +1055,82 @@ def test_interval_cases(self): "name": "Basic interval", "interval": self.Interval(months=14, days=3, nanos=43926789000123), "expected": "P1Y2M3DT12H12M6.789000123S", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Months only", "interval": self.Interval(months=10, days=0, nanos=0), "expected": "P10M", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Days only", "interval": self.Interval(months=0, days=10, nanos=0), "expected": "P10D", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Seconds only", "interval": self.Interval(months=0, days=0, nanos=10000000000), "expected": "PT10S", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Milliseconds only", "interval": self.Interval(months=0, days=0, nanos=10000000), "expected": "PT0.010S", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Microseconds only", "interval": self.Interval(months=0, days=0, nanos=10000), "expected": "PT0.000010S", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Nanoseconds only", "interval": self.Interval(months=0, days=0, nanos=10), "expected": "PT0.000000010S", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Mixed components", "interval": self.Interval(months=10, days=20, nanos=1030), "expected": "P10M20DT0.000001030S", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Mixed components with negative nanos", "interval": self.Interval(months=10, days=20, nanos=-1030), "expected": "P10M20DT-0.000001030S", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Negative interval", "interval": self.Interval(months=-14, days=-3, nanos=-43926789000123), "expected": "P-1Y-2M-3DT-12H-12M-6.789000123S", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Mixed signs", "interval": self.Interval(months=10, days=3, nanos=-41401234000000), "expected": "P10M3DT-11H-30M-1.234S", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Large values", - "interval": self.Interval(months=25, days=15, nanos=316223999999999999999), + "interval": self.Interval( + months=25, days=15, nanos=316223999999999999999 + ), "expected": "P2Y1M15DT87839999H59M59.999999999S", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) + "expected_type": self.Type(code=self.TypeCode.INTERVAL), }, { "name": "Zero interval", "interval": self.Interval(months=0, days=0, nanos=0), "expected": "P0Y", - "expected_type": self.Type(code=self.TypeCode.INTERVAL) - } + "expected_type": self.Type(code=self.TypeCode.INTERVAL), + }, ] for case in test_cases: @@ -1155,7 +1157,7 @@ def test_parse_interval_cases(self): "expected_months": 14, "expected_days": 3, "expected_nanos": 43926789000123, - "want_err": False + "want_err": False, }, { "name": "interval with negative minutes", @@ -1163,7 +1165,7 @@ def test_parse_interval_cases(self): "expected_months": 14, "expected_days": 3, "expected_nanos": 43926000000000, - "want_err": False + "want_err": False, }, { "name": "date only interval", @@ -1171,7 +1173,7 @@ def test_parse_interval_cases(self): "expected_months": 14, "expected_days": 3, "expected_nanos": 0, - "want_err": False + "want_err": False, }, { "name": "years and months only", @@ -1179,7 +1181,7 @@ def test_parse_interval_cases(self): "expected_months": 14, "expected_days": 0, "expected_nanos": 0, - "want_err": False + "want_err": False, }, { "name": "years only", @@ -1187,7 +1189,7 @@ def test_parse_interval_cases(self): "expected_months": 12, "expected_days": 0, "expected_nanos": 0, - "want_err": False + "want_err": False, }, { "name": "months only", @@ -1195,7 +1197,7 @@ def test_parse_interval_cases(self): "expected_months": 2, "expected_days": 0, "expected_nanos": 0, - "want_err": False + "want_err": False, }, { "name": "days only", @@ -1203,7 +1205,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 3, "expected_nanos": 0, - "want_err": False + "want_err": False, }, { "name": "time components with fractional seconds", @@ -1211,7 +1213,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 15906789000100, - "want_err": False + "want_err": False, }, { "name": "time components without fractional seconds", @@ -1219,7 +1221,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 15906000000000, - "want_err": False + "want_err": False, }, { "name": "hours and seconds only", @@ -1227,7 +1229,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 14430000000000, - "want_err": False + "want_err": False, }, { "name": "hours and minutes only", @@ -1235,7 +1237,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 14460000000000, - "want_err": False + "want_err": False, }, { "name": "minutes only", @@ -1243,7 +1245,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 300000000000, - "want_err": False + "want_err": False, }, { "name": "fractional seconds only", @@ -1251,7 +1253,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 6789000000, - "want_err": False + "want_err": False, }, { "name": "small fractional seconds", @@ -1259,7 +1261,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 123000000, - "want_err": False + "want_err": False, }, { "name": "very small fractional seconds", @@ -1267,7 +1269,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 123, - "want_err": False + "want_err": False, }, { "name": "zero years", @@ -1275,7 +1277,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 0, - "want_err": False + "want_err": False, }, { "name": "all negative components", @@ -1283,7 +1285,7 @@ def test_parse_interval_cases(self): "expected_months": -14, "expected_days": -3, "expected_nanos": -43926789000123, - "want_err": False + "want_err": False, }, { "name": "mixed signs in components", @@ -1291,7 +1293,7 @@ def test_parse_interval_cases(self): "expected_months": 10, "expected_days": 3, "expected_nanos": 43746789000000, - "want_err": False + "want_err": False, }, { "name": "negative years with mixed signs", @@ -1299,7 +1301,7 @@ def test_parse_interval_cases(self): "expected_months": -10, "expected_days": -3, "expected_nanos": -43866789000000, - "want_err": False + "want_err": False, }, { "name": "negative time components", @@ -1307,7 +1309,7 @@ def test_parse_interval_cases(self): "expected_months": 14, "expected_days": 3, "expected_nanos": -12906789000100, - "want_err": False + "want_err": False, }, { "name": "large time values", @@ -1315,7 +1317,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 366100500000000, - "want_err": False + "want_err": False, }, { "name": "only time components with seconds", @@ -1323,7 +1325,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 45001000000000, - "want_err": False + "want_err": False, }, { "name": "date and time no seconds", @@ -1331,7 +1333,7 @@ def test_parse_interval_cases(self): "expected_months": 14, "expected_days": 3, "expected_nanos": 45000000000000, - "want_err": False + "want_err": False, }, { "name": "fractional seconds with max digits", @@ -1339,7 +1341,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 123456789, - "want_err": False + "want_err": False, }, { "name": "hours and fractional seconds", @@ -1347,7 +1349,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 3600500000000, - "want_err": False + "want_err": False, }, { "name": "years and months to months with fractional seconds", @@ -1355,7 +1357,7 @@ def test_parse_interval_cases(self): "expected_months": 14, "expected_days": 3, "expected_nanos": 45001234567890, - "want_err": False + "want_err": False, }, { "name": "comma as decimal point", @@ -1363,7 +1365,7 @@ def test_parse_interval_cases(self): "expected_months": 14, "expected_days": 3, "expected_nanos": 45001234567890, - "want_err": False + "want_err": False, }, { "name": "fractional seconds without 0 before decimal", @@ -1371,7 +1373,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 500000000, - "want_err": False + "want_err": False, }, { "name": "mixed signs", @@ -1379,7 +1381,7 @@ def test_parse_interval_cases(self): "expected_months": -10, "expected_days": 3, "expected_nanos": 41401234000000, - "want_err": False + "want_err": False, }, { "name": "more mixed signs", @@ -1387,7 +1389,7 @@ def test_parse_interval_cases(self): "expected_months": 10, "expected_days": 3, "expected_nanos": -41401234000000, - "want_err": False + "want_err": False, }, { "name": "trailing zeros after decimal", @@ -1395,7 +1397,7 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 1234000000, - "want_err": False + "want_err": False, }, { "name": "all zeros after decimal", @@ -1403,89 +1405,57 @@ def test_parse_interval_cases(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 1000000000, - "want_err": False + "want_err": False, }, # Invalid cases - { - "name": "invalid format", - "input": "invalid", - "want_err": True - }, - { - "name": "missing duration specifier", - "input": "P", - "want_err": True - }, - { - "name": "missing time components", - "input": "PT", - "want_err": True - }, - { - "name": "missing unit specifier", - "input": "P1YM", - "want_err": True - }, - { - "name": "missing T separator", - "input": "P1Y2M3D4H5M6S", - "want_err": True - }, + {"name": "invalid format", "input": "invalid", "want_err": True}, + {"name": "missing duration specifier", "input": "P", "want_err": True}, + {"name": "missing time components", "input": "PT", "want_err": True}, + {"name": "missing unit specifier", "input": "P1YM", "want_err": True}, + {"name": "missing T separator", "input": "P1Y2M3D4H5M6S", "want_err": True}, { "name": "missing decimal value", "input": "P1Y2M3DT4H5M6.S", - "want_err": True + "want_err": True, }, { "name": "extra unit specifier", "input": "P1Y2M3DT4H5M6.789SS", - "want_err": True + "want_err": True, }, { "name": "missing value after decimal", "input": "P1Y2M3DT4H5M6.", - "want_err": True + "want_err": True, }, { "name": "non-digit after decimal", "input": "P1Y2M3DT4H5M6.ABC", - "want_err": True - }, - { - "name": "missing unit", - "input": "P1Y2M3", - "want_err": True - }, - { - "name": "missing time value", - "input": "P1Y2M3DT", - "want_err": True + "want_err": True, }, + {"name": "missing unit", "input": "P1Y2M3", "want_err": True}, + {"name": "missing time value", "input": "P1Y2M3DT", "want_err": True}, { "name": "invalid negative sign position", "input": "P-T1H", - "want_err": True - }, - { - "name": "trailing negative sign", - "input": "PT1H-", - "want_err": True + "want_err": True, }, + {"name": "trailing negative sign", "input": "PT1H-", "want_err": True}, { "name": "too many decimal places", "input": "P1Y2M3DT4H5M6.789123456789S", - "want_err": True + "want_err": True, }, { "name": "multiple decimal points", "input": "P1Y2M3DT4H5M6.123.456S", - "want_err": True + "want_err": True, }, { "name": "both dot and comma decimals", "input": "P1Y2M3DT4H5M6.,789S", - "want_err": True - } + "want_err": True, + }, ] for case in test_cases: @@ -1508,7 +1478,7 @@ def test_large_values(self): "expected_months": 0, "expected_days": 0, "expected_nanos": 316224000000000000000, - "want_err": False + "want_err": False, }, { "name": "large negative hours", @@ -1516,7 +1486,7 @@ def test_large_values(self): "expected_months": 0, "expected_days": 0, "expected_nanos": -316224000000000000000, - "want_err": False + "want_err": False, }, { "name": "large mixed values with max precision", @@ -1524,7 +1494,7 @@ def test_large_values(self): "expected_months": 25, "expected_days": 15, "expected_nanos": 316223999999999999999, - "want_err": False + "want_err": False, }, { "name": "large mixed negative values with max precision", @@ -1532,8 +1502,8 @@ def test_large_values(self): "expected_months": 25, "expected_days": 15, "expected_nanos": -316223999999999999999, - "want_err": False - } + "want_err": False, + }, ] for case in large_test_cases: From 7d6ad43dd6d0f79255c601c9256f9feb5aa60c78 Mon Sep 17 00:00:00 2001 From: rahul yadav Date: Fri, 25 Apr 2025 10:30:29 +0530 Subject: [PATCH 08/12] fix cleaning up of stale databases --- tests/system/_helpers.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/system/_helpers.py b/tests/system/_helpers.py index f157a8ee59..3c569aced2 100644 --- a/tests/system/_helpers.py +++ b/tests/system/_helpers.py @@ -115,9 +115,19 @@ def scrub_instance_ignore_not_found(to_scrub): """Helper for func:`cleanup_old_instances`""" scrub_instance_backups(to_scrub) + for database_pb in to_scrub.list_databases(): + db = to_scrub.database(database_pb.name.split("/")[-1]) + try: + if db.enable_drop_protection: + db.enable_drop_protection = False + operation = db.update(["enable_drop_protection"]) + operation.result(DATABASE_OPERATION_TIMEOUT_IN_SECONDS) + except exceptions.NotFound: + pass + try: retry_429_503(to_scrub.delete)() - except exceptions.NotFound: # lost the race + except exceptions.NotFound: pass From 3487b5484a42e1e787c902d7bc47ba8c25bf9555 Mon Sep 17 00:00:00 2001 From: rahul yadav Date: Mon, 28 Apr 2025 15:28:21 +0530 Subject: [PATCH 09/12] fix cleanup --- tests/system/_helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/system/_helpers.py b/tests/system/_helpers.py index 3c569aced2..f37aefc2e5 100644 --- a/tests/system/_helpers.py +++ b/tests/system/_helpers.py @@ -117,6 +117,7 @@ def scrub_instance_ignore_not_found(to_scrub): for database_pb in to_scrub.list_databases(): db = to_scrub.database(database_pb.name.split("/")[-1]) + db.reload() try: if db.enable_drop_protection: db.enable_drop_protection = False From dd85a5f03dad07d864085729894fc84e04444495 Mon Sep 17 00:00:00 2001 From: rahul yadav Date: Mon, 28 Apr 2025 16:13:12 +0530 Subject: [PATCH 10/12] pick random instance config --- tests/system/conftest.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/system/conftest.py b/tests/system/conftest.py index 1337de4972..06418c0993 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -151,10 +151,13 @@ def instance_config(instance_configs): if not instance_configs: raise ValueError("No instance configs found.") - us_west1_config = [ - config for config in instance_configs if config.display_name == "us-west1" + import random + us_configs = [ + config for config in instance_configs + if config.display_name in ["us-south1", "us-east4", "us-west1"] ] - config = us_west1_config[0] if len(us_west1_config) > 0 else instance_configs[0] + + config = random.choice(us_configs) if us_configs else random.choice(instance_configs) yield config From 64fcf82ce1eb11d0b9e6749140cb6aa4d508bad9 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Mon, 28 Apr 2025 10:45:31 +0000 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- tests/system/conftest.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/system/conftest.py b/tests/system/conftest.py index 06418c0993..04c6664ff9 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -152,12 +152,16 @@ def instance_config(instance_configs): raise ValueError("No instance configs found.") import random + us_configs = [ - config for config in instance_configs + config + for config in instance_configs if config.display_name in ["us-south1", "us-east4", "us-west1"] ] - - config = random.choice(us_configs) if us_configs else random.choice(instance_configs) + + config = ( + random.choice(us_configs) if us_configs else random.choice(instance_configs) + ) yield config From 284633b674218dbc0d84ff36b002234800555622 Mon Sep 17 00:00:00 2001 From: rahul yadav Date: Mon, 28 Apr 2025 16:45:18 +0530 Subject: [PATCH 12/12] skip us-west1 --- tests/system/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/conftest.py b/tests/system/conftest.py index 04c6664ff9..bc94d065b2 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -156,7 +156,7 @@ def instance_config(instance_configs): us_configs = [ config for config in instance_configs - if config.display_name in ["us-south1", "us-east4", "us-west1"] + if config.display_name in ["us-south1", "us-east4"] ] config = (