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

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)
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

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:
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        )

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:
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

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:
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

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:
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")

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:
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)

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:
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)

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:
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)

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:
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))

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:
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}")

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:
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

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:
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")

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'