v0.150.0
  1import datetime
  2
  3from plain.utils.html import avoid_wrapping
  4from plain.utils.text import pluralize_lazy
  5from plain.utils.timezone import is_aware
  6
  7
  8def timesince(
  9    d: datetime.datetime,
 10    *,
 11    now: datetime.datetime | None = None,
 12    reversed: bool = False,
 13    format: str | dict[str, str] = "verbose",
 14    depth: int = 2,
 15) -> str:
 16    """
 17    Take two datetime objects and return the time between d and now as a nicely
 18    formatted string, e.g., "10 minutes" or "10m" (depending on the format).
 19
 20    `format` can be:
 21        - "verbose": e.g., "1 year, 2 months"
 22        - "short": e.g., "1y 2m"
 23        - A custom dictionary defining time unit formats.
 24
 25    Units used are years, months, weeks, days, hours, and minutes.
 26    Seconds and microseconds are ignored.
 27
 28    The algorithm takes into account the varying duration of years and months.
 29    For example, there is exactly "1 year, 1 month" between 2013/02/10 and
 30    2014/03/10, but also between 2007/08/10 and 2008/09/10 despite the delta
 31    being 393 days in the former case and 397 in the latter.
 32
 33    Up to `depth` adjacent units will be displayed. For example,
 34    "2 weeks, 3 days" and "1 year, 3 months" are possible outputs, but
 35    "2 weeks, 3 hours" and "1 year, 5 days" are not.
 36
 37    Arguments:
 38        d: A datetime object representing the starting time.
 39        now: A datetime object representing the current time. Defaults to the
 40             current time if not provided.
 41        reversed: If True, calculates time until `d` rather than since `d`.
 42        format: The output format, either "verbose", "short", or a custom
 43                dictionary of time unit formats.
 44        depth: An integer specifying the number of adjacent time units to display.
 45
 46    Returns:
 47        A string representing the time difference, formatted according to the
 48        specified format.
 49
 50    Raises:
 51        ValueError: If depth is less than 1 or if format is invalid.
 52    """
 53    TIME_CHUNKS = [
 54        60 * 60 * 24 * 7,  # week
 55        60 * 60 * 24,  # day
 56        60 * 60,  # hour
 57        60,  # minute
 58    ]
 59    MONTHS_DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
 60    TIME_STRINGS_KEYS = ["year", "month", "week", "day", "hour", "minute"]
 61
 62    VERBOSE_TIME_STRINGS = {
 63        "year": pluralize_lazy("%(num)d year", "%(num)d years", "num"),
 64        "month": pluralize_lazy("%(num)d month", "%(num)d months", "num"),
 65        "week": pluralize_lazy("%(num)d week", "%(num)d weeks", "num"),
 66        "day": pluralize_lazy("%(num)d day", "%(num)d days", "num"),
 67        "hour": pluralize_lazy("%(num)d hour", "%(num)d hours", "num"),
 68        "minute": pluralize_lazy("%(num)d minute", "%(num)d minutes", "num"),
 69    }
 70    SHORT_TIME_STRINGS = {
 71        "year": "%(num)dy",
 72        "month": "%(num)dmo",
 73        "week": "%(num)dw",
 74        "day": "%(num)dd",
 75        "hour": "%(num)dh",
 76        "minute": "%(num)dm",
 77    }
 78
 79    # Determine time_strings based on format
 80    if format == "verbose":
 81        time_strings = VERBOSE_TIME_STRINGS
 82    elif format == "short":
 83        time_strings = SHORT_TIME_STRINGS
 84    elif isinstance(format, dict):
 85        time_strings = format
 86    else:
 87        raise ValueError(
 88            "format must be 'verbose', 'short', or a custom dictionary of formats."
 89        )
 90
 91    if depth <= 0:
 92        raise ValueError("depth must be greater than 0.")
 93
 94    # Convert datetime.date to datetime.datetime for comparison.
 95    if not isinstance(d, datetime.datetime):
 96        d = datetime.datetime(d.year, d.month, d.day)
 97    if now and not isinstance(now, datetime.datetime):
 98        now = datetime.datetime(now.year, now.month, now.day)
 99
100    now = now or datetime.datetime.now(datetime.UTC if is_aware(d) else None)
101
102    if reversed:
103        d, now = now, d
104    delta = now - d
105
106    # Ignore microseconds.
107    since = delta.days * 24 * 60 * 60 + delta.seconds
108    if since <= 0:
109        # d is in the future compared to now, stop processing.
110        return avoid_wrapping(time_strings["minute"] % {"num": 0})
111
112    # Get years and months.
113    total_months = (now.year - d.year) * 12 + (now.month - d.month)
114    if d.day > now.day or (d.day == now.day and d.time() > now.time()):
115        total_months -= 1
116    years, months = divmod(total_months, 12)
117
118    # Calculate the remaining time.
119    if years or months:
120        pivot_year = d.year + years
121        pivot_month = d.month + months
122        if pivot_month > 12:
123            pivot_month -= 12
124            pivot_year += 1
125        pivot = datetime.datetime(
126            pivot_year,
127            pivot_month,
128            min(MONTHS_DAYS[pivot_month - 1], d.day),
129            d.hour,
130            d.minute,
131            d.second,
132            tzinfo=d.tzinfo,
133        )
134    else:
135        pivot = d
136    remaining_time = (now - pivot).total_seconds()
137    partials = [years, months]
138    for chunk in TIME_CHUNKS:
139        count = int(remaining_time // chunk)
140        partials.append(count)
141        remaining_time -= chunk * count
142
143    # Find the first non-zero part (if any) and then build the result, until
144    # depth.
145    i = 0
146    for i, value in enumerate(partials):
147        if value != 0:
148            break
149    else:
150        return avoid_wrapping(time_strings["minute"] % {"num": 0})
151
152    result = []
153    current_depth = 0
154    while i < len(TIME_STRINGS_KEYS) and current_depth < depth:
155        value = partials[i]
156        if value == 0:
157            break
158        name = TIME_STRINGS_KEYS[i]
159        result.append(avoid_wrapping(time_strings[name] % {"num": value}))
160        current_depth += 1
161        i += 1
162
163    return ", ".join(result)
164
165
166def timeuntil(
167    d: datetime.datetime,
168    now: datetime.datetime | None = None,
169    format: str | dict[str, str] = "verbose",
170    depth: int = 2,
171) -> str:
172    """
173    Like timesince, but return a string measuring the time until the given time.
174    """
175    return timesince(d, now=now, reversed=True, format=format, depth=depth)