Last active
August 30, 2026 15:57
-
-
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`, but additionally supports 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, but interpreted as calendar-relative | |
| month offsets when applied to a ``datetime.datetime`` instance. | |
| 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 fully compatible with the time span syntax | |
| defined 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`` syntax, ``HH:MM``, ``HH:MM:SS``, and | |
| ``HH:MM:SS.s`` 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 always result in a whole number of months. | |
| Operations that would result in 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 follow the conventions of ``timedelta``: | |
| >>> 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) | |
| ``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 months and years | |
| are supported and interpreted as relative calendar offsets:: | |
| >>> datetime(2026, 2, 14) - RelativeTimeSpan.from_string("1 month") | |
| datetime.datetime(2026, 1, 14, 0, 0) | |
| When applied to a ``datetime`` instance and the target month does not | |
| contain the original day, the last valid day of that month is used. The | |
| calendar-relative month offset is always 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 be 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.__mul__(1 / 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