"""Pure-Python implementation of ItemizedDelta and ItemizedDateDelta.
These types are always pure Python, even when the Rust extension is active.
The Rust extension imports them from this module.
"""
from __future__ import annotations
from collections import Counter
from collections.abc import (
ItemsView,
KeysView,
Mapping,
ValuesView,
)
from datetime import date as _date
from typing import (
TYPE_CHECKING,
Any,
Iterator,
Sequence,
TypeVar,
cast,
no_type_check,
overload,
)
from warnings import warn
from ._common import (
OFFSET_SHIFT_STALE_MSG,
PLAIN_SHIFT_UNAWARE_MSG,
SPHINX_RUNNING, # noqa
UNSET,
WARNING_HANDLING_DOCS_MSG,
WheneverWarning,
_Base,
add_alternate_constructors,
final,
)
from ._math import (
DATE_DELTA_UNITS,
DELTA_UNITS,
DIFF_FUNCS,
EXACT_UNITS_STRICT,
Sign,
resolve_leap_day,
)
from ._parse import parse_timedelta_component
from ._typing import DateDeltaUnitStr, DeltaUnitStr, RoundModeStr
if TYPE_CHECKING:
from . import _pywhenever as _whenever
else:
import whenever as _whenever
_object_new = object.__new__
_T = TypeVar("_T")
def _shift_datetime_operator(
datetime: _T,
delta: ItemizedDelta | ItemizedDateDelta | _whenever.TimeDelta,
subtract: bool,
warn_stacklevel: int = 3,
) -> _T:
from ._core import (
NaiveArithmeticWarning,
OffsetDateTime,
PlainDateTime,
StaleOffsetWarning,
TimeDelta,
)
operand = cast(Any, datetime)
if isinstance(datetime, PlainDateTime):
if (
isinstance(delta, TimeDelta)
or isinstance(delta, ItemizedDelta)
and any(delta.get(unit, 0) for unit in EXACT_UNITS_STRICT)
):
warn(
PLAIN_SHIFT_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=warn_stacklevel,
)
kwargs = {"naive_arithmetic_ok": True}
elif isinstance(datetime, OffsetDateTime):
warn(
OFFSET_SHIFT_STALE_MSG,
StaleOffsetWarning,
stacklevel=warn_stacklevel,
)
kwargs = {"stale_offset_ok": True}
else:
kwargs = {}
operation = operand.subtract if subtract else operand.add
return cast(_T, operation(delta, **kwargs))
# A special version of "Counter + Counter" that preserves zero
# and negative values.
def _items_add(
# NOTE: we need to explicitly specify a union with itemized deltas
# because mypy can't detect that itemized deltas are Mapping[str, int].
a: Mapping[str, int] | ItemizedDelta | ItemizedDateDelta,
b: Mapping[str, int] | ItemizedDelta | ItemizedDateDelta,
) -> Mapping[str, int]:
sum = Counter(a)
sum.update(b)
return sum
def _resolve_rounding(
round_mode: RoundModeStr, round_increment: int
) -> tuple[RoundModeStr, int]:
mode = "trunc" if round_mode is UNSET else round_mode
increment = 1 if round_increment is UNSET else round_increment
if not isinstance(increment, int):
raise TypeError("round_increment must be an integer")
if increment <= 0:
raise ValueError("round_increment must be a positive integer in range")
return mode, increment
CALENDAR_UNIT_OPERATOR_COMPOSITION_MSG = (
"Using `+` or `-` between two itemized deltas combines their fields instead "
"of applying the deltas one after another. With calendar units such as "
"months or days, the combined delta can produce a different date because "
"calendar arithmetic may clamp at month boundaries. To apply the deltas "
"sequentially, apply each one to the date or datetime in a separate step. "
"To create one delta relative to a starting point, use the corresponding "
"`.add()` or `.subtract()` method with `relative_to=...` and "
"`in_units=...`. If field-wise composition is intentional, use that method "
"with `cal_unit_composition_ok=True`. " + WARNING_HANDLING_DOCS_MSG
)
CALENDAR_UNIT_METHOD_COMPOSITION_MSG = (
"Calling `.add()` or `.subtract()` without `relative_to` combines the "
"itemized deltas field by field. With calendar units such as months or "
"days, the resulting delta may behave differently from applying the deltas "
"one after another. Pass `relative_to=...` and `in_units=...` to create a "
"delta relative to a specific starting point. If field-wise composition is "
"intentional, pass `cal_unit_composition_ok=True`. "
+ WARNING_HANDLING_DOCS_MSG
)
def _has_nonzero_calendar_units(
delta: Mapping[str, int] | ItemizedDelta | ItemizedDateDelta,
) -> bool:
return any(map(delta.get, DATE_DELTA_UNITS))
class CalendarUnitCompositionWarning(WheneverWarning):
"""Warn when itemized deltas are composed field by field.
Itemized deltas preserve the exact fields they were created with:
``1 month`` remains ``1 month`` rather than being normalized to days.
Composing two itemized deltas without a ``relative_to`` reference therefore
performs literal field-wise arithmetic, such as
``ItemizedDateDelta(months=1) + ItemizedDateDelta(months=1)`` becoming
``ItemizedDateDelta(months=2)``.
This is often useful for display and ISO 8601 round-tripping, but it is
not the same as sequentially applying both deltas to a date or datetime.
Calendar units do not compose reliably: for example, adding one month to
January 31 may clamp to the end of February, so adding another month from
there can differ from adding two months to January 31 in one step.
The warning is only emitted when either operand contains a nonzero calendar
unit; exact-only composition does not warn.
To preserve calendar-aware semantics, pass ``relative_to=...`` and
``in_units=...`` to :meth:`~whenever.ItemizedDelta.add` or
:meth:`~whenever.ItemizedDateDelta.add`. If field-wise composition is
intentional, pass ``cal_unit_composition_ok=True`` or use Python's
standard warning filters.
"""
__module__ = "whenever"
_MAX_DELTA_YEARS = 9999
_MAX_DELTA_MONTHS = 9999 * 12
_MAX_DELTA_WEEKS = 9999 * 53
_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_SUBSEC_NANOS = 999_999_999
_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
def _check_bound(i: int | None, max_value: int) -> int | None:
if i and i > max_value:
raise ValueError("delta out of range")
return i
def _check_component(
value: int,
sign: Sign,
max_value: int, # may also be UNSET
) -> tuple[int | None, Sign]:
if value is UNSET:
return None, sign
elif value == 0:
return 0, sign
elif value < 0:
if sign == 1:
raise ValueError("mixed sign in delta")
sign = -1
if -value > max_value:
raise ValueError("delta out of range")
else: # value > 0
if sign == -1:
raise ValueError("mixed sign in delta")
sign = 1
if value > max_value:
raise ValueError("delta out of range")
return value, sign
@final
class ItemizedDelta(_Base, Mapping[DeltaUnitStr, int]):
"""A duration that preserves the exact fields it was created with.
It closely models the ISO 8601 duration format for durations.
>>> d = ItemizedDelta(weeks=2, days=3, hours=14)
ItemizedDelta("P2w3dT14h")
>>> d = ItemizedDelta("P2w3dT14h")
>>> str(d)
'P2w3dT14h'
It behaves like a mapping where the keys are
the unit names and the values are the amounts.
Items are ordered from largest to smallest unit.
>>> d['weeks']
2
>>> d.get('minutes')
None
>>> dict(d)
{"weeks": 2, "days": 3, "hours": 14}
>>> list(d.keys())
["weeks", "days", "hours"]
>>> weeks, days, hours = d.values()
(2, 3, 14)
``ItemizedDelta`` also supports other dictionary-like operations:
>>> "months" in d # check for presence of a field
False
>>> len(d) # number of fields set
3
Zero values are considered distinct from "missing" values:
>>> d2 = ItemizedDelta(years=2, weeks=3, hours=0)
>>> dict(d2)
{"years": 2, "weeks": 3, "hours": 0}
Additionally, no normalization is performed.
Months are not rolled into years, minutes into hours, etc.
>>> d3 = ItemizedDelta(months=24, minutes=90)
ItemizedDelta("P24mT90m")
Empty durations are not allowed. At least one field must be set (but it can be zero):
>>> ItemizedDelta()
ValueError: At least one field must be set
>>> ItemizedDelta(seconds=0)
ItemizedDelta("PT0s")
Negative durations are supported, but all fields must have the same sign:
>>> d4 = ItemizedDelta(years=-1, weeks=-2, days=0)
ItemizedDelta("-P1y2w0d")
>>> ItemizedDelta(years=1, days=-3)
ValueError: All fields must have the same sign
Note
----
Unlike :class:`TimeDelta`, ``ItemizedDelta`` does not normalize
its fields. This means that ``ItemizedDelta(hours=90)`` and
``ItemizedDelta(days=3, hours=18)`` are considered different values.
To convert to a normalized form, use :meth:`in_units`.
See also the `delta documentation <https://whenever.rtfd.io/en/latest/guide/deltas.html>`_.
"""
__module__ = "whenever"
__slots__ = (
# Values are stored as signed integers (or None if not set).
# All non-zero fields must have the same sign.
"_years",
"_months",
"_weeks",
"_days",
"_hours",
"_minutes",
"_seconds",
# FUTURE: allow nanoseconds to exceed 999,999,999?
"_nanoseconds",
)
def _has_cal(self) -> bool:
"""True if this delta has any calendar units (years, months, weeks, days) set."""
return (
self._years is not None
or self._months is not None
or self._weeks is not None
or self._days is not None
)
def _has_exact_time(self) -> bool:
"""True if this delta has any exact time units (hours, minutes, seconds, nanoseconds) set."""
return (
self._hours is not None
or self._minutes is not None
or self._seconds is not None
or self._nanoseconds is not None
)
# 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: int = ...,
minutes: int = ...,
seconds: int = ...,
nanoseconds: int = ...,
) -> None: ...
def __init__(
self,
*,
years: int = UNSET,
months: int = UNSET,
weeks: int = UNSET,
days: int = UNSET,
hours: int = UNSET,
minutes: int = UNSET,
seconds: int = UNSET,
nanoseconds: int = UNSET,
) -> None:
sign: Sign = 0
if nanoseconds is not UNSET and seconds is UNSET:
seconds = 0
self._years, sign = _check_component(years, sign, _MAX_DELTA_YEARS)
self._months, sign = _check_component(months, sign, _MAX_DELTA_MONTHS)
self._weeks, sign = _check_component(weeks, sign, _MAX_DELTA_WEEKS)
self._days, sign = _check_component(days, sign, _MAX_DELTA_DAYS)
self._hours, sign = _check_component(hours, sign, _MAX_DELTA_HOURS)
self._minutes, sign = _check_component(
minutes, sign, _MAX_DELTA_MINUTES
)
self._seconds, sign = _check_component(
seconds, sign, _MAX_DELTA_SECONDS
)
self._nanoseconds, sign = _check_component(
nanoseconds, sign, _MAX_SUBSEC_NANOS
)
if (
years is UNSET
and months is UNSET
and weeks is UNSET
and days is UNSET
and hours is UNSET
and minutes is UNSET
and seconds is UNSET
and nanoseconds is UNSET
):
# This is to ensure ISO8601 formatting/parsing is round-trip safe.
# There is no "empty" duration in ISO8601; at least one field must be present.
raise ValueError("at least one field must be set")
__init__ = add_alternate_constructors(__init__)
[docs]
def sign(self) -> Sign:
"""The sign of the delta, 1, 0, or -1"""
for v in (
self._years,
self._months,
self._weeks,
self._days,
self._hours,
self._minutes,
self._seconds,
self._nanoseconds,
):
if v:
return 1 if v > 0 else -1
return 0
# FUTURE: a float_seconds method that combines seconds and nanoseconds into a single float value?
[docs]
def __iter__(self) -> Iterator[DeltaUnitStr]:
"""Iterate over all non-missing fields, ordered from largest to smallest unit."""
if self._years is not None:
yield "years"
if self._months is not None:
yield "months"
if self._weeks is not None:
yield "weeks"
if self._days is not None:
yield "days"
if self._hours is not None:
yield "hours"
if self._minutes is not None:
yield "minutes"
if self._seconds is not None:
yield "seconds"
if self._nanoseconds is not None:
yield "nanoseconds"
# These methods defer to the base class implementations, but need to be
# documented here for the API docs.
if not TYPE_CHECKING: # pragma: no cover
if SPHINX_RUNNING:
[docs]
def keys(self) -> KeysView[DeltaUnitStr]:
"""The names of all defined fields, in order of largest to smallest unit.
Part of the mapping protocol
"""
...
# FUTURE: an optimized ValuesView class that defers to the internal
# fields directly instead of going through __getitem__
[docs]
def values(self) -> ValuesView[int]:
"""Return all defined field values, in order
of largest to smallest unit.
>>> d = ItemizedDelta(years=3, hours=12, days=0)
>>> years, days, hours = d.values()
(3, 0, 12)
>>> list(d.values())
[3, 0, 12]
Part of the mapping protocol
"""
...
[docs]
def items(self) -> ItemsView[DeltaUnitStr, int]:
"""Return all defined fields as (unit, value) pairs
ordered from largest to smallest unit.
>>> d = ItemizedDelta(years=3, hours=12, days=0)
>>> list(d.items())
[('years', 3), ('days', 0), ('hours', 12)]
Part of the mapping protocol
"""
...
@overload
def get(self, key: DeltaUnitStr, /) -> int | None: ...
@overload
def get(self, key: DeltaUnitStr, default: int, /) -> int: ...
[docs]
def get(
self, key: DeltaUnitStr, default: object = None, /
) -> object:
"""Get the value of a specific field by name, or return default if not set.
Part of the mapping protocol
"""
...
[docs]
def __getitem__(self, key: str) -> int:
"""Get the value of a specific field by name.
>>> d = ItemizedDelta(weeks=1, days=3)
>>> d["weeks"]
1
>>> d["days"]
3
>>> d["hours"]
KeyError: 'hours'
"""
match key:
case "years":
value = self._years
case "months":
value = self._months
case "weeks":
value = self._weeks
case "days":
value = self._days
case "hours":
value = self._hours
case "minutes":
value = self._minutes
case "seconds":
value = self._seconds
case "nanoseconds":
value = self._nanoseconds
case _:
raise KeyError(key)
if value is not None:
return value
raise KeyError(key)
[docs]
def __len__(self) -> int:
"""Get the number of fields that are set.
>>> d = ItemizedDelta(weeks=1, days=3)
>>> len(d)
2
"""
return (
(self._years is not None)
+ (self._months is not None)
+ (self._weeks is not None)
+ (self._days is not None)
+ (self._hours is not None)
+ (self._minutes is not None)
+ (self._seconds is not None)
+ (self._nanoseconds is not None)
)
[docs]
def __contains__(self, key: object) -> bool:
"""Check if a specific field is set.
>>> d = ItemizedDelta(weeks=1, days=3)
>>> "weeks" in d
True
>>> "hours" in d
False
"""
match key:
case "years":
return self._years is not None
case "months":
return self._months is not None
case "weeks":
return self._weeks is not None
case "days":
return self._days is not None
case "hours":
return self._hours is not None
case "minutes":
return self._minutes is not None
case "seconds":
return self._seconds is not None
case "nanoseconds":
return self._nanoseconds is not None
case _:
return False
[docs]
def __bool__(self) -> bool:
"""An ItemizedDelta is considered False if its sign is 0.
>>> bool(ItemizedDelta(weeks=0))
False
>>> bool(ItemizedDelta(weeks=1))
True
"""
return bool(
self._years
or self._months
or self._weeks
or self._days
or self._hours
or self._minutes
or self._seconds
or self._nanoseconds
)
[docs]
@classmethod
def parse_iso(cls, s: str, /) -> ItemizedDelta:
"""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
PT0M # 0 minutes
PT3M40.5S # 3 minutes and 40.5 seconds
P1W11DT90M # 1 week, 11 days, and 90 minutes
-PT7H400M # -7 hours and -400 minutes
+PT7H4M # 7 hours and 4 minutes (7:04:00)
Inverse of :meth:`format_iso`
>>> ItemizeDelta.parse_iso("-P1W11DT4H")
ItemizeDelta("-P1w11dT4h")
"""
exc = ValueError(f"Invalid format: {s!r}")
prev_unit = ""
years, months, weeks, days, hours, minutes, seconds, nanos = (
None,
) * 8
# Catch certain invalid strings early, making parsing easier
if len(s) < 3 or not s.isascii() or s[-1] in "Tt":
raise exc
sign: Sign
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
# parse the date part
while rest and not rest.startswith("T"):
rest, value, unit = _parse_datedelta_component(rest, exc)
if unit == "Y" and prev_unit == "":
years = value
elif unit == "M" and prev_unit in "Y":
months = value
elif unit == "W" and prev_unit in "YM":
weeks = value
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_new, value, unit = parse_timedelta_component(rest, exc)
if unit == "H" and prev_unit == "":
hours = value
elif unit == "M" and prev_unit in "H":
minutes = value
elif unit == "S":
seconds = value // 1_000_000_000
# Only set nanos if there are fractional digits
if "," in rest or "." in rest:
nanos = value % 1_000_000_000
if rest_new:
raise exc
break
else:
raise exc
rest = rest_new
prev_unit = unit
if not (
years
or months
or weeks
or days
or hours
or minutes
or seconds
or nanos
):
sign = 0
# NOTE: we've implicitly validated that at least one field is set
return cls._from_signed(
sign,
years,
months,
weeks,
days,
hours,
minutes,
seconds,
nanos,
)
[docs]
def date_and_time_parts(
self,
) -> tuple[ItemizedDateDelta | None, _whenever.TimeDelta | None]:
"""Split into date and time parts.
Either part may be None if no fields were set of that type.
At least one part will be non-None, since at least one field must be set.
>>> d = ItemizedDelta(
... years=1,
... months=2,
... weeks=3,
... days=4,
... hours=5,
... minutes=6,
... seconds=7,
... nanoseconds=8,
... )
>>> date_part, time_part = d.date_and_time_parts()
>>> date_part
ItemizedDateDelta("P1y2m3w4d")
>>> time_part
TimeDelta("P5h6m7.000000008s")
>>> ItemizedDelta(weeks=2).date_and_time_parts()
(ItemizedDateDelta("P2w"), None)
"""
from ._core import TimeDelta
years, months, weeks, days = date_values = (
self._years,
self._months,
self._weeks,
self._days,
)
if all(v is None for v in date_values):
date_part = None
else:
sgn = self.sign()
date_part = ItemizedDateDelta._from_signed(
sgn if any(date_values) else 0,
years=abs(years) if years is not None else None,
months=abs(months) if months is not None else None,
weeks=abs(weeks) if weeks is not None else None,
days=abs(days) if days is not None else None,
)
hours, minutes, seconds, nanoseconds = time_values = (
self._hours,
self._minutes,
self._seconds,
self._nanoseconds,
)
if all(v is None for v in time_values):
time_part = None
else:
time_part = TimeDelta(
hours=hours or 0,
minutes=minutes or 0,
seconds=seconds or 0,
nanoseconds=nanoseconds or 0,
)
return date_part, time_part
# A private constructor that bypasses sign/presence validation.
# All field values must be non-negative; `sign` is applied when storing.
@classmethod
def _from_signed(
cls,
sign: Sign,
years: int | None = None,
months: int | None = None,
weeks: int | None = None,
days: int | None = None,
hours: int | None = None,
minutes: int | None = None,
seconds: int | None = None,
nanoseconds: int | None = None,
) -> ItemizedDelta:
self = _object_new(cls)
def _apply(v: int | None, max_val: int) -> int | None:
v = _check_bound(v, max_val)
return -v if v and sign < 0 else v
self._years = _apply(years, _MAX_DELTA_YEARS)
self._months = _apply(months, _MAX_DELTA_MONTHS)
self._weeks = _apply(weeks, _MAX_DELTA_WEEKS)
self._days = _apply(days, _MAX_DELTA_DAYS)
self._hours = _apply(hours, _MAX_DELTA_HOURS)
self._minutes = _apply(minutes, _MAX_DELTA_MINUTES)
self._seconds = _apply(seconds, _MAX_DELTA_SECONDS)
self._nanoseconds = _apply(nanoseconds, _MAX_SUBSEC_NANOS)
return self
[docs]
def __eq__(self, other: object) -> bool:
"""Compare for equality. Each field is individually compared.
No normalization is performed. Zero values are considered equivalent
to missing values.
Thus, ``ItemizedDelta(weeks=1, seconds=0) == ItemizedDelta(weeks=1)``
>>> d = ItemizedDelta(weeks=2, minutes=90)
>>> d == ItemizedDelta(weeks=2, minutes=90)
True
>>> d == ItemizedDelta(weeks=2, minutes=91)
False
If you want strict equality (including presence of fields),
use :meth:`exact_eq`.
"""
if not isinstance(other, ItemizedDelta):
return NotImplemented
return (
(self._years or 0) == (other._years or 0)
and (self._months or 0) == (other._months or 0)
and (self._weeks or 0) == (other._weeks or 0)
and (self._days or 0) == (other._days or 0)
and (self._hours or 0) == (other._hours or 0)
and (self._minutes or 0) == (other._minutes or 0)
and (self._seconds or 0) == (other._seconds or 0)
and (self._nanoseconds or 0) == (other._nanoseconds or 0)
)
[docs]
def exact_eq(self, other: ItemizedDelta, /) -> bool:
"""Check for strict equality. All fields *and their presence* must match."""
return (
self._years == other._years
and self._months == other._months
and self._weeks == other._weeks
and self._days == other._days
and self._hours == other._hours
and self._minutes == other._minutes
and self._seconds == other._seconds
and self._nanoseconds == other._nanoseconds
)
[docs]
def __abs__(self) -> ItemizedDelta:
"""If the contents are negative, return the positive version
>>> d = ItemizedDelta(weeks=-2, days=-3)
>>> abs(d)
ItemizedDelta("P2w3d")
"""
if self.sign() >= 0:
return self
return ItemizedDelta._from_signed(
1,
abs(self._years) if self._years is not None else None,
abs(self._months) if self._months is not None else None,
abs(self._weeks) if self._weeks is not None else None,
abs(self._days) if self._days is not None else None,
abs(self._hours) if self._hours is not None else None,
abs(self._minutes) if self._minutes is not None else None,
abs(self._seconds) if self._seconds is not None else None,
abs(self._nanoseconds) if self._nanoseconds is not None else None,
)
[docs]
def __neg__(self) -> ItemizedDelta:
"""Invert the sign of the contents
>>> d = ItemizedDelta(weeks=2, days=3)
>>> -d
ItemizedDelta("-P2w3d")
>>> --d
ItemizedDelta("P2w3d")
"""
if self.sign() == 0:
return self
return ItemizedDelta._from_signed(
-self.sign(),
abs(self._years) if self._years is not None else None,
abs(self._months) if self._months is not None else None,
abs(self._weeks) if self._weeks is not None else None,
abs(self._days) if self._days is not None else None,
abs(self._hours) if self._hours is not None else None,
abs(self._minutes) if self._minutes is not None else None,
abs(self._seconds) if self._seconds is not None else None,
abs(self._nanoseconds) if self._nanoseconds is not None else None,
)
@overload
def add(
self,
other: ItemizedDelta,
/,
*,
relative_to: _whenever.ZonedDateTime,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDelta: ...
@overload
def add(
self,
/,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
hours: int = ...,
minutes: int = ...,
seconds: int = ...,
nanoseconds: int = ...,
relative_to: _whenever.ZonedDateTime,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDelta: ...
@overload
def add(
self,
other: ItemizedDelta,
/,
*,
cal_unit_composition_ok: bool = ...,
) -> ItemizedDelta: ...
@overload
def add(
self,
other: ItemizedDateDelta,
/,
*,
cal_unit_composition_ok: bool = ...,
) -> ItemizedDelta: ...
@overload
def add(
self,
/,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
hours: int = ...,
minutes: int = ...,
seconds: int = ...,
nanoseconds: int = ...,
cal_unit_composition_ok: bool = ...,
) -> ItemizedDelta: ...
[docs]
def add(
self,
arg: ItemizedDelta | ItemizedDateDelta = UNSET,
/,
*,
relative_to: _whenever.ZonedDateTime = UNSET,
in_units: Sequence[DeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
cal_unit_composition_ok: bool = UNSET,
**kwargs: int,
) -> ItemizedDelta:
"""Add time to this delta, returning a new delta.
Without a `relative_to` reference, composition is field-wise and warns
when nonzero calendar units are involved. The warning can be suppressed
with `cal_unit_composition_ok=True`.
"""
# Normalize the input into a single unit->value mapping
other: Mapping[str, int]
if kwargs:
if arg is not UNSET:
# NIT: this message is slightly confusing
raise TypeError("Cannot mix positional and keyword arguments")
if invalid := kwargs.keys() - DELTA_UNITS:
raise TypeError(
f"Unexpected keyword argument: {invalid.pop()!r}"
)
other = kwargs
elif isinstance(arg, (ItemizedDelta, ItemizedDateDelta)):
# Mypy can't see how itemized deltas are always valid str->int mappings
other = arg # type: ignore[assignment]
elif arg is not UNSET:
raise TypeError("Expected an itemized delta")
else:
other = {}
if (
arg is UNSET
and not kwargs
and relative_to is UNSET
and in_units is UNSET
and round_mode is UNSET
and round_increment is UNSET
):
return self
if relative_to is UNSET:
if in_units is not UNSET:
raise TypeError(
"Cannot specify `in_units` without `relative_to`"
)
if round_mode is not UNSET or round_increment is not UNSET:
raise TypeError("rounding requires `relative_to`")
if not cal_unit_composition_ok and (
_has_nonzero_calendar_units(self)
or _has_nonzero_calendar_units(other)
):
warn(
CALENDAR_UNIT_METHOD_COMPOSITION_MSG,
CalendarUnitCompositionWarning,
stacklevel=2,
)
return ItemizedDelta(**_items_add(self, other))
if in_units is UNSET:
raise TypeError(
"Must specify `in_units` when `relative_to` is given"
)
round_mode, round_increment = _resolve_rounding(
round_mode, round_increment
)
return relative_to.add(
years=self.get("years", 0) + other.get("years", 0),
months=self.get("months", 0) + other.get("months", 0),
weeks=self.get("weeks", 0) + other.get("weeks", 0),
days=self.get("days", 0) + other.get("days", 0),
hours=self.get("hours", 0) + other.get("hours", 0),
minutes=self.get("minutes", 0) + other.get("minutes", 0),
seconds=self.get("seconds", 0) + other.get("seconds", 0),
nanoseconds=self.get("nanoseconds", 0)
+ other.get("nanoseconds", 0),
).since(
relative_to,
in_units=in_units,
round_mode=round_mode,
round_increment=round_increment,
)
@overload
def subtract(
self,
other: ItemizedDelta,
/,
*,
relative_to: _whenever.ZonedDateTime,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDelta: ...
@overload
def subtract(
self,
/,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
hours: int = ...,
minutes: int = ...,
seconds: int = ...,
nanoseconds: int = ...,
relative_to: _whenever.ZonedDateTime,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDelta: ...
@overload
def subtract(
self,
other: ItemizedDelta,
/,
*,
cal_unit_composition_ok: bool = ...,
) -> ItemizedDelta: ...
@overload
def subtract(
self,
other: ItemizedDateDelta,
/,
*,
cal_unit_composition_ok: bool = ...,
) -> ItemizedDelta: ...
@overload
def subtract(
self,
/,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
hours: int = ...,
minutes: int = ...,
seconds: int = ...,
nanoseconds: int = ...,
cal_unit_composition_ok: bool = ...,
) -> ItemizedDelta: ...
[docs]
def subtract(
self,
arg: ItemizedDelta | ItemizedDateDelta = UNSET,
/,
*,
relative_to: _whenever.ZonedDateTime = UNSET,
in_units: Sequence[DeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
cal_unit_composition_ok: bool = UNSET,
**kwargs: int,
) -> ItemizedDelta:
"""Subtract time from this delta, returning a new delta."""
# Invert the arguments and pass to add()
if kwargs:
kwargs = {k: -v for k, v in kwargs.items()}
if arg:
arg = -arg
return self.add( # type: ignore[no-any-return,call-overload]
arg,
relative_to=relative_to,
in_units=in_units,
round_mode=round_mode,
round_increment=round_increment,
cal_unit_composition_ok=cal_unit_composition_ok,
**kwargs,
)
def __add__(
self,
other: ItemizedDelta
| ItemizedDateDelta
| _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime,
) -> (
ItemizedDelta
| _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime
):
from ._core import OffsetDateTime, PlainDateTime, ZonedDateTime
if isinstance(other, (ZonedDateTime, PlainDateTime, OffsetDateTime)):
return _shift_datetime_operator(other, self, False)
if not isinstance(other, (ItemizedDelta, ItemizedDateDelta)):
return NotImplemented
if _has_nonzero_calendar_units(self) or _has_nonzero_calendar_units(
other
):
warn(
CALENDAR_UNIT_OPERATOR_COMPOSITION_MSG,
CalendarUnitCompositionWarning,
stacklevel=2,
)
return ItemizedDelta(**_items_add(self, other))
def __radd__(
self,
other: _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime,
) -> (
_whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime
):
from ._core import OffsetDateTime, PlainDateTime, ZonedDateTime
if isinstance(other, (ZonedDateTime, PlainDateTime, OffsetDateTime)):
return _shift_datetime_operator(other, self, False)
return NotImplemented
def __sub__(
self, other: ItemizedDelta | ItemizedDateDelta
) -> ItemizedDelta:
if not isinstance(other, (ItemizedDelta, ItemizedDateDelta)):
return NotImplemented
if _has_nonzero_calendar_units(self) or _has_nonzero_calendar_units(
other
):
warn(
CALENDAR_UNIT_OPERATOR_COMPOSITION_MSG,
CalendarUnitCompositionWarning,
stacklevel=2,
)
return ItemizedDelta(**_items_add(self, -other))
def __rsub__(
self,
other: _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime,
) -> (
_whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime
):
from ._core import OffsetDateTime, PlainDateTime, ZonedDateTime
if isinstance(other, (ZonedDateTime, PlainDateTime, OffsetDateTime)):
return _shift_datetime_operator(other, self, True)
return NotImplemented
[docs]
def in_units(
self,
units: Sequence[DeltaUnitStr],
/,
*,
relative_to: _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime,
round_mode: RoundModeStr = "trunc",
round_increment: int = 1,
) -> ItemizedDelta:
"""Convert this delta into the specified units. A `relative_to` datetime
is required to resolve calendar units.
>>> d = ItemizedDelta(years=1, months=8, minutes=1000)
>>> d.in_units(["weeks", "hours"], relative_to=ZonedDateTime(2020, 6, 30, 12, tz="Asia/Tokyo"))
ItemizedDelta("P86w160h")
Parameters
----------
relative_to
A :class:`ZonedDateTime`, :class:`PlainDateTime`, or
:class:`OffsetDateTime` reference point.
- :class:`ZonedDateTime`: DST-aware; emits no warning
- :class:`PlainDateTime`: emits :class:`NaiveArithmeticWarning`
when the conversion crosses the calendar/exact-time boundary
(i.e. the delta or output mixes calendar and exact-time units).
Pure calendar-to-calendar or exact-to-exact conversions do not warn.
- :class:`OffsetDateTime`: emits :class:`StaleOffsetWarning`
when the delta contains calendar units (years, months, weeks, days)
**or** the output units include calendar units
"""
from ._core import (
NaiveArithmeticWarning,
OffsetDateTime,
PlainDateTime,
StaleOffsetWarning,
ZonedDateTime,
)
from ._pywhenever import (
PLAIN_RELATIVE_TO_UNAWARE_MSG,
STALE_OFFSET_CALENDAR_MSG,
)
has_exact_in_units = any(map(EXACT_UNITS_STRICT.__contains__, units))
has_cal_in_units = any(map(DATE_DELTA_UNITS.__contains__, units))
if isinstance(relative_to, PlainDateTime):
if (self._has_exact_time() or has_exact_in_units) and (
self._has_cal() or has_cal_in_units
):
warn(
PLAIN_RELATIVE_TO_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=2,
)
relative_to = relative_to.assume_tz("UTC")
elif isinstance(relative_to, OffsetDateTime):
if self._has_cal() or has_cal_in_units:
warn(
StaleOffsetWarning(STALE_OFFSET_CALENDAR_MSG),
stacklevel=2,
)
relative_to = relative_to.to_plain().assume_tz("UTC")
elif not isinstance(relative_to, ZonedDateTime):
raise TypeError(
"relative_to must be a ZonedDateTime, PlainDateTime, or OffsetDateTime"
)
return relative_to.add(self).since(
relative_to,
in_units=units,
round_mode=round_mode,
round_increment=round_increment,
)
[docs]
def total(
self,
unit: DeltaUnitStr,
/,
*,
relative_to: _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime,
) -> float:
"""Return the total duration expressed in the specified unit as a float
Parameters
----------
relative_to
A :class:`ZonedDateTime`, :class:`PlainDateTime`, or
:class:`OffsetDateTime` reference point.
- :class:`ZonedDateTime`: DST-aware; emits no warning
- :class:`PlainDateTime`: emits :class:`NaiveArithmeticWarning`
when the conversion crosses the calendar/exact-time boundary
(i.e. the delta or target unit mixes calendar and exact-time units).
Pure calendar-to-calendar or exact-to-exact conversions do not warn.
- :class:`OffsetDateTime`: emits :class:`StaleOffsetWarning`
when the delta contains calendar units (years, months, weeks, days)
**or** the target unit is a calendar unit
"""
from ._core import (
NaiveArithmeticWarning,
OffsetDateTime,
PlainDateTime,
StaleOffsetWarning,
ZonedDateTime,
)
from ._pywhenever import (
PLAIN_RELATIVE_TO_UNAWARE_MSG,
STALE_OFFSET_CALENDAR_MSG,
)
is_exact_unit = unit in EXACT_UNITS_STRICT
if isinstance(relative_to, PlainDateTime):
if (self._has_exact_time() or is_exact_unit) and (
self._has_cal() or not is_exact_unit
):
warn(
PLAIN_RELATIVE_TO_UNAWARE_MSG,
NaiveArithmeticWarning,
stacklevel=2,
)
relative_to = relative_to.assume_tz("UTC")
elif isinstance(relative_to, OffsetDateTime):
if self._has_cal() or not is_exact_unit:
warn(
StaleOffsetWarning(STALE_OFFSET_CALENDAR_MSG),
stacklevel=2,
)
relative_to = relative_to.to_plain().assume_tz("UTC")
elif not isinstance(relative_to, ZonedDateTime):
raise TypeError(
"relative_to must be a ZonedDateTime, PlainDateTime, or OffsetDateTime"
)
return (relative_to.add(self) - relative_to).total(
unit, relative_to=relative_to
)
if not TYPE_CHECKING:
# This overload ensures it shows up nicely in the API docs, not just as "kwargs"
@overload
def replace(
self,
*,
years: int | None = ...,
months: int | None = ...,
weeks: int | None = ...,
days: int | None = ...,
hours: int | None = ...,
minutes: int | None = ...,
seconds: int | None = ...,
nanoseconds: int | None = ...,
) -> ItemizedDelta: ...
[docs]
def replace(self, **kwargs: int | None) -> ItemizedDelta:
"""Return a new delta with specific fields replaced.
Fields set to ``None`` will be removed.
All normal validation rules apply.
>>> d = ItemizedDelta(years=1, months=2, hours=3)
>>> d.replace(months=None, hours=2)
ItemizedDelta("P1yT2h")
"""
kwargs_w_sentinel = {
k: UNSET if v is None else v for k, v in kwargs.items()
}
fields = {**self, **kwargs_w_sentinel}
if all(v is UNSET for v in fields.values()):
raise ValueError("at least one field must remain set")
return ItemizedDelta(**fields)
@no_type_check
def __reduce__(self):
return (
_unpkl_idelta,
(
self._years,
self._months,
self._weeks,
self._days,
self._hours,
self._minutes,
self._seconds,
self._nanoseconds,
),
)
def __repr__(self) -> str:
return f'ItemizedDelta("{self.format_iso(lowercase_units=True)}")'
__str__ = format_iso
def _init_from_iso(self, s: str) -> None:
parsed = type(self).parse_iso(s)
self._years = parsed._years
self._months = parsed._months
self._weeks = parsed._weeks
self._days = parsed._days
self._hours = parsed._hours
self._minutes = parsed._minutes
self._seconds = parsed._seconds
self._nanoseconds = parsed._nanoseconds
def _to_tuple(self) -> tuple[int | None, ...]: # pragma: no cover
return (
self._years,
self._months,
self._weeks,
self._days,
self._hours,
self._minutes,
self._seconds,
self._nanoseconds,
)
# A separate unpickling function allows us to make backwards-compatible changes
# to the pickling format in the future
def _unpkl_idelta(
years: int | None,
months: int | None,
weeks: int | None,
days: int | None,
hours: int | None,
minutes: int | None,
seconds: int | None,
nanoseconds: int | None,
) -> ItemizedDelta:
self = _object_new(ItemizedDelta)
self._years = years
self._months = months
self._weeks = weeks
self._days = days
self._hours = hours
self._minutes = minutes
self._seconds = seconds
self._nanoseconds = nanoseconds
return self
_unpkl_idelta.__module__ = "whenever"
@final
class ItemizedDateDelta(_Base, Mapping[DateDeltaUnitStr, int]):
"""A date duration that preserves the exact fields it was created with.
It closely models the ISO 8601 duration format for date-only durations.
>>> d = ItemizedDateDelta(years=2, weeks=3)
ItemizedDateDelta("P2Y3W")
>>> d = ItemizedDateDelta("P22W")
>>> str(d)
'P22W'
It behaves like a mapping where the keys are
the unit names and the values are the amounts.
Items are ordered from largest to smallest unit.
>>> d['weeks']
22
>>> d.get('days')
None
>>> dict(d)
{"years": 2, "weeks": 3}
>>> list(d.keys())
["years", "weeks"]
>>> years, weeks = d.values()
(2, 3)
``ItemizedDateDelta`` also supports other dictionary-like operations:
>>> "days" in d # check for presence of a field
False
>>> len(d) # number of fields set
2
Zero values are considered distinct from "missing" values:
>>> d2 = ItemizedDateDelta(years=2, weeks=3, days=0)
>>> dict(d2)
{"years": 2, "weeks": 3, "days": 0}
Additionally, no normalization is performed.
Months are not rolled into years, weeks into days, etc.
>>> d3 = ItemizedDateDelta(months=24, days=100)
ItemizedDateDelta("P24m100d")
Empty durations are not allowed. At least one field must be set (but it can be zero):
>>> ItemizedDateDelta()
ValueError: At least one field must be set
>>> ItemizedDateDelta(days=0)
ItemizedDateDelta("P0d")
Negative durations are supported, but all fields must have the same sign:
>>> d4 = ItemizedDateDelta(years=-1, weeks=-2, days=0)
ItemizedDateDelta("-P1y2w0d")
>>> ItemizedDateDelta(years=1, days=-3)
ValueError: All fields must have the same sign
Note
----
Unlike its predecessor ``DateDelta``, ``ItemizedDateDelta`` does not normalize
its fields. This means that ``ItemizedDateDelta(months=14)`` and
``ItemizedDateDelta(years=1, months=2)`` are considered different values.
To convert to a normalized form, use :meth:`in_units`.
See also the `delta documentation <https://whenever.rtfd.io/en/latest/guide/deltas.html>`_.
"""
__module__ = "whenever"
__slots__ = (
# Values are stored as signed integers (or None if not set).
# All non-zero fields must have the same sign.
"_years",
"_months",
"_weeks",
"_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 = UNSET,
months: int = UNSET,
weeks: int = UNSET,
days: int = UNSET,
) -> None:
sign: Sign = 0
self._years, sign = _check_component(years, sign, _MAX_DELTA_YEARS)
self._months, sign = _check_component(months, sign, _MAX_DELTA_MONTHS)
self._weeks, sign = _check_component(weeks, sign, _MAX_DELTA_WEEKS)
self._days, sign = _check_component(days, sign, _MAX_DELTA_DAYS)
if (
years is UNSET
and months is UNSET
and weeks is UNSET
and days is UNSET
):
# This is to ensure ISO8601 formatting/parsing is round-trip safe.
# There is no "empty" duration in ISO8601; at least one field must be present.
raise ValueError("at least one field must be set")
__init__ = add_alternate_constructors(__init__)
[docs]
def sign(self) -> Sign:
"""The sign of the delta, whether it's positive, negative, or zero.
>>> ItemizedDateDelta(weeks=2).sign()
1
>>> ItemizedDateDelta(days=-3).sign()
-1
>>> ItemizedDateDelta(weeks=0).sign()
0
"""
for v in (self._years, self._months, self._weeks, self._days):
if v:
return 1 if v > 0 else -1
return 0
[docs]
def in_units(
self,
units: Sequence[DateDeltaUnitStr],
/,
*,
relative_to: _whenever.Date,
round_mode: RoundModeStr = "trunc",
round_increment: int = 1,
) -> ItemizedDateDelta:
"""Convert this delta into the specified units. A `relative_to` date
is required to resolve variable-length units (years and months).
>>> d = ItemizedDateDelta(years=1, months=8)
>>> d.in_units(["weeks", "days"], relative_to=Date(2020, 6, 30))
ItemizedDateDelta("P86w6d")
"""
return relative_to.add(self).since(
relative_to,
in_units=units,
round_mode=round_mode,
round_increment=round_increment,
)
if not TYPE_CHECKING:
# This overload ensures it shows up nicely in the API docs, not just as "kwargs"
@overload
def replace(
self,
*,
years: int | None = ...,
months: int | None = ...,
weeks: int | None = ...,
days: int | None = ...,
) -> ItemizedDateDelta: ...
[docs]
def replace(self, **kwargs: int | None) -> ItemizedDateDelta:
"""Return a new delta with specific fields replaced.
Fields set to ``None`` will be removed.
All normal validation rules apply.
>>> d = ItemizedDateDelta(years=1, months=2, weeks=3)
>>> d.replace(months=None, weeks=4)
ItemizedDateDelta("P1y4w")
"""
kwargs_w_sentinel = {
k: UNSET if v is None else v for k, v in kwargs.items()
}
# Keys may be invalid here, but the constructor will catch that.
fields: dict[str, object] = {
**{key: value for key, value in self.items()},
**kwargs_w_sentinel,
}
if all(v is UNSET for v in fields.values()):
raise ValueError("at least one field must remain set")
return ItemizedDateDelta(**cast(Any, fields))
[docs]
@classmethod
def parse_iso(cls, s: str, /) -> ItemizedDateDelta:
"""Parse the *popular interpretation* of the ISO 8601 duration format.
Inverse of :meth:`format_iso`
>>> ItemizedDateDelta.parse_iso("-P1W11D")
ItemizedDateDelta("-P1w11d")
You can also use the constructor ``ItemizedDateDelta(s)`` which is
equivalent to ``ItemizedDateDelta.parse_iso(s)``.
Note
----
Does not parse all possible ISO 8601 durations. In particular,
it doesn't allow fractional values.
See :ref:`here <iso8601-durations>` for more information.
"""
exc = ValueError(f"Invalid format: {s!r}")
# Catch certain invalid strings early, making parsing easier
if len(s) < 3 or not s.isascii():
raise exc
sign: Sign
s = s.upper() # normalize to uppercase for parsing
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
years, months, weeks, days = (None,) * 4
prev_unit = ""
while rest:
rest, value, unit = _parse_datedelta_component(rest, exc)
if unit == "Y" and prev_unit == "":
years = value
elif unit == "M" and prev_unit in "Y":
months = value
elif unit == "W" and prev_unit in "YM":
weeks = value
elif unit == "D" and prev_unit in "YMW":
days = value
break
else:
raise exc # components out of order
prev_unit = unit
if rest:
raise exc
if not (years or months or weeks or days):
sign = 0
# NOTE: we've implicitly validated that at least one field is set
return cls._from_signed(sign, years, months, weeks, days)
# These methods defer to the base class implementations, but need to be
# documented here for the API docs.
if not TYPE_CHECKING: # pragma: no cover
if SPHINX_RUNNING:
[docs]
def keys(self) -> KeysView[DateDeltaUnitStr]:
"""The names of all defined fields, ordered from largest to smallest unit.
Part of the mapping protocol
"""
...
# FUTURE: an optimized ValuesView class that defers to the internal
# fields directly instead of going through __getitem__
[docs]
def values(self) -> ValuesView[int]:
"""Return all defined field values, in order
of largest to smallest unit.
>>> d = ItemizedDateDelta(years=3, days=12, months=0)
>>> years, months, days = d.values()
(3, 0, 12)
>>> list(d.values())
[3, 0, 12]
"""
...
[docs]
def items(self) -> ItemsView[DateDeltaUnitStr, int]:
"""Return all defined fields as (unit, value) pairs
ordered from largest to smallest unit.
>>> d = ItemizedDateDelta(years=3, days=12, months=0)
>>> list(d.items())
[('years', 3), ('months', 0), ('days', 12)]
"""
...
@overload
def get(self, key: DateDeltaUnitStr, /) -> int | None: ...
@overload
def get(self, key: DateDeltaUnitStr, default: int, /) -> int: ...
[docs]
def get(
self, key: DateDeltaUnitStr, default: object = None, /
) -> object:
"""Get the value of a specific field by name, or return default if not set.
Part of the mapping protocol
"""
...
[docs]
def __iter__(self) -> Iterator[DateDeltaUnitStr]:
"""Iterate over all unit names for fields that are set, ordered from largest to smallest unit."""
if self._years is not None:
yield "years"
if self._months is not None:
yield "months"
if self._weeks is not None:
yield "weeks"
if self._days is not None:
yield "days"
[docs]
def __getitem__(self, key: DateDeltaUnitStr) -> int:
"""Get the value of a specific field by name.
>>> d = ItemizedDateDelta(weeks=1, days=0)
>>> d["weeks"]
1
>>> d["days"]
0
>>> d["years"]
KeyError: 'years'
"""
match key:
case "years":
value = self._years
case "months":
value = self._months
case "weeks":
value = self._weeks
case "days":
value = self._days
case _:
raise KeyError(key)
if value is not None:
return value
raise KeyError(key)
[docs]
def __len__(self) -> int:
"""Get the number of fields that are set.
>>> d = ItemizedDateDelta(weeks=1, days=0)
>>> len(d)
2
"""
return (
(self._years is not None)
+ (self._months is not None)
+ (self._weeks is not None)
+ (self._days is not None)
)
[docs]
def __contains__(self, key: object) -> bool:
"""Check if a specific field is set.
>>> d = ItemizedDateDelta(weeks=1, days=0)
>>> "weeks" in d
True
>>> "days" in d
True
>>> "months" in d
False
"""
match key:
case "years":
return self._years is not None
case "months":
return self._months is not None
case "weeks":
return self._weeks is not None
case "days":
return self._days is not None
case _:
return False
[docs]
def __bool__(self) -> bool:
"""An ItemizedDateDelta is considered False if its sign is 0.
>>> d = ItemizedDateDelta(weeks=0)
>>> bool(d)
False
>>> d = ItemizedDateDelta(weeks=1)
>>> bool(d)
True
"""
return bool(self._years or self._months or self._weeks or self._days)
[docs]
def __eq__(self, other: object) -> bool:
"""Compare each field for equality, under the following rules:
- No normalization is performed. 12 months is not equal to 1 year, etc.
- Zero values are considered equivalent to missing values.
If you want strict equality (including presence of fields),
use :meth:`exact_eq`.
>>> d = ItemizedDateDelta(weeks=2, days=3)
>>> d == ItemizedDateDelta(weeks=2, days=3, months=0)
True
>>> d == ItemizedDateDelta(weeks=2, days=4)
False
"""
if not isinstance(other, ItemizedDateDelta):
return NotImplemented
return (
(self._years or 0) == (other._years or 0)
and (self._months or 0) == (other._months or 0)
and (self._weeks or 0) == (other._weeks or 0)
and (self._days or 0) == (other._days or 0)
)
[docs]
def exact_eq(self, other: ItemizedDateDelta, /) -> bool:
"""Check for strict equality. All fields *and their presence* must match.
>>> d = ItemizedDateDelta(weeks=2, days=3)
>>> d == ItemizedDateDelta(weeks=2, days=3)
True
>>> d == ItemizedDateDelta(weeks=2, days=3, months=0)
True
>>> d.exact_eq(ItemizedDateDelta(weeks=2, days=3, months=0))
False
"""
return (
self._years == other._years
and self._months == other._months
and self._weeks == other._weeks
and self._days == other._days
)
[docs]
def __abs__(self) -> ItemizedDateDelta:
"""If the contents are negative, return the positive version
>>> d = ItemizedDateDelta(weeks=-2, days=-3)
>>> abs(d)
ItemizedDateDelta("P2w3d")
"""
if self.sign() >= 0:
return self
return ItemizedDateDelta._from_signed(
1,
abs(self._years) if self._years is not None else None,
abs(self._months) if self._months is not None else None,
abs(self._weeks) if self._weeks is not None else None,
abs(self._days) if self._days is not None else None,
)
[docs]
def __neg__(self) -> ItemizedDateDelta:
"""Invert the sign of the contents
>>> d = ItemizedDateDelta(weeks=2, days=3)
>>> -d
ItemizedDateDelta("-P2w3d")
>>> --d
ItemizedDateDelta("P2w3d")
"""
if self.sign() == 0:
return self
return ItemizedDateDelta._from_signed(
-self.sign(),
abs(self._years) if self._years is not None else None,
abs(self._months) if self._months is not None else None,
abs(self._weeks) if self._weeks is not None else None,
abs(self._days) if self._days is not None else None,
)
@overload
def add(
self,
other: ItemizedDateDelta,
/,
*,
relative_to: _whenever.Date,
in_units: Sequence[DateDeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDateDelta: ...
@overload
def add(
self,
other: ItemizedDelta,
/,
*,
relative_to: _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDelta: ...
@overload
def add(
self,
/,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
relative_to: _whenever.Date,
in_units: Sequence[DateDeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDateDelta: ...
@overload
def add(
self,
other: ItemizedDateDelta,
/,
*,
cal_unit_composition_ok: bool = ...,
) -> ItemizedDateDelta: ...
@overload
def add(
self,
other: ItemizedDelta,
/,
*,
cal_unit_composition_ok: bool = ...,
) -> ItemizedDelta: ...
@overload
def add(
self,
/,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
cal_unit_composition_ok: bool = ...,
) -> ItemizedDateDelta | ItemizedDelta: ...
[docs]
def add(
self,
arg: ItemizedDateDelta | ItemizedDelta = UNSET,
/,
*,
relative_to: _whenever.Date
| _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime = UNSET,
in_units: Sequence[DeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
cal_unit_composition_ok: bool = UNSET,
**kwargs: int,
) -> ItemizedDateDelta | ItemizedDelta:
"""Add time to this delta, returning a new delta."""
other: Mapping[str, int]
if kwargs:
if arg is not UNSET:
raise TypeError("Cannot mix positional and keyword arguments")
if invalid := kwargs.keys() - DATE_DELTA_UNITS:
raise TypeError(
f"Unexpected keyword argument: {next(iter(invalid))!r}"
)
other = kwargs
elif isinstance(arg, (ItemizedDateDelta, ItemizedDelta)):
# Mypy doesn't see that itemized deltas are valid str->int maps
other = arg # type: ignore[assignment]
elif arg is not UNSET:
raise TypeError("Expected an itemized delta")
else:
other = {}
if (
arg is UNSET
and not kwargs
and relative_to is UNSET
and in_units is UNSET
and round_mode is UNSET
and round_increment is UNSET
):
return self
if relative_to is UNSET:
if in_units is not UNSET:
raise TypeError(
"Cannot specify `in_units` without `relative_to`"
)
if round_mode is not UNSET or round_increment is not UNSET:
raise TypeError("rounding requires `relative_to`")
if not cal_unit_composition_ok and (
_has_nonzero_calendar_units(self)
or _has_nonzero_calendar_units(other)
):
warn(
CALENDAR_UNIT_METHOD_COMPOSITION_MSG,
CalendarUnitCompositionWarning,
stacklevel=2,
)
if isinstance(arg, ItemizedDelta):
return ItemizedDelta(**_items_add(self, other))
else:
return ItemizedDateDelta(**_items_add(self, other))
if in_units is UNSET:
raise TypeError(
"Must specify `in_units` when `relative_to` is given"
)
round_mode, round_increment = _resolve_rounding(
round_mode, round_increment
)
combined = _items_add(self, other)
if isinstance(arg, ItemizedDelta):
from ._core import OffsetDateTime, PlainDateTime, ZonedDateTime
if not isinstance(
relative_to, (ZonedDateTime, PlainDateTime, OffsetDateTime)
):
raise TypeError(
"relative_to must be a ZonedDateTime, PlainDateTime, or "
"OffsetDateTime when composing with ItemizedDelta"
)
return cast(
ItemizedDelta,
relative_to.add(**cast(Any, combined)).since(
cast(Any, relative_to),
in_units=in_units,
round_mode=round_mode,
round_increment=round_increment,
),
)
from ._core import Date
assert isinstance(relative_to, Date)
return relative_to.add(**cast(Any, combined)).since(
relative_to,
in_units=cast(Sequence[DateDeltaUnitStr], in_units),
round_mode=round_mode,
round_increment=round_increment,
)
@overload
def subtract(
self,
other: ItemizedDelta,
/,
*,
relative_to: _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime,
in_units: Sequence[DeltaUnitStr],
round_mode: RoundModeStr = ...,
round_increment: int = ...,
) -> ItemizedDelta: ...
@overload
def subtract(
self,
/,
*,
years: int = ...,
months: int = ...,
weeks: int = ...,
days: int = ...,
relative_to: _whenever.Date,
in_units: Sequence[DateDeltaUnitStr],
round_mode: RoundModeStr = "trunc",
round_increment: int = 1,
) -> ItemizedDateDelta: ...
@overload
def subtract(
self,
other: ItemizedDateDelta,
/,
*,
relative_to: _whenever.Date,
in_units: Sequence[DateDeltaUnitStr],
round_mode: RoundModeStr = "trunc",
round_increment: int = 1,
) -> ItemizedDateDelta: ...
@overload
def subtract(
self,
other: ItemizedDateDelta,
/,
*,
cal_unit_composition_ok: bool = ...,
) -> ItemizedDateDelta: ...
@overload
def subtract(
self,
other: ItemizedDelta,
/,
*,
cal_unit_composition_ok: bool = ...,
) -> ItemizedDelta: ...
[docs]
def subtract(
self,
arg: ItemizedDateDelta | ItemizedDelta = UNSET,
/,
*,
relative_to: _whenever.Date
| _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime = UNSET,
in_units: Sequence[DeltaUnitStr] = UNSET,
round_mode: RoundModeStr = UNSET,
round_increment: int = UNSET,
cal_unit_composition_ok: bool = UNSET,
**kwargs: int,
) -> ItemizedDateDelta | ItemizedDelta:
"""Subtract time from this delta, returning a new delta."""
# Invert the arguments and pass to add()
if kwargs:
kwargs = {k: -v for k, v in kwargs.items()}
if arg:
arg = -arg
return self.add( # type: ignore[no-any-return,call-overload]
arg,
relative_to=relative_to,
in_units=in_units,
round_mode=round_mode,
round_increment=round_increment,
cal_unit_composition_ok=cal_unit_composition_ok,
**kwargs,
)
def __add__(
self,
other: ItemizedDateDelta
| ItemizedDelta
| _whenever.Date
| _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime,
) -> (
ItemizedDateDelta
| ItemizedDelta
| _whenever.Date
| _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime
):
from ._core import Date, OffsetDateTime, PlainDateTime, ZonedDateTime
if isinstance(
other, (Date, ZonedDateTime, PlainDateTime, OffsetDateTime)
):
return _shift_datetime_operator(other, self, False)
if isinstance(other, (ItemizedDateDelta, ItemizedDelta)):
if _has_nonzero_calendar_units(
self
) or _has_nonzero_calendar_units(other):
warn(
CALENDAR_UNIT_OPERATOR_COMPOSITION_MSG,
CalendarUnitCompositionWarning,
stacklevel=2,
)
return type(other)(**_items_add(self, other))
else:
return NotImplemented
def __radd__(
self,
other: _whenever.Date
| _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime,
) -> (
_whenever.Date
| _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime
):
from ._core import Date, OffsetDateTime, PlainDateTime, ZonedDateTime
if isinstance(
other, (Date, ZonedDateTime, PlainDateTime, OffsetDateTime)
):
return _shift_datetime_operator(other, self, False)
return NotImplemented
def __sub__(
self, other: ItemizedDateDelta | ItemizedDelta
) -> ItemizedDateDelta | ItemizedDelta:
if isinstance(other, (ItemizedDateDelta, ItemizedDelta)):
if _has_nonzero_calendar_units(
self
) or _has_nonzero_calendar_units(other):
warn(
CALENDAR_UNIT_OPERATOR_COMPOSITION_MSG,
CalendarUnitCompositionWarning,
stacklevel=2,
)
return type(other)(**_items_add(self, -other))
else:
return NotImplemented
def __rsub__(
self,
other: _whenever.Date
| _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime,
) -> (
_whenever.Date
| _whenever.ZonedDateTime
| _whenever.PlainDateTime
| _whenever.OffsetDateTime
):
from ._core import Date, OffsetDateTime, PlainDateTime, ZonedDateTime
if isinstance(
other, (Date, ZonedDateTime, PlainDateTime, OffsetDateTime)
):
return _shift_datetime_operator(other, self, True)
return NotImplemented
[docs]
def total(
self, unit: DateDeltaUnitStr, /, *, relative_to: _whenever.Date
) -> float:
"""Return the total duration expressed in the specified unit as a float
>>> ItemizedDateDelta(years=1, months=6).total("months", relative_to=Date(2020, 1, 31))
18.0
>>> ItemizedDateDelta(days=1000).total("years", relative_to=Date(2020, 4, 10))
2.73972602739726
"""
shifted = relative_to.add(self)
sgn = self.sign()
shifted_d = _date(shifted.year, shifted.month, shifted.day)
ref_d = _date(relative_to.year, relative_to.month, relative_to.day)
try:
trunc_amount, trunc_date_interim, expand_date_interim = DIFF_FUNCS[
unit
](shifted_d, ref_d, 1, sgn or 1)
except KeyError:
raise ValueError(f"Unsupported unit: {unit!r}") from None
trunc_date = resolve_leap_day(trunc_date_interim)
expand_date = resolve_leap_day(expand_date_interim)
return (
trunc_amount
+ ((shifted_d - trunc_date) / (expand_date - trunc_date))
) * sgn
# A private constructor that bypasses sign/presence validation.
# All field values must be non-negative; `sign` is applied when storing.
@classmethod
def _from_signed(
cls,
sign: Sign,
years: int | None = None,
months: int | None = None,
weeks: int | None = None,
days: int | None = None,
) -> ItemizedDateDelta:
self = _object_new(cls)
def _apply(v: int | None, max_val: int) -> int | None:
v = _check_bound(v, max_val)
return -v if v and sign < 0 else v
self._years = _apply(years, _MAX_DELTA_YEARS)
self._months = _apply(months, _MAX_DELTA_MONTHS)
self._weeks = _apply(weeks, _MAX_DELTA_WEEKS)
self._days = _apply(days, _MAX_DELTA_DAYS)
return self
@no_type_check
def __reduce__(self):
return (
_unpkl_iddelta,
(
self._years,
self._months,
self._weeks,
self._days,
),
)
def __repr__(self) -> str:
return f'ItemizedDateDelta("{self.format_iso(lowercase_units=True)}")'
__str__ = format_iso
def _init_from_iso(self, s: str) -> None:
parsed = type(self).parse_iso(s)
self._years = parsed._years
self._months = parsed._months
self._weeks = parsed._weeks
self._days = parsed._days
def _to_tuple(self) -> tuple[int | None, ...]: # pragma: no cover
return (self._years, self._months, self._weeks, self._days)
# A separate unpickling function allows us to make backwards-compatible changes
# to the pickling format in the future
def _unpkl_iddelta(
years: int | None,
months: int | None,
weeks: int | None,
days: int | None,
) -> ItemizedDateDelta:
self = _object_new(ItemizedDateDelta)
self._years = years
self._months = months
self._weeks = weeks
self._days = days
return self
_unpkl_iddelta.__module__ = "whenever"