airbyte_cdk.utils.datetime_helpers
Provides consistent datetime handling across Airbyte with ISO8601/RFC3339 compliance.
Copyright (c) 2023 Airbyte, Inc., all rights reserved.
This module provides a custom datetime class (AirbyteDateTime) and helper functions that ensure consistent datetime handling across Airbyte. All datetime strings are formatted according to ISO8601/RFC3339 standards with 'T' delimiter and '+00:00' for UTC timezone.
Key Features:
- Timezone-aware datetime objects (defaults to UTC)
- ISO8601/RFC3339 compliant string formatting
- Consistent parsing of various datetime formats
- Support for Unix timestamps and milliseconds
- Type-safe datetime arithmetic with timedelta
Basic Usage
from airbyte_cdk.utils.datetime_helpers import AirbyteDateTime, ab_datetime_now, ab_datetime_parse
from datetime import timedelta, timezone
## Current time in UTC
now = ab_datetime_now()
print(now) # 2023-03-14T15:09:26.535897Z
# Parse various datetime formats
dt = ab_datetime_parse("2023-03-14T15:09:26Z") # ISO8601/RFC3339
dt = ab_datetime_parse("2023-03-14") # Date only (assumes midnight UTC)
dt = ab_datetime_parse(1678806566) # Unix timestamp
## Create with explicit timezone
dt = AirbyteDateTime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc)
print(dt) # 2023-03-14T15:09:26+00:00
# Datetime arithmetic with timedelta
tomorrow = dt + timedelta(days=1)
yesterday = dt - timedelta(days=1)
time_diff = tomorrow - yesterday # timedelta object
Millisecond Timestamp Handling
# Convert to millisecond timestamp
dt = ab_datetime_parse("2023-03-14T15:09:26Z")
ms = dt.to_epoch_millis() # 1678806566000
# Create from millisecond timestamp
dt = AirbyteDateTime.from_epoch_millis(1678806566000)
print(dt) # 2023-03-14T15:09:26Z
Timezone Handling
# Create with non-UTC timezone
tz = timezone(timedelta(hours=-4)) # EDT
dt = AirbyteDateTime(2023, 3, 14, 15, 9, 26, tzinfo=tz)
print(dt) # 2023-03-14T15:09:26-04:00
## Parse with timezone
dt = ab_datetime_parse("2023-03-14T15:09:26-04:00")
print(dt) # 2023-03-14T15:09:26-04:00
## Naive datetimes are automatically converted to UTC
dt = ab_datetime_parse("2023-03-14T15:09:26")
print(dt) # 2023-03-14T15:09:26Z
Format Validation
from airbyte_cdk.utils.datetime_helpers import ab_datetime_try_parse
# Validate ISO8601/RFC3339 format
assert ab_datetime_try_parse("2023-03-14T15:09:26Z") # Basic UTC format
assert ab_datetime_try_parse("2023-03-14T15:09:26-04:00") # With timezone offset
assert ab_datetime_try_parse("2023-03-14T15:09:26+00:00") # With explicit UTC offset
assert ab_datetime_try_parse("2023-03-14 15:09:26Z") # Missing T delimiter but still parsable
assert not ab_datetime_try_parse("foo") # Invalid: not parsable, returns `None`
1"""Provides consistent datetime handling across Airbyte with ISO8601/RFC3339 compliance. 2 3Copyright (c) 2023 Airbyte, Inc., all rights reserved. 4 5This module provides a custom datetime class (AirbyteDateTime) and helper functions that ensure 6consistent datetime handling across Airbyte. All datetime strings are formatted according to 7ISO8601/RFC3339 standards with 'T' delimiter and '+00:00' for UTC timezone. 8 9Key Features: 10 - Timezone-aware datetime objects (defaults to UTC) 11 - ISO8601/RFC3339 compliant string formatting 12 - Consistent parsing of various datetime formats 13 - Support for Unix timestamps and milliseconds 14 - Type-safe datetime arithmetic with timedelta 15 16## Basic Usage 17 18```python 19from airbyte_cdk.utils.datetime_helpers import AirbyteDateTime, ab_datetime_now, ab_datetime_parse 20from datetime import timedelta, timezone 21 22## Current time in UTC 23now = ab_datetime_now() 24print(now) # 2023-03-14T15:09:26.535897Z 25 26# Parse various datetime formats 27dt = ab_datetime_parse("2023-03-14T15:09:26Z") # ISO8601/RFC3339 28dt = ab_datetime_parse("2023-03-14") # Date only (assumes midnight UTC) 29dt = ab_datetime_parse(1678806566) # Unix timestamp 30 31## Create with explicit timezone 32dt = AirbyteDateTime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc) 33print(dt) # 2023-03-14T15:09:26+00:00 34 35# Datetime arithmetic with timedelta 36tomorrow = dt + timedelta(days=1) 37yesterday = dt - timedelta(days=1) 38time_diff = tomorrow - yesterday # timedelta object 39``` 40 41## Millisecond Timestamp Handling 42 43```python 44# Convert to millisecond timestamp 45dt = ab_datetime_parse("2023-03-14T15:09:26Z") 46ms = dt.to_epoch_millis() # 1678806566000 47 48# Create from millisecond timestamp 49dt = AirbyteDateTime.from_epoch_millis(1678806566000) 50print(dt) # 2023-03-14T15:09:26Z 51``` 52 53## Timezone Handling 54 55```python 56# Create with non-UTC timezone 57tz = timezone(timedelta(hours=-4)) # EDT 58dt = AirbyteDateTime(2023, 3, 14, 15, 9, 26, tzinfo=tz) 59print(dt) # 2023-03-14T15:09:26-04:00 60 61## Parse with timezone 62dt = ab_datetime_parse("2023-03-14T15:09:26-04:00") 63print(dt) # 2023-03-14T15:09:26-04:00 64 65## Naive datetimes are automatically converted to UTC 66dt = ab_datetime_parse("2023-03-14T15:09:26") 67print(dt) # 2023-03-14T15:09:26Z 68``` 69 70# Format Validation 71 72```python 73from airbyte_cdk.utils.datetime_helpers import ab_datetime_try_parse 74 75# Validate ISO8601/RFC3339 format 76assert ab_datetime_try_parse("2023-03-14T15:09:26Z") # Basic UTC format 77assert ab_datetime_try_parse("2023-03-14T15:09:26-04:00") # With timezone offset 78assert ab_datetime_try_parse("2023-03-14T15:09:26+00:00") # With explicit UTC offset 79assert ab_datetime_try_parse("2023-03-14 15:09:26Z") # Missing T delimiter but still parsable 80assert not ab_datetime_try_parse("foo") # Invalid: not parsable, returns `None` 81``` 82""" 83 84from datetime import datetime, timedelta, timezone 85from typing import Any, Optional, Union, overload 86 87from dateutil import parser 88from typing_extensions import Never 89from whenever import Instant, LocalDateTime, ZonedDateTime 90 91 92class AirbyteDateTime(datetime): 93 """A timezone-aware datetime class with ISO8601/RFC3339 string representation and operator overloading. 94 95 This class extends the standard datetime class to provide consistent timezone handling 96 (defaulting to UTC) and ISO8601/RFC3339 compliant string formatting. It also supports 97 operator overloading for datetime arithmetic with timedelta objects. 98 99 Example: 100 >>> dt = AirbyteDateTime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc) 101 >>> str(dt) 102 '2023-03-14T15:09:26+00:00' 103 >>> dt + timedelta(hours=1) 104 '2023-03-14T16:09:26+00:00' 105 """ 106 107 def __new__(cls, *args: Any, **kwargs: Any) -> "AirbyteDateTime": 108 """Creates a new timezone-aware AirbyteDateTime instance. 109 110 Ensures all instances are timezone-aware by defaulting to UTC if no timezone is provided. 111 112 Returns: 113 AirbyteDateTime: A new timezone-aware datetime instance. 114 """ 115 self = super().__new__(cls, *args, **kwargs) 116 if self.tzinfo is None: 117 return self.replace(tzinfo=timezone.utc) 118 return self 119 120 @classmethod 121 def from_datetime(cls, dt: datetime) -> "AirbyteDateTime": 122 """Converts a standard datetime to AirbyteDateTime. 123 124 Args: 125 dt: A standard datetime object to convert. 126 127 Returns: 128 AirbyteDateTime: A new timezone-aware AirbyteDateTime instance. 129 """ 130 return cls( 131 dt.year, 132 dt.month, 133 dt.day, 134 dt.hour, 135 dt.minute, 136 dt.second, 137 dt.microsecond, 138 dt.tzinfo or timezone.utc, 139 ) 140 141 def to_datetime(self) -> datetime: 142 """Converts this AirbyteDateTime to a standard datetime object. 143 144 Today, this just returns `self` because AirbyteDateTime is a subclass of `datetime`. 145 In the future, we may modify our internal representation to use a different base class. 146 """ 147 return self 148 149 def __str__(self) -> str: 150 """Returns the datetime in ISO8601/RFC3339 format with 'T' delimiter. 151 152 Ensures consistent string representation with timezone, using '+00:00' for UTC. 153 Preserves full microsecond precision when present, omits when zero. 154 155 Returns: 156 str: ISO8601/RFC3339 formatted string. 157 """ 158 aware_self = self if self.tzinfo else self.replace(tzinfo=timezone.utc) 159 return aware_self.isoformat(sep="T", timespec="auto") 160 161 def __repr__(self) -> str: 162 """Returns the same string representation as __str__ for consistency. 163 164 Returns: 165 str: ISO8601/RFC3339 formatted string. 166 """ 167 return self.__str__() 168 169 def add(self, delta: timedelta) -> "AirbyteDateTime": 170 """Add a timedelta interval to this datetime. 171 172 This method provides a more explicit alternative to the + operator 173 for adding time intervals to datetimes. 174 175 Args: 176 delta: The timedelta interval to add. 177 178 Returns: 179 AirbyteDateTime: A new datetime with the interval added. 180 181 Example: 182 >>> dt = AirbyteDateTime(2023, 3, 14, tzinfo=timezone.utc) 183 >>> dt.add(timedelta(hours=1)) 184 '2023-03-14T01:00:00Z' 185 """ 186 return self + delta 187 188 def subtract(self, delta: timedelta) -> "AirbyteDateTime": 189 """Subtract a timedelta interval from this datetime. 190 191 This method provides a more explicit alternative to the - operator 192 for subtracting time intervals from datetimes. 193 194 Args: 195 delta: The timedelta interval to subtract. 196 197 Returns: 198 AirbyteDateTime: A new datetime with the interval subtracted. 199 200 Example: 201 >>> dt = AirbyteDateTime(2023, 3, 14, tzinfo=timezone.utc) 202 >>> dt.subtract(timedelta(hours=1)) 203 '2023-03-13T23:00:00Z' 204 """ 205 result = super().__sub__(delta) 206 if isinstance(result, datetime): 207 return AirbyteDateTime.from_datetime(result) 208 raise TypeError("Invalid operation") 209 210 def __add__(self, other: timedelta) -> "AirbyteDateTime": 211 """Adds a timedelta to this datetime. 212 213 Args: 214 other: A timedelta object to add. 215 216 Returns: 217 AirbyteDateTime: A new datetime with the timedelta added. 218 219 Raises: 220 TypeError: If other is not a timedelta. 221 """ 222 result = super().__add__(other) 223 if isinstance(result, datetime): 224 return AirbyteDateTime.from_datetime(result) 225 raise TypeError("Invalid operation") 226 227 def __radd__(self, other: timedelta) -> "AirbyteDateTime": 228 """Supports timedelta + AirbyteDateTime operation. 229 230 Args: 231 other: A timedelta object to add. 232 233 Returns: 234 AirbyteDateTime: A new datetime with the timedelta added. 235 236 Raises: 237 TypeError: If other is not a timedelta. 238 """ 239 return self.__add__(other) 240 241 @overload # type: ignore[override] 242 def __sub__(self, other: timedelta) -> "AirbyteDateTime": ... 243 244 @overload # type: ignore[override] 245 def __sub__(self, other: Union[datetime, "AirbyteDateTime"]) -> timedelta: ... 246 247 def __sub__( 248 self, other: Union[datetime, "AirbyteDateTime", timedelta] 249 ) -> Union[timedelta, "AirbyteDateTime"]: # type: ignore[override] 250 """Subtracts a datetime, AirbyteDateTime, or timedelta from this datetime. 251 252 Args: 253 other: A datetime, AirbyteDateTime, or timedelta object to subtract. 254 255 Returns: 256 Union[timedelta, AirbyteDateTime]: A timedelta if subtracting datetime/AirbyteDateTime, 257 or a new datetime if subtracting timedelta. 258 259 Raises: 260 TypeError: If other is not a datetime, AirbyteDateTime, or timedelta. 261 """ 262 if isinstance(other, timedelta): 263 result = super().__sub__(other) # type: ignore[call-overload] 264 if isinstance(result, datetime): 265 return AirbyteDateTime.from_datetime(result) 266 elif isinstance(other, (datetime, AirbyteDateTime)): 267 result = super().__sub__(other) # type: ignore[call-overload] 268 if isinstance(result, timedelta): 269 return result 270 raise TypeError( 271 f"unsupported operand type(s) for -: '{type(self).__name__}' and '{type(other).__name__}'" 272 ) 273 274 def __rsub__(self, other: datetime) -> timedelta: 275 """Supports datetime - AirbyteDateTime operation. 276 277 Args: 278 other: A datetime object. 279 280 Returns: 281 timedelta: The time difference between the datetimes. 282 283 Raises: 284 TypeError: If other is not a datetime. 285 """ 286 if not isinstance(other, datetime): 287 return NotImplemented 288 result = other - datetime( 289 self.year, 290 self.month, 291 self.day, 292 self.hour, 293 self.minute, 294 self.second, 295 self.microsecond, 296 self.tzinfo, 297 ) 298 if isinstance(result, timedelta): 299 return result 300 raise TypeError("Invalid operation") 301 302 def to_epoch_millis(self) -> int: 303 """Return the Unix timestamp in milliseconds for this datetime. 304 305 Returns: 306 int: Number of milliseconds since Unix epoch (January 1, 1970). 307 308 Example: 309 >>> dt = AirbyteDateTime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc) 310 >>> dt.to_epoch_millis() 311 1678806566000 312 """ 313 return int(self.timestamp() * 1000) 314 315 @classmethod 316 def from_epoch_millis(cls, milliseconds: int) -> "AirbyteDateTime": 317 """Create an AirbyteDateTime from Unix timestamp in milliseconds. 318 319 Args: 320 milliseconds: Number of milliseconds since Unix epoch (January 1, 1970). 321 322 Returns: 323 AirbyteDateTime: A new timezone-aware datetime instance (UTC). 324 325 Example: 326 >>> dt = AirbyteDateTime.from_epoch_millis(1678806566000) 327 >>> str(dt) 328 '2023-03-14T15:09:26+00:00' 329 """ 330 return cls.fromtimestamp(milliseconds / 1000.0, timezone.utc) 331 332 @classmethod 333 def from_str(cls, dt_str: str) -> "AirbyteDateTime": 334 """Thin convenience wrapper around `ab_datetime_parse()`. 335 336 This method attempts to create a new `AirbyteDateTime` using all available parsing 337 strategies. 338 339 Raises: 340 ValueError: If the value cannot be parsed into a valid datetime object. 341 """ 342 return ab_datetime_parse(dt_str) 343 344 345def ab_datetime_now() -> AirbyteDateTime: 346 """Returns the current time as an AirbyteDateTime in UTC timezone. 347 348 Previously named: now() 349 350 Returns: 351 AirbyteDateTime: Current UTC time. 352 353 Example: 354 >>> dt = ab_datetime_now() 355 >>> str(dt) # Returns current time in ISO8601/RFC3339 356 '2023-03-14T15:09:26.535897Z' 357 """ 358 return AirbyteDateTime.from_datetime(datetime.now(timezone.utc)) 359 360 361def ab_datetime_parse(dt_str: str | int) -> AirbyteDateTime: 362 """Parses a datetime string or timestamp into an AirbyteDateTime with timezone awareness. 363 364 This implementation is as flexible as possible to handle various datetime formats. 365 Always returns a timezone-aware datetime (defaults to UTC if no timezone specified). 366 367 Handles: 368 - ISO8601/RFC3339 format strings (with ' ' or 'T' delimiter) 369 - Unix timestamps (as integers or strings) 370 - Date-only strings (YYYY-MM-DD) 371 - Timezone-aware formats (+00:00 for UTC, or ±HH:MM offset) 372 - Anything that can be parsed by `dateutil.parser.parse()` 373 374 Args: 375 dt_str: A datetime string in ISO8601/RFC3339 format, Unix timestamp (int/str), 376 or other recognizable datetime format. 377 378 Returns: 379 AirbyteDateTime: A timezone-aware datetime object. 380 381 Raises: 382 ValueError: If the input cannot be parsed as a valid datetime. 383 384 Example: 385 >>> ab_datetime_parse("2023-03-14T15:09:26+00:00") 386 '2023-03-14T15:09:26+00:00' 387 >>> ab_datetime_parse(1678806000) # Unix timestamp 388 '2023-03-14T15:00:00+00:00' 389 >>> ab_datetime_parse("2023-03-14") # Date-only 390 '2023-03-14T00:00:00+00:00' 391 """ 392 try: 393 # Handle numeric values as Unix timestamps (UTC) 394 if isinstance(dt_str, int) or ( 395 isinstance(dt_str, str) 396 and (dt_str.isdigit() or (dt_str.startswith("-") and dt_str[1:].isdigit())) 397 ): 398 timestamp = int(dt_str) 399 if timestamp < 0: 400 raise ValueError("Timestamp cannot be negative") 401 if len(str(abs(timestamp))) > 10: 402 raise ValueError("Timestamp value too large") 403 instant = Instant.from_timestamp(timestamp) 404 return AirbyteDateTime.from_datetime(instant.py_datetime()) 405 406 if not isinstance(dt_str, str): 407 raise ValueError( 408 f"Could not parse datetime string: expected string or integer, got {type(dt_str)}" 409 ) 410 411 # Handle date-only format first 412 if ":" not in dt_str and dt_str.count("-") == 2 and "/" not in dt_str: 413 try: 414 year, month, day = map(int, dt_str.split("-")) 415 if not (1 <= month <= 12 and 1 <= day <= 31): 416 raise ValueError(f"Invalid date format: {dt_str}") 417 instant = Instant.from_utc(year, month, day, 0, 0, 0) 418 return AirbyteDateTime.from_datetime(instant.py_datetime()) 419 except (ValueError, TypeError): 420 raise ValueError(f"Invalid date format: {dt_str}") 421 422 # Reject time-only strings without date 423 if ":" in dt_str and dt_str.count("-") < 2 and dt_str.count("/") < 2: 424 raise ValueError(f"Missing date part in datetime string: {dt_str}") 425 426 # Try parsing with dateutil for timezone handling 427 try: 428 parsed = parser.parse(dt_str) 429 if parsed.tzinfo is None: 430 parsed = parsed.replace(tzinfo=timezone.utc) 431 432 return AirbyteDateTime.from_datetime(parsed) 433 except (ValueError, TypeError): 434 raise ValueError(f"Could not parse datetime string: {dt_str}") 435 except ValueError as e: 436 if "Invalid date format:" in str(e): 437 raise 438 if "Timestamp cannot be negative" in str(e): 439 raise 440 if "Timestamp value too large" in str(e): 441 raise 442 raise ValueError(f"Could not parse datetime string: {dt_str}") 443 444 445def ab_datetime_try_parse(dt_str: str) -> AirbyteDateTime | None: 446 """Try to parse the input as a datetime, failing gracefully instead of raising an exception. 447 448 This is a thin wrapper around `ab_datetime_parse()` that catches parsing errors and 449 returns `None` instead of raising an exception. 450 The implementation is as flexible as possible to handle various datetime formats. 451 Always returns a timezone-aware datetime (defaults to `UTC` if no timezone specified). 452 453 Example: 454 >>> ab_datetime_try_parse("2023-03-14T15:09:26Z") # Returns AirbyteDateTime 455 >>> ab_datetime_try_parse("2023-03-14 15:09:26Z") # Missing 'T' delimiter still parsable 456 >>> ab_datetime_try_parse("2023-03-14") # Returns midnight UTC time 457 """ 458 try: 459 return ab_datetime_parse(dt_str) 460 except (ValueError, TypeError): 461 return None 462 463 464def ab_datetime_format( 465 dt: Union[datetime, AirbyteDateTime], 466 format: str | None = None, 467) -> str: 468 """Formats a datetime object as an ISO8601/RFC3339 string with 'T' delimiter and timezone. 469 470 Previously named: format() 471 472 Converts any datetime object to a string with 'T' delimiter and proper timezone. 473 If the datetime is naive (no timezone), UTC is assumed. 474 Uses '+00:00' for UTC timezone, otherwise keeps the original timezone offset. 475 476 Args: 477 dt: Any datetime object to format. 478 format: Optional format string. If provided, calls `strftime()` with this format. 479 Otherwise, uses the default ISO8601/RFC3339 format, adapted for available precision. 480 481 Returns: 482 str: ISO8601/RFC3339 formatted datetime string. 483 484 Example: 485 >>> dt = datetime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc) 486 >>> ab_datetime_format(dt) 487 '2023-03-14T15:09:26+00:00' 488 """ 489 if isinstance(dt, AirbyteDateTime): 490 return str(dt) 491 492 if dt.tzinfo is None: 493 dt = dt.replace(tzinfo=timezone.utc) 494 495 if format: 496 return dt.strftime(format) 497 498 # Format with consistent timezone representation and "T" delimiter 499 return dt.isoformat(sep="T", timespec="auto")
93class AirbyteDateTime(datetime): 94 """A timezone-aware datetime class with ISO8601/RFC3339 string representation and operator overloading. 95 96 This class extends the standard datetime class to provide consistent timezone handling 97 (defaulting to UTC) and ISO8601/RFC3339 compliant string formatting. It also supports 98 operator overloading for datetime arithmetic with timedelta objects. 99 100 Example: 101 >>> dt = AirbyteDateTime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc) 102 >>> str(dt) 103 '2023-03-14T15:09:26+00:00' 104 >>> dt + timedelta(hours=1) 105 '2023-03-14T16:09:26+00:00' 106 """ 107 108 def __new__(cls, *args: Any, **kwargs: Any) -> "AirbyteDateTime": 109 """Creates a new timezone-aware AirbyteDateTime instance. 110 111 Ensures all instances are timezone-aware by defaulting to UTC if no timezone is provided. 112 113 Returns: 114 AirbyteDateTime: A new timezone-aware datetime instance. 115 """ 116 self = super().__new__(cls, *args, **kwargs) 117 if self.tzinfo is None: 118 return self.replace(tzinfo=timezone.utc) 119 return self 120 121 @classmethod 122 def from_datetime(cls, dt: datetime) -> "AirbyteDateTime": 123 """Converts a standard datetime to AirbyteDateTime. 124 125 Args: 126 dt: A standard datetime object to convert. 127 128 Returns: 129 AirbyteDateTime: A new timezone-aware AirbyteDateTime instance. 130 """ 131 return cls( 132 dt.year, 133 dt.month, 134 dt.day, 135 dt.hour, 136 dt.minute, 137 dt.second, 138 dt.microsecond, 139 dt.tzinfo or timezone.utc, 140 ) 141 142 def to_datetime(self) -> datetime: 143 """Converts this AirbyteDateTime to a standard datetime object. 144 145 Today, this just returns `self` because AirbyteDateTime is a subclass of `datetime`. 146 In the future, we may modify our internal representation to use a different base class. 147 """ 148 return self 149 150 def __str__(self) -> str: 151 """Returns the datetime in ISO8601/RFC3339 format with 'T' delimiter. 152 153 Ensures consistent string representation with timezone, using '+00:00' for UTC. 154 Preserves full microsecond precision when present, omits when zero. 155 156 Returns: 157 str: ISO8601/RFC3339 formatted string. 158 """ 159 aware_self = self if self.tzinfo else self.replace(tzinfo=timezone.utc) 160 return aware_self.isoformat(sep="T", timespec="auto") 161 162 def __repr__(self) -> str: 163 """Returns the same string representation as __str__ for consistency. 164 165 Returns: 166 str: ISO8601/RFC3339 formatted string. 167 """ 168 return self.__str__() 169 170 def add(self, delta: timedelta) -> "AirbyteDateTime": 171 """Add a timedelta interval to this datetime. 172 173 This method provides a more explicit alternative to the + operator 174 for adding time intervals to datetimes. 175 176 Args: 177 delta: The timedelta interval to add. 178 179 Returns: 180 AirbyteDateTime: A new datetime with the interval added. 181 182 Example: 183 >>> dt = AirbyteDateTime(2023, 3, 14, tzinfo=timezone.utc) 184 >>> dt.add(timedelta(hours=1)) 185 '2023-03-14T01:00:00Z' 186 """ 187 return self + delta 188 189 def subtract(self, delta: timedelta) -> "AirbyteDateTime": 190 """Subtract a timedelta interval from this datetime. 191 192 This method provides a more explicit alternative to the - operator 193 for subtracting time intervals from datetimes. 194 195 Args: 196 delta: The timedelta interval to subtract. 197 198 Returns: 199 AirbyteDateTime: A new datetime with the interval subtracted. 200 201 Example: 202 >>> dt = AirbyteDateTime(2023, 3, 14, tzinfo=timezone.utc) 203 >>> dt.subtract(timedelta(hours=1)) 204 '2023-03-13T23:00:00Z' 205 """ 206 result = super().__sub__(delta) 207 if isinstance(result, datetime): 208 return AirbyteDateTime.from_datetime(result) 209 raise TypeError("Invalid operation") 210 211 def __add__(self, other: timedelta) -> "AirbyteDateTime": 212 """Adds a timedelta to this datetime. 213 214 Args: 215 other: A timedelta object to add. 216 217 Returns: 218 AirbyteDateTime: A new datetime with the timedelta added. 219 220 Raises: 221 TypeError: If other is not a timedelta. 222 """ 223 result = super().__add__(other) 224 if isinstance(result, datetime): 225 return AirbyteDateTime.from_datetime(result) 226 raise TypeError("Invalid operation") 227 228 def __radd__(self, other: timedelta) -> "AirbyteDateTime": 229 """Supports timedelta + AirbyteDateTime operation. 230 231 Args: 232 other: A timedelta object to add. 233 234 Returns: 235 AirbyteDateTime: A new datetime with the timedelta added. 236 237 Raises: 238 TypeError: If other is not a timedelta. 239 """ 240 return self.__add__(other) 241 242 @overload # type: ignore[override] 243 def __sub__(self, other: timedelta) -> "AirbyteDateTime": ... 244 245 @overload # type: ignore[override] 246 def __sub__(self, other: Union[datetime, "AirbyteDateTime"]) -> timedelta: ... 247 248 def __sub__( 249 self, other: Union[datetime, "AirbyteDateTime", timedelta] 250 ) -> Union[timedelta, "AirbyteDateTime"]: # type: ignore[override] 251 """Subtracts a datetime, AirbyteDateTime, or timedelta from this datetime. 252 253 Args: 254 other: A datetime, AirbyteDateTime, or timedelta object to subtract. 255 256 Returns: 257 Union[timedelta, AirbyteDateTime]: A timedelta if subtracting datetime/AirbyteDateTime, 258 or a new datetime if subtracting timedelta. 259 260 Raises: 261 TypeError: If other is not a datetime, AirbyteDateTime, or timedelta. 262 """ 263 if isinstance(other, timedelta): 264 result = super().__sub__(other) # type: ignore[call-overload] 265 if isinstance(result, datetime): 266 return AirbyteDateTime.from_datetime(result) 267 elif isinstance(other, (datetime, AirbyteDateTime)): 268 result = super().__sub__(other) # type: ignore[call-overload] 269 if isinstance(result, timedelta): 270 return result 271 raise TypeError( 272 f"unsupported operand type(s) for -: '{type(self).__name__}' and '{type(other).__name__}'" 273 ) 274 275 def __rsub__(self, other: datetime) -> timedelta: 276 """Supports datetime - AirbyteDateTime operation. 277 278 Args: 279 other: A datetime object. 280 281 Returns: 282 timedelta: The time difference between the datetimes. 283 284 Raises: 285 TypeError: If other is not a datetime. 286 """ 287 if not isinstance(other, datetime): 288 return NotImplemented 289 result = other - datetime( 290 self.year, 291 self.month, 292 self.day, 293 self.hour, 294 self.minute, 295 self.second, 296 self.microsecond, 297 self.tzinfo, 298 ) 299 if isinstance(result, timedelta): 300 return result 301 raise TypeError("Invalid operation") 302 303 def to_epoch_millis(self) -> int: 304 """Return the Unix timestamp in milliseconds for this datetime. 305 306 Returns: 307 int: Number of milliseconds since Unix epoch (January 1, 1970). 308 309 Example: 310 >>> dt = AirbyteDateTime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc) 311 >>> dt.to_epoch_millis() 312 1678806566000 313 """ 314 return int(self.timestamp() * 1000) 315 316 @classmethod 317 def from_epoch_millis(cls, milliseconds: int) -> "AirbyteDateTime": 318 """Create an AirbyteDateTime from Unix timestamp in milliseconds. 319 320 Args: 321 milliseconds: Number of milliseconds since Unix epoch (January 1, 1970). 322 323 Returns: 324 AirbyteDateTime: A new timezone-aware datetime instance (UTC). 325 326 Example: 327 >>> dt = AirbyteDateTime.from_epoch_millis(1678806566000) 328 >>> str(dt) 329 '2023-03-14T15:09:26+00:00' 330 """ 331 return cls.fromtimestamp(milliseconds / 1000.0, timezone.utc) 332 333 @classmethod 334 def from_str(cls, dt_str: str) -> "AirbyteDateTime": 335 """Thin convenience wrapper around `ab_datetime_parse()`. 336 337 This method attempts to create a new `AirbyteDateTime` using all available parsing 338 strategies. 339 340 Raises: 341 ValueError: If the value cannot be parsed into a valid datetime object. 342 """ 343 return ab_datetime_parse(dt_str)
A timezone-aware datetime class with ISO8601/RFC3339 string representation and operator overloading.
This class extends the standard datetime class to provide consistent timezone handling (defaulting to UTC) and ISO8601/RFC3339 compliant string formatting. It also supports operator overloading for datetime arithmetic with timedelta objects.
Example:
>>> dt = AirbyteDateTime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc) >>> str(dt) '2023-03-14T15:09:26+00:00' >>> dt + timedelta(hours=1) '2023-03-14T16:09:26+00:00'
108 def __new__(cls, *args: Any, **kwargs: Any) -> "AirbyteDateTime": 109 """Creates a new timezone-aware AirbyteDateTime instance. 110 111 Ensures all instances are timezone-aware by defaulting to UTC if no timezone is provided. 112 113 Returns: 114 AirbyteDateTime: A new timezone-aware datetime instance. 115 """ 116 self = super().__new__(cls, *args, **kwargs) 117 if self.tzinfo is None: 118 return self.replace(tzinfo=timezone.utc) 119 return self
Creates a new timezone-aware AirbyteDateTime instance.
Ensures all instances are timezone-aware by defaulting to UTC if no timezone is provided.
Returns:
AirbyteDateTime: A new timezone-aware datetime instance.
121 @classmethod 122 def from_datetime(cls, dt: datetime) -> "AirbyteDateTime": 123 """Converts a standard datetime to AirbyteDateTime. 124 125 Args: 126 dt: A standard datetime object to convert. 127 128 Returns: 129 AirbyteDateTime: A new timezone-aware AirbyteDateTime instance. 130 """ 131 return cls( 132 dt.year, 133 dt.month, 134 dt.day, 135 dt.hour, 136 dt.minute, 137 dt.second, 138 dt.microsecond, 139 dt.tzinfo or timezone.utc, 140 )
Converts a standard datetime to AirbyteDateTime.
Arguments:
- dt: A standard datetime object to convert.
Returns:
AirbyteDateTime: A new timezone-aware AirbyteDateTime instance.
142 def to_datetime(self) -> datetime: 143 """Converts this AirbyteDateTime to a standard datetime object. 144 145 Today, this just returns `self` because AirbyteDateTime is a subclass of `datetime`. 146 In the future, we may modify our internal representation to use a different base class. 147 """ 148 return self
Converts this AirbyteDateTime to a standard datetime object.
Today, this just returns self
because AirbyteDateTime is a subclass of datetime
.
In the future, we may modify our internal representation to use a different base class.
170 def add(self, delta: timedelta) -> "AirbyteDateTime": 171 """Add a timedelta interval to this datetime. 172 173 This method provides a more explicit alternative to the + operator 174 for adding time intervals to datetimes. 175 176 Args: 177 delta: The timedelta interval to add. 178 179 Returns: 180 AirbyteDateTime: A new datetime with the interval added. 181 182 Example: 183 >>> dt = AirbyteDateTime(2023, 3, 14, tzinfo=timezone.utc) 184 >>> dt.add(timedelta(hours=1)) 185 '2023-03-14T01:00:00Z' 186 """ 187 return self + delta
Add a timedelta interval to this datetime.
This method provides a more explicit alternative to the + operator for adding time intervals to datetimes.
Arguments:
- delta: The timedelta interval to add.
Returns:
AirbyteDateTime: A new datetime with the interval added.
Example:
>>> dt = AirbyteDateTime(2023, 3, 14, tzinfo=timezone.utc) >>> dt.add(timedelta(hours=1)) '2023-03-14T01:00:00Z'
189 def subtract(self, delta: timedelta) -> "AirbyteDateTime": 190 """Subtract a timedelta interval from this datetime. 191 192 This method provides a more explicit alternative to the - operator 193 for subtracting time intervals from datetimes. 194 195 Args: 196 delta: The timedelta interval to subtract. 197 198 Returns: 199 AirbyteDateTime: A new datetime with the interval subtracted. 200 201 Example: 202 >>> dt = AirbyteDateTime(2023, 3, 14, tzinfo=timezone.utc) 203 >>> dt.subtract(timedelta(hours=1)) 204 '2023-03-13T23:00:00Z' 205 """ 206 result = super().__sub__(delta) 207 if isinstance(result, datetime): 208 return AirbyteDateTime.from_datetime(result) 209 raise TypeError("Invalid operation")
Subtract a timedelta interval from this datetime.
This method provides a more explicit alternative to the - operator for subtracting time intervals from datetimes.
Arguments:
- delta: The timedelta interval to subtract.
Returns:
AirbyteDateTime: A new datetime with the interval subtracted.
Example:
>>> dt = AirbyteDateTime(2023, 3, 14, tzinfo=timezone.utc) >>> dt.subtract(timedelta(hours=1)) '2023-03-13T23:00:00Z'
303 def to_epoch_millis(self) -> int: 304 """Return the Unix timestamp in milliseconds for this datetime. 305 306 Returns: 307 int: Number of milliseconds since Unix epoch (January 1, 1970). 308 309 Example: 310 >>> dt = AirbyteDateTime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc) 311 >>> dt.to_epoch_millis() 312 1678806566000 313 """ 314 return int(self.timestamp() * 1000)
Return the Unix timestamp in milliseconds for this datetime.
Returns:
int: Number of milliseconds since Unix epoch (January 1, 1970).
Example:
>>> dt = AirbyteDateTime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc) >>> dt.to_epoch_millis() 1678806566000
316 @classmethod 317 def from_epoch_millis(cls, milliseconds: int) -> "AirbyteDateTime": 318 """Create an AirbyteDateTime from Unix timestamp in milliseconds. 319 320 Args: 321 milliseconds: Number of milliseconds since Unix epoch (January 1, 1970). 322 323 Returns: 324 AirbyteDateTime: A new timezone-aware datetime instance (UTC). 325 326 Example: 327 >>> dt = AirbyteDateTime.from_epoch_millis(1678806566000) 328 >>> str(dt) 329 '2023-03-14T15:09:26+00:00' 330 """ 331 return cls.fromtimestamp(milliseconds / 1000.0, timezone.utc)
Create an AirbyteDateTime from Unix timestamp in milliseconds.
Arguments:
- milliseconds: Number of milliseconds since Unix epoch (January 1, 1970).
Returns:
AirbyteDateTime: A new timezone-aware datetime instance (UTC).
Example:
>>> dt = AirbyteDateTime.from_epoch_millis(1678806566000) >>> str(dt) '2023-03-14T15:09:26+00:00'
333 @classmethod 334 def from_str(cls, dt_str: str) -> "AirbyteDateTime": 335 """Thin convenience wrapper around `ab_datetime_parse()`. 336 337 This method attempts to create a new `AirbyteDateTime` using all available parsing 338 strategies. 339 340 Raises: 341 ValueError: If the value cannot be parsed into a valid datetime object. 342 """ 343 return ab_datetime_parse(dt_str)
Thin convenience wrapper around ab_datetime_parse()
.
This method attempts to create a new AirbyteDateTime
using all available parsing
strategies.
Raises:
- ValueError: If the value cannot be parsed into a valid datetime object.
346def ab_datetime_now() -> AirbyteDateTime: 347 """Returns the current time as an AirbyteDateTime in UTC timezone. 348 349 Previously named: now() 350 351 Returns: 352 AirbyteDateTime: Current UTC time. 353 354 Example: 355 >>> dt = ab_datetime_now() 356 >>> str(dt) # Returns current time in ISO8601/RFC3339 357 '2023-03-14T15:09:26.535897Z' 358 """ 359 return AirbyteDateTime.from_datetime(datetime.now(timezone.utc))
Returns the current time as an AirbyteDateTime in UTC timezone.
Previously named: now()
Returns:
AirbyteDateTime: Current UTC time.
Example:
>>> dt = ab_datetime_now() >>> str(dt) # Returns current time in ISO8601/RFC3339 '2023-03-14T15:09:26.535897Z'
362def ab_datetime_parse(dt_str: str | int) -> AirbyteDateTime: 363 """Parses a datetime string or timestamp into an AirbyteDateTime with timezone awareness. 364 365 This implementation is as flexible as possible to handle various datetime formats. 366 Always returns a timezone-aware datetime (defaults to UTC if no timezone specified). 367 368 Handles: 369 - ISO8601/RFC3339 format strings (with ' ' or 'T' delimiter) 370 - Unix timestamps (as integers or strings) 371 - Date-only strings (YYYY-MM-DD) 372 - Timezone-aware formats (+00:00 for UTC, or ±HH:MM offset) 373 - Anything that can be parsed by `dateutil.parser.parse()` 374 375 Args: 376 dt_str: A datetime string in ISO8601/RFC3339 format, Unix timestamp (int/str), 377 or other recognizable datetime format. 378 379 Returns: 380 AirbyteDateTime: A timezone-aware datetime object. 381 382 Raises: 383 ValueError: If the input cannot be parsed as a valid datetime. 384 385 Example: 386 >>> ab_datetime_parse("2023-03-14T15:09:26+00:00") 387 '2023-03-14T15:09:26+00:00' 388 >>> ab_datetime_parse(1678806000) # Unix timestamp 389 '2023-03-14T15:00:00+00:00' 390 >>> ab_datetime_parse("2023-03-14") # Date-only 391 '2023-03-14T00:00:00+00:00' 392 """ 393 try: 394 # Handle numeric values as Unix timestamps (UTC) 395 if isinstance(dt_str, int) or ( 396 isinstance(dt_str, str) 397 and (dt_str.isdigit() or (dt_str.startswith("-") and dt_str[1:].isdigit())) 398 ): 399 timestamp = int(dt_str) 400 if timestamp < 0: 401 raise ValueError("Timestamp cannot be negative") 402 if len(str(abs(timestamp))) > 10: 403 raise ValueError("Timestamp value too large") 404 instant = Instant.from_timestamp(timestamp) 405 return AirbyteDateTime.from_datetime(instant.py_datetime()) 406 407 if not isinstance(dt_str, str): 408 raise ValueError( 409 f"Could not parse datetime string: expected string or integer, got {type(dt_str)}" 410 ) 411 412 # Handle date-only format first 413 if ":" not in dt_str and dt_str.count("-") == 2 and "/" not in dt_str: 414 try: 415 year, month, day = map(int, dt_str.split("-")) 416 if not (1 <= month <= 12 and 1 <= day <= 31): 417 raise ValueError(f"Invalid date format: {dt_str}") 418 instant = Instant.from_utc(year, month, day, 0, 0, 0) 419 return AirbyteDateTime.from_datetime(instant.py_datetime()) 420 except (ValueError, TypeError): 421 raise ValueError(f"Invalid date format: {dt_str}") 422 423 # Reject time-only strings without date 424 if ":" in dt_str and dt_str.count("-") < 2 and dt_str.count("/") < 2: 425 raise ValueError(f"Missing date part in datetime string: {dt_str}") 426 427 # Try parsing with dateutil for timezone handling 428 try: 429 parsed = parser.parse(dt_str) 430 if parsed.tzinfo is None: 431 parsed = parsed.replace(tzinfo=timezone.utc) 432 433 return AirbyteDateTime.from_datetime(parsed) 434 except (ValueError, TypeError): 435 raise ValueError(f"Could not parse datetime string: {dt_str}") 436 except ValueError as e: 437 if "Invalid date format:" in str(e): 438 raise 439 if "Timestamp cannot be negative" in str(e): 440 raise 441 if "Timestamp value too large" in str(e): 442 raise 443 raise ValueError(f"Could not parse datetime string: {dt_str}")
Parses a datetime string or timestamp into an AirbyteDateTime with timezone awareness.
This implementation is as flexible as possible to handle various datetime formats. Always returns a timezone-aware datetime (defaults to UTC if no timezone specified).
Handles:
- ISO8601/RFC3339 format strings (with ' ' or 'T' delimiter)
- Unix timestamps (as integers or strings)
- Date-only strings (YYYY-MM-DD)
- Timezone-aware formats (+00:00 for UTC, or ±HH:MM offset)
- Anything that can be parsed by
dateutil.parser.parse()
Arguments:
- dt_str: A datetime string in ISO8601/RFC3339 format, Unix timestamp (int/str), or other recognizable datetime format.
Returns:
AirbyteDateTime: A timezone-aware datetime object.
Raises:
- ValueError: If the input cannot be parsed as a valid datetime.
Example:
>>> ab_datetime_parse("2023-03-14T15:09:26+00:00") '2023-03-14T15:09:26+00:00' >>> ab_datetime_parse(1678806000) # Unix timestamp '2023-03-14T15:00:00+00:00' >>> ab_datetime_parse("2023-03-14") # Date-only '2023-03-14T00:00:00+00:00'
446def ab_datetime_try_parse(dt_str: str) -> AirbyteDateTime | None: 447 """Try to parse the input as a datetime, failing gracefully instead of raising an exception. 448 449 This is a thin wrapper around `ab_datetime_parse()` that catches parsing errors and 450 returns `None` instead of raising an exception. 451 The implementation is as flexible as possible to handle various datetime formats. 452 Always returns a timezone-aware datetime (defaults to `UTC` if no timezone specified). 453 454 Example: 455 >>> ab_datetime_try_parse("2023-03-14T15:09:26Z") # Returns AirbyteDateTime 456 >>> ab_datetime_try_parse("2023-03-14 15:09:26Z") # Missing 'T' delimiter still parsable 457 >>> ab_datetime_try_parse("2023-03-14") # Returns midnight UTC time 458 """ 459 try: 460 return ab_datetime_parse(dt_str) 461 except (ValueError, TypeError): 462 return None
Try to parse the input as a datetime, failing gracefully instead of raising an exception.
This is a thin wrapper around ab_datetime_parse()
that catches parsing errors and
returns None
instead of raising an exception.
The implementation is as flexible as possible to handle various datetime formats.
Always returns a timezone-aware datetime (defaults to UTC
if no timezone specified).
Example:
>>> ab_datetime_try_parse("2023-03-14T15:09:26Z") # Returns AirbyteDateTime >>> ab_datetime_try_parse("2023-03-14 15:09:26Z") # Missing 'T' delimiter still parsable >>> ab_datetime_try_parse("2023-03-14") # Returns midnight UTC time
465def ab_datetime_format( 466 dt: Union[datetime, AirbyteDateTime], 467 format: str | None = None, 468) -> str: 469 """Formats a datetime object as an ISO8601/RFC3339 string with 'T' delimiter and timezone. 470 471 Previously named: format() 472 473 Converts any datetime object to a string with 'T' delimiter and proper timezone. 474 If the datetime is naive (no timezone), UTC is assumed. 475 Uses '+00:00' for UTC timezone, otherwise keeps the original timezone offset. 476 477 Args: 478 dt: Any datetime object to format. 479 format: Optional format string. If provided, calls `strftime()` with this format. 480 Otherwise, uses the default ISO8601/RFC3339 format, adapted for available precision. 481 482 Returns: 483 str: ISO8601/RFC3339 formatted datetime string. 484 485 Example: 486 >>> dt = datetime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc) 487 >>> ab_datetime_format(dt) 488 '2023-03-14T15:09:26+00:00' 489 """ 490 if isinstance(dt, AirbyteDateTime): 491 return str(dt) 492 493 if dt.tzinfo is None: 494 dt = dt.replace(tzinfo=timezone.utc) 495 496 if format: 497 return dt.strftime(format) 498 499 # Format with consistent timezone representation and "T" delimiter 500 return dt.isoformat(sep="T", timespec="auto")
Formats a datetime object as an ISO8601/RFC3339 string with 'T' delimiter and timezone.
Previously named: format()
Converts any datetime object to a string with 'T' delimiter and proper timezone. If the datetime is naive (no timezone), UTC is assumed. Uses '+00:00' for UTC timezone, otherwise keeps the original timezone offset.
Arguments:
- dt: Any datetime object to format.
- format: Optional format string. If provided, calls
strftime()
with this format. Otherwise, uses the default ISO8601/RFC3339 format, adapted for available precision.
Returns:
str: ISO8601/RFC3339 formatted datetime string.
Example:
>>> dt = datetime(2023, 3, 14, 15, 9, 26, tzinfo=timezone.utc) >>> ab_datetime_format(dt) '2023-03-14T15:09:26+00:00'