Skip to content

Instantly share code, notes, and snippets.

@PhrozenByte
Last active August 31, 2026 09:03
Show Gist options
  • Select an option

  • Save PhrozenByte/d9eb17b2d29e947e29d33bc20c890202 to your computer and use it in GitHub Desktop.

Select an option

Save PhrozenByte/d9eb17b2d29e947e29d33bc20c890202 to your computer and use it in GitHub Desktop.
Systemd-like time span implementation in Python.
# 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