Source code for whenever._pywhenever

"""The pure-Python implementation of the whenever library."""

# The MIT License (MIT)
#
# Copyright (c) Arie Bovenberg
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

# Maintainer's notes:
#
# - Why is everything in one file?
#   - Flat is better than nested
#   - It prevents circular imports since the classes 'know' about each other
#   - It's easier to vendor the main functionality, if needed.
# - There is some code duplication in this file. This is intentional:
#   - It makes it easier to understand the code
#   - It's sometimes necessary for the type checker
#   - It saves some overhead
# - We don't make use of certain "obvious" modules like re or pathlib.
#   This is to keep the import time down.
from __future__ import annotations

import enum
import os.path
import sys
import warnings
from abc import ABC, abstractmethod
from collections import OrderedDict
from datetime import (
    date as _date,
    datetime as _datetime,
    time as _time,
    timedelta as _timedelta,
    timezone as _timezone,
)
from io import BytesIO
from math import fmod
from struct import pack, unpack
from time import time_ns
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    ClassVar,
    Literal,
    Mapping,
    NewType,
    NoReturn,
    TypeVar,
    Union,
    cast,
    no_type_check,
    overload,
)
from weakref import WeakValueDictionary

# zoneinfo is a relatively expensive import, so we import it lazily
if TYPE_CHECKING:
    from zoneinfo import ZoneInfo

__all__ = [
    # Date and time
    "Date",
    "YearMonth",
    "MonthDay",
    "Time",
    "Instant",
    "OffsetDateTime",
    "ZonedDateTime",
    "SystemDateTime",
    "PlainDateTime",
    # Deltas and time units
    "DateDelta",
    "TimeDelta",
    "DateTimeDelta",
    "years",
    "months",
    "weeks",
    "days",
    "hours",
    "minutes",
    "seconds",
    "milliseconds",
    "microseconds",
    "nanoseconds",
    # Exceptions
    "SkippedTime",
    "RepeatedTime",
    "InvalidOffsetError",
    "ImplicitlyIgnoringDST",
    "TimeZoneNotFoundError",
    "Weekday",
]


class Weekday(enum.Enum):
    """Day of the week; ``.value`` corresponds with ISO numbering."""

    MONDAY = 1
    TUESDAY = 2
    WEDNESDAY = 3
    THURSDAY = 4
    FRIDAY = 5
    SATURDAY = 6
    SUNDAY = 7


# Helpers that pre-compute/lookup as much as possible
_UTC = _timezone.utc
_object_new = object.__new__
_MAX_DELTA_MONTHS = 9999 * 12
_MAX_DELTA_DAYS = 9999 * 366
_MAX_DELTA_NANOS = _MAX_DELTA_DAYS * 24 * 3_600_000_000_000
_UNSET = object()
_PY312 = sys.version_info >= (3, 12)
_PY311 = sys.version_info >= (3, 11)
_Nanos = int  # type alias for subsecond nanoseconds


class _ImmutableBase:
    __slots__ = ()

    # Immutable classes don't need to be copied
    @no_type_check
    def __copy__(self):
        return self

    @no_type_check
    def __deepcopy__(self, _):
        return self

    @no_type_check
    @classmethod
    def __get_pydantic_core_schema__(cls, *_, **kwargs):
        from ._utils import pydantic_schema

        return pydantic_schema(cls)


if TYPE_CHECKING:
    from typing import final
else:

    def final(cls):

        def init_subclass_not_allowed(cls, **kwargs):  # pragma: no cover
            raise TypeError("Subclassing not allowed")

        cls.__init_subclass__ = init_subclass_not_allowed
        return cls


@final
class Date(_ImmutableBase):
    """A date without a time component

    Example
    -------
    >>> d = Date(2021, 1, 2)
    Date(2021-01-02)
    """

    __slots__ = ("_py_date",)

    MIN: ClassVar[Date]
    """The minimum possible date"""
    MAX: ClassVar[Date]
    """The maximum possible date"""

    def __init__(self, year: int, month: int, day: int) -> None:
        self._py_date = _date(year, month, day)

[docs] @classmethod def today_in_system_tz(cls) -> Date: """Get the current date in the system's local timezone. Alias for ``SystemDateTime.now().date()``. Example ------- >>> Date.today_in_system_tz() Date(2021-01-02) """ # Use now() so this function gets patched like the other now functions return SystemDateTime.now().date()
@property def year(self) -> int: return self._py_date.year @property def month(self) -> int: return self._py_date.month @property def day(self) -> int: return self._py_date.day
[docs] def year_month(self) -> YearMonth: """The year and month (without a day component) Example ------- >>> Date(2021, 1, 2).year_month() YearMonth(2021-01) """ return YearMonth._from_py_unchecked(self._py_date.replace(day=1))
[docs] def month_day(self) -> MonthDay: """The month and day (without a year component) Example ------- >>> Date(2021, 1, 2).month_day() MonthDay(--01-02) """ return MonthDay._from_py_unchecked( self._py_date.replace(year=_DUMMY_LEAP_YEAR) )
[docs] def day_of_week(self) -> Weekday: """The day of the week Example ------- >>> Date(2021, 1, 2).day_of_week() Weekday.SATURDAY >>> Weekday.SATURDAY.value 6 # the ISO value """ return Weekday(self._py_date.isoweekday())
[docs] def at(self, t: Time, /) -> PlainDateTime: """Combine a date with a time to create a datetime Example ------- >>> d = Date(2021, 1, 2) >>> d.at(Time(12, 30)) PlainDateTime(2021-01-02 12:30:00) You can use methods like :meth:`~PlainDateTime.assume_utc` or :meth:`~PlainDateTime.assume_tz` to find the corresponding exact time. """ return PlainDateTime._from_py_unchecked( _datetime.combine(self._py_date, t._py_time), t._nanos )
[docs] def py_date(self) -> _date: """Convert to a standard library :class:`~datetime.date`""" return self._py_date
[docs] @classmethod def from_py_date(cls, d: _date, /) -> Date: """Create from a :class:`~datetime.date` Example ------- >>> Date.from_py_date(date(2021, 1, 2)) Date(2021-01-02) """ self = _object_new(cls) if type(d) is _date: pass elif type(d) is _datetime: d = d.date() elif isinstance(d, _date): # the only subclass-safe way to ensure we have exactly a datetime.date d = _date(d.year, d.month, d.day) else: raise TypeError(f"Expected date, got {type(d)!r}") self._py_date = d return self
[docs] def format_common_iso(self) -> str: """Format as the common ISO 8601 date format. Inverse of :meth:`parse_common_iso`. Example ------- >>> Date(2021, 1, 2).format_common_iso() '2021-01-02' """ return self._py_date.isoformat()
[docs] @classmethod def parse_common_iso(cls, s: str, /) -> Date: """Parse a date from an ISO8601 string The following formats are accepted: - ``YYYY-MM-DD`` ("extended" format) - ``YYYYMMDD`` ("basic" format) Inverse of :meth:`format_common_iso` Example ------- >>> Date.parse_common_iso("2021-01-02") Date(2021-01-02) """ return cls._from_py_unchecked(_date_from_iso(s))
[docs] def replace(self, **kwargs: Any) -> Date: """Create a new instance with the given fields replaced Example ------- >>> d = Date(2021, 1, 2) >>> d.replace(day=4) Date(2021-01-04) """ return Date._from_py_unchecked(self._py_date.replace(**kwargs))
[docs] @no_type_check def add(self, *args, **kwargs) -> Date: """Add a components to a date. See :ref:`the docs on arithmetic <arithmetic>` for more information. Example ------- >>> d = Date(2021, 1, 2) >>> d.add(years=1, months=2, days=3) Date(2022-03-05) >>> Date(2020, 2, 29).add(years=1) Date(2021-02-28) """ return self._shift(1, *args, **kwargs)
[docs] @no_type_check def subtract(self, *args, **kwargs) -> Date: """Subtract components from a date. See :ref:`the docs on arithmetic <arithmetic>` for more information. Example ------- >>> d = Date(2021, 1, 2) >>> d.subtract(years=1, months=2, days=3) Date(2019-10-30) >>> Date(2021, 3, 1).subtract(years=1) Date(2020-03-01) """ return self._shift(-1, *args, **kwargs)
@no_type_check def _shift( self, sign: int, delta: DateDelta | _UNSET = _UNSET, /, **kwargs ) -> Date: if kwargs: if delta is not _UNSET: raise TypeError( "Cannot combine positional and keyword arguments" ) return self._shift_kwargs(sign, **kwargs) elif delta is not _UNSET: return self._shift_kwargs( sign, months=delta._months, days=delta._days ) else: # no arguments, just return self return self @no_type_check def _shift_kwargs(self, sign, years=0, months=0, weeks=0, days=0) -> Date: return Date._from_py_unchecked( self._add_months(sign * (years * 12 + months))._py_date + _timedelta(weeks * 7 + days) * sign )
[docs] def days_until(self, other: Date, /) -> int: """Calculate the number of days from this date to another date. If the other date is before this date, the result is negative. Example ------- >>> Date(2021, 1, 2).days_until(Date(2021, 1, 5)) 3 Note ---- If you're interested in calculating the difference in terms of days **and** months, use the subtraction operator instead. """ return (other._py_date - self._py_date).days
[docs] def days_since(self, other: Date, /) -> int: """Calculate the number of days this day is after another date. If the other date is after this date, the result is negative. Example ------- >>> Date(2021, 1, 5).days_since(Date(2021, 1, 2)) 3 Note ---- If you're interested in calculating the difference in terms of days **and** months, use the subtraction operator instead. """ return (self._py_date - other._py_date).days
def _add_months(self, mos: int) -> Date: year_overflow, month_new = divmod(self.month - 1 + mos, 12) month_new += 1 year_new = self.year + year_overflow return Date( year_new, month_new, min(self.day, _days_in_month(year_new, month_new)), ) def _add_days(self, days: int) -> Date: return Date._from_py_unchecked(self._py_date + _timedelta(days))
[docs] def __add__(self, p: DateDelta) -> Date: """Add a delta to a date. Behaves the same as :meth:`add` """ return ( # type: ignore[no-any-return] self.add(months=p._months, days=p._days) if isinstance(p, DateDelta) else NotImplemented )
@overload def __sub__(self, d: DateDelta) -> Date: ... @overload def __sub__(self, d: Date) -> DateDelta: ...
[docs] def __sub__(self, d: DateDelta | Date) -> Date | DateDelta: """Subtract a delta from a date, or subtract two dates Subtracting a delta works the same as :meth:`subtract`. >>> Date(2021, 1, 2) - DateDelta(weeks=1, days=3) Date(2020-12-26) The difference between two dates is calculated in months and days, such that: >>> delta = d1 - d2 >>> d2 + delta == d1 # always The following is not always true: >>> d1 - (d1 - d2) == d2 # not always true! >>> -(d2 - d1) == d1 - d2 # not always true! Examples: >>> Date(2023, 4, 15) - Date(2011, 6, 24) DateDelta(P12Y9M22D) >>> # Truncation >>> Date(2024, 4, 30) - Date(2023, 5, 31) DateDelta(P11M) >>> Date(2024, 3, 31) - Date(2023, 6, 30) DateDelta(P9M1D) >>> # the other way around, the result is different >>> Date(2023, 6, 30) - Date(2024, 3, 31) DateDelta(-P9M) Note ---- If you'd like to calculate the difference in days only (no months), use the :meth:`days_until` or :meth:`days_since` instead. """ if isinstance(d, DateDelta): return self.subtract(months=d._months, days=d._days) # type: ignore[no-any-return] elif isinstance(d, Date): mos = self.month - d.month + 12 * (self.year - d.year) shifted = d._add_months(mos) # yes, it's a bit duplicated, but preferable to being clever. if d > self: if shifted < self: # i.e. we've overshot mos += 1 shifted = d._add_months(mos) dys = ( -shifted.day - _days_in_month(self.year, self.month) + self.day ) else: dys = self.day - shifted.day else: if shifted > self: # i.e. we've overshot mos -= 1 shifted = d._add_months(mos) dys = ( -shifted.day + _days_in_month(shifted.year, shifted.month) + self.day ) else: dys = self.day - shifted.day return DateDelta(months=mos, days=dys) return NotImplemented
__str__ = format_common_iso def __repr__(self) -> str: return f"Date({self})"
[docs] def __eq__(self, other: object) -> bool: """Compare for equality Example ------- >>> d = Date(2021, 1, 2) >>> d == Date(2021, 1, 2) True >>> d == Date(2021, 1, 3) False """ if not isinstance(other, Date): return NotImplemented return self._py_date == other._py_date
def __hash__(self) -> int: return hash(self._py_date)
[docs] def __lt__(self, other: Date) -> bool: if not isinstance(other, Date): return NotImplemented return self._py_date < other._py_date
[docs] def __le__(self, other: Date) -> bool: if not isinstance(other, Date): return NotImplemented return self._py_date <= other._py_date
[docs] def __gt__(self, other: Date) -> bool: if not isinstance(other, Date): return NotImplemented return self._py_date > other._py_date
[docs] def __ge__(self, other: Date) -> bool: if not isinstance(other, Date): return NotImplemented return self._py_date >= other._py_date
@classmethod def _from_py_unchecked(cls, d: _date, /) -> Date: self = _object_new(cls) self._py_date = d return self @no_type_check def __reduce__(self): return _unpkl_date, (pack("<HBB", self.year, self.month, self.day),) # A separate unpickling function allows us to make backwards-compatible changes # to the pickling format in the future @no_type_check def _unpkl_date(data: bytes) -> Date: return Date(*unpack("<HBB", data)) Date.MIN = Date._from_py_unchecked(_date.min) Date.MAX = Date._from_py_unchecked(_date.max) @final class YearMonth(_ImmutableBase): """A year and month without a day component Useful for representing recurring events or billing periods. Example ------- >>> ym = YearMonth(2021, 1) YearMonth(2021-01) """ # We store the underlying data in a datetime.date object, # which allows us to benefit from its functionality and performance. # It isn't exposed to the user, so it's not a problem. __slots__ = ("_py_date",) MIN: ClassVar[YearMonth] """The minimum possible year-month""" MAX: ClassVar[YearMonth] """The maximum possible year-month""" def __init__(self, year: int, month: int) -> None: self._py_date = _date(year, month, 1) @property def year(self) -> int: return self._py_date.year @property def month(self) -> int: return self._py_date.month
[docs] def format_common_iso(self) -> str: """Format as the common ISO 8601 year-month format. Inverse of :meth:`parse_common_iso`. Example ------- >>> YearMonth(2021, 1).format_common_iso() '2021-01' """ return self._py_date.isoformat()[:7]
[docs] @classmethod def parse_common_iso(cls, s: str, /) -> YearMonth: """Create from the common ISO 8601 format ``YYYY-MM`` or ``YYYYMM``. Inverse of :meth:`format_common_iso` Example ------- >>> YearMonth.parse_common_iso("2021-01") YearMonth(2021-01) """ return cls._from_py_unchecked(_yearmonth_from_iso(s))
[docs] def replace(self, **kwargs: Any) -> YearMonth: """Create a new instance with the given fields replaced Example ------- >>> d = YearMonth(2021, 12) >>> d.replace(month=3) YearMonth(2021-03) """ if "day" in kwargs: raise TypeError( "replace() got an unexpected keyword argument 'day'" ) return YearMonth._from_py_unchecked(self._py_date.replace(**kwargs))
[docs] def on_day(self, day: int, /) -> Date: """Create a date from this year-month with a given day Example ------- >>> YearMonth(2021, 1).on_day(2) Date(2021-01-02) """ return Date._from_py_unchecked(self._py_date.replace(day=day))
__str__ = format_common_iso def __repr__(self) -> str: return f"YearMonth({self})"
[docs] def __eq__(self, other: object) -> bool: """Compare for equality Example ------- >>> ym = YearMonth(2021, 1) >>> ym == YearMonth(2021, 1) True >>> ym == YearMonth(2021, 2) False """ if not isinstance(other, YearMonth): return NotImplemented return self._py_date == other._py_date
[docs] def __lt__(self, other: YearMonth) -> bool: if not isinstance(other, YearMonth): return NotImplemented return self._py_date < other._py_date
[docs] def __le__(self, other: YearMonth) -> bool: if not isinstance(other, YearMonth): return NotImplemented return self._py_date <= other._py_date
[docs] def __gt__(self, other: YearMonth) -> bool: if not isinstance(other, YearMonth): return NotImplemented return self._py_date > other._py_date
[docs] def __ge__(self, other: YearMonth) -> bool: if not isinstance(other, YearMonth): return NotImplemented return self._py_date >= other._py_date
def __hash__(self) -> int: return hash(self._py_date) @classmethod def _from_py_unchecked(cls, d: _date, /) -> YearMonth: assert d.day == 1 self = _object_new(cls) self._py_date = d return self @no_type_check def __reduce__(self): return _unpkl_ym, (pack("<HB", self.year, self.month),) # A separate unpickling function allows us to make backwards-compatible changes # to the pickling format in the future @no_type_check def _unpkl_ym(data: bytes) -> YearMonth: return YearMonth(*unpack("<HB", data)) YearMonth.MIN = YearMonth._from_py_unchecked(_date.min) YearMonth.MAX = YearMonth._from_py_unchecked(_date.max.replace(day=1)) _DUMMY_LEAP_YEAR = 4 @final class MonthDay(_ImmutableBase): """A month and day without a year component. Useful for representing recurring events or birthdays. Example ------- >>> MonthDay(11, 23) MonthDay(--11-23) """ # We store the underlying data in a datetime.date object, # which allows us to benefit from its functionality and performance. # It isn't exposed to the user, so it's not a problem. __slots__ = ("_py_date",) MIN: ClassVar[MonthDay] """The minimum possible month-day""" MAX: ClassVar[MonthDay] """The maximum possible month-day""" def __init__(self, month: int, day: int) -> None: self._py_date = _date(_DUMMY_LEAP_YEAR, month, day) @property def month(self) -> int: return self._py_date.month @property def day(self) -> int: return self._py_date.day
[docs] def format_common_iso(self) -> str: """Format as the common ISO 8601 month-day format. Inverse of ``parse_common_iso``. Example ------- >>> MonthDay(10, 8).format_common_iso() '--10-08' Note ---- This format is officially only part of the 2000 edition of the ISO 8601 standard. There is no alternative for month-day in the newer editions. However, it is still widely used in other libraries. """ return f"-{self._py_date.isoformat()[4:]}"
[docs] @classmethod def parse_common_iso(cls, s: str, /) -> MonthDay: """Create from the common ISO 8601 format ``--MM-DD`` or ``--MMDD``. Inverse of :meth:`format_common_iso` Example ------- >>> MonthDay.parse_common_iso("--11-23") MonthDay(--11-23) """ return cls._from_py_unchecked(_monthday_from_iso(s))
[docs] def replace(self, **kwargs: Any) -> MonthDay: """Create a new instance with the given fields replaced Example ------- >>> d = MonthDay(11, 23) >>> d.replace(month=3) MonthDay(--03-23) """ if "year" in kwargs: raise TypeError( "replace() got an unexpected keyword argument 'year'" ) return MonthDay._from_py_unchecked(self._py_date.replace(**kwargs))
[docs] def in_year(self, year: int, /) -> Date: """Create a date from this month-day with a given day Example ------- >>> MonthDay(8, 1).in_year(2025) Date(2025-08-01) Note ---- This method will raise a ``ValueError`` if the month-day is a leap day and the year is not a leap year. """ return Date._from_py_unchecked(self._py_date.replace(year=year))
[docs] def is_leap(self) -> bool: """Check if the month-day is February 29th Example ------- >>> MonthDay(2, 29).is_leap() True >>> MonthDay(3, 1).is_leap() False """ return self._py_date.month == 2 and self._py_date.day == 29
__str__ = format_common_iso def __repr__(self) -> str: return f"MonthDay({self})"
[docs] def __eq__(self, other: object) -> bool: """Compare for equality Example ------- >>> md = MonthDay(10, 1) >>> md == MonthDay(10, 1) True >>> md == MonthDay(10, 2) False """ if not isinstance(other, MonthDay): return NotImplemented return self._py_date == other._py_date
[docs] def __lt__(self, other: MonthDay) -> bool: if not isinstance(other, MonthDay): return NotImplemented return self._py_date < other._py_date
[docs] def __le__(self, other: MonthDay) -> bool: if not isinstance(other, MonthDay): return NotImplemented return self._py_date <= other._py_date
[docs] def __gt__(self, other: MonthDay) -> bool: if not isinstance(other, MonthDay): return NotImplemented return self._py_date > other._py_date
[docs] def __ge__(self, other: MonthDay) -> bool: if not isinstance(other, MonthDay): return NotImplemented return self._py_date >= other._py_date
def __hash__(self) -> int: return hash(self._py_date) @classmethod def _from_py_unchecked(cls, d: _date, /) -> MonthDay: assert d.year == _DUMMY_LEAP_YEAR self = _object_new(cls) self._py_date = d return self @no_type_check def __reduce__(self): return _unpkl_md, (pack("<BB", self.month, self.day),) # A separate unpickling function allows us to make backwards-compatible changes # to the pickling format in the future @no_type_check def _unpkl_md(data: bytes) -> MonthDay: return MonthDay(*unpack("<BB", data)) MonthDay.MIN = MonthDay._from_py_unchecked( _date.min.replace(year=_DUMMY_LEAP_YEAR) ) MonthDay.MAX = MonthDay._from_py_unchecked( _date.max.replace(year=_DUMMY_LEAP_YEAR) ) @final class Time(_ImmutableBase): """Time of day without a date component Example ------- >>> t = Time(12, 30, 0) Time(12:30:00) """ __slots__ = ("_py_time", "_nanos") MIN: ClassVar[Time] """The minimum time, at midnight""" MIDNIGHT: ClassVar[Time] """Alias for :attr:`MIN`""" NOON: ClassVar[Time] """The time at noon""" MAX: ClassVar[Time] """The maximum time, just before midnight""" def __init__( self, hour: int = 0, minute: int = 0, second: int = 0, *, nanosecond: int = 0, ) -> None: self._py_time = _time(hour, minute, second) if nanosecond < 0 or nanosecond >= 1_000_000_000: raise ValueError("Nanosecond out of range") self._nanos = nanosecond @property def hour(self) -> int: return self._py_time.hour @property def minute(self) -> int: return self._py_time.minute @property def second(self) -> int: return self._py_time.second @property def nanosecond(self) -> int: return self._nanos
[docs] def on(self, d: Date, /) -> PlainDateTime: """Combine a time with a date to create a datetime Example ------- >>> t = Time(12, 30) >>> t.on(Date(2021, 1, 2)) PlainDateTime(2021-01-02 12:30:00) Then, use methods like :meth:`~PlainDateTime.assume_utc` or :meth:`~PlainDateTime.assume_tz` to find the corresponding exact time. """ return PlainDateTime._from_py_unchecked( _datetime.combine(d._py_date, self._py_time), self._nanos, )
[docs] def py_time(self) -> _time: """Convert to a standard library :class:`~datetime.time`""" return self._py_time.replace(microsecond=self._nanos // 1_000)
[docs] @classmethod def from_py_time(cls, t: _time, /) -> Time: """Create from a :class:`~datetime.time` Example ------- >>> Time.from_py_time(time(12, 30, 0)) Time(12:30:00) `fold` value is ignored. """ if type(t) is _time: t = t.replace(tzinfo=None, fold=0) elif isinstance(t, _time): # subclass-safe way to ensure we have exactly a datetime.time t = _time(t.hour, t.minute, t.second, t.microsecond) else: raise TypeError(f"Expected datetime.time, got {type(t)!r}") return cls._from_py_unchecked( t.replace(microsecond=0), t.microsecond * 1_000 )
[docs] def format_common_iso(self) -> str: """Format as the common ISO 8601 time format. Inverse of :meth:`parse_common_iso`. Example ------- >>> Time(12, 30, 0).format_common_iso() '12:30:00' """ return ( (self._py_time.isoformat() + f".{self._nanos:09d}").rstrip("0") if self._nanos else self._py_time.isoformat() )
[docs] @classmethod def parse_common_iso(cls, s: str, /) -> Time: """Create from the common ISO 8601 time format Inverse of :meth:`format_common_iso` Example ------- >>> Time.parse_common_iso("12:30:00") Time(12:30:00) """ return cls._from_py_unchecked(*_time_from_iso(s))
[docs] def replace(self, **kwargs: Any) -> Time: """Create a new instance with the given fields replaced Example ------- >>> t = Time(12, 30, 0) >>> d.replace(minute=3, nanosecond=4_000) Time(12:03:00.000004) """ _check_invalid_replace_kwargs(kwargs) nanos = _pop_nanos_kwarg(kwargs, self._nanos) return Time._from_py_unchecked(self._py_time.replace(**kwargs), nanos)
def _to_ns_since_midnight(self) -> int: return ( self._py_time.hour * 3_600_000_000_000 + self._py_time.minute * 60_000_000_000 + self._py_time.second * 1_000_000_000 + self._nanos ) @classmethod def _from_ns_since_midnight(cls, ns: int) -> Time: assert 0 <= ns < 86_400_000_000_000 (hours, ns) = divmod(ns, 3_600_000_000_000) (minutes, ns) = divmod(ns, 60_000_000_000) (seconds, ns) = divmod(ns, 1_000_000_000) return cls._from_py_unchecked(_time(hours, minutes, seconds), ns)
[docs] def round( self, unit: Literal[ "hour", "minute", "second", "millisecond", "microsecond", "nanosecond", ] = "second", increment: int = 1, mode: Literal[ "ceil", "floor", "half_ceil", "half_floor", "half_even" ] = "half_even", ) -> Time: """Round the time to the specified unit and increment. Various rounding modes are available. Examples -------- >>> Time(12, 39, 59).round("minute", 15) Time(12:45:00) >>> Time(8, 9, 13).round("second", 5, mode="floor") Time(08:09:10) """ if unit == "day": # type: ignore[comparison-overlap] raise ValueError("Cannot round Time to day") return self._round_unchecked( increment_to_ns(unit, increment, any_hour_ok=False), mode, 86_400_000_000_000, )[0]
def _round_unchecked( self, increment_ns: int, mode: str, day_in_ns: int, ) -> tuple[Time, int]: # the time, and whether the result is "next day" quotient, remainder_ns = divmod( self._to_ns_since_midnight(), increment_ns ) if mode == "half_even": # check the default mode first threshold_ns = increment_ns // 2 + (quotient % 2 == 0) or 1 elif mode == "ceil": threshold_ns = 1 # Always round up elif mode == "floor": threshold_ns = increment_ns + 1 # Never round up elif mode == "half_floor": threshold_ns = increment_ns // 2 + 1 elif mode == "half_ceil": threshold_ns = increment_ns // 2 or 1 else: raise ValueError(f"Invalid rounding mode: {mode!r}") round_up = remainder_ns >= threshold_ns ns_since_midnight = (quotient + round_up) * increment_ns next_day, ns_since_midnight = divmod(ns_since_midnight, day_in_ns) return self._from_ns_since_midnight(ns_since_midnight), next_day @classmethod def _from_py_unchecked(cls, t: _time, nanos: int, /) -> Time: assert not t.microsecond self = _object_new(cls) self._py_time = t self._nanos = nanos return self __str__ = format_common_iso def __repr__(self) -> str: return f"Time({self})"
[docs] def __eq__(self, other: object) -> bool: """Compare for equality Example ------- >>> t = Time(12, 30, 0) >>> t == Time(12, 30, 0) True >>> t == Time(12, 30, 1) False """ if not isinstance(other, Time): return NotImplemented return (self._py_time, self._nanos) == (other._py_time, other._nanos)
def __hash__(self) -> int: return hash((self._py_time, self._nanos))
[docs] def __lt__(self, other: Time) -> bool: if not isinstance(other, Time): return NotImplemented return (self._py_time, self._nanos) < (other._py_time, self._nanos)
[docs] def __le__(self, other: Time) -> bool: if not isinstance(other, Time): return NotImplemented return (self._py_time, self._nanos) <= (other._py_time, other._nanos)
[docs] def __gt__(self, other: Time) -> bool: if not isinstance(other, Time): return NotImplemented return (self._py_time, self._nanos) > (other._py_time, other._nanos)
[docs] def __ge__(self, other: Time) -> bool: if not isinstance(other, Time): return NotImplemented return (self._py_time, self._nanos) >= (other._py_time, other._nanos)
@no_type_check def __reduce__(self): return ( _unpkl_time, ( pack( "<BBBI", self._py_time.hour, self._py_time.minute, self._py_time.second, self._nanos, ), ), ) _NS_PER_UNIT = { "minute": 60_000_000_000, "second": 1_000_000_000, "millisecond": 1_000_000, "microsecond": 1_000, "nanosecond": 1, } def increment_to_ns(unit: str, increment: int, any_hour_ok: bool) -> int: if increment < 1 or increment > 1_000 or increment != int(increment): raise ValueError("Invalid increment") if unit == "day": if increment == 1: return 86_400_000_000_000 else: raise ValueError("Invalid increment for day") elif unit == "hour": if 24 % increment and not any_hour_ok: raise ValueError("Invalid increment for hour") else: return 3_600_000_000_000 * increment elif unit in ("minute", "second"): if 60 % increment: raise ValueError(f"Invalid increment for {unit}") else: return _NS_PER_UNIT[unit] * increment elif unit in ("millisecond", "microsecond", "nanosecond"): if 1_000 % increment: raise ValueError(f"Invalid increment for {unit}") else: return _NS_PER_UNIT[unit] * increment else: raise ValueError(f"Invalid unit: {unit}") # A separate unpickling function allows us to make backwards-compatible changes # to the pickling format in the future def _unpkl_time(data: bytes) -> Time: *args, nanos = unpack("<BBBI", data) return Time._from_py_unchecked(_time(*args), nanos) Time.MIN = Time() Time.MIDNIGHT = Time() Time.NOON = Time(12) Time.MAX = Time(23, 59, 59, nanosecond=999_999_999) @final class TimeDelta(_ImmutableBase): """A duration consisting of a precise time: hours, minutes, (nano)seconds The inputs are normalized, so 90 minutes becomes 1 hour and 30 minutes, for example. Examples -------- >>> d = TimeDelta(hours=1, minutes=30) TimeDelta(PT1h30m) >>> d.in_minutes() 90.0 Note ---- A shorter way to instantiate a timedelta is to use the helper functions :func:`~whenever.hours`, :func:`~whenever.minutes`, etc. """ __slots__ = ("_total_ns",) def __init__( self, *, hours: float = 0, minutes: float = 0, seconds: float = 0, milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, ) -> None: assert type(nanoseconds) is int # catch this common mistake ns = self._total_ns = ( # Cast individual components to int to avoid floating point errors int(hours * 3_600_000_000_000) + int(minutes * 60_000_000_000) + int(seconds * 1_000_000_000) + int(milliseconds * 1_000_000) + int(microseconds * 1_000) + nanoseconds ) if abs(ns) > _MAX_DELTA_NANOS: raise ValueError("TimeDelta out of range") ZERO: ClassVar[TimeDelta] """A delta of zero""" MAX: ClassVar[TimeDelta] """The maximum possible delta""" MIN: ClassVar[TimeDelta] """The minimum possible delta""" _date_part: ClassVar[DateDelta] @property def _time_part(self) -> TimeDelta: return self
[docs] def in_days_of_24h(self) -> float: """The total size in days (of exactly 24 hours each) Note ---- Note that this may not be the same as days on the calendar, since some days have 23 or 25 hours due to daylight saving time. """ return self._total_ns / 86_400_000_000_000
[docs] def in_hours(self) -> float: """The total size in hours Example ------- >>> d = TimeDelta(hours=1, minutes=30) >>> d.in_hours() 1.5 """ return self._total_ns / 3_600_000_000_000
[docs] def in_minutes(self) -> float: """The total size in minutes Example ------- >>> d = TimeDelta(hours=1, minutes=30, seconds=30) >>> d.in_minutes() 90.5 """ return self._total_ns / 60_000_000_000
[docs] def in_seconds(self) -> float: """The total size in seconds Example ------- >>> d = TimeDelta(minutes=2, seconds=1, microseconds=500_000) >>> d.in_seconds() 121.5 """ return self._total_ns / 1_000_000_000
[docs] def in_milliseconds(self) -> float: """The total size in milliseconds >>> d = TimeDelta(seconds=2, microseconds=50) >>> d.in_milliseconds() 2_000.05 """ return self._total_ns / 1_000_000
[docs] def in_microseconds(self) -> float: """The total size in microseconds >>> d = TimeDelta(seconds=2, nanoseconds=50) >>> d.in_microseconds() 2_000_000.05 """ return self._total_ns / 1_000
[docs] def in_nanoseconds(self) -> int: """The total size in nanoseconds >>> d = TimeDelta(seconds=2, nanoseconds=50) >>> d.in_nanoseconds() 2_000_000_050 """ return self._total_ns
[docs] def in_hrs_mins_secs_nanos(self) -> tuple[int, int, int, int]: """Convert to a tuple of (hours, minutes, seconds, nanoseconds) Example ------- >>> d = TimeDelta(hours=1, minutes=30, microseconds=5_000_090) >>> d.in_hrs_mins_secs_nanos() (1, 30, 5, 90_000) """ hours, rem = divmod(abs(self._total_ns), 3_600_000_000_000) mins, rem = divmod(rem, 60_000_000_000) secs, ns = divmod(rem, 1_000_000_000) return ( (hours, mins, secs, ns) if self._total_ns >= 0 else (-hours, -mins, -secs, -ns) )
[docs] def py_timedelta(self) -> _timedelta: """Convert to a :class:`~datetime.timedelta` Inverse of :meth:`from_py_timedelta` Note ---- Nanoseconds are truncated to microseconds. If you need more control over rounding, use :meth:`round` first. Example ------- >>> d = TimeDelta(hours=1, minutes=30) >>> d.py_timedelta() timedelta(seconds=5400) """ return _timedelta(microseconds=self._total_ns // 1_000)
[docs] @classmethod def from_py_timedelta(cls, td: _timedelta, /) -> TimeDelta: """Create from a :class:`~datetime.timedelta` Inverse of :meth:`py_timedelta` Example ------- >>> TimeDelta.from_py_timedelta(timedelta(seconds=5400)) TimeDelta(PT1h30m) """ if type(td) is not _timedelta: raise TypeError("Expected datetime.timedelta exactly") return TimeDelta( microseconds=td.microseconds, seconds=td.seconds, hours=td.days * 24, )
[docs] def format_common_iso(self) -> str: """Format as the *popular interpretation* of the ISO 8601 duration format. May not strictly adhere to (all versions of) the standard. See :ref:`here <iso8601-durations>` for more information. Inverse of :meth:`parse_common_iso`. Example ------- >>> TimeDelta(hours=1, minutes=30).format_common_iso() 'PT1H30M' """ hrs, mins, secs, ns = abs(self).in_hrs_mins_secs_nanos() seconds = ( f"{secs + ns / 1_000_000_000:.9f}".rstrip("0") if ns else str(secs) ) return f"{(self._total_ns < 0) * '-'}PT" + ( ( f"{hrs}H" * bool(hrs) + f"{mins}M" * bool(mins) + f"{seconds}S" * bool(secs or ns) ) or "0S" )
[docs] @classmethod def parse_common_iso(cls, s: str, /) -> TimeDelta: """Parse the *popular interpretation* of the ISO 8601 duration format. Does not parse all possible ISO 8601 durations. See :ref:`here <iso8601-durations>` for more information. Inverse of :meth:`format_common_iso` Example ------- >>> TimeDelta.parse_common_iso("PT1H80M") TimeDelta(PT2h20m) Note ---- Any duration with a date part is considered invalid. ``PT0S`` is valid, but ``P0D`` is not. """ exc = ValueError(f"Invalid format: {s!r}") prev_unit = "" nanos = 0 if len(s) < 4 or not s.isascii(): raise exc s = s.upper() if s.startswith("PT"): sign = 1 rest = s[2:] elif s.startswith("-PT"): sign = -1 rest = s[3:] elif s.startswith("+PT"): sign = 1 rest = s[3:] else: raise exc while rest: rest, value, unit = _parse_timedelta_component(rest, exc) if unit == "H" and prev_unit == "": nanos += value * 3_600_000_000_000 elif unit == "M" and prev_unit in "H": nanos += value * 60_000_000_000 elif unit == "S": nanos += value if rest: raise exc break else: raise exc # components out of order prev_unit = unit if nanos > _MAX_DELTA_NANOS: raise ValueError("TimeDelta out of range") return TimeDelta._from_nanos_unchecked(sign * nanos)
[docs] def round( self, unit: Literal[ "hour", "minute", "second", "millisecond", "microsecond", "nanosecond", ] = "second", increment: int = 1, mode: Literal[ "ceil", "floor", "half_ceil", "half_floor", "half_even" ] = "half_even", ) -> TimeDelta: """Round the delta to the specified unit and increment. Various rounding modes are available. Examples -------- >>> t = TimeDelta(seconds=12345) TimeDelta(PT3h25m45s) >>> t.round("minute") TimeDelta(PT3h26m) >>> t.round("second", increment=10, mode="floor") TimeDelta(PT3h25m40s) """ if unit == "day": # type: ignore[comparison-overlap] raise ValueError(CANNOT_ROUND_DAY_MSG) increment_ns = increment_to_ns(unit, increment, any_hour_ok=True) quotient, remainder_ns = divmod(self._total_ns, increment_ns) if mode == "half_even": # check the default case first threshold_ns = increment_ns // 2 + (quotient % 2 == 0) or 1 elif mode == "ceil": threshold_ns = 1 # Always round up elif mode == "floor": threshold_ns = increment_ns + 1 # Never round up elif mode == "half_floor": threshold_ns = increment_ns // 2 + 1 elif mode == "half_ceil": threshold_ns = increment_ns // 2 or 1 else: raise ValueError(f"Invalid rounding mode: {mode!r}") round_up = remainder_ns >= threshold_ns rounded_ns = (quotient + round_up) * increment_ns if abs(rounded_ns) > _MAX_DELTA_NANOS: raise ValueError("Resulting TimeDelta out of range") return self._from_nanos_unchecked(rounded_ns)
[docs] def __add__(self, other: TimeDelta) -> TimeDelta: """Add two deltas together Example ------- >>> d = TimeDelta(hours=1, minutes=30) >>> d + TimeDelta(minutes=30) TimeDelta(PT2h) """ if not isinstance(other, TimeDelta): return NotImplemented return TimeDelta(nanoseconds=self._total_ns + other._total_ns)
[docs] def __sub__(self, other: TimeDelta) -> TimeDelta: """Subtract two deltas Example ------- >>> d = TimeDelta(hours=1, minutes=30) >>> d - TimeDelta(minutes=30) TimeDelta(PT1h) """ if not isinstance(other, TimeDelta): return NotImplemented return TimeDelta(nanoseconds=self._total_ns - other._total_ns)
[docs] def __eq__(self, other: object) -> bool: """Compare for equality Example ------- >>> d = TimeDelta(hours=1, minutes=30) >>> d == TimeDelta(minutes=90) True >>> d == TimeDelta(hours=2) False """ if not isinstance(other, TimeDelta): return NotImplemented return self._total_ns == other._total_ns
def __hash__(self) -> int: return hash(self._total_ns) def __lt__(self, other: TimeDelta) -> bool: if not isinstance(other, TimeDelta): return NotImplemented return self._total_ns < other._total_ns def __le__(self, other: TimeDelta) -> bool: if not isinstance(other, TimeDelta): return NotImplemented return self._total_ns <= other._total_ns
[docs] def __gt__(self, other: TimeDelta) -> bool: if not isinstance(other, TimeDelta): return NotImplemented return self._total_ns > other._total_ns
def __ge__(self, other: TimeDelta) -> bool: if not isinstance(other, TimeDelta): return NotImplemented return self._total_ns >= other._total_ns
[docs] def __bool__(self) -> bool: """True if the value is non-zero Example ------- >>> bool(TimeDelta()) False >>> bool(TimeDelta(minutes=1)) True """ return bool(self._total_ns)
[docs] def __mul__(self, other: float) -> TimeDelta: """Multiply by a number Example ------- >>> d = TimeDelta(hours=1, minutes=30) >>> d * 2.5 TimeDelta(PT3h45m) """ if not isinstance(other, (int, float)): return NotImplemented return TimeDelta(nanoseconds=int(self._total_ns * other))
def __rmul__(self, other: float) -> TimeDelta: return self * other
[docs] def __neg__(self) -> TimeDelta: """Negate the value Example ------- >>> d = TimeDelta(hours=1, minutes=30) >>> -d TimeDelta(-PT1h30m) """ return TimeDelta(nanoseconds=-self._total_ns)
def __pos__(self) -> TimeDelta: """Return the value unchanged Example ------- >>> d = TimeDelta(hours=1, minutes=30) >>> +d TimeDelta(PT1h30m) """ return self @overload def __truediv__(self, other: float) -> TimeDelta: ... @overload def __truediv__(self, other: TimeDelta) -> float: ...
[docs] def __truediv__(self, other: float | TimeDelta) -> TimeDelta | float: """Divide by a number or another delta Example ------- >>> d = TimeDelta(hours=1, minutes=30) >>> d / 2.5 TimeDelta(PT36m) >>> d / TimeDelta(minutes=30) 3.0 Note ---- Because TimeDelta is limited to nanosecond precision, the result of division may not be exact. """ if isinstance(other, TimeDelta): return self._total_ns / other._total_ns elif isinstance(other, (int, float)): return TimeDelta(nanoseconds=int(self._total_ns / other)) return NotImplemented
def __floordiv__(self, other: TimeDelta) -> int: """Floor division by another delta Example ------- >>> d = TimeDelta(hours=1, minutes=39) >>> d // time_delta(minutes=15) 6 """ if not isinstance(other, TimeDelta): return NotImplemented return self._total_ns // other._total_ns def __mod__(self, other: TimeDelta) -> TimeDelta: """Modulo by another delta Example ------- >>> d = TimeDelta(hours=1, minutes=39) >>> d % TimeDelta(minutes=15) TimeDelta(PT9m) """ if not isinstance(other, TimeDelta): return NotImplemented return TimeDelta(nanoseconds=self._total_ns % other._total_ns)
[docs] def __abs__(self) -> TimeDelta: """The absolute value Example ------- >>> d = TimeDelta(hours=-1, minutes=-30) >>> abs(d) TimeDelta(PT1h30m) """ return TimeDelta._from_nanos_unchecked(abs(self._total_ns))
__str__ = format_common_iso def __repr__(self) -> str: iso = self.format_common_iso() # lowercase everything besides the prefix (don't forget the sign!) cased = iso[:3] + iso[3:].lower() return f"TimeDelta({cased})" @no_type_check def __reduce__(self): return _unpkl_tdelta, ( pack("<qI", *divmod(self._total_ns, 1_000_000_000)), ) @classmethod def _from_nanos_unchecked(cls, ns: int) -> TimeDelta: new = _object_new(cls) new._total_ns = ns return new # A separate unpickling function allows us to make backwards-compatible changes # to the pickling format in the future @no_type_check def _unpkl_tdelta(data: bytes) -> TimeDelta: s, ns = unpack("<qI", data) return TimeDelta(seconds=s, nanoseconds=ns) _MAX_TDELTA_DIGITS = 35 # consistent with Rust extension def _parse_timedelta_component( fullstr: str, exc: Exception ) -> tuple[str, int, Literal["H", "M", "S"]]: try: split_index, unit = next( (i, c) for i, c in enumerate(fullstr) if c in "HMS" ) except StopIteration: raise exc raw, rest = fullstr[:split_index], fullstr[split_index + 1 :] if unit == "S": digits, sep, nanos_raw = _split_nextchar(raw, ".,") if ( len(digits) > _MAX_TDELTA_DIGITS or not digits.isdigit() or len(nanos_raw) > 9 or (sep and not nanos_raw.isdigit()) ): raise exc value = int(digits) * 1_000_000_000 + int(nanos_raw.ljust(9, "0")) else: if len(raw) > _MAX_TDELTA_DIGITS or not raw.isdigit(): raise exc value = int(raw) return rest, value, cast(Literal["H", "M", "S"], unit) TimeDelta.ZERO = TimeDelta() TimeDelta.MAX = TimeDelta(seconds=9999 * 366 * 24 * 3_600) TimeDelta.MIN = TimeDelta(seconds=-9999 * 366 * 24 * 3_600) @final class DateDelta(_ImmutableBase): """A duration of time consisting of calendar units (years, months, weeks, and days) """ __slots__ = ("_months", "_days") def __init__( self, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0 ) -> None: months = self._months = months + 12 * years days = self._days = days + 7 * weeks if (months > 0 and days < 0) or (months < 0 and days > 0): raise ValueError("Mixed sign in date delta") elif ( abs(self._months) > _MAX_DELTA_MONTHS or abs(self._days) > _MAX_DELTA_DAYS ): raise ValueError("Date delta months out of range") ZERO: ClassVar[DateDelta] """A delta of zero""" _time_part = TimeDelta.ZERO @property def _date_part(self) -> DateDelta: return self
[docs] def in_months_days(self) -> tuple[int, int]: """Convert to a tuple of months and days. Example ------- >>> p = DateDelta(months=25, days=9) >>> p.in_months_days() (25, 9) >>> DateDelta(months=-13, weeks=-5) (-13, -35) """ return self._months, self._days
[docs] def in_years_months_days(self) -> tuple[int, int, int]: """Convert to a tuple of years, months, and days. Example ------- >>> p = DateDelta(years=1, months=2, days=11) >>> p.in_years_months_days() (1, 2, 11) """ years = int(self._months / 12) months = int(fmod(self._months, 12)) return years, months, self._days
[docs] def format_common_iso(self) -> str: """Format as the *popular interpretation* of the ISO 8601 duration format. May not strictly adhere to (all versions of) the standard. See :ref:`here <iso8601-durations>` for more information. Inverse of :meth:`parse_common_iso`. The format looks like this: .. code-block:: text P(nY)(nM)(nD) For example: .. code-block:: text P1D P2M P1Y2M3W4D Example ------- >>> p = DateDelta(years=1, months=2, weeks=3, days=11) >>> p.format_common_iso() 'P1Y2M3W11D' >>> DateDelta().format_common_iso() 'P0D' """ if self._months < 0 or self._days < 0: sign = "-" months, days = -self._months, -self._days else: sign = "" months, days = self._months, self._days years = months // 12 months %= 12 date = ( f"{years}Y" * bool(years), f"{months}M" * bool(months), f"{days}D" * bool(days), ) return sign + "P" + ("".join(date) or "0D")
__str__ = format_common_iso
[docs] @classmethod def parse_common_iso(cls, s: str, /) -> DateDelta: """Parse the *popular interpretation* of the ISO 8601 duration format. Does not parse all possible ISO 8601 durations. See :ref:`here <iso8601-durations>` for more information. Inverse of :meth:`format_common_iso` Example ------- >>> DateDelta.parse_common_iso("P1W11D") DateDelta(P1w11d) >>> DateDelta.parse_common_iso("-P3m") DateDelta(-P3m) Note ---- Only durations without time component are accepted. ``P0D`` is valid, but ``PT0S`` is not. Note ---- The number of digits in each component is limited to 8. """ exc = ValueError(f"Invalid format: {s!r}") prev_unit = "" months = 0 days = 0 if len(s) < 3 or not s.isascii(): raise exc s = s.upper() if s[0] == "P": sign = 1 rest = s[1:] elif s.startswith("-P"): sign = -1 rest = s[2:] elif s.startswith("+P"): sign = 1 rest = s[2:] else: raise exc while rest: rest, value, unit = _parse_datedelta_component(rest, exc) if unit == "Y" and prev_unit == "": months += value * 12 elif unit == "M" and prev_unit in "Y": months += value elif unit == "W" and prev_unit in "YM": days += value * 7 elif unit == "D" and prev_unit in "YMW": days += value if rest: raise exc # leftover characters break else: raise exc # components out of order prev_unit = unit try: return DateDelta(months=sign * months, days=sign * days) except ValueError: raise exc
@overload def __add__(self, other: DateDelta) -> DateDelta: ... @overload def __add__(self, other: TimeDelta) -> DateTimeDelta: ...
[docs] def __add__( self, other: DateDelta | TimeDelta ) -> DateDelta | DateTimeDelta: """Add the fields of another delta to this one Example ------- >>> p = DateDelta(weeks=2, months=1) >>> p + DateDelta(weeks=1, days=4) DateDelta(P1m25d) """ if isinstance(other, DateDelta): return DateDelta( months=self._months + other._months, days=self._days + other._days, ) elif isinstance(other, TimeDelta): new = _object_new(DateTimeDelta) new._date_part = self new._time_part = other return new else: return NotImplemented
def __radd__(self, other: TimeDelta) -> DateTimeDelta: if isinstance(other, TimeDelta): new = _object_new(DateTimeDelta) new._date_part = self new._time_part = other return new return NotImplemented @overload def __sub__(self, other: DateDelta) -> DateDelta: ... @overload def __sub__(self, other: TimeDelta) -> DateTimeDelta: ...
[docs] def __sub__( self, other: DateDelta | TimeDelta ) -> DateDelta | DateTimeDelta: """Subtract the fields of another delta from this one Example ------- >>> p = DateDelta(weeks=2, days=3) >>> p - DateDelta(days=2) DateDelta(P15d) """ if isinstance(other, DateDelta): return DateDelta( months=self._months - other._months, days=self._days - other._days, ) elif isinstance(other, TimeDelta): return self + (-other) else: return NotImplemented
def __rsub__(self, other: TimeDelta) -> DateTimeDelta: if isinstance(other, TimeDelta): return -self + other return NotImplemented
[docs] def __eq__(self, other: object) -> bool: """Compare for equality, normalized to months and days. `a == b` is equivalent to `a.in_months_days() == b.in_months_days()` Example ------- >>> p = DateDelta(weeks=4, days=2) DateDelta(P30d) >>> p == DateDelta(weeks=3, days=9) True >>> p == DateDelta(weeks=2, days=4) True # same number of days >>> p == DateDelta(months=1) False # months and days cannot be compared directly """ if not isinstance(other, DateDelta): return NotImplemented return self._months == other._months and self._days == other._days
def __hash__(self) -> int: return hash((self._months, self._days))
[docs] def __bool__(self) -> bool: """True if any contains any non-zero data Example ------- >>> bool(DateDelta()) False >>> bool(DateDelta(days=-1)) True """ return bool(self._months or self._days)
def __repr__(self) -> str: iso = self.format_common_iso() # lowercase everything besides the prefix (don't forget the sign!) cased = iso[:2] + iso[2:].lower() return f"DateDelta({cased})"
[docs] def __neg__(self) -> DateDelta: """Negate the contents Example ------- >>> p = DateDelta(weeks=2, days=3) >>> -p DateDelta(-P17d) """ return DateDelta(months=-self._months, days=-self._days)
def __pos__(self) -> DateDelta: """Return the value unchanged Example ------- >>> p = DateDelta(weeks=2, days=-3) DateDelta(P11d) >>> +p DateDelta(P11d) """ return self
[docs] def __mul__(self, other: int) -> DateDelta: """Multiply the contents by a round number Example ------- >>> p = DateDelta(years=1, weeks=2) >>> p * 2 DateDelta(P2y28d) """ if not isinstance(other, int): return NotImplemented return DateDelta( months=self._months * other, days=self._days * other, )
def __rmul__(self, other: int) -> DateDelta: if isinstance(other, int): return self * other return NotImplemented
[docs] def __abs__(self) -> DateDelta: """If the contents are negative, return the positive version Example ------- >>> p = DateDelta(months=-2, days=-3) >>> abs(p) DateDelta(P2m3d) """ return DateDelta(months=abs(self._months), days=abs(self._days))
@no_type_check def __reduce__(self): return (_unpkl_ddelta, (self._months, self._days)) # A separate unpickling function allows us to make backwards-compatible changes # to the pickling format in the future def _unpkl_ddelta(months: int, days: int) -> DateDelta: return DateDelta(months=months, days=days) _MAX_DDELTA_DIGITS = 8 # consistent with Rust extension def _parse_datedelta_component(s: str, exc: Exception) -> tuple[str, int, str]: try: split_index, unit = next( (i, c) for i, c in enumerate(s) if c in "YMWD" ) except StopIteration: raise exc raw, rest = s[:split_index], s[split_index + 1 :] if not raw.isdigit() or len(raw) > _MAX_DDELTA_DIGITS: raise exc return rest, int(raw), unit DateDelta.ZERO = DateDelta() TimeDelta._date_part = DateDelta.ZERO @final class DateTimeDelta(_ImmutableBase): """A duration with both a date and time component.""" __slots__ = ("_date_part", "_time_part") def __init__( self, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, hours: float = 0, minutes: float = 0, seconds: float = 0, milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, ) -> None: self._date_part = DateDelta( years=years, months=months, weeks=weeks, days=days ) self._time_part = TimeDelta( hours=hours, minutes=minutes, seconds=seconds, milliseconds=milliseconds, microseconds=microseconds, nanoseconds=nanoseconds, ) if ( (self._date_part._months < 0 or self._date_part._days < 0) and self._time_part._total_ns > 0 ) or ( (self._date_part._months > 0 or self._date_part._days > 0) and self._time_part._total_ns < 0 ): raise ValueError("Mixed sign in date-time delta") ZERO: ClassVar[DateTimeDelta] """A delta of zero"""
[docs] def date_part(self) -> DateDelta: """The date part of the delta""" return self._date_part
[docs] def time_part(self) -> TimeDelta: """The time part of the delta""" return self._time_part
[docs] def in_months_days_secs_nanos(self) -> tuple[int, int, int, int]: """Convert to a tuple of (months, days, seconds, nanoseconds) Example ------- >>> d = DateTimeDelta(weeks=1, days=11, hours=4, microseconds=2) >>> d.in_months_days_secs_nanos() (0, 18, 14_400, 2000) """ subsec_nanos = int(fmod(self._time_part._total_ns, 1_000_000_000)) whole_seconds = int(self._time_part._total_ns / 1_000_000_000) return self._date_part.in_months_days() + (whole_seconds, subsec_nanos)
[docs] def format_common_iso(self) -> str: """Format as the *popular interpretation* of the ISO 8601 duration format. May not strictly adhere to (all versions of) the standard. See :ref:`here <iso8601-durations>` for more information. Inverse of :meth:`parse_common_iso`. The format is: .. code-block:: text P(nY)(nM)(nD)T(nH)(nM)(nS) Example ------- >>> d = DateTimeDelta( ... weeks=1, ... days=11, ... hours=4, ... milliseconds=12, ... ) >>> d.format_common_iso() 'P1W11DT4H0.012S' """ sign = ( self._date_part._months < 0 or self._date_part._days < 0 or self._time_part._total_ns < 0 ) * "-" date = abs(self._date_part).format_common_iso()[1:] * bool( self._date_part ) time = abs(self._time_part).format_common_iso()[1:] * bool( self._time_part ) return sign + "P" + ((date + time) or "0D")
[docs] @classmethod def parse_common_iso(cls, s: str, /) -> DateTimeDelta: """Parse the *popular interpretation* of the ISO 8601 duration format. Does not parse all possible ISO 8601 durations. See :ref:`here <iso8601-durations>` for more information. Examples: .. code-block:: text P4D # 4 days PT4H # 4 hours PT3M40.5S # 3 minutes and 40.5 seconds P1W11DT4H # 1 week, 11 days, and 4 hours -PT7H4M # -7 hours and -4 minutes (-7:04:00) +PT7H4M # 7 hours and 4 minutes (7:04:00) Inverse of :meth:`format_common_iso` Example ------- >>> DateTimeDelta.parse_common_iso("-P1W11DT4H") DateTimeDelta(-P1w11dT4h) """ exc = ValueError(f"Invalid format: {s!r}") prev_unit = "" months = 0 days = 0 nanos = 0 if len(s) < 3 or not s.isascii() or s.endswith("T"): raise exc s = s.upper() if s[0] == "P": sign = 1 rest = s[1:] elif s.startswith("-P"): sign = -1 rest = s[2:] elif s.startswith("+P"): sign = 1 rest = s[2:] else: raise exc while rest and not rest.startswith("T"): rest, value, unit = _parse_datedelta_component(rest, exc) if unit == "Y" and prev_unit == "": months += value * 12 elif unit == "M" and prev_unit in "Y": months += value elif unit == "W" and prev_unit in "YM": days += value * 7 elif unit == "D" and prev_unit in "YMW": days += value break else: raise exc # components out of order prev_unit = unit try: ddelta = DateDelta(months=sign * months, days=sign * days) except ValueError: raise exc prev_unit = "" if rest and not rest.startswith("T"): raise exc # skip the "T" separator rest = rest[1:] while rest: rest, value, unit = _parse_timedelta_component(rest, exc) if unit == "H" and prev_unit == "": nanos += value * 3_600_000_000_000 elif unit == "M" and prev_unit in "H": nanos += value * 60_000_000_000 elif unit == "S": nanos += value if rest: raise exc break else: raise exc prev_unit = unit if nanos > _MAX_DELTA_NANOS: raise exc tdelta = TimeDelta._from_nanos_unchecked(sign * nanos) return cls._from_parts(ddelta, tdelta)
[docs] def __add__(self, other: Delta) -> DateTimeDelta: """Add two deltas together Example ------- >>> d = DateTimeDelta(weeks=1, days=11, hours=4) >>> d + DateTimeDelta(months=2, days=3, minutes=90) DateTimeDelta(P1m1w14dT5h30m) """ new = _object_new(DateTimeDelta) if isinstance(other, DateTimeDelta): new._date_part = self._date_part + other._date_part new._time_part = self._time_part + other._time_part elif isinstance(other, TimeDelta): new._date_part = self._date_part new._time_part = self._time_part + other elif isinstance(other, DateDelta): new._date_part = self._date_part + other new._time_part = self._time_part else: return NotImplemented return new
def __radd__(self, other: TimeDelta | DateDelta) -> DateTimeDelta: if isinstance(other, (TimeDelta, DateDelta)): return self + other return NotImplemented
[docs] def __sub__( self, other: DateTimeDelta | TimeDelta | DateDelta ) -> DateTimeDelta: """Subtract two deltas Example ------- >>> d = DateTimeDelta(weeks=1, days=11, hours=4) >>> d - DateTimeDelta(months=2, days=3, minutes=90) DateTimeDelta(-P2m1w8dT2h30m) """ if isinstance(other, DateTimeDelta): d = self._date_part - other._date_part t = self._time_part - other._time_part elif isinstance(other, TimeDelta): d = self._date_part t = self._time_part - other elif isinstance(other, DateDelta): d = self._date_part - other t = self._time_part else: return NotImplemented return self._from_parts(d, t)
def __rsub__(self, other: TimeDelta | DateDelta) -> DateTimeDelta: new = _object_new(DateTimeDelta) if isinstance(other, TimeDelta): new._date_part = -self._date_part new._time_part = other - self._time_part elif isinstance(other, DateDelta): new._date_part = other - self._date_part new._time_part = -self._time_part else: return NotImplemented return new
[docs] def __eq__(self, other: object) -> bool: """Compare for equality Example ------- >>> d = DateTimeDelta( ... weeks=1, ... days=23, ... hours=4, ... ) >>> d == DateTimeDelta( ... weeks=1, ... days=23, ... minutes=4 * 60, # normalized ... ) True >>> d == DateTimeDelta( ... weeks=4, ... days=2, # days/weeks are normalized ... hours=4, ... ) True >>> d == DateTimeDelta( ... months=1, # months/days cannot be compared directly ... hours=4, ... ) False """ if not isinstance(other, DateTimeDelta): return NotImplemented return ( self._date_part == other._date_part and self._time_part == other._time_part )
def __hash__(self) -> int: return hash((self._date_part, self._time_part))
[docs] def __bool__(self) -> bool: """True if any field is non-zero Example ------- >>> bool(DateTimeDelta()) False >>> bool(DateTimeDelta(minutes=1)) True """ return bool(self._date_part or self._time_part)
[docs] def __mul__(self, other: int) -> DateTimeDelta: """Multiply by a number Example ------- >>> d = DateTimeDelta(weeks=1, days=11, hours=4) >>> d * 2 DateTimeDelta(P2w22dT8h) """ # OPTIMIZE: use unchecked constructor return self._from_parts( self._date_part * other, self._time_part * other )
def __rmul__(self, other: int) -> DateTimeDelta: return self * other
[docs] def __neg__(self) -> DateTimeDelta: """Negate the delta Example ------- >>> d = DateTimeDelta(days=11, hours=4) >>> -d DateTimeDelta(-P11dT4h) """ # OPTIMIZE: use unchecked constructor return self._from_parts(-self._date_part, -self._time_part)
def __pos__(self) -> DateTimeDelta: """Return the delta unchanged Example ------- >>> d = DateTimeDelta(weeks=1, days=-11, hours=4) >>> +d DateTimeDelta(P1W11DT4H) """ return self
[docs] def __abs__(self) -> DateTimeDelta: """The absolute value of the delta Example ------- >>> d = DateTimeDelta(weeks=1, days=-11, hours=4) >>> abs(d) DateTimeDelta(P1w11dT4h) """ new = _object_new(DateTimeDelta) new._date_part = abs(self._date_part) new._time_part = abs(self._time_part) return new
__str__ = format_common_iso def __repr__(self) -> str: iso = self.format_common_iso() # lowercase everything besides the prefix and separator cased = "".join(c if c in "PT" else c.lower() for c in iso) return f"DateTimeDelta({cased})" @classmethod def _from_parts(cls, d: DateDelta, t: TimeDelta) -> DateTimeDelta: new = _object_new(cls) new._date_part = d new._time_part = t if ((d._months < 0 or d._days < 0) and t._total_ns > 0) or ( (d._months > 0 or d._days > 0) and t._total_ns < 0 ): raise ValueError("Mixed sign in date-time delta") return new @no_type_check def __reduce__(self): secs, nanos = divmod(self._time_part._total_ns, 1_000_000_000) return ( _unpkl_dtdelta, (self._date_part._months, self._date_part._days, secs, nanos), ) # A separate unpickling function allows us to make backwards-compatible changes # to the pickling format in the future @no_type_check def _unpkl_dtdelta( months: int, days: int, secs: int, nanos: int ) -> DateTimeDelta: new = _object_new(DateTimeDelta) new._date_part = DateDelta(months=months, days=days) new._time_part = TimeDelta(seconds=secs, nanoseconds=nanos) return new DateTimeDelta.ZERO = DateTimeDelta() Delta = Union[DateTimeDelta, TimeDelta, DateDelta] _T = TypeVar("_T")
[docs] class _BasicConversions(_ImmutableBase, ABC): """Methods for types converting to/from the standard library and ISO8601: - :class:`Instant` - :class:`PlainDateTime` - :class:`ZonedDateTime` - :class:`OffsetDateTime` - :class:`SystemDateTime` (This base class class itself is not for public use.) """ __slots__ = ("_py_dt", "_nanos") _py_dt: _datetime _nanos: int
[docs] @classmethod @abstractmethod def from_py_datetime(cls: type[_T], d: _datetime, /) -> _T: """Create an instance from a :class:`~datetime.datetime` object. Inverse of :meth:`~_BasicConversions.py_datetime`. Note ---- The datetime is checked for validity, raising similar exceptions to the constructor. ``ValueError`` is raised if the datetime doesn't have the correct tzinfo matching the class. For example, :class:`ZonedDateTime` requires a :class:`~zoneinfo.ZoneInfo` tzinfo. Warning ------- No exceptions are raised if the datetime is ambiguous. Its ``fold`` attribute is used to disambiguate. """
[docs] def py_datetime(self) -> _datetime: """Convert to a standard library :class:`~datetime.datetime` Note ---- Nanoseconds are truncated to microseconds. If you wish to customize the rounding behavior, use the ``round()`` method first. """ return self._py_dt.replace(microsecond=self._nanos // 1_000)
[docs] @abstractmethod def format_common_iso(self) -> str: """Format as common ISO string representation. Each subclass has a different format. See :ref:`here <iso8601>` for more information. """ raise NotImplementedError()
[docs] @classmethod @abstractmethod def parse_common_iso(cls: type[_T], s: str, /) -> _T: """Create an instance from common ISO 8601 representation, which is different for each subclass. See :ref:`here <iso8601>` for more information. """
def __str__(self) -> str: """Same as :meth:`format_common_iso`""" return self.format_common_iso() @classmethod def _from_py_unchecked(cls: type[_T], d: _datetime, nanos: int, /) -> _T: assert not d.microsecond assert 0 <= nanos < 1_000_000_000 self = _object_new(cls) self._py_dt = d # type: ignore[attr-defined] self._nanos = nanos # type: ignore[attr-defined] return self
class _LocalTime(_BasicConversions, ABC): """Methods for types that know a local date and time-of-day: - :class:`PlainDateTime` - :class:`ZonedDateTime` - :class:`OffsetDateTime` - :class:`SystemDateTime` (The class itself is not for public use.) """ __slots__ = () @property def year(self) -> int: return self._py_dt.year @property def month(self) -> int: return self._py_dt.month @property def day(self) -> int: return self._py_dt.day @property def hour(self) -> int: return self._py_dt.hour @property def minute(self) -> int: return self._py_dt.minute @property def second(self) -> int: return self._py_dt.second @property def nanosecond(self) -> int: return self._nanos
[docs] def date(self) -> Date: """The date part of the datetime Example ------- >>> d = Instant.from_utc(2021, 1, 2, 3, 4, 5) >>> d.date() Date(2021-01-02) To perform the inverse, use :meth:`Date.at` and a method like :meth:`~PlainDateTime.assume_utc` ortestoffset :meth:`~PlainDateTime.assume_tz`: >>> date.at(time).assume_tz("Europe/London") """ return Date._from_py_unchecked(self._py_dt.date())
[docs] def time(self) -> Time: """The time-of-day part of the datetime Example ------- >>> d = ZonedDateTime(2021, 1, 2, 3, 4, 5, tz="Europe/Paris") ZonedDateTime(2021-01-02T03:04:05+01:00[Europe/Paris]) >>> d.time() Time(03:04:05) To perform the inverse, use :meth:`Time.on` and a method like :meth:`~PlainDateTime.assume_utc` or :meth:`~PlainDateTime.assume_tz`: >>> time.on(date).assume_tz("Europe/Paris") """ return Time._from_py_unchecked(self._py_dt.time(), self._nanos)
# We document these methods as abtract, # but they are actually implemented slightly different per subclass if not TYPE_CHECKING: # pragma: no cover
[docs] @abstractmethod def replace(self: _T, /, **kwargs: Any) -> _T: """Construct a new instance with the given fields replaced. Arguments are the same as the constructor, but only keyword arguments are allowed. Note ---- If you need to shift the datetime by a duration, use the addition and subtraction operators instead. These account for daylight saving time and other complications. Warning ------- The same exceptions as the constructor may be raised. For system and zoned datetimes, The ``disambiguate`` keyword argument is recommended to resolve ambiguities explicitly. For more information, see whenever.rtfd.io/en/latest/overview.html#ambiguity-in-timezones Example ------- >>> d = PlainDateTime(2020, 8, 15, 23, 12) >>> d.replace(year=2021) PlainDateTime(2021-08-15 23:12:00) >>> >>> z = ZonedDateTime(2020, 8, 15, 23, 12, tz="Europe/London") >>> z.replace(year=2021) ZonedDateTime(2021-08-15T23:12:00+01:00) """
[docs] def replace_date(self: _T, date: Date, /, **kwargs) -> _T: """Create a new instance with the date replaced Example ------- >>> d = PlainDateTime(2020, 8, 15, hour=4) >>> d.replace_date(Date(2021, 1, 1)) PlainDateTime(2021-01-01T04:00:00) >>> zdt = ZonedDateTime.now("Europe/London") >>> zdt.replace_date(Date(2021, 1, 1)) ZonedDateTime(2021-01-01T13:00:00.23439+00:00[Europe/London]) See :meth:`replace` for more information. """
[docs] def replace_time(self: _T, time: Time, /, **kwargs) -> _T: """Create a new instance with the time replaced Example ------- >>> d = PlainDateTime(2020, 8, 15, hour=4) >>> d.replace_time(Time(12, 30)) PlainDateTime(2020-08-15T12:30:00) >>> zdt = ZonedDateTime.now("Europe/London") >>> zdt.replace_time(Time(12, 30)) ZonedDateTime(2024-06-15T12:30:00+01:00[Europe/London]) See :meth:`replace` for more information. """
[docs] @abstractmethod def add( self: _T, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, hours: float = 0, minutes: float = 0, seconds: float = 0, milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, **kwargs, ) -> _T: """Add date and time units to this datetime. Arithmetic on datetimes is complicated. Additional keyword arguments ``ignore_dst`` and ``disambiguate`` may be relevant for certain types and situations. See :ref:`the docs on arithmetic <arithmetic>` for more information and the reasoning behind it. """
[docs] @abstractmethod def subtract( self: _T, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, hours: float = 0, minutes: float = 0, seconds: float = 0, milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, **kwargs, ) -> _T: """Inverse of :meth:`add`."""
[docs] def round( self: _T, unit: Literal[ "day", "hour", "minute", "second", "millisecond", "microsecond", "nanosecond", ] = "second", increment: int = 1, mode: Literal[ "ceil", "floor", "half_ceil", "half_floor", "half_even" ] = "half_even", ) -> _T: """Round the datetime to the specified unit and increment. Different rounding modes are available. Examples -------- >>> d = ZonedDateTime(2020, 8, 15, 23, 24, 18, tz="Europe/Paris") >>> d.round("day") ZonedDateTime(2020-08-16 00:00:00+02:00[Europe/Paris]) >>> d.round("minute", increment=15, mode="floor") ZonedDateTime(2020-08-15 23:15:00+02:00[Europe/Paris]) Notes ----- * In the rare case that rounding results in an ambiguous time, the offset is preserved if possible. Otherwise, the time is resolved according to the "compatible" strategy. * Rounding in "day" mode may be affected by DST transitions. i.e. on 23-hour days, 11:31 AM is rounded up. * For ``OffsetDateTime``, the ``ignore_dst`` parameter is required, because it is possible (though unlikely) that the rounded datetime will not have the same offset. * This method has similar behavior to the ``round()`` method of Temporal objects in JavaScript. """
class _ExactTime(_BasicConversions): """Methods for types that represent a specific moment in time. Implemented by: - :class:`Instant` - :class:`ZonedDateTime` - :class:`OffsetDateTime` - :class:`SystemDateTime` (This base class class itself is not for public use.) """ __slots__ = () # These methods aren't strictly abstract (they don't follow LSP), # but we do document them here. if not TYPE_CHECKING: # pragma: no cover
[docs] @classmethod def now(cls: type[_T], **kwargs) -> _T: """Create an instance from the current time. This method on :class:`~ZonedDateTime` and :class:`~OffsetDateTime` requires an additional timezone or offset argument, respectively. Example ------- >>> Instant.now() Instant(2021-08-15T22:12:00.49821Z) >>> ZonedDateTime.now("Europe/London") ZonedDateTime(2021-08-15 23:12:00.50332+01:00[Europe/London]) """
[docs] def timestamp(self) -> int: """The UNIX timestamp for this datetime. Inverse of :meth:`from_timestamp`. Note ---- In contrast to the standard library, this method always returns an integer, not a float. This is because floating point timestamps are not precise enough to represent all instants to nanosecond precision. This decision is consistent with other modern date-time libraries. Example ------- >>> Instant.from_utc(1970, 1, 1).timestamp() 0 >>> ts = 1_123_000_000 >>> Instant.from_timestamp(ts).timestamp() == ts True """ return int(self._py_dt.timestamp())
[docs] def timestamp_millis(self) -> int: """Like :meth:`timestamp`, but with millisecond precision.""" return int(self._py_dt.timestamp()) * 1_000 + self._nanos // 1_000_000
[docs] def timestamp_nanos(self) -> int: """Like :meth:`timestamp`, but with nanosecond precision.""" return int(self._py_dt.timestamp()) * 1_000_000_000 + self._nanos
if not TYPE_CHECKING:
[docs] @classmethod def from_timestamp(cls: type[_T], i: int | float, /, **kwargs) -> _T: """Create an instance from a UNIX timestamp. The inverse of :meth:`~_ExactTime.timestamp`. :class:`~ZonedDateTime` and :class:`~OffsetDateTime` require a ``tz=`` and ``offset=`` kwarg, respectively. Note ---- ``from_timestamp()`` also accepts floats, in order to ease migration from the standard library. Note however that ``timestamp()`` only returns integers. The reason is that floating point timestamps are not precise enough to represent all instants to nanosecond precision. Example ------- >>> Instant.from_timestamp(0) Instant(1970-01-01T00:00:00Z) >>> ZonedDateTime.from_timestamp(1_123_000_000, tz="America/New_York") ZonedDateTime(2005-08-02 12:26:40-04:00[America/New_York]) """
[docs] @classmethod def from_timestamp_millis(cls: type[_T], i: int, /, **kwargs) -> _T: """Like :meth:`from_timestamp`, but for milliseconds."""
[docs] @classmethod def from_timestamp_nanos(cls: type[_T], i: int, /, **kwargs) -> _T: """Like :meth:`from_timestamp`, but for nanoseconds."""
@overload def to_fixed_offset(self, /) -> OffsetDateTime: ... @overload def to_fixed_offset( self, offset: int | TimeDelta, / ) -> OffsetDateTime: ...
[docs] def to_fixed_offset( self, offset: int | TimeDelta | None = None, / ) -> OffsetDateTime: """Convert to an OffsetDateTime that represents the same moment in time. If not offset is given, the offset is taken from the original datetime. """ return OffsetDateTime._from_py_unchecked( self._py_dt.astimezone( # mypy doesn't know that offset is never None _timezone(self._py_dt.utcoffset()) # type: ignore[arg-type] if offset is None else _load_offset(offset) ), self._nanos, )
[docs] def to_tz(self, tz: str, /) -> ZonedDateTime: """Convert to a ZonedDateTime that represents the same moment in time. Raises ------ ~whenever.TimeZoneNotFoundError If the timezone ID is not found in the system's timezone database. """ return ZonedDateTime._from_py_unchecked( self._py_dt.astimezone(_get_tz(tz)), self._nanos )
[docs] def to_system_tz(self) -> SystemDateTime: """Convert to a SystemDateTime that represents the same moment in time.""" return SystemDateTime._from_py_unchecked( self._py_dt.astimezone(), self._nanos )
[docs] def exact_eq(self: _T, other: _T, /) -> bool: """Compare objects by their values (instead of whether they represent the same instant). Different types are never equal. Note ---- If ``a.exact_eq(b)`` is true, then ``a == b`` is also true, but the converse is not necessarily true. Examples -------- >>> a = OffsetDateTime(2020, 8, 15, hour=12, offset=1) >>> b = OffsetDateTime(2020, 8, 15, hour=13, offset=2) >>> a == b True # equivalent instants >>> a.exact_eq(b) False # different values (hour and offset) >>> a.exact_eq(Instant.now()) TypeError # different types """ if type(self) is not type(other): raise TypeError("Cannot compare different types") return ( self._py_dt, # type: ignore[attr-defined] self._py_dt.utcoffset(), # type: ignore[attr-defined] self._nanos, # type: ignore[attr-defined] self._py_dt.tzinfo, # type: ignore[attr-defined] ) == ( other._py_dt, # type: ignore[attr-defined] other._py_dt.utcoffset(), # type: ignore[attr-defined] other._nanos, # type: ignore[attr-defined] other._py_dt.tzinfo, # type: ignore[attr-defined] )
[docs] def difference( self, other: Instant | OffsetDateTime | ZonedDateTime | SystemDateTime, /, ) -> TimeDelta: """Calculate the difference between two instants in time. Equivalent to :meth:`__sub__`. See :ref:`the docs on arithmetic <arithmetic>` for more information. """ return self - other # type: ignore[operator, no-any-return]
[docs] def __eq__(self, other: object) -> bool: """Check if two datetimes represent at the same moment in time ``a == b`` is equivalent to ``a.instant() == b.instant()`` Note ---- If you want to exactly compare the values on their values instead, use :meth:`exact_eq`. Example ------- >>> Instant.from_utc(2020, 8, 15, hour=23) == Instant.from_utc(2020, 8, 15, hour=23) True >>> OffsetDateTime(2020, 8, 15, hour=23, offset=1) == ( ... ZonedDateTime(2020, 8, 15, hour=18, tz="America/New_York") ... ) True """ if not isinstance(other, _ExactTime): return NotImplemented # We can't rely on simple equality, because it isn't equal # between two datetimes with different timezones if one of the # datetimes needs fold to disambiguate it. # See peps.python.org/pep-0495/#aware-datetime-equality-comparison. # We want to avoid this legacy edge case, so we normalize to UTC. return (self._py_dt.astimezone(_UTC), self._nanos) == ( other._py_dt.astimezone(_UTC), other._nanos, )
[docs] def __lt__(self, other: _ExactTime) -> bool: """Compare two datetimes by when they occur in time ``a < b`` is equivalent to ``a.instant() < b.instant()`` Example ------- >>> OffsetDateTime(2020, 8, 15, hour=23, offset=8) < ( ... ZoneDateTime(2020, 8, 15, hour=20, tz="Europe/Amsterdam") ... ) True """ if not isinstance(other, _ExactTime): return NotImplemented return (self._py_dt.astimezone(_UTC), self._nanos) < ( other._py_dt.astimezone(_UTC), other._nanos, )
[docs] def __le__(self, other: _ExactTime) -> bool: """Compare two datetimes by when they occur in time ``a <= b`` is equivalent to ``a.instant() <= b.instant()`` Example ------- >>> OffsetDateTime(2020, 8, 15, hour=23, offset=8) <= ( ... ZoneDateTime(2020, 8, 15, hour=20, tz="Europe/Amsterdam") ... ) True """ if not isinstance(other, _ExactTime): return NotImplemented return (self._py_dt.astimezone(_UTC), self._nanos) <= ( other._py_dt.astimezone(_UTC), other._nanos, )
[docs] def __gt__(self, other: _ExactTime) -> bool: """Compare two datetimes by when they occur in time ``a > b`` is equivalent to ``a.instant() > b.instant()`` Example ------- >>> OffsetDateTime(2020, 8, 15, hour=19, offset=-8) > ( ... ZoneDateTime(2020, 8, 15, hour=20, tz="Europe/Amsterdam") ... ) True """ if not isinstance(other, _ExactTime): return NotImplemented return (self._py_dt.astimezone(_UTC), self._nanos) > ( other._py_dt.astimezone(_UTC), other._nanos, )
[docs] def __ge__(self, other: _ExactTime) -> bool: """Compare two datetimes by when they occur in time ``a >= b`` is equivalent to ``a.instant() >= b.instant()`` Example ------- >>> OffsetDateTime(2020, 8, 15, hour=19, offset=-8) >= ( ... ZoneDateTime(2020, 8, 15, hour=20, tz="Europe/Amsterdam") ... ) True """ if not isinstance(other, _ExactTime): return NotImplemented return (self._py_dt.astimezone(_UTC), self._nanos) >= ( other._py_dt.astimezone(_UTC), other._nanos, )
# Mypy doesn't like overloaded overrides, but we'd like to document # this 'abstract' behaviour anyway if not TYPE_CHECKING: # pragma: no branch
[docs] @abstractmethod def __sub__(self, other: _ExactTime) -> TimeDelta: """Calculate the duration between two datetimes ``a - b`` is equivalent to ``a.instant() - b.instant()`` Equivalent to :meth:`difference`. See :ref:`the docs on arithmetic <arithmetic>` for more information. Example ------- >>> d = Instant.from_utc(2020, 8, 15, hour=23) >>> d - ZonedDateTime(2020, 8, 15, hour=20, tz="Europe/Amsterdam") TimeDelta(05:00:00) """ if isinstance(other, _ExactTime): py_delta = self._py_dt.astimezone(_UTC) - other._py_dt return TimeDelta( seconds=py_delta.days * 86_400 + py_delta.seconds, nanoseconds=self._nanos - other._nanos, ) return NotImplemented
class _ExactAndLocalTime(_LocalTime, _ExactTime): """Common behavior for all types that know an exact time and corresponding local date and time-of-day. - :class:`ZonedDateTime` - :class:`OffsetDateTime` - :class:`SystemDateTime` (The class itself it not for public use.) """ __slots__ = () @property def offset(self) -> TimeDelta: """The UTC offset of the datetime""" return TimeDelta._from_nanos_unchecked( int( self._py_dt.utcoffset().total_seconds() # type: ignore[union-attr] * 1_000_000_000 ) )
[docs] def to_instant(self) -> Instant: """Get the underlying instant in time Example ------- >>> d = ZonedDateTime(2020, 8, 15, hour=23, tz="Europe/Amsterdam") >>> d.instant() Instant(2020-08-15 21:00:00Z) """ return Instant._from_py_unchecked( self._py_dt.astimezone(_UTC), self._nanos )
def instant(self) -> Instant: warnings.warn( "instant() is deprecated. Use to_instant() instead.", DeprecationWarning, ) return self.to_instant()
[docs] def to_plain(self) -> PlainDateTime: """Get the underlying date and time (without offset or timezone) As an inverse, :class:`PlainDateTime` has methods :meth:`~PlainDateTime.assume_utc`, :meth:`~PlainDateTime.assume_fixed_offset` , :meth:`~PlainDateTime.assume_tz`, and :meth:`~PlainDateTime.assume_system_tz` which may require additional arguments. """ return PlainDateTime._from_py_unchecked( self._py_dt.replace(tzinfo=None), self._nanos, )
def local(self) -> PlainDateTime: warnings.warn( "local() is deprecated. Use to_plain() instead.", DeprecationWarning, ) return self.to_plain() @final class Instant(_ExactTime): """Represents a moment in time with nanosecond precision. This class is great for representing a specific point in time independent of location. It maps 1:1 to UTC or a UNIX timestamp. Example ------- >>> from whenever import Instant >>> py311_release = Instant.from_utc(2022, 10, 24, hour=17) Instant(2022-10-24 17:00:00Z) >>> py311_release.add(hours=3).timestamp() 1666641600 """ __slots__ = () def __init__(self) -> None: raise TypeError( "Instant instances cannot be created through the constructor. " "Use `Instant.from_utc` or `Instant.now` instead." )
[docs] @classmethod def from_utc( cls, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, *, nanosecond: int = 0, ) -> Instant: """Create an Instant defined by a UTC date and time.""" if nanosecond < 0 or nanosecond >= 1_000_000_000: raise ValueError(f"nanosecond out of range: {nanosecond}") return cls._from_py_unchecked( _datetime(year, month, day, hour, minute, second, 0, _UTC), nanosecond, )
MIN: ClassVar[Instant] """The minimum representable instant.""" MAX: ClassVar[Instant] """The maximum representable instant.""" @classmethod def now(cls) -> Instant: """Create an Instant from the current time.""" secs, nanos = divmod(time_ns(), 1_000_000_000) return cls._from_py_unchecked(_fromtimestamp(secs, _UTC), nanos) @classmethod def from_timestamp(cls, i: int | float, /) -> Instant: """Create an Instant from a UNIX timestamp (in seconds). The inverse of the ``timestamp()`` method. """ secs, fract = divmod(i, 1) return cls._from_py_unchecked( _fromtimestamp(secs, _UTC), int(fract * 1_000_000_000) ) @classmethod def from_timestamp_millis(cls, i: int, /) -> Instant: """Create an Instant from a UNIX timestamp (in milliseconds). The inverse of the ``timestamp_millis()`` method. """ if not isinstance(i, int): raise TypeError("method requires an integer") secs, millis = divmod(i, 1_000) return cls._from_py_unchecked( _fromtimestamp(secs, _UTC), millis * 1_000_000 ) @classmethod def from_timestamp_nanos(cls, i: int, /) -> Instant: """Create an Instant from a UNIX timestamp (in nanoseconds). The inverse of the ``timestamp_nanos()`` method. """ if not isinstance(i, int): raise TypeError("method requires an integer") secs, nanos = divmod(i, 1_000_000_000) return cls._from_py_unchecked(_fromtimestamp(secs, _UTC), nanos) @classmethod def from_py_datetime(cls, d: _datetime, /) -> Instant: """Create an Instant from a standard library ``datetime`` object. The datetime must be aware. The inverse of the ``py_datetime()`` method. """ if d.tzinfo is None or d.utcoffset() is None: raise ValueError( "Cannot create Instant from a naive datetime. " "Use PlainDateTime.from_py_datetime() for this." ) as_utc = d.astimezone(_UTC) return cls._from_py_unchecked( _strip_subclasses(as_utc.replace(microsecond=0)), as_utc.microsecond * 1_000, ) def format_common_iso(self) -> str: """Convert to the popular ISO format ``YYYY-MM-DDTHH:MM:SSZ`` The inverse of the ``parse_common_iso()`` method. """ return ( self._py_dt.isoformat()[:-6] + bool(self._nanos) * f".{self._nanos:09d}".rstrip("0") + "Z" ) @classmethod def parse_common_iso(cls, s: str, /) -> Instant: """Parse an ISO 8601 string. Supports basic and extended formats, but not week dates or ordinal dates. See the `docs on ISO8601 support <https://whenever.readthedocs.io/en/latest/overview.html#iso-8601>`_ for more information. The inverse of the ``format_common_iso()`` method. """ dt, nanos = _offset_dt_from_iso(s) return cls._from_py_unchecked(dt.astimezone(_UTC), nanos)
[docs] def format_rfc2822(self) -> str: """Format as an RFC 2822 string. The inverse of the ``parse_rfc2822()`` method. Note ---- The output is also compatible with the (stricter) RFC 9110 standard. Example ------- >>> Instant.from_utc(2020, 8, 8, hour=23, minute=12).format_rfc2822() "Sat, 08 Aug 2020 23:12:00 GMT" """ return ( f"{_WEEKDAY_TO_RFC2822[self._py_dt.weekday()]}, " f"{self._py_dt.day:02} " f"{_MONTH_TO_RFC2822[self._py_dt.month]} {self._py_dt.year:04} " f"{self._py_dt.time()} GMT" )
[docs] @classmethod def parse_rfc2822(cls, s: str, /) -> Instant: """Parse a UTC datetime in RFC 2822 format. The inverse of the ``format_rfc2822()`` method. Example ------- >>> Instant.parse_rfc2822("Sat, 15 Aug 2020 23:12:00 GMT") Instant(2020-08-15 23:12:00Z) >>> # also valid: >>> Instant.parse_rfc2822("Sat, 15 Aug 2020 23:12:00 +0000") >>> Instant.parse_rfc2822("Sat, 15 Aug 2020 23:12:00 +0800") >>> Instant.parse_rfc2822("Sat, 15 Aug 2020 23:12:00 -0000") >>> Instant.parse_rfc2822("Sat, 15 Aug 2020 23:12:00 UT") >>> Instant.parse_rfc2822("Sat, 15 Aug 2020 23:12:00 MST") Note ---- - Although technically part of the RFC 2822 standard, comments within folding whitespace are not supported. """ return cls._from_py_unchecked(_parse_rfc2822(s).astimezone(_UTC), 0)
[docs] def add( self, *, hours: float = 0, minutes: float = 0, seconds: float = 0, milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, ) -> Instant: """Add a time amount to this instant. See the `docs on arithmetic <https://whenever.readthedocs.io/en/latest/overview.html#arithmetic>`_ for more information. """ return self + TimeDelta( hours=hours, minutes=minutes, seconds=seconds, milliseconds=milliseconds, microseconds=microseconds, nanoseconds=nanoseconds, )
[docs] def subtract( self, *, hours: float = 0, minutes: float = 0, seconds: float = 0, milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, ) -> Instant: """Subtract a time amount from this instant. See the `docs on arithmetic <https://whenever.readthedocs.io/en/latest/overview.html#arithmetic>`_ for more information. """ return self.add( hours=-hours, minutes=-minutes, seconds=-seconds, milliseconds=-milliseconds, microseconds=-microseconds, nanoseconds=-nanoseconds, )
[docs] def round( self, unit: Literal[ "hour", "minute", "second", "millisecond", "microsecond", "nanosecond", ] = "second", increment: int = 1, mode: Literal[ "ceil", "floor", "half_ceil", "half_floor", "half_even" ] = "half_even", ) -> Instant: """Round the instant to the specified unit and increment. Various rounding modes are available. Examples -------- >>> Instant.from_utc(2020, 1, 1, 12, 39, 59).round("minute", 15) Instant(2020-01-01 12:45:00Z) >>> Instant.from_utc(2020, 1, 1, 8, 9, 13).round("second", 5, mode="floor") Instant(2020-01-01 08:09:10Z) """ if unit == "day": # type: ignore[comparison-overlap] raise ValueError(CANNOT_ROUND_DAY_MSG) rounded_time, next_day = Time._from_py_unchecked( self._py_dt.time(), self._nanos )._round_unchecked( increment_to_ns(unit, increment, any_hour_ok=False), mode, 86_400_000_000_000, ) return self._from_py_unchecked( _datetime.combine( self._py_dt.date() + _timedelta(days=next_day), rounded_time._py_time, tzinfo=_UTC, ), rounded_time._nanos, )
[docs] def __add__(self, delta: TimeDelta) -> Instant: """Add a time amount to this datetime. See the `docs on arithmetic <https://whenever.readthedocs.io/en/latest/overview.html#arithmetic>`_ for more information. """ if isinstance(delta, TimeDelta): delta_secs, nanos = divmod( self._nanos + delta._time_part._total_ns, 1_000_000_000, ) return self._from_py_unchecked( self._py_dt + _timedelta(seconds=delta_secs), nanos, ) return NotImplemented
@overload def __sub__(self, other: _ExactTime) -> TimeDelta: ... @overload def __sub__(self, other: TimeDelta) -> Instant: ...
[docs] def __sub__(self, other: TimeDelta | _ExactTime) -> Instant | TimeDelta: """Subtract another exact time or timedelta Subtraction of deltas happens in the same way as the :meth:`subtract` method. Subtraction of instants happens the same way as the :meth:`~_ExactTime.difference` method. See the `docs on arithmetic <https://whenever.readthedocs.io/en/latest/overview.html#arithmetic>`_ for more information. Example ------- >>> d = Instant.from_utc(2020, 8, 15, hour=23, minute=12) >>> d - hours(24) - seconds(5) Instant(2020-08-14 23:11:55Z) >>> d - Instant.from_utc(2020, 8, 14) TimeDelta(47:12:00) """ if isinstance(other, _ExactTime): return super().__sub__(other) # type: ignore[misc, no-any-return] elif isinstance(other, TimeDelta): return self + -other return NotImplemented
def __hash__(self) -> int: return hash((self._py_dt, self._nanos)) def __repr__(self) -> str: return f"Instant({str(self).replace('T', ' ')})" # a custom pickle implementation with a smaller payload def __reduce__(self) -> tuple[object, ...]: return ( _unpkl_inst, (pack("<qL", int(self._py_dt.timestamp()), self._nanos),), ) _UNIX_INSTANT = -int(_datetime(1, 1, 1, tzinfo=_UTC).timestamp()) + 86_400 # Backwards compatibility for instances pickled before 0.8.0 def _unpkl_utc(data: bytes) -> Instant: secs, nanos = unpack("<qL", data) return Instant._from_py_unchecked( _fromtimestamp(secs - _UNIX_INSTANT, _UTC), nanos ) # A separate unpickling function allows us to make backwards-compatible changes # to the pickling format in the future def _unpkl_inst(data: bytes) -> Instant: secs, nanos = unpack("<qL", data) return Instant._from_py_unchecked(_fromtimestamp(secs, _UTC), nanos) @final class OffsetDateTime(_ExactAndLocalTime): """A datetime with a fixed UTC offset. Useful for representing a "static" local date and time-of-day at a specific location. Example ------- >>> # Midnight in Salt Lake City >>> OffsetDateTime(2023, 4, 21, offset=-6) OffsetDateTime(2023-04-21 00:00:00-06:00) Note ---- Adjusting instances of this class do *not* account for daylight saving time. If you need to add or subtract durations from an offset datetime and account for DST, convert to a ``ZonedDateTime`` first, This class knows when the offset changes. """ __slots__ = () def __init__( self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, *, nanosecond: int = 0, offset: int | TimeDelta, ) -> None: self._py_dt = _check_utc_bounds( _datetime( year, month, day, hour, minute, second, 0, _load_offset(offset), ) ) if nanosecond < 0 or nanosecond >= 1_000_000_000: raise ValueError(f"nanosecond out of range: {nanosecond}") self._nanos = nanosecond @classmethod def now( cls, offset: int | TimeDelta, /, *, ignore_dst: bool = False ) -> OffsetDateTime: """Create an instance from the current time. Important --------- Getting the current time with a fixed offset implicitly ignores DST and other timezone changes. Instead, use ``Instant.now()`` or ``ZonedDateTime.now(<tz_id>)`` if you know the timezone. Or, if you want to ignore DST and accept potentially incorrect offsets, pass ``ignore_dst=True`` to this method. For more information, see `the documentation <https://whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic>`_. """ if ignore_dst is not True: raise ImplicitlyIgnoringDST(OFFSET_NOW_DST_MSG) secs, nanos = divmod(time_ns(), 1_000_000_000) return cls._from_py_unchecked( _fromtimestamp(secs, _load_offset(offset)), nanos ) def format_common_iso(self) -> str: """Convert to the popular ISO format ``YYYY-MM-DDTHH:MM:SS±HH:MM`` The inverse of the ``parse_common_iso()`` method. """ iso_without_fracs = self._py_dt.isoformat() return ( iso_without_fracs[:19] + bool(self._nanos) * f".{self._nanos:09d}".rstrip("0") + iso_without_fracs[19:] ) @classmethod def parse_common_iso(cls, s: str, /) -> OffsetDateTime: """Parse the popular ISO format ``YYYY-MM-DDTHH:MM:SS±HH:MM`` The inverse of the ``format_common_iso()`` method. Example ------- >>> OffsetDateTime.parse_common_iso("2020-08-15T23:12:00+02:00") OffsetDateTime(2020-08-15 23:12:00+02:00) """ return cls._from_py_unchecked(*_offset_dt_from_iso(s)) @classmethod def from_timestamp( cls, i: int, /, *, offset: int | TimeDelta, ignore_dst: bool = False ) -> OffsetDateTime: """Create an instance from a UNIX timestamp (in seconds). The inverse of the ``timestamp()`` method. Important --------- Creating an instance from a UNIX timestamp implicitly ignores DST and other timezone changes. This because you don't strictly know if the given offset is correct for an arbitrary timestamp. Instead, use ``Instant.from_timestamp()`` or ``ZonedDateTime.from_timestamp()`` if you know the timezone. Or, if you want to ignore DST and accept potentially incorrect offsets, pass ``ignore_dst=True`` to this method. For more information, see `the documentation <https://whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic>`_. """ if ignore_dst is not True: raise ImplicitlyIgnoringDST(TIMESTAMP_DST_MSG) secs, fract = divmod(i, 1) return cls._from_py_unchecked( _fromtimestamp(secs, _load_offset(offset)), int(fract * 1_000_000_000), ) @classmethod def from_timestamp_millis( cls, i: int, /, *, offset: int | TimeDelta, ignore_dst: bool = False ) -> OffsetDateTime: """Create an instance from a UNIX timestamp (in milliseconds). The inverse of the ``timestamp_millis()`` method. Important --------- Creating an instance from a UNIX timestamp implicitly ignores DST and other timezone changes. This because you don't strictly know if the given offset is correct for an arbitrary timestamp. Instead, use ``Instant.from_timestamp_millis()`` or ``ZonedDateTime.from_timestamp_millis()`` if you know the timezone. Or, if you want to ignore DST and accept potentially incorrect offsets, pass ``ignore_dst=True`` to this method. For more information, see `the documentation <https://whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic>`_. """ if ignore_dst is not True: raise ImplicitlyIgnoringDST(TIMESTAMP_DST_MSG) if not isinstance(i, int): raise TypeError("method requires an integer") secs, millis = divmod(i, 1_000) return cls._from_py_unchecked( _fromtimestamp(secs, _load_offset(offset)), millis * 1_000_000 ) @classmethod def from_timestamp_nanos( cls, i: int, /, *, offset: int | TimeDelta, ignore_dst: bool = False ) -> OffsetDateTime: """Create an instance from a UNIX timestamp (in nanoseconds). The inverse of the ``timestamp_nanos()`` method. Important --------- Creating an instance from a UNIX timestamp implicitly ignores DST and other timezone changes. This because you don't strictly know if the given offset is correct for an arbitrary timestamp. Instead, use ``Instant.from_timestamp_nanos()`` or ``ZonedDateTime.from_timestamp_nanos()`` if you know the timezone. Or, if you want to ignore DST and accept potentially incorrect offsets, pass ``ignore_dst=True`` to this method. For more information, see `the documentation <https://whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic>`_. """ if ignore_dst is not True: raise ImplicitlyIgnoringDST(TIMESTAMP_DST_MSG) if not isinstance(i, int): raise TypeError("method requires an integer") secs, nanos = divmod(i, 1_000_000_000) return cls._from_py_unchecked( _fromtimestamp(secs, _load_offset(offset)), nanos ) @classmethod def from_py_datetime(cls, d: _datetime, /) -> OffsetDateTime: """Create an instance from a standard library ``datetime`` object. The datetime must be aware. The inverse of the ``py_datetime()`` method. """ if d.tzinfo is None or (offset := d.utcoffset()) is None: raise ValueError( "Cannot create from a naive datetime. " "Use PlainDateTime.from_py_datetime() for this." ) elif offset.microseconds: raise ValueError("Sub-second offsets are not supported") return cls._from_py_unchecked( _check_utc_bounds( _strip_subclasses( d.replace(microsecond=0, tzinfo=_timezone(offset)) ) ), d.microsecond * 1_000, ) def replace( self, /, ignore_dst: bool = False, **kwargs: Any ) -> OffsetDateTime: """Construct a new instance with the given fields replaced. Important --------- Replacing fields of an offset datetime implicitly ignores DST and other timezone changes. This because it isn't guaranteed that the same offset will be valid at the new time. If you want to account for DST, convert to a ``ZonedDateTime`` first. Or, if you want to ignore DST and accept potentially incorrect offsets, pass ``ignore_dst=True`` to this method. """ _check_invalid_replace_kwargs(kwargs) if ignore_dst is not True: raise ImplicitlyIgnoringDST(ADJUST_OFFSET_DATETIME_MSG) try: kwargs["tzinfo"] = _load_offset(kwargs.pop("offset")) except KeyError: pass nanos = _pop_nanos_kwarg(kwargs, self._nanos) return self._from_py_unchecked( _check_utc_bounds(self._py_dt.replace(**kwargs)), nanos ) def replace_date( self, date: Date, /, *, ignore_dst: bool = False ) -> OffsetDateTime: """Construct a new instance with the date replaced. See the ``replace()`` method for more information. """ if ignore_dst is not True: raise ImplicitlyIgnoringDST(ADJUST_OFFSET_DATETIME_MSG) return self._from_py_unchecked( _check_utc_bounds( _datetime.combine(date._py_date, self._py_dt.timetz()) ), self._nanos, ) def replace_time( self, time: Time, /, *, ignore_dst: bool = False ) -> OffsetDateTime: """Construct a new instance with the time replaced. See the ``replace()`` method for more information. """ if ignore_dst is not True: raise ImplicitlyIgnoringDST(ADJUST_OFFSET_DATETIME_MSG) return self._from_py_unchecked( _check_utc_bounds( _datetime.combine( self._py_dt.date(), time._py_time, self._py_dt.tzinfo ) ), time._nanos, ) def __hash__(self) -> int: return hash((self._py_dt, self._nanos)) def __sub__(self, other: _ExactTime) -> TimeDelta: """Calculate the duration relative to another exact time.""" if isinstance(other, (TimeDelta, DateDelta, DateTimeDelta)): raise ImplicitlyIgnoringDST(ADJUST_OFFSET_DATETIME_MSG) return super().__sub__(other) # type: ignore[misc, no-any-return]
[docs] @classmethod def parse_strptime(cls, s: str, /, *, format: str) -> OffsetDateTime: """Parse a datetime with offset using the standard library ``strptime()`` method. Example ------- >>> OffsetDateTime.parse_strptime("2020-08-15+0200", format="%Y-%m-%d%z") OffsetDateTime(2020-08-15 00:00:00+02:00) Note ---- This method defers to the standard library ``strptime()`` method, which may behave differently in different Python versions. It also only supports up to microsecond precision. Important --------- An offset *must* be present in the format string. This means you MUST include the directive ``%z``, ``%Z``, or ``%:z``. To parse a datetime without an offset, use ``PlainDateTime`` instead. """ parsed = _datetime.strptime(s, format) if (offset := parsed.utcoffset()) is None: raise ValueError( "Parsed datetime must have an offset. " "Use %z, %Z, or %:z in the format string" ) if offset.microseconds: raise ValueError("Sub-second offsets are not supported") return cls._from_py_unchecked( _check_utc_bounds(parsed.replace(microsecond=0)), parsed.microsecond * 1_000, )
[docs] def format_rfc2822(self) -> str: """Format as an RFC 2822 string. The inverse of the ``parse_rfc2822()`` method. Example ------- >>> OffsetDateTime(2020, 8, 15, 23, 12, offset=hours(2)).format_rfc2822() "Sat, 15 Aug 2020 23:12:00 +0200" """ offset = int(self._py_dt.utcoffset().total_seconds()) # type: ignore[union-attr] offset_sign = "-" if offset < 0 else "+" offset = abs(offset) offset_h = offset // 3600 offset_m = (offset % 3600) // 60 return ( f"{_WEEKDAY_TO_RFC2822[self._py_dt.weekday()]}, " f"{self._py_dt.day:02} " f"{_MONTH_TO_RFC2822[self._py_dt.month]} {self._py_dt.year:04} " f"{self._py_dt.time()} " f"{offset_sign}{offset_h:02}{offset_m:02}" )
[docs] @classmethod def parse_rfc2822(cls, s: str, /) -> OffsetDateTime: """Parse an offset datetime in RFC 2822 format. The inverse of the ``format_rfc2822()`` method. Example ------- >>> OffsetDateTime.parse_rfc2822("Sat, 15 Aug 2020 23:12:00 +0200") OffsetDateTime(2020-08-15 23:12:00+02:00) >>> # also valid: >>> OffsetDateTime.parse_rfc2822("Sat, 15 Aug 2020 23:12:00 UT") >>> OffsetDateTime.parse_rfc2822("Sat, 15 Aug 2020 23:12:00 GMT") >>> OffsetDateTime.parse_rfc2822("Sat, 15 Aug 2020 23:12:00 MST") Note ---- - Strictly speaking, an offset of ``-0000`` means that the offset is "unknown". Here, we treat it the same as +0000. - Although technically part of the RFC 2822 standard, comments within folding whitespace are not supported. """ return cls._from_py_unchecked(_parse_rfc2822(s), 0)
@no_type_check def add(self, *args, **kwargs) -> OffsetDateTime: """Add a time amount to this datetime. Important --------- Shifting a fixed-offset datetime implicitly ignore DST and other timezone changes. This because it isn't guaranteed that the same offset will be valid at the resulting time. If you want to account for DST, convert to a ``ZonedDateTime`` first. Or, if you want to ignore DST and accept potentially incorrect offsets, pass ``ignore_dst=True`` to this method. For more information, see `the documentation <https://whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic>`_. """ return self._shift(1, *args, **kwargs) @no_type_check def subtract(self, *args, **kwargs) -> OffsetDateTime: """Subtract a time amount from this datetime. Important --------- Shifting a fixed-offset datetime implicitly ignore DST and other timezone changes. This because it isn't guaranteed that the same offset will be valid at the resulting time. If you want to account for DST, convert to a ``ZonedDateTime`` first. Or, if you want to ignore DST and accept potentially incorrect offsets, pass ``ignore_dst=True`` to this method. For more information, see `the documentation <https://whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic>`_. """ return self._shift(-1, *args, **kwargs) @no_type_check def _shift( self, sign: int, arg: Delta | _UNSET = _UNSET, /, *, ignore_dst: bool = False, **kwargs, ) -> OffsetDateTime: if ignore_dst is not True: raise ImplicitlyIgnoringDST(ADJUST_OFFSET_DATETIME_MSG) elif kwargs: if arg is _UNSET: return self._shift_kwargs(sign, **kwargs) raise TypeError("Cannot mix positional and keyword arguments") elif arg is not _UNSET: return self._shift_kwargs( sign, months=arg._date_part._months, days=arg._date_part._days, nanoseconds=arg._time_part._total_ns, ) else: return self def _shift_kwargs( self, sign: int, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, hours: float = 0, minutes: float = 0, seconds: float = 0, milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, ) -> OffsetDateTime: py_dt_with_new_date = self.replace_date( self.date() ._add_months(sign * (years * 12 + months)) ._add_days(sign * (weeks * 7 + days)), ignore_dst=True, )._py_dt tdelta = sign * TimeDelta( hours=hours, minutes=minutes, seconds=seconds, milliseconds=milliseconds, microseconds=microseconds, nanoseconds=nanoseconds, ) delta_secs, nanos = divmod( tdelta._total_ns + self._nanos, 1_000_000_000 ) return self._from_py_unchecked( (py_dt_with_new_date + _timedelta(seconds=delta_secs)), nanos, ) def round( self, unit: Literal[ "day", "hour", "minute", "second", "millisecond", "microsecond", "nanosecond", ] = "second", increment: int = 1, mode: Literal[ "ceil", "floor", "half_ceil", "half_floor", "half_even" ] = "half_even", *, ignore_dst: bool = False, ) -> OffsetDateTime: """Round the datetime to the specified unit and increment. Different rounding modes are available. Examples -------- >>> d = OffsetDateTime(2020, 8, 15, 23, 24, 18, offset=+4) >>> d.round("day") OffsetDateTime(2020-08-16 00:00:00[+04:00]) >>> d.round("minute", increment=15, mode="floor") OffsetDateTime(2020-08-15 23:15:00[+04:00]) Note ---- * The ``ignore_dst`` parameter is required, because it is possible (though unlikely) that the rounded datetime will not have the same offset. * This method has similar behavior to the ``round()`` method of Temporal objects in JavaScript. """ if ignore_dst is not True: raise ImplicitlyIgnoringDST(OFFSET_ROUNDING_DST_MSG) return ( self.to_plain() ._round_unchecked( increment_to_ns(unit, increment, any_hour_ok=False), mode, 86_400_000_000_000, ) .assume_fixed_offset(self.offset) ) def __repr__(self) -> str: return f"OffsetDateTime({str(self).replace('T', ' ')})" # a custom pickle implementation with a smaller payload def __reduce__(self) -> tuple[object, ...]: return ( _unpkl_offset, ( pack( "<HBBBBBil", *self._py_dt.timetuple()[:6], self._nanos, int(self._py_dt.utcoffset().total_seconds()), # type: ignore[union-attr] ), ), ) # A separate function is needed for unpickling, because the # constructor doesn't accept positional offset argument as # required by __reduce__. # Also, it allows backwards-compatible changes to the pickling format. def _unpkl_offset(data: bytes) -> OffsetDateTime: *args, nanos, offset_secs = unpack("<HBBBBBil", data) args += (0, _timezone(_timedelta(seconds=offset_secs))) return OffsetDateTime._from_py_unchecked(_datetime(*args), nanos) @final class ZonedDateTime(_ExactAndLocalTime): """A datetime associated with a timezone in the IANA database. Useful for representing the exact time at a specific location. Example ------- >>> ZonedDateTime(2024, 12, 8, hour=11, tz="Europe/Paris") ZonedDateTime(2024-12-08 11:00:00+01:00[Europe/Paris]) >>> # Explicitly resolve ambiguities during DST transitions >>> ZonedDateTime(2023, 10, 29, 1, 15, tz="Europe/London", disambiguate="earlier") ZonedDateTime(2023-10-29 01:15:00+01:00[Europe/London]) Important --------- To use this type properly, read more about `ambiguity in timezones <https://whenever.rtfd.io/en/latest/overview.html#ambiguity-in-timezones>`_. """ __slots__ = () def __init__( self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, *, nanosecond: int = 0, tz: str, disambiguate: Disambiguate = "compatible", ) -> None: self._py_dt = _resolve_ambiguity( _datetime( year, month, day, hour, minute, second, 0, zone := _get_tz(tz), ), zone, disambiguate, ) if nanosecond < 0 or nanosecond >= 1_000_000_000: raise ValueError(f"nanosecond out of range: {nanosecond}") self._nanos = nanosecond @classmethod def now(cls, tz: str, /) -> ZonedDateTime: """Create an instance from the current time in the given timezone.""" secs, nanos = divmod(time_ns(), 1_000_000_000) return cls._from_py_unchecked(_fromtimestamp(secs, _get_tz(tz)), nanos) def format_common_iso(self) -> str: """Convert to the popular ISO format ``YYYY-MM-DDTHH:MM:SS±HH:MM[TZ_ID]`` The inverse of the ``parse_common_iso()`` method. Example ------- >>> ZonedDateTime(2020, 8, 15, hour=23, minute=12, tz="Europe/London") ZonedDateTime(2020-08-15 23:12:00+01:00[Europe/London]) Important --------- The timezone ID is a recent extension to the ISO 8601 format (RFC 9557). Althought it is gaining popularity, it is not yet widely supported by ISO 8601 parsers. """ py_isofmt = self._py_dt.isoformat() return ( py_isofmt[:19] # without the offset + bool(self._nanos) * f".{self._nanos:09d}".rstrip("0") + py_isofmt[19:] + f"[{self._py_dt.tzinfo.key}]" # type: ignore[union-attr] ) @classmethod def parse_common_iso(cls, s: str, /) -> ZonedDateTime: """Parse from the popular ISO format ``YYYY-MM-DDTHH:MM:SS±HH:MM[TZ_ID]`` The inverse of the ``format_common_iso()`` method. Example ------- >>> ZonedDateTime.parse_common_iso("2020-08-15T23:12:00+01:00[Europe/London]") ZonedDateTime(2020-08-15 23:12:00+01:00[Europe/London]) Important --------- The timezone ID is a recent extension to the ISO 8601 format (RFC 9557). Althought it is gaining popularity, it is not yet widely supported. """ return cls._from_py_unchecked(*_zdt_from_iso(s)) @classmethod def from_timestamp(cls, i: int, /, *, tz: str) -> ZonedDateTime: """Create an instance from a UNIX timestamp (in seconds). The inverse of the ``timestamp()`` method. """ secs, fract = divmod(i, 1) return cls._from_py_unchecked( _fromtimestamp(secs, _get_tz(tz)), int(fract * 1_000_000_000) ) @classmethod def from_timestamp_millis(cls, i: int, /, *, tz: str) -> ZonedDateTime: """Create an instance from a UNIX timestamp (in milliseconds). The inverse of the ``timestamp_millis()`` method. """ if not isinstance(i, int): raise TypeError("method requires an integer") secs, millis = divmod(i, 1_000) return cls._from_py_unchecked( _fromtimestamp(secs, _get_tz(tz)), millis * 1_000_000 ) @classmethod def from_timestamp_nanos(cls, i: int, /, *, tz: str) -> ZonedDateTime: """Create an instance from a UNIX timestamp (in nanoseconds). The inverse of the ``timestamp_nanos()`` method. """ if not isinstance(i, int): raise TypeError("method requires an integer") secs, nanos = divmod(i, 1_000_000_000) return cls._from_py_unchecked(_fromtimestamp(secs, _get_tz(tz)), nanos) # FUTURE: optional `disambiguate` to override fold? @classmethod def from_py_datetime(cls, d: _datetime, /) -> ZonedDateTime: """Create an instance from a standard library ``datetime`` object with a ``ZoneInfo`` tzinfo. The inverse of the ``py_datetime()`` method. Attention --------- If the datetime is ambiguous (e.g. during a DST transition), the ``fold`` attribute is used to disambiguate the time. """ from zoneinfo import ZoneInfo if type(d.tzinfo) is not ZoneInfo: raise ValueError( "Can only create ZonedDateTime from tzinfo=ZoneInfo (exactly), " f"got datetime with tzinfo={d.tzinfo!r}" ) if d.tzinfo.key is None: raise ValueError(ZONEINFO_NO_KEY_MSG) # This ensures skipped times are disambiguated according to the fold. d = d.astimezone(_UTC).astimezone(_get_tz(d.tzinfo.key)) return cls._from_py_unchecked( _strip_subclasses(d.replace(microsecond=0)), d.microsecond * 1_000 ) def replace_date( self, date: Date, /, disambiguate: Disambiguate | None = None ) -> ZonedDateTime: """Construct a new instance with the date replaced. See the ``replace()`` method for more information. """ return self._from_py_unchecked( _resolve_ambiguity( _datetime.combine(date._py_date, self._py_dt.timetz()), # mypy doesn't know that tzinfo is always a ZoneInfo here self._py_dt.tzinfo, # type: ignore[arg-type] # mypy doesn't know that offset is never None here disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), self._nanos, ) def replace_time( self, time: Time, /, disambiguate: Disambiguate | None = None ) -> ZonedDateTime: """Construct a new instance with the time replaced. See the ``replace()`` method for more information. """ return self._from_py_unchecked( _resolve_ambiguity( _datetime.combine( self._py_dt, time._py_time, self._py_dt.tzinfo ), # mypy doesn't know that tzinfo is always a ZoneInfo here self._py_dt.tzinfo, # type: ignore[arg-type] # mypy doesn't know that offset is never None here disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), time._nanos, ) def replace( self, /, disambiguate: Disambiguate | None = None, **kwargs: Any ) -> ZonedDateTime: """Construct a new instance with the given fields replaced. Important --------- Replacing fields of a ZonedDateTime may result in an ambiguous time (e.g. during a DST transition). Therefore, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. By default, if the tz remains the same, the offset is used to disambiguate if possible, falling back to the "compatible" strategy if needed. See `the documentation <https://whenever.rtfd.io/en/latest/overview.html#ambiguity-in-timezones>`_ for more information. """ _check_invalid_replace_kwargs(kwargs) try: tz = kwargs.pop("tz") except KeyError: pass else: kwargs["tzinfo"] = zoneinfo_new = _get_tz(tz) if zoneinfo_new is not self._py_dt.tzinfo: disambiguate = disambiguate or "compatible" nanos = _pop_nanos_kwarg(kwargs, self._nanos) return self._from_py_unchecked( _resolve_ambiguity( self._py_dt.replace(**kwargs), kwargs.get("tzinfo", self._py_dt.tzinfo), # type: ignore[arg-type] # mypy doesn't know that offset is never None here disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), nanos, ) @property def tz(self) -> str: """The timezone ID""" return self._py_dt.tzinfo.key # type: ignore[union-attr,no-any-return] def __hash__(self) -> int: return hash((self._py_dt.astimezone(_UTC), self._nanos)) def __add__(self, delta: Delta) -> ZonedDateTime: """Add an amount of time, accounting for timezone changes (e.g. DST). See `the docs <https://whenever.rtfd.io/en/latest/overview.html#arithmetic>`_ for more information. """ if isinstance(delta, TimeDelta): delta_secs, nanos = divmod( delta._time_part._total_ns + self._nanos, 1_000_000_000 ) return self._from_py_unchecked( ( self._py_dt.astimezone(_UTC) + _timedelta(seconds=delta_secs) ).astimezone(self._py_dt.tzinfo), nanos, ) elif isinstance(delta, DateDelta): return self.replace_date(self.date() + delta) elif isinstance(delta, DateTimeDelta): return ( self.replace_date(self.date() + delta._date_part) + delta._time_part ) return NotImplemented @overload def __sub__(self, other: _ExactTime) -> TimeDelta: ... @overload def __sub__(self, other: TimeDelta) -> ZonedDateTime: ... def __sub__(self, other: TimeDelta | _ExactTime) -> _ExactTime | TimeDelta: """Subtract another datetime or duration. See `the docs <https://whenever.rtfd.io/en/latest/overview.html#arithmetic>`_ for more information. """ if isinstance(other, _ExactTime): return super().__sub__(other) # type: ignore[misc, no-any-return] elif isinstance(other, (TimeDelta, DateDelta, DateTimeDelta)): return self + -other return NotImplemented @no_type_check def add(self, *args, **kwargs) -> ZonedDateTime: """Add a time amount to this datetime. Important --------- Shifting a ``ZonedDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation <https://whenever.rtfd.io/en/latest/overview.html#arithmetic>`_ for more information. """ return self._shift(1, *args, **kwargs) @no_type_check def subtract(self, *args, **kwargs) -> ZonedDateTime: """Subtract a time amount from this datetime. Important --------- Shifting a ``ZonedDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation <https://whenever.rtfd.io/en/latest/overview.html#arithmetic>`_ for more information. """ return self._shift(-1, *args, **kwargs) @no_type_check def _shift( self, sign: int, delta: Delta | _UNSET = _UNSET, /, *, disambiguate: Disambiguate | None = None, **kwargs, ) -> ZonedDateTime: if kwargs: if delta is _UNSET: return self._shift_kwargs( sign, disambiguate=disambiguate, **kwargs ) raise TypeError("Cannot mix positional and keyword arguments") elif delta is not _UNSET: return self._shift_kwargs( sign, months=delta._date_part._months, days=delta._date_part._days, nanoseconds=delta._time_part._total_ns, disambiguate=disambiguate, ) else: return self def _shift_kwargs( self, sign: int, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, hours: float = 0, minutes: float = 0, seconds: float = 0, milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, disambiguate: Disambiguate | None, ) -> ZonedDateTime: months_total = sign * (years * 12 + months) days_total = sign * (weeks * 7 + days) if months_total or days_total: self = self.replace_date( self.date()._add_months(months_total)._add_days(days_total), disambiguate=disambiguate, ) return self + sign * TimeDelta( hours=hours, minutes=minutes, seconds=seconds, milliseconds=milliseconds, microseconds=microseconds, nanoseconds=nanoseconds, )
[docs] def is_ambiguous(self) -> bool: """Whether the date and time-of-day are ambiguous, e.g. due to a DST transition. Example ------- >>> ZonedDateTime(2020, 8, 15, 23, tz="Europe/London").is_ambiguous() False >>> ZonedDateTime(2023, 10, 29, 2, 15, tz="Europe/Amsterdam").is_ambiguous() True """ # We make use of a quirk of the standard library here: # ambiguous datetimes are never equal across timezones return self._py_dt.astimezone(_UTC) != self._py_dt
[docs] def day_length(self) -> TimeDelta: """The duration between the start of the current day and the next. This is usually 24 hours, but may be different due to timezone transitions. Example ------- >>> ZonedDateTime(2020, 8, 15, tz="Europe/London").day_length() TimeDelta(24:00:00) >>> ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam").day_length() TimeDelta(25:00:00) """ midnight = _datetime.combine( self._py_dt.date(), _time(), self._py_dt.tzinfo ) next_midnight = midnight + _timedelta(days=1) return TimeDelta.from_py_timedelta( next_midnight.astimezone(_UTC) - midnight.astimezone(_UTC) )
[docs] def start_of_day(self) -> ZonedDateTime: """The start of the current calendar day. This is almost always at midnight the same day, but may be different for timezones which transition at—and thus skip over—midnight. """ midnight = _datetime.combine( self._py_dt.date(), _time(), self._py_dt.tzinfo ) return self._from_py_unchecked( midnight.astimezone(_UTC).astimezone(self._py_dt.tzinfo), 0 )
def round( self, unit: Literal[ "day", "hour", "minute", "second", "millisecond", "microsecond", "nanosecond", ] = "second", increment: int = 1, mode: Literal[ "ceil", "floor", "half_ceil", "half_floor", "half_even" ] = "half_even", ) -> ZonedDateTime: """Round the datetime to the specified unit and increment. Different rounding modes are available. Examples -------- >>> d = ZonedDateTime(2020, 8, 15, 23, 24, 18, tz="Europe/Paris") >>> d.round("day") ZonedDateTime(2020-08-16 00:00:00+02:00[Europe/Paris]) >>> d.round("minute", increment=15, mode="floor") ZonedDateTime(2020-08-15 23:15:00+02:00[Europe/Paris]) Notes ----- * In the rare case that rounding results in an ambiguous time, the offset is preserved if possible. Otherwise, the time is resolved according to the "compatible" strategy. * Rounding in "day" mode may be affected by DST transitions. i.e. on 23-hour days, 11:31 AM is rounded up. * This method has similar behavior to the ``round()`` method of Temporal objects in JavaScript. """ increment_ns = increment_to_ns(unit, increment, any_hour_ok=False) if unit == "day": increment_ns = day_ns = self.day_length()._total_ns else: day_ns = 86_400_000_000_000 rounded_local = self.to_plain()._round_unchecked( increment_ns, mode, day_ns ) return self._from_py_unchecked( _resolve_ambiguity_using_prev_offset( rounded_local._py_dt.replace(tzinfo=self._py_dt.tzinfo), self._py_dt.utcoffset(), # type: ignore[arg-type] ), rounded_local._nanos, ) def py_datetime(self) -> _datetime: # We convert to UTC first, then to a *non* file based ZoneInfo. # We don't just `replace()` the timezone, because in theory # they could disagree about the offset. This ensures we keep the # same moment in time. # FUTURE: write a test for this (a bit complicated) from zoneinfo import ZoneInfo return ( self._py_dt.astimezone(_UTC) .astimezone( ZoneInfo(self._py_dt.tzinfo.key) # type: ignore[union-attr] ) .replace( microsecond=self._nanos // 1_000, ) ) def __repr__(self) -> str: return f"ZonedDateTime({str(self).replace('T', ' ', 1)})" # a custom pickle implementation with a smaller payload def __reduce__(self) -> tuple[object, ...]: return ( _unpkl_zoned, ( pack( "<HBBBBBil", *self._py_dt.timetuple()[:6], self._nanos, int(self._py_dt.utcoffset().total_seconds()), # type: ignore[union-attr] ), self._py_dt.tzinfo.key, # type: ignore[union-attr] ), ) # A separate function is needed for unpickling, because the # constructor doesn't accept positional tz and fold arguments as # required by __reduce__. # Also, it allows backwards-compatible changes to the pickling format. def _unpkl_zoned( data: bytes, tz: str, ) -> ZonedDateTime: *args, nanos, offset_secs = unpack("<HBBBBBil", data) args += (0, _get_tz(tz)) return ZonedDateTime._from_py_unchecked( _adjust_fold_to_offset( _datetime(*args), _timedelta(seconds=offset_secs) ), nanos, ) @final class SystemDateTime(_ExactAndLocalTime): """Represents a time in the system timezone. It is similar to ``OffsetDateTime``, but it knows about the system timezone and its DST transitions. Example ------- >>> # 8:00 in the system timezone—Paris in this case >>> alarm = SystemDateTime(2024, 3, 31, hour=6) SystemDateTime(2024-03-31 06:00:00+02:00) >>> # Conversion based on Paris' offset >>> alarm.instant() Instant(2024-03-31 04:00:00Z) >>> # DST-safe arithmetic >>> bedtime = alarm - hours(8) SystemDateTime(2024-03-30 21:00:00+01:00) Attention --------- To use this type properly, read more about `ambiguity <https://whenever.rtfd.io/en/latest/overview.html#ambiguity-in-timezones>`_ and `working with the system timezone <https://whenever.rtfd.io/en/latest/overview.html#the-system-timezone>`_. """ __slots__ = () def __init__( self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, *, nanosecond: int = 0, disambiguate: Disambiguate = "compatible", ) -> None: self._py_dt = _resolve_system_ambiguity( _datetime( year, month, day, hour, minute, second, 0, ), disambiguate, ) if nanosecond < 0 or nanosecond >= 1_000_000_000: raise ValueError("nanosecond out of range") self._nanos = nanosecond @classmethod def now(cls) -> SystemDateTime: """Create an instance from the current time in the system timezone.""" secs, nanos = divmod(time_ns(), 1_000_000_000) return cls._from_py_unchecked( _fromtimestamp(secs, _UTC).astimezone(None), nanos ) format_common_iso = OffsetDateTime.format_common_iso """Convert to the popular ISO format ``YYYY-MM-DDTHH:MM:SS±HH:MM`` The inverse of the ``parse_common_iso()`` method. Important --------- Information about the system timezone name is *not* included in the output. """ @classmethod def parse_common_iso(cls, s: str, /) -> SystemDateTime: """Parse from the popular ISO format ``YYYY-MM-DDTHH:MM:SS±HH:MM`` Important --------- The offset isn't adjusted to the current system timezone. See `the docs <https://whenever.rtfd.io/en/latest/overview.html#the-system-timezone>`_ for more information. """ return cls._from_py_unchecked(*_offset_dt_from_iso(s)) @classmethod def from_timestamp(cls, i: int | float, /) -> SystemDateTime: """Create an instance from a UNIX timestamp (in seconds). The inverse of the ``timestamp()`` method. """ secs, fract = divmod(i, 1) return cls._from_py_unchecked( _fromtimestamp(secs, _UTC).astimezone(), int(fract * 1_000_000_000) ) @classmethod def from_timestamp_millis(cls, i: int, /) -> SystemDateTime: """Create an instance from a UNIX timestamp (in milliseconds). The inverse of the ``timestamp_millis()`` method. """ if not isinstance(i, int): raise TypeError("method requires an integer") secs, millis = divmod(i, 1_000) return cls._from_py_unchecked( _fromtimestamp(secs, _UTC).astimezone(), millis * 1_000_000 ) @classmethod def from_timestamp_nanos(cls, i: int, /) -> SystemDateTime: """Create an instance from a UNIX timestamp (in nanoseconds). The inverse of the ``timestamp_nanos()`` method. """ if not isinstance(i, int): raise TypeError("method requires an integer") secs, nanos = divmod(i, 1_000_000_000) return cls._from_py_unchecked( _fromtimestamp(secs, _UTC).astimezone(), nanos ) @classmethod def from_py_datetime(cls, d: _datetime, /) -> SystemDateTime: """Create an instance from a standard library ``datetime`` object. The datetime must be aware. The inverse of the ``py_datetime()`` method. """ odt = OffsetDateTime.from_py_datetime(d) return cls._from_py_unchecked(odt._py_dt, odt._nanos) def __repr__(self) -> str: return f"SystemDateTime({str(self).replace('T', ' ')})" # FUTURE: expose the tzname? def replace_date( self, date: Date, /, disambiguate: Disambiguate | None = None ) -> SystemDateTime: """Construct a new instance with the date replaced. See the ``replace()`` method for more information. """ return self._from_py_unchecked( _resolve_system_ambiguity( _datetime.combine(date._py_date, self._py_dt.time()), # mypy doesn't know that offset is never None here disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), self._nanos, ) def replace_time( self, time: Time, /, disambiguate: Disambiguate | None = None ) -> SystemDateTime: """Construct a new instance with the time replaced. See the ``replace()`` method for more information. """ return self._from_py_unchecked( _resolve_system_ambiguity( _datetime.combine(self._py_dt, time._py_time), # mypy doesn't know that offset is never None here disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), time._nanos, ) def replace( self, /, disambiguate: Disambiguate | None = None, **kwargs: Any ) -> SystemDateTime: """Construct a new instance with the given fields replaced. Important --------- Replacing fields of a SystemDateTime may result in an ambiguous time (e.g. during a DST transition). Therefore, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation <https://whenever.rtfd.io/en/latest/overview.html#ambiguity-in-timezones>`_ for more information. """ _check_invalid_replace_kwargs(kwargs) nanos = _pop_nanos_kwarg(kwargs, self._nanos) return self._from_py_unchecked( _resolve_system_ambiguity( self._py_dt.replace(tzinfo=None, **kwargs), # mypy doesn't know that offset is never None here disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), nanos, ) def __hash__(self) -> int: return hash((self._py_dt, self._nanos)) def __add__(self, delta: TimeDelta) -> SystemDateTime: """Add an amount of time, accounting for timezone changes (e.g. DST). See `the docs <https://whenever.rtfd.io/en/latest/overview.html#arithmetic>`_ for more information. """ if isinstance(delta, TimeDelta): py_dt = self._py_dt delta_secs, nanos = divmod( delta._time_part._total_ns + self._nanos, 1_000_000_000 ) return self._from_py_unchecked( (py_dt + _timedelta(seconds=delta_secs)).astimezone(), nanos ) elif isinstance(delta, DateDelta): return self.replace_date(self.date() + delta) elif isinstance(delta, DateTimeDelta): return ( self.replace_date(self.date() + delta._date_part) + delta._time_part ) return NotImplemented @overload def __sub__(self, other: _ExactTime) -> TimeDelta: ... @overload def __sub__(self, other: TimeDelta) -> SystemDateTime: ... def __sub__(self, other: TimeDelta | _ExactTime) -> _ExactTime | Delta: """Subtract another datetime or duration See `the docs <https://whenever.rtfd.io/en/latest/overview.html#arithmetic>`_ for more information. """ if isinstance(other, _ExactTime): return super().__sub__(other) # type: ignore[misc, no-any-return] elif isinstance(other, (TimeDelta, DateDelta, DateTimeDelta)): return self + -other return NotImplemented @no_type_check def add(self, *args, **kwargs) -> SystemDateTime: """Add a time amount to this datetime. Important --------- Shifting a ``SystemDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation <https://whenever.rtfd.io/en/latest/overview.html#arithmetic>`_ for more information. """ return self._shift(1, *args, **kwargs) @no_type_check def subtract(self, *args, **kwargs) -> SystemDateTime: """Subtract a time amount from this datetime. Important --------- Shifting a ``SystemDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation <https://whenever.rtfd.io/en/latest/overview.html#arithmetic>`_ for more information. """ return self._shift(-1, *args, **kwargs) @no_type_check def _shift( self, sign: int, delta: Delta | _UNSET = _UNSET, /, *, disambiguate: Disambiguate | None = None, **kwargs, ) -> SystemDateTime: if kwargs: if delta is _UNSET: return self._shift_kwargs( sign, disambiguate=disambiguate, **kwargs ) raise TypeError("Cannot mix positional and keyword arguments") elif delta is not _UNSET: return self._shift_kwargs( sign, months=delta._date_part._months, days=delta._date_part._days, nanoseconds=delta._time_part._total_ns, disambiguate=disambiguate, ) else: return self def _shift_kwargs( self, sign: int, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, hours: float = 0, minutes: float = 0, seconds: float = 0, milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, disambiguate: Disambiguate | None, ) -> SystemDateTime: months_total = sign * (years * 12 + months) days_total = sign * (weeks * 7 + days) if months_total or days_total: self = self.replace_date( self.date()._add_months(months_total)._add_days(days_total), disambiguate=disambiguate, ) return self + sign * TimeDelta( hours=hours, minutes=minutes, seconds=seconds, milliseconds=milliseconds, microseconds=microseconds, nanoseconds=nanoseconds, )
[docs] def is_ambiguous(self) -> bool: """Whether the date and time-of-day is ambiguous, e.g. due to a DST transition. Example ------- >>> # with system configured in Europe/Paris >>> SystemDateTime(2020, 8, 15, 23).is_ambiguous() False >>> SystemDateTime(2023, 10, 29, 2, 15).is_ambiguous() True Note ---- This method may give a different result after a change to the system timezone. """ naive = self._py_dt.replace(tzinfo=None) return naive.astimezone(_UTC) != naive.replace(fold=1).astimezone(_UTC)
[docs] def day_length(self) -> TimeDelta: """The duration between the start of the current day and the next. This is usually 24 hours, but may be different due to timezone transitions. Example ------- >>> # with system configured in Europe/Paris >>> SystemDateTime(2020, 8, 15).day_length() TimeDelta(24:00:00) >>> SystemDateTime(2023, 10, 29).day_length() TimeDelta(25:00:00) Note ---- This method may give a different result after a change to the system timezone. """ midnight = _datetime.combine(self._py_dt.date(), _time()) next_midnight = midnight + _timedelta(days=1) return TimeDelta.from_py_timedelta( _resolve_system_ambiguity(next_midnight, "compatible") - _resolve_system_ambiguity(midnight, "compatible") )
[docs] def start_of_day(self) -> SystemDateTime: """The start of the current calendar day. This is almost always at midnight the same day, but may be different for timezones which transition at—and thus skip over—midnight. Note ---- This method may give a different result after a change to the system timezone. """ midnight = _datetime.combine(self._py_dt.date(), _time()) return self._from_py_unchecked( _resolve_system_ambiguity(midnight, "compatible"), 0 )
def round( self, unit: Literal[ "day", "hour", "minute", "second", "millisecond", "microsecond", "nanosecond", ] = "second", increment: int = 1, mode: Literal[ "ceil", "floor", "half_ceil", "half_floor", "half_even" ] = "half_even", ) -> SystemDateTime: """Round the datetime to the specified unit and increment. Different rounding modes are available. Examples -------- >>> d = SystemDateTime(2020, 8, 15, 23, 24, 18) >>> d.round("day") SystemDateTime(2020-08-16 00:00:00+02:00) >>> d.round("minute", increment=15, mode="floor") SystemDateTime(2020-08-15 23:15:00+02:00) Notes ----- * In the rare case that rounding results in an ambiguous time, the offset is preserved if possible. Otherwise, the time is resolved according to the "compatible" strategy. * Rounding in "day" mode may be affected by DST transitions. i.e. on 23-hour days, 11:31 AM is rounded up. * This method has similar behavior to the ``round()`` method of Temporal objects in JavaScript. * The result of this method may change if the system timezone changes. """ increment_ns = increment_to_ns(unit, increment, any_hour_ok=False) if unit == "day": increment_ns = day_ns = self.day_length()._total_ns else: day_ns = 86_400_000_000_000 rounded_local = self.to_plain()._round_unchecked( increment_ns, mode, day_ns ) return self._from_py_unchecked( _resolve_system_ambiguity_using_prev_offset( rounded_local._py_dt, self._py_dt.utcoffset(), # type: ignore[arg-type] ), rounded_local._nanos, ) # a custom pickle implementation with a smaller payload def __reduce__(self) -> tuple[object, ...]: return ( _unpkl_system, ( pack( "<HBBBBBil", *self._py_dt.timetuple()[:6], self._nanos, int(self._py_dt.utcoffset().total_seconds()), # type: ignore[union-attr] ), ), ) # A separate function is needed for unpickling, because the # constructor doesn't accept positional fold arguments as # required by __reduce__. # Also, it allows backwards-compatible changes to the pickling format. def _unpkl_system(data: bytes) -> SystemDateTime: *args, nanos, offset_secs = unpack("<HBBBBBil", data) args += (0, _timezone(_timedelta(seconds=offset_secs))) return SystemDateTime._from_py_unchecked(_datetime(*args), nanos) @final class PlainDateTime(_LocalTime): """A combination of date and time-of-day, without a timezone. Can be used to represent local time, i.e. how time appears to people on a wall clock. It can't be mixed with exact time types (e.g. ``Instant``, ``ZonedDateTime``) Conversion to exact time types can only be done by explicitly assuming a timezone or offset. Examples of when to use this type: - You need to express a date and time as it would be observed locally on the "wall clock" or calendar. - You receive a date and time without any timezone information, and you need a type to represent this lack of information. - In the rare case you truly don't need to account for timezones, or Daylight Saving Time transitions. For example, when modeling time in a simulation game. """ def __init__( self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, *, nanosecond: int = 0, ) -> None: self._py_dt = _datetime(year, month, day, hour, minute, second) self._nanos = nanosecond def format_common_iso(self) -> str: """Convert to the popular ISO format ``YYYY-MM-DDTHH:MM:SS`` The inverse of the ``parse_common_iso()`` method. """ return ( (self._py_dt.isoformat() + f".{self._nanos:09d}").rstrip("0") if self._nanos else self._py_dt.isoformat() ) @classmethod def parse_common_iso(cls, s: str, /) -> PlainDateTime: """Parse the popular ISO format ``YYYY-MM-DDTHH:MM:SS`` The inverse of the ``format_common_iso()`` method. Example ------- >>> PlainDateTime.parse_common_iso("2020-08-15T23:12:00") PlainDateTime(2020-08-15 23:12:00) """ return cls._from_py_unchecked(*_datetime_from_iso(s)) @classmethod def from_py_datetime(cls, d: _datetime, /) -> PlainDateTime: """Create an instance from a "naive" standard library ``datetime`` object""" if d.tzinfo is not None: raise ValueError( "Can only create PlainDateTime from a naive datetime, " f"got datetime with tzinfo={d.tzinfo!r}" ) return cls._from_py_unchecked( _strip_subclasses(d.replace(microsecond=0)), d.microsecond * 1_000 ) def replace(self, /, **kwargs: Any) -> PlainDateTime: """Construct a new instance with the given fields replaced.""" if not _no_tzinfo_fold_or_ms(kwargs): raise TypeError( "tzinfo, fold, or microsecond are not allowed arguments" ) nanos = _pop_nanos_kwarg(kwargs, self._nanos) return self._from_py_unchecked(self._py_dt.replace(**kwargs), nanos) def replace_date(self, d: Date, /) -> PlainDateTime: """Construct a new instance with the date replaced.""" return self._from_py_unchecked( _datetime.combine(d._py_date, self._py_dt.time()), self._nanos ) def replace_time(self, t: Time, /) -> PlainDateTime: """Construct a new instance with the time replaced.""" return self._from_py_unchecked( _datetime.combine(self._py_dt.date(), t._py_time), t._nanos ) def __hash__(self) -> int: return hash((self._py_dt, self._nanos))
[docs] def __eq__(self, other: object) -> bool: """Compare objects for equality. Only ever equal to other :class:`PlainDateTime` instances with the same values. Warning ------- To comply with the Python data model, this method can't raise a :exc:`TypeError` when comparing with other types. Although it seems to be the sensible response, it would result in `surprising behavior <https://stackoverflow.com/a/33417512>`_ when using values as dictionary keys. Use mypy's ``--strict-equality`` flag to detect and prevent this. Example ------- >>> PlainDateTime(2020, 8, 15, 23) == PlainDateTime(2020, 8, 15, 23) True >>> PlainDateTime(2020, 8, 15, 23, 1) == PlainDateTime(2020, 8, 15, 23) False >>> PlainDateTime(2020, 8, 15) == Instant.from_utc(2020, 8, 15) False # Use mypy's --strict-equality flag to detect this. """ if not isinstance(other, PlainDateTime): return NotImplemented return (self._py_dt, self._nanos) == (other._py_dt, other._nanos)
MIN: ClassVar[PlainDateTime] """The minimum representable value of this type.""" MAX: ClassVar[PlainDateTime] """The maximum representable value of this type.""" def __lt__(self, other: PlainDateTime) -> bool: if not isinstance(other, PlainDateTime): return NotImplemented return (self._py_dt, self._nanos) < (other._py_dt, other._nanos) def __le__(self, other: PlainDateTime) -> bool: if not isinstance(other, PlainDateTime): return NotImplemented return (self._py_dt, self._nanos) <= (other._py_dt, other._nanos) def __gt__(self, other: PlainDateTime) -> bool: if not isinstance(other, PlainDateTime): return NotImplemented return (self._py_dt, self._nanos) > (other._py_dt, other._nanos) def __ge__(self, other: PlainDateTime) -> bool: if not isinstance(other, PlainDateTime): return NotImplemented return (self._py_dt, self._nanos) >= (other._py_dt, other._nanos) def __add__(self, delta: DateDelta) -> PlainDateTime: """Add a delta to this datetime. See :ref:`the docs on arithmetic <arithmetic>` for more information. """ if isinstance(delta, DateDelta): return self._from_py_unchecked( _datetime.combine( (self.date() + delta._date_part)._py_date, self._py_dt.time(), ), self._nanos, ) elif isinstance(delta, (TimeDelta, DateTimeDelta)): raise ImplicitlyIgnoringDST(SHIFT_LOCAL_MSG) return NotImplemented def __sub__(self, other: DateDelta) -> PlainDateTime: """Subtract another datetime or delta See :ref:`the docs on arithmetic <arithmetic>` for more information. """ # Handling these extra types allows for descriptive error messages if isinstance(other, (DateDelta, TimeDelta, DateTimeDelta)): return self + -other elif isinstance(other, PlainDateTime): raise ImplicitlyIgnoringDST(DIFF_OPERATOR_LOCAL_MSG) return NotImplemented
[docs] def difference( self, other: PlainDateTime, /, *, ignore_dst: bool = False ) -> TimeDelta: """Calculate the difference between two times without a timezone. Important --------- The difference between two datetimes without a timezone implicitly ignores DST transitions and other timezone changes. To perform DST-safe operations, convert to a ``ZonedDateTime`` first. Or, if you don't know the timezone and accept potentially incorrect results during DST transitions, pass ``ignore_dst=True``. For more information, see `the docs <https://whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic>`_. """ if ignore_dst is not True: raise ImplicitlyIgnoringDST(DIFF_LOCAL_MSG) py_delta = self._py_dt - other._py_dt return TimeDelta( seconds=py_delta.days * 86_400 + py_delta.seconds, nanoseconds=self._nanos - other._nanos, )
@no_type_check def add(self, *args, **kwargs) -> PlainDateTime: """Add a time amount to this datetime. Important --------- Shifting a ``PlainDateTime`` with **exact units** (e.g. hours, seconds) implicitly ignores DST transitions and other timezone changes. If you need to account for these, convert to a ``ZonedDateTime`` first. Or, if you don't know the timezone and accept potentially incorrect results during DST transitions, pass ``ignore_dst=True``. See `the documentation <https://whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic>`_ for more information. """ return self._shift(1, *args, **kwargs) @no_type_check def subtract(self, *args, **kwargs) -> PlainDateTime: """Subtract a time amount from this datetime. Important --------- Shifting a ``PlainDateTime`` with **exact units** (e.g. hours, seconds) implicitly ignores DST transitions and other timezone changes. If you need to account for these, convert to a ``ZonedDateTime`` first. Or, if you don't know the timezone and accept potentially incorrect results during DST transitions, pass ``ignore_dst=True``. See `the documentation <https://whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic>`_ for more information. """ return self._shift(-1, *args, **kwargs) @no_type_check def _shift( self, sign: int, arg: Delta | _UNSET = _UNSET, /, *, ignore_dst: bool = False, **kwargs, ) -> PlainDateTime: if kwargs: if arg is _UNSET: return self._shift_kwargs(sign, ignore_dst, **kwargs) raise TypeError("Cannot mix positional and keyword arguments") elif arg is not _UNSET: return self._shift_kwargs( sign, ignore_dst, months=arg._date_part._months, days=arg._date_part._days, nanoseconds=arg._time_part._total_ns, ) else: return self def _shift_kwargs( self, sign: int, ignore_dst: bool, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, hours: float = 0, minutes: float = 0, seconds: float = 0, milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, ) -> PlainDateTime: py_dt_with_new_date = self.replace_date( self.date() ._add_months(sign * (years * 12 + months)) ._add_days(sign * (weeks * 7 + days)), )._py_dt tdelta = sign * TimeDelta( hours=hours, minutes=minutes, seconds=seconds, milliseconds=milliseconds, microseconds=microseconds, nanoseconds=nanoseconds, ) if tdelta and ignore_dst is not True: raise ImplicitlyIgnoringDST(ADJUST_LOCAL_DATETIME_MSG) delta_secs, nanos = divmod( tdelta._total_ns + self._nanos, 1_000_000_000 ) return self._from_py_unchecked( (py_dt_with_new_date + _timedelta(seconds=delta_secs)), nanos, )
[docs] @classmethod def parse_strptime(cls, s: str, /, *, format: str) -> PlainDateTime: """Parse a plain datetime using the standard library ``strptime()`` method. Example ------- >>> PlainDateTime.parse_strptime("2020-08-15", format="%d/%m/%Y_%H:%M") PlainDateTime(2020-08-15 00:00:00) Note ---- This method defers to the standard library ``strptime()`` method, which may behave differently in different Python versions. It also only supports up to microsecond precision. Important --------- There may not be an offset in the format string. This means you CANNOT use the directives ``%z``, ``%Z``, or ``%:z``. Use ``OffsetDateTime`` to parse datetimes with an offset. """ parsed = _datetime.strptime(s, format) if parsed.tzinfo is not None: raise ValueError( "Parsed datetime can't have an offset. " "Do not use %z, %Z, or %:z in the format string" ) return cls._from_py_unchecked( parsed.replace(microsecond=0), parsed.microsecond * 1_000 )
[docs] def assume_utc(self) -> Instant: """Assume the datetime is in UTC, creating an ``Instant``. Example ------- >>> PlainDateTime(2020, 8, 15, 23, 12).assume_utc() Instant(2020-08-15 23:12:00Z) """ return Instant._from_py_unchecked( self._py_dt.replace(tzinfo=_UTC), self._nanos )
[docs] def assume_fixed_offset( self, offset: int | TimeDelta, / ) -> OffsetDateTime: """Assume the datetime has the given offset, creating an ``OffsetDateTime``. Example ------- >>> PlainDateTime(2020, 8, 15, 23, 12).assume_fixed_offset(+2) OffsetDateTime(2020-08-15 23:12:00+02:00) """ return OffsetDateTime._from_py_unchecked( self._py_dt.replace(tzinfo=_load_offset(offset)), self._nanos )
[docs] def assume_tz( self, tz: str, /, disambiguate: Disambiguate = "compatible" ) -> ZonedDateTime: """Assume the datetime is in the given timezone, creating a ``ZonedDateTime``. Note ---- The local time may be ambiguous in the given timezone (e.g. during a DST transition). You can explicitly specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation <https://whenever.rtfd.io/en/latest/overview.html#ambiguity-in-timezones>`_ for more information. Example ------- >>> d = PlainDateTime(2020, 8, 15, 23, 12) >>> d.assume_tz("Europe/Amsterdam", disambiguate="raise") ZonedDateTime(2020-08-15 23:12:00+02:00[Europe/Amsterdam]) """ return ZonedDateTime._from_py_unchecked( _resolve_ambiguity( self._py_dt.replace(tzinfo=(zone := _get_tz(tz))), zone, disambiguate, ), self._nanos, )
[docs] def assume_system_tz( self, disambiguate: Disambiguate = "compatible" ) -> SystemDateTime: """Assume the datetime is in the system timezone, creating a ``SystemDateTime``. Note ---- The local time may be ambiguous in the system timezone (e.g. during a DST transition). You can explicitly specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation <https://whenever.rtfd.io/en/latest/overview.html#ambiguity-in-timezones>`_ for more information. Example ------- >>> d = PlainDateTime(2020, 8, 15, 23, 12) >>> # assuming system timezone is America/New_York >>> d.assume_system_tz(disambiguate="raise") SystemDateTime(2020-08-15 23:12:00-04:00) """ return SystemDateTime._from_py_unchecked( _resolve_system_ambiguity(self._py_dt, disambiguate), self._nanos, )
def round( self, unit: Literal[ "day", "hour", "minute", "second", "millisecond", "microsecond", "nanosecond", ] = "second", increment: int = 1, mode: Literal[ "ceil", "floor", "half_ceil", "half_floor", "half_even" ] = "half_even", ) -> PlainDateTime: """Round the datetime to the specified unit and increment. Different rounding modes are available. Examples -------- >>> d = PlainDateTime(2020, 8, 15, 23, 24, 18) >>> d.round("day") PlainDateTime(2020-08-16 00:00:00) >>> d.round("minute", increment=15, mode="floor") PlainDateTime(2020-08-15 23:15:00) Note ---- This method has similar behavior to the ``round()`` method of Temporal objects in JavaScript. """ return self._round_unchecked( increment_to_ns(unit, increment, any_hour_ok=False), mode, 86_400_000_000_000, ) def _round_unchecked( self, increment_ns: int, mode: str, day_ns: int ) -> PlainDateTime: rounded_time, next_day = self.time()._round_unchecked( increment_ns, mode, day_ns ) return self.date()._add_days(next_day).at(rounded_time) def __repr__(self) -> str: return f"PlainDateTime({str(self).replace('T', ' ')})" # a custom pickle implementation with a smaller payload def __reduce__(self) -> tuple[object, ...]: return ( _unpkl_local, (pack("<HBBBBBi", *self._py_dt.timetuple()[:6], self._nanos),), ) # A separate unpickling function allows us to make backwards-compatible changes # to the pickling format in the future @no_type_check def _unpkl_local(data: bytes) -> PlainDateTime: *args, nanos = unpack("<HBBBBBi", data) return PlainDateTime._from_py_unchecked(_datetime(*args), nanos) class RepeatedTime(ValueError): """A datetime is repeated in a timezone, e.g. because of DST""" @classmethod def _for_tz(cls, d: _datetime, tz: ZoneInfo) -> RepeatedTime: return cls( f"{d.replace(tzinfo=None)} is repeated " f"in timezone {tz.key!r}" ) @classmethod def _for_system_tz(cls, d: _datetime) -> RepeatedTime: return cls( f"{d.replace(tzinfo=None)} is repeated in the system timezone" ) class SkippedTime(ValueError): """A datetime is skipped in a timezone, e.g. because of DST""" @classmethod def _for_tz(cls, d: _datetime, tz: ZoneInfo) -> SkippedTime: return cls( f"{d.replace(tzinfo=None)} is skipped " f"in timezone {tz.key!r}" ) @classmethod def _for_system_tz(cls, d: _datetime) -> SkippedTime: return cls( f"{d.replace(tzinfo=None)} is skipped in the system timezone" ) class InvalidOffsetError(ValueError): """A string has an invalid offset for the given zone""" class ImplicitlyIgnoringDST(TypeError): """A calculation was performed that implicitly ignored DST""" class TimeZoneNotFoundError(ValueError): """A timezone with the given ID was not found""" @classmethod def for_key(cls, key: str) -> TimeZoneNotFoundError: return cls(f"No time zone found for key: {key!r}") _IGNORE_DST_SUGGESTION = ( "To perform DST-safe operations, convert to a ZonedDateTime first. " "Or, if you don't know the timezone and accept potentially incorrect results " "during DST transitions, pass `ignore_dst=True`. For more information, see " "whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic" ) SHIFT_LOCAL_MSG = ( "Adding or subtracting a (date)time delta to a datetime without timezone " "implicitly ignores DST transitions and other timezone " "changes. Use the `add` or `subtract` method instead." ) DIFF_OPERATOR_LOCAL_MSG = ( "The difference between two datetimes without timezone implicitly ignores " "DST transitions and other timezone changes. " "Use the `difference` method instead." ) DIFF_LOCAL_MSG = ( "The difference between two datetimes without timezone implicitly ignores " "DST transitions and other timezone changes. " + _IGNORE_DST_SUGGESTION ) TIMESTAMP_DST_MSG = ( "Converting from a timestamp with a fixed offset implicitly ignores DST " "and other timezone changes. To perform a DST-safe conversion, use " "ZonedDateTime.from_timestamp() instead. " "Or, if you don't know the timezone and accept potentially incorrect results " "during DST transitions, pass `ignore_dst=True`. For more information, see " "whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic" ) OFFSET_NOW_DST_MSG = ( "Getting the current time with a fixed offset implicitly ignores DST " "and other timezone changes. Instead, use `Instant.now()` or " "`ZonedDateTime.now(<tz name>)` if you know the timezone. " "Or, if you want to ignore DST and accept potentially incorrect offsets, " "pass `ignore_dst=True` to this method. For more information, see " "whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic" ) OFFSET_ROUNDING_DST_MSG = ( "Rounding a fixed offset datetime may (in rare cases) result in a datetime " "for which the offset is incorrect. This is because the offset may change " "during DST transitions. To perform DST-safe rounding, convert to a " "ZonedDateTime first. Or, if you don't know the timezone and accept " "potentially incorrect results during DST transitions, pass `ignore_dst=True`. " "For more information, see whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic" ) ADJUST_OFFSET_DATETIME_MSG = ( "Adjusting a fixed offset datetime implicitly ignores DST and other timezone changes. " + _IGNORE_DST_SUGGESTION ) ADJUST_LOCAL_DATETIME_MSG = ( "Adjusting a datetime without timezone by time units (e.g. hours and minutess) ignores " "DST and other timezone changes. " + _IGNORE_DST_SUGGESTION ) CANNOT_ROUND_DAY_MSG = ( "Cannot round to day, because days do not have a fixed length. " "Due to daylight saving time, some days have 23 or 25 hours." "If you wish to round to exaxtly 24 hours, use `round('hour', increment=24)`." ) ZONEINFO_NO_KEY_MSG = """\ The 'key' attribute of the datetime's ZoneInfo object is None. A ZonedDateTime requires a full IANA timezone ID (e.g., 'Europe/Paris') \ to be created. This error typically means the ZoneInfo object was loaded from \ a file without its 'key' parameter being specified. To fix this, provide the correct IANA ID when you create the ZoneInfo object. \ If the ID is truly unknown, you can use OffsetDateTime.from_py_datetime() as \ an alternative, but be aware this is a lossy conversion that only preserves \ the current UTC offset and discards future daylight saving rules. \ Please note that a timezone abbreviation like 'CEST' from tzinfo.tzname() \ is not a valid IANA ID and cannot be used here.""" def _resolve_ambiguity( dt: _datetime, zone: ZoneInfo, disambiguate: Disambiguate | _timedelta ) -> _datetime: if isinstance(disambiguate, _timedelta): return _resolve_ambiguity_using_prev_offset(dt, disambiguate) dt = dt.replace(fold=_as_fold(disambiguate)) dt_utc = dt.astimezone(_UTC) # Non-existent times: they don't survive a UTC roundtrip if dt_utc.astimezone(zone) != dt: if disambiguate == "raise": raise SkippedTime._for_tz(dt, zone) elif disambiguate != "compatible": # i.e. "earlier" or "later" # In gaps, the relationship between # fold and earlier/later is reversed dt = dt.replace(fold=not dt.fold) # Perform the normalisation, shifting away from non-existent times dt = dt.astimezone(_UTC).astimezone(zone) # Ambiguous times: they're never equal to other timezones elif disambiguate == "raise" and dt_utc != dt: raise RepeatedTime._for_tz(dt, zone) return dt def _resolve_ambiguity_using_prev_offset( dt: _datetime, prev_offset: _timedelta, ) -> _datetime: if prev_offset == dt.utcoffset(): pass elif prev_offset == dt.replace(fold=not dt.fold).utcoffset(): dt = dt.replace(fold=not dt.fold) else: # No offset match. Setting fold=0 adopts the 'compatible' strategy dt = dt.replace(fold=0) # This roundtrip ensures skipped times are shifted return dt.astimezone(_UTC).astimezone(dt.tzinfo) # Whether the fold of a system time needs to be flipped in a gap # was changed (fixed) in Python 3.12. See cpython/issues/83861 _requires_flip: Callable[[Disambiguate], bool] = ( "compatible".__ne__ if _PY312 else "compatible".__eq__ ) # FUTURE: document that this isn't threadsafe (system tz may change) def _resolve_system_ambiguity( dt: _datetime, disambiguate: Disambiguate | _timedelta ) -> _datetime: assert dt.tzinfo is None if isinstance(disambiguate, _timedelta): return _resolve_system_ambiguity_using_prev_offset(dt, disambiguate) dt = dt.replace(fold=_as_fold(disambiguate)) norm = dt.astimezone(_UTC).astimezone() # going through UTC resolves gaps # Non-existent times: they don't survive a UTC roundtrip if norm.replace(tzinfo=None) != dt: if disambiguate == "raise": raise SkippedTime._for_system_tz(dt) elif _requires_flip(disambiguate): dt = dt.replace(fold=not dt.fold) # perform the normalisation, shifting away from non-existent times norm = dt.astimezone(_UTC).astimezone() # Ambiguous times: their UTC depends on the fold elif disambiguate == "raise" and norm != dt.replace(fold=1).astimezone( _UTC ): raise RepeatedTime._for_system_tz(dt) return norm def _resolve_system_ambiguity_using_prev_offset( dt: _datetime, prev_offset: _timedelta ) -> _datetime: if dt.astimezone(_UTC).astimezone().utcoffset() == prev_offset: pass elif ( dt.replace(fold=not dt.fold).astimezone(_UTC).astimezone().utcoffset() == prev_offset ): dt = dt.replace(fold=not dt.fold) else: # rare: no offset match. # We account for this CPython bug: cpython/issues/83861 if ( not _PY312 # i.e. it's in a gap and dt.astimezone(_UTC).astimezone().replace(tzinfo=None) != dt ): # pragma: no cover dt = dt.replace(fold=not dt.fold) else: dt = dt.replace(fold=0) return dt.astimezone(_UTC).astimezone() def _load_offset(offset: int | TimeDelta, /) -> _timezone: if isinstance(offset, int): return _timezone(_timedelta(hours=offset)) elif isinstance(offset, TimeDelta): if offset._total_ns % 1_000_000_000: raise ValueError("Offset must be a whole number of seconds") return _timezone(offset.py_timedelta()) else: raise TypeError( "offset must be an int or TimeDelta, e.g. `hours(2.5)`" ) # Helpers that pre-compute/lookup as much as possible _no_tzinfo_fold_or_ms = {"tzinfo", "fold", "microsecond"}.isdisjoint _fromtimestamp = _datetime.fromtimestamp def _parse_err(s: str) -> NoReturn: raise ValueError(f"Invalid format: {s!r}") from None def _parse_nanos(s: str) -> _Nanos: if len(s) > 9 or not s.isdigit() or not s.isascii(): raise ValueError("Invalid decimals") return int(s.ljust(9, "0")) def _split_nextchar( s: str, chars: str, start: int = 0, end: int = -1 ) -> tuple[str, str | None, str]: for c in chars: if (idx := s.find(c, start, end)) != -1: return (s[:idx], c, s[idx + 1 :]) return (s, None, "") _is_sep = " Tt".__contains__ def _offset_from_iso(s: str) -> _timedelta: if len(s) == 5 and s[2] == ":" and s[3] < "6": # most common: HH:MM return _timedelta(hours=int(s[:2]), minutes=int(s[3:])) elif len(s) == 4 and s[2] < "6": # HHMM return _timedelta(hours=int(s[:2]), minutes=int(s[2:])) elif len(s) == 2: # HH return _timedelta(hours=int(s)) elif ( len(s) == 8 and s[2] == ":" and s[5] == ":" and s[3] < "6" and s[6] < "6" ): # HH:MM:SS return _timedelta( hours=int(s[:2]), minutes=int(s[3:5]), seconds=int(s[6:]) ) elif len(s) == 6: # HHMMSS return _timedelta( hours=int(s[:2]), minutes=int(s[2:4]), seconds=int(s[4:]) ) else: raise ValueError("Invalid offset format") def _datetime_from_iso(s: str) -> tuple[_datetime, _Nanos]: if len(s) < 11 or "W" in s or not s.isascii(): _parse_err(s) # OPTIMIZE: the happy path can be faster try: if _is_sep(s[10]): # date in extended format rest, date = s[11:], _date.fromisoformat(s[:10]) elif _is_sep(s[8]): # date in basic format rest, date = s[9:], __date_from_iso_basic(s[:8]) else: _parse_err(s) time, nanos = _time_from_iso(rest) except ValueError: _parse_err(s) return _datetime.combine(date, time), nanos def _offset_dt_from_iso(s: str) -> tuple[_datetime, _Nanos]: if len(s) < 11 or "W" in s[:11] or not s.isascii(): _parse_err(s) try: if _is_sep(s[10]): # date in extended format rest, date = s[11:], _date.fromisoformat(s[:10]) elif _is_sep(s[8]): # date in basic format rest, date = s[9:], __date_from_iso_basic(s[:8]) else: _parse_err(s) time, nanos, offset, _ = _time_offset_tz_from_iso(rest) if offset is None: raise ValueError("Missing offset") elif offset == "Z": tzinfo = _UTC else: assert isinstance(offset, _timedelta) tzinfo = _timezone(offset) return ( _check_utc_bounds(_datetime.combine(date, time, tzinfo)), nanos, ) except ValueError: _parse_err(s) def _zdt_from_iso(s: str) -> tuple[_datetime, _Nanos]: if len(s) < 11 or "W" in s[:11] or not s.isascii(): _parse_err(s) try: if _is_sep(s[10]): # date in extended format rest, date = s[11:], _date.fromisoformat(s[:10]) elif _is_sep(s[8]): # date in basic format rest, date = s[9:], __date_from_iso_basic(s[:8]) else: _parse_err(s) time, nanos, offset, tzid = _time_offset_tz_from_iso(rest) except ValueError: _parse_err(s) if tzid is None: _parse_err(s) tz = _get_tz(tzid) if offset is None: dt = _resolve_ambiguity( _datetime.combine(date, time, tz), tz, "compatible", ) elif offset == "Z": dt = _datetime.combine(date, time, _UTC).astimezone(tz) else: assert isinstance(offset, _timedelta) try: _timezone(offset) # check if offset is <24 hours except ValueError: _parse_err(s) dt = _datetime.combine(date, time, tz) # detect a gap dt_norm = dt.astimezone(_UTC).astimezone(tz) if dt_norm != dt: raise InvalidOffsetError() elif dt.utcoffset() != offset: dt = dt.replace(fold=1) if dt.utcoffset() != offset: raise InvalidOffsetError() return (dt, nanos) def _time_from_iso(s_orig: str) -> tuple[_time, _Nanos]: s, sep, nanos_raw = _split_nextchar(s_orig, ".,", 6, 9) try: return ( __time_from_iso_nofrac(s), _parse_nanos(nanos_raw) if sep else 0, ) except ValueError: _parse_err(s_orig) # Parse the time, UTC offset, and timezone ID def _time_offset_tz_from_iso( s: str, ) -> tuple[_time, _Nanos, _timedelta | Literal["Z"] | None, _BenignKey | None]: # ditch the bracketted timezone (if present) if s.endswith("]"): # NOTE: sorry for the unicode escape sequences. Literal brackets # break my LSP's indentation detection. \x5b is open bracket ']' s, tz_raw = s[:-1].rsplit("\x5b", 1) tz = _validate_key(tz_raw) else: tz = None # determine the offset offset: Literal["Z"] | _timedelta | None if s.endswith(("Z", "z")): s_time = s[:-1] offset = "Z" else: s_time, sign, s_offset = _split_nextchar(s, "+-") if sign is None: offset = None else: offset = _offset_from_iso(s_offset) if sign == "-": offset = -offset time, nanos = _time_from_iso(s_time) return (time, nanos, offset, tz) def _yearmonth_from_iso(s: str) -> _date: if not s.isascii(): _parse_err(s) try: if len(s) == 7 and s[4] == "-": year, month = int(s[:4]), int(s[5:]) elif len(s) == 6: year, month = int(s[:4]), int(s[4:]) else: _parse_err(s) return _date(year, month, 1) except ValueError: _parse_err(s) def _monthday_from_iso(s: str) -> _date: if not (s.startswith("--") and s.isascii()): _parse_err(s) try: if len(s) == 7 and s[4] == "-": month, day = int(s[2:4]), int(s[5:]) elif len(s) == 6: month, day = int(s[2:4]), int(s[4:]) else: _parse_err(s) return _date(_DUMMY_LEAP_YEAR, month, day) except ValueError: _parse_err(s) # The ISO parsing functions were improved in Python 3.11, # so we use them if available. if _PY311: __date_from_iso_basic = _date.fromisoformat def __time_from_iso_nofrac(s: str) -> _time: # Compensate for a bug in CPython where times like "12:34:56:78" are # accepted as valid times. This is only fixed in Python 3.14+ if s.count(":") > 2: raise ValueError() if all(map("0123456789:".__contains__, s)): return _time.fromisoformat(s) raise ValueError() def _date_from_iso(s: str) -> _date: # prevent isoformat from parsing stuff we don't want it to if "W" in s or not s.isascii(): _parse_err(s) try: return _date.fromisoformat(s) except ValueError: _parse_err(s) else: # pragma: no cover def __date_from_iso_basic(s: str, /) -> _date: return _date.fromisoformat(s[:4] + "-" + s[4:6] + "-" + s[6:8]) def __time_from_iso_nofrac(s: str) -> _time: # Compensate for the fact that Python's isoformat # doesn't support basic ISO 8601 formats if len(s) == 4: s = s[:2] + ":" + s[2:] elif len(s) == 6: s = s[:2] + ":" + s[2:4] + ":" + s[4:] if all(map("0123456789:".__contains__, s)): return _time.fromisoformat(s) raise ValueError() def _date_from_iso(s: str) -> _date: if not s.isascii(): _parse_err(s) try: if len(s) == 8: return __date_from_iso_basic(s) return _date.fromisoformat(s) except ValueError: _parse_err(s) _RFC2822_WEEKDAY_TO_ISO = { "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6, "sun": 7, } _WEEKDAY_TO_RFC2822 = [s.title() for s in _RFC2822_WEEKDAY_TO_ISO] _RFC2822_MONTH_NAMES = { "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12, } _MONTH_TO_RFC2822 = [s.title() for s in _RFC2822_MONTH_NAMES] _MONTH_TO_RFC2822.insert(0, "") # 1-indexed _RFC2822_ZONES = { "EST": -5, "EDT": -4, "CST": -6, "CDT": -5, "MST": -7, "MDT": -6, "PST": -8, "PDT": -7, "UT": 0, "GMT": 0, } def _parse_rfc2822(s: str) -> _datetime: # Technically, only tab, space and CRLF are allowed in RFC2822, # but we allow any ASCII whitespace if not s.isascii(): _parse_err(s) # Parse the weekday try: first, second, *parts = s.split() if first.isdigit(): iso_weekday = None parts = [first, second, *parts] else: # Case: Mon, 23 Jan if len(first) == 4 and first[3] == ",": weekday_raw = first[:3] parts = [second, *parts] # Case: Mon , 23 Jan elif len(first) == 3 and second == ",": weekday_raw = first # Case: Mon ,23 Jan elif len(first) == 3 and second.startswith(","): weekday_raw = first parts = [second[1:], *parts] # Case: Mon,23 Jan elif len(first) > 4 and first[3] == ",": weekday_raw = first[:3] parts = [first[4:], second, *parts] else: _parse_err(s) iso_weekday = _RFC2822_WEEKDAY_TO_ISO[weekday_raw.lower()] except (ValueError, KeyError): _parse_err(s) # Parse the date try: day_raw, month_raw, year_raw, *parts = parts if len(day_raw) > 2: _parse_err(s) day = int(day_raw) month = _RFC2822_MONTH_NAMES[month_raw.lower()] if len(year_raw) == 4: year = int(year_raw) elif len(year_raw) == 2: year = int(year_raw) if year < 50: year += 2000 else: year += 1900 elif len(year_raw) == 3: year = int(year_raw) + 1900 else: _parse_err(s) date = _date(year, month, day) except (ValueError, KeyError): _parse_err(s) if iso_weekday and iso_weekday != date.isoweekday(): _parse_err(s) # Parse the time try: # time components may be separated by whitespace *time_parts, offset_raw = parts time_raw = "".join(time_parts) if len(time_raw) == 5 and time_raw[2] == ":": time = _time(int(time_raw[:2]), int(time_raw[3:])) elif len(time_raw) == 8 and time_raw[2] == ":" and time_raw[5] == ":": time = _time( int(time_raw[:2]), int(time_raw[3:5]), int(time_raw[6:]) ) else: _parse_err(s) except ValueError: _parse_err(s) # Parse the offset try: if offset_raw.startswith(("+", "-")) and len(offset_raw) == 5: sign = 1 if offset_raw[0] == "+" else -1 offset = ( _timedelta( hours=int(offset_raw[1:3]), minutes=int(offset_raw[3:5]) ) * sign ) elif offset_raw.isalpha(): # According to the spec, unknown timezones should # just be treated at -0000 (UTC with unknown offset) offset = _timedelta( hours=_RFC2822_ZONES.get(offset_raw.upper(), 0) ) else: _parse_err(s) tzinfo = _timezone(offset) except ValueError: _parse_err(s) return _check_utc_bounds(_datetime.combine(date, time, tzinfo=tzinfo)) def _check_utc_bounds(dt: _datetime) -> _datetime: try: dt.astimezone(_UTC) except (OverflowError, ValueError): raise ValueError("Instant out of range") return dt def _check_invalid_replace_kwargs(kwargs: Any) -> None: if not _no_tzinfo_fold_or_ms(kwargs): raise TypeError( "tzinfo, fold, or microsecond are not allowed arguments" ) def _pop_nanos_kwarg(kwargs: Any, default: int) -> int: nanos = kwargs.pop("nanosecond", default) if type(nanos) is not int: raise TypeError("nanosecond must be an int") elif not 0 <= nanos < 1_000_000_000: raise ValueError("Invalid nanosecond value") return nanos def _isleap(year: int) -> bool: return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) # 1-indexed days per month _monthdays = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] def _days_in_month(year: int, month: int) -> int: return _monthdays[month] + (month == 2 and _isleap(year)) # Use this to strip any incoming datetime classes down to instances # of the datetime.datetime class exactly. def _strip_subclasses(dt: _datetime) -> _datetime: if type(dt) is _datetime: return dt else: return _datetime( dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond, dt.tzinfo, fold=dt.fold, ) Instant.MIN = Instant._from_py_unchecked( _datetime.min.replace(tzinfo=_UTC), 0, ) Instant.MAX = Instant._from_py_unchecked( _datetime.max.replace(tzinfo=_UTC, microsecond=0), 999_999_999, ) PlainDateTime.MIN = PlainDateTime._from_py_unchecked(_datetime.min, 0) PlainDateTime.MAX = PlainDateTime._from_py_unchecked( _datetime.max.replace(microsecond=0), 999_999_999 ) Disambiguate = Literal["compatible", "earlier", "later", "raise"] Fold = Literal[0, 1] _disambiguate_to_fold: Mapping[str, Fold] = { "compatible": 0, "earlier": 0, "later": 1, "raise": 0, } def _adjust_fold_to_offset(dt: _datetime, offset: _timedelta) -> _datetime: if offset != dt.utcoffset(): # offset/zone mismatch: try other fold dt = dt.replace(fold=1) if dt.utcoffset() != offset: # pragma: no cover (#39) raise InvalidOffsetError() return dt def _as_fold(s: str) -> Fold: try: return _disambiguate_to_fold[s] except KeyError: raise ValueError(f"Invalid disambiguate setting: {s!r}") def years(i: int, /) -> DateDelta: """Create a :class:`~DateDelta` with the given number of years. ``years(1) == DateDelta(years=1)`` """ return DateDelta(years=i) def months(i: int, /) -> DateDelta: """Create a :class:`~DateDelta` with the given number of months. ``months(1) == DateDelta(months=1)`` """ return DateDelta(months=i) def weeks(i: int, /) -> DateDelta: """Create a :class:`~DateDelta` with the given number of weeks. ``weeks(1) == DateDelta(weeks=1)`` """ return DateDelta(weeks=i) def days(i: int, /) -> DateDelta: """Create a :class:`~DateDelta` with the given number of days. ``days(1) == DateDelta(days=1)`` """ return DateDelta(days=i) def hours(i: float, /) -> TimeDelta: """Create a :class:`~TimeDelta` with the given number of hours. ``hours(1) == TimeDelta(hours=1)`` """ return TimeDelta(hours=i) def minutes(i: float, /) -> TimeDelta: """Create a :class:`TimeDelta` with the given number of minutes. ``minutes(1) == TimeDelta(minutes=1)`` """ return TimeDelta(minutes=i) def seconds(i: float, /) -> TimeDelta: """Create a :class:`TimeDelta` with the given number of seconds. ``seconds(1) == TimeDelta(seconds=1)`` """ return TimeDelta(seconds=i) def milliseconds(i: int, /) -> TimeDelta: """Create a :class:`TimeDelta` with the given number of milliseconds. ``milliseconds(1) == TimeDelta(milliseconds=1)`` """ return TimeDelta(milliseconds=i) def microseconds(i: float, /) -> TimeDelta: """Create a :class:`TimeDelta` with the given number of microseconds. ``microseconds(1) == TimeDelta(microseconds=1)`` """ return TimeDelta(microseconds=i) def nanoseconds(i: int, /) -> TimeDelta: """Create a :class:`TimeDelta` with the given number of nanoseconds. ``nanoseconds(1) == TimeDelta(nanoseconds=1)`` """ return TimeDelta(nanoseconds=i) # We expose the public members in the root of the module. # For clarity, we remove the "_pywhenever" part from the names, # since this is an implementation detail. for name in __all__ + "_LocalTime _ExactTime _ExactAndLocalTime".split(): member = locals()[name] if getattr(member, "__module__", None) == __name__: # pragma: no branch member.__module__ = "whenever" # clear up loop variables so they don't leak into the namespace del name del member for _unpkl in ( _unpkl_date, _unpkl_ym, _unpkl_md, _unpkl_time, _unpkl_tdelta, _unpkl_dtdelta, _unpkl_ddelta, _unpkl_utc, _unpkl_offset, _unpkl_zoned, _unpkl_system, _unpkl_local, ): _unpkl.__module__ = "whenever" # disable further subclassing final(_ImmutableBase) final(_ExactTime) final(_LocalTime) final(_ExactAndLocalTime) final(_BasicConversions) def _patch_time_frozen(inst: Instant) -> None: global time_ns def time_ns() -> int: return inst.timestamp_nanos() def _patch_time_keep_ticking(inst: Instant) -> None: global time_ns _patched_at = time_ns() _time_ns = time_ns def time_ns() -> int: return inst.timestamp_nanos() + _time_ns() - _patched_at def _unpatch_time() -> None: global time_ns from time import time_ns _TZPATH: tuple[str, ...] = () # Our cache for loaded tz files. The design is based off zoneinfo. # Why roll our own? To ensure it works independently of zoneinfo, # and thus works identically to the Rust extension. _TZCACHE_LRU_SIZE = 8 _tzcache_lru: OrderedDict[str, ZoneInfo] = OrderedDict() _tzcache_lookup: WeakValueDictionary[str, ZoneInfo] = WeakValueDictionary() def _set_tzpath(to: tuple[str, ...]) -> None: global _TZPATH _TZPATH = to def _clear_tz_cache() -> None: _tzcache_lru.clear() _tzcache_lookup.clear() def _clear_tz_cache_by_keys(keys: tuple[str, ...]) -> None: for k in keys: _tzcache_lookup.pop(k, None) _tzcache_lru.pop(k, None) def _get_tz(key: str) -> ZoneInfo: try: zinfo = _tzcache_lookup[key] except KeyError: zinfo = _tzcache_lookup[key] = _load_tz(_validate_key(key)) # Update the LRU _tzcache_lru[key] = _tzcache_lru.pop(key, zinfo) if len(_tzcache_lru) > _TZCACHE_LRU_SIZE: _tzcache_lru.popitem(last=False) return zinfo def _validate_key(key: str) -> _BenignKey: """Checks for invalid characters and path traversal in the key.""" if ( key.isascii() # There's no standard limit on IANA tz IDs, but we have to draw # the line somewhere to prevent abuse. and 0 < len(key) < 100 and all(b.isalnum() or b in "-_+/." for b in key) # specific sequences not allowed and ".." not in key and "//" not in key and "/./" not in key # specic restrictions on the first and list characters and key[0] not in "-+/" and key[-1] != "/" ): return _BenignKey(key) else: raise TimeZoneNotFoundError.for_key(key) # Alias for a TZ key that has been confirmed not to be a path traversal # or contain other "bad" characters. _BenignKey = NewType("_BenignKey", str) def _try_tzif_from_path(key: _BenignKey) -> bytes | None: for search_path in _TZPATH: target = os.path.join(search_path, key) if os.path.isfile(target): with open(target, "rb") as f: return f.read() return None def _tzif_from_tzdata(key: _BenignKey) -> bytes: try: tzdata_path = __import__("tzdata.zoneinfo").zoneinfo.__path__[0] # We check before we read, since the resulting exceptions vary # on different platforms if os.path.isfile( relpath := os.path.join(tzdata_path, *key.split("/")) ): with open(relpath, "rb") as f: return f.read() else: raise FileNotFoundError() # Several exceptions amount to "can't find the key" except ( ImportError, FileNotFoundError, UnicodeEncodeError, ): raise TimeZoneNotFoundError.for_key(key) def _load_tz(key: _BenignKey) -> ZoneInfo: from zoneinfo import ZoneInfo # Reminder: we load manually from files to ensure we operate # independently of zoneinfo's own caching mechanism tzif = _try_tzif_from_path(key) or _tzif_from_tzdata(key) if not tzif.startswith(b"TZif"): # We've found a file, but doesn't look like a TZif file. # Stop here instead of getting a cryptic error later. raise TimeZoneNotFoundError.for_key(key) return ZoneInfo.from_file(BytesIO(tzif), key)