Last active
August 31, 2026 09:03
-
-
Save PhrozenByte/d9eb17b2d29e947e29d33bc20c890202 to your computer and use it in GitHub Desktop.
Systemd-like time span implementation in Python.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Systemd-like time span implementation in Python | |
| # | |
| # Requires Python 3.11 or later. | |
| # | |
| # Copyright (C) 2026 Daniel Rudolf <https://www.daniel-rudolf.de> | |
| # | |
| # This program is free software: you can redistribute it and/or modify it under | |
| # the terms of the GNU General Public License as published by the Free Software | |
| # Foundation, version 3 of the License only. | |
| # | |
| # This program is distributed in the hope that it will be useful, but WITHOUT | |
| # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS | |
| # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. | |
| # | |
| # You should have received a copy of the GNU General Public License along with | |
| # this program. If not, see <http://www.gnu.org/licenses/>. | |
| # | |
| # SPDX-License-Identifier: GPL-3.0-only | |
| from __future__ import annotations | |
| import re | |
| from calendar import monthrange | |
| from dataclasses import InitVar, dataclass | |
| from datetime import datetime, timedelta | |
| from re import Pattern | |
| from types import MappingProxyType | |
| from typing import ClassVar, Literal, Mapping, Self, Sequence, cast, overload | |
| @dataclass(frozen=True, slots=True) | |
| class RelativeTimeSpan: | |
| """ | |
| Represents a relative time span that can be applied to | |
| :class:`datetime.datetime` objects. | |
| The class follows the normalization and arithmetic conventions of | |
| :class:`datetime.timedelta` for fixed-length time units, while additionally | |
| supporting calendar months and years. Internally, ``timedelta`` is used to | |
| normalize fixed-length components (weeks, days, hours, minutes, seconds, | |
| milliseconds, and microseconds) to days, seconds, and microseconds. Months | |
| and years are not converted to a fixed number of days. Instead, they are | |
| represented internally as a number of calendar months and applied as | |
| calendar-relative offsets when the span is added to or subtracted from a | |
| ``datetime.datetime`` instance. Years are converted to months during | |
| construction. Consequently, fractional years are supported as long | |
| as they correspond to a whole number of months. | |
| Instances can be created using the constructor:: | |
| >>> RelativeTimeSpan(minutes=30, hours=3, milliseconds=500) | |
| RelativeTimeSpan(days=0, seconds=12600, microseconds=500000, months=0) | |
| >>> RelativeTimeSpan(weeks=1, months=-2, years=1) | |
| RelativeTimeSpan(days=7, seconds=0, microseconds=0, months=10) | |
| Alternatively, use :meth:`from_string` to parse a time span from a string. | |
| The syntax is based on and supports the time span syntax used by | |
| ``systemd.time``. See the `systemd.time documentation | |
| <https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html>`_ | |
| for the supported syntax and units:: | |
| >>> RelativeTimeSpan.from_string("2d 4 hr") | |
| RelativeTimeSpan(days=2, seconds=14400, microseconds=0, months=0) | |
| >>> RelativeTimeSpan.from_string("1 month") | |
| RelativeTimeSpan(days=0, seconds=0, microseconds=0, months=1) | |
| In addition to the ``systemd.time``-style syntax, ``HH:MM``, ``HH:MM:SS``, | |
| and ``HH:MM:SS.us`` time specifications are supported:: | |
| >>> RelativeTimeSpan.from_string("01:30:15") | |
| RelativeTimeSpan(days=0, seconds=5415, microseconds=0, months=0) | |
| Signs can be used to add or subtract individual parts. A sign applies to | |
| subsequent parts until another sign is encountered:: | |
| >>> RelativeTimeSpan.from_string("-1 day 3h + 30min") | |
| RelativeTimeSpan(days=-2, seconds=77400, microseconds=0, months=0) | |
| >>> RelativeTimeSpan.from_string("+2h -30min") | |
| RelativeTimeSpan(days=0, seconds=5400, microseconds=0, months=0) | |
| Signs can be disabled by passing ``allow_signs=False``:: | |
| >>> RelativeTimeSpan.from_string("1 day 2h", allow_signs=False) | |
| RelativeTimeSpan(days=1, seconds=7200, microseconds=0, months=0) | |
| >>> RelativeTimeSpan.from_string("-1 day", allow_signs=False) | |
| Traceback (most recent call last): | |
| ... | |
| ValueError: Invalid relative time span '-1 day': You must not use signs to quantify numbers | |
| Fixed-length units can be specified using floating-point values and are | |
| normalized in the same way as ``timedelta``:: | |
| >>> RelativeTimeSpan(hours=1.5) | |
| RelativeTimeSpan(days=0, seconds=5400, microseconds=0, months=0) | |
| >>> RelativeTimeSpan.from_string("-1.5 days 1.25s") | |
| RelativeTimeSpan(days=-2, seconds=43201, microseconds=250000, months=0) | |
| Months and years must resolve to a whole number of months. Operations that | |
| would produce a fractional number of months raise :class:`ValueError`:: | |
| >>> RelativeTimeSpan(years=0.5) | |
| RelativeTimeSpan(days=0, seconds=0, microseconds=0, months=6) | |
| >>> RelativeTimeSpan(years=0.2) | |
| Traceback (most recent call last): | |
| ... | |
| ValueError: Invalid years: 0.2 years would yield fractional months | |
| >>> RelativeTimeSpan.from_string("1 month") * 0.5 | |
| Traceback (most recent call last): | |
| ... | |
| ValueError: Multiplication with 0.5 would yield fractional months | |
| Arithmetic operations are supported for ``RelativeTimeSpan`` instances and | |
| follow ``timedelta``-like semantics for fixed-length components. Calendar | |
| months are handled separately and are never converted to a fixed number of | |
| days:: | |
| >>> RelativeTimeSpan.from_string("1.25 days") + RelativeTimeSpan(hours=2) | |
| RelativeTimeSpan(days=1, seconds=28800, microseconds=0, months=0) | |
| >>> RelativeTimeSpan(days=1, months=1) - RelativeTimeSpan(hours=2) | |
| RelativeTimeSpan(days=0, seconds=79200, microseconds=0, months=1) | |
| >>> RelativeTimeSpan.from_string("2h") * 3 | |
| RelativeTimeSpan(days=0, seconds=21600, microseconds=0, months=0) | |
| >>> RelativeTimeSpan(months=6) / 2 | |
| RelativeTimeSpan(days=0, seconds=0, microseconds=0, months=3) | |
| >>> -RelativeTimeSpan.from_string("2h") | |
| RelativeTimeSpan(days=-1, seconds=79200, microseconds=0, months=0) | |
| Floor division follows the semantics of ``timedelta`` for fixed-length | |
| units, while the month component is divided separately:: | |
| >>> RelativeTimeSpan.from_string("3 months 3 days") // 2 | |
| RelativeTimeSpan(days=1, seconds=43200, microseconds=0, months=1) | |
| ``RelativeTimeSpan`` can also be combined with ``timedelta``:: | |
| >>> RelativeTimeSpan.from_string("1 day") + timedelta(hours=2) | |
| RelativeTimeSpan(days=1, seconds=7200, microseconds=0, months=0) | |
| >>> timedelta(hours=12) - RelativeTimeSpan(days=0.5) | |
| RelativeTimeSpan(days=0, seconds=0, microseconds=0, months=0) | |
| The primary difference from ``timedelta`` is that ``RelativeTimeSpan`` | |
| supports calendar months and years. When a ``RelativeTimeSpan`` is | |
| applied to a ``datetime`` instance, its month component is interpreted | |
| as a calendar-relative offset rather than as a fixed number of days:: | |
| >>> datetime(2026, 2, 14) - RelativeTimeSpan.from_string("1 month") | |
| datetime.datetime(2026, 1, 14, 0, 0) | |
| When the target month does not contain the original day, the last valid | |
| day of the target month is used. The calendar-relative month offset is | |
| applied before the fixed-length time offset:: | |
| >>> datetime(2024, 1, 31) + RelativeTimeSpan.from_string("1 month") | |
| datetime.datetime(2024, 2, 29, 0, 0) | |
| >>> datetime(2026, 3, 31) - RelativeTimeSpan(days=1, months=1) | |
| datetime.datetime(2026, 2, 27, 0, 0) | |
| >>> datetime(2026, 3, 31) - timedelta(days=1) - RelativeTimeSpan(months=1) | |
| datetime.datetime(2026, 2, 28, 0, 0) | |
| Instances are immutable and can therefore be safely reused as relative | |
| offsets. | |
| """ | |
| UNITS: ClassVar[Mapping[str, Sequence[str]]] = MappingProxyType( | |
| { | |
| "microseconds": ("usec", "us", "µs"), | |
| "milliseconds": ("msec", "ms"), | |
| "seconds": ("seconds", "second", "sec", "s"), | |
| "minutes": ("minutes", "minute", "min", "m"), | |
| "hours": ("hours", "hour", "hr", "h"), | |
| "days": ("days", "day", "d"), | |
| "weeks": ("weeks", "week", "w"), | |
| "months": ("months", "month", "M"), | |
| "years": ("years", "year", "y"), | |
| } | |
| ) | |
| _UNIT_IDS: ClassVar[dict[str, str]] = { | |
| unit: unit_id for unit_id, units in UNITS.items() for unit in units | |
| } | |
| _NUMBER_UNIT_REGEX: ClassVar[Pattern[str]] = re.compile( | |
| r"(?:(?P<int>\d+)(?:\.0+)?|(?P<float>\d+\.\d+))(?:\s*(?P<unit>[a-zA-Zµ]+)\s*|\s+|\Z)" | |
| ) | |
| _TIME_REGEX: ClassVar[Pattern[str]] = re.compile( | |
| r"(?P<hh>\d+):(?P<mm>\d+)(?::(?P<ss_int>\d+)(?:\.0+)?|:(?P<ss_float>\d+\.\d+))?(?:\s+|\Z)" | |
| ) | |
| _REGEX: ClassVar[Pattern[str]] = re.compile( | |
| rf"(?P<sign>[+-])?(?:{_NUMBER_UNIT_REGEX.pattern}|{_TIME_REGEX.pattern})" | |
| ) | |
| days: float | int = 0 | |
| seconds: float | int = 0 | |
| microseconds: float | int = 0 | |
| milliseconds: InitVar[float | int] = 0 | |
| minutes: InitVar[float | int] = 0 | |
| hours: InitVar[float | int] = 0 | |
| weeks: InitVar[float | int] = 0 | |
| months: int = 0 | |
| years: InitVar[float | int] = 0 | |
| def __post_init__( | |
| self, | |
| milliseconds: float | int, | |
| minutes: float | int, | |
| hours: float | int, | |
| weeks: float | int, | |
| years: float | int, | |
| ) -> None: | |
| if not isinstance(self.months, int): | |
| raise ValueError(f"Invalid months: Expecting int, got {self.months!r}") | |
| if not isinstance(years, (int, float)): | |
| raise ValueError(f"Invalid years: Expecting float or int, got {years!r}") | |
| # calculate months from years; years must yield whole months | |
| year_months: float | int = years * 12 | |
| if isinstance(year_months, float) and not year_months.is_integer(): | |
| raise ValueError(f"Invalid years: {years!r} years would yield fractional months") | |
| # use timedelta to normalize everything else to days, seconds, and microseconds | |
| delta = timedelta( | |
| self.days, self.seconds, self.microseconds, milliseconds, minutes, hours, weeks | |
| ) | |
| # update instance | |
| object.__setattr__(self, "months", self.months + int(year_months)) | |
| object.__setattr__(self, "days", delta.days) | |
| object.__setattr__(self, "seconds", delta.seconds) | |
| object.__setattr__(self, "microseconds", delta.microseconds) | |
| @classmethod | |
| def from_string(cls, format_string: str, *, allow_signs: bool = True) -> Self: | |
| parts: dict[str, float | int] = dict.fromkeys(cls.UNITS, 0) | |
| sign: Literal["+", "-"] | None = None | |
| remaining = format_string.strip() | |
| while remaining: | |
| # match next part | |
| match = cls._REGEX.match(remaining) | |
| if match is None: | |
| raise ValueError( | |
| f"Invalid relative time span {format_string!r}: " | |
| f"Malformed content: {remaining}" | |
| ) | |
| # get sign and multiplier | |
| # if no sign is given, assume the previous sign, | |
| # i.e., unless a new sign is given, use it for all following parts | |
| previous_sign = sign | |
| sign = cast(Literal["+", "-"] | None, match.group("sign")) or previous_sign | |
| if sign is not None and not allow_signs: | |
| raise ValueError( | |
| f"Invalid relative time span {format_string!r}: " | |
| f"You must not use signs to quantify numbers: {match.group().rstrip()}" | |
| ) | |
| mult = -1 if sign == "-" else 1 | |
| # get value(s) | |
| if match.group("hh") is not None: | |
| # parse hh:mm:ss.us time | |
| hh: int = int(match.group("hh")) | |
| mm: int = int(match.group("mm")) | |
| ss: float | int = 0 | |
| if match.group("ss_float") is not None: | |
| ss = float(match.group("ss_float")) | |
| elif match.group("ss_int") is not None: | |
| ss = int(match.group("ss_int")) | |
| parts["hours"] += hh * mult | |
| parts["minutes"] += mm * mult | |
| parts["seconds"] += ss * mult | |
| else: | |
| # parse number with unit | |
| number: float | int = 0 | |
| if match.group("float") is not None: | |
| number = float(match.group("float")) | |
| elif match.group("int") is not None: | |
| number = int(match.group("int")) | |
| unit: str | None = match.group("unit") | |
| if unit and unit not in cls._UNIT_IDS: | |
| raise ValueError( | |
| f"Invalid relative time span {format_string!r}: " | |
| f"Unknown unit: {match.group().rstrip()}" | |
| ) | |
| unit_id = cls._UNIT_IDS[unit] if unit else "seconds" | |
| parts[unit_id] += number * mult | |
| # update remaining string | |
| remaining = remaining[match.end() :] | |
| # create instance | |
| try: | |
| return cls( | |
| parts["days"], | |
| parts["seconds"], | |
| parts["microseconds"], | |
| parts["milliseconds"], | |
| parts["minutes"], | |
| parts["hours"], | |
| parts["weeks"], | |
| parts["months"], # type: ignore[arg-type] | |
| parts["years"], | |
| ) | |
| except Exception as exception: | |
| raise ValueError( | |
| f"Invalid relative time span {format_string!r}" | |
| f"{f': {exception}' if str(exception) else ''}" | |
| ) from exception | |
| def __str__(self) -> str: | |
| info = timedelta(self.days, self.seconds, self.microseconds).__str__() | |
| if self.months: | |
| info = f"{self.months:d} month{'s' if self.months != 1 else ''}, " + info | |
| return info | |
| def _add_to_datetime(self, from_ts: datetime) -> datetime: | |
| # prepare months offset | |
| total_months = from_ts.year * 12 + (from_ts.month - 1) + self.months | |
| year = total_months // 12 | |
| month = total_months % 12 + 1 | |
| day = min(from_ts.day, monthrange(year, month)[1]) | |
| # prepare days, seconds, and microseconds offset | |
| delta = timedelta(self.days, self.seconds, self.microseconds) | |
| # apply offsets | |
| return from_ts.replace(year=year, month=month, day=day) + delta | |
| @overload | |
| def __add__(self, other: datetime) -> datetime: ... | |
| @overload | |
| def __add__(self, other: RelativeTimeSpan | timedelta) -> Self: ... | |
| def __add__(self, other: datetime | RelativeTimeSpan | timedelta) -> datetime | Self: | |
| if isinstance(other, datetime): | |
| return self._add_to_datetime(other) | |
| elif isinstance(other, (RelativeTimeSpan, timedelta)): | |
| return self.__class__( | |
| months=self.months + other.months if isinstance(other, RelativeTimeSpan) else 0, | |
| days=self.days + other.days, | |
| seconds=self.seconds + other.seconds, | |
| microseconds=self.microseconds + other.microseconds, | |
| ) | |
| return NotImplemented | |
| __radd__ = __add__ | |
| def __sub__(self, other: RelativeTimeSpan | timedelta) -> Self: | |
| if isinstance(other, (RelativeTimeSpan, timedelta)): | |
| return self + (-other) | |
| return NotImplemented | |
| @overload | |
| def __rsub__(self, other: datetime) -> datetime: ... | |
| @overload | |
| def __rsub__(self, other: timedelta) -> Self: ... | |
| def __rsub__(self, other: datetime | timedelta) -> datetime | Self: | |
| if isinstance(other, (datetime, timedelta)): | |
| return -self + other | |
| return NotImplemented | |
| def __neg__(self) -> Self: | |
| return self.__class__( | |
| months=-self.months, | |
| days=-self.days, | |
| seconds=-self.seconds, | |
| microseconds=-self.microseconds, | |
| ) | |
| def __pos__(self) -> Self: | |
| return self | |
| def __mul__(self, other: float | int) -> Self: | |
| if isinstance(other, (int, float)): | |
| new_months: float | int = self.months * other | |
| if isinstance(new_months, float) and not new_months.is_integer(): | |
| raise ValueError(f"Multiplication with {other!r} would yield fractional months") | |
| return self.__class__( | |
| months=int(new_months), | |
| days=self.days * other, | |
| seconds=self.seconds * other, | |
| microseconds=self.microseconds * other, | |
| ) | |
| return NotImplemented | |
| __rmul__ = __mul__ | |
| def __truediv__(self, other: float | int) -> Self: | |
| if isinstance(other, (int, float)): | |
| return self * (1 / other) | |
| return NotImplemented | |
| def __floordiv__(self, other: float | int) -> Self: | |
| if isinstance(other, (int, float)): | |
| delta = timedelta(self.days, self.seconds, self.microseconds) | |
| return self.__class__( | |
| months=int(self.months // other), seconds=delta.total_seconds() // other | |
| ) | |
| return NotImplemented | |
| def __bool__(self) -> bool: | |
| return self.months != 0 or self.days != 0 or self.seconds != 0 or self.microseconds != 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment