Skip to content

Instantly share code, notes, and snippets.

@PhrozenByte
Last active August 30, 2026 15:57
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`, 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