Source code for meta_package_manager.duration

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# 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; either version 2
# of the License, or (at your option) any later version.
#
# 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, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Parsing of release-age durations for the :option:`mpm --cooldown` option.

Defines :py:class:`Duration`, the :py:class:`click_extra.ParamType` that turns a
friendly duration, an ISO 8601 duration, or an RFC 3339 timestamp into a
:py:class:`datetime.timedelta`.
"""

from __future__ import annotations

import re
from datetime import datetime, timedelta, timezone
from typing import ClassVar

from click_extra import ParamType

TYPE_CHECKING = False
if TYPE_CHECKING:
    from click import Context as ClickContext
    from click_extra import Parameter


[docs] class Duration(ParamType): """Parse a cooldown spec into a :py:class:`datetime.timedelta`. Accepts three input shapes: - **Friendly duration**: ``7 days``, ``1 week``, ``12h``, ``30m``, ``45s``, or a bare number of days like ``7``. - **ISO 8601 duration**: ``P7D``, ``PT12H``, ``P1WT6H``. Case-insensitive. - **RFC 3339 absolute timestamp**: ``2024-05-01T00:00:00Z`` or with an offset like ``+02:00``. Converted at parse time to ``now - timestamp``; a timestamp in the future disables the cooldown. A zero duration or empty input parses to ``None``, which disables the cooldown (handy to override a value set in the configuration file). .. note:: Durations resolve to a fixed number of seconds, assuming a day is 24 hours. The local time zone, DST transitions, and calendar boundaries are ignored. Calendar units (months, years) are rejected for the same reason: 28-31 days and 365-366 days make them unsuitable for a precise release-age cutoff. Use ``days`` or ``weeks`` instead. """ name = "duration" _UNIT_SECONDS: ClassVar[dict[str, int]] = { "": 86400, "s": 1, "sec": 1, "secs": 1, "second": 1, "seconds": 1, "m": 60, "min": 60, "mins": 60, "minute": 60, "minutes": 60, "h": 3600, "hr": 3600, "hrs": 3600, "hour": 3600, "hours": 3600, "d": 86400, "day": 86400, "days": 86400, "w": 604800, "week": 604800, "weeks": 604800, } """Number of seconds each recognized unit represents (empty unit means days).""" _CALENDAR_UNITS = frozenset({ "mo", "mon", "month", "months", "y", "yr", "yrs", "year", "years", }) """Calendar units rejected for ambiguity: months span 28-31 days, years 365-366.""" _FRIENDLY_PATTERN = re.compile(r"(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>[a-z]*)") _ISO8601_PATTERN = re.compile( r"P" r"(?:(?P<years>\d+(?:\.\d+)?)Y)?" r"(?:(?P<months>\d+(?:\.\d+)?)M)?" r"(?:(?P<weeks>\d+(?:\.\d+)?)W)?" r"(?:(?P<days>\d+(?:\.\d+)?)D)?" r"(?:T" r"(?:(?P<hours>\d+(?:\.\d+)?)H)?" r"(?:(?P<minutes>\d+(?:\.\d+)?)M)?" r"(?:(?P<seconds>\d+(?:\.\d+)?)S)?" r")?", ) _EXAMPLES = ( "'7 days', '1 week', '12h', '30m', 'P7D', 'PT12H', " "or an RFC 3339 timestamp like '2024-05-01T00:00:00Z'" ) _CALENDAR_REJECT = ( "calendar units (months, years) are rejected because their length is " "ambiguous: months span 28-31 days, years 365-366. Use days or weeks " "instead, like '30 days' or '4 weeks'." )
[docs] def convert( self, value: object, param: Parameter | None, ctx: ClickContext | None, ) -> timedelta | None: """Coerce ``value`` to a :py:class:`datetime.timedelta` (or ``None``).""" if value is None or isinstance(value, timedelta): return value text = str(value).strip() if not text: return None # RFC 3339 absolute timestamp: starts with a 4-digit year and a dash. if len(text) >= 5 and text[:4].isdigit() and text[4] == "-": return self._parse_timestamp(text, value, param, ctx) # ISO 8601 duration: starts with 'P' (case-insensitive). if text[:1] in ("P", "p"): return self._parse_iso8601(text.upper(), value, param, ctx) # Friendly duration. return self._parse_friendly(text.lower(), value, param, ctx)
def _parse_timestamp( self, text: str, value: object, param: Parameter | None, ctx: ClickContext | None, ) -> timedelta | None: normalized = text.upper().replace("Z", "+00:00") try: ts = datetime.fromisoformat(normalized) except ValueError: self.fail( f"{value!r} looks like an RFC 3339 timestamp but cannot be " f"parsed. Accepted: {self._EXAMPLES}.", param, ctx, ) if ts.tzinfo is None: self.fail( f"{value!r} is missing a time zone. Use a fully qualified " "RFC 3339 timestamp with 'Z' or an offset like '+00:00'.", param, ctx, ) delta = datetime.now(tz=timezone.utc) - ts.astimezone(timezone.utc) return delta if delta.total_seconds() > 0 else None def _parse_iso8601( self, text: str, value: object, param: Parameter | None, ctx: ClickContext | None, ) -> timedelta | None: match = self._ISO8601_PATTERN.fullmatch(text) if not match or not any(match.groups()): self.fail( f"{value!r} is not a valid ISO 8601 duration " f"(examples: 'P7D', 'PT12H', 'P1WT6H'). Accepted: {self._EXAMPLES}.", param, ctx, ) groups = match.groupdict() if groups["years"] or groups["months"]: self.fail(f"{value!r}: {self._CALENDAR_REJECT}", param, ctx) seconds = ( float(groups["weeks"] or 0) * 604800 + float(groups["days"] or 0) * 86400 + float(groups["hours"] or 0) * 3600 + float(groups["minutes"] or 0) * 60 + float(groups["seconds"] or 0) ) return timedelta(seconds=seconds) if seconds else None def _parse_friendly( self, text: str, value: object, param: Parameter | None, ctx: ClickContext | None, ) -> timedelta | None: match = self._FRIENDLY_PATTERN.fullmatch(text) if match: unit = match["unit"] if unit in self._CALENDAR_UNITS: self.fail(f"{value!r}: {self._CALENDAR_REJECT}", param, ctx) if unit in self._UNIT_SECONDS: seconds = float(match["value"]) * self._UNIT_SECONDS[unit] return timedelta(seconds=seconds) if seconds else None self.fail( f"{value!r} is not a valid duration (examples: {self._EXAMPLES}).", param, ctx, )