codekingpro/portable-devtools
114k
1"""Time humanizing functions.2 3These are largely borrowed from Django's `contrib.humanize`.4"""5 6from __future__ import annotations7 8from enum import Enum9from functools import total_ordering10 11from .i18n import _gettext as _12from .i18n import _ngettext13from .number import intcomma14 15TYPE_CHECKING = False16if TYPE_CHECKING:17 import datetime as dt18 from collections.abc import Iterable19 from typing import Any20 21__all__ = [22 "naturaldate",23 "naturalday",24 "naturaldelta",25 "naturaltime",26 "precisedelta",27]28 29 30@total_ordering31class Unit(Enum):32 MICROSECONDS = 033 MILLISECONDS = 134 SECONDS = 235 MINUTES = 336 HOURS = 437 DAYS = 538 MONTHS = 639 YEARS = 740 41 def __lt__(self, other: Any) -> Any:42 if self.__class__ is other.__class__:43 return self.value < other.value44 return NotImplemented45 46 47def _now() -> dt.datetime:48 import datetime as dt49 50 return dt.datetime.now()51 52 53def _abs_timedelta(delta: dt.timedelta) -> dt.timedelta:54 """Return an "absolute" value for a timedelta, always representing a time distance.55 56 Args:57 delta (datetime.timedelta): Input timedelta.58 59 Returns:60 datetime.timedelta: Absolute timedelta.61 """62 if delta.days < 0:63 now = _now()64 return now - (now + delta)65 return delta66 67 68def _date_and_delta(69 value: Any, *, now: dt.datetime | None = None, precise: bool = False70) -> tuple[Any, Any]:71 """Turn a value into a date and a timedelta which represents how long ago it was.72 73 If that's not possible, return `(None, value)`.74 """75 import datetime as dt76 77 if not now:78 now = _now()79 if isinstance(value, dt.datetime):80 date = value81 delta = now - value82 elif isinstance(value, dt.timedelta):83 date = now - value84 delta = value85 else:86 try:87 value = value if precise else round(value)88 delta = dt.timedelta(seconds=value)89 date = now - delta90 except (ValueError, TypeError):91 return None, value92 return date, _abs_timedelta(delta)93 94 95def naturaldelta(96 value: dt.timedelta | float,97 months: bool = True,98 minimum_unit: str = "seconds",99) -> str:100 """Return a natural representation of a timedelta or number of seconds.101 102 This is similar to `naturaltime`, but does not add tense to the result.103 104 The timedelta will be rounded to the nearest unit that makes sense.105 106 Args:107 value (datetime.timedelta, int or float): A timedelta or a number of seconds.108 months (bool): If `True`, then a number of months (based on 30.5 days) will be109 used for fuzziness between years.110 minimum_unit (str): The lowest unit that can be used.111 112 Returns:113 str (str or `value`): A natural representation of the amount of time114 elapsed unless `value` is not datetime.timedelta or cannot be115 converted to int (cannot be float due to 'inf' or 'nan').116 In that case, a `value` is returned unchanged.117 118 Raises:119 OverflowError: If `value` is too large to convert to datetime.timedelta.120 121 Examples:122 Compare two timestamps in a custom local timezone::123 124 ```pycon125 >>> import datetime as dt126 >>> from dateutil.tz import gettz127 128 >>> berlin = gettz("Europe/Berlin")129 >>> now = dt.datetime.now(tz=berlin)130 >>> later = now + dt.timedelta(minutes=30)131 132 >>> assert naturaldelta(later - now) == "30 minutes"133 True134 ```135 136 """137 import datetime as dt138 139 tmp = Unit[minimum_unit.upper()]140 if tmp not in (Unit.SECONDS, Unit.MILLISECONDS, Unit.MICROSECONDS):141 msg = f"Minimum unit '{minimum_unit}' not supported"142 raise ValueError(msg)143 min_unit = tmp144 145 if isinstance(value, dt.timedelta):146 delta = value147 else:148 try:149 int(value) # Explicitly don't support string such as "NaN" or "inf"150 value = float(value)151 delta = dt.timedelta(seconds=value)152 except (ValueError, TypeError):153 return str(value)154 155 use_months = months156 157 delta = abs(delta)158 years = delta.days // 365159 days = delta.days % 365160 num_months = round(days / 30.5)161 162 if years == 0 and days < 1:163 if delta.seconds == 0:164 if min_unit == Unit.MICROSECONDS and delta.microseconds < 1000:165 return (166 _ngettext("%d microsecond", "%d microseconds", delta.microseconds)167 % delta.microseconds168 )169 170 if min_unit == Unit.MILLISECONDS or (171 min_unit == Unit.MICROSECONDS and 1000 <= delta.microseconds < 1_000_000172 ):173 milliseconds = delta.microseconds / 1000174 return (175 _ngettext("%d millisecond", "%d milliseconds", int(milliseconds))176 % milliseconds177 )178 return _("a moment")179 180 if delta.seconds == 1:181 return _("a second")182 183 if delta.seconds < 60:184 return _ngettext("%d second", "%d seconds", delta.seconds) % delta.seconds185 186 if 60 <= delta.seconds < 3600:187 minutes = round(delta.seconds / 60)188 if minutes == 1:189 return _("a minute")190 191 if minutes == 60:192 return _("an hour")193 194 return _ngettext("%d minute", "%d minutes", minutes) % minutes195 196 if 3600 <= delta.seconds:197 hours = round(delta.seconds / 3600)198 if hours == 1:199 return _("an hour")200 201 if hours == 24:202 return _("a day")203 204 return _ngettext("%d hour", "%d hours", hours) % hours205 206 elif years == 0:207 if days == 1:208 return _("a day")209 210 if not use_months:211 return _ngettext("%d day", "%d days", days) % days212 213 if num_months == 0:214 return _ngettext("%d day", "%d days", days) % days215 216 if num_months == 1:217 return _("a month")218 219 if num_months == 12:220 return _("a year")221 222 return _ngettext("%d month", "%d months", num_months) % num_months223 224 elif years == 1:225 if num_months == 0 and days == 0:226 return _("a year")227 228 if num_months == 0:229 return _ngettext("1 year, %d day", "1 year, %d days", days) % days230 231 if use_months:232 if num_months == 1:233 return _("1 year, 1 month")234 235 if num_months == 12:236 years += 1237 return _ngettext("%d year", "%d years", years) % years238 239 return (240 _ngettext("1 year, %d month", "1 year, %d months", num_months)241 % num_months242 )243 244 return _ngettext("1 year, %d day", "1 year, %d days", days) % days245 246 return _ngettext("%d year", "%d years", years).replace("%d", "%s") % intcomma(years)247 248 249def naturaltime(250 value: dt.datetime | dt.timedelta | float,251 future: bool = False,252 months: bool = True,253 minimum_unit: str = "seconds",254 when: dt.datetime | None = None,255) -> str:256 """Return a natural representation of a time in a resolution that makes sense.257 258 This is more or less compatible with Django's `naturaltime` filter.259 260 The time will be rounded to the nearest unit that makes sense.261 262 Args:263 value (datetime.datetime, datetime.timedelta, int or float): A `datetime`, a264 `timedelta`, or a number of seconds.265 future (bool): Ignored for `datetime`s and `timedelta`s, where the tense is266 always figured out based on the current time. For integers and floats, the267 return value will be past tense by default, unless future is `True`.268 months (bool): If `True`, then a number of months (based on 30.5 days) will be269 used for fuzziness between years.270 minimum_unit (str): The lowest unit that can be used.271 when (datetime.datetime): Point in time relative to which _value_ is272 interpreted. Defaults to the current time in the local timezone.273 274 Returns:275 str: A natural representation of the input in a resolution that makes sense.276 """277 import datetime as dt278 279 value = _convert_aware_datetime(value)280 when = _convert_aware_datetime(when)281 282 now = when or _now()283 284 date, delta = _date_and_delta(value, now=now)285 if date is None:286 return str(value)287 # determine tense by value only if datetime/timedelta were passed288 if isinstance(value, (dt.datetime, dt.timedelta)):289 future = date > now290 291 ago = _("%s from now") if future else _("%s ago")292 delta = naturaldelta(delta, months, minimum_unit)293 294 if delta == _("a moment"):295 return _("now")296 297 return str(ago % delta)298 299 300def _convert_aware_datetime(301 value: dt.datetime | dt.timedelta | float | None,302) -> Any:303 """Convert aware datetime to naive datetime and pass through any other type."""304 import datetime as dt305 306 if isinstance(value, dt.datetime) and value.tzinfo is not None:307 value = dt.datetime.fromtimestamp(value.timestamp())308 return value309 310 311def naturalday(value: dt.date | dt.datetime, format: str = "%b %d") -> str:312 """Return a natural day.313 314 For date values that are tomorrow, today or yesterday compared to315 present day return representing string. Otherwise, return a string316 formatted according to `format`.317 318 """319 import datetime as dt320 321 try:322 value = dt.date(value.year, value.month, value.day)323 except AttributeError:324 # Passed value wasn't date-ish325 return str(value)326 except (OverflowError, ValueError):327 # Date arguments out of range328 return str(value)329 delta = value - dt.date.today()330 331 if delta.days == 0:332 return _("today")333 334 if delta.days == 1:335 return _("tomorrow")336 337 if delta.days == -1:338 return _("yesterday")339 340 return value.strftime(format)341 342 343def naturaldate(value: dt.date | dt.datetime) -> str:344 """Like `naturalday`, but append a year for dates more than ~five months away."""345 import datetime as dt346 347 try:348 value = dt.date(value.year, value.month, value.day)349 except AttributeError:350 # Passed value wasn't date-ish351 return str(value)352 except (OverflowError, ValueError):353 # Date arguments out of range354 return str(value)355 delta = _abs_timedelta(value - dt.date.today())356 if delta.days >= 5 * 365 / 12:357 return naturalday(value, "%b %d %Y")358 return naturalday(value)359 360 361def _quotient_and_remainder(362 value: float,363 divisor: float,364 unit: Unit,365 minimum_unit: Unit,366 suppress: Iterable[Unit],367 format: str,368) -> tuple[float, float]:369 """Divide `value` by `divisor`, returning the quotient and remainder.370 371 If `unit` is `minimum_unit`, the quotient will be the rounding of `value / divisor`372 according to the `format` string and the remainder will be zero. The rationale is373 that if `unit` is the unit of the quotient, we cannot represent the remainder374 because it would require a unit smaller than the `minimum_unit`.375 376 >>> from humanize.time import _quotient_and_remainder, Unit377 >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.DAYS, [], "%0.2f")378 (1.5, 0)379 380 If `unit` is in `suppress`, the quotient will be zero and the remainder will be the381 initial value. The idea is that if we cannot use `unit`, we are forced to use a382 lower unit, so we cannot do the division.383 384 >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, [Unit.DAYS], "%0.2f")385 (0, 36)386 387 In other cases, return the quotient and remainder as `divmod` would do it.388 389 >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, [], "%0.2f")390 (1, 12)391 392 """393 if unit == minimum_unit:394 return _rounding_by_fmt(format, value / divisor), 0395 396 if unit in suppress:397 return 0, value398 399 # Convert the remainder back to integer is necessary for months. 1 month is 30.5400 # days on average, but if we have 31 days, we want to count is as a whole month,401 # and not as 1 month plus a remainder of 0.5 days.402 q, r = divmod(value, divisor)403 return q, int(r)404 405 406def _suitable_minimum_unit(min_unit: Unit, suppress: Iterable[Unit]) -> Unit:407 """Return a minimum unit suitable that is not suppressed.408 409 If not suppressed, return the same unit:410 411 >>> from humanize.time import _suitable_minimum_unit, Unit412 >>> _suitable_minimum_unit(Unit.HOURS, []).name413 'HOURS'414 415 But if suppressed, find a unit greater than the original one that is not416 suppressed:417 418 >>> _suitable_minimum_unit(Unit.HOURS, [Unit.HOURS]).name419 'DAYS'420 421 >>> _suitable_minimum_unit(Unit.HOURS, [Unit.HOURS, Unit.DAYS]).name422 'MONTHS'423 """424 if min_unit in suppress:425 for unit in Unit:426 if unit > min_unit and unit not in suppress:427 return unit428 429 msg = "Minimum unit is suppressed and no suitable replacement was found"430 raise ValueError(msg)431 432 return min_unit433 434 435def _suppress_lower_units(min_unit: Unit, suppress: Iterable[Unit]) -> set[Unit]:436 """Extend suppressed units (if any) with all units lower than the minimum unit.437 438 >>> from humanize.time import _suppress_lower_units, Unit439 >>> [x.name for x in sorted(_suppress_lower_units(Unit.SECONDS, [Unit.DAYS]))]440 ['MICROSECONDS', 'MILLISECONDS', 'DAYS']441 """442 suppress = set(suppress)443 for unit in Unit:444 if unit == min_unit:445 break446 suppress.add(unit)447 448 return suppress449 450 451def precisedelta(452 value: dt.timedelta | float | None,453 minimum_unit: str = "seconds",454 suppress: Iterable[str] = (),455 format: str = "%0.2f",456) -> str:457 """Return a precise representation of a timedelta or number of seconds.458 459 ```pycon460 >>> import datetime as dt461 >>> from humanize.time import precisedelta462 463 >>> delta = dt.timedelta(seconds=3633, days=2, microseconds=123000)464 >>> precisedelta(delta)465 '2 days, 1 hour and 33.12 seconds'466 467 ```468 469 A custom `format` can be specified to control how the fractional part470 is represented:471 472 ```pycon473 >>> precisedelta(delta, format="%0.4f")474 '2 days, 1 hour and 33.1230 seconds'475 476 ```477 478 Instead, the `minimum_unit` can be changed to have a better resolution;479 the function will still readjust the unit to use the greatest of the480 units that does not lose precision.481 482 For example setting microseconds but still representing the date with milliseconds:483 484 ```pycon485 >>> precisedelta(delta, minimum_unit="microseconds")486 '2 days, 1 hour, 33 seconds and 123 milliseconds'487 488 ```489 490 If desired, some units can be suppressed: you will not see them represented and the491 time of the other units will be adjusted to keep representing the same timedelta:492 493 ```pycon494 >>> precisedelta(delta, suppress=['days'])495 '49 hours and 33.12 seconds'496 497 ```498 499 Note that microseconds precision is lost if the seconds and all500 the units below are suppressed:501 502 ```pycon503 >>> delta = dt.timedelta(seconds=90, microseconds=100)504 >>> precisedelta(delta, suppress=['seconds', 'milliseconds', 'microseconds'])505 '1.50 minutes'506 507 ```508 509 If the delta is too small to be represented with the minimum unit,510 a value of zero will be returned:511 512 ```pycon513 >>> delta = dt.timedelta(seconds=1)514 >>> precisedelta(delta, minimum_unit="minutes")515 '0.02 minutes'516 517 >>> delta = dt.timedelta(seconds=0.1)518 >>> precisedelta(delta, minimum_unit="minutes")519 '0 minutes'520 521 ```522 """523 date, delta = _date_and_delta(value, precise=True)524 if date is None:525 return str(value)526 527 suppress_set = {Unit[s.upper()] for s in suppress}528 529 # Find a suitable minimum unit (it can be greater than the one that the530 # user gave us, if that one is suppressed).531 min_unit = Unit[minimum_unit.upper()]532 min_unit = _suitable_minimum_unit(min_unit, suppress_set)533 del minimum_unit534 535 # Expand the suppressed units list/set to include all the units536 # that are below the minimum unit537 suppress_set = _suppress_lower_units(min_unit, suppress_set)538 539 # handy aliases540 days = delta.days541 secs = delta.seconds542 usecs = delta.microseconds543 544 MICROSECONDS, MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS, MONTHS, YEARS = list(545 Unit546 )547 548 # Given DAYS compute YEARS and the remainder of DAYS as follows:549 # if YEARS is the minimum unit, we cannot use DAYS so550 # we will use a float for YEARS and 0 for DAYS:551 # years, days = years/days, 0552 #553 # if YEARS is suppressed, use DAYS:554 # years, days = 0, days555 #556 # otherwise:557 # years, days = divmod(years, days)558 #559 # The same applies for months, hours, minutes and milliseconds below560 years, days = _quotient_and_remainder(561 days, 365, YEARS, min_unit, suppress_set, format562 )563 months, days = _quotient_and_remainder(564 days, 30.5, MONTHS, min_unit, suppress_set, format565 )566 567 secs = days * 24 * 3600 + secs568 days, secs = _quotient_and_remainder(569 secs, 24 * 3600, DAYS, min_unit, suppress_set, format570 )571 572 hours, secs = _quotient_and_remainder(573 secs, 3600, HOURS, min_unit, suppress_set, format574 )575 minutes, secs = _quotient_and_remainder(576 secs, 60, MINUTES, min_unit, suppress_set, format577 )578 579 usecs = secs * 1e6 + usecs580 secs, usecs = _quotient_and_remainder(581 usecs, 1e6, SECONDS, min_unit, suppress_set, format582 )583 584 msecs, usecs = _quotient_and_remainder(585 usecs, 1000, MILLISECONDS, min_unit, suppress_set, format586 )587 588 # Due to rounding, it could be that a unit is high enough to be promoted to a higher589 # unit. Example: 59.9 minutes was rounded to 60 minutes, and thus it should become 0590 # minutes and one hour more.591 if msecs >= 1_000 and SECONDS not in suppress_set:592 msecs -= 1_000593 secs += 1594 if secs >= 60 and MINUTES not in suppress_set:595 secs -= 60596 minutes += 1597 if minutes >= 60 and HOURS not in suppress_set:598 minutes -= 60599 hours += 1600 if hours >= 24 and DAYS not in suppress_set:601 hours -= 24602 days += 1603 # When adjusting we should not deal anymore with fractional days as all rounding has604 # been already made. We promote 31 days to an extra month.605 if days >= 31 and MONTHS not in suppress_set:606 days -= 31607 months += 1608 if months >= 12 and YEARS not in suppress_set:609 months -= 12610 years += 1611 612 fmts = [613 ("%d year", "%d years", years),614 ("%d month", "%d months", months),615 ("%d day", "%d days", days),616 ("%d hour", "%d hours", hours),617 ("%d minute", "%d minutes", minutes),618 ("%d second", "%d seconds", secs),619 ("%d millisecond", "%d milliseconds", msecs),620 ("%d microsecond", "%d microseconds", usecs),621 ]622 623 texts: list[str] = []624 for unit, fmt in zip(reversed(Unit), fmts):625 singular_txt, plural_txt, fmt_value = fmt626 if fmt_value > 0 or (not texts and unit == min_unit):627 _fmt_value = 2 if 1 < fmt_value < 2 else int(fmt_value)628 fmt_txt = _ngettext(singular_txt, plural_txt, _fmt_value)629 import math630 631 if unit == min_unit and math.modf(fmt_value)[0] > 0:632 fmt_txt = fmt_txt.replace("%d", format)633 elif unit == YEARS:634 if math.modf(fmt_value)[0] == 0:635 fmt_value = int(fmt_value)636 fmt_txt = fmt_txt.replace("%d", "%s")637 texts.append(fmt_txt % intcomma(fmt_value))638 continue639 640 texts.append(fmt_txt % fmt_value)641 642 if unit == min_unit:643 break644 645 if len(texts) == 1:646 return texts[0]647 648 head = ", ".join(texts[:-1])649 tail = texts[-1]650 651 return _("%s and %s") % (head, tail)652 653 654def _rounding_by_fmt(format: str, value: float) -> float | int:655 """Round a number according to the string format provided.656 657 The string format is the old printf-style string formatting.658 659 If we are using a format which truncates the value, such as "%d" or "%i", the660 returned value will be of type `int`.661 662 If we are using a format which rounds the value, such as "%.2f" or even "%.0f",663 we will return a float.664 """665 result = format % value666 667 try:668 value = int(result)669 except ValueError:670 value = float(result)671 672 return value673 