Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix days to hours boundary for precise delta #19

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions src/humanize/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ def __lt__(self, other):
return self.value < other.value
return NotImplemented

def succ(self):
if self == Unit.YEARS:
raise IndexError()

v = self.value + 1
return Unit(v)

def pred(self):
if self == Unit.MICROSECONDS:
raise IndexError()

v = self.value - 1
return Unit(v)


def _now():
return dt.datetime.now()
Expand Down Expand Up @@ -396,7 +410,9 @@ def _suppress_lower_units(min_unit, suppress):
return suppress


def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f") -> str:
def precisedelta(
value, minimum_unit="seconds", suppress=(), format="%0.2f", truncate=False
) -> str:
"""Return a precise representation of a timedelta.

```pycon
Expand Down Expand Up @@ -503,12 +519,8 @@ def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f") ->
years, days = _quotient_and_remainder(days, 365, YEARS, min_unit, suppress)
months, days = _quotient_and_remainder(days, 30.5, MONTHS, min_unit, suppress)

# If DAYS is not in suppress, we can represent the days but
# if it is a suppressed unit, we need to carry it to a lower unit,
# seconds in this case.
#
# The same applies for secs and usecs below
days, secs = _carry(days, secs, 24 * 3600, DAYS, min_unit, suppress)
secs = days * 24 * 3600 + secs
days, secs = _quotient_and_remainder(secs, 24 * 3600, DAYS, min_unit, suppress)

hours, secs = _quotient_and_remainder(secs, 3600, HOURS, min_unit, suppress)
minutes, secs = _quotient_and_remainder(secs, 60, MINUTES, min_unit, suppress)
Expand All @@ -533,6 +545,14 @@ def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f") ->
("%d microsecond", "%d microseconds", usecs),
]

if truncate:
for unit, fmt in reversed(list(zip(reversed(Unit), fmts))):
_x, _x, value = fmt
if unit == min_unit:
value = int(value)
if value == 0 and min_unit < YEARS:
min_unit = min_unit.succ()

texts = []
for unit, fmt in zip(reversed(Unit), fmts):
singular_txt, plural_txt, value = fmt
Expand Down
94 changes: 94 additions & 0 deletions tests/issue-14-days-hours-boundary-in-precisedelta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Days / hours boundary in `precisedelta`

When a `timedelta` is described by `precisedelta`, it
is aimed to be as human as possible.

```python
>>> import humanize
>>> import datetime

>>> humanize.precisedelta(datetime.timedelta(days=31))
'1 month and 12 hours'

>>> humanize.precisedelta(datetime.timedelta(days=62))
'2 months and 1 day'

>>> humanize.precisedelta(datetime.timedelta(days=92))
'3 months and 12 hours'

>>> humanize.precisedelta(datetime.timedelta(days=32))
'1 month, 1 day and 12 hours'
```

Setting a minimum unit forces us to use fractional value for the latest
unit, in this case, `days`:

```python
>>> humanize.precisedelta(datetime.timedelta(days=31), minimum_unit='days')
'1 month and 0.50 days'

>>> humanize.precisedelta(datetime.timedelta(days=62), minimum_unit='days')
'2 months and 1 day'

>>> humanize.precisedelta(datetime.timedelta(days=92), minimum_unit='days')
'3 months and 0.50 days'

>>> humanize.precisedelta(datetime.timedelta(days=32), minimum_unit='days')
'1 month and 1.50 days'
```

The `format` controls how this fractional (latest) value is formatted.
Using an integer representation like `'%d'` may yield unexpected results
as things like `0.50 days` are cast to `0 days` during the formatting:

```python
>>> humanize.precisedelta(datetime.timedelta(days=31), minimum_unit='days', format='%d')
'1 month and 0 days'

>>> humanize.precisedelta(datetime.timedelta(days=62), minimum_unit='days', format='%d')
'2 months and 1 day'

>>> humanize.precisedelta(datetime.timedelta(days=92), minimum_unit='days', format='%d')
'3 months and 0 days'

>>> humanize.precisedelta(datetime.timedelta(days=32), minimum_unit='days', format='%d')
'1 month and 1 days'
```

`precisedelta` accepts a `truncate` flag to truncate and drop values too
close to zero.

```python
>>> humanize.precisedelta(datetime.timedelta(days=31), minimum_unit='days', format='%d', truncate=True)
'1 month'

>>> humanize.precisedelta(datetime.timedelta(days=62), minimum_unit='days', format='%d', truncate=True)
'2 months and 1 day'

>>> humanize.precisedelta(datetime.timedelta(days=92), minimum_unit='days', format='%d', truncate=True)
'3 months'

>>> humanize.precisedelta(datetime.timedelta(days=32), minimum_unit='days', format='%d', truncate=True)
'1 month and 1 days'
```

Notice the `truncate=True` does not imply `format='%d'` as fractional
numbers are still possible and `format` still apply:

```python
>>> humanize.precisedelta(datetime.timedelta(days=31), minimum_unit='days', truncate=True)
'1 month'

>>> humanize.precisedelta(datetime.timedelta(days=62), minimum_unit='days', truncate=True)
'2 months and 1 day'

>>> humanize.precisedelta(datetime.timedelta(days=92), minimum_unit='days', truncate=True)
'3 months'

>>> humanize.precisedelta(datetime.timedelta(days=32), minimum_unit='days', truncate=True)
'1 month and 1.50 days'
```

## References

See [issue 14](https://github.com/python-humanize/humanize/issues/14)