"""The main pure-Python implementation of the whenever library."""
# Maintainer's notes:
#
# - Why is most stuff in one file?
# - Flat is better than nested
# - It prevents circular imports since the classes 'know' about each other
# - 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
from __future__ import annotations
from datetime import (
date as _date,
datetime as _datetime,
time as _time,
timedelta as _timedelta,
timezone as _timezone,
)
from math import fmod
from struct import pack, unpack
from time import time_ns
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Literal,
Sequence,
TypeVar,
cast,
no_type_check,
overload,
)
from warnings import warn
from . import _ideltas
from ._common import (
OFFSET_SHIFT_STALE_MSG,
PLAIN_SHIFT_UNAWARE_MSG,
SPHINX_RUNNING,
UNSET,
WARNING_HANDLING_DOCS_MSG,
WheneverDeprecationWarning,
WheneverWarning,
_Base,
add_alternate_constructors,
check_utc_bounds,
final,
mk_fixed_tzinfo,
)
from ._format import (
compile_pattern,
format_fields,
parse_fields,
validate_fields,
)
from ._math import (
DATE_DELTA_UNITS,
DELTA_UNITS,
DIFF_FUNCS,
EXACT_UNITS_STRICT,
NS_PER_UNIT_PLURAL,
Sign,
custom_round,
date_diff,
days_in_month,
increment_to_ns_for_datetime,
increment_to_ns_for_delta,
is_leap,
resolve_leap_day,
)
from ._parse import (
MONTH_TO_RFC2822,
WEEKDAY_TO_RFC2822,
InvalidOffsetError,
date_from_iso,
datetime_from_iso,
offset_dt_from_iso,
parse_rfc2822,
parse_timedelta_component,
time_from_iso,
zdt_from_iso,
)
from ._shared import (
IsoWeekDate,
MonthDay,
Weekday,
YearMonth,
_nth_weekday_of_month,
)
from ._typing import (
DateDeltaUnitStr,
DeltaUnitStr,
DisambiguateStr,
ExactDeltaUnitStr,
OffsetMismatchStr,
RoundModeStr,
)
from ._tz import ( # noqa: F401
Fold,
Gap,
RepeatedTime,
SkippedTime,
TimeZone,
TimeZoneNotFoundError,
Unambiguous,
_clear_tz_cache as _clear_tz_cache,
_clear_tz_cache_by_keys as _clear_tz_cache_by_keys,
_get_tzpath as _get_tzpath,
_set_tzpath as _set_tzpath,
get_system_tz,
get_tz,
reset_system_tz,
resolve_ambiguity,
resolve_ambiguity_using_prev_offset,
)
CalendarUnitCompositionWarning = _ideltas.CalendarUnitCompositionWarning
__all__ = (
# Date and time
"Date",
"YearMonth",
"MonthDay",
"IsoWeekDate",
"Time",
"Instant",
"OffsetDateTime",
"ZonedDateTime",
"PlainDateTime",
# Deltas and time units
"DateDelta",
"TimeDelta",
"DateTimeDelta",
"ItemizedDelta",
"ItemizedDateDelta",
"years",
"months",
"weeks",
"days",
"hours",
"minutes",
"seconds",
"milliseconds",
"microseconds",
"nanoseconds",
# Exceptions/warnings
"DaysAssumed24HoursWarning",
"StaleOffsetWarning",
"NaiveArithmeticWarning",
"CalendarUnitCompositionWarning",
"WheneverWarning",
"PotentialDstBugWarning",
"WheneverDeprecationWarning",
"SkippedTime",
"RepeatedTime",
"InvalidOffsetError",
"ImplicitlyIgnoringDST",
"TimeZoneNotFoundError",
# Other
"reset_system_tz",
"_unpkl_date",
"_unpkl_ddelta",
"_unpkl_dtdelta",
"_unpkl_iddelta",
"_unpkl_idelta",
"_unpkl_inst",
"_unpkl_local",
"_unpkl_offset",
"_unpkl_tdelta",
"_unpkl_time",
"_unpkl_utc",
"_unpkl_zoned",
)
# 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_HOURS = _MAX_DELTA_DAYS * 24
_MAX_DELTA_MINUTES = _MAX_DELTA_HOURS * 60
_MAX_DELTA_SECONDS = _MAX_DELTA_MINUTES * 60
_MAX_DELTA_NANOS = _MAX_DELTA_SECONDS * 1_000_000_000
_MAX_SUBSEC_NANOS = 999_999_999
_Nanos = int # type alias for subsecond nanoseconds
_T = TypeVar("_T")
def _time_units_to_nanos(
sign: int,
hours: float,
minutes: float,
seconds: float,
milliseconds: float,
microseconds: float,
nanoseconds: int,
) -> int:
delta_ns = sign * (
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(delta_ns) > _MAX_DELTA_NANOS:
raise ValueError("TimeDelta out of range")
return delta_ns
_UNITS_FOR_START_END_OF = (
"year",
"month",
"week_mon",
"week_sun",
"day",
"hour",
"minute",
"second",
)
def _start_of_dt(dt: _datetime, unit: str) -> _datetime:
if unit == "year":
return dt.replace(month=1, day=1, hour=0, minute=0, second=0)
elif unit == "month":
return dt.replace(day=1, hour=0, minute=0, second=0)
elif unit == "week_mon":
days_back = dt.isoweekday() - 1
d = dt - _timedelta(days=days_back)
return d.replace(hour=0, minute=0, second=0)
elif unit == "week_sun":
days_back = dt.isoweekday() % 7
d = dt - _timedelta(days=days_back)
return d.replace(hour=0, minute=0, second=0)
elif unit == "day":
return dt.replace(hour=0, minute=0, second=0)
elif unit == "hour":
return dt.replace(minute=0, second=0)
elif unit == "minute":
return dt.replace(second=0)
elif unit == "second":
return dt
elif unit == "week":
raise ValueError(
"unit 'week' is ambiguous. Use 'week_mon' or 'week_sun' instead."
)
else:
raise ValueError(
f"Invalid unit: {unit!r}. "
f"Valid units: {', '.join(map(repr, _UNITS_FOR_START_END_OF))}"
)
def _end_of_dt(dt: _datetime, unit: str) -> _datetime:
if unit == "year":
return dt.replace(month=12, day=31, hour=23, minute=59, second=59)
elif unit == "month":
return dt.replace(
day=days_in_month(dt.year, dt.month),
hour=23,
minute=59,
second=59,
)
elif unit == "week_mon":
days_fwd = 7 - dt.isoweekday()
d = dt + _timedelta(days=days_fwd)
return d.replace(hour=23, minute=59, second=59)
elif unit == "week_sun":
days_fwd = (6 - dt.isoweekday()) % 7
d = dt + _timedelta(days=days_fwd)
return d.replace(hour=23, minute=59, second=59)
elif unit == "day":
return dt.replace(hour=23, minute=59, second=59)
elif unit == "hour":
return dt.replace(minute=59, second=59)
elif unit == "minute":
return dt.replace(second=59)
elif unit == "second":
return dt
elif unit == "week":
raise ValueError(
"unit 'week' is ambiguous. Use 'week_mon' or 'week_sun' instead."
)
else:
raise ValueError(
f"Invalid unit: {unit!r}. "
f"Valid units: {', '.join(map(repr, _UNITS_FOR_START_END_OF))}"
)
def _start_of_next_dt(dt: _datetime, unit: str) -> _datetime:
if unit == "year":
return dt.replace(
year=dt.year + 1,
month=1,
day=1,
hour=0,
minute=0,
second=0,
)
elif unit == "month":
year, month = divmod(dt.month, 12)
return dt.replace(
year=dt.year + year,
month=month + 1,
day=1,
hour=0,
minute=0,
second=0,
)
elif unit == "week_mon":
days_fwd = 8 - dt.isoweekday()
d = dt + _timedelta(days=days_fwd)
return d.replace(hour=0, minute=0, second=0)
elif unit == "week_sun":
days_fwd = 7 - dt.isoweekday() % 7
d = dt + _timedelta(days=days_fwd)
return d.replace(hour=0, minute=0, second=0)
else:
assert unit == "day"
# OPTIMIZE: compute days(1), hours(1) etc. as singletons
return (dt + _timedelta(days=1)).replace(hour=0, minute=0, second=0)
[docs]
@final
class Date(_Base):
"""A date without a time component.
>>> d = Date(2021, 1, 2)
Date("2021-01-02")
Can also be constructed from an ISO 8601 string
or a standard library :class:`~datetime.date`:
>>> Date("2021-01-02")
Date("2021-01-02")
>>> Date(date(2021, 1, 2))
Date("2021-01-02")
Dates support arithmetic with :class:`~whenever.ItemizedDateDelta`:
>>> delta = Date("2021-02-28").since(Date("1994-05-15"), in_units=["years", "days"])
ItemizedDateDelta("P26y289d")
>>> Date("1994-05-15").add(delta)
Date("2021-02-28")
Dates can be compared and sorted:
>>> Date(2021, 1, 2) > Date(2021, 1, 1)
True
"""
__slots__ = ("_py_date",)
MIN: ClassVar[Date]
"""The minimum possible date"""
MAX: ClassVar[Date]
"""The maximum possible date"""
# Overloads for a nice autodoc.
# Proper typing of the constructors is handled in the type stubs
if not TYPE_CHECKING:
@overload
def __init__(self, iso_string: str, /) -> None: ...
@overload
def __init__(self, py_date: _date, /) -> None: ...
@overload
def __init__(self, year: int, month: int, day: int) -> None: ...
def __init__(self, year: int, month: int, day: int) -> None:
self._py_date = _date(year, month, day)
__init__ = add_alternate_constructors(__init__, py_type=_date)
[docs]
@classmethod
def today_in_system_tz(cls) -> Date:
"""Get the current date in the system's local timezone.
Alias for ``Instant.now().to_system_tz().date()``.
>>> Date.today_in_system_tz()
Date("2021-01-02")
"""
# Use now() so this function gets patched like the other now functions
return Instant.now().to_system_tz().date()
@property
def year(self) -> int:
"""The year component of the date
>>> Date(2021, 1, 2).year
2021
"""
return self._py_date.year
@property
def month(self) -> int:
"""The month component of the date
>>> Date(2021, 1, 2).month
1
"""
return self._py_date.month
@property
def day(self) -> int:
"""The day component of the date
>>> Date(2021, 1, 2).day
2
"""
return self._py_date.day
[docs]
def year_month(self) -> YearMonth:
"""The year and month (without a day component)
>>> 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)
>>> 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
>>> Date(2021, 1, 2).day_of_week()
Weekday.SATURDAY
>>> Weekday.SATURDAY.value
6 # the ISO value
"""
return Weekday(self._py_date.isoweekday())
[docs]
def iso_week_date(self) -> IsoWeekDate:
"""The ISO week date for this date
>>> Date(2024, 12, 30).iso_week_date()
IsoWeekDate("2025-W01-1")
"""
y, w, d = self._py_date.isocalendar()
return IsoWeekDate._from_parts_unchecked(y, w, Weekday(d))
[docs]
def day_of_year(self) -> int:
"""Ordinal day in the year (1--366)
>>> Date(2021, 1, 2).day_of_year()
2
>>> Date(2021, 12, 31).day_of_year()
365
"""
return self._py_date.timetuple().tm_yday
[docs]
def days_in_month(self) -> int:
"""Number of days in the current month (28--31)
>>> Date(2024, 2, 1).days_in_month()
29
>>> Date(2023, 2, 1).days_in_month()
28
"""
return days_in_month(self._py_date.year, self._py_date.month)
[docs]
def days_in_year(self) -> int:
"""Number of days in the current year (365 or 366)
>>> Date(2024, 1, 1).days_in_year()
366
>>> Date(2023, 1, 1).days_in_year()
365
"""
return 366 if is_leap(self._py_date.year) else 365
[docs]
def in_leap_year(self) -> bool:
"""Whether this date's year is a leap year
>>> Date(2024, 1, 1).in_leap_year()
True
>>> Date(2023, 1, 1).in_leap_year()
False
"""
return is_leap(self._py_date.year)
[docs]
def next_day(self) -> Date:
"""The date immediately following
>>> Date(2021, 1, 2).next_day()
Date("2021-01-03")
"""
return Date._from_py_unchecked(self._py_date + _timedelta(days=1))
[docs]
def prev_day(self) -> Date:
"""The date immediately preceding
>>> Date(2021, 1, 2).prev_day()
Date("2021-01-01")
"""
return Date._from_py_unchecked(self._py_date - _timedelta(days=1))
[docs]
def start_of(
self, unit: Literal["year", "month", "week_mon", "week_sun"], /
) -> Date:
"""The start of the given calendar unit
>>> Date(2024, 8, 15).start_of("year")
Date("2024-01-01")
>>> Date(2024, 8, 15).start_of("month")
Date("2024-08-01")
>>> Date(2024, 8, 15).start_of("week_mon")
Date("2024-08-12")
"""
if unit == "year":
return Date._from_py_unchecked(
self._py_date.replace(month=1, day=1)
)
elif unit == "month":
return Date._from_py_unchecked(self._py_date.replace(day=1))
elif unit == "week_mon":
days_back = self._py_date.isoweekday() - 1
return Date._from_py_unchecked(
self._py_date - _timedelta(days=days_back)
)
elif unit == "week_sun":
days_back = self._py_date.isoweekday() % 7
return Date._from_py_unchecked(
self._py_date - _timedelta(days=days_back)
)
elif unit == "week":
raise ValueError(
"unit 'week' is ambiguous. "
"Use 'week_mon' or 'week_sun' instead."
)
else:
raise ValueError(f"Invalid value for unit: {unit!r}")
[docs]
def end_of(
self, unit: Literal["year", "month", "week_mon", "week_sun"], /
) -> Date:
"""The end of the given calendar unit
>>> Date(2024, 8, 15).end_of("year")
Date("2024-12-31")
>>> Date(2024, 8, 15).end_of("month")
Date("2024-08-31")
>>> Date(2024, 8, 15).end_of("week_mon")
Date("2024-08-18")
See also :meth:`start_of`
"""
if unit == "year":
return Date._from_py_unchecked(
self._py_date.replace(month=12, day=31)
)
elif unit == "month":
return Date._from_py_unchecked(
self._py_date.replace(
day=days_in_month(self._py_date.year, self._py_date.month)
)
)
elif unit == "week_mon":
days_fwd = 7 - self._py_date.isoweekday()
return Date._from_py_unchecked(
self._py_date + _timedelta(days=days_fwd)
)
elif unit == "week_sun":
days_fwd = (6 - self._py_date.isoweekday()) % 7
return Date._from_py_unchecked(
self._py_date + _timedelta(days=days_fwd)
)
elif unit == "week":
raise ValueError(
"unit 'week' is ambiguous. "
"Use 'week_mon' or 'week_sun' instead."
)
else:
raise ValueError(f"Invalid value for unit: {unit!r}")
[docs]
def nth_weekday_of_month(self, n: int, weekday: Weekday, /) -> Date:
"""The n-th occurrence of a weekday in this date's month.
Negative ``n`` counts from the end.
``n=0`` raises :class:`ValueError`.
>>> Date(2024, 8, 1).nth_weekday_of_month(2, Weekday.FRIDAY)
Date("2024-08-09")
>>> Date(2024, 8, 1).nth_weekday_of_month(-1, Weekday.FRIDAY)
Date("2024-08-30")
"""
if n == 0:
raise ValueError("n must not be 0")
if not isinstance(weekday, Weekday):
raise TypeError("weekday must be a Weekday enum member")
if not (-5 <= n <= 5):
raise ValueError("n must be between -5 and 5")
year, month = self._py_date.year, self._py_date.month
day = _nth_weekday_of_month(year, month, n, weekday.value)
return Date._from_py_unchecked(_date(year, month, day))
[docs]
def nth_weekday(self, n: int, weekday: Weekday, /) -> Date:
"""The n-th occurrence of a weekday from this date (exclusive).
Negative ``n`` searches backward.
``n=0`` raises :class:`ValueError`.
>>> Date(2024, 8, 1).nth_weekday(1, Weekday.FRIDAY)
Date("2024-08-02")
>>> Date(2024, 8, 1).nth_weekday(-1, Weekday.WEDNESDAY)
Date("2024-07-31")
"""
if n == 0:
raise ValueError("n must not be 0")
if not isinstance(weekday, Weekday):
raise TypeError("weekday must be a Weekday enum member")
if not (-521_722 <= n <= 521_722):
raise ValueError("n out of range")
target_dow = weekday.value
self_dow = self._py_date.isoweekday()
if n > 0:
offset = (target_dow - self_dow) % 7
if offset == 0:
offset = 7
delta = offset + (n - 1) * 7
else:
offset = (self_dow - target_dow) % 7
if offset == 0:
offset = 7
delta = -(offset + (-n - 1) * 7)
return Date._from_py_unchecked(self._py_date + _timedelta(days=delta))
[docs]
def at(self, t: Time, /) -> PlainDateTime:
"""Combine a date with a time to create a datetime
>>> 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), t._nanos
)
[docs]
def to_stdlib(self) -> _date:
"""Convert to a standard library :class:`~datetime.date`"""
return self._py_date
[docs]
def py_date(self) -> _date:
"""Convert to a standard library :class:`~datetime.date`
.. deprecated:: 0.10.0
Use :meth:`to_stdlib` instead.
"""
warn(
"py_date() is deprecated and will be removed in a future release; "
"use to_stdlib() instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return self.to_stdlib()
[docs]
@classmethod
def from_py_date(cls, d: _date, /) -> Date:
"""Create from a :class:`~datetime.date`
>>> Date.from_py_date(date(2021, 1, 2))
Date("2021-01-02")
.. deprecated:: 0.10.0
Use the constructor ``Date(d)`` instead.
"""
warn(
"from_py_date() is deprecated and will be removed in a future release; "
"use Date() instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
self = _object_new(cls)
self._init_from_py(d)
return self
def _init_from_py(self, d: _date) -> None:
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: # pragma: no cover
raise TypeError(f"Expected date, got {type(d)!r}")
self._py_date = d
[docs]
@classmethod
def parse_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_iso`
>>> Date.parse_iso("2021-01-02")
Date("2021-01-02")
"""
return cls._from_py_unchecked(date_from_iso(s))
def _init_from_iso(self, s: str) -> None:
self._py_date = date_from_iso(s)
_PATTERN_CATS = frozenset({"date"})
[docs]
@classmethod
def parse(cls, s: str, /, *, format: str) -> Date:
"""Parse a date from a custom pattern string.
See :ref:`pattern-format` for details.
>>> Date.parse("2024/03/15", format="YYYY/MM/DD")
Date("2024-03-15")
>>> Date.parse("15 Mar 2024", format="DD MMM YYYY")
Date("2024-03-15")
"""
elements = compile_pattern(format)
validate_fields(elements, cls._PATTERN_CATS, "Date")
state = parse_fields(elements, s)
if state.year is None or state.month is None or state.day is None:
raise ValueError(
"Pattern must include year (YYYY/YY), "
"month (MM/MMM/MMMM), and day (DD) fields"
)
result = cls(state.year, state.month, state.day)
if (
state.weekday is not None
and result._py_date.weekday() != state.weekday
):
raise ValueError("Parsed weekday does not match the date")
return result
if not TYPE_CHECKING: # for a nice autodoc
@overload
def replace(
self, year: int = ..., month: int = ..., day: int = ...
) -> Date: ...
[docs]
def replace(self, **kwargs: Any) -> Date:
"""Create a new instance with the given fields replaced
>>> d = Date(2021, 1, 2)
>>> d.replace(day=4)
Date("2021-01-04")
"""
return Date._from_py_unchecked(self._py_date.replace(**kwargs))
@overload
def add(self, delta: ItemizedDateDelta | DateDelta, /) -> Date: ...
@overload
def add(
self,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
) -> Date: ...
[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.
>>> 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)
@overload
def subtract(self, delta: ItemizedDateDelta | DateDelta, /) -> Date: ...
@overload
def subtract(
self,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
) -> Date: ...
[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.
>>> 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: ItemizedDateDelta | DateDelta = UNSET,
/,
**kwargs,
) -> Date:
if kwargs:
if delta is not UNSET:
raise TypeError(
"Cannot combine positional and keyword arguments"
)
elif delta is not UNSET:
if isinstance(delta, ItemizedDateDelta):
kwargs = delta
else:
assert isinstance(delta, DateDelta)
kwargs = {"months": delta._months, "days": delta._days}
else: # no arguments, just return self
return self
return self._shift_kwargs(sign, **kwargs)
def _shift_kwargs(
self,
sign: int,
years: int = 0,
months: int = 0,
weeks: int = 0,
days: int = 0,
) -> Date:
return Date._from_py_unchecked(
self._add_months(sign * (years * 12 + months))._py_date
+ _timedelta(weeks * 7 + days) * sign
)
[docs]
def days_since(self, other: Date, /) -> int:
"""Calculate the number of days this day is after another date.
.. deprecated:: 0.10.0
Use :meth:`since` with `unit="days"` instead.
"""
warn(
"days_since() is deprecated and will be removed in a future release; "
"use since() with total='days' instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return (self._py_date - other._py_date).days
[docs]
def days_until(self, other: Date, /) -> int:
"""Calculate the number of days from this date to another date.
.. deprecated:: 0.10.0
Use :meth:`until` with `unit="days"` instead.
"""
warn(
"days_until() is deprecated and will be removed in a future release; "
"use until() with total='days' instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return (other._py_date - self._py_date).days
@overload
def since(
self,
b: Date,
/,
*,
total: DateDeltaUnitStr,
) -> float: ...
@overload
def since(
self,
b: Date,
/,
*,
in_units: Sequence[DateDeltaUnitStr],
round_mode: RoundModeStr = "trunc",
round_increment: int = ...,
) -> ItemizedDateDelta: ...
[docs]
def since(
self,
b: Date,
/,
*,
total: DateDeltaUnitStr = UNSET,
in_units: Sequence[DateDeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
) -> ItemizedDateDelta | float:
"""Calculate the difference between this date and another date.
The difference is calculated in terms of the chosen calendar unit
or units.
>>> d = Date(2023, 4, 15)
>>> d.since(Date("2020-01-01"), in_units=["years", "months"])
ItemizedDateDelta("P3y3m")
>>> d.since(Date("2020-01-01"), total="weeks")
170.0
Parameters
----------
other
The date to calculate the difference since.
total
If specified, the difference is returned as a float in terms
of this single unit. Cannot be combined with ``in_units``.
The fractional part is based on the number of days in the
surrounding calendar period — not a fixed conversion factor.
For example, 6 months from January 1 spans 181 days of a
365-day year, giving approximately 0.496 years, not 0.5.
in_units
If specified, the difference is calculated in terms of these units,
in decreasing order of size. Cannot be combined with ``total``.
round_mode
The rounding mode to apply to the smallest specified unit.
Only valid with ``in_units``.
round_increment
The increment to round to for the smallest specified unit.
Only valid with ``in_units``.
Returns
-------
ItemizedDateDelta | float
If ``in_units`` is specified, the difference is returned
as an :class:`ItemizedDateDelta`,
If ``total`` is specified, as a float number of the specified unit.
"""
if total is not UNSET:
if in_units is not UNSET:
raise TypeError("Cannot specify both 'total' and 'in_units'")
if round_mode is not UNSET or round_increment is not UNSET:
raise TypeError(
"'round_mode' and 'round_increment' cannot be used with 'total'"
)
_unit_index(total, DATE_DELTA_UNITS)
sign: Literal[1, -1] = 1 if self._py_date >= b._py_date else -1
trunc_amount, trunc_date_interim, expand_date_interim = DIFF_FUNCS[
total
](self._py_date, b._py_date, 1, sign)
trunc_date = resolve_leap_day(trunc_date_interim)
expand_date = resolve_leap_day(expand_date_interim)
denom = float((expand_date - trunc_date).days)
num = float((self._py_date - trunc_date).days)
return (trunc_amount + num / denom) * sign
elif in_units is UNSET:
raise TypeError("Must specify either `in_units` or `total`")
units = _normalize_units(in_units, valid_units=DATE_DELTA_UNITS)
effective_increment = (
1 if round_increment is UNSET else round_increment
)
effective_round_mode = "trunc" if round_mode is UNSET else round_mode
smallest_unit = units[-1]
sign = 1 if self >= b else -1
results, trunc, expand = date_diff(
self._py_date,
b._py_date,
effective_increment,
units,
sign,
)
# Round is expensive, so only do it if needed
if effective_round_mode != "trunc":
trunc_date = resolve_leap_day(trunc)
results[smallest_unit] = custom_round(
results[smallest_unit],
abs((self._py_date - trunc_date).days),
abs((resolve_leap_day(expand) - trunc_date).days),
effective_round_mode,
effective_increment,
sign,
)
return ItemizedDateDelta._from_signed(
sign if any(results.values()) else 0, **results
)
@overload
def until(
self,
b: Date,
/,
*,
total: DateDeltaUnitStr,
) -> float: ...
@overload
def until(
self,
b: Date,
/,
*,
in_units: Sequence[DateDeltaUnitStr],
round_mode: RoundModeStr = "trunc",
round_increment: int = ...,
) -> ItemizedDateDelta: ...
[docs]
def until(
self,
b: Date,
/,
*,
total: DateDeltaUnitStr = UNSET,
in_units: Sequence[DateDeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
) -> ItemizedDateDelta | float:
"""Companion to :meth:`since` that calculates the difference until another date.
See :meth:`since` for more information.
"""
return b.since( # type: ignore[call-overload, no-any-return]
self,
total=total,
in_units=in_units,
round_mode=round_mode,
round_increment=round_increment,
)
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`
.. deprecated:: 0.10.0
Using the ``+`` operator on :class:`Date` is deprecated;
use the :meth:`add` method instead.
"""
if isinstance(p, DateDelta):
warn(
"Using the + operator on Date is deprecated and will be removed "
"in a future release; "
"use the .add() method instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return self.add(months=p._months, days=p._days)
return 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!
>>> 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)
.. deprecated:: 0.10.0
Using the ``-`` operator on :class:`Date` is deprecated;
use the :meth:`subtract` method or the :meth:`since` method instead.
"""
if isinstance(d, DateDelta):
warn(
"Using the `-` operator on Date is deprecated and will be removed "
"in a future release; "
"use the .subtract() method instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return self.subtract(months=d._months, days=d._days)
elif isinstance(d, Date):
warn(
"Using the `-` operator on Date is deprecated and will be removed "
"in a future release; "
"use the .since() method with explicit units instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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._from_months_days(mos, dys)
return NotImplemented
__str__ = format_iso
def __repr__(self) -> str:
return f'Date("{self}")'
[docs]
def __eq__(self, other: object) -> bool:
"""Compare for equality
>>> 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._init_from_inner(d)
return self
def _init_from_inner(self, d: _date, /) -> None:
self._py_date = d
@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)
_DUMMY_LEAP_YEAR = 4
[docs]
@final
class Time(_Base):
"""Time of day without a date component.
>>> t = Time(12, 30, 0)
Time("12:30:00")
Can also be constructed from an ISO 8601 string:
>>> Time("12:30:00")
Time("12:30:00")
Or a standard library :class:`~datetime.time`:
>>> Time(time(12, 30, 0))
Time("12:30:00")
Note
----
When constructing from a :class:`~datetime.time`, the ``fold``
attribute and ``tzinfo`` are ignored.
Sub-second precision up to nanoseconds is supported:
>>> Time(12, 30, 0, nanosecond=1)
Time("12:30:00.000000001")
Times can be compared and sorted:
>>> Time(12, 30) > Time(8, 0)
True
"""
__slots__ = ("_py", "_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"""
# Overloads for a nice autodoc.
# Proper typing of the constructors is handled in the type stubs
if not TYPE_CHECKING:
@overload
def __init__(self, iso_string: str, /) -> None: ...
@overload
def __init__(self, t: _time, /) -> None: ...
@overload
def __init__(
self,
hour: int = 0,
minute: int = 0,
second: int = 0,
*,
nanosecond: int = 0,
) -> None: ...
def __init__(
self,
hour: int = 0,
minute: int = 0,
second: int = 0,
*,
nanosecond: int = 0,
) -> None:
self._py = _time(hour, minute, second)
if nanosecond < 0 or nanosecond >= 1_000_000_000:
raise ValueError("Nanosecond out of range")
self._nanos = nanosecond
__init__ = add_alternate_constructors(__init__, py_type=_time)
def _init_from_iso(self, s: str) -> None:
self._py, self._nanos = time_from_iso(s)
@property
def hour(self) -> int:
"""The hour component of the time
>>> Time(12, 30, 0).hour
12
"""
return self._py.hour
@property
def minute(self) -> int:
"""The minute component of the time
>>> Time(12, 30, 0).minute
30
"""
return self._py.minute
@property
def second(self) -> int:
"""The second component of the time
>>> Time(12, 30, 0).second
0
"""
return self._py.second
@property
def nanosecond(self) -> int:
"""The nanosecond component of the time
>>> Time("12:30:00.003).nanosecond
3000000
"""
return self._nanos
[docs]
def on(self, d: Date, /) -> PlainDateTime:
"""Combine a time with a date to create a datetime
>>> 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:
>>> t.on(Date(2021, 1, 2)).assume_tz("America/New_York")
ExactDateTime("2021-01-02 12:30:00-05:00[America/New_York]")
"""
return PlainDateTime._from_py_unchecked(
_datetime.combine(d._py_date, self._py),
self._nanos,
)
[docs]
def to_stdlib(self) -> _time:
"""Convert to a standard library :class:`~datetime.time`
Note
----
Nanoseconds are truncated to microseconds.
If you need more control over rounding, use :meth:`round` first.
"""
return self._py.replace(microsecond=self._nanos // 1_000)
[docs]
def py_time(self) -> _time:
"""Convert to a standard library :class:`~datetime.time`
.. deprecated:: 0.10.0
Use :meth:`to_stdlib` instead.
"""
warn(
"py_time() is deprecated and will be removed in a future release; "
"use to_stdlib() instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return self.to_stdlib()
[docs]
@classmethod
def from_py_time(cls, t: _time, /) -> Time:
"""Create from a :class:`~datetime.time`
>>> Time.from_py_time(time(12, 30, 0))
Time(12:30:00)
.. deprecated:: 0.10.0
Use the constructor ``Time(t)`` instead.
"""
warn(
"from_py_time() is deprecated and will be removed in a future release; "
"use Time() instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
self = _object_new(cls)
self._init_from_py(t)
return self
def _init_from_py(self, t: _time, /) -> None:
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: # pragma: no cover
raise TypeError(f"Expected datetime.time, got {type(t)!r}")
return self._init_from_inner(
(t.replace(microsecond=0), t.microsecond * 1_000)
)
[docs]
@classmethod
def parse_iso(cls, s: str, /) -> Time:
"""Create from the ISO 8601 time format
Inverse of :meth:`format_iso`
>>> Time.parse_iso("12:30:00")
Time(12:30:00)
"""
return cls._from_py_unchecked(*time_from_iso(s))
_PATTERN_CATS = frozenset({"time"})
def __format__(self, spec: str, /) -> str:
return str(self) if not spec else self.format(spec)
[docs]
@classmethod
def parse(cls, s: str, /, *, format: str) -> Time:
"""Parse a time from a custom pattern string.
See :ref:`pattern-format` for details.
>>> Time.parse("14:30:05", format="hh:mm:ss")
Time(14:30:05)
>>> Time.parse("02:30 PM", format="ii:mm aa")
Time(14:30:00)
"""
elements = compile_pattern(format)
validate_fields(elements, cls._PATTERN_CATS, "Time")
state = parse_fields(elements, s)
return cls(
hour=state.hour or 0,
minute=state.minute or 0,
second=state.second or 0,
nanosecond=state.nanos,
)
if not TYPE_CHECKING: # for a nice autodoc
@overload
def replace(
self,
hour: int = ...,
minute: int = ...,
second: int = ...,
nanosecond: int = ...,
) -> Time: ...
[docs]
def replace(self, **kwargs: Any) -> Time:
"""Create a new instance with the given fields replaced
>>> 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.replace(**kwargs), nanos)
def _to_ns_since_midnight(self) -> int:
return (
self._py.hour * 3_600_000_000_000
+ self._py.minute * 60_000_000_000
+ self._py.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",
]
| TimeDelta
) = "second",
/,
*,
increment: int = 1,
mode: RoundModeStr = "half_even",
) -> Time:
"""Round the time to the specified unit and increment,
or to a multiple of a :class:`TimeDelta`.
Various rounding modes are available.
>>> Time(12, 39, 59).round("minute", 15)
Time(12:45:00)
>>> Time(8, 9, 13).round("second", 5, mode="floor")
Time(08:09:10)
>>> Time(12, 39, 59).round(TimeDelta(minutes=15))
Time(12:45:00)
"""
if isinstance(unit, TimeDelta):
if increment != 1:
raise TypeError(
"Cannot specify both a TimeDelta and an increment"
)
increment_ns = unit._to_round_increment_ns(False)
else:
if unit == "day": # type: ignore[comparison-overlap]
raise ValueError("Cannot round Time to day")
increment_ns = increment_to_ns_for_datetime(unit, increment)
return self._round_unchecked(
increment_ns,
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
)
floor = quotient * increment_ns
if mode not in ("floor", "trunc"):
floor = custom_round(
floor,
remainder_ns,
increment_ns,
mode,
increment_ns,
1,
)
next_day, ns_since_midnight = divmod(floor, 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:
self = _object_new(cls)
self._init_from_inner((t, nanos))
return self
def _init_from_inner(self, inner: tuple[_time, int]) -> None:
t, nanos = inner
assert not t.microsecond
self._py = t
self._nanos = nanos
__str__ = format_iso
def __repr__(self) -> str:
return f'Time("{self}")'
[docs]
def __eq__(self, other: object) -> bool:
"""Compare for equality
>>> 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, self._nanos) == (other._py, other._nanos)
def __hash__(self) -> int:
return hash((self._py, self._nanos))
[docs]
def __lt__(self, other: Time) -> bool:
if not isinstance(other, Time):
return NotImplemented
return (self._py, self._nanos) < (other._py, other._nanos)
[docs]
def __le__(self, other: Time) -> bool:
if not isinstance(other, Time):
return NotImplemented
return (self._py, self._nanos) <= (other._py, other._nanos)
[docs]
def __gt__(self, other: Time) -> bool:
if not isinstance(other, Time):
return NotImplemented
return (self._py, self._nanos) > (other._py, other._nanos)
[docs]
def __ge__(self, other: Time) -> bool:
if not isinstance(other, Time):
return NotImplemented
return (self._py, self._nanos) >= (other._py, other._nanos)
@no_type_check
def __reduce__(self):
return (
_unpkl_time,
(
pack(
"<BBBI",
self._py.hour,
self._py.minute,
self._py.second,
self._nanos,
),
),
)
# 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(*args, nanosecond=nanos)
Time.MIN = Time()
Time.MIDNIGHT = Time()
Time.NOON = Time(12)
Time.MAX = Time(23, 59, 59, nanosecond=_MAX_SUBSEC_NANOS)
[docs]
@final
class TimeDelta(_Base):
"""A duration consisting of a precise time: hours, minutes, (nano)seconds.
For durations including months or days, use :class:`~ItemizedDelta`,
or :class:`~whenever.ItemizedDateDelta` for date-only durations.
The inputs are normalized, so 90 minutes becomes 1 hour and 30 minutes,
for example.
>>> d = TimeDelta(hours=1, minutes=90)
TimeDelta("PT2h30m")
>>> d.total("minutes")
150.0
Can also be constructed from an ISO 8601 duration string
or a standard library :class:`~datetime.timedelta`:
>>> TimeDelta("PT2h30m")
TimeDelta("PT2h30m")
Note
----
Subclasses of :class:`~datetime.timedelta` are not accepted,
because they often add additional state that cannot be represented.
``TimeDelta`` can be added to or subtracted from datetime types
to shift them by an exact amount of time:
>>> Instant("2022-10-24 00:00Z") + TimeDelta(hours=3)
Instant("2022-10-24 03:00:00Z")
Note
----
A shorter way to instantiate a timedelta is to use the helper functions
:func:`~whenever.hours`, :func:`~whenever.minutes`, etc.
"""
__slots__ = ("_total_ns",)
# Overloads for a nice autodoc.
# Proper typing of the constructors is handled in the type stubs
if not TYPE_CHECKING:
@overload
def __init__(self, iso_string: str, /) -> None: ...
@overload
def __init__(self, py_timedelta: _timedelta, /) -> None: ...
@overload
def __init__(
self,
*,
weeks: float = 0,
days: float = 0,
hours: float = 0,
minutes: float = 0,
seconds: float = 0,
milliseconds: float = 0,
microseconds: float = 0,
nanoseconds: int = 0,
days_assumed_24h_ok: bool = UNSET,
) -> None: ...
def __init__(
self,
*,
weeks: float = 0,
days: float = 0,
hours: float = 0,
minutes: float = 0,
seconds: float = 0,
milliseconds: float = 0,
microseconds: float = 0,
nanoseconds: int = 0,
days_assumed_24h_ok: bool = UNSET,
) -> None:
assert type(nanoseconds) is int # catch this common mistake
if (weeks or days) and not days_assumed_24h_ok:
warn(
DAYS_NOT_ALWAYS_24H_MSG,
DaysAssumed24HoursWarning,
stacklevel=3, # extra frame from add_alternate_constructors
)
ns = self._total_ns = (
# Cast individual components to int to avoid floating point errors
int(weeks * 7 * 86_400_000_000_000)
+ int(days * 86_400_000_000_000)
+ 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")
__init__ = add_alternate_constructors(__init__, py_type=_timedelta)
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 total(
self,
unit: Literal[
"years",
"months",
"weeks",
"days",
"hours",
"minutes",
"seconds",
"milliseconds",
"microseconds",
"nanoseconds",
],
relative_to: ZonedDateTime | PlainDateTime | OffsetDateTime = UNSET,
_warn_stacklevel: int = 2,
days_assumed_24h_ok: bool = UNSET,
) -> float | int:
"""The total size in the given unit, as a float (or int for nanoseconds)
For calendar units (years, months, weeks, days), a ``relative_to``
argument is required to determine the actual duration of each unit:
- :class:`ZonedDateTime`: DST-aware; emits no warning
- :class:`PlainDateTime`: no timezone context; emits
:class:`NaiveArithmeticWarning`
- :class:`OffsetDateTime`: fixed offset; emits
:class:`StaleOffsetWarning`
>>> d = TimeDelta(hours=1, minutes=30)
>>> d.total('minutes')
90.0
"""
if unit in ("days", "weeks", "years", "months"):
if relative_to is not UNSET:
# For non-zoned datetimes, we can just pretend to work in
# the UTC 'timezone' and continue with the tz aware logic.
if isinstance(relative_to, PlainDateTime):
warn(
PLAIN_RELATIVE_TO_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=_warn_stacklevel,
)
relative_to = relative_to.assume_tz("UTC")
elif isinstance(relative_to, OffsetDateTime):
warn(
StaleOffsetWarning(STALE_OFFSET_CALENDAR_MSG),
stacklevel=_warn_stacklevel,
)
relative_to = relative_to.to_plain().assume_tz("UTC")
shifted = relative_to + self
sign: Literal[1, -1] = 1 if self._total_ns >= 0 else -1
target_date = shifted.date()
# The while loop handles the rare case of a 24h+ gap (e.g. Samoa 2011),
# where two consecutive dates map to the same instant.
if sign == 1:
while relative_to.replace_date(target_date) > shifted:
target_date = target_date.subtract(days=1)
else:
while relative_to.replace_date(target_date) < shifted:
target_date = target_date.add(days=1)
trunc_amount, trunc_date, expanded_date = DIFF_FUNCS[unit](
target_date._py_date,
relative_to._py_dt.date(),
1,
sign,
)
trunc_zdt = relative_to.replace_date(
Date._from_py_unchecked(resolve_leap_day(trunc_date))
)
return (
trunc_amount
+ (shifted - trunc_zdt)
/ (
relative_to.replace_date(
Date._from_py_unchecked(
resolve_leap_day(expanded_date)
)
)
- trunc_zdt
)
) * sign
elif unit in ("days", "weeks"):
if not days_assumed_24h_ok:
warn(
DAYS_NOT_ALWAYS_24H_MSG,
DaysAssumed24HoursWarning,
stacklevel=_warn_stacklevel,
)
else:
raise TypeError(
f"Cannot convert TimeDelta to {unit!r} without a `relative_to` parameter"
)
elif unit == "nanoseconds":
return self._total_ns
try:
return self._total_ns / NS_PER_UNIT_PLURAL[unit]
except KeyError:
raise ValueError(f"Invalid unit: {unit!r}")
[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.
.. deprecated:: 0.10.0
Use :meth:`total` with ``'days'`` instead.
"""
warn(
"in_days_of_24h() is deprecated and will be removed in a future "
"release; use total('days') instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return self._total_ns / 86_400_000_000_000
[docs]
def in_hours(self) -> float:
"""The total size in hours
>>> d = TimeDelta(hours=1, minutes=30)
>>> d.in_hours()
1.5
.. deprecated:: 0.10.0
Use :meth:`total` with ``'hours'`` instead.
"""
warn(
"in_hours() is deprecated and will be removed in a future release; "
"use total('hours') instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return self._total_ns / 3_600_000_000_000
[docs]
def in_minutes(self) -> float:
"""The total size in minutes
>>> d = TimeDelta(hours=1, minutes=30, seconds=30)
>>> d.in_minutes()
90.5
.. deprecated:: 0.10.0
Use :meth:`total` with ``'minutes'`` instead.
"""
warn(
"in_minutes() is deprecated and will be removed in a future release; "
"use total('minutes') instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return self._total_ns / 60_000_000_000
[docs]
def in_seconds(self) -> float:
"""The total size in seconds
>>> d = TimeDelta(minutes=2, seconds=1, microseconds=500_000)
>>> d.in_seconds()
121.5
.. deprecated:: 0.10.0
Use :meth:`total` with ``'seconds'`` instead.
"""
warn(
"in_seconds() is deprecated and will be removed in a future release; "
"use total('seconds') instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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
.. deprecated:: 0.10.0
Use :meth:`total` with ``'milliseconds'`` instead.
"""
warn(
"in_milliseconds() is deprecated and will be removed in a future "
"release; use total('milliseconds') instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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
.. deprecated:: 0.10.0
Use :meth:`total` with ``'microseconds'`` instead.
"""
warn(
"in_microseconds() is deprecated and will be removed in a future "
"release; use total('microseconds') instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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
.. deprecated:: 0.10.0
Use :meth:`total` with ``'nanoseconds'`` instead.
"""
warn(
"in_nanoseconds() is deprecated and will be removed in a future "
"release; use total('nanoseconds') instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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)
>>> d = TimeDelta(hours=1, minutes=30, microseconds=5_000_090)
>>> d.in_hrs_mins_secs_nanos()
(1, 30, 5, 90_000)
.. deprecated:: 0.10.0
Use :meth:`in_units` with ``['hours', 'minutes', 'seconds', 'nanoseconds']`` instead.
"""
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 in_units(
self,
units: Sequence[DeltaUnitStr],
/,
*,
round_mode: RoundModeStr = "trunc",
round_increment: int = 1,
relative_to: ZonedDateTime | PlainDateTime | OffsetDateTime = UNSET,
days_assumed_24h_ok: bool = UNSET,
) -> ItemizedDelta:
"""Convert to a :class:`ItemizedDelta` with the specified units
>>> d = TimeDelta(hours=2, minutes=30, seconds=23, milliseconds=500)
>>> d.in_units(['minutes', 'seconds'])
ItemizedDelta("PT150m24s")
>>> (hrs, mins) = d.in_units(('hours', 'minutes'), round_mode='ceil').values()
(2, 31)
Parameters
----------
units
A sequence of plural unit names, in descending order.
Valid unit names are: ``weeks``, ``days``, ``hours``,
``minutes``, ``seconds``, ``nanoseconds``.
``years`` and ``months`` are also allowed if ``relative_to``
is provided.
round_mode
The rounding mode to use when rounding before conversion.
See :meth:`round` for details.
round_increment
The rounding increment to use when rounding before conversion.
See :meth:`round` for details.
relative_to
A reference datetime required when using calendar units
(``years``, ``months``, ``days``, or ``weeks``) to account for variable unit lengths.
- :class:`ZonedDateTime`: DST-aware; emits no warning
- :class:`PlainDateTime`: does not account for time zones; emits
:class:`NaiveArithmeticWarning`
- :class:`OffsetDateTime`: does not account for DST changes; emits
:class:`StaleOffsetWarning`
"""
has_years_months = "years" in units or "months" in units
if has_years_months and relative_to is UNSET:
raise TypeError(
"Years and months units require a `relative_to` argument"
)
units = _normalize_units(units, DELTA_UNITS)
if units[-1] == "nanoseconds" and (
len(units) == 1 or units[-2] != "seconds"
):
raise ValueError(
"Nanoseconds can only be specified together with seconds"
)
if relative_to is not UNSET:
has_cal = has_years_months or "days" in units or "weeks" in units
if isinstance(relative_to, PlainDateTime):
if has_cal:
warn(
PLAIN_RELATIVE_TO_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=2,
)
relative_to = relative_to.assume_tz("UTC")
elif isinstance(relative_to, OffsetDateTime):
if has_cal:
warn(
StaleOffsetWarning(STALE_OFFSET_CALENDAR_MSG),
stacklevel=2,
)
relative_to = relative_to.to_plain().assume_tz("UTC")
return (relative_to + self).since(
relative_to,
in_units=units,
round_mode=round_mode,
round_increment=round_increment,
)
if ("days" in units or "weeks" in units) and not days_assumed_24h_ok:
warn(
DAYS_NOT_ALWAYS_24H_MSG,
DaysAssumed24HoursWarning,
stacklevel=2,
)
result = self._in_exact_units(
# NOTE: this case is safe because we cannot reach here if there
# are years or months, and the other units are all valid
cast(Sequence[ExactDeltaUnitStr], units),
round_mode,
round_increment,
)
sign: Sign = 1 if self._total_ns >= 0 else -1
if not any(result.values()):
sign = 0 # due to rounding, the result may be zero even if self is not zero
return ItemizedDelta._from_signed(sign, **result)
def _in_exact_units(
self,
units: Sequence[ExactDeltaUnitStr],
round_mode: RoundModeStr,
round_increment: int,
) -> dict[ExactDeltaUnitStr, int]:
self = self.round(
# trim the last 's' from the smallest unit to get the singular form
units[-1][:-1], # type: ignore[arg-type]
increment=round_increment,
mode=round_mode,
)
remaining_ns = abs(self._total_ns)
values = {}
for u in units:
values[u], remaining_ns = divmod(remaining_ns, _DELTA_ITEMS_NS[u])
return values
[docs]
def to_stdlib(self) -> _timedelta:
"""Convert to a :class:`~datetime.timedelta`
>>> d = TimeDelta(hours=1, minutes=30)
>>> d.to_stdlib()
timedelta(seconds=5400)
Note
----
Nanoseconds are truncated to microseconds.
If you need more control over rounding, use :meth:`round` first.
"""
return _timedelta(microseconds=self._total_ns // 1_000)
[docs]
def py_timedelta(self) -> _timedelta:
"""Convert to a :class:`~datetime.timedelta`
.. deprecated:: 0.10.0
Use :meth:`to_stdlib` instead.
"""
warn(
"py_timedelta() is deprecated and will be removed in a future release; "
"use to_stdlib() instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return self.to_stdlib()
def _init_from_py(self, td: _timedelta, /) -> None:
if type(td) is not _timedelta:
raise TypeError("Expected datetime.timedelta exactly")
self._total_ns = ns = (
td.microseconds * 1_000
+ td.seconds * 1_000_000_000
+ td.days * 24 * 3_600_000_000_000
)
if abs(ns) > _MAX_DELTA_NANOS:
raise ValueError("TimeDelta out of range")
[docs]
@classmethod
def from_py_timedelta(cls, td: _timedelta, /) -> TimeDelta:
"""Create from a :class:`~datetime.timedelta`
>>> TimeDelta.from_py_timedelta(timedelta(seconds=5400))
TimeDelta("PT1h30m")
.. deprecated:: 0.10.0
Use the constructor ``TimeDelta(td)`` instead.
"""
warn(
"from_py_timedelta() is deprecated and will be removed in a future "
"release; use TimeDelta() instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
self = _object_new(cls)
self._init_from_py(td)
return self
def _init_from_iso(self, s: str) -> None:
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")
self._total_ns = sign * nanos
[docs]
@classmethod
def parse_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_iso`
>>> TimeDelta.parse_iso("PT1H80M")
TimeDelta("PT2h20m")
Note
----
Any duration with a date part is considered invalid.
``PT0S`` is valid, but ``P0D`` is not.
"""
self = _object_new(cls)
self._init_from_iso(s)
return self
[docs]
def round(
self,
unit: (
Literal[
"week",
"day",
"hour",
"minute",
"second",
"millisecond",
"microsecond",
"nanosecond",
]
| TimeDelta
) = "second",
/,
*,
increment: int = 1,
mode: RoundModeStr = "half_even",
days_assumed_24h_ok: bool = UNSET,
) -> TimeDelta:
"""Round the delta to the specified unit and increment,
or to a multiple of another :class:`TimeDelta`.
Various rounding modes are available.
>>> t = TimeDelta(seconds=12345)
TimeDelta("PT3h25m45s")
>>> t.round("minute")
TimeDelta("PT3h26m")
>>> t.round("second", increment=10, mode="floor")
TimeDelta("PT3h25m40s")
>>> t.round(TimeDelta(minutes=15))
TimeDelta("PT3h30m")
"""
if isinstance(unit, TimeDelta):
if increment != 1:
raise TypeError(
"Cannot specify both a TimeDelta and an increment"
)
increment_ns = unit._to_round_increment_ns(not days_assumed_24h_ok)
else:
if unit in ("day", "week") and not days_assumed_24h_ok:
warn(
DAYS_NOT_ALWAYS_24H_MSG,
DaysAssumed24HoursWarning,
stacklevel=2,
)
increment_ns = increment_to_ns_for_delta(unit, increment)
quotient, remainder_ns = divmod(abs(self._total_ns), increment_ns)
sign: Literal[1, -1] = 1 if self._total_ns >= 0 else -1
abs_result = quotient * increment_ns
if mode != "trunc":
abs_result = custom_round(
abs_result,
remainder_ns,
increment_ns,
mode,
increment_ns,
sign,
)
if abs_result > _MAX_DELTA_NANOS:
raise ValueError("Resulting TimeDelta out of range")
return self._from_nanos_unchecked(abs_result * sign)
@overload
def add(self, other: TimeDelta, /) -> TimeDelta: ...
@overload
def add(
self,
/,
*,
weeks: float = ...,
days: float = ...,
hours: float = ...,
minutes: float = ...,
seconds: float = ...,
milliseconds: float = ...,
microseconds: float = ...,
nanoseconds: int = ...,
) -> TimeDelta: ...
[docs]
def add(self, arg: TimeDelta = UNSET, /, **kwargs: Any) -> TimeDelta:
"""Add time to this delta, returning a new delta.
Days and weeks are treated as exact 24-hour and 168-hour units,
which emits a :class:`~whenever.DaysAssumed24HoursWarning`."""
if kwargs:
if arg is not UNSET:
raise TypeError("Cannot mix positional and keyword arguments")
return self + TimeDelta(**kwargs)
elif arg is not UNSET:
return self + arg
else:
return self
@overload
def subtract(self, other: TimeDelta, /) -> TimeDelta: ...
@overload
def subtract(
self,
/,
*,
weeks: float = ...,
days: float = ...,
hours: float = ...,
minutes: float = ...,
seconds: float = ...,
milliseconds: float = ...,
microseconds: float = ...,
nanoseconds: int = ...,
) -> TimeDelta: ...
[docs]
def subtract(self, arg: TimeDelta = UNSET, /, **kwargs: Any) -> TimeDelta:
"""Subtract time from this delta, returning a new delta.
Days and weeks are treated as exact 24-hour and 168-hour units,
which emits a :class:`~whenever.DaysAssumed24HoursWarning`."""
if kwargs:
if arg is not UNSET:
raise TypeError("Cannot mix positional and keyword arguments")
return self - TimeDelta(**kwargs)
elif arg is not UNSET:
return self - arg
else:
return self
@overload
def __add__(self, other: TimeDelta) -> TimeDelta: ...
@overload
def __add__(self, other: Instant) -> Instant: ...
@overload
def __add__(self, other: PlainDateTime) -> PlainDateTime: ...
@overload
def __add__(self, other: OffsetDateTime) -> OffsetDateTime: ...
@overload
def __add__(self, other: ZonedDateTime) -> ZonedDateTime: ...
[docs]
def __add__(
self,
other: TimeDelta
| Instant
| PlainDateTime
| OffsetDateTime
| ZonedDateTime,
) -> TimeDelta | Instant | PlainDateTime | OffsetDateTime | ZonedDateTime:
"""Add two deltas together
>>> d = TimeDelta(hours=1, minutes=30)
>>> d + TimeDelta(minutes=30)
TimeDelta("PT2h")
"""
if isinstance(
other, (Instant, PlainDateTime, OffsetDateTime, ZonedDateTime)
):
return _ideltas._shift_datetime_operator(other, self, False)
if isinstance(other, TimeDelta):
return TimeDelta(nanoseconds=self._total_ns + other._total_ns)
return NotImplemented
[docs]
def __sub__(self, other: TimeDelta) -> TimeDelta:
"""Subtract two deltas
>>> 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
>>> 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)
[docs]
def __lt__(self, other: TimeDelta) -> bool:
if not isinstance(other, TimeDelta):
return NotImplemented
return self._total_ns < other._total_ns
[docs]
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
[docs]
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
>>> bool(TimeDelta())
False
>>> bool(TimeDelta(minutes=1))
True
"""
return bool(self._total_ns)
[docs]
def __mul__(self, other: float) -> TimeDelta:
"""Multiply by a number
>>> d = TimeDelta(hours=1, minutes=30)
>>> d * 2.5
TimeDelta("PT3h45m")
"""
if not isinstance(other, (int, float)):
return NotImplemented
result = int(self._total_ns * other)
if abs(result) > _MAX_DELTA_NANOS:
raise ValueError("TimeDelta out of range")
return TimeDelta._from_nanos_unchecked(result)
def __rmul__(self, other: float) -> TimeDelta:
return self * other
[docs]
def __neg__(self) -> TimeDelta:
"""Negate the value
>>> d = TimeDelta(hours=1, minutes=30)
>>> -d
TimeDelta(-PT1h30m)
"""
# No range check needed: negating a valid TimeDelta always stays in range
return TimeDelta._from_nanos_unchecked(-self._total_ns)
[docs]
def __pos__(self) -> TimeDelta:
"""Return the value unchanged
>>> 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
>>> 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
[docs]
def __floordiv__(self, other: TimeDelta) -> int:
"""Floor division by another delta
>>> 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
[docs]
def __mod__(self, other: TimeDelta) -> TimeDelta:
"""Modulo by another delta
>>> 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
>>> d = TimeDelta(hours=-1, minutes=-30)
>>> abs(d)
TimeDelta("PT1h30m")
"""
return TimeDelta._from_nanos_unchecked(abs(self._total_ns))
__str__ = format_iso
def __repr__(self) -> str:
iso = self.format_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
def _to_round_increment_ns(self, for_delta: bool) -> int:
if (increment_ns := self._total_ns) <= 0:
raise ValueError("Round increment must be positive, and nonzero")
if not for_delta and 86_400_000_000_000 % increment_ns:
raise ValueError(
"Invalid increment. Must divide a 24-hour day evenly."
)
return increment_ns
# 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)
_DELTA_ITEMS_NS = {
"weeks": 1_000_000_000 * 60 * 60 * 24 * 7,
"days": 1_000_000_000 * 60 * 60 * 24,
"hours": 1_000_000_000 * 60 * 60,
"minutes": 1_000_000_000 * 60,
"seconds": 1_000_000_000,
"nanoseconds": 1,
}
TimeDelta.ZERO = TimeDelta()
TimeDelta.MAX = TimeDelta(seconds=9999 * 366 * 24 * 3_600)
TimeDelta.MIN = TimeDelta(seconds=-9999 * 366 * 24 * 3_600)
[docs]
@final
class DateDelta(_Base):
"""A duration of time consisting of calendar units
(years, months, weeks, and days).
.. deprecated:: 0.10.0
Use :class:`ItemizedDateDelta` instead.
``DateDelta`` normalizes its inputs (e.g. 14 months becomes
1 year and 2 months), losing the original fields.
``ItemizedDateDelta`` preserves the exact fields it was created with.
"""
__slots__ = ("_months", "_days")
# Overloads for a nice autodoc.
# Proper typing of the constructors is handled in the type stubs
if not TYPE_CHECKING:
@overload
def __init__(self, iso_string: str, /) -> None: ...
@overload
def __init__(
self,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
) -> None: ...
def __init__(
self, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0
) -> None:
warn(
"DateDelta is deprecated and will be removed in a future release; "
"use ItemizedDateDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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 DateDelta")
elif (
abs(self._months) > _MAX_DELTA_MONTHS
or abs(self._days) > _MAX_DELTA_DAYS
):
raise ValueError("Date delta months out of range")
__init__ = add_alternate_constructors(
__init__,
deprecation_msg=(
"DateDelta is deprecated and will be removed in a future release; "
"use ItemizedDateDelta instead."
),
)
@classmethod
def _from_months_days(cls, months: int, days: int) -> DateDelta:
"""Internal: create without deprecation warning"""
self = _object_new(cls)
if (months > 0 and days < 0) or (months < 0 and days > 0):
raise ValueError("mixed sign in DateDelta")
elif abs(months) > _MAX_DELTA_MONTHS or abs(days) > _MAX_DELTA_DAYS:
raise ValueError("Date delta months out of range")
self._months = months
self._days = days
return self
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.
>>> 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.
>>> 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
__str__ = format_iso
def _init_from_iso(self, s: str) -> None:
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
if months > _MAX_DELTA_MONTHS or days > _MAX_DELTA_DAYS:
raise ValueError("DateDelta out of range")
self._months = sign * months
self._days = sign * days
[docs]
@classmethod
def parse_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_iso`
>>> DateDelta.parse_iso("P1W11D")
DateDelta("P1w11d")
>>> DateDelta.parse_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.
"""
warn(
"DateDelta is deprecated and will be removed in a future release; "
"use ItemizedDateDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
self = _object_new(cls)
self._init_from_iso(s)
return self
@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
>>> p = DateDelta(weeks=2, months=1)
>>> p + DateDelta(weeks=1, days=4)
DateDelta("P1m25d")
"""
if isinstance(other, DateDelta):
return DateDelta._from_months_days(
self._months + other._months,
self._days + other._days,
)
elif isinstance(other, TimeDelta):
warn(
"DateTimeDelta is deprecated and will be removed in a future "
"release; use ItemizedDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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):
warn(
"DateTimeDelta is deprecated and will be removed in a future "
"release; use ItemizedDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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
>>> p = DateDelta(weeks=2, days=3)
>>> p - DateDelta(days=2)
DateDelta("P15d")
"""
if isinstance(other, DateDelta):
return DateDelta._from_months_days(
self._months - other._months,
self._days - other._days,
)
elif isinstance(other, TimeDelta):
warn(
"DateTimeDelta is deprecated and will be removed in a future "
"release; use ItemizedDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return self + (-other)
else:
return NotImplemented
def __rsub__(self, other: TimeDelta) -> DateTimeDelta:
if isinstance(other, TimeDelta):
warn(
"DateTimeDelta is deprecated and will be removed in a future "
"release; use ItemizedDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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()`
>>> 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
>>> bool(DateDelta())
False
>>> bool(DateDelta(days=-1))
True
"""
return bool(self._months or self._days)
def __repr__(self) -> str:
iso = self.format_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
>>> p = DateDelta(weeks=2, days=3)
>>> -p
DateDelta(-P17d)
"""
return DateDelta._from_months_days(-self._months, -self._days)
def __pos__(self) -> DateDelta:
"""Return the value unchanged
>>> 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
>>> p = DateDelta(years=1, weeks=2)
>>> p * 2
DateDelta("P2y28d")
"""
if not isinstance(other, int):
return NotImplemented
return DateDelta._from_months_days(
self._months * other,
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
>>> p = DateDelta(months=-2, days=-3)
>>> abs(p)
DateDelta("P2m3d")
"""
return DateDelta._from_months_days(abs(self._months), 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._from_months_days(months, days)
_MAX_DDELTA_DIGITS = 8 # consistent with Rust extension
# Returns (rest_of_string, value, unit), e.g. ("3D", 2, "Y")
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._from_months_days(0, 0)
TimeDelta._date_part = DateDelta.ZERO
[docs]
@final
class DateTimeDelta(_Base):
"""A duration with both a date and time component.
.. deprecated:: 0.10.0
Use :class:`ItemizedDelta` instead.
``DateTimeDelta`` normalizes its inputs separately for the date
and time parts, losing the original fields.
``ItemizedDelta`` preserves the exact fields it was created with.
"""
__slots__ = ("_date_part", "_time_part")
# Overloads for a nice autodoc.
# Proper typing of the constructors is handled in the type stubs
if not TYPE_CHECKING:
@overload
def __init__(self, iso_string: str, /) -> None: ...
@overload
def __init__(
self,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
hours: float = ...,
minutes: float = ...,
seconds: float = ...,
milliseconds: float = ...,
microseconds: float = ...,
nanoseconds: int = ...,
) -> None: ...
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:
warn(
"DateTimeDelta is deprecated and will be removed in a future release; "
"use ItemizedDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
self._date_part = DateDelta._from_months_days(
months + 12 * years, days + 7 * weeks
)
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 DateTimeDelta")
__init__ = add_alternate_constructors(
__init__,
deprecation_msg=(
"DateTimeDelta is deprecated and will be removed in a future release; "
"use ItemizedDelta instead."
),
)
ZERO: ClassVar[DateTimeDelta]
"""A delta of zero"""
[docs]
def date_part(self) -> DateDelta:
"""The date part of the delta
.. deprecated:: 0.10.0
"""
warn(
"DateTimeDelta.date_part() is deprecated and will be removed in a "
"future release. Migrate to ItemizedDelta and use "
".date_and_time_parts() instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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)
>>> 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)
def _init_from_iso(self, s: str) -> None:
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
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
try:
ddelta = DateDelta._from_months_days(sign * months, sign * days)
except ValueError:
raise exc
tdelta = TimeDelta._from_nanos_unchecked(sign * nanos)
return self._init_from_parts(ddelta, tdelta)
[docs]
@classmethod
def parse_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.
.. 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_iso`
>>> DateTimeDelta.parse_iso("-P1W11DT4H")
DateTimeDelta(-P1w11dT4h)
"""
warn(
"DateTimeDelta is deprecated and will be removed in a future release; "
"use ItemizedDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
self = _object_new(cls)
self._init_from_iso(s)
return self
[docs]
def __add__(
self, other: DateTimeDelta | DateDelta | TimeDelta
) -> DateTimeDelta:
"""Add two deltas together
>>> 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
>>> 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
>>> 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
>>> 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
>>> 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
>>> 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
>>> d = DateTimeDelta(weeks=1, days=-11, hours=4)
>>> +d
DateTimeDelta("P1W11DT4H")
"""
return self
[docs]
def __abs__(self) -> DateTimeDelta:
"""The absolute value of the delta
>>> 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_iso
def __repr__(self) -> str:
iso = self.format_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}")'
def _init_from_parts(self, d: DateDelta, t: TimeDelta) -> None:
self._date_part = d
self._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 DateTimeDelta")
@classmethod
def _from_parts(cls, d: DateDelta, t: TimeDelta) -> DateTimeDelta:
new = _object_new(cls)
new._init_from_parts(d, t)
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:
return DateTimeDelta._from_parts(
DateDelta._from_months_days(months, days),
TimeDelta(seconds=secs, nanoseconds=nanos),
)
DateTimeDelta.ZERO = DateTimeDelta._from_parts(
DateDelta._from_months_days(0, 0), TimeDelta.ZERO
)
# Methods for types converting to/from the standard library and ISO8601:
#
# - Instant
# - PlainDateTime
# - ZonedDateTime
# - OffsetDateTime
#
# (This base class class itself is not for public use.)
class _BasicConversions(_Base):
__slots__ = ("_py_dt", "_nanos")
_py_dt: _datetime
_nanos: int
@classmethod
def from_py_datetime(cls: type[_T], d: _datetime, /) -> _T:
"""Create an instance from a :class:`~datetime.datetime` object.
.. deprecated:: 0.10.0
Use the constructor instead (e.g. ``Instant(d)``,
``ZonedDateTime(d)``, etc.)
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.
"""
warn(
"from_py_datetime() is deprecated and will be removed in a future "
"release; use the constructor instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
self = _object_new(cls)
self._init_from_py(d) # type: ignore[attr-defined]
return self
def to_stdlib(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.
- For :class:`ZonedDateTime` linked to a system timezone without a
IANA timezone ID, the returned Python datetime will have
a fixed offset (:class:`~datetime.timezone` tzinfo)
"""
return self._py_dt.replace(microsecond=self._nanos // 1_000)
def py_datetime(self) -> _datetime:
"""Convert to a standard library :class:`~datetime.datetime`
.. deprecated:: 0.10.0
Use :meth:`to_stdlib` instead.
"""
warn(
"py_datetime() is deprecated and will be removed in a future release; "
"use to_stdlib() instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return self.to_stdlib()
def format_iso(self) -> str:
raise NotImplementedError # pragma: no cover
@classmethod
def parse_iso(cls: type[_T], s: str, /) -> _T:
raise NotImplementedError # pragma: no cover
def __str__(self) -> str:
return self.format_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
def _init_from_py(self, d: _datetime) -> None:
raise NotImplementedError # pragma: no cover
# Methods for types that know a local date and time-of-day:
# - PlainDateTime
# - ZonedDateTime
# - OffsetDateTime
# (The class itself is not for public use.)
class _LocalTime(_BasicConversions):
__slots__ = ()
@property
def year(self) -> int:
"""The year component of the datetime"""
return self._py_dt.year
@property
def month(self) -> int:
"""The month component of the datetime"""
return self._py_dt.month
@property
def day(self) -> int:
"""The day component of the datetime"""
return self._py_dt.day
@property
def hour(self) -> int:
"""The hour component of the datetime"""
return self._py_dt.hour
@property
def minute(self) -> int:
"""The minute component of the datetime"""
return self._py_dt.minute
@property
def second(self) -> int:
"""The second component of the datetime"""
return self._py_dt.second
@property
def nanosecond(self) -> int:
"""The nanosecond component of the datetime"""
return self._nanos
def date(self) -> Date:
"""The date part of the datetime
>>> d = PlaineDateTime("2020-01-02 03:04:05")
>>> d.date()
Date("2021-01-02")
To perform the inverse, use :meth:`Date.at` and a method
like :meth:`~PlainDateTime.assume_utc` or
:meth:`~PlainDateTime.assume_tz`:
>>> date.at(time).assume_tz("Europe/London")
ZonedDateTime("2021-01-02T03:04:05+00:00[Europe/London]")
"""
return Date._from_py_unchecked(self._py_dt.date())
def time(self) -> Time:
"""The time-of-day part of the datetime
>>> d = 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")
ZonedDateTime("2021-01-02T03:04:05+01:00[Europe/Paris]")
"""
return Time._from_py_unchecked(self._py_dt.time(), self._nanos)
def day_of_year(self) -> int:
"""Ordinal day in the year (1--366)
>>> PlainDateTime(2021, 1, 2).day_of_year()
2
"""
return self._py_dt.timetuple().tm_yday
def days_in_month(self) -> int:
"""Number of days in the current month (28--31)
>>> PlainDateTime(2024, 2, 1).days_in_month()
29
"""
return days_in_month(self._py_dt.year, self._py_dt.month)
def days_in_year(self) -> int:
"""Number of days in the current year (365 or 366)
>>> PlainDateTime(2024, 1, 1).days_in_year()
366
"""
return 366 if is_leap(self._py_dt.year) else 365
def in_leap_year(self) -> bool:
"""Whether this date's year is a leap year
>>> PlainDateTime(2024, 1, 1).in_leap_year()
True
"""
return is_leap(self._py_dt.year)
# Methods for types that represent a specific moment in time.
# Implemented by:
# - Instant
# - ZonedDateTime
# - OffsetDateTime
# (This base class class itself is not for public use.)
class _ExactTime(_BasicConversions):
__slots__ = ()
def timestamp(self) -> int:
"""The UNIX timestamp for this datetime. Inverse of :meth:`from_timestamp`.
>>> Instant.from_utc(1970, 1, 1).timestamp()
0
>>> ts = 1_123_000_000
>>> Instant.from_timestamp(ts).timestamp() == ts
True
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.
"""
return int(self._py_dt.timestamp())
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
def timestamp_nanos(self) -> int:
"""Like :meth:`timestamp`, but with nanosecond precision."""
return int(self._py_dt.timestamp()) * 1_000_000_000 + self._nanos
@overload
def to_fixed_offset(self, /) -> OffsetDateTime: ...
@overload
def to_fixed_offset(
self, offset: int | TimeDelta, /
) -> OffsetDateTime: ...
def to_fixed_offset(
self, offset: int | TimeDelta = UNSET, /
) -> 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 UNSET
else _load_offset(offset)
),
self._nanos,
)
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 timezone database.
"""
_tz = get_tz(tz)
return ZonedDateTime._from_py_unchecked(
_to_tz(self._py_dt, _tz), self._nanos, _tz
)
def to_system_tz(self) -> ZonedDateTime:
"""Convert to a ZonedDateTime of the system's timezone."""
tz = get_system_tz()
return ZonedDateTime._from_py_unchecked(
_to_tz(self._py_dt, tz), self._nanos, tz
)
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.
>>> 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
Note
----
If ``a.exact_eq(b)`` is true, then
``a == b`` is also true, but the converse is not necessarily true.
"""
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]
) == (
other._py_dt, # type: ignore[attr-defined]
other._py_dt.utcoffset(), # type: ignore[attr-defined]
other._nanos, # type: ignore[attr-defined]
)
def difference(
self,
other: Instant | OffsetDateTime | ZonedDateTime,
/,
) -> TimeDelta:
"""Calculate the exact time difference between two datetimes.
This method returns the exact elapsed :class:`TimeDelta` between
two instants in time. Equivalent to the subtraction operator (``-``).
Use :meth:`~whenever.ZonedDateTime.since` or
:meth:`~whenever.ZonedDateTime.until` for more advanced
options such as calendar units, unit decomposition, and rounding.
"""
return self - other # type: ignore[operator, no-any-return]
def __eq__(self, other: object) -> bool:
"""Check if two datetimes represent at the same moment in time
``a == b`` is equivalent to ``a.to_instant() == b.to_instant()``
Note
----
If you want to exactly compare the values on their values
instead, use :meth:`exact_eq`.
>>> 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, _EXACT_TIME_TYPES):
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,
)
def __lt__(self, other: _ExactTimeAlias) -> bool:
"""Compare two datetimes by when they occur in time
``a < b`` is equivalent to ``a.to_instant() < b.to_instant()``
>>> OffsetDateTime(2020, 8, 15, hour=23, offset=8) < (
... ZoneDateTime(2020, 8, 15, hour=20, tz="Europe/Amsterdam")
... )
True
"""
if not isinstance(other, _EXACT_TIME_TYPES):
return NotImplemented
return (self._py_dt.astimezone(_UTC), self._nanos) < (
other._py_dt.astimezone(_UTC),
other._nanos,
)
def __le__(self, other: _ExactTimeAlias) -> bool:
"""Compare two datetimes by when they occur in time
``a <= b`` is equivalent to ``a.to_instant() <= b.to_instant()``
>>> OffsetDateTime(2020, 8, 15, hour=23, offset=8) <= (
... ZoneDateTime(2020, 8, 15, hour=20, tz="Europe/Amsterdam")
... )
True
"""
if not isinstance(other, _EXACT_TIME_TYPES):
return NotImplemented
return (self._py_dt.astimezone(_UTC), self._nanos) <= (
other._py_dt.astimezone(_UTC),
other._nanos,
)
def __gt__(self, other: _ExactTimeAlias) -> bool:
"""Compare two datetimes by when they occur in time
``a > b`` is equivalent to ``a.to_instant() > b.to_instant()``
>>> OffsetDateTime(2020, 8, 15, hour=19, offset=-8) > (
... ZoneDateTime(2020, 8, 15, hour=20, tz="Europe/Amsterdam")
... )
True
"""
if not isinstance(other, _EXACT_TIME_TYPES):
return NotImplemented
return (self._py_dt.astimezone(_UTC), self._nanos) > (
other._py_dt.astimezone(_UTC),
other._nanos,
)
def __ge__(self, other: _ExactTimeAlias) -> bool:
"""Compare two datetimes by when they occur in time
``a >= b`` is equivalent to ``a.to_instant() >= b.to_instant()``
>>> OffsetDateTime(2020, 8, 15, hour=19, offset=-8) >= (
... ZoneDateTime(2020, 8, 15, hour=20, tz="Europe/Amsterdam")
... )
True
"""
if not isinstance(other, _EXACT_TIME_TYPES):
return NotImplemented
return (self._py_dt.astimezone(_UTC), self._nanos) >= (
other._py_dt.astimezone(_UTC),
other._nanos,
)
def _subtract_operator(self, other: _ExactTimeAlias) -> TimeDelta:
if isinstance(other, _EXACT_TIME_TYPES):
py_delta = self._py_dt.astimezone(_UTC) - other._py_dt
total_ns = (
(py_delta.days * 86_400 + py_delta.seconds) * 1_000_000_000
+ self._nanos
- other._nanos
)
return TimeDelta._from_nanos_unchecked(total_ns)
return NotImplemented
# Common behavior for all types that know an exact time and
# corresponding local date and time-of-day.
# - ZonedDateTime
# - OffsetDateTime
# (The class itself it not for public use.)
class _ExactAndLocalTime(_LocalTime, _ExactTime):
__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
)
)
def to_instant(self) -> Instant:
"""Get the underlying instant in time
>>> d = ZonedDateTime(2020, 8, 15, hour=23, tz="Europe/Amsterdam")
>>> d.to_instant()
Instant("2020-08-15 21:00:00Z")
"""
return Instant._from_py_unchecked(
self._py_dt.astimezone(_UTC), self._nanos
)
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`.
"""
return PlainDateTime._from_py_unchecked(
self._py_dt.replace(tzinfo=None),
self._nanos,
)
[docs]
@final
class Instant(_ExactTime):
"""A moment in time, independent of any timezone or calendar.
This is the right type when you only care about *when* something happened,
not the local date or time. It maps 1:1 to a UNIX timestamp.
>>> 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
Can also be constructed from an ISO 8601 string, a UNIX timestamp,
or a standard library :class:`~datetime.datetime`:
>>> Instant("2022-10-24T17:00:00Z")
Instant("2022-10-24 17:00:00Z")
Convert to other types for local date/time information:
>>> py311_release.to_tz("US/Pacific")
ZonedDateTime("2022-10-24 10:00:00-07:00[US/Pacific]")
Note
----
Although the debug representation uses UTC, ``Instant`` does *not* have
``.year``, ``.hour``, or other calendar attributes—it is not a UTC datetime.
See the `FAQ <https://whenever.rtfd.io/en/latest/faq.html#why-doesn-t-instant-have-year-hour-etc>`_.
"""
__slots__ = ()
MIN: ClassVar[Instant]
"""The minimum representable instant."""
MAX: ClassVar[Instant]
"""The maximum representable instant."""
def __init__(self, arg: str | _datetime, /) -> None:
"""Create an Instant from an ISO 8601 string or a standard library datetime."""
if isinstance(arg, str):
self._init_from_iso(arg)
elif isinstance(arg, _datetime):
self._init_from_py(arg)
else:
raise TypeError(
"Instant constructor requires an ISO string or stdlib datetime"
)
[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,
)
[docs]
@classmethod
def now(cls) -> Instant:
"""Create an Instant from the current time.
>>> Instant.now()
Instant("2024-06-15 12:34:56.789123456Z")
"""
secs, nanos = divmod(time_ns(), 1_000_000_000)
return cls._from_py_unchecked(_fromtimestamp(secs, _UTC), nanos)
[docs]
@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)
)
[docs]
@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
)
[docs]
@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)
def _init_from_py(self, d: _datetime) -> None:
if d.tzinfo is None or d.utcoffset() is None:
raise ValueError(
"Cannot create Instant from a naive datetime. "
"Use PlainDateTime() instead."
)
as_utc = d.astimezone(_UTC)
self._py_dt = _strip_subclasses(as_utc.replace(microsecond=0))
self._nanos = as_utc.microsecond * 1_000
[docs]
@classmethod
def parse_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.rtfd.io/en/latest/reference/iso8601.html>`__ for more information.
The inverse of the ``format_iso()`` method.
"""
self = _object_new(cls)
self._init_from_iso(s)
return self
def _init_from_iso(self, s: str) -> None:
dt, nanos = offset_dt_from_iso(s)
self._py_dt = dt.astimezone(_UTC)
self._nanos = nanos
[docs]
@classmethod
def parse_rfc2822(cls, s: str, /) -> Instant:
"""Parse a UTC datetime in RFC 2822 format.
The inverse of the ``format_rfc2822()`` method.
>>> 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)
_PATTERN_CATS = frozenset({"date", "time", "offset"})
[docs]
@classmethod
def parse(cls, s: str, /, *, format: str) -> Instant:
"""Parse an instant from a custom pattern string.
The pattern **must** include an offset field (``x``/``X``)
to unambiguously identify the instant.
See :ref:`pattern-format` for details.
.. tip::
If your input string doesn't include an offset, parse it with
:meth:`PlainDateTime.parse` first, then convert using
:meth:`~PlainDateTime.assume_utc` or
:meth:`~PlainDateTime.assume_tz`.
>>> Instant.parse("2024-03-15 14:30Z", format="YYYY-MM-DD hh:mmXXX")
Instant("2024-03-15 14:30:00Z")
>>> Instant.parse("2024-03-15 14:30+05:30", format="YYYY-MM-DD hh:mmxxx")
Instant("2024-03-15 09:00:00Z")
"""
elements = compile_pattern(format)
validate_fields(elements, cls._PATTERN_CATS, "Instant")
state = parse_fields(elements, s)
if state.offset_secs is None:
raise ValueError(
"Instant.parse() pattern must include an offset field (x/X)"
)
if state.year is None or state.month is None or state.day is None:
raise ValueError(
"Pattern must include year, month, and day fields"
)
dt = check_utc_bounds(
_datetime(
state.year,
state.month,
state.day,
state.hour or 0,
state.minute or 0,
state.second or 0,
tzinfo=_timezone(_timedelta(seconds=state.offset_secs)),
)
).astimezone(_UTC)
return cls._from_py_unchecked(dt, state.nanos)
if not TYPE_CHECKING: # for a nicer autodoc
@overload
def add(self, d: TimeDelta, /) -> Instant: ...
@overload
def add(
self,
*,
weeks: float = 0,
days: float = 0,
hours: float = 0,
minutes: float = 0,
seconds: float = 0,
milliseconds: float = 0,
microseconds: float = 0,
nanoseconds: int = 0,
days_assumed_24h_ok: bool = UNSET,
) -> Instant: ...
[docs]
@no_type_check
def add(self, *args, **kwargs) -> Instant:
"""Add a time amount to this instant.
See the `docs on arithmetic <https://whenever.rtfd.io/en/latest/guide/arithmetic.html>`__ for more information.
"""
return self._shift(1, *args, **kwargs)
if not TYPE_CHECKING: # for a nicer autodoc
@overload
def subtract(self, d: TimeDelta, /) -> Instant: ...
@overload
def subtract(
self,
*,
weeks: float = 0,
days: float = 0,
hours: float = 0,
minutes: float = 0,
seconds: float = 0,
milliseconds: float = 0,
microseconds: float = 0,
nanoseconds: int = 0,
days_assumed_24h_ok: bool = UNSET,
) -> Instant: ...
[docs]
@no_type_check
def subtract(self, *args, **kwargs) -> Instant:
"""Subtract a time amount from this instant.
See the `docs on arithmetic <https://whenever.rtfd.io/en/latest/guide/arithmetic.html>`__ for more information.
"""
return self._shift(-1, *args, **kwargs)
@no_type_check
def _shift(
self,
sign: int,
arg: TimeDelta | UNSET = UNSET,
/,
**kwargs,
) -> Instant:
if kwargs:
if arg is not UNSET:
raise TypeError("Cannot mix positional and keyword arguments")
return self._shift_kwargs(sign, **kwargs)
elif arg is not UNSET:
if not isinstance(arg, TimeDelta):
raise TypeError(f"argument must be a TimeDelta, got {arg!r}")
return self._shift_kwargs(sign, nanoseconds=arg._total_ns)
else:
return self
def _shift_kwargs(
self,
sign: int,
*,
weeks: float = 0,
days: float = 0,
hours: float = 0,
minutes: float = 0,
seconds: float = 0,
milliseconds: float = 0,
microseconds: float = 0,
nanoseconds: int = 0,
days_assumed_24h_ok: bool = UNSET,
) -> Instant:
if (weeks or days) and not days_assumed_24h_ok:
warn(
DAYS_NOT_ALWAYS_24H_MSG,
DaysAssumed24HoursWarning,
stacklevel=4,
)
delta_ns = sign * (
int(weeks * 7 * 86_400_000_000_000)
+ int(days * 86_400_000_000_000)
+ 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(delta_ns) > _MAX_DELTA_NANOS:
raise ValueError("TimeDelta out of range")
delta_secs, nanos = divmod(self._nanos + delta_ns, 1_000_000_000)
return self._from_py_unchecked(
self._py_dt + _timedelta(seconds=delta_secs),
nanos,
)
[docs]
def round(
self,
unit: (
Literal[
"hour",
"minute",
"second",
"millisecond",
"microsecond",
"nanosecond",
]
| TimeDelta
) = "second",
/,
*,
increment: int = 1,
mode: RoundModeStr = "half_even",
) -> Instant:
"""Round the instant to the specified unit and increment,
or to a multiple of a :class:`TimeDelta`.
Various rounding modes are available.
>>> 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")
>>> Instant.from_utc(2020, 1, 1, 12, 39, 59).round(TimeDelta(minutes=15))
Instant("2020-01-01 12:45:00Z")
"""
if isinstance(unit, TimeDelta):
if increment != 1:
raise TypeError(
"Cannot specify both a TimeDelta and an increment"
)
increment_ns = unit._to_round_increment_ns(False)
else:
if unit == "day": # type: ignore[comparison-overlap]
raise ValueError(CANNOT_ROUND_DAY_MSG)
increment_ns = increment_to_ns_for_datetime(unit, increment)
rounded_time, next_day = Time._from_py_unchecked(
self._py_dt.time(), self._nanos
)._round_unchecked(
increment_ns,
mode,
86_400_000_000_000,
)
return self._from_py_unchecked(
_datetime.combine(
self._py_dt.date() + _timedelta(days=next_day),
rounded_time._py,
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.rtfd.io/en/latest/guide/arithmetic.html>`__ 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: _ExactTimeAlias) -> TimeDelta: ...
@overload
def __sub__(self, other: TimeDelta) -> Instant: ...
[docs]
def __sub__(
self, other: TimeDelta | _ExactTimeAlias
) -> Instant | TimeDelta:
"""Subtract another exact time or timedelta
See the `docs on arithmetic <https://whenever.rtfd.io/en/latest/guide/arithmetic.html>`__ for more information.
>>> 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, _EXACT_TIME_TYPES):
return self._subtract_operator(other)
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),),
)
# Backwards compatibility for instances pickled before 0.8.0
def _unpkl_utc(data: bytes) -> Instant:
secs, nanos = unpack("<qL", data)
if nanos >= 1_000_000_000:
raise ValueError(f"nanosecond out of range: {nanos}")
return Instant._from_py_unchecked(
_fromtimestamp(secs - 62_135_683_200, _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)
if nanos >= 1_000_000_000:
raise ValueError(f"nanosecond out of range: {nanos}")
return Instant._from_py_unchecked(
_EPOCH_DT + _timedelta(seconds=secs), nanos
)
[docs]
@final
class OffsetDateTime(_ExactAndLocalTime):
"""A datetime with a fixed UTC offset.
Useful for representing a moment in time together with the local
date and time as observed at that offset. The offset is fixed and
does not account for DST transitions.
>>> # Midnight in Salt Lake City
>>> OffsetDateTime(2023, 4, 21, offset=-6)
OffsetDateTime("2023-04-21 00:00:00-06:00")
Can also be constructed from an ISO 8601 string
or a standard library :class:`~datetime.datetime`:
>>> OffsetDateTime("2023-04-21T00:00:00-06:00")
OffsetDateTime("2023-04-21 00:00:00-06:00")
Convert to :class:`~whenever.ZonedDateTime` for DST-aware operations:
>>> dt = OffsetDateTime(2023, 4, 21, offset=-6)
>>> dt.assume_tz("US/Mountain")
ZonedDateTime("2023-04-21 00:00:00-06:00[US/Mountain]")
Important
---------
Operations that shift, round, or replace fields of this type keep the
original offset, which may become stale if DST rules have changed.
Use :meth:`assume_tz` to convert to a ``ZonedDateTime`` first if you
need DST-aware arithmetic.
"""
__slots__ = ()
# Overloads are for a nicer autodoc
# Typing is arranged in the stubs
if not TYPE_CHECKING:
@overload
def __init__(self, iso_string: str, /) -> None: ...
@overload
def __init__(self, py_dt: _datetime, /) -> None: ...
@overload
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: ...
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
__init__ = add_alternate_constructors(__init__, py_type=_datetime)
[docs]
@classmethod
def now(
cls,
offset: int | TimeDelta,
/,
*,
ignore_dst: bool = UNSET,
stale_offset_ok: bool = UNSET,
) -> OffsetDateTime:
"""Create an instance from the current time.
Warning
-------
Getting the current time as an ``OffsetDateTime`` with a fixed UTC offset
is correct for that offset, but the offset may be stale for the region you
intend: fixed offsets don't update when DST or other timezone rules change.
Use ``ZonedDateTime.now('<tz>')`` if you know the timezone, or
``Instant.now()`` for timezone-agnostic exact time.
Pass ``stale_offset_ok=True`` to suppress.
"""
if ignore_dst is not UNSET:
warn(
IGNORE_DST_DEPRECATED_MSG,
WheneverDeprecationWarning,
stacklevel=2,
)
if not stale_offset_ok:
warn(
OFFSET_NOW_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
secs, nanos = divmod(time_ns(), 1_000_000_000)
return cls._from_py_unchecked(
_fromtimestamp(secs, _load_offset(offset)), nanos
)
[docs]
@classmethod
def parse_iso(cls, s: str, /) -> OffsetDateTime:
"""Parse an ISO 8601 string with a UTC offset.
Supports ``YYYY-MM-DDTHH:MM:SS±HH:MM`` and variants
(see the `ISO 8601 docs <https://whenever.rtfd.io/en/latest/reference/iso8601.html>`__
for full details).
The inverse of the ``format_iso()`` method.
>>> OffsetDateTime.parse_iso("2020-08-15T23:12:00+02:00")
OffsetDateTime("2020-08-15 23:12:00+02:00")
Note
----
``Z`` is accepted as an offset and treated as ``+00:00``.
Strictly speaking, ``Z`` means "UTC" (i.e. no fixed offset),
but in practice it is almost universally used as a synonym for ``+00:00``.
"""
self = _object_new(cls)
self._init_from_iso(s)
return self
def _init_from_iso(self, s: str) -> None:
self._py_dt, self._nanos = offset_dt_from_iso(s)
[docs]
@classmethod
def from_timestamp(
cls,
i: int | float,
/,
*,
offset: int | TimeDelta,
ignore_dst: bool = UNSET,
stale_offset_ok: bool = UNSET,
) -> OffsetDateTime:
"""Create an instance from a UNIX timestamp (in seconds).
The inverse of the ``timestamp()`` method.
Warning
-------
Converting a UNIX timestamp to ``OffsetDateTime`` with a fixed UTC offset
is correct for that offset, but the offset may be stale for the region you
intend at that timestamp: a fixed offset contains no DST or other timezone
rules. Use
``ZonedDateTime.from_timestamp(ts, tz='<tz>')`` if you know the timezone,
or ``Instant.from_timestamp()`` for timezone-agnostic exact time.
Pass ``stale_offset_ok=True`` to suppress.
"""
if ignore_dst is not UNSET:
warn(
IGNORE_DST_DEPRECATED_MSG,
WheneverDeprecationWarning,
stacklevel=2,
)
if not stale_offset_ok:
warn(
OFFSET_FROM_TIMESTAMP_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
secs, fract = divmod(i, 1)
return cls._from_py_unchecked(
_fromtimestamp(secs, _load_offset(offset)),
int(fract * 1_000_000_000),
)
[docs]
@classmethod
def from_timestamp_millis(
cls,
i: int,
/,
*,
offset: int | TimeDelta,
ignore_dst: bool = UNSET,
stale_offset_ok: bool = UNSET,
) -> OffsetDateTime:
"""Create an instance from a UNIX timestamp (in milliseconds).
The inverse of the ``timestamp_millis()`` method.
See :meth:`from_timestamp` for more information.
"""
if ignore_dst is not UNSET:
warn(
IGNORE_DST_DEPRECATED_MSG,
WheneverDeprecationWarning,
stacklevel=2,
)
if not stale_offset_ok:
warn(
OFFSET_FROM_TIMESTAMP_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
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
)
[docs]
@classmethod
def from_timestamp_nanos(
cls,
i: int,
/,
*,
offset: int | TimeDelta,
ignore_dst: bool = UNSET,
stale_offset_ok: bool = UNSET,
) -> OffsetDateTime:
"""Create an instance from a UNIX timestamp (in nanoseconds).
The inverse of the ``timestamp_nanos()`` method.
See :meth:`from_timestamp` for more information.
"""
if ignore_dst is not UNSET:
warn(
IGNORE_DST_DEPRECATED_MSG,
WheneverDeprecationWarning,
stacklevel=2,
)
if not stale_offset_ok:
warn(
OFFSET_FROM_TIMESTAMP_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
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
)
def _init_from_py(self, d: _datetime) -> None:
if d.tzinfo is None or (offset := d.utcoffset()) is None:
raise ValueError(
"Cannot create from a naive datetime. "
"Use PlainDateTime() instead."
)
elif offset.microseconds:
raise ValueError("sub-second offset precision not supported")
self._py_dt = check_utc_bounds(
_strip_subclasses(
d.replace(microsecond=0, tzinfo=_timezone(offset))
)
)
self._nanos = d.microsecond * 1_000
if not TYPE_CHECKING: # for a nicer autodoc
@overload
def replace(
self,
year: int = ...,
month: int = ...,
day: int = ...,
hour: int = ...,
minute: int = ...,
second: int = ...,
*,
nanosecond: int = ...,
offset: int | TimeDelta = ...,
ignore_dst: bool = ...,
stale_offset_ok: bool = ...,
) -> OffsetDateTime: ...
[docs]
def replace(
self,
/,
ignore_dst: bool = UNSET,
stale_offset_ok: bool = UNSET,
**kwargs: Any,
) -> OffsetDateTime:
"""Construct a new instance with the given fields replaced.
Warning
-------
Replacing fields of an ``OffsetDateTime`` keeps the fixed UTC offset,
which may no longer be correct after the change (e.g. replacing the month
on a European-timezone datetime may move it into a different DST period).
Convert to ``ZonedDateTime`` first for timezone-aware field replacement
using :meth:`assume_tz`.
Pass ``stale_offset_ok=True`` to suppress.
"""
if ignore_dst is not UNSET:
warn(
IGNORE_DST_DEPRECATED_MSG,
WheneverDeprecationWarning,
stacklevel=2,
)
if not stale_offset_ok:
warn(
OFFSET_REPLACE_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
_check_invalid_replace_kwargs(kwargs)
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
)
[docs]
def replace_date(
self,
date: Date,
/,
*,
ignore_dst: bool = UNSET,
stale_offset_ok: bool = UNSET,
) -> OffsetDateTime:
"""Construct a new instance with the date replaced.
See :meth:`replace` for more information.
"""
if ignore_dst is not UNSET:
warn(
IGNORE_DST_DEPRECATED_MSG,
WheneverDeprecationWarning,
stacklevel=2,
)
if not stale_offset_ok:
warn(
OFFSET_REPLACE_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
return self._from_py_unchecked(
check_utc_bounds(
_datetime.combine(date._py_date, self._py_dt.timetz())
),
self._nanos,
)
[docs]
def replace_time(
self,
time: Time,
/,
*,
ignore_dst: bool = UNSET,
stale_offset_ok: bool = UNSET,
) -> OffsetDateTime:
"""Construct a new instance with the time replaced.
See :meth:`replace` for more information.
"""
if ignore_dst is not UNSET:
warn(
IGNORE_DST_DEPRECATED_MSG,
WheneverDeprecationWarning,
stacklevel=2,
)
if not stale_offset_ok:
warn(
OFFSET_REPLACE_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
return self._from_py_unchecked(
check_utc_bounds(
_datetime.combine(
self._py_dt.date(), time._py, self._py_dt.tzinfo
)
),
time._nanos,
)
[docs]
def start_of(
self,
unit: Literal[
"year",
"month",
"week_mon",
"week_sun",
"day",
"hour",
"minute",
"second",
],
/,
*,
stale_offset_ok: bool = UNSET,
) -> OffsetDateTime:
"""The start of the given unit
>>> OffsetDateTime(2024, 8, 15, 14, 30, offset=5).start_of("day")
OffsetDateTime("2024-08-15 00:00:00+05:00")
Warning
-------
The offset is preserved, which may not be correct for the
resulting time. See :class:`~whenever.StaleOffsetWarning`.
Pass ``stale_offset_ok=True`` to suppress.
"""
if not stale_offset_ok:
warn(
OFFSET_START_END_OF_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
new_dt = _start_of_dt(self._py_dt, unit)
return self._from_py_unchecked(check_utc_bounds(new_dt), 0)
[docs]
def end_of(
self,
unit: Literal[
"year",
"month",
"week_mon",
"week_sun",
"day",
"hour",
"minute",
"second",
],
/,
*,
stale_offset_ok: bool = UNSET,
) -> OffsetDateTime:
"""The end of the given unit
>>> OffsetDateTime(2024, 8, 15, 14, 30, offset=5).end_of("day")
OffsetDateTime("2024-08-15 23:59:59.999999999+05:00")
See also :meth:`start_of`
"""
if not stale_offset_ok:
warn(
OFFSET_START_END_OF_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
new_dt = _end_of_dt(self._py_dt, unit)
return self._from_py_unchecked(
check_utc_bounds(new_dt), _MAX_SUBSEC_NANOS
)
def __hash__(self) -> int:
return hash((self._py_dt, self._nanos))
[docs]
def __add__(self, delta: TimeDelta) -> OffsetDateTime:
"""Add a time delta to this datetime.
Warning
-------
Shifting an ``OffsetDateTime`` keeps the fixed UTC offset, which may not
match the actual offset after a DST or other timezone transition.
For example, adding 1 day to ``2024-03-09 12:00-07:00`` gives
``2024-03-10 12:00-07:00``, but if this offset represents Denver,
Colorado (America/Denver), the actual offset changed to ``-06:00`` that day.
Convert to a ``ZonedDateTime`` first for timezone-aware arithmetic
using :meth:`assume_tz`.
Use ``.add(..., stale_offset_ok=True)`` or Python's
standard warning filters to suppress.
"""
if isinstance(delta, TimeDelta):
warn(
OFFSET_SHIFT_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
delta_secs, nanos = divmod(
delta._total_ns + self._nanos, 1_000_000_000
)
return self._from_py_unchecked(
check_utc_bounds(self._py_dt + _timedelta(seconds=delta_secs)),
nanos,
)
return NotImplemented
@overload
def __sub__(self, other: _ExactTimeAlias) -> TimeDelta: ...
@overload
def __sub__(self, other: TimeDelta) -> OffsetDateTime: ...
[docs]
def __sub__(
self,
other: _ExactTimeAlias | TimeDelta,
) -> TimeDelta | OffsetDateTime:
"""Subtract a time delta or calculate the duration to another exact time."""
if isinstance(other, TimeDelta):
warn(
OFFSET_SHIFT_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
delta_secs, nanos = divmod(
-other._total_ns + self._nanos, 1_000_000_000
)
return self._from_py_unchecked(
check_utc_bounds(self._py_dt + _timedelta(seconds=delta_secs)),
nanos,
)
return super()._subtract_operator(other)
[docs]
@classmethod
def parse_strptime(cls, s: str, /, *, format: str) -> OffsetDateTime:
"""Parse a datetime with offset using the standard library ``strptime()`` method.
.. deprecated:: 0.10.0
Use :meth:`parse` with a pattern string instead, or use
``OffsetDateTime(datetime.strptime(...))``.
"""
warn(
"parse_strptime() is deprecated and will be removed in a future "
"release; "
"use parse() with a pattern string instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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 offset precision not supported")
return cls._from_py_unchecked(
check_utc_bounds(parsed.replace(microsecond=0)),
parsed.microsecond * 1_000,
)
[docs]
@classmethod
def parse_rfc2822(cls, s: str, /) -> OffsetDateTime:
"""Parse an offset datetime in RFC 2822 format.
The inverse of the ``format_rfc2822()`` method.
>>> 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)
_PATTERN_CATS = frozenset({"date", "time", "offset"})
[docs]
@classmethod
def parse(cls, s: str, /, *, format: str) -> OffsetDateTime:
"""Parse an offset datetime from a custom pattern string.
The pattern **must** include an offset field (``x``/``X``).
See :ref:`pattern-format` for details.
.. tip::
If your input string doesn't include an offset, parse it with
:meth:`PlainDateTime.parse` first, then convert using
:meth:`~PlainDateTime.assume_fixed_offset` or
:meth:`~PlainDateTime.assume_tz`.
>>> OffsetDateTime.parse("2024-03-15 14:30+02:00", format="YYYY-MM-DD hh:mmxxx")
OffsetDateTime("2024-03-15 14:30:00+02:00")
"""
elements = compile_pattern(format)
validate_fields(elements, cls._PATTERN_CATS, "OffsetDateTime")
state = parse_fields(elements, s)
if state.offset_secs is None:
raise ValueError(
"OffsetDateTime.parse() pattern must include an offset "
"field (x/X)"
)
if state.year is None or state.month is None or state.day is None:
raise ValueError(
"Pattern must include year, month, and day fields"
)
result = cls(
state.year,
state.month,
state.day,
state.hour or 0,
state.minute or 0,
state.second or 0,
nanosecond=state.nanos,
offset=TimeDelta(seconds=state.offset_secs),
)
if (
state.weekday is not None
and result._py_dt.weekday() != state.weekday
):
raise ValueError("Parsed weekday does not match the date")
return result
if not TYPE_CHECKING: # for a nicer autodoc
@overload
def add(self, delta: AnyDelta, /) -> OffsetDateTime: ...
@overload
def add(
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,
ignore_dst: bool = ...,
stale_offset_ok: bool = ...,
) -> OffsetDateTime: ...
[docs]
@no_type_check
def add(self, *args, **kwargs) -> OffsetDateTime:
"""Add a time amount to this datetime.
Warning
-------
Shifting an ``OffsetDateTime`` keeps the fixed UTC offset, which may not
match the actual offset after a DST or other timezone transition.
Convert to a ``ZonedDateTime`` first for timezone-aware arithmetic
using :meth:`assume_tz`.
Pass ``stale_offset_ok=True`` to suppress;
Python's standard warning filters also apply.
"""
return self._shift(1, *args, **kwargs)
if not TYPE_CHECKING: # for a nicer autodoc
@overload
def subtract(self, delta: AnyDelta, /) -> OffsetDateTime: ...
@overload
def subtract(
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,
ignore_dst: bool = ...,
stale_offset_ok: bool = ...,
) -> OffsetDateTime: ...
[docs]
@no_type_check
def subtract(self, *args, **kwargs) -> OffsetDateTime:
"""Subtract a time amount from this datetime.
See :meth:`add` for more information.
"""
return self._shift(-1, *args, **kwargs)
@no_type_check
def _shift(
self,
sign: int,
arg: AnyDelta | UNSET = UNSET,
/,
*,
ignore_dst: bool = UNSET,
stale_offset_ok: bool = UNSET,
**kwargs,
) -> OffsetDateTime:
if ignore_dst is not UNSET:
warn(
IGNORE_DST_DEPRECATED_MSG,
WheneverDeprecationWarning,
stacklevel=3,
)
if not stale_offset_ok:
warn(
OFFSET_SHIFT_STALE_MSG,
StaleOffsetWarning,
stacklevel=3,
)
if kwargs:
if arg is UNSET:
return self._shift_kwargs(sign, **kwargs)
raise TypeError("Cannot mix positional and keyword arguments")
elif isinstance(arg, (ItemizedDelta, ItemizedDateDelta)):
return self._shift_kwargs(sign, **arg)
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 = _datetime.combine(
(
self.date()
._add_months(sign * (years * 12 + months))
._add_days(sign * (weeks * 7 + days))
)._py_date,
self._py_dt.timetz(),
)
delta_ns = _time_units_to_nanos(
sign,
hours,
minutes,
seconds,
milliseconds,
microseconds,
nanoseconds,
)
delta_secs, nanos = divmod(delta_ns + self._nanos, 1_000_000_000)
return self._from_py_unchecked(
check_utc_bounds(
py_dt_with_new_date + _timedelta(seconds=delta_secs)
),
nanos,
)
[docs]
def round(
self,
unit: (
Literal[
"day",
"hour",
"minute",
"second",
"millisecond",
"microsecond",
"nanosecond",
]
| TimeDelta
) = "second",
/,
*,
increment: int = 1,
mode: RoundModeStr = "half_even",
ignore_dst: bool = UNSET,
stale_offset_ok: bool = UNSET,
) -> OffsetDateTime:
"""Round the datetime to the specified unit and increment,
or to a multiple of a :class:`TimeDelta`.
Different rounding modes are available.
>>> 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]")
Warning
-------
Rounding an ``OffsetDateTime`` keeps the fixed UTC offset, which may not
be accurate if the rounded datetime crosses into a different DST period.
Convert to a ``ZonedDateTime`` first for timezone-aware rounding
using :meth:`assume_tz`.
Pass ``stale_offset_ok=True`` to suppress.
"""
if ignore_dst is not UNSET:
warn(
IGNORE_DST_DEPRECATED_MSG,
WheneverDeprecationWarning,
stacklevel=2,
)
if not stale_offset_ok:
warn(
OFFSET_ROUND_STALE_MSG,
StaleOffsetWarning,
stacklevel=2,
)
if isinstance(unit, TimeDelta):
if increment != 1:
raise TypeError(
"Cannot specify both a TimeDelta and an increment"
)
increment_ns = unit._to_round_increment_ns(False)
else:
increment_ns = increment_to_ns_for_datetime(unit, increment)
return (
self.to_plain()
._round_unchecked(
increment_ns,
mode,
86_400_000_000_000,
)
.assume_fixed_offset(self.offset)
)
[docs]
def assume_tz(
self, tz: str, *, offset_mismatch: OffsetMismatchStr = "raise"
) -> ZonedDateTime:
"""Associate this offset datetime with a timezone, returning a ZonedDateTime.
This is the inverse of :meth:`ZonedDateTime.to_fixed_offset`.
By default, if the offset of this datetime doesn't match the actual
offset of the timezone at this datetime, an error is raised.
Using the ``offset_mismatch`` parameter, you can choose to ignore
the mismatch, keeping either the instant or the local time the same.
"""
if offset_mismatch not in ("raise", "keep_instant", "keep_local"):
raise ValueError(
f"Invalid value for offset_mismatch: {offset_mismatch!r}"
)
result = self.to_tz(tz)
if (
offset_mismatch == "keep_instant"
or result._py_dt.utcoffset() == self._py_dt.utcoffset()
):
return result
elif offset_mismatch == "raise":
offset_expected = _format_offset(
self._py_dt.utcoffset(), # type: ignore[arg-type]
basic=False,
)
offset_actual = _format_offset(
result._py_dt.utcoffset(), # type: ignore[arg-type]
basic=False,
)
raise InvalidOffsetError(
f"Offset mismatch: timezone {tz!r} has offset {offset_actual}, "
f"but offset {offset_expected} was expected"
)
else: # offset_mismatch == "keep_local":
return self.to_plain().assume_tz(tz)
@overload
def since(
self,
b: OffsetDateTime,
/,
*,
total: DeltaUnitStr,
) -> float: ...
@overload
def since(
self,
b: OffsetDateTime,
/,
*,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDelta: ...
[docs]
def since(
self,
b: OffsetDateTime,
/,
*,
total: DeltaUnitStr = UNSET,
in_units: Sequence[DeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
) -> ItemizedDelta | float:
"""Calculate the duration since another OffsetDateTime,
in terms of the specified units.
>>> d1 = OffsetDateTime(2020, 8, 15, 23, 12, offset=2)
>>> d2 = OffsetDateTime(2020, 8, 14, 22, offset=2)
>>> d1.since(d2, in_units=["hours", "minutes"],
... round_increment=15,
... round_mode="ceil")
ItemizedDelta("PT25h15m")
When calculating calendar units (years, months, weeks, days),
both datetimes must have the same offset.
"""
return _offset_since(
self,
b,
None if total is UNSET else total,
None if in_units is UNSET else in_units,
round_mode,
round_increment,
)
@overload
def until(
self,
b: OffsetDateTime,
/,
*,
total: DeltaUnitStr,
) -> float: ...
@overload
def until(
self,
b: OffsetDateTime,
/,
*,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDelta: ...
[docs]
def until(
self,
b: OffsetDateTime,
/,
*,
total: DeltaUnitStr = UNSET,
in_units: Sequence[DeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
) -> ItemizedDelta | float:
"""Inverse of the ``since()`` method. See :meth:`since` for more information."""
return _offset_since(
b,
self,
None if total is UNSET else total,
None if in_units is UNSET else in_units,
round_mode,
round_increment,
)
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)
return OffsetDateTime(
*args,
nanosecond=nanos,
offset=TimeDelta(seconds=offset_secs),
)
[docs]
@final
class ZonedDateTime(_ExactAndLocalTime):
"""A datetime associated with a timezone from the IANA database.
This is the right type when you need both the exact moment *and*
the local date/time at a specific location. Arithmetic is fully
DST-aware: the offset is always kept in sync with the timezone rules.
>>> ZonedDateTime("2024-12-08T11[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]")
>>> # From a standard library datetime (must have a ZoneInfo tzinfo)
>>> ZonedDateTime(datetime(2020, 8, 15, 23, 12, tzinfo=ZoneInfo("Europe/London")))
ZonedDateTime("2020-08-15 23:12:00+01:00[Europe/London]")
Convert to other types to discard timezone information:
>>> d = ZonedDateTime(2024, 7, 1, 12, tz="Europe/Amsterdam")
>>> d.to_instant()
Instant("2024-07-01 10:00:00Z")
>>> d.to_plain()
PlainDateTime("2024-07-01 12:00:00")
Important
---------
To use this type properly, read more about
`ambiguity in timezones <https://whenever.rtfd.io/en/latest/guide/ambiguity.html>`_.
"""
__slots__ = ("_tz",)
# Overloads are for a nicer autodoc
# Typing is arranged in the stubs
if not TYPE_CHECKING:
@overload
def __init__(self, iso_string: str, /) -> None: ...
@overload
def __init__(self, py_dt: _datetime, /) -> None: ...
@overload
def __init__(
self,
year: int,
month: int,
day: int,
hour: int = 0,
minute: int = 0,
second: int = 0,
*,
nanosecond: int = 0,
tz: str,
disambiguate: DisambiguateStr = "compatible",
) -> None: ...
def __init__(
self,
year: int,
month: int,
day: int,
hour: int = 0,
minute: int = 0,
second: int = 0,
*,
nanosecond: int = 0,
tz: str,
disambiguate: DisambiguateStr = "compatible",
) -> None:
self._py_dt = resolve_ambiguity(
_datetime(
year,
month,
day,
hour,
minute,
second,
0,
),
(_tz := get_tz(tz)),
disambiguate,
)
if nanosecond < 0 or nanosecond >= 1_000_000_000:
raise ValueError(f"nanosecond out of range: {nanosecond}")
self._nanos = nanosecond
self._tz = _tz
__init__ = add_alternate_constructors(__init__, py_type=_datetime)
[docs]
@classmethod
def from_system_tz(
cls,
year: int,
month: int,
day: int,
hour: int = 0,
minute: int = 0,
second: int = 0,
*,
nanosecond: int = 0,
disambiguate: DisambiguateStr = "compatible",
) -> ZonedDateTime:
"""Create an instance in the system timezone.
Equivalent to ``ZonedDateTime(..., tz=<the system timezone>)``,
except it also works for system timezones whose corresponding
IANA timezone ID is unknown.
>>> ZonedDateTime.from_system_tz(2020, 8, 15, hour=23, minute=12)
ZonedDateTime("2020-08-15 23:12:00+02:00[Europe/Berlin]")
"""
tz = get_system_tz()
dt = resolve_ambiguity(
_datetime(
year,
month,
day,
hour,
minute,
second,
0,
),
tz,
disambiguate,
)
if nanosecond < 0 or nanosecond >= 1_000_000_000:
raise ValueError(f"nanosecond out of range: {nanosecond}")
return cls._from_py_unchecked(dt, nanosecond, tz)
[docs]
@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)
_tz = get_tz(tz)
return cls._from_py_unchecked(_from_epoch(secs, _tz), nanos, _tz)
[docs]
@classmethod
def now_in_system_tz(cls) -> ZonedDateTime:
"""Create an instance from the current time in the system timezone.
Equivalent to ``Instant.now().to_system_tz()``.
"""
tz = get_system_tz()
secs, nanos = divmod(time_ns(), 1_000_000_000)
return cls._from_py_unchecked(_from_epoch(secs, tz), nanos, tz)
# FUTURE: allow handling offset mismatches
[docs]
@classmethod
def parse_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_iso()`` method.
>>> ZonedDateTime.parse_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).
Although it is gaining popularity, it is not yet widely supported.
"""
self = _object_new(cls)
self._init_from_iso(s)
return self
def _init_from_iso(self, s: str) -> None:
self._py_dt, self._nanos, self._tz = zdt_from_iso(s)
_PATTERN_CATS = frozenset({"date", "time", "offset", "tz"})
[docs]
@classmethod
def parse(
cls,
s: str,
/,
*,
format: str,
disambiguate: DisambiguateStr = "compatible",
) -> ZonedDateTime:
"""Parse a zoned datetime from a custom pattern string.
The pattern **must** include a timezone ID field (``VV``).
An offset field (``x``/``X``) is optional but recommended for
disambiguation during DST transitions.
See :ref:`pattern-format` for details.
.. tip::
If your input string doesn't include a timezone ID, parse it with
:meth:`PlainDateTime.parse` first, then convert using
:meth:`~PlainDateTime.assume_tz`.
>>> ZonedDateTime.parse(
... "2024-03-15 14:30+01:00[Europe/Paris]",
... format="YYYY-MM-DD hh:mmxxx'['VV']'",
... )
ZonedDateTime("2024-03-15 14:30:00+01:00[Europe/Paris]")
"""
elements = compile_pattern(format)
validate_fields(elements, cls._PATTERN_CATS, "ZonedDateTime")
state = parse_fields(elements, s)
if state.tz_id is None:
raise ValueError(
"ZonedDateTime.parse() pattern must include a "
"timezone ID field (VV)"
)
if state.year is None or state.month is None or state.day is None:
raise ValueError(
"Pattern must include year, month, and day fields"
)
tz = get_tz(state.tz_id)
dt = _datetime(
state.year,
state.month,
state.day,
state.hour or 0,
state.minute or 0,
state.second or 0,
)
if state.offset_secs is not None:
# Use offset to disambiguate during DST transitions.
# Try both "earlier" and "later" to find the matching offset.
earlier = resolve_ambiguity(dt, tz, "earlier")
earlier_offset = int(
earlier.utcoffset().total_seconds() # type: ignore[union-attr]
)
if earlier_offset == state.offset_secs:
resolved = earlier
else:
later = resolve_ambiguity(dt, tz, "later")
later_offset = int(
later.utcoffset().total_seconds() # type: ignore[union-attr]
)
if later_offset == state.offset_secs:
resolved = later
else:
raise ValueError(
f"Offset {state.offset_secs}s does not match "
f"timezone {state.tz_id!r}"
)
# Reject skipped times: if the resolved local time doesn't
# match the input, the time was shifted out of a DST gap.
if (
resolved.hour != (state.hour or 0)
or resolved.minute != (state.minute or 0)
or resolved.second != (state.second or 0)
):
raise ValueError(
f"The local time does not exist in "
f"timezone {state.tz_id!r}"
)
else:
resolved = resolve_ambiguity(dt, tz, disambiguate)
self = _object_new(cls)
self._py_dt = resolved
self._nanos = state.nanos
self._tz = tz
if state.weekday is not None and resolved.weekday() != state.weekday:
raise ValueError("Parsed weekday does not match the date")
return self
[docs]
@classmethod
def from_timestamp(cls, i: int | float, /, *, tz: str) -> ZonedDateTime:
"""Create an instance from a UNIX timestamp (in seconds).
The inverse of the ``timestamp()`` method.
"""
secs, fract = divmod(i, 1)
_tz = get_tz(tz)
return cls._from_py_unchecked(
_from_epoch(int(secs), _tz), int(fract * 1_000_000_000), _tz
)
[docs]
@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)
_tz = get_tz(tz)
return cls._from_py_unchecked(
_from_epoch(secs, _tz), millis * 1_000_000, _tz
)
[docs]
@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)
_tz = get_tz(tz)
return cls._from_py_unchecked(_from_epoch(secs, _tz), nanos, _tz)
def _init_from_py(self, d: _datetime) -> None:
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)
# We go through the epoch to ensure the result represents the same instant.
# If we'd use the local time, ZoneInfo could theoretically pick a different
# offset than we get from our database.
epoch = int(d.timestamp())
_tz = get_tz(d.tzinfo.key)
offset = _tz.offset_for_instant(int(epoch))
# Recalculating from epoch ensures we shift times within a gap
self._py_dt = _from_epoch(int(epoch), _tz).astimezone(
mk_fixed_tzinfo(offset)
)
self._nanos = d.microsecond * 1_000
self._tz = _tz
[docs]
def replace_date(
self, date: Date, /, disambiguate: DisambiguateStr = UNSET
) -> 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.time()),
self._tz,
disambiguate or self._py_dt.utcoffset(),
),
self._nanos,
self._tz,
)
[docs]
def replace_time(
self, time: Time, /, disambiguate: DisambiguateStr = UNSET
) -> 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),
self._tz,
disambiguate or self._py_dt.utcoffset(),
),
time._nanos,
self._tz,
)
if not TYPE_CHECKING: # for a nicer autodoc
@overload
def replace(
self,
year: int = ...,
month: int = ...,
day: int = ...,
hour: int = ...,
minute: int = ...,
second: int = ...,
*,
nanosecond: int = ...,
tz: str = ...,
disambiguate: DisambiguateStr = ...,
) -> ZonedDateTime: ...
[docs]
def replace(
self, /, disambiguate: DisambiguateStr = UNSET, **kwargs: Any
) -> ZonedDateTime:
"""Construct a new instance with the given fields replaced.
Tip
---
If you need the start or end of a unit (e.g. the start of the day),
use :meth:`start_of` and :meth:`end_of` instead.
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/guide/ambiguity.html>`__
for more information.
"""
_check_invalid_replace_kwargs(kwargs)
try:
tzid = kwargs.pop("tz")
except KeyError:
tz = self._tz
else:
tz = get_tz(tzid)
# Don't attempt to preserve offset when changing tz
if tz is not self._tz:
disambiguate = disambiguate or "compatible"
nanos = _pop_nanos_kwarg(kwargs, self._nanos)
return self._from_py_unchecked(
resolve_ambiguity(
self._py_dt.replace(**kwargs, tzinfo=None),
tz,
disambiguate or self._py_dt.utcoffset(),
),
nanos,
tz,
)
@property
def tz(self) -> str | None:
"""The timezone ID. In rare cases, this may be ``None``,
if the ``ZonedDateTime`` was created from a system timezone
without a known IANA key.
"""
return self._tz.key
def __hash__(self) -> int:
return hash((self._py_dt, self._nanos))
[docs]
def __add__(
self,
delta: TimeDelta | DateDelta | DateTimeDelta,
) -> ZonedDateTime:
"""Add an amount of time, accounting for timezone changes (e.g. DST).
See `the docs <https://whenever.rtfd.io/en/latest/guide/arithmetic.html>`__
for more information.
"""
if isinstance(delta, TimeDelta):
delta_secs, nanos = divmod(
delta._time_part._total_ns + self._nanos, 1_000_000_000
)
new_epoch = int(self._py_dt.timestamp()) + delta_secs
return self._from_py_unchecked(
_from_epoch(new_epoch, self._tz),
nanos,
self._tz,
)
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: _ExactTimeAlias) -> TimeDelta: ...
@overload
def __sub__(self, other: TimeDelta) -> ZonedDateTime: ...
[docs]
def __sub__(
self, other: TimeDelta | _ExactTimeAlias
) -> _ExactTimeAlias | TimeDelta:
"""Subtract another datetime or duration.
See `the docs <https://whenever.rtfd.io/en/latest/guide/arithmetic.html>`__
for more information.
"""
if isinstance(other, _EXACT_TIME_TYPES):
return self._subtract_operator(other)
elif isinstance(
other,
(
TimeDelta,
DateDelta,
DateTimeDelta,
),
):
return self + -other
return NotImplemented
@overload
def add(
self,
d: AnyDelta,
/,
*,
disambiguate: DisambiguateStr = ...,
) -> ZonedDateTime: ...
@overload
def add(
self,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
hours: float = ...,
minutes: float = ...,
seconds: float = ...,
milliseconds: float = ...,
microseconds: float = ...,
nanoseconds: int = ...,
disambiguate: DisambiguateStr = ...,
) -> ZonedDateTime: ...
[docs]
@no_type_check
def add(self, *args, **kwargs) -> ZonedDateTime:
"""Return a new ``ZonedDateTime`` shifted by the given time amounts
Important
---------
Shifting by **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/guide/arithmetic.html>`__
for more information.
"""
return self._shift(1, *args, **kwargs)
@overload
def subtract(
self,
d: AnyDelta,
/,
*,
disambiguate: DisambiguateStr = ...,
) -> ZonedDateTime: ...
@overload
def subtract(
self,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
hours: float = ...,
minutes: float = ...,
seconds: float = ...,
milliseconds: float = ...,
microseconds: float = ...,
nanoseconds: int = ...,
disambiguate: DisambiguateStr = ...,
) -> ZonedDateTime: ...
[docs]
@no_type_check
def subtract(self, *args, **kwargs) -> ZonedDateTime:
"""The inverse of the ``add()`` method. See :meth:`add` for more information."""
return self._shift(-1, *args, **kwargs)
@no_type_check
def _shift(
self,
sign: int,
delta: AnyDelta | UNSET = UNSET,
/,
*,
disambiguate: DisambiguateStr = UNSET,
**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 UNSET:
return self
elif isinstance(delta, (ItemizedDelta, ItemizedDateDelta)):
return self._shift_kwargs(sign, **delta, disambiguate=disambiguate)
elif isinstance(delta, (TimeDelta, DateDelta, DateTimeDelta)):
return self._shift_kwargs(
sign,
months=delta._date_part._months,
days=delta._date_part._days,
nanoseconds=delta._time_part._total_ns,
disambiguate=disambiguate,
)
else:
raise TypeError("argument must be a delta, got {delta!r}")
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: DisambiguateStr = UNSET,
) -> 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,
)
delta_ns = _time_units_to_nanos(
sign,
hours,
minutes,
seconds,
milliseconds,
microseconds,
nanoseconds,
)
delta_secs, nanos = divmod(delta_ns + self._nanos, 1_000_000_000)
new_epoch = int(self._py_dt.timestamp()) + delta_secs
return self._from_py_unchecked(
_from_epoch(new_epoch, self._tz),
nanos,
self._tz,
)
@overload
def since(
self,
b: ZonedDateTime,
/,
*,
total: DeltaUnitStr,
) -> float: ...
@overload
def since(
self,
b: ZonedDateTime,
/,
*,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDelta: ...
# FUTURE: add round_unit to the signature,
# in order to allow rounding to millis, micros, and nanos
[docs]
def since(
self,
b: ZonedDateTime,
/,
*,
total: DeltaUnitStr = UNSET,
in_units: Sequence[DeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
) -> ItemizedDelta | float:
"""Calculate the duration since another ZonedDateTime,
in terms of the specified units.
>>> d1 = ZonedDateTime("2020-08-15T23:12:00+01:00[Europe/London]")
>>> d2 = ZonedDateTime("2020-08-14T22:00:00+09:00[Asia/Tokyo]")
>>> d1.since(d2, in_units=["hours", "minutes"],
... round_increment=15,
... round_mode="ceil")
ItemizedDelta("PT33h15m")
When calculating calendar units (years, months, weeks, days),
both datetimes must have the same timezone.
"""
return _zoned_since(
self,
b,
None if total is UNSET else total,
None if in_units is UNSET else in_units,
round_mode,
round_increment,
)
@overload
def until(
self,
b: ZonedDateTime,
/,
*,
total: DeltaUnitStr,
) -> float: ...
@overload
def until(
self,
b: ZonedDateTime,
/,
*,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDelta: ...
[docs]
def until(
self,
b: ZonedDateTime,
/,
*,
total: DeltaUnitStr = UNSET,
in_units: Sequence[DeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
) -> ItemizedDelta | float:
"""Inverse of the ``since()`` method. See :meth:`since` for more information."""
return _zoned_since(
b,
self,
None if total is UNSET else total,
None if in_units is UNSET else in_units,
round_mode,
round_increment,
)
[docs]
def is_ambiguous(self) -> bool:
"""Whether the date and time-of-day are ambiguous, e.g. due to a DST transition.
>>> ZonedDateTime(2020, 8, 15, 23, tz="Europe/London").is_ambiguous()
False
>>> ZonedDateTime(2023, 10, 29, 2, 15, tz="Europe/Amsterdam").is_ambiguous()
True
"""
return (
type(
self._tz.ambiguity_for_local(self._py_dt.replace(tzinfo=None))
)
is not Unambiguous
)
[docs]
def next_transition(self) -> ZonedDateTime | None:
"""The next timezone transition after this datetime, if any.
Returns ``None`` if the timezone has no further transitions
(e.g. for UTC or fixed-offset timezones).
>>> d = ZonedDateTime(2024, 1, 1, tz="America/New_York")
>>> d.next_transition()
ZonedDateTime(2024-03-10 03:00:00-04:00[America/New_York])
"""
epoch = int(self._py_dt.timestamp())
if (result := self._tz.next_transition(epoch)) is None:
return None
t, offset = result
return self._from_py_unchecked(
_from_epoch_offset(t, offset), 0, self._tz
)
[docs]
def prev_transition(self) -> ZonedDateTime | None:
"""The previous timezone transition before this datetime, if any.
Returns ``None`` if the timezone has no earlier transitions
(e.g. for UTC or fixed-offset timezones).
>>> d = ZonedDateTime(2024, 1, 1, tz="America/New_York")
>>> d.prev_transition()
ZonedDateTime(2023-11-05 01:00:00-05:00[America/New_York])
"""
epoch = int(self._py_dt.timestamp())
if (result := self._tz.prev_transition(epoch)) is None:
return None
t, offset = result
return self._from_py_unchecked(
_from_epoch_offset(t, offset), 0, self._tz
)
[docs]
def dst_offset(self) -> TimeDelta:
"""The DST offset (adjustment) as a :class:`TimeDelta`.
>>> ZonedDateTime(2020, 8, 15, tz="Europe/London").dst_offset()
TimeDelta("PT1h")
>>> ZonedDateTime(2020, 1, 15, tz="Europe/London").dst_offset()
TimeDelta("PT0s")
This value is ``TimeDelta.ZERO`` when DST is not active:
>>> if zoned_dt.dst_offset():
... print("DST is active")
Note
----
Some timezones have unusual DST rules. For example,
Europe/Dublin defines its standard time as IST (UTC+1) and uses
"negative DST" in winter. In such cases, this method
returns a negative value during winter.
"""
dst_saving, _ = self._tz.meta_for_instant(int(self._py_dt.timestamp()))
return TimeDelta._from_nanos_unchecked(dst_saving * 1_000_000_000)
[docs]
def tz_abbrev(self) -> str:
"""The timezone abbreviation (e.g. ``"EST"``, ``"CEST"``).
>>> ZonedDateTime(2020, 8, 15, tz="Europe/London").tz_abbrev()
'BST'
>>> ZonedDateTime(2020, 1, 15, tz="Europe/London").tz_abbrev()
'GMT'
Warning
-------
The abbreviation is often ambiguous and may not be unique,
but it is commonly used in human-readable formats.
Use the timezone ID (e.g. ``"Europe/London"``) for unambiguous identification of timezones.
"""
return self._tz.meta_for_instant(int(self._py_dt.timestamp()))[1]
[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.
>>> ZonedDateTime(2020, 8, 15, tz="Europe/London").day_length()
TimeDelta("PT24h")
>>> ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam").day_length()
TimeDelta("PT25h")
"""
midnight_naive = _datetime.combine(self._py_dt.date(), _time.min)
midnight = resolve_ambiguity(
midnight_naive,
self._tz,
"compatible",
)
next_midnight = resolve_ambiguity(
midnight_naive + _timedelta(days=1),
self._tz,
"compatible",
)
return TimeDelta.from_py_timedelta(next_midnight - midnight)
[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.
.. deprecated:: 0.10.0
Use ``start_of("day")`` instead.
"""
warn(
"start_of_day() is deprecated and will be removed in a future release; "
'use start_of("day") instead.',
WheneverDeprecationWarning,
stacklevel=2,
)
return self.start_of("day")
def _resolve_for_unit(self, naive: _datetime, unit: str) -> _datetime:
tz = self._tz
if unit in ("year", "month", "week_mon", "week_sun", "day"):
return resolve_ambiguity(naive, tz, "compatible")
match tz.ambiguity_for_local(naive):
case Unambiguous(offset):
pass
case Fold(_, earlier_offset, later_offset):
current_offset = int(
self._py_dt.utcoffset().total_seconds() # type: ignore[union-attr]
)
offset = (
later_offset
if current_offset == later_offset
else earlier_offset
)
case Gap(end, later_offset, _): # pragma: no branch
# A skipped boundary starts at the first instant after the gap.
return _from_epoch_offset(end - later_offset, later_offset)
return naive.replace(tzinfo=mk_fixed_tzinfo(offset))
def _resolve_end_of_time_unit(
self, naive: _datetime, unit: str
) -> _datetime:
local_epoch = int(naive.replace(tzinfo=_UTC).timestamp())
match self._tz._ambiguity_for_local_epoch(local_epoch):
case Unambiguous(offset):
pass
case Fold(end, earlier_offset, later_offset):
current_offset = int(
self._py_dt.utcoffset().total_seconds() # type: ignore[union-attr]
)
unit_seconds = {"hour": 3_600, "minute": 60, "second": 1}[unit]
# A fold shorter than the unit is part of the same local-clock
# unit, so include it when it ends exactly at the boundary.
offset = (
later_offset
if current_offset == later_offset
or (
local_epoch + 1 == end
and earlier_offset - later_offset < unit_seconds
)
else earlier_offset
)
case Gap(end, later_offset, earlier_offset): # pragma: no branch
# A skipped endpoint ends at the instant before the gap.
return _from_epoch_offset(
end - later_offset - 1, earlier_offset
)
return naive.replace(tzinfo=mk_fixed_tzinfo(offset))
[docs]
def start_of(
self,
unit: Literal[
"year",
"month",
"week_mon",
"week_sun",
"day",
"hour",
"minute",
"second",
],
/,
) -> ZonedDateTime:
"""The start of the given unit
>>> ZonedDateTime(2024, 8, 15, 14, 30, tz="America/New_York").start_of("day")
ZonedDateTime("2024-08-15 00:00:00-04:00[America/New_York]")
>>> ZonedDateTime(2024, 8, 15, 14, 30, tz="America/New_York").start_of("hour")
ZonedDateTime("2024-08-15 14:00:00-04:00[America/New_York]")
For ``"day"``, ``"month"``, ``"week_mon"``, ``"week_sun"``,
and ``"year"``, the resulting time
is resolved in the timezone using ``"compatible"`` disambiguation,
since midnight may not exist due to DST transitions.
For ``"hour"``, ``"minute"``, and ``"second"``, the existing offset
is preserved if valid. A boundary skipped by a transition is moved to
the first valid time after the gap.
"""
new_dt = _start_of_dt(self._py_dt, unit)
naive = new_dt.replace(tzinfo=None)
return self._from_py_unchecked(
self._resolve_for_unit(naive, unit), 0, self._tz
)
[docs]
def end_of(
self,
unit: Literal[
"year",
"month",
"week_mon",
"week_sun",
"day",
"hour",
"minute",
"second",
],
/,
) -> ZonedDateTime:
"""The end of the given unit
>>> ZonedDateTime(2024, 8, 15, 14, 30, tz="America/New_York").end_of("day")
ZonedDateTime("2024-08-15 23:59:59.999999999-04:00[America/New_York]")
See also :meth:`start_of`
"""
if unit in ("year", "month", "week_mon", "week_sun", "day"):
new_dt = _start_of_next_dt(self._py_dt, unit)
naive = new_dt.replace(tzinfo=None)
return self._from_py_unchecked(
self._resolve_for_unit(naive, unit), 0, self._tz
).subtract(nanoseconds=1)
new_dt = _end_of_dt(self._py_dt, unit)
naive = new_dt.replace(tzinfo=None)
return self._from_py_unchecked(
self._resolve_end_of_time_unit(naive, unit),
_MAX_SUBSEC_NANOS,
self._tz,
)
[docs]
def round(
self,
unit: (
Literal[
"day",
"hour",
"minute",
"second",
"millisecond",
"microsecond",
"nanosecond",
]
| TimeDelta
) = "second",
/,
*,
increment: int = 1,
mode: RoundModeStr = "half_even",
) -> ZonedDateTime:
"""Round the datetime to the specified unit and increment,
or to a multiple of a :class:`TimeDelta`.
Different rounding modes are available.
>>> d = ZonedDateTime("2020-08-15 23:24:18+02:00[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 a repeated time,
the offset is preserved if possible.
Otherwise, ambiguity 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.
"""
if isinstance(unit, TimeDelta):
if increment != 1:
raise TypeError(
"Cannot specify both a TimeDelta and an increment"
)
increment_ns = unit._to_round_increment_ns(False)
day_ns = 86_400_000_000_000
elif unit == "day":
increment_ns = increment_to_ns_for_datetime(unit, increment)
increment_ns = day_ns = self.day_length()._total_ns
else:
increment_ns = increment_to_ns_for_datetime(unit, increment)
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,
self._py_dt.utcoffset(), # type: ignore[arg-type]
self._tz,
),
rounded_local._nanos,
self._tz,
)
[docs]
def to_stdlib(self) -> _datetime:
if (key := self._tz.key) is None:
# For system timezoned datetimes without a key,
# there's nothing else we can do. This is documented behavior.
return self._py_dt.replace(microsecond=self._nanos // 1_000)
from zoneinfo import ZoneInfo
# We go through astimezone because, in theory, ZoneInfo could disagree
# with our offset. This ensures we keep the same moment in time.
# FUTURE: add a test case for this.
return self._py_dt.astimezone(ZoneInfo(key)).replace(
microsecond=self._nanos // 1_000,
)
# This override is technically incompatible, but it's very convenient
# and it's not part of the public API
@classmethod
def _from_py_unchecked( # type: ignore[override]
cls, d: _datetime, nanos: int, tz: TimeZone, /
) -> ZonedDateTime:
assert not d.microsecond
assert 0 <= nanos < 1_000_000_000
self = _object_new(cls)
self._py_dt = d
self._nanos = nanos
self._tz = tz
return self
[docs]
def exact_eq(self, other: ZonedDateTime, /) -> bool:
if type(other) is not type(self):
raise TypeError("exact_eq() requires same-type arguments")
return (
self._py_dt == other._py_dt # same moment in time
and self._nanos == other._nanos
and self._tz == other._tz # same timezone
# don't need to check the offset, it's implied by the above
)
# An override with shortcut for efficiency if the timezone stays the same
[docs]
def to_tz(self, tz: str, /) -> ZonedDateTime:
if (_tz := get_tz(tz)) == self._tz:
return self
return self._from_py_unchecked(
_to_tz(self._py_dt, _tz), self._nanos, _tz
)
def __repr__(self) -> str:
return (
f'ZonedDateTime("{_format_date(self._py_dt, False)} '
f"{_format_time(self._py_dt, self._nanos, 'auto', False)}"
f"{_format_offset(self._py_dt.utcoffset(), False)}" # type: ignore[arg-type]
f'[{self._tz.key or "<system timezone without ID>"}]")'
)
# a custom pickle implementation with a smaller payload
def __reduce__(self) -> tuple[object, ...]:
if (key := self._tz.key) is None:
raise ValueError(
"ZonedDateTime with unknown timezone ID cannot be pickled"
)
return (
_unpkl_zoned,
(
pack(
"<HBBBBBil",
*self._py_dt.timetuple()[:6],
self._nanos,
int(self._py_dt.utcoffset().total_seconds()), # type: ignore[union-attr]
),
key,
),
)
# 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, tzid: str) -> ZonedDateTime:
*args, nanos, offset_secs = unpack("<HBBBBBil", data)
if nanos >= 1_000_000_000:
raise ValueError(f"nanosecond out of range: {nanos}")
return ZonedDateTime._from_py_unchecked(
# mypy thinks tzinfo is passed twice. We know it's not.
check_utc_bounds(
_datetime(*args, tzinfo=mk_fixed_tzinfo(offset_secs)) # type: ignore[misc]
),
nanos,
get_tz(tzid),
)
# Concrete types that implement _ExactTime. Defined here (after all three
# classes) so methods in _ExactTime can use it at call time.
_EXACT_TIME_TYPES = (Instant, OffsetDateTime, ZonedDateTime)
[docs]
@final
class PlainDateTime(_LocalTime):
"""A date and time-of-day without any timezone information.
Represents "wall clock" time as people observe it locally.
It can't be mixed with exact-time types (e.g. ``Instant``,
``ZonedDateTime``) without explicitly assuming a timezone or offset.
>>> PlainDateTime(2024, 3, 10, 15, 30)
PlainDateTime("2024-03-10 15:30:00")
Can also be constructed from an ISO 8601 string
or a standard library :class:`~datetime.datetime`:
>>> PlainDateTime("2024-03-10T15:30:00")
PlainDateTime("2024-03-10 15:30:00")
Convert to an exact time type by supplying a timezone or offset:
>>> dt = PlainDateTime(2024, 3, 10, 15, 30)
>>> dt.assume_tz("Europe/Amsterdam")
ZonedDateTime("2024-03-10 15:30:00+01:00[Europe/Amsterdam]")
>>> dt.assume_fixed_offset(5)
OffsetDateTime("2024-03-10 15:30:00+05:00")
When to use this type:
- You need to express a date and time as it would appear on a
wall clock, independent of timezone.
- You receive a datetime without timezone information and need
to represent this lack of information in the type system.
- You're working in a context where timezones and DST
transitions truly don't apply (e.g. a simulation).
"""
# Overloads are for a nice autodoc
# Proper typing is done in the stubs
if not TYPE_CHECKING:
@overload
def __init__(self, iso_string: str, /) -> None: ...
@overload
def __init__(self, py_dt: _datetime, /) -> None: ...
@overload
def __init__(
self,
year: int,
month: int,
day: int,
hour: int = 0,
minute: int = 0,
second: int = 0,
*,
nanosecond: int = 0,
) -> None: ...
def __init__(
self,
year: int,
month: int,
day: int,
hour: int = 0,
minute: int = 0,
second: int = 0,
*,
nanosecond: int = 0,
) -> None:
if nanosecond < 0 or nanosecond >= 1_000_000_000:
raise ValueError(f"nanosecond out of range: {nanosecond}")
self._py_dt = _datetime(year, month, day, hour, minute, second)
self._nanos = nanosecond
__init__ = add_alternate_constructors(__init__, py_type=_datetime)
[docs]
@classmethod
def parse_iso(cls, s: str, /) -> PlainDateTime:
"""Parse the popular ISO format ``YYYY-MM-DDTHH:MM:SS``
The inverse of the ``format_iso()`` method.
>>> PlainDateTime.parse_iso("2020-08-15T23:12:00")
PlainDateTime("2020-08-15 23:12:00")
"""
self = _object_new(cls)
self._init_from_iso(s)
return self
def _init_from_iso(self, s: str) -> None:
self._py_dt, self._nanos = datetime_from_iso(s)
_PATTERN_CATS = frozenset({"date", "time"})
[docs]
@classmethod
def parse(cls, s: str, /, *, format: str) -> PlainDateTime:
"""Parse a plain datetime from a custom pattern string.
See :ref:`pattern-format` for details.
>>> PlainDateTime.parse("2024-03-15 14:30", format="YYYY-MM-DD hh:mm")
PlainDateTime("2024-03-15 14:30:00")
"""
elements = compile_pattern(format)
validate_fields(elements, cls._PATTERN_CATS, "PlainDateTime")
state = parse_fields(elements, s)
if state.year is None or state.month is None or state.day is None:
raise ValueError(
"Pattern must include year, month, and day fields"
)
result = cls(
state.year,
state.month,
state.day,
state.hour or 0,
state.minute or 0,
state.second or 0,
nanosecond=state.nanos,
)
if (
state.weekday is not None
and result._py_dt.weekday() != state.weekday
):
raise ValueError("Parsed weekday does not match the date")
return result
def _init_from_py(self, d: _datetime) -> None:
if d.tzinfo is not None:
raise ValueError(
"Can only create PlainDateTime from a naive datetime, "
f"got datetime with tzinfo={d.tzinfo!r}"
)
self._py_dt = _strip_subclasses(d.replace(microsecond=0))
self._nanos = d.microsecond * 1_000
if not TYPE_CHECKING: # for a nicer autodoc
@overload
def replace(
self,
*,
year: int = ...,
month: int = ...,
day: int = ...,
hour: int = ...,
minute: int = ...,
second: int = ...,
nanosecond: int = ...,
) -> PlainDateTime: ...
[docs]
def replace(self, /, **kwargs: Any) -> PlainDateTime:
"""Construct a new instance with the given fields replaced."""
_check_invalid_replace_kwargs(kwargs)
nanos = _pop_nanos_kwarg(kwargs, self._nanos)
return self._from_py_unchecked(self._py_dt.replace(**kwargs), nanos)
[docs]
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
)
[docs]
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), t._nanos
)
[docs]
def start_of(
self,
unit: Literal[
"year",
"month",
"week_mon",
"week_sun",
"day",
"hour",
"minute",
"second",
],
/,
) -> PlainDateTime:
"""The start of the given unit
>>> PlainDateTime(2024, 8, 15, 14, 30, 45).start_of("day")
PlainDateTime("2024-08-15 00:00:00")
>>> PlainDateTime(2024, 8, 15, 14, 30, 45).start_of("hour")
PlainDateTime("2024-08-15 14:00:00")
"""
new_dt = _start_of_dt(self._py_dt, unit)
return self._from_py_unchecked(new_dt, 0)
[docs]
def end_of(
self,
unit: Literal[
"year",
"month",
"week_mon",
"week_sun",
"day",
"hour",
"minute",
"second",
],
/,
) -> PlainDateTime:
"""The end of the given unit
>>> PlainDateTime(2024, 8, 15, 14, 30, 45).end_of("day")
PlainDateTime("2024-08-15 23:59:59.999999999")
>>> PlainDateTime(2024, 8, 15, 14, 30, 45).end_of("hour")
PlainDateTime("2024-08-15 14:59:59.999999999")
See also :meth:`start_of`
"""
new_dt = _end_of_dt(self._py_dt, unit)
return self._from_py_unchecked(new_dt, _MAX_SUBSEC_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.
>>> 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."""
[docs]
def __lt__(self, other: PlainDateTime) -> bool:
if not isinstance(other, PlainDateTime):
return NotImplemented
return (self._py_dt, self._nanos) < (other._py_dt, other._nanos)
[docs]
def __le__(self, other: PlainDateTime) -> bool:
if not isinstance(other, PlainDateTime):
return NotImplemented
return (self._py_dt, self._nanos) <= (other._py_dt, other._nanos)
[docs]
def __gt__(self, other: PlainDateTime) -> bool:
if not isinstance(other, PlainDateTime):
return NotImplemented
return (self._py_dt, self._nanos) > (other._py_dt, other._nanos)
[docs]
def __ge__(self, other: PlainDateTime) -> bool:
if not isinstance(other, PlainDateTime):
return NotImplemented
return (self._py_dt, self._nanos) >= (other._py_dt, other._nanos)
[docs]
def __add__(self, delta: DateDelta | TimeDelta) -> PlainDateTime:
"""Add a delta to this datetime.
Warning
-------
Adding exact time units (a ``TimeDelta``) to a ``PlainDateTime`` does
not account for timezone transitions that may occur in the interval.
Use ``.assume_tz('<tz>') + delta`` if you know the timezone.
Use ``.add(..., naive_arithmetic_ok=True)`` or Python's
standard warning filters to suppress.
"""
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):
warn(
PLAIN_SHIFT_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=2,
)
delta_secs, nanos = divmod(
delta._total_ns + self._nanos, 1_000_000_000
)
return self._from_py_unchecked(
self._py_dt + _timedelta(seconds=delta_secs), nanos
)
return NotImplemented
@overload
def __sub__(self, other: PlainDateTime) -> TimeDelta: ...
@overload
def __sub__(self, other: TimeDelta | DateDelta) -> PlainDateTime: ...
[docs]
def __sub__(
self,
other: PlainDateTime | TimeDelta | DateDelta,
) -> TimeDelta | PlainDateTime:
"""Subtract a delta or calculate the duration to another plain datetime.
Warning
-------
Subtracting a ``TimeDelta`` or measuring the difference between two
``PlainDateTime`` values does not account for timezone transitions that
may occur in the interval. Use :meth:`~whenever.PlainDateTime.assume_tz`
to convert to a ``ZonedDateTime`` first for accurate results.
Use ``.add(..., naive_arithmetic_ok=True)`` or Python's
standard warning filters to suppress.
"""
if isinstance(other, TimeDelta):
warn(
PLAIN_SHIFT_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=2,
)
delta_secs, nanos = divmod(
-other._total_ns + self._nanos, 1_000_000_000
)
return self._from_py_unchecked(
self._py_dt + _timedelta(seconds=delta_secs), nanos
)
elif isinstance(other, PlainDateTime):
warn(
PLAIN_DIFF_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=2,
)
return self._sub(other)
elif isinstance(other, (DateDelta, DateTimeDelta)):
return self + -other
else:
return NotImplemented
def _sub(self, other: PlainDateTime) -> TimeDelta:
py_delta = self._py_dt - other._py_dt
return TimeDelta(
seconds=py_delta.days * 86_400 + py_delta.seconds,
nanoseconds=self._nanos - other._nanos,
)
[docs]
def difference(
self,
other: PlainDateTime,
/,
*,
ignore_dst: bool = UNSET,
naive_arithmetic_ok: bool = UNSET,
) -> TimeDelta:
"""Calculate the exact time difference between two plain datetimes.
This method returns the exact elapsed :class:`TimeDelta` between two
``PlainDateTime`` values. Equivalent to the subtraction operator (``-``),
but allows suppressing the :class:`NaiveArithmeticWarning`
via the ``naive_arithmetic_ok`` parameter.
Use :meth:`since` or :meth:`until` for more advanced options such as
calendar units, unit decomposition, and rounding.
Warning
-------
Calculating the difference between two ``PlainDateTime`` values does
not account for timezone transitions. Use :meth:`assume_tz` to convert
to a ``ZonedDateTime`` first for accurate results.
"""
if ignore_dst is not UNSET:
warn(
IGNORE_DST_DEPRECATED_MSG,
WheneverDeprecationWarning,
stacklevel=2,
)
if not naive_arithmetic_ok:
warn(
PLAIN_DIFF_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=2,
)
return self._sub(other)
@overload
def since(
self,
b: PlainDateTime,
/,
*,
total: DeltaUnitStr,
naive_arithmetic_ok: bool = ...,
) -> float: ...
@overload
def since(
self,
b: PlainDateTime,
/,
*,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
naive_arithmetic_ok: bool = ...,
) -> ItemizedDelta: ...
[docs]
def since(
self,
b: PlainDateTime,
/,
*,
total: DeltaUnitStr = UNSET,
in_units: Sequence[DeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
naive_arithmetic_ok: bool = UNSET,
) -> ItemizedDelta | float:
"""Calculate the duration since another PlainDateTime,
in terms of the specified units.
>>> d1 = PlainDateTime(2020, 8, 15, 23, 12)
>>> d2 = PlainDateTime(2020, 8, 14, 22)
>>> d1.since(d2, in_units=["hours", "minutes"],
... round_increment=15,
... round_mode="ceil")
ItemizedDelta("PT25h15m")
"""
return _plain_since(
self,
b,
None if total is UNSET else total,
None if in_units is UNSET else in_units,
round_mode,
round_increment,
emit_warn=not naive_arithmetic_ok,
)
@overload
def until(
self,
b: PlainDateTime,
/,
*,
total: DeltaUnitStr,
naive_arithmetic_ok: bool = ...,
) -> float: ...
@overload
def until(
self,
b: PlainDateTime,
/,
*,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
naive_arithmetic_ok: bool = ...,
) -> ItemizedDelta: ...
[docs]
def until(
self,
b: PlainDateTime,
/,
*,
total: DeltaUnitStr = UNSET,
in_units: Sequence[DeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
naive_arithmetic_ok: bool = UNSET,
) -> ItemizedDelta | float:
"""Inverse of the ``since()`` method. See :meth:`since` for more information."""
return _plain_since(
b,
self,
None if total is UNSET else total,
None if in_units is UNSET else in_units,
round_mode,
round_increment,
emit_warn=not naive_arithmetic_ok,
)
@overload
def add(
self,
d: AnyDelta,
/,
*,
ignore_dst: bool = ...,
naive_arithmetic_ok: bool = ...,
) -> PlainDateTime: ...
@overload
def add(
self,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
hours: float = ...,
minutes: float = ...,
seconds: float = ...,
milliseconds: float = ...,
microseconds: float = ...,
nanoseconds: int = ...,
ignore_dst: bool = ...,
naive_arithmetic_ok: bool = ...,
) -> PlainDateTime: ...
[docs]
@no_type_check
def add(self, *args, **kwargs) -> PlainDateTime:
"""Add a time amount to this datetime.
Warning
-------
Adding **exact time units** (e.g. hours, seconds) to a ``PlainDateTime``
does not account for timezone transitions that may occur in the interval.
Use ``.assume_tz('<tz>') + delta`` if you know the timezone.
Pass ``naive_arithmetic_ok=True`` to suppress;
Python's standard warning filters also apply.
"""
return self._shift(1, *args, **kwargs)
@overload
def subtract(
self,
d: AnyDelta,
/,
*,
ignore_dst: bool = ...,
naive_arithmetic_ok: bool = ...,
) -> PlainDateTime: ...
@overload
def subtract(
self,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
hours: float = ...,
minutes: float = ...,
seconds: float = ...,
milliseconds: float = ...,
microseconds: float = ...,
nanoseconds: int = ...,
ignore_dst: bool = ...,
naive_arithmetic_ok: bool = ...,
) -> PlainDateTime: ...
[docs]
@no_type_check
def subtract(self, *args, **kwargs) -> PlainDateTime:
"""Subtract a time amount from this datetime.
See :meth:`add` for more information.
"""
return self._shift(-1, *args, **kwargs)
@no_type_check
def _shift(
self,
sign: int,
arg: AnyDelta | UNSET = UNSET,
/,
*,
ignore_dst: bool = UNSET,
naive_arithmetic_ok: bool = UNSET,
**kwargs,
) -> PlainDateTime:
if ignore_dst is not UNSET:
warn(
IGNORE_DST_DEPRECATED_MSG,
WheneverDeprecationWarning,
stacklevel=3,
)
if kwargs:
if arg is UNSET:
return self._shift_kwargs(
sign,
naive_arithmetic_ok=naive_arithmetic_ok,
**kwargs,
)
raise TypeError("Cannot mix positional and keyword arguments")
elif isinstance(arg, (ItemizedDelta, ItemizedDateDelta)):
return self._shift_kwargs(
sign,
naive_arithmetic_ok=naive_arithmetic_ok,
**arg,
)
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,
naive_arithmetic_ok=naive_arithmetic_ok,
)
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,
naive_arithmetic_ok: bool = UNSET,
) -> 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
delta_ns = _time_units_to_nanos(
sign,
hours,
minutes,
seconds,
milliseconds,
microseconds,
nanoseconds,
)
if delta_ns != 0 and not naive_arithmetic_ok:
warn(
PLAIN_SHIFT_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=4,
)
delta_secs, nanos = divmod(delta_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.
.. deprecated:: 0.10.0
Use :meth:`parse` with a pattern string instead, or use
``PlainDateTime(datetime.strptime(...))``.
"""
warn(
"parse_strptime() is deprecated and will be removed in a future "
"release; "
"use parse() with a pattern string instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
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``.
>>> 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``.
>>> 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: DisambiguateStr = "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/guide/ambiguity.html>`__
for more information.
>>> 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, _tz := get_tz(tz), disambiguate),
self._nanos,
_tz,
)
[docs]
def assume_system_tz(
self, disambiguate: DisambiguateStr = "compatible"
) -> ZonedDateTime:
"""Assume the datetime is in the system timezone,
creating a ``ZonedDateTime``.
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/guide/ambiguity.html>`__
for more information.
>>> d = PlainDateTime(2020, 8, 15, 23, 12)
>>> # assuming system timezone is America/New_York
>>> d.assume_system_tz(disambiguate="raise")
ZonedDateTime("2020-08-15 23:12:00-04:00[America/New_York]")
"""
return ZonedDateTime._from_py_unchecked(
resolve_ambiguity(
self._py_dt, tz := get_system_tz(), disambiguate
),
self._nanos,
tz,
)
[docs]
def round(
self,
unit: (
Literal[
"day",
"hour",
"minute",
"second",
"millisecond",
"microsecond",
"nanosecond",
]
| TimeDelta
) = "second",
/,
*,
increment: int = 1,
mode: RoundModeStr = "half_even",
) -> PlainDateTime:
"""Round the datetime to the specified unit and increment,
or to a multiple of a :class:`TimeDelta`.
Different rounding modes are available.
>>> 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")
"""
if isinstance(unit, TimeDelta):
if increment != 1:
raise TypeError(
"Cannot specify both a TimeDelta and an increment"
)
increment_ns = unit._to_round_increment_ns(False)
else:
increment_ns = increment_to_ns_for_datetime(unit, increment)
return self._round_unchecked(increment_ns, 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(*args, nanosecond=nanos)
[docs]
class PotentialDstBugWarning(WheneverWarning):
"""Base class for warnings about potential DST-related bugs in user code.
Not raised directly. Subclasses cover three distinct scenarios:
- :class:`~whenever.DaysAssumed24HoursWarning` — days treated as exact 24-hour units
- :class:`~whenever.StaleOffsetWarning` — fixed offset may be wrong after a DST shift
- :class:`~whenever.NaiveArithmeticWarning` — exact-time arithmetic without timezone context
Catching or filtering this base class handles all three at once:
.. code-block:: python
import warnings, whenever
warnings.filterwarnings("error", category=whenever.PotentialDstBugWarning)
"""
[docs]
class DaysAssumed24HoursWarning(PotentialDstBugWarning):
"""Raised when days are treated as exactly 24 hours, which may be wrong
across a DST transition.
:class:`~whenever.TimeDelta` always represents exact time.
Constructing one with ``days`` or ``weeks`` kwargs converts those units
to nanoseconds using fixed 86400-second days. If you later add this delta
to a :class:`~whenever.ZonedDateTime` on a day where clocks spring forward
or fall back, the local time of the result will be off by the transition
length (usually one hour).
.. rubric:: When it can occur
.. code-block:: python
from whenever import TimeDelta, ZonedDateTime
# TimeDelta(days=1) is exactly 86 400 seconds — no DST awareness.
delta = TimeDelta(days=1) # DaysAssumed24HoursWarning
# Adding it to a ZonedDateTime on a spring-forward day gives the
# wrong local time:
eve = ZonedDateTime(2025, 3, 30, 12, tz="Europe/Amsterdam")
eve + delta
# ZonedDateTime("2025-03-31 13:00:00+02:00[Europe/Amsterdam]")
# ^^ 13:00, not 12:00 — one hour lost to the DST transition
.. rubric:: How to fix it
Use calendar-based arithmetic directly on the datetime to preserve
local time across transitions:
.. code-block:: python
eve.add(days=1)
# ZonedDateTime("2025-03-31 12:00:00+02:00[Europe/Amsterdam]") âś“
To suppress when exact 24-hour arithmetic is genuinely intended, pass
``days_assumed_24h_ok=True`` (or use Python's standard warning filters):
.. code-block:: python
TimeDelta(days=1, days_assumed_24h_ok=True)
"""
[docs]
class StaleOffsetWarning(PotentialDstBugWarning):
"""Raised when an :class:`~whenever.OffsetDateTime` operation may
silently preserve an incorrect UTC offset.
A fixed UTC offset (e.g. ``+02:00``) carries no timezone rules — it doesn't
know about DST, historical offset changes, or future policy decisions.
After shifting, rounding, or replacing fields of an
:class:`~whenever.OffsetDateTime`, the original offset is kept verbatim.
If the region's rules changed since that offset was recorded, the result
is a timestamp that is off by the difference — silently.
.. rubric:: When it can occur
.. code-block:: python
from whenever import OffsetDateTime
# Denver is UTC-7 in winter, UTC-6 in summer.
# On 2024-03-10, clocks spring forward at 2:00 AM.
d = OffsetDateTime(2024, 3, 9, 13, offset=-7)
d.add(hours=24) # StaleOffsetWarning
# OffsetDateTime("2024-03-10 13:00:00-07:00")
# ^^ -07:00 is wrong; Denver is -06:00 on this date
.. rubric:: How to fix it
Convert to :class:`~whenever.ZonedDateTime` first so the offset updates
automatically with the timezone rules:
.. code-block:: python
d.assume_tz("America/Denver").add(hours=24)
# ZonedDateTime("2024-03-10 14:00:00-06:00[America/Denver]") âś“
To suppress when the fixed offset is deliberate and known to be correct,
pass ``stale_offset_ok=True`` (or use Python's standard warning filters):
.. code-block:: python
d.add(hours=24, stale_offset_ok=True)
"""
[docs]
class NaiveArithmeticWarning(PotentialDstBugWarning):
"""Raised when exact-time arithmetic is performed on a
:class:`~whenever.PlainDateTime` without timezone context.
:class:`~whenever.PlainDateTime` carries no timezone information, so it
can't account for DST transitions. When you add or subtract exact time
units (hours, minutes, seconds) or measure the exact difference between
two :class:`~whenever.PlainDateTime` values, the computation treats every
hour as equal. If a timezone transition falls in the interval, the result
may be off by an hour or more.
.. rubric:: When it can occur
.. code-block:: python
from whenever import PlainDateTime
# On 2023-10-29, Amsterdam clocks fall back at 3:00 AM.
# PlainDateTime has no knowledge of this.
d = PlainDateTime(2023, 10, 29, 1, 30)
d.add(hours=2) # NaiveArithmeticWarning
# PlainDateTime("2023-10-29 03:30:00")
# ^^ only 1 real hour passed in Amsterdam (clocks went back)
# Also emitted for exact-unit differences:
d2 = PlainDateTime(2023, 10, 30, 1, 30)
d2 - d # NaiveArithmeticWarning
.. rubric:: How to fix it
Attach a timezone with :meth:`~whenever.PlainDateTime.assume_tz` first,
then perform arithmetic on the resulting :class:`~whenever.ZonedDateTime`:
.. code-block:: python
d.assume_tz("Europe/Amsterdam").add(hours=2)
# ZonedDateTime("2023-10-29 02:30:00+01:00[Europe/Amsterdam]") âś“
To suppress when timezone context doesn't apply (e.g. simulations,
clock times not tied to a real-world timezone, or when you know no
transitions occur in the interval), pass ``naive_arithmetic_ok=True``
(or use Python's standard warning filters):
.. code-block:: python
d.add(hours=2, naive_arithmetic_ok=True)
"""
[docs]
class ImplicitlyIgnoringDST(TypeError):
"""Raised when an operation would silently ignore DST transitions.
.. deprecated:: 0.10.0
This exception is deprecated and will be removed in a future version.
"""
OFFSET_NOW_STALE_MSG = (
"You are getting the current time using a fixed UTC offset. A fixed offset "
"has no timezone rules, so it may be stale for the region you intend—no "
"longer matching that region's actual offset after a DST or other rule "
"change. If you mean a named timezone, use ZonedDateTime.now('<tz>'); if "
"you only need the current instant, use Instant.now(). If the fixed offset "
"is intentional, pass `stale_offset_ok=True`. " + WARNING_HANDLING_DOCS_MSG
)
OFFSET_FROM_TIMESTAMP_STALE_MSG = (
"You are converting a timestamp using a fixed UTC offset. The result is "
"correct for that offset, but the offset may be stale at this timestamp—no "
"longer matching the actual offset used by the region you intend. If you "
"mean a named timezone, use ZonedDateTime.from_timestamp(ts, tz='<tz>'); "
"if you only need the instant, use Instant.from_timestamp(ts). If the fixed "
"offset is intentional, pass `stale_offset_ok=True`. "
+ WARNING_HANDLING_DOCS_MSG
)
OFFSET_REPLACE_STALE_MSG = (
"Replacing fields of an OffsetDateTime keeps its fixed UTC offset. The "
"offset may become stale—no longer matching the region's actual offset—if "
"the result is in a different DST or timezone-rule period (e.g. after "
"replacing the month on a European-timezone datetime). "
"Convert to ZonedDateTime first (using .assume_tz()) for timezone-aware field replacement. "
"If the fixed offset is intentional, pass `stale_offset_ok=True`. "
+ WARNING_HANDLING_DOCS_MSG
)
OFFSET_ROUND_STALE_MSG = (
"Rounding an OffsetDateTime keeps its fixed UTC offset. The offset may "
"become stale—no longer matching the region's actual offset—if the rounded "
"time crosses a DST or other timezone boundary. "
"Convert to a ZonedDateTime first (using .assume_tz()) for timezone-aware rounding. "
"If the fixed offset is intentional, pass `stale_offset_ok=True`. "
+ WARNING_HANDLING_DOCS_MSG
)
OFFSET_START_END_OF_STALE_MSG = (
"Getting the start or end of a unit on an OffsetDateTime keeps its fixed "
"UTC offset. The offset may become stale—no longer matching the region's "
"actual offset—at the resulting time "
"(e.g. the start of the year may have a different UTC offset due to DST). "
"Convert to ZonedDateTime first (using .assume_tz()) for timezone-aware results. "
"If the fixed offset is intentional, pass `stale_offset_ok=True`. "
+ WARNING_HANDLING_DOCS_MSG
)
PLAIN_DIFF_UNAWARE_MSG = (
"Calculating the difference between two PlainDateTime values does not account for "
"timezone transitions that may have occurred between them: "
"for example, PlainDateTime(2023, 3, 26, 3, 0) - PlainDateTime(2023, 3, 26, 1, 0) "
"gives 2h, but in Amsterdam clocks jumped from 2:00 to 3:00 that morning, "
"so only 1 real hour elapsed. "
"Use .assume_tz('<tz>') for both values if you know the timezone. "
"If timezone transitions are intentionally irrelevant here, pass "
"`naive_arithmetic_ok=True`. " + WARNING_HANDLING_DOCS_MSG
)
PLAIN_RELATIVE_TO_UNAWARE_MSG = (
"Using a PlainDateTime as reference does not account for timezone transitions: "
"without a timezone, converting between calendar units (months, days) and "
"exact time units (hours, seconds) is ambiguous across DST boundaries. "
"Use .assume_tz('<tz>') for timezone-aware results. "
"If timezone transitions are intentionally irrelevant here, pass "
"`naive_arithmetic_ok=True`. " + WARNING_HANDLING_DOCS_MSG
)
STALE_OFFSET_CALENDAR_MSG = (
"You are calculating calendar units relative to an OffsetDateTime. Because "
"it contains only a fixed offset, Whenever must assume that the offset "
"remains constant throughout the calculation. That offset may be stale "
"during part of the period if the value represents a region that crosses a "
"DST or other rule change, producing an incorrect result. Use a "
"ZonedDateTime for timezone-aware calendar arithmetic. If the fixed-offset "
"assumption is intentional, pass `stale_offset_ok=True`. "
+ WARNING_HANDLING_DOCS_MSG
)
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 exactly 24 hours, use `round('hour', increment=24)`."
)
ZONEINFO_NO_KEY_MSG = (
"Can't determine the IANA timezone ID of the given datetime: "
"The 'key' attribute of the datetime's ZoneInfo object is None. \n"
"This typically means the ZoneInfo object represents the system timezone with "
"an unknown ID. As an alternative, you can use OffsetDateTime.from_py_datetime(), "
"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 datetime.tzname() "
"is not a valid IANA timezone ID and cannot be used here."
)
FORMAT_ISO_NO_TZ_MSG = (
"This ZonedDateTime has no timezone ID and cannot be formatted in the "
"standard ISO format, which requires it. "
"This typically means the ZonedDateTime was created from a system timezone "
"with an unknown ID. To format without the timezone designator, set the "
"`tz=` argument to 'never' or 'auto'."
)
DAYS_NOT_ALWAYS_24H_MSG = (
"You are using days or weeks as exact time, so Whenever will treat each day "
"as exactly 24 hours. A calendar day can be 23 or 25 hours during a DST "
"transition, so this may differ from calendar arithmetic. If you mean "
"calendar days, perform the operation on a ZonedDateTime or pass "
"`relative_to=...` where supported. If fixed 24-hour periods are "
"intentional, pass `days_assumed_24h_ok=True`. "
+ WARNING_HANDLING_DOCS_MSG
)
IGNORE_DST_DEPRECATED_MSG = (
"`ignore_dst` is deprecated and will be removed in a future release. For "
"OffsetDateTime operations, use `stale_offset_ok`; for PlainDateTime "
"operations, use `naive_arithmetic_ok`."
)
def _to_tz(dt: _datetime, tz: TimeZone) -> _datetime:
return dt.astimezone(
mk_fixed_tzinfo(tz.offset_for_instant(int(dt.timestamp())))
)
_MAX_ORDINAL = _date.max.toordinal()
_EPOCH_DT = _datetime(1970, 1, 1, tzinfo=_UTC)
def _from_epoch(ts: int, tz: TimeZone) -> _datetime:
return _from_epoch_offset(ts, tz.offset_for_instant(ts))
def _from_epoch_offset(ts: int, offset: int) -> _datetime:
# Check ts (UTC), not local_ts below, because a negative UTC offset can
# make local_ts land inside the valid datetime range even when ts itself
# is out of range — meaning fromtimestamp() would silently succeed and
# return a datetime that exceeds Instant.MAX.
if (ordinal := ts // 86_400 + 719_163) < 1 or ordinal > _MAX_ORDINAL:
raise OverflowError("Time out of range")
local_ts = ts + offset
# datetime.fromtimestamp() is faster than manual arithmetic, but may fail
# for dates outside the platform's time_t range (e.g. year 1 or year 9999
# on 32-bit Windows). Fall back to the portable ordinal approach in that case.
try:
return _datetime.fromtimestamp(local_ts, _UTC).replace(
tzinfo=mk_fixed_tzinfo(offset)
)
except (OSError, OverflowError, ValueError): # pragma: no cover
return (_EPOCH_DT + _timedelta(seconds=local_ts)).replace(
tzinfo=mk_fixed_tzinfo(offset)
)
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.to_stdlib())
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 _format_date(d: _date, basic: bool) -> str:
sep = "" if basic else "-"
return f"{d.year:04d}{sep}{d.month:02d}{sep}{d.day:02d}"
def _format_time(
t: _time | _datetime, ns: _Nanos, precision: str, basic: bool
) -> str:
sep = "" if basic else ":"
if precision == "hour":
return f"{t.hour:02d}"
elif precision == "minute":
return f"{t.hour:02d}{sep}{t.minute:02d}"
else:
return (
f"{t.hour:02d}{sep}{t.minute:02d}{sep}{t.second:02d}"
+ _format_nanos(ns, precision)
)
def _format_offset(offset: _timedelta | Literal["Z", ""], basic: bool) -> str:
if isinstance(offset, str):
return offset
sep = "" if basic else ":"
sign = "-" if offset.days == -1 else "+"
hours, remainder = divmod(abs(int(offset.total_seconds())), 3600)
minutes, seconds = divmod(remainder, 60)
if seconds:
return f"{sign}{int(hours):02d}{sep}{int(minutes):02d}{sep}{int(seconds):02d}"
else:
return f"{sign}{int(hours):02d}{sep}{int(minutes):02d}"
def _format_nanos(ns: _Nanos, precision: str) -> str:
ns_str = f".{ns:09d}"
if precision == "auto":
return bool(ns) * ns_str.rstrip("0")
elif precision == "nanosecond":
return ns_str
elif precision == "microsecond":
return ns_str[:7]
elif precision == "millisecond":
return ns_str[:4]
elif precision in ("second", "hour", "minute"):
return ""
else:
raise ValueError(f"Invalid precision unit: {precision!r}. ")
def _format_dt(
dt: _datetime,
ns: _Nanos,
offset: _timedelta | Literal["Z", ""],
unit: str,
sep: Literal["T", " "] = "T",
basic: bool = False,
) -> str:
if sep not in ("T", " "):
raise ValueError("sep must be either 'T' or ' '")
basic = _expect_bool(basic, "basic")
return (
f"{_format_date(dt, basic)}{sep}"
f"{_format_time(dt, ns, unit, basic)}"
f"{_format_offset(offset, basic)}"
)
def _expect_bool(value: object, name: str) -> bool:
if type(value) is not bool:
raise TypeError(f"{name} must be a boolean")
return value
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 _unit_index(u: str, units: Sequence[str]) -> int:
try:
return units.index(u)
except ValueError:
raise ValueError(
f"Invalid unit {u!r}. Unit must be one of "
+ ", ".join(repr(u) for u in units)
)
def _plain_since(
self: PlainDateTime,
b: PlainDateTime,
total: DeltaUnitStr | None,
in_units: Sequence[DeltaUnitStr] | None,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
emit_warn: bool = True,
) -> ItemizedDelta | float:
"""Shared since() implementation for PlainDateTime and OffsetDateTime.
Days are always 24 hours (no DST adjustments).
"""
if total is not None:
if in_units is not None:
raise TypeError("Cannot specify both 'total' and 'in_units'")
if round_mode is not UNSET or round_increment is not UNSET:
raise TypeError(
"'round_mode' and 'round_increment' cannot be used with 'total'"
)
# Warn if the requested unit is an exact time unit.
# Calendar units (years/months/weeks/days) don't involve clock time,
# so there's no DST ambiguity.
if emit_warn and total in EXACT_UNITS_STRICT:
warn(
PLAIN_DIFF_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=3,
)
# Use UTC ZonedDateTime to avoid double-warning inside TimeDelta.total.
return self._sub(b).total(total, relative_to=b.assume_tz("UTC"))
elif in_units is None:
raise TypeError("Must specify either `total` or `in_units`")
effective_increment = 1 if round_increment is UNSET else round_increment
effective_round_mode = "trunc" if round_mode is UNSET else round_mode
units = _normalize_units(in_units, valid_units=DELTA_UNITS)
cal_units, exact_units = _split_calendar_and_exact_units(units)
# Warn only when the output contains exact time units (hours/min/sec/ns).
# Calendar-only output (months, days, etc.) doesn't involve clock time,
# so there's no DST ambiguity in that case.
if emit_warn and exact_units:
warn(
PLAIN_DIFF_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=3,
)
sign: Literal[1, -1] = 1 if self >= b else -1
target_date = self.date()._py_date
# Adjust target_date so the exact remainder has the same sign
# as the overall difference.
if sign == 1:
if b.replace_date(Date._from_py_unchecked(target_date)) > self:
target_date -= _timedelta(days=1)
else:
if b.replace_date(Date._from_py_unchecked(target_date)) < self:
target_date += _timedelta(days=1)
cal_results, trunc_date, expand_date = date_diff(
target_date,
b._py_dt.date(),
1 if exact_units else effective_increment,
cal_units,
sign,
)
trunc = b.replace_date(
Date._from_py_unchecked(resolve_leap_day(trunc_date)),
)
expand = b.replace_date(
Date._from_py_unchecked(resolve_leap_day(expand_date)),
)
smallest_unit = units[-1]
result = cast(dict[DeltaUnitStr, int], cal_results)
if exact_units:
diff_td = TimeDelta(
seconds=(self._py_dt - trunc._py_dt).days * 86_400
+ (self._py_dt - trunc._py_dt).seconds,
nanoseconds=self._nanos - trunc._nanos,
)
result.update(
diff_td._in_exact_units( # type: ignore[arg-type]
exact_units,
round_increment=effective_increment,
round_mode=effective_round_mode,
)
)
else:
if effective_round_mode != "trunc":
self_ns = (
(self._py_dt - trunc._py_dt).days * 86_400_000_000_000
+ (self._py_dt - trunc._py_dt).seconds * 1_000_000_000
+ self._nanos
- trunc._nanos
)
expand_ns = (
(expand._py_dt - trunc._py_dt).days * 86_400_000_000_000
+ (expand._py_dt - trunc._py_dt).seconds * 1_000_000_000
+ expand._nanos
- trunc._nanos
)
result[smallest_unit] = custom_round(
result[smallest_unit],
abs(self_ns),
abs(expand_ns),
effective_round_mode,
effective_increment,
sign,
)
return ItemizedDelta._from_signed(
sign if any(result.values()) else 0, **result
)
def _offset_since(
self: OffsetDateTime,
b: OffsetDateTime,
total: DeltaUnitStr | None,
in_units: Sequence[DeltaUnitStr] | None,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
) -> ItemizedDelta | float:
"""since() implementation for OffsetDateTime.
Calendar units require both datetimes to have the same offset.
"""
same_offset = self._py_dt.utcoffset() == b._py_dt.utcoffset()
if total is not None:
if in_units is not None:
raise TypeError("Cannot specify both 'total' and 'in_units'")
if round_mode is not UNSET or round_increment is not UNSET:
raise TypeError(
"'round_mode' and 'round_increment' cannot be used with 'total'"
)
if total in ("years", "months") and not same_offset:
raise ValueError(
"Calendar units can only be used to compare OffsetDateTimes "
"with the same offset"
)
# Pass UTC ZonedDateTime to avoid warning in TimeDelta.total;
# OffsetDateTime.since() never emits warnings.
return self._subtract_operator(b).total(
total, relative_to=b.to_plain().assume_tz("UTC")
)
elif in_units is None:
raise TypeError("Must specify either `total` or `in_units`")
effective_increment = 1 if round_increment is UNSET else round_increment
effective_round_mode = "trunc" if round_mode is UNSET else round_mode
resolved_units = _normalize_units(in_units, valid_units=DELTA_UNITS)
cal_units, exact_units = _split_calendar_and_exact_units(resolved_units)
if cal_units and not same_offset:
raise ValueError(
"Calendar units can only be used to compare OffsetDateTimes "
"with the same offset"
)
if same_offset:
# Same offset: delegate to the plain implementation
return _plain_since(
self.to_plain(),
b.to_plain(),
None,
in_units,
effective_round_mode,
effective_increment,
emit_warn=False,
)
else:
# Different offsets, exact units only: compute via TimeDelta
diff = self._subtract_operator(b)
sign: Sign = 1 if diff._total_ns >= 0 else -1
result = diff._in_exact_units(
exact_units,
round_increment=effective_increment,
round_mode=effective_round_mode,
)
return ItemizedDelta._from_signed(
sign if any(result.values()) else 0, **result
)
def _zoned_since(
a: ZonedDateTime,
b: ZonedDateTime,
total: DeltaUnitStr | None,
in_units: Sequence[DeltaUnitStr] | None,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
) -> ItemizedDelta | float:
"""Shared since() implementation for ZonedDateTime.
Calendar units require both datetimes to have the same timezone.
"""
if total is not None:
if in_units is not None:
raise TypeError("Cannot specify both 'total' and 'in_units'")
if round_mode is not UNSET or round_increment is not UNSET:
raise TypeError(
"'round_mode' and 'round_increment' cannot be used with 'total'"
)
if total in DATE_DELTA_UNITS and a.tz != b.tz:
raise ValueError(
"Calendar units can only be used to compare ZonedDateTimes "
"with the same timezone"
)
return (a - b).total(total, relative_to=b)
elif in_units is None:
raise TypeError("Must specify either `total` or `in_units`")
effective_increment = 1 if round_increment is UNSET else round_increment
effective_round_mode = "trunc" if round_mode is UNSET else round_mode
units = _normalize_units(in_units, valid_units=DELTA_UNITS)
cal_units, exact_units = _split_calendar_and_exact_units(units)
if cal_units and a.tz != b.tz:
raise ValueError(
"Calendar units can only be used to compare ZonedDateTimes "
"with the same timezone"
)
sign: Literal[1, -1] = 1 if a >= b else -1
# Adjust target_date so the exact remainder has the same sign
# as the overall difference. The while loop handles the rare case
# of a 24h+ gap, e.g. Samoa in 2011.
target_date = a.date()
if sign == 1:
while b.replace_date(target_date) > a:
target_date = target_date.subtract(days=1)
else:
while b.replace_date(target_date) < a:
target_date = target_date.add(days=1)
cal_results, trunc_date, expand_date = date_diff(
target_date._py_date,
b._py_dt.date(),
# Rounding only applies to the smallest unit.
# Thus if there are any exact units, calendar units aren't rounded.
1 if exact_units else effective_increment,
cal_units,
sign,
)
trunc = b.replace_date(
Date._from_py_unchecked(resolve_leap_day(trunc_date)),
)
expand = b.replace_date(
Date._from_py_unchecked(resolve_leap_day(expand_date)),
)
# Rounding is very different for exact units than calendar units
smallest_unit = units[-1]
result = cast(dict[DeltaUnitStr, int], cal_results)
if exact_units:
result.update(
(a - trunc)._in_exact_units( # type: ignore[arg-type]
exact_units,
round_increment=effective_increment,
round_mode=effective_round_mode,
)
)
else:
# Round is expensive, so only do it if needed
if effective_round_mode != "trunc":
result[smallest_unit] = custom_round(
result[smallest_unit],
abs((a - trunc)._total_ns),
abs((expand - trunc)._total_ns),
effective_round_mode,
effective_increment,
sign,
)
return ItemizedDelta._from_signed(
sign if any(result.values()) else 0, **result
)
_Tstr = TypeVar("_Tstr", bound=str)
def _normalize_units(
units: Sequence[str],
valid_units: Sequence[_Tstr],
) -> Sequence[_Tstr]:
if isinstance(units, str):
raise TypeError(
"units must be a sequence of strings, not a single string"
)
if not units:
raise ValueError("At least one unit must be specified")
else:
if sorted(units, key=lambda u: _unit_index(u, valid_units)) != list(
units
):
raise ValueError("units must be in decreasing order of size")
elif len(set(units)) != len(units):
raise ValueError("units cannot contain duplicates")
return units # type: ignore[return-value]
def _split_calendar_and_exact_units(
units: Sequence[DeltaUnitStr],
) -> tuple[Sequence[DateDeltaUnitStr], Sequence[ExactDeltaUnitStr]]:
split_index = next(
(i for i, u in enumerate(units) if u not in DATE_DELTA_UNITS),
len(units),
)
return units[:split_index], units[split_index:] # type: ignore[return-value]
# 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),
_MAX_SUBSEC_NANOS,
)
PlainDateTime.MIN = PlainDateTime._from_py_unchecked(_datetime.min, 0)
PlainDateTime.MAX = PlainDateTime._from_py_unchecked(
_datetime.max.replace(microsecond=0), _MAX_SUBSEC_NANOS
)
[docs]
def years(i: int, /) -> DateDelta:
"""Create a :class:`~DateDelta` with the given number of years.
``years(1) == DateDelta(years=1)``
.. deprecated:: 0.10.0
Use :class:`~whenever.ItemizedDateDelta` instead
"""
warn(
"years() is deprecated and will be removed in a future release; "
"use ItemizedDateDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return DateDelta._from_months_days(12 * i, 0)
[docs]
def months(i: int, /) -> DateDelta:
"""Create a :class:`~DateDelta` with the given number of months.
``months(1) == DateDelta(months=1)``
.. deprecated:: 0.10.0
Use :class:`~whenever.ItemizedDateDelta` instead
"""
warn(
"months() is deprecated and will be removed in a future release; "
"use ItemizedDateDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return DateDelta._from_months_days(i, 0)
[docs]
def weeks(i: int, /) -> DateDelta:
"""Create a :class:`~DateDelta` with the given number of weeks.
``weeks(1) == DateDelta(weeks=1)``
.. deprecated:: 0.10.0
Use :class:`~whenever.ItemizedDateDelta` instead
"""
warn(
"weeks() is deprecated and will be removed in a future release; "
"use ItemizedDateDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return DateDelta._from_months_days(0, 7 * i)
[docs]
def days(i: int, /) -> DateDelta:
"""Create a :class:`~DateDelta` with the given number of days.
``days(1) == DateDelta(days=1)``
.. deprecated:: 0.10.0
Use :class:`~whenever.ItemizedDateDelta` instead
"""
warn(
"days() is deprecated and will be removed in a future release; "
"use ItemizedDateDelta instead.",
WheneverDeprecationWarning,
stacklevel=2,
)
return DateDelta._from_months_days(0, i)
[docs]
def hours(i: float, /) -> TimeDelta:
"""Create a :class:`~TimeDelta` with the given number of hours.
``hours(1) == TimeDelta(hours=1)``
"""
return TimeDelta(hours=i)
[docs]
def minutes(i: float, /) -> TimeDelta:
"""Create a :class:`TimeDelta` with the given number of minutes.
``minutes(1) == TimeDelta(minutes=1)``
"""
return TimeDelta(minutes=i)
[docs]
def seconds(i: float, /) -> TimeDelta:
"""Create a :class:`TimeDelta` with the given number of seconds.
``seconds(1) == TimeDelta(seconds=1)``
"""
return TimeDelta(seconds=i)
[docs]
def milliseconds(i: float, /) -> TimeDelta:
"""Create a :class:`TimeDelta` with the given number of milliseconds.
``milliseconds(1) == TimeDelta(milliseconds=1)``
"""
return TimeDelta(milliseconds=i)
[docs]
def microseconds(i: float, /) -> TimeDelta:
"""Create a :class:`TimeDelta` with the given number of microseconds.
``microseconds(1) == TimeDelta(microseconds=1)``
"""
return TimeDelta(microseconds=i)
[docs]
def nanoseconds(i: int, /) -> TimeDelta:
"""Create a :class:`TimeDelta` with the given number of nanoseconds.
``nanoseconds(1) == TimeDelta(nanoseconds=1)``
"""
return TimeDelta(nanoseconds=i)
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
# This alias exists because we don't want to expose the _ExactTime abstract class
# in the public API, but we do want to use it in type annotations.
_ExactTimeAlias = Instant | OffsetDateTime | ZonedDateTime
# Itemized deltas are implemented in the dedicated pure-Python module.
ItemizedDateDelta = _ideltas.ItemizedDateDelta
ItemizedDelta = _ideltas.ItemizedDelta
_unpkl_iddelta = _ideltas._unpkl_iddelta
_unpkl_idelta = _ideltas._unpkl_idelta
AnyDelta: Any = (
DateTimeDelta | TimeDelta | DateDelta | ItemizedDelta | ItemizedDateDelta
)
# 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.
# This is important for usability, as users would otherwise
# be directed to an internal module they shouldn't use directly,
# also because these internal modules aren't available in the Rust version!
# This does mess up sphinx autodoc's introspection a bit, so we fix that below.
# see https://github.com/sphinx-doc/sphinx/issues/3673
if not SPHINX_RUNNING: # pragma: no branch
_ftype = type(_unpkl_date)
for _name in __all__:
_member = globals()[_name]
if isinstance(_member, (type, _ftype)) and (
getattr(_member, "__module__", "") or ""
).startswith("whenever"): # pragma: no branch
_member.__module__ = "whenever"
# disable further subclassing
final(_Base)
final(_ExactTime)
final(_LocalTime)
final(_ExactAndLocalTime)
final(_BasicConversions)