Repository: jmoiron/humanize

Issue:
The package (wrongly) assumes all times are in the local timezone

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/jmoiron/humanize/pull/144?src=pr&el=h1) Report
> Merging [#144](https://codecov.io/gh/jmoiron/humanize/pull/144?src=pr&el=desc) into [master](https://codecov.io/gh/jmoiron/humanize/commit/56a06434c5e7026c70e674ec45975e5e27ae7e70&el=desc) will **increase** coverage by `0.01%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/jmoiron/humanize/pull/144/graphs/tree.svg?width=650&height=150&src=pr&token=uL9HFbdqDq)](https://codecov.io/gh/jmoiron/humanize/pull/144?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #144      +/-   ##
==========================================
+ Coverage   99.28%   99.30%   +0.01%     
==========================================
  Files           9        9              
  Lines         421      429       +8     
==========================================
+ Hits          418      426       +8     
  Misses          3        3              
```

| Flag | Coverage Δ | |
|---|---|---|
| #GHA_Ubuntu | `99.30% <100.00%> (+0.01%)` | :arrow_up: |
| #GHA_Windows | `?` | |
| #GHA_macOS | `?` | |

| [Impacted Files](https://codecov.io/gh/jmoiron/humanize/pull/144?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/humanize/time.py](https://codecov.io/gh/jmoiron/humanize/pull/144/diff?src=pr&el=tree#diff-c3JjL2h1bWFuaXplL3RpbWUucHk=) | `100.00% <100.00%> (ø)` | |
| [tests/test\_time.py](https://codecov.io/gh/jmoiron/humanize/pull/144/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF90aW1lLnB5) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/jmoiron/humanize/pull/144?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/jmoiron/humanize/pull/144?src=pr&el=footer). Last update [56a0643...e0008fa](https://codecov.io/gh/jmoiron/humanize/pull/144?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

phijor: So... yeah, `black` seems to be fighting `flake8` here. I cannot put the test cases on multiple lines (`black` folds them into one), but having them folded into one line by `black` results in a trailing comma which `flake8` does not like. :exploding_head: 
hugovk: Thank you for the PR!

So `now` could be another timezone, or in the past or future. Would it be better to call it `when`?

For example `now=yesterday` or `now=next_week` might be better as `when=yesterday` or `when=next_week`?


> So... yeah, `black` seems to be fighting `flake8` here. I cannot put the test cases on multiple lines (`black` folds them into one), but having them folded into one line by `black` results in a trailing comma which `flake8` does not like. 🤯

Yeah, it's a bit annoying, I think https://github.com/psf/black/issues/1288 is the issue for that.
phijor: > Thank you for the PR!

Thank you for your feedback :) Date, time and timezones are confusing.

> So `now` could be another timezone, or in the past or future. Would it be better to call it `when`?
> 
> For example `now=yesterday` or `now=next_week` might be better as `when=yesterday` or `when=next_week`?

Calling it `when` sounds like a good idea. I'll push some changes and resolve conflicts in next few days.

My use case for this is as follows: I have a pair of `datetime` objects, `current_time` and `starting_time`. I cannot control which timezone they are in, but I can assume that it is the same for both of them. Now I want to print (in a human readable way) when `starting_time` happened, relative to `current_time`. Something like

```python
>>> (
    f"starting_time is {naturalday(starting_time)}, "
    f"which was {naturaltime(starting_time, when=current_time)}"
)
"starting_time is Jul 12, which was 10 days ago"
```

Usually it would make sense to just use `naturaltime(current_time - starting_time)`, but the current implementation (`date_and_delta`) insists on calculating a date relative to `_now()`, so that won't work. Maybe we can even work around that without having to add a `when` parameter by refactoring `date_and_delta`.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e21eccf..7af3b16 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,7 +6,7 @@ repos:
         args: ["--py36-plus"]
 
   - repo: https://github.com/psf/black
-    rev: 19.10b0
+    rev: 20.8b1
     hooks:
       - id: black
         args: ["--target-version", "py36"]
diff --git a/src/humanize/time.py b/src/humanize/time.py
index b0bf85b..bc1922c 100644
--- a/src/humanize/time.py
+++ b/src/humanize/time.py
@@ -81,7 +81,7 @@ def date_and_delta(value, *, now=None):
     return date, abs_timedelta(delta)
 
 
-def naturaldelta(value, months=True, minimum_unit="seconds"):
+def naturaldelta(value, months=True, minimum_unit="seconds", when=None):
     """Return a natural representation of a timedelta or number of seconds.
 
     This is similar to `naturaltime`, but does not add tense to the result.
@@ -91,16 +91,30 @@ def naturaldelta(value, months=True, minimum_unit="seconds"):
         months (bool): If `True`, then a number of months (based on 30.5 days) will be
             used for fuzziness between years.
         minimum_unit (str): The lowest unit that can be used.
+        when (datetime.timedelta): Point in time relative to which _value_ is
+            interpreted.  Defaults to the current time in the local timezone.
 
     Returns:
         str: A natural representation of the amount of time elapsed.
+
+    Examples
+        Compare two timestamps in a custom local timezone::
+
+        import datetime as dt
+        from dateutil.tz import gettz
+
+        berlin = gettz("Europe/Berlin")
+        now = dt.datetime.now(tz=berlin)
+        later = now + dt.timedelta(minutes=30)
+
+        assert naturaldelta(later, when=now) == "30 minutes"
     """
     tmp = Unit[minimum_unit.upper()]
     if tmp not in (Unit.SECONDS, Unit.MILLISECONDS, Unit.MICROSECONDS):
         raise ValueError(f"Minimum unit '{minimum_unit}' not supported")
     minimum_unit = tmp
 
-    date, delta = date_and_delta(value)
+    date, delta = date_and_delta(value, now=when)
     if date is None:
         return value
 
@@ -173,7 +187,7 @@ def naturaldelta(value, months=True, minimum_unit="seconds"):
         return ngettext("%d year", "%d years", years) % years
 
 
-def naturaltime(value, future=False, months=True, minimum_unit="seconds"):
+def naturaltime(value, future=False, months=True, minimum_unit="seconds", when=None):
     """Return a natural representation of a time in a resolution that makes sense.
 
     This is more or less compatible with Django's `naturaltime` filter.
@@ -186,11 +200,13 @@ def naturaltime(value, future=False, months=True, minimum_unit="seconds"):
         months (bool): If `True`, then a number of months (based on 30.5 days) will be
             used for fuzziness between years.
         minimum_unit (str): The lowest unit that can be used.
+        when (datetime.datetime): Point in time relative to which _value_ is
+            interpreted.  Defaults to the current time in the local timezone.
 
     Returns:
         str: A natural representation of the input in a resolution that makes sense.
     """
-    now = _now()
+    now = when or _now()
     date, delta = date_and_delta(value, now=now)
     if date is None:
         return value
@@ -199,7 +215,7 @@ def naturaltime(value, future=False, months=True, minimum_unit="seconds"):
         future = date > now
 
     ago = _("%s from now") if future else _("%s ago")
-    delta = naturaldelta(delta, months, minimum_unit)
+    delta = naturaldelta(delta, months, minimum_unit, when=when)
 
     if delta == _("a moment"):
         return _("now")


Test patch:
diff --git a/tests/test_time.py b/tests/test_time.py
index bf5a853..90c0130 100644
--- a/tests/test_time.py
+++ b/tests/test_time.py
@@ -24,6 +24,8 @@ ONE_YEAR = 365.25 * ONE_DAY
 
 with freeze_time("2020-02-02"):
     NOW = dt.datetime.now()
+    NOW_UTC = dt.datetime.now(tz=dt.timezone.utc)
+    NOW_UTC_PLUS_01_00 = dt.datetime.now(tz=dt.timezone(offset=dt.timedelta(hours=1)))
     TODAY = dt.date.today()
     TOMORROW = TODAY + ONE_DAY_DELTA
     YESTERDAY = TODAY - ONE_DAY_DELTA
@@ -331,6 +333,37 @@ def test_naturaldelta_minimum_unit_explicit(minimum_unit, seconds, expected):
     assert humanize.naturaldelta(delta, minimum_unit=minimum_unit) == expected
 
 
+@pytest.mark.parametrize(
+    "test_input, when, expected",
+    [
+        (NOW, NOW, "a moment"),
+        (NOW_UTC, NOW_UTC, "a moment"),
+    ],
+)
+def test_naturaldelta_when_explicit(test_input, when, expected):
+    # Act / Assert
+    assert humanize.naturaldelta(test_input, when=when) == expected
+
+
+@pytest.mark.parametrize(
+    "value, when",
+    [
+        (NOW_UTC, None),
+        (NOW_UTC, NOW),
+        (NOW_UTC_PLUS_01_00, None),
+        (NOW_UTC_PLUS_01_00, NOW),
+    ],
+)
+def test_naturaldelta_when_missing_tzinfo(value, when):
+    """Subtraction `when - value` is not defined by the `datetime` module when
+    either operand has not timezone-info (`tz=None`) and raises a TypeError.
+    """
+
+    # Act / Assert
+    with pytest.raises(TypeError):
+        humanize.naturaldelta(value, when=when)
+
+
 @pytest.mark.parametrize(
     "seconds, expected",
     [