Skip to content

Commit eb11c5f

Browse files
committed
Add custom Support duration and timestamp
1 parent efbaa2e commit eb11c5f

File tree

2 files changed

+526
-0
lines changed

2 files changed

+526
-0
lines changed

databricks/sdk/common.py

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
"""Common types for the Databricks SDK.
2+
3+
This module provides common types used by different APIs.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import logging
9+
import re
10+
from datetime import datetime, timedelta, timezone
11+
12+
_LOG = logging.getLogger("databricks.sdk")
13+
14+
# Python datetime library does not have nanoseconds precision. These classes below are used to work around this limitation.
15+
16+
17+
class Duration:
18+
"""Represents a duration with nanosecond precision.
19+
20+
This class provides nanosecond precision for durations, which is not supported
21+
by Python's standard datetime.timedelta.
22+
23+
Attributes:
24+
seconds (int): Number of seconds in the duration
25+
nanoseconds (int): Number of nanoseconds (0-999999999)
26+
"""
27+
28+
def __init__(self, seconds: int = 0, nanoseconds: int = 0) -> None:
29+
"""Initialize a Duration with seconds and nanoseconds.
30+
31+
Args:
32+
seconds: Number of seconds
33+
nanoseconds: Number of nanoseconds (0-999999999)
34+
35+
Raises:
36+
TypeError: If seconds or nanoseconds are not integers
37+
ValueError: If nanoseconds is not between 0 and 999999999
38+
"""
39+
if not isinstance(seconds, int):
40+
raise TypeError("seconds must be an integer")
41+
if not isinstance(nanoseconds, int):
42+
raise TypeError("nanoseconds must be an integer")
43+
if nanoseconds < 0 or nanoseconds >= 1_000_000_000:
44+
raise ValueError("nanoseconds must be between 0 and 999999999")
45+
46+
self.seconds = seconds
47+
self.nanoseconds = nanoseconds
48+
49+
@classmethod
50+
def from_timedelta(cls, td: timedelta) -> "Duration":
51+
"""Convert a datetime.timedelta to Duration.
52+
53+
Args:
54+
td: The timedelta to convert
55+
56+
Returns:
57+
Duration: A new Duration instance with equivalent time span
58+
59+
Note:
60+
The conversion may lose precision as timedelta only supports microsecond precision
61+
"""
62+
total_seconds = int(td.total_seconds())
63+
# Get the microseconds part and convert to nanoseconds
64+
microseconds = td.microseconds
65+
nanoseconds = microseconds * 1000
66+
return cls(seconds=total_seconds, nanoseconds=nanoseconds)
67+
68+
def to_timedelta(self) -> timedelta:
69+
"""Convert Duration to datetime.timedelta.
70+
71+
Returns:
72+
timedelta: A new timedelta instance with equivalent time span
73+
74+
Note:
75+
The conversion may lose precision as timedelta only supports microsecond precision
76+
"""
77+
# Convert nanoseconds to microseconds for timedelta
78+
microseconds = self.nanoseconds // 1000
79+
return timedelta(seconds=self.seconds, microseconds=microseconds)
80+
81+
def __repr__(self) -> str:
82+
"""Return a string representation of the Duration.
83+
84+
Returns:
85+
str: String in the format 'Duration(seconds=X, nanoseconds=Y)'
86+
"""
87+
return f"Duration(seconds={self.seconds}, nanoseconds={self.nanoseconds})"
88+
89+
def __eq__(self, other: object) -> bool:
90+
"""Compare this Duration with another object for equality.
91+
92+
Args:
93+
other: Object to compare with
94+
95+
Returns:
96+
bool: True if other is a Duration with same seconds and nanoseconds
97+
"""
98+
if not isinstance(other, Duration):
99+
return NotImplemented
100+
return self.seconds == other.seconds and self.nanoseconds == other.nanoseconds
101+
102+
@classmethod
103+
def parse(cls, duration_str: str) -> "Duration":
104+
"""Parse a duration string in the format 'Xs' where X is a decimal number.
105+
106+
Examples:
107+
"3.1s" -> Duration(seconds=3, nanoseconds=100000000)
108+
"1.5s" -> Duration(seconds=1, nanoseconds=500000000)
109+
"10s" -> Duration(seconds=10, nanoseconds=0)
110+
111+
Args:
112+
duration_str: String in the format 'Xs' where X is a decimal number
113+
114+
Returns:
115+
A new Duration instance
116+
117+
Raises:
118+
ValueError: If the string format is invalid
119+
"""
120+
if not duration_str.endswith("s"):
121+
raise ValueError("Duration string must end with 's'")
122+
123+
try:
124+
# Remove the 's' suffix and convert to float
125+
value = float(duration_str[:-1])
126+
# Split into integer and fractional parts
127+
seconds = int(value)
128+
# Convert fractional part to nanoseconds
129+
nanoseconds = int((value - seconds) * 1_000_000_000)
130+
return cls(seconds=seconds, nanoseconds=nanoseconds)
131+
except ValueError as e:
132+
raise ValueError(f"Invalid duration format: {duration_str}") from e
133+
134+
def to_string(self) -> str:
135+
"""Convert Duration to string format 'Xs' where X is a decimal number.
136+
137+
Examples:
138+
Duration(seconds=3, nanoseconds=100000000) -> "3.1s"
139+
Duration(seconds=1, nanoseconds=500000000) -> "1.5s"
140+
Duration(seconds=10, nanoseconds=0) -> "10s"
141+
142+
Returns:
143+
String representation of the duration
144+
"""
145+
if self.nanoseconds == 0:
146+
return f"{self.seconds}s"
147+
148+
# Convert to decimal representation
149+
total_seconds = self.seconds + (self.nanoseconds / 1_000_000_000)
150+
# Format with up to 9 decimal places, removing trailing zeros
151+
return f"{total_seconds:.9f}".rstrip("0").rstrip(".") + "s"
152+
153+
154+
class Timestamp:
155+
"""Represents a timestamp with nanosecond precision.
156+
157+
This class provides nanosecond precision for timestamps, which is not supported
158+
by Python's standard datetime. It's compatible with protobuf Timestamp format and
159+
supports RFC3339 string formatting.
160+
161+
Attributes:
162+
seconds (int): Seconds since Unix epoch (1970-01-01T00:00:00Z)
163+
nanos (int): Nanoseconds (0-999999999)
164+
"""
165+
166+
# RFC3339 regex pattern for validation and parsing
167+
_RFC3339_PATTERN = re.compile(
168+
r"^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:?\d{2})$"
169+
)
170+
171+
def __init__(self, seconds: int = 0, nanos: int = 0) -> None:
172+
"""Initialize a Timestamp with seconds since epoch and nanoseconds.
173+
174+
Args:
175+
seconds: Seconds since Unix epoch (1970-01-01T00:00:00Z)
176+
nanos: Nanoseconds (0-999999999)
177+
178+
Raises:
179+
TypeError: If seconds or nanos are not integers
180+
ValueError: If nanos is not between 0 and 999999999
181+
"""
182+
if not isinstance(seconds, int):
183+
raise TypeError("seconds must be an integer")
184+
if not isinstance(nanos, int):
185+
raise TypeError("nanos must be an integer")
186+
if nanos < 0 or nanos >= 1_000_000_000:
187+
raise ValueError("nanos must be between 0 and 999999999")
188+
189+
self.seconds = seconds
190+
self.nanos = nanos
191+
192+
@classmethod
193+
def from_datetime(cls, dt: datetime) -> "Timestamp":
194+
"""Convert a datetime.datetime to Timestamp.
195+
196+
Args:
197+
dt: The datetime to convert. If naive, it's assumed to be UTC.
198+
199+
Returns:
200+
Timestamp: A new Timestamp instance
201+
202+
Note:
203+
The datetime is converted to UTC if it isn't already
204+
"""
205+
# If datetime is naive (no timezone), assume UTC
206+
if dt.tzinfo is None:
207+
dt = dt.replace(tzinfo=timezone.utc)
208+
# Convert to UTC
209+
utc_dt = dt.astimezone(timezone.utc)
210+
# Use timestamp() to get seconds since epoch
211+
seconds = int(utc_dt.timestamp())
212+
nanos = utc_dt.microsecond * 1000
213+
return cls(seconds=seconds, nanos=nanos)
214+
215+
def to_datetime(self) -> datetime:
216+
"""Convert Timestamp to datetime.datetime.
217+
218+
Returns:
219+
datetime: A new datetime instance in UTC timezone
220+
221+
Note:
222+
The returned datetime will have microsecond precision at most
223+
"""
224+
# Create base datetime from seconds
225+
dt = datetime.fromtimestamp(self.seconds, tz=timezone.utc)
226+
# Add nanoseconds converted to microseconds
227+
microseconds = self.nanos // 1000
228+
return dt.replace(microsecond=microseconds)
229+
230+
@classmethod
231+
def parse(cls, timestamp_str: str) -> "Timestamp":
232+
"""Parse an RFC3339 formatted string into a Timestamp.
233+
234+
Examples:
235+
>>> Timestamp.parse("2023-01-01T12:00:00Z")
236+
>>> Timestamp.parse("2023-01-01T12:00:00.123456789Z")
237+
>>> Timestamp.parse("2023-01-01T12:00:00+01:00")
238+
239+
Args:
240+
timestamp_str: RFC3339 formatted timestamp string
241+
242+
Returns:
243+
Timestamp: A new Timestamp instance
244+
245+
Raises:
246+
ValueError: If the string format is invalid or not RFC3339 compliant
247+
"""
248+
match = cls._RFC3339_PATTERN.match(timestamp_str)
249+
if not match:
250+
raise ValueError(f"Invalid RFC3339 format: {timestamp_str}")
251+
252+
year, month, day, hour, minute, second, frac, offset = match.groups()
253+
254+
# Build the datetime string with a standardized offset format
255+
dt_str = f"{year}-{month}-{day}T{hour}:{minute}:{second}"
256+
if frac:
257+
# Pad or truncate to 9 digits for nanoseconds
258+
frac = (frac + "000000000")[:9]
259+
dt_str += f".{frac}"
260+
261+
# Handle timezone offset
262+
if offset == "Z":
263+
dt_str += "+00:00"
264+
elif ":" not in offset:
265+
# Insert colon in offset if not present (e.g., +0000 -> +00:00)
266+
dt_str += f"{offset[:3]}:{offset[3:]}"
267+
else:
268+
dt_str += offset
269+
270+
dt = datetime.fromisoformat(dt_str)
271+
return cls.from_datetime(dt)
272+
273+
def to_string(self) -> str:
274+
"""Convert Timestamp to RFC3339 formatted string.
275+
276+
Returns:
277+
str: RFC3339 formatted timestamp string in UTC timezone
278+
279+
Note:
280+
The string will include nanosecond precision only if nanos > 0
281+
"""
282+
# Convert seconds to UTC datetime for formatting
283+
dt = datetime.fromtimestamp(self.seconds, tz=timezone.utc)
284+
base = dt.strftime("%Y-%m-%dT%H:%M:%S")
285+
286+
# Add nanoseconds if present
287+
if self.nanos == 0:
288+
return base + "Z"
289+
290+
# Format nanoseconds, removing trailing zeros
291+
nanos_str = f"{self.nanos:09d}".rstrip("0")
292+
return f"{base}.{nanos_str}Z"
293+
294+
def __repr__(self) -> str:
295+
"""Return a string representation of the Timestamp.
296+
297+
Returns:
298+
str: String in the format 'Timestamp(seconds=X, nanos=Y)'
299+
"""
300+
return f"Timestamp(seconds={self.seconds}, nanos={self.nanos})"
301+
302+
def __eq__(self, other: object) -> bool:
303+
"""Compare this Timestamp with another object for equality.
304+
305+
Args:
306+
other: Object to compare with
307+
308+
Returns:
309+
bool: True if other is a Timestamp with same seconds and nanos
310+
"""
311+
if not isinstance(other, Timestamp):
312+
return NotImplemented
313+
return self.seconds == other.seconds and self.nanos == other.nanos

0 commit comments

Comments
 (0)