Skip to content

FIX: properly set tz for YearLocator #12678

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

Merged
merged 2 commits into from
Jan 28, 2019

Conversation

jklymak
Copy link
Member

@jklymak jklymak commented Oct 30, 2018

PR Summary

dates.YearLocator was not always being called with the timezone info. Now it is...

Closes #12675:

import matplotlib.pyplot as plt
import pytz
from datetime import datetime, timedelta, timezone

x = [datetime(2010, 1, 1).astimezone(pytz.timezone('America/New_York')) + timedelta(i) for i in range(2000)]
plt.plot(x, [0] * len(x));
plt.show()

Now has the first tick at 2010 instead of 2009....

PR Checklist

  • Has Pytest style unit tests
  • Code is Flake 8 compliant
  • New features are documented, with examples if plot related
  • Documentation is sphinx and numpydoc compliant
  • Added an entry to doc/users/next_whats_new/ if major new feature (follow instructions in README.rst there)
  • Documented in doc/api/api_changes.rst if API changed in a backward-incompatible way

@QuLogic
Copy link
Member

QuLogic commented Oct 30, 2018

Add/modify a test?

@jklymak
Copy link
Member Author

jklymak commented Oct 30, 2018

Yeah, good point - we should have caught this in testing before.

@jklymak
Copy link
Member Author

jklymak commented Oct 30, 2018

test added...

@jklymak
Copy link
Member Author

jklymak commented Oct 30, 2018

I have a bad feeling that this solution will fail on 3.5 for some datetime objects. As of 3.6 you can do datetime.astimezone, but before 3.6 you cannot if there is no timezone info. So I'm re-milestoning this fix for 3.1. There may be a way to work around this for 3.0.x, but I'm not clear what it is.

@jklymak jklymak added this to the v3.1 milestone Oct 30, 2018
@jklymak jklymak modified the milestones: v3.1, v3.0.x Oct 31, 2018
@jklymak
Copy link
Member Author

jklymak commented Oct 31, 2018

OK, this will fail for py 3.5 if the user supplies a naive datetime (no timezone info) and they are using the YearLocator, but will work otherwise. Added a slightly less mysterious error message for that case.

@jklymak
Copy link
Member Author

jklymak commented Oct 31, 2018

test_constrainedlayout.py::test_colorbar_location seems to be a bit of a flaky test. its a bit complicated, involves a solver, and sometimes seems to fail.

timhoffm
timhoffm previously approved these changes Nov 1, 2018
try:
ticks = [vmin.astimezone(self.tz)]
except ValueError:
raise ValueError('naive datetime objects cannot be used '
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be a good place to use the raise ... from e functionality of python3 (https://www.python.org/dev/peps/pep-3134/)

try:
    ticks = [vmin.astimezone(self.tz)]
except ValueError as E:
    raise ValueError() from E

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OH, Cool, didn't know about that. I'll do soon.

}
# Note that tzinfo does not work with pytz timezones, and
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is moot if we drop py3.5?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, this is targetted at 3.0.x so have to support 3.5 still.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep... Could milestone 3.1 if you prefer.

@tacaswell
Copy link
Member

attn @pganssle for advise on how to deal with timezones cleanly....

@tacaswell
Copy link
Member

Requiring timezone aware datetimes to use the YearLocator seems like a pretty big API change? Am I misunderstanding this?

@jklymak
Copy link
Member Author

jklymak commented Nov 4, 2018

No, this just fixes the YearLocator - it wasn't getting the timezone information passed to it properly, so 1 Jan 00:00 sometimes looked like 31 Dec 23:00, and the year was formatted as the previous year. This shouldn't affect the other Locators or Formatters in the autodateLocator (I'd hope we'd fail a few tests if it did!).

for t_delta, expected in results:
d2 = d1 + t_delta
locator = _create_auto_date_locator(d1, d2)
assert list(map(str, mdates.num2date(locator()))) == expected


@pytest.mark.pytz
@pytest.mark.skipif(not __has_pytz(), reason="Requires pytz")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you using pytz for this? Tests with pytz are just to ensure matplotlib is compatible with pytz, but this is a general functionality test. It's better to use dateutil.tz, which is not conditional on pytz installation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original bug report was pytz, and pytz doesn't work if the code is not as written in the PR. i.e. I needed to test pytz to get the PR to work, so it makes sense to test it here...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it not possible to reproduce the bug with dateutil.tz? Just because the reporter used pytz doesn't mean you can't create an MWE without that dependency.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just checked, the original bug is also present when you use dateutil.tz, with the original MWE now (more accurately in this case):

import matplotlib.pyplot as plt
from dateutil import tz
from datetime import datetime, timedelta

x = [datetime(2010, 1, 1, tzinfo=tz.gettz('America/New_York')) + timedelta(i)
     for i in range(2000)]
plt.plot(x, [0] * len(x));

}
# Note that tzinfo does not work with pytz timezones, and
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pganssle : the way that tz info was put in before was to do vmin.replace(year=ymin, **self.replaced), but pytz doesn't work properly (at all) with replace. So we need a test with pytz to test that this code works.

To be honest, we roundtrip most dates (datetime-> ordinal -> datetime) so the fact that pytz doesn't do the right thing w/ tzinfo isn't that big a deal practically, but it would be nice if it worked.

I guess we could add another test that doesn't use pytz, but I think pytz should be tested on this code path.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace(tzinfo=tz) and astimezone(tz) do two very different things.

If you think of time zones as units for datetimes, replace(tzinfo=tz) is like getting a value that says "1 ft", scratching off the "ft" and adding "m" to get "1m". astimezone(tz) is like getting something that says 1ft and converting it to meters to get 0.3m.

Generally speaking, replace is used if you know that the local portion of the datetime is correct and want to say what time zone it's in. astimezone is used if you know that the absolute time represented by the datetime is correct and you want to convert it to a different local time.

In Python >=3.6, astimezone can be used on naive datetimes, but what that does is that it interprets the naive datetime as local time, and converts that to the time zone of interest. What that means is that if you are in Tokyo and give this a naive datetime like datetime(2018, 1, 1) and have YearLocator(tz=tz.gettz("America/New_York"), that's going to get converted to 2017-12-31 10:00:00-05:00, which is probably not what you wanted.

Copy link
Member

@pganssle pganssle Nov 4, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytz doesn't work properly (at all) with replace. So we need a test with pytz to test that this code works.

Also, I'll note that I'm well aware of the limitations of pytz.

I think probably it's best to use two tests, one with pytz (skipped if pytz is not present) and one with dateutil. If you intend for the interface to look the same, you can likely parametrize the test function with a conditional skip on pytz.

@pganssle
Copy link
Member

pganssle commented Nov 4, 2018

I haven't totally grokked how your code works, but it's worth noting that I solved a similar problem in the RRULE locator some time back, so RRuleLocator should be able to handle time zones correctly. The way it works in rrulewrapper is that if an RRULE has an attached time zone, that time zone is stripped off, the RRULE generates dates, then the time zone is re-attached before the datetime object is emitted.

I think you probably want to do something similar for YearlyLocator.

@jklymak jklymak force-pushed the fix-yearlocator-tz branch from 34cd842 to a6a96e3 Compare November 4, 2018 18:07
Copy link
Member

@timhoffm timhoffm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would create a cbook.localize_tz() helper to make this more readable. Something like (untested):

def localize_tz(date, tz):
    if hasattr(tz, 'localize'):
        return tz.localize(date, is_dst=True)
    else:
        return date.replace(tzinfo=tz)

Or alteratively, a cbook.datetime_replace() that can handle the tz.

@jklymak jklymak force-pushed the fix-yearlocator-tz branch 2 times, most recently from 49b5d55 to 478ea3d Compare November 18, 2018 18:13
@jklymak
Copy link
Member Author

jklymak commented Nov 18, 2018

I didn't make a cbook function because I can't see this being applicable outside of dates.py. But otherwise, thanks for the suggestion

@timhoffm
Copy link
Member

The solution with a private method is equally fine.

Copy link
Member

@timhoffm timhoffm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the code, you always do a replace followed by _localize_tz. In that context a _date_replace migh even be better. Less clutter in the calling code and for non-pytz even a bit faster since you don't replace twice.

def _date_replace(date, tzinfo=None, **kwargs):
    """
    Drop-in replacement for datetime.replace() with support for pytz timezones.
    """
    if hasattr(tzinfo, 'localize'):
        date = date.replace(tzinfo=None, **kwargs)
        return tzinfo.localize(date, is_dst=True)
    return date.replace(tzinfo=tzinfo, **kwargs)

But I won't hold up if you want to stay with _localize_tz.

@@ -1409,13 +1419,22 @@ def tick_values(self, vmin, vmax):
ymin = self.base.le(vmin.year) * self.base.step
ymax = self.base.ge(vmax.year) * self.base.step

ticks = [vmin.replace(year=ymin, **self.replaced)]
vmin = vmin.replace(year=ymin, **self.replaced)
print('vmin before', vmin)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug print?

Suggested change
print('vmin before', vmin)

vmin = vmin.replace(year=ymin, **self.replaced)
print('vmin before', vmin)
vmin = _localize_tz(vmin, self.tz)
print('vmin after', vmin)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug print?

Suggested change
print('vmin after', vmin)

@jklymak
Copy link
Member Author

jklymak commented Nov 18, 2018

Fair enough, that's an easy refactor....

@@ -1426,7 +1441,9 @@ def autoscale(self):
ymin = self.base.le(dmin.year)
ymax = self.base.ge(dmax.year)
vmin = dmin.replace(year=ymin, **self.replaced)
vmin = vmin.astimezone(self.tz)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this acutally different from he above cases?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ummm, hmmm, not sure this was properly tested....

@jklymak jklymak force-pushed the fix-yearlocator-tz branch 2 times, most recently from 0478bc1 to b244b3d Compare November 19, 2018 03:17
Copy link
Member

@pganssle pganssle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we worked everything out on this one, yeah? I think this can be merged.

@jklymak jklymak force-pushed the fix-yearlocator-tz branch from b244b3d to 5ec912f Compare January 14, 2019 21:27
@jklymak
Copy link
Member Author

jklymak commented Jan 14, 2019

Rebased. Note two reviews...

@timhoffm
Copy link
Member

The other reviewer does not have write access. Do we count this as a full review?

@jklymak
Copy link
Member Author

jklymak commented Jan 20, 2019

No not really. Just hoping that someone else would take a quick look on the strength of the existing reviews and merge.

FIX: add check for py3.5 to fail if naive datetime

TST: make pytz yearlylocator test
@jklymak jklymak force-pushed the fix-yearlocator-tz branch from 5ec912f to d8fcd82 Compare January 21, 2019 18:43
@tacaswell tacaswell merged commit 00d71ce into matplotlib:master Jan 28, 2019
@lumberbot-app
Copy link

lumberbot-app bot commented Jan 28, 2019

Owee, I'm MrMeeseeks, Look at me.

There seem to be a conflict, please backport manually. Here are approximate instructions:

  1. Checkout backport branch and update it.
$ git checkout v3.0.x
$ git pull
  1. Cherry pick the first parent branch of the this PR on top of the older branch:
$ git cherry-pick -m1 00d71cebb3f3e1eed5774b8942ebc9607961a61e
  1. You will likely have some merge/cherry-pick conflict here, fix them and commit:
$ git commit -am 'Backport PR #12678: FIX: properly set tz for YearLocator'
  1. Push to a named branch :
git push YOURFORK v3.0.x:auto-backport-of-pr-12678-on-v3.0.x
  1. Create a PR against branch v3.0.x, I would have named this PR:

"Backport PR #12678 on branch v3.0.x"

And apply the correct labels and milestones.

Congratulation you did some good work ! Hopefully your backport PR will be tested by the continuous integration and merged soon!

If these instruction are inaccurate, feel free to suggest an improvement.

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

Successfully merging this pull request may close these issues.

Off-by-one bug in annual axis labels when localized time crosses year boundary
5 participants