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")
class AirbyteDateTime(datetime.datetime):
 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'
AirbyteDateTime(*args: Any, **kwargs: Any)
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.

@classmethod
def from_datetime( cls, dt: datetime.datetime) -> AirbyteDateTime:
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.

def to_datetime(self) -> datetime.datetime:
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.

def add( self, delta: datetime.timedelta) -> AirbyteDateTime:
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'
def subtract( self, delta: datetime.timedelta) -> AirbyteDateTime:
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'
def to_epoch_millis(self) -> int:
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
@classmethod
def from_epoch_millis( cls, milliseconds: int) -> AirbyteDateTime:
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'
@classmethod
def from_str(cls, dt_str: str) -> AirbyteDateTime:
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.
def ab_datetime_now() -> AirbyteDateTime:
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'
def ab_datetime_parse(dt_str: str | int) -> AirbyteDateTime:
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'
def ab_datetime_try_parse(dt_str: str) -> AirbyteDateTime | None:
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
def ab_datetime_format( dt: Union[datetime.datetime, AirbyteDateTime], format: str | None = None) -> str:
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'