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

tzinfo isn't considered #17

Closed
Inphi opened this issue Mar 3, 2016 · 9 comments
Closed

tzinfo isn't considered #17

Inphi opened this issue Mar 3, 2016 · 9 comments

Comments

@Inphi
Copy link

Inphi commented Mar 3, 2016

I read the comments made in #10 about handling timezones and DST transitions. The solutions given is is inadequate, because parse-crontab, after calculating the future datetime, computes the timedelta between the future and the passed in now (tz-aware) datetime.

To see why consider the following example:

dt = datetime(2016, 03, 13, hour=5, minute=0, tzinfo=est) # where est is the America/New_York implementation
print CronTab("00 09 13 MAR *").next(dt)

The output is 4 hours, instead of 5 hours, because next() does the following sub operation (line 390 of master)

delay = future - dt # i.e. 2016-03-13 09:00:00-04:00 - 2016-03-13 05:00:00-05:00

That operation does not return the expected delay because, according to the python docs:

Subtraction of a datetime from a datetime is defined only if both operands are naive, or if both are aware. If one is aware and the other is naive, TypeError is raised.
If both are naive, or both are aware and have the same tzinfo attribute, the tzinfo attributes are ignored, and the result is a timedelta object t such that datetime2 + t == datetime1. No time zone adjustments are done in this case.

Which makes sense, because, I think, the intent was to have datetime arithmetic operations and timedelta objects have the Group property (i.e. behave similar to regular integers). If tzinfo attributes were considered when calling sub, then datetime2 + t would not equal datetime1.
But I don't think parse-crontab should behave similarly, because the result of a next() isn't a timedelta, but the number of seconds between the datetime passed in and the schedule.
Right now, i can get around this by adding the number of seconds returned to the passed in dt argument of next(), normalize the resulting future datetime and then compute the difference, again, to get the true number of seconds till the schedule. But I think the onus of applying the tzinfo to datetime for computations should be on parse-crontab and not the client.

@josiahcarlson
Copy link
Owner

Some 95% of Crontab's purpose was to be the cron scheduling library for my other project, Rpqueue. Rpqueue doesn't do timezones, so I didn't build Crontab to do timezones either. Not to say I can't (turns out it's really easy to add), I'm just explaining why it is the way it is.

That said, I get the same result as you when I run your code, but your code is partly wrong:

>>> datetime(2016, 3, 13, 5, tzinfo=eastern)
datetime.datetime(2016, 3, 13, 5, 0, tzinfo=<DstTzInfo 'US/Eastern' LMT-1 day, 19:04:00 STD>)

Compare that to the below from the pytz docs:

>>> datetime(2016, 3, 13, 5, tzinfo=pytz.utc).astimezone(eastern)
datetime.datetime(2016, 3, 13, 0, 0, tzinfo=<DstTzInfo 'US/Eastern' EST-1 day, 19:00:00 STD>)

With the "correct" datetime, I get 9 hours instead of 4:

>>> dt = datetime(2016, 3, 13, 5, tzinfo=pytz.utc).astimezone(eastern)
>>> crontab.CronTab('0 9 13 3 *').next(dt)
32400.0

That's not terribly helpful, because the actual timing should be 8 hours due to the DST change, but it turns out that the fix is easy:

>>> def next_with_timezone(ct, now):
...     fixed = now.replace(tzinfo=None)
...     runtime = datetime.utcfromtimestamp(ct.next(fixed, delta=False))
...     delta = now.tzinfo.localize(runtime) - now
...     return delta.days*86400 + delta.seconds + delta.microseconds/1000000.
... 
>>> next_with_timezone(crontab.CronTab('0 9 13 3 *'), dt)
28800

That function will get you going for now, and I'll add a method that does the same soon-ish.

@josiahcarlson
Copy link
Owner

... the reason I say "soon-ish" is because the current library uses datetime.now() and datetime.fromtimestamp() instead of datetime.utcnow() and datetime.utcfromtimestamp(), which wasn't really my intended behavior from the beginning, but now I've got to add pending deprecation warnings to fix that stuff.

@Inphi
Copy link
Author

Inphi commented Mar 11, 2016

Yeah, my result is different from yours because I'm assuming the CronTab schedule is in localtime.

However, I don't and can't use tzinfo (for various reasons), so next_with_timezone won't work for folks that use alternative tzinfo implmentations (localize isn't defined by datetime.tzinfo). However, I do the following instead:

def next_normalized(ct, now):
  now_tz = now
  with_tz = None == now.dst()
  if not with_tz:
    now_tz = now.replace(tzinfo=None) + now.utcoffset()

  delay = ct.next(now_tz)
  future = now + datetime.timedelta(seconds=delay)
  if with_tz:
    return delay
  dst_offset = future.dst() - now.dst()
  interval = datetime.timedelta(seconds=delay) - dst_offset
  return interval.days * 86400 + interval.seconds + interval.microseconds / 1000000.

The caveat of this approach is that it is not sensitive to any future (but highly unlikely) gmtoffset changes and the future is computed twice here (in CronTab.next and in next_normalized). You might prefer next_normalized as it avoids any dependency on pytz (or maybe not, as I assume most people aren't constrained to not using pytz unlike me 😄 )

@josiahcarlson
Copy link
Owner

I should have read your comment before pushing 0.21.0 with support for pytz. :p

In any case, this should be a lot cleaner:

# Warning this may not be right!!!

def arbitrary_time_impl(ct, now):
    utc_next = ct.next(now.replace(tzinfo=None), delta=False, default_utc=True)
    # if the following line worked, that would be awesome... just some way to
    # jam a timezone onto the time and for the date/time to read the same.
    local_next = utc_next.replace(tzinfo=now.tzinfo)
    delta = local_next - now
    return delta.days * 86400 + delta.seconds + delta.microseconds / 100000.

That is using 0.21.0 semantics.

@josiahcarlson
Copy link
Owner

Actually, I suppose I need to release a 0.21.1 to remove the pytz requirement, as it is only required for testing.

@josiahcarlson
Copy link
Owner

Okay, pushed to remove the pytz requirement. Can you point me to whatever timezone package you are using?

@Inphi
Copy link
Author

Inphi commented Mar 20, 2016

I'm using an in-house timezone package that adheres to this tzinfo description.
For testing, the sample USTimeZone class in the above link should suffice.

@josiahcarlson
Copy link
Owner

Okay, 0.21.2 offers support for tz libs that only offer .utcoffset(), which is pytz and apparently your custom tz library. It does the wait with dst swap calculation properly for timezones when delta=True. When delta=False, it also does it correctly, returning unixtime for the next scheduled time (adjusted for dst + tz).

Let me know if you have issues with this one.

@Inphi
Copy link
Author

Inphi commented Mar 28, 2016

Works fine. Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants