Source code for action0.celery_sched.durations

"""
Parse the interval of an ``every`` schedule.

Three spellings are accepted:

- a number of seconds: ``30``, ``2.5`` (or the same as a string, which is what
  ``!ENV`` produces);
- a duration string of ``<amount><unit>`` parts: ``90s``, ``5m``,
  ``1h30m``, ``1.5h``, ``2d 12h`` — units ``w``, ``d``, ``h``, ``m``, ``s``
  and ``ms``, lowercase only (so ``m`` can never be misread as months);
- a mapping of :py:class:`~datetime.timedelta` arguments:
  ``{hours: 1, minutes: 30}`` in YAML, ``{ hours = 1, minutes = 30 }`` in TOML.
"""

import math
import re
from datetime import timedelta
from typing import Any

from action0.celery_sched.errors import DefinitionError
from action0.celery_sched.errors import located
from action0.celery_sched.values import as_number
from action0.celery_sched.values import check_keys
from action0.celery_sched.values import describe

# seconds per unit of a duration string
_UNIT_SECONDS: dict[str, float] = {
    "w": 7 * 24 * 3600,
    "d": 24 * 3600,
    "h": 3600,
    "m": 60,
    "s": 1,
    "ms": 0.001,
}

# the keyword arguments of timedelta that make sense for a schedule
_MAPPING_KEYS = ("weeks", "days", "hours", "minutes", "seconds", "milliseconds")

# one "<amount><unit>" part; "ms" is listed before "m" so it wins the alternation
_PART = r"(?P<amount>\d+(?:\.\d+)?)\s*(?P<unit>ms|[wdhms])"
_PART_PATTERN = re.compile(_PART)
_DURATION_PATTERN = re.compile(rf"\s*(?:{_PART}\s*)+")


[docs] def parse_duration(value: object) -> timedelta: """ Turn the value of an ``every`` key into a positive :py:class:`~datetime.timedelta`. >>> parse_duration(90) datetime.timedelta(seconds=90) >>> parse_duration("1h30m") datetime.timedelta(seconds=5400) >>> parse_duration({"days": 1, "hours": 12}) datetime.timedelta(days=1, seconds=43200) :param value: the parsed value, from either format :returns: the interval :raises DefinitionError: if the value is malformed, zero or negative """ if isinstance(value, dict): interval = _from_mapping(value) elif isinstance(value, str) and not _looks_numeric(value): interval = _from_string(value) else: interval = _from_seconds(as_number(value)) if interval <= timedelta(0): raise DefinitionError(f"the interval must be positive, got {describe(value)}") return interval
def _looks_numeric(text: str) -> bool: """Tell a bare number (seconds) from a duration string.""" try: as_number(text) except DefinitionError: return False return True def _from_seconds(seconds: int | float) -> timedelta: """Build the interval from a plain number of seconds.""" if not math.isfinite(seconds): raise DefinitionError(f"the interval must be finite, got {seconds!r}") try: return timedelta(seconds=seconds) except OverflowError: raise DefinitionError(f"the interval is too large: {seconds!r} seconds") from None def _from_string(text: str) -> timedelta: """Build the interval from a duration string like ``1h30m``.""" if not _DURATION_PATTERN.fullmatch(text): raise DefinitionError( f"expected seconds or a duration like '90s', '5m' or '1h30m', got {text!r}" ) seconds = sum( float(match["amount"]) * _UNIT_SECONDS[match["unit"]] for match in _PART_PATTERN.finditer(text) ) return _from_seconds(seconds) def _from_mapping(mapping: dict[str, Any]) -> timedelta: """Build the interval from ``timedelta`` keyword arguments.""" check_keys(mapping, allowed=_MAPPING_KEYS) if not mapping: raise DefinitionError(f"expected at least one of: {', '.join(_MAPPING_KEYS)}") arguments: dict[str, float] = {} for key, amount in mapping.items(): with located(key): arguments[key] = as_number(amount) if not math.isfinite(arguments[key]): raise DefinitionError(f"must be finite, got {amount!r}") try: return timedelta(**arguments) except OverflowError: raise DefinitionError("the interval is too large") from None