diff --git a/databricks/sdk/common_types/common.py b/databricks/sdk/common_types/common.py new file mode 100644 index 000000000..515630e21 --- /dev/null +++ b/databricks/sdk/common_types/common.py @@ -0,0 +1,345 @@ +"""Common types for the Databricks SDK. + +This module provides common types used by different APIs. +""" + +from __future__ import annotations + +import re +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +# Python datetime library does not have nanoseconds precision. These classes below are used to work around this limitation. + + +class Duration: + """Represents a duration with nanosecond precision. + + This class provides nanosecond precision for durations, which is not supported + by Python's standard datetime.timedelta. + + Attributes: + seconds (int): Number of seconds in the duration + nanoseconds (int): Number of nanoseconds (0-999999999) + """ + + def __init__(self, seconds: int = 0, nanoseconds: int = 0) -> None: + """Initialize a Duration with seconds and nanoseconds. + + Args: + seconds: Number of seconds + nanoseconds: Number of nanoseconds (0-999999999) + + Raises: + TypeError: If seconds or nanoseconds are not integers + ValueError: If nanoseconds is not between 0 and 999999999 + """ + if not isinstance(seconds, int): + raise TypeError("seconds must be an integer") + if not isinstance(nanoseconds, int): + raise TypeError("nanoseconds must be an integer") + if nanoseconds < 0 or nanoseconds >= 1_000_000_000: + raise ValueError("nanoseconds must be between 0 and 999999999") + + self.seconds = seconds + self.nanoseconds = nanoseconds + + @classmethod + def from_timedelta(cls, td: timedelta) -> "Duration": + """Convert a datetime.timedelta to Duration. + + Args: + td: The timedelta to convert + + Returns: + Duration: A new Duration instance with equivalent time span + + """ + # Use Decimal for precise calculation of total seconds + total_seconds = Decimal(str(td.total_seconds())) + seconds = int(total_seconds) + # Get the fractional part and convert to nanoseconds + # This preserves more precision than using microsecond * 1000 + fractional = total_seconds - seconds + nanoseconds = int(fractional * Decimal("1000000000")) + return cls(seconds=seconds, nanoseconds=nanoseconds) + + def to_timedelta(self) -> timedelta: + """Convert Duration to datetime.timedelta. + + Returns: + timedelta: A new timedelta instance with equivalent time span + + Note: + The conversion will lose nanosecond precision as timedelta + only supports microsecond precision. Nanoseconds beyond + microsecond precision will be truncated. + """ + # Convert nanoseconds to microseconds, truncating any extra precision + microseconds = self.nanoseconds // 1000 + return timedelta(seconds=self.seconds, microseconds=microseconds) + + def __repr__(self) -> str: + """Return a string representation of the Duration. + + Returns: + str: String in the format 'Duration(seconds=X, nanoseconds=Y)' + """ + return f"Duration(seconds={self.seconds}, nanoseconds={self.nanoseconds})" + + def __eq__(self, other: object) -> bool: + """Compare this Duration with another object for equality. + + Args: + other: Object to compare with + + Returns: + bool: True if other is a Duration with same seconds and nanoseconds + """ + if not isinstance(other, Duration): + return NotImplemented + return self.seconds == other.seconds and self.nanoseconds == other.nanoseconds + + @classmethod + def parse(cls, duration_str: str) -> "Duration": + """Parse a duration string in the format 'Xs' where X is a decimal number. + + Examples: + "3.1s" -> Duration(seconds=3, nanoseconds=100000000) + "1.5s" -> Duration(seconds=1, nanoseconds=500000000) + "10s" -> Duration(seconds=10, nanoseconds=0) + + Args: + duration_str: String in the format 'Xs' where X is a decimal number + + Returns: + A new Duration instance + + Raises: + ValueError: If the string format is invalid + """ + if not duration_str.endswith("s"): + raise ValueError("Duration string must end with 's'") + + try: + # Remove the 's' suffix and convert to Decimal + value = Decimal(duration_str[:-1]) + # Split into integer and fractional parts + seconds = int(value) + # Convert fractional part to nanoseconds + nanoseconds = int((value - seconds) * 1_000_000_000) + return cls(seconds=seconds, nanoseconds=nanoseconds) + except ValueError as e: + raise ValueError(f"Invalid duration format: {duration_str}") from e + + def to_string(self) -> str: + """Convert Duration to string format 'Xs' where X is a decimal number. + + Examples: + Duration(seconds=3, nanoseconds=100000000) -> "3.1s" + Duration(seconds=1, nanoseconds=500000000) -> "1.5s" + Duration(seconds=10, nanoseconds=0) -> "10s" + + Returns: + String representation of the duration + """ + if self.nanoseconds == 0: + return f"{self.seconds}s" + + # Use Decimal for precise decimal arithmetic + total = Decimal(self.seconds) + (Decimal(self.nanoseconds) / Decimal("1000000000")) + # Format with up to 9 decimal places, removing trailing zeros + return f"{total:.9f}".rstrip("0").rstrip(".") + "s" + + +class Timestamp: + """Represents a timestamp with nanosecond precision. + + This class provides nanosecond precision for timestamps, which is not supported + by Python's standard datetime. It's compatible with protobuf Timestamp format and + supports RFC3339 string formatting. + + Attributes: + seconds (int): Seconds since Unix epoch (1970-01-01T00:00:00Z) + nanos (int): Nanoseconds (0-999999999) + """ + + # RFC3339 regex pattern for validation and parsing + _RFC3339_PATTERN = re.compile( + r"^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:?\d{2})$" + ) + + def __init__(self, seconds: int = 0, nanos: int = 0) -> None: + """Initialize a Timestamp with seconds since epoch and nanoseconds. + + Args: + seconds: Seconds since Unix epoch (1970-01-01T00:00:00Z) + nanos: Nanoseconds (0-999999999) + + Raises: + TypeError: If seconds or nanos are not integers + ValueError: If nanos is not between 0 and 999999999 + """ + if not isinstance(seconds, int): + raise TypeError("seconds must be an integer") + if not isinstance(nanos, int): + raise TypeError("nanos must be an integer") + if nanos < 0 or nanos >= 1_000_000_000: + raise ValueError("nanos must be between 0 and 999999999") + + self.seconds = seconds + self.nanos = nanos + + @classmethod + def from_datetime(cls, dt: datetime) -> "Timestamp": + """Convert a datetime.datetime to Timestamp. + + Args: + dt: The datetime to convert. If naive, it's assumed to be UTC. + + Returns: + Timestamp: A new Timestamp instance + + Note: + The datetime is converted to UTC if it isn't already. + Note that datetime only supports microsecond precision, so nanoseconds + will be padded with zeros. + """ + # If datetime is naive (no timezone), assume UTC + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + # Convert to UTC + utc_dt = dt.astimezone(timezone.utc) + + # Get seconds since epoch using Decimal for precise calculation + # datetime.timestamp() returns float, so we need to handle it carefully + ts = Decimal(str(utc_dt.timestamp())) + seconds = int(ts) + # Get the fractional part and convert to nanoseconds + # This preserves more precision than using microsecond * 1000 + fractional = ts - seconds + nanos = int(fractional * Decimal("1000000000")) + + return cls(seconds=seconds, nanos=nanos) + + def to_datetime(self) -> datetime: + """Convert Timestamp to datetime.datetime. + + Returns: + datetime: A new datetime instance in UTC timezone + + Note: + The returned datetime will have microsecond precision at most. + Nanoseconds beyond microsecond precision will be truncated. + """ + # Create base datetime from seconds + dt = datetime.fromtimestamp(self.seconds, tz=timezone.utc) + # Convert nanoseconds to microseconds, truncating any extra precision + microseconds = self.nanos // 1000 + return dt.replace(microsecond=microseconds) + + @classmethod + def parse(cls, timestamp_str: str) -> "Timestamp": + """Parse an RFC3339 formatted string into a Timestamp. + + Examples: + >>> Timestamp.parse("2023-01-01T12:00:00Z") + >>> Timestamp.parse("2023-01-01T12:00:00.123456789Z") + >>> Timestamp.parse("2023-01-01T12:00:00+01:00") + + Args: + timestamp_str: RFC3339 formatted timestamp string + + Returns: + Timestamp: A new Timestamp instance + + Raises: + ValueError: If the string format is invalid or not RFC3339 compliant + """ + match = cls._RFC3339_PATTERN.match(timestamp_str) + if not match: + raise ValueError(f"Invalid RFC3339 format: {timestamp_str}") + + year, month, day, hour, minute, second, frac, offset = match.groups() + + # Build the datetime string with a standardized offset format + dt_str = f"{year}-{month}-{day}T{hour}:{minute}:{second}" + + # Handle fractional seconds, truncating to microseconds for fromisoformat + nanos = 0 + if frac: + # Pad to 9 digits for nanoseconds + frac = (frac + "000000000")[:9] + # Truncate to 6 digits (microseconds) for fromisoformat + dt_str += f".{frac[:6]}" + # Store full nanosecond precision separately + nanos = int(frac) + + # Handle timezone offset + if offset == "Z": + dt_str += "+00:00" + elif ":" not in offset: + # Insert colon in offset if not present (e.g., +0000 -> +00:00) + dt_str += f"{offset[:3]}:{offset[3:]}" + else: + dt_str += offset + + # Parse with microsecond precision + dt = datetime.fromisoformat(dt_str) + # Create timestamp with full nanosecond precision + return cls.from_datetime(dt).replace(nanos=nanos) + + def to_string(self) -> str: + """Convert Timestamp to RFC3339 formatted string. + + Returns: + str: RFC3339 formatted timestamp string in UTC timezone + + Note: + The string will include nanosecond precision only if nanos > 0 + """ + # Convert seconds to UTC datetime for formatting + dt = datetime.fromtimestamp(self.seconds, tz=timezone.utc) + base = dt.strftime("%Y-%m-%dT%H:%M:%S") + + # Add nanoseconds if present + if self.nanos == 0: + return base + "Z" + + # Format nanoseconds, removing trailing zeros + nanos_str = f"{self.nanos:09d}".rstrip("0") + return f"{base}.{nanos_str}Z" + + def __repr__(self) -> str: + """Return a string representation of the Timestamp. + + Returns: + str: String in the format 'Timestamp(seconds=X, nanos=Y)' + """ + return f"Timestamp(seconds={self.seconds}, nanos={self.nanos})" + + def __eq__(self, other: object) -> bool: + """Compare this Timestamp with another object for equality. + + Args: + other: Object to compare with + + Returns: + bool: True if other is a Timestamp with same seconds and nanos + """ + if not isinstance(other, Timestamp): + return NotImplemented + return self.seconds == other.seconds and self.nanos == other.nanos + + def replace(self, **kwargs) -> "Timestamp": + """Create a new Timestamp with the given fields replaced. + + Args: + **kwargs: Fields to replace (seconds, nanos) + + Returns: + A new Timestamp instance with the specified fields replaced + """ + seconds = kwargs.get("seconds", self.seconds) + nanos = kwargs.get("nanos", self.nanos) + return Timestamp(seconds=seconds, nanos=nanos) diff --git a/databricks/sdk/service/_internal.py b/databricks/sdk/service/_internal.py index 1e501e0e0..3ae4b4a35 100644 --- a/databricks/sdk/service/_internal.py +++ b/databricks/sdk/service/_internal.py @@ -1,6 +1,8 @@ import datetime import urllib.parse -from typing import Callable, Dict, Generic, Optional, Type, TypeVar +from typing import Any, Callable, Dict, Generic, List, Optional, Type, TypeVar + +from databricks.sdk.common_types import common def _from_dict(d: Dict[str, any], field: str, cls: Type) -> any: @@ -42,6 +44,54 @@ def _repeated_enum(d: Dict[str, any], field: str, cls: Type) -> any: return res +def _get_duration(d: Dict[str, Any], field: str) -> Optional[common.Duration]: + if field not in d or d[field] is None: + return None + return common.Duration.parse(d[field]) + + +def _repeated_duration(d: Dict[str, Any], field: str) -> Optional[List[common.Duration]]: + if field not in d or not d[field]: + return None + return [common.Duration.parse(v) for v in d[field]] + + +def _get_timestamp(d: Dict[str, Any], field: str) -> Optional[common.Timestamp]: + if field not in d or d[field] is None: + return None + return common.Timestamp.parse(d[field]) + + +def _repeated_timestamp(d: Dict[str, Any], field: str) -> Optional[List[common.Timestamp]]: + if field not in d or not d[field]: + return None + return [common.Timestamp.parse(v) for v in d[field]] + + +def _get_value(d: Dict[str, Any], field: str) -> Optional[Any]: + if field not in d or d[field] is None: + return None + return d[field] + + +def _repeated_value(d: Dict[str, Any], field: str) -> Optional[List[Any]]: + if field not in d or not d[field]: + return None + return d[field] + + +def _get_field_mask(d: Dict[str, Any], field: str) -> Optional[List[str]]: + if field not in d or d[field] is None: + return None + return d[field].split(",") + + +def _repeated_field_mask(d: Dict[str, Any], field: str) -> Optional[List[str]]: + if field not in d or not d[field]: + return None + return [v.split(",") for v in d[field]] + + def _escape_multi_segment_path_parameter(param: str) -> str: return urllib.parse.quote(param) diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 000000000..4d333c22e --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,327 @@ +"""Tests for common types in the Databricks SDK.""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from databricks.sdk.common_types.common import Duration, Timestamp + + +@pytest.mark.parametrize( + "seconds,nanoseconds,expected_seconds,expected_nanoseconds,raises", + [ + (1, 500000000, 1, 500000000, None), # Valid initialization + (0, 0, 0, 0, None), # Default values + ("1", 0, None, None, TypeError), # Invalid seconds type + (0, "500000000", None, None, TypeError), # Invalid nanoseconds type + (0, -1, None, None, ValueError), # Negative nanoseconds + (0, 1_000_000_000, None, None, ValueError), # Nanoseconds too large + ], +) +def test_duration_initialization(seconds, nanoseconds, expected_seconds, expected_nanoseconds, raises): + """Test Duration initialization and validation.""" + if raises: + with pytest.raises(raises): + Duration(seconds=seconds, nanoseconds=nanoseconds) + else: + d = Duration(seconds=seconds, nanoseconds=nanoseconds) + assert d.seconds == expected_seconds + assert d.nanoseconds == expected_nanoseconds + + +@pytest.mark.parametrize( + "td,expected_seconds,expected_nanoseconds", + [ + (timedelta(seconds=10), 10, 0), # Whole seconds + (timedelta(microseconds=500000), 0, 500000000), # Microseconds only + (timedelta(seconds=1, microseconds=500000), 1, 500000000), # Both + ], +) +def test_duration_from_timedelta(td, expected_seconds, expected_nanoseconds): + """Test conversion from timedelta to Duration.""" + d = Duration.from_timedelta(td) + assert d.seconds == expected_seconds + assert d.nanoseconds == expected_nanoseconds + + +@pytest.mark.parametrize( + "seconds,nanoseconds,expected_total_seconds", + [ + (10, 0, 10.0), # Whole seconds + (0, 500000000, 0.5), # Nanoseconds only + (1, 500000000, 1.5), # Both + ], +) +def test_duration_to_timedelta(seconds, nanoseconds, expected_total_seconds): + """Test conversion from Duration to timedelta.""" + d = Duration(seconds=seconds, nanoseconds=nanoseconds) + td = d.to_timedelta() + assert td.total_seconds() == expected_total_seconds + + +@pytest.mark.parametrize( + "duration_str,expected_seconds,expected_nanoseconds,raises", + [ + ("10s", 10, 0, None), # Whole seconds + ("1.5s", 1, 500000000, None), # Decimal seconds + ("10", None, None, ValueError), # Missing 's' + ("invalid", None, None, ValueError), # Invalid format + ], +) +def test_duration_parse(duration_str, expected_seconds, expected_nanoseconds, raises): + """Test parsing duration strings.""" + if raises: + with pytest.raises(raises): + Duration.parse(duration_str) + else: + d = Duration.parse(duration_str) + assert d.seconds == expected_seconds + assert d.nanoseconds == expected_nanoseconds + + +@pytest.mark.parametrize( + "seconds,nanoseconds,expected_string", + [ + (10, 0, "10s"), # Whole seconds + (1, 500000000, "1.5s"), # Decimal seconds + (0, 500000000, "0.5s"), # Nanoseconds only + ], +) +def test_duration_to_string(seconds, nanoseconds, expected_string): + """Test string representation of Duration.""" + d = Duration(seconds=seconds, nanoseconds=nanoseconds) + assert d.to_string() == expected_string + + +@pytest.mark.parametrize( + "seconds,nanos,expected_seconds,expected_nanos,raises", + [ + (1609459200, 500000000, 1609459200, 500000000, None), # Valid initialization + (0, 0, 0, 0, None), # Default values + ("1609459200", 0, None, None, TypeError), # Invalid seconds type + (0, "500000000", None, None, TypeError), # Invalid nanos type + (0, -1, None, None, ValueError), # Negative nanos + (0, 1_000_000_000, None, None, ValueError), # Nanos too large + ], +) +def test_timestamp_initialization(seconds, nanos, expected_seconds, expected_nanos, raises): + """Test Timestamp initialization and validation.""" + if raises: + with pytest.raises(raises): + Timestamp(seconds=seconds, nanos=nanos) + else: + ts = Timestamp(seconds=seconds, nanos=nanos) + assert ts.seconds == expected_seconds + assert ts.nanos == expected_nanos + + +@pytest.mark.parametrize( + "dt,expected_seconds,expected_nanos", + [ + # UTC datetime + (datetime(2021, 1, 1, 12, 0, 0, 500000, tzinfo=timezone.utc), 1609502400, 500000000), + # Naive datetime (treated as UTC) + (datetime(2021, 1, 1, 12, 0, 0, 500000), 1609502400, 500000000), + # Different timezone (converted to UTC) + (datetime(2021, 1, 1, 13, 0, 0, 500000, tzinfo=timezone(timedelta(hours=1))), 1609502400, 500000000), + ], +) +def test_timestamp_from_datetime(dt, expected_seconds, expected_nanos): + """Test conversion from datetime to Timestamp.""" + ts = Timestamp.from_datetime(dt) + assert ts.seconds == expected_seconds + assert ts.nanos == expected_nanos + + +@pytest.mark.parametrize( + "seconds,nanos,expected_year,expected_month,expected_day,expected_hour,expected_minute,expected_second,expected_microsecond", + [ + (1609459200, 0, 2021, 1, 1, 0, 0, 0, 0), # Whole seconds + (1609459200, 500000000, 2021, 1, 1, 0, 0, 0, 500000), # With nanoseconds + ], +) +def test_timestamp_to_datetime( + seconds, + nanos, + expected_year, + expected_month, + expected_day, + expected_hour, + expected_minute, + expected_second, + expected_microsecond, +): + """Test conversion from Timestamp to datetime.""" + ts = Timestamp(seconds=seconds, nanos=nanos) + dt = ts.to_datetime() + assert dt.year == expected_year + assert dt.month == expected_month + assert dt.day == expected_day + assert dt.hour == expected_hour + assert dt.minute == expected_minute + assert dt.second == expected_second + assert dt.microsecond == expected_microsecond + assert dt.tzinfo == timezone.utc + + +@pytest.mark.parametrize( + "timestamp_str,expected_seconds,expected_nanos,raises", + [ + ("2021-01-01T12:00:00Z", 1609502400, 0, None), # Basic format + ("2021-01-01T12:00:00.5Z", 1609502400, 500000000, None), # With nanoseconds + ("2021-01-01T13:00:00+01:00", 1609502400, 0, None), # With timezone offset + ("2021-01-01", None, None, ValueError), # Missing time + ("invalid", None, None, ValueError), # Invalid format + ], +) +def test_timestamp_parse(timestamp_str, expected_seconds, expected_nanos, raises): + """Test parsing RFC3339 timestamp strings.""" + if raises: + with pytest.raises(raises): + Timestamp.parse(timestamp_str) + else: + ts = Timestamp.parse(timestamp_str) + assert ts.seconds == expected_seconds + assert ts.nanos == expected_nanos + + +@pytest.mark.parametrize( + "seconds,nanos,expected_string", + [ + (1609459200, 0, "2021-01-01T00:00:00Z"), # Whole seconds + (1609459200, 500000000, "2021-01-01T00:00:00.5Z"), # With nanoseconds + ], +) +def test_timestamp_to_string(seconds, nanos, expected_string): + """Test string representation of Timestamp.""" + ts = Timestamp(seconds=seconds, nanos=nanos) + assert ts.to_string() == expected_string + + +@pytest.mark.parametrize( + "ts1,ts2,expected_equal", + [ + (Timestamp(1609459200, 500000000), Timestamp(1609459200, 500000000), True), # Equal timestamps + (Timestamp(1609459200, 500000000), Timestamp(1609459200, 0), False), # Different nanos + (Timestamp(1609459200, 500000000), "not a timestamp", False), # Different type + ], +) +def test_timestamp_equality(ts1, ts2, expected_equal): + """Test Timestamp equality comparison.""" + assert (ts1 == ts2) == expected_equal + assert (ts1 != ts2) != expected_equal + + +@pytest.mark.parametrize( + "seconds,nanoseconds,expected_microseconds", + [ + # Test cases with microsecond precision (timedelta limitation) + (0, 999999999, 999999), # Maximum nanoseconds rounds to max microseconds + (0, 999999998, 999999), # Rounds to max microseconds + (0, 1, 0), # Minimum nanoseconds rounds to 0 microseconds + (0, 100000000, 100000), # 0.1 seconds in nanoseconds + (0, 333333333, 333333), # 1/3 second in nanoseconds + (0, 666666666, 666666), # 2/3 second in nanoseconds + (0, 500000000, 500000), # Half second + (0, 250000000, 250000), # Quarter second + (0, 750000000, 750000), # Three quarters second + (0, 125000000, 125000), # 1/8 second + (0, 375000000, 375000), # 3/8 second + (0, 625000000, 625000), # 5/8 second + (0, 875000000, 875000), # 7/8 second + ], +) +def test_duration_float_precision(seconds, nanoseconds, expected_microseconds): + """Test Duration float precision handling with various nanosecond values. + + Note: timedelta only supports microsecond precision (6 decimal places), + so nanosecond values are rounded to the nearest microsecond. + """ + d = Duration(seconds=seconds, nanoseconds=nanoseconds) + td = d.to_timedelta() + assert td.microseconds == expected_microseconds + + +@pytest.mark.parametrize( + "duration_str,expected_microseconds", + [ + ("0.999999999s", 999999), # Maximum precision rounds to max microseconds + ("0.999999998s", 999999), # Rounds to max microseconds + ("0.000000001s", 0), # Minimum precision rounds to 0 microseconds + ("0.1s", 100000), # 0.1 seconds + ("0.333333333s", 333333), # 1/3 second + ("0.666666666s", 666666), # 2/3 second + ("0.5s", 500000), # Half second + ("0.25s", 250000), # Quarter second + ("0.75s", 750000), # Three quarters second + ("0.125s", 125000), # 1/8 second + ("0.375s", 375000), # 3/8 second + ("0.625s", 625000), # 5/8 second + ("0.875s", 875000), # 7/8 second + ], +) +def test_duration_parse_precision(duration_str, expected_microseconds): + """Test Duration parsing precision with various decimal values. + + Note: timedelta only supports microsecond precision (6 decimal places), + so nanosecond values are rounded to the nearest microsecond. + """ + d = Duration.parse(duration_str) + td = d.to_timedelta() + assert td.microseconds == expected_microseconds + + +@pytest.mark.parametrize( + "timestamp_str,expected_nanos", + [ + ("2021-01-01T12:00:00.999999999Z", 999999999), # Maximum precision + ("2021-01-01T12:00:00.999999998Z", 999999998), + ("2021-01-01T12:00:00.000000001Z", 1), # Minimum precision + ("2021-01-01T12:00:00.1Z", 100000000), + ("2021-01-01T12:00:00.333333333Z", 333333333), # 1/3 second + ("2021-01-01T12:00:00.666666666Z", 666666666), # 2/3 second + ("2021-01-01T12:00:00.5Z", 500000000), + ("2021-01-01T12:00:00.25Z", 250000000), + ("2021-01-01T12:00:00.75Z", 750000000), + ("2021-01-01T12:00:00.125Z", 125000000), + ("2021-01-01T12:00:00.375Z", 375000000), + ("2021-01-01T12:00:00.625Z", 625000000), + ("2021-01-01T12:00:00.875Z", 875000000), + ], +) +def test_timestamp_parse_precision(timestamp_str, expected_nanos): + """Test Timestamp parsing precision with various decimal values.""" + ts = Timestamp.parse(timestamp_str) + assert ts.nanos == expected_nanos + # Verify round-trip conversion + assert ts.to_string() == timestamp_str + + +@pytest.mark.parametrize( + "seconds,nanos,expected_microseconds", + [ + (0, 999999999, 999999), # Maximum nanoseconds + (0, 999999998, 999999), + (0, 1, 0), # Minimum nanoseconds (rounds to 0 microseconds) + (0, 100000000, 100000), # 0.1 seconds + (0, 333333333, 333333), # 1/3 second + (0, 666666666, 666666), # 2/3 second + (0, 500000000, 500000), # Half second + (0, 250000000, 250000), # Quarter second + (0, 750000000, 750000), # Three quarters second + (0, 125000000, 125000), # 1/8 second + (0, 375000000, 375000), # 3/8 second + (0, 625000000, 625000), # 5/8 second + (0, 875000000, 875000), # 7/8 second + ], +) +def test_timestamp_datetime_precision(seconds, nanos, expected_microseconds): + """Test Timestamp to datetime conversion precision.""" + ts = Timestamp(seconds=seconds, nanos=nanos) + dt = ts.to_datetime() + assert dt.microsecond == expected_microseconds + # Verify round-trip conversion + ts2 = Timestamp.from_datetime(dt) + # Note: We can't expect exact nanos equality due to microsecond rounding + # but we can verify the microseconds match + assert ts2.to_datetime().microsecond == expected_microseconds