Repository: celery/celery

Issue:
Minor bug in celery utils time.py file
There is a function named remaining in the celery.utils.time.py file. In that function, I think the statement start = start.replace(tzinfo=now.tzinfo) (line 211) has to be replaced with start = start.astimezone(now.tzinfo).

I hope the expected working by using the statement (`start = start.replace(tzinfo=now.tzinfo)`) is to change the `start` variable's date and time according to the `now` variable's timezone. But the replace function in the above mentioned statement is just replacing the timezone of the `start` variable without changing the values of its date and time. I thought `astimezone` is the method required for our purpose (which converts the date and time in `start` variable to the given timezone argument).

So, I think `start = start.replace(tzinfo=now.tzinfo)` must be changed to `start = start.astimezone(now.tzinfo)`.

Please correct me incase I am wrong.

Module versions:
Python 3.5.2
celery==4.2.1
django-celery-beat==1.4.0


Discussion:
I think that we should be calling [`make_aware()`](https://github.com/celery/celery/blob/master/celery/utils/time.py#L285) instead of doing a replace.
Care to issue a PR?
I would like to...But was something changed in `5.0-devel` branch? I dont find that `start = start.replace(tzinfo=now.tzinfo)` line itself. So, I would like to know whether the issue is taken care of already. Else, I would issue a PR. Please let me know.

Also, `make_aware` just makes a `datetime` aware from being naive I guess. But from the code, the statement `start = start.replace(tzinfo=now.tzinfo)` is called when the `utcoffset` of the `start` and `now` variables are different. So, doesn't that mean the `replace` function was trying to normalize them to one timezone before finding the remaining time (the purpose of `remaining` function)? And I think `astimezone` is suitable for this rather than `make_aware`. Please correct me if I am wrong.
`make_aware` also eventually calls `astimezone`. 
So, I think the bug can be resolved by just removing the following lines https://github.com/celery/celery/blob/59547c5ccb78d2f30d53cb7fedf9db147f2153c8/celery/utils/time.py#L210-L212
The reason is that even if both the `start` and `now` variables are in different timezones, the line https://github.com/celery/celery/blob/59547c5ccb78d2f30d53cb7fedf9db147f2153c8/celery/utils/time.py#L216 (subtraction of two datetimes) will take care of the timezone difference and return the time difference accordingly. 
So, I hope there is no need for a PR (for this minor change).
I'm quite busy with Celery 5.
If you can prove that's the case with a unit test and a contribution, go ahead and do so.
Hey, I just found that in `5.0-devel` branch, the above mentioned lines have already been removed. 
https://github.com/celery/celery/blob/7ee75fa9882545bea799db97a40cc7879d35e726/celery/utils/time.py#L180-L207
So, the issue might have been taken care already. In that case, the issue can be closed.
If the unit-test contribution is still needed, then please let me know.
I think we still need this on 4.3 and I don't think there was a test added to that.
hey..I can do that. Can you please tell me how to add a test and give PR (I know about issuing PR but not sure about adding unittests)? Sorry but I dont have much knowledge about unittests.
Just need some clarification on the specification about scheduling:
Suppose user in America/Los_Angeles timezone schedules a task to run like this (he schedules during summer): 10:00 AM, 11th day of the month, once in 6 months.
So, what is the expected scheduling?
1. When the task runs in summer, it will run on 10:00 AM PDT time and during winter, it will run at 10:00 AM PST time i.e. it will run at 10:00 AM time irrespective of PDT or PST.
OR
2. It will run on 10:00 AM PDT time during summer and 9:00 AM PST (equivalent of 10:00 AM PDT) during winter.

Resolving patch:
diff --git a/celery/utils/time.py b/celery/utils/time.py
--- a/celery/utils/time.py
+++ b/celery/utils/time.py
@@ -207,8 +207,8 @@ def remaining(start, ends_in, now=None, relative=False):
         ~datetime.timedelta: Remaining time.
     """
     now = now or datetime.utcnow()
-    if now.utcoffset() != start.utcoffset():
-        # Timezone has changed, or DST started/ended
+    if str(start.tzinfo) == str(now.tzinfo) and now.utcoffset() != start.utcoffset():
+        # DST started/ended
         start = start.replace(tzinfo=now.tzinfo)
     end_date = start + ends_in
     if relative:


Test patch:
diff --git a/t/unit/utils/test_time.py b/t/unit/utils/test_time.py
--- a/t/unit/utils/test_time.py
+++ b/t/unit/utils/test_time.py
@@ -107,9 +107,53 @@ def test_maybe_timedelta(arg, expected):
     assert maybe_timedelta(arg) == expected
 
 
-def test_remaining_relative():
+def test_remaining():
+    # Relative
     remaining(datetime.utcnow(), timedelta(hours=1), relative=True)
 
+    """
+    The upcoming cases check whether the next run is calculated correctly
+    """
+    eastern_tz = pytz.timezone("US/Eastern")
+    tokyo_tz = pytz.timezone("Asia/Tokyo")
+
+    # Case 1: `start` in UTC and `now` in other timezone
+    start = datetime.now(pytz.utc)
+    now = datetime.now(eastern_tz)
+    delta = timedelta(hours=1)
+    assert str(start.tzinfo) == str(pytz.utc)
+    assert str(now.tzinfo) == str(eastern_tz)
+    rem_secs = remaining(start, delta, now).total_seconds()
+    # assert remaining time is approximately equal to delta
+    assert rem_secs == pytest.approx(delta.total_seconds(), abs=1)
+
+    # Case 2: `start` and `now` in different timezones (other than UTC)
+    start = datetime.now(eastern_tz)
+    now = datetime.now(tokyo_tz)
+    delta = timedelta(hours=1)
+    assert str(start.tzinfo) == str(eastern_tz)
+    assert str(now.tzinfo) == str(tokyo_tz)
+    rem_secs = remaining(start, delta, now).total_seconds()
+    assert rem_secs == pytest.approx(delta.total_seconds(), abs=1)
+
+    """
+    Case 3: DST check
+    Suppose start (which is last_run_time) is in EST while next_run is in EDT, then
+    check whether the `next_run` is actually the time specified in the start (i.e. there is not an hour diff due to DST).
+    In 2019, DST starts on March 10
+    """
+    start = eastern_tz.localize(datetime(month=3, day=9, year=2019, hour=10, minute=0))         # EST
+    now = eastern_tz.localize(datetime(day=11, month=3, year=2019, hour=1, minute=0))           # EDT
+    delta = ffwd(hour=10, year=2019, microsecond=0, minute=0, second=0, day=11, weeks=0, month=3)
+    # `next_actual_time` is the next time to run (derived from delta)
+    next_actual_time = eastern_tz.localize(datetime(day=11, month=3, year=2019, hour=10, minute=0))         # EDT
+    assert start.tzname() == "EST"
+    assert now.tzname() == "EDT"
+    assert next_actual_time.tzname() == "EDT"
+    rem_time = remaining(start, delta, now)
+    next_run = now + rem_time
+    assert next_run == next_actual_time
+
 
 class test_timezone: