Source code for rororo.timedelta

"""
================
rororo.timedelta
================

Useful functions to work with timedelta instances.

"""

import datetime
import re
from typing import Union

from rororo.annotations import DictStrInt
from rororo.utils import to_int


__all__ = (
    "str_to_timedelta",
    "timedelta_average",
    "timedelta_div",
    "timedelta_seconds",
    "timedelta_to_str",
)


SECONDS_PER_DAY = 86400
SECONDS_PER_WEEK = 604800
TIMEDELTA_FORMAT = "G:i"
TIMEDELTA_FORMATS = {
    "d": ("%(days)02d", r"(?P<days>\d{2,})"),
    "f": (
        "%(weeks)d%(short_weeks_label)s "
        "%(week_days)d%(short_week_days_label)s "
        "%(day_hours)d:%(hour_minutes)02d:%(minute_seconds)02d",
        r"((?P<weeks>\d+)%(short_weeks_label)s )?"
        r"((?P<week_days>\d{1,})%(short_week_days_label)s )?"
        r"(?P<hours>\d{1,2})\:(?P<minutes>\d{2})(\:(?P<seconds>\d{2}))?",
    ),
    "F": (
        "%(weeks)d %(weeks_label)s, %(week_days)d %(week_days_label)s, "
        "%(day_hours)d:%(hour_minutes)02d:%(minute_seconds)02d",
        r"((?P<weeks>\d+) %(weeks_label)s, )?"
        r"((?P<week_days>\d{1,}) %(week_days_label)s, )?"
        r"(?P<hours>\d{1,2})\:(?P<minutes>\d{2})(\:(?P<seconds>\d{2}))?",
    ),
    "g": ("%(day_hours)d", r"(?P<day_hours>\d{1,2})"),
    "G": ("%(hours)d", r"(?P<hours>\d+)"),
    "h": ("%(day_hours)02d", r"(?P<day_hours>\d{2})"),
    "H": ("%(hours)02d", r"(?P<hours>\d{2,})"),
    "i": ("%(hour_minutes)02d", r"(?P<hour_minutes>\d{2})"),
    "I": ("%(minutes)02d", r"(?P<minutes>\d{2,})"),
    "j": ("%(days)d", r"(?P<days>\d+)"),
    "l": ("%(days_label)s", r"%(days_label)s"),
    "L": ("%(weeks_label)s", r"%(weeks_label)s"),
    "m": ("%(week_days_label)s", r"%(week_days_label)s"),
    "r": (
        "%(days)d%(short_days_label)s "
        "%(day_hours)d:%(hour_minutes)02d:%(minute_seconds)02d",
        r"((?P<days>\d+)%(short_days_label)s )?"
        r"(?P<hours>\d{1,2})\:(?P<minutes>\d{2})\:(?P<seconds>\d{2})",
    ),
    "R": (
        "%(days)d %(days_label)s, "
        "%(day_hours)d:%(hour_minutes)02d:%(minute_seconds)02d",
        r"((?P<days>\d+) %(days_label)s, )?"
        r"(?P<hours>\d{1,2})\:(?P<minutes>\d{2})\:(?P<seconds>\d{2})",
    ),
    "s": ("%(minute_seconds)02d", r"(?P<minute_seconds>\d{2})"),
    "S": ("%(seconds)02d", r"(?P<seconds>{2,})"),
    "": ("%(microseconds)d", r"(?P<microseconds>\d{1,})"),
    "w": ("%(week_days)d", r"(?P<week_days>\d{1})"),
    "W": ("%(weeks)d", r"(?P<weeks>\d+)"),
}


[docs]def str_to_timedelta( value: str, fmt: Union[str, None] = None ) -> Union[datetime.timedelta, None]: """ Convert string value to timedelta instance according to the given format. If format not set function tries to load timedelta using default ``TIMEDELTA_FORMAT`` and then both of magic "full" formats. You should also specify list of formats and function tries to convert to timedelta using each of formats in list. First matched format would return the converted timedelta instance. If user specified format, but function cannot convert string to new timedelta instance - ``ValueError`` would be raised. But if user did not specify the format, function would be fail silently and return ``None`` as result. :param value: String representation of timedelta. :param fmt: Format to use for conversion. """ def timedelta_kwargs(data: DictStrInt) -> DictStrInt: """ Convert day_hours, hour_minutes, minute_seconds, week_days and weeks to timedelta seconds. """ seconds = data.get("seconds", 0) seconds += data.get("day_hours", 0) * 3600 seconds += data.pop("hour_minutes", 0) * 60 seconds += data.pop("minute_seconds", 0) seconds += data.pop("week_days", 0) * SECONDS_PER_DAY seconds += data.pop("weeks", 0) * SECONDS_PER_WEEK data.update({"seconds": seconds}) return data if not isinstance(value, str): raise ValueError( 'Value should be a "str" instance. You use {0}.'.format( type(value) ) ) user_fmt = fmt if isinstance(fmt, (list, tuple)): formats = list(fmt) elif fmt is None: formats = [TIMEDELTA_FORMAT, "F", "f"] else: formats = [fmt] locale_data = { "days_label": "({0}|{1})".format("day", "days"), "short_days_label": "d", "short_week_days_label": "d", "short_weeks_label": "w", "week_days_label": "({0}|{1})".format("day", "days"), "weeks_label": "({0}|{1})".format("week", "weeks"), } regexps = [] for item in formats: processed = r"^" for part in item: if part in TIMEDELTA_FORMATS: part = TIMEDELTA_FORMATS[part][1] % locale_data else: part = re.escape(part) processed += part processed += r"$" regexps.append(processed) for regexp in regexps: timedelta_re = re.compile(regexp) matched = timedelta_re.match(value) if matched: data = { key: to_int(value) or 0 for key, value in matched.groupdict().items() } return datetime.timedelta(**timedelta_kwargs(data)) if user_fmt: err = "Cannot convert {0!r} to timedelta instance, using {1!r} format." raise ValueError(err.format(value, user_fmt)) return None
[docs]def timedelta_average(*values: datetime.timedelta) -> datetime.timedelta: r"""Compute the arithmetic mean for timedeltas list. :param \*values: Timedelta instances to process. """ if isinstance(values[0], (list, tuple)): values = values[0] return sum(values, datetime.timedelta()) // len(values)
[docs]def timedelta_div( first: datetime.timedelta, second: datetime.timedelta ) -> Union[float, None]: """Implement divison for timedelta instances. :param first: First timedelta instance. :param second: Second timedelta instance. """ first_seconds = timedelta_seconds(first) second_seconds = timedelta_seconds(second) if not second_seconds: return None return first_seconds / second_seconds
[docs]def timedelta_seconds(value: datetime.timedelta) -> int: """Return full number of seconds from timedelta. By default, Python returns only one day seconds, not all timedelta seconds. :param value: Timedelta instance. """ return SECONDS_PER_DAY * value.days + value.seconds
[docs]def timedelta_to_str( value: datetime.timedelta, fmt: Union[str, None] = None ) -> str: """Display the timedelta formatted according to the given string. You should use global setting ``TIMEDELTA_FORMAT`` to specify default format to this function there (like ``DATE_FORMAT`` for builtin ``date`` template filter). Default value for ``TIMEDELTA_FORMAT`` is ``'G:i'``. Format uses the same policy as Django ``date`` template filter or PHP ``date`` function with several differences. Available format strings: +------------------+-----------------------------+------------------------+ | Format character | Description | Example output | +==================+=============================+========================+ | ``a`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``A`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``b`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``B`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``c`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``d`` | Total days, 2 digits with | ``'01'``, ``'41'`` | | | leading zeros. Do not | | | | combine with ``w`` format. | | +------------------+-----------------------------+------------------------+ | ``D`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``f`` | Magic "full" format with | ``'2w 4d 1:28:07'`` | | | short labels. | | +------------------+-----------------------------+------------------------+ | ``F`` | Magic "full" format with | ``'2 weeks, 4 days, | | | normal labels. | 1:28:07'`` | +------------------+-----------------------------+------------------------+ | ``g`` | Day, not total, hours | ``'0'`` to ``'23'`` | | | without leading zeros. To | | | | use with ``d``, ``j``, or | | | | ``w``. | | +------------------+-----------------------------+------------------------+ | ``G`` | Total hours without | ``'1'``, ``'433'`` | | | leading zeros. Do not | | | | combine with ``g`` or | | | | ``h`` formats. | | +------------------+-----------------------------+------------------------+ | ``h`` | Day, not total, hours with | ``'00'`` to ``'23'`` | | | leading zeros. To use with | | | | ``d`` or ``w``. | | +------------------+-----------------------------+------------------------+ | ``H`` | Total hours with leading | ``'01', ``'433'`` | | | zeros. Do not combine with | | | | ``g`` or ``h`` formats. | | +------------------+-----------------------------+------------------------+ | ``i`` | Hour, not total, minutes, 2 | ``00`` to ``'59'`` | | | digits with leading zeros | | | | To use with ``g``, ``G``, | | | | ``h`` or ``H`` formats. | | +------------------+-----------------------------+------------------------+ | ``I`` | Total minutes, 2 digits or | ``'01'``, ``'433'`` | | | more with leading zeros. Do | | | | not combine with ``i`` | | | | format. | | +------------------+-----------------------------+------------------------+ | ``j`` | Total days, one or 2 digits | ``'1'``, ``'41'`` | | | without leading zeros. Do | | | | not combine with ``w`` | | | | format. | | +------------------+-----------------------------+------------------------+ | ``J`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``l`` | Days long label. | ``'day'`` or | | | Pluralized and localized. | ``'days'`` | +------------------+-----------------------------+------------------------+ | ``L`` | Weeks long label. | ``'week'`` or | | | Pluralized and localized. | ``'weeks'`` | +------------------+-----------------------------+------------------------+ | ``m`` | Week days long label. | ``'day'`` or | | | Pluralized and localized. | ``'days'`` | +------------------+-----------------------------+------------------------+ | ``M`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``n`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``N`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``O`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``P`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``r`` | Standart Python timedelta | ``'18 d 1:28:07'`` | | | representation with short | | | | labels. | | +------------------+-----------------------------+------------------------+ | ``R`` | Standart Python timedelta | ``'18 days, 1:28:07'`` | | | representation with normal | | | | labels. | | +------------------+-----------------------------+------------------------+ | ``s`` | Minute, not total, seconds, | ``'00'`` to ``'59'`` | | | 2 digits with leading | | | | zeros. To use with ``i`` or | | | | ``I``. | | +------------------+-----------------------------+------------------------+ | ``S`` | Total seconds. 2 digits or | ``'00'``, ``'433'`` | | | more with leading zeros. Do | | | | not combine with ``s`` | | | | format. | | +------------------+-----------------------------+------------------------+ | ``t`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``T`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``u`` | Second, not total, | ``0`` to ``999999`` | | | microseconds. | | +------------------+-----------------------------+------------------------+ | ``U`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``w`` | Week, not total, days, one | ``0`` to ``6`` | | | digit without leading | | | | zeros. To use with ``W``. | | +------------------+-----------------------------+------------------------+ | ``W`` | Total weeks, one or more | ``'1'``, ``'41'`` | | | digits without leading | | | | zeros. | | +------------------+-----------------------------+------------------------+ | ``y`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``Y`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``z`` | Not implemented. | | +------------------+-----------------------------+------------------------+ | ``Z`` | Not implemented. | | +------------------+-----------------------------+------------------------+ For example, :: >>> import datetime >>> from rororo.timedelta import timedelta_to_str >>> delta = datetime.timedelta(seconds=99660) >>> timedelta_to_str(delta) ... '27:41' >>> timedelta_to_str(delta, 'r') ... '1d 3:41:00' >>> timedelta_to_str(delta, 'f') ... '1d 3:41' >>> timedelta_to_str(delta, 'W L, w l, H:i:s') ... '0 weeks, 1 day, 03:41:00' Couple words about magic "full" formats. These formats show weeks number with week label, days number with day label and seconds only if weeks number, days number or seconds greater that zero. For example, :: >>> import datetime >>> from rororo.timedelta import timedelta_to_str >>> delta = datetime.timedelta(hours=12) >>> timedelta_to_str(delta, 'f') ... '12:00' >>> timedelta_to_str(delta, 'F') ... '12:00' >>> delta = datetime.timedelta(hours=12, seconds=30) >>> timedelta_to_str(delta, 'f') ... '12:00:30' >>> timedelta_to_str(delta, 'F') ... '12:00:30' >>> delta = datetime.timedelta(hours=168) >>> timedelta_to_str(delta, 'f') ... '1w 0:00' >>> timedelta_to_str(delta, 'F') ... '1 week, 0:00' :param value: Timedelta instance to convert to string. :param fmt: Format to use for conversion. """ # Only ``datetime.timedelta`` instances allowed for this function if not isinstance(value, datetime.timedelta): err = 'Value should be a "datetime.timedelta" instance. You use {0}.' raise ValueError(err.format(type(value))) # Generate total data days = value.days microseconds = value.microseconds seconds = timedelta_seconds(value) hours = seconds // 3600 minutes = seconds // 60 weeks = days // 7 # Generate collapsed data day_hours = hours - days * 24 hour_minutes = minutes - hours * 60 minute_seconds = seconds - minutes * 60 week_days = days - weeks * 7 days_label = "day" if days % 10 == 1 else "days" short_days_label = "d" short_week_days_label = "d" short_weeks_label = "w" week_days_label = "day" if week_days % 10 == 1 else "days" weeks_label = "week" if weeks % 10 == 1 else "weeks" # Collect data data = locals() fmt = fmt or TIMEDELTA_FORMAT processed = "" for part in fmt: if part in TIMEDELTA_FORMATS: is_full_part = part in ("f", "F") is_repr_part = part in ("r", "R") part = TIMEDELTA_FORMATS[part][0] if is_full_part or is_repr_part: if is_repr_part and not days: part = part.replace("%(days)d", "") part = part.replace("%(days_label)s,", "") part = part.replace("%(short_days_label)s", "") if is_full_part and not minute_seconds: part = part.replace(":%(minute_seconds)02d", "") if is_full_part and not weeks: part = part.replace("%(weeks)d", "") part = part.replace("%(short_weeks_label)s", "") part = part.replace("%(weeks_label)s,", "") if is_full_part and not week_days: part = part.replace("%(week_days)d", "") part = part.replace("%(short_week_days_label)s", "") part = part.replace("%(week_days_label)s,", "") part = part.strip() part = " ".join(part.split()) processed += part return processed % data