Skip to content

DateFormatter shows microseconds instead of %f for years <= 1900 #3242

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 1 commit into from
Jun 18, 2015
Merged
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
7 changes: 7 additions & 0 deletions doc/users/whats_new/updated_date_formatter.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
DateFormatter strftime
----------------------
Date formatters' (:class:`~matplotlib.dates.DateFormatter`)
:meth:`~matplotlib.dates.DateFormatter.strftime` method will format
a :class:`datetime.datetime` object with the format string passed to
the formatter's constructor. This method accepts datetimes with years
before 1900, unlike :meth:`datetime.datetime.strftime`.
122 changes: 87 additions & 35 deletions lib/matplotlib/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ class DateFormatter(ticker.Formatter):

def __init__(self, fmt, tz=None):
"""
*fmt* is an :func:`strftime` format string; *tz* is the
*fmt* is a :func:`strftime` format string; *tz* is the
:class:`tzinfo` instance.
"""
if tz is None:
Expand All @@ -414,28 +414,54 @@ def __call__(self, x, pos=0):
def set_tzinfo(self, tz):
self.tz = tz

def _findall(self, text, substr):
# Also finds overlaps
sites = []
def _replace_common_substr(self, s1, s2, sub1, sub2, replacement):
"""Helper function for replacing substrings sub1 and sub2
located at the same indexes in strings s1 and s2 respectively,
with the string replacement. It is expected that sub1 and sub2
have the same length. Returns the pair s1, s2 after the
substitutions.
"""
# Find common indexes of substrings sub1 in s1 and sub2 in s2
# and make substitutions inplace. Because this is inplace,
# it is okay if len(replacement) != len(sub1), len(sub2).
i = 0
while 1:
j = text.find(substr, i)
while True:
j = s1.find(sub1, i)
if j == -1:
break
sites.append(j)

i = j + 1
return sites
if s2[j:j + len(sub2)] != sub2:
continue

# Dalke: I hope I did this math right. Every 28 years the
# calendar repeats, except through century leap years excepting
# the 400 year leap years. But only if you're using the Gregorian
# calendar.
s1 = s1[:j] + replacement + s1[j + len(sub1):]
s2 = s2[:j] + replacement + s2[j + len(sub2):]

def strftime(self, dt, fmt):
fmt = self.illegal_s.sub(r"\1", fmt)
fmt = fmt.replace("%s", "s")
if dt.year > 1900:
return cbook.unicode_safe(dt.strftime(fmt))
return s1, s2

def strftime_pre_1900(self, dt, fmt=None):
"""Call time.strftime for years before 1900 by rolling
forward a multiple of 28 years.

*fmt* is a :func:`strftime` format string.

Dalke: I hope I did this math right. Every 28 years the
calendar repeats, except through century leap years excepting
the 400 year leap years. But only if you're using the Gregorian
calendar.
"""
if fmt is None:
fmt = self.fmt

# Since python's time module's strftime implementation does not
# support %f microsecond (but the datetime module does), use a
# regular expression substitution to replace instances of %f.
# Note that this can be useful since python's floating-point
# precision representation for datetime causes precision to be
# more accurate closer to year 0 (around the year 2000, precision
# can be at 10s of microseconds).
fmt = re.sub(r'((^|[^%])(%%)*)%f',
r'\g<1>{0:06d}'.format(dt.microsecond), fmt)

year = dt.year
# For every non-leap year century, advance by
Expand All @@ -444,26 +470,52 @@ def strftime(self, dt, fmt):
off = 6 * (delta // 100 + delta // 400)
year = year + off

# Move to around the year 2000
year = year + ((2000 - year) // 28) * 28
# Move to between the years 1973 and 2000
year1 = year + ((2000 - year) // 28) * 28
year2 = year1 + 28
timetuple = dt.timetuple()
s1 = time.strftime(fmt, (year,) + timetuple[1:])
sites1 = self._findall(s1, str(year))

s2 = time.strftime(fmt, (year + 28,) + timetuple[1:])
sites2 = self._findall(s2, str(year + 28))

sites = []
for site in sites1:
if site in sites2:
sites.append(site)

s = s1
syear = "%4d" % (dt.year,)
for site in sites:
s = s[:site] + syear + s[site + 4:]
# Generate timestamp string for year and year+28
s1 = time.strftime(fmt, (year1,) + timetuple[1:])
s2 = time.strftime(fmt, (year2,) + timetuple[1:])

# Replace instances of respective years (both 2-digit and 4-digit)
# that are located at the same indexes of s1, s2 with dt's year.
# Note that C++'s strftime implementation does not use padded
# zeros or padded whitespace for %y or %Y for years before 100, but
# uses padded zeros for %x. (For example, try the runnable examples
# with .tm_year in the interval [-1900, -1800] on
# http://en.cppreference.com/w/c/chrono/strftime.) For ease of
# implementation, we always use padded zeros for %y, %Y, and %x.
s1, s2 = self._replace_common_substr(s1, s2,
"{0:04d}".format(year1),
"{0:04d}".format(year2),
"{0:04d}".format(dt.year))
s1, s2 = self._replace_common_substr(s1, s2,
"{0:02d}".format(year1 % 100),
"{0:02d}".format(year2 % 100),
"{0:02d}".format(dt.year % 100))
return cbook.unicode_safe(s1)

def strftime(self, dt, fmt=None):
"""Refer to documentation for datetime.strftime.

*fmt* is a :func:`strftime` format string.

Warning: For years before 1900, depending upon the current
locale it is possible that the year displayed with %x might
be incorrect. For years before 100, %y and %Y will yield
zero-padded strings.
"""
if fmt is None:
fmt = self.fmt
fmt = self.illegal_s.sub(r"\1", fmt)
fmt = fmt.replace("%s", "s")
if dt.year >= 1900:
# Note: in python 3.3 this is okay for years >= 1000,
# refer to http://bugs.python.org/issue177742
return cbook.unicode_safe(dt.strftime(fmt))

return cbook.unicode_safe(s)
return self.strftime_pre_1900(dt, fmt)


class IndexDateFormatter(ticker.Formatter):
Expand Down
49 changes: 49 additions & 0 deletions lib/matplotlib/tests/test_dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,55 @@ def test_DateFormatter():
fig.autofmt_xdate()


def test_date_formatter_strftime():
"""
Tests that DateFormatter matches datetime.strftime,
check microseconds for years before 1900 for bug #3179
as well as a few related issues for years before 1900.
"""
def test_strftime_fields(dt):
"""For datetime object dt, check DateFormatter fields"""
# Note: the last couple of %%s are to check multiple %s are handled
# properly; %% should get replaced by %.
formatter = mdates.DateFormatter("%w %d %m %y %Y %H %I %M %S %%%f %%x")
# Compute date fields without using datetime.strftime,
# since datetime.strftime does not work before year 1900
formatted_date_str = (
"{weekday} {day:02d} {month:02d} {year:02d} {full_year:04d} "
"{hour24:02d} {hour12:02d} {minute:02d} {second:02d} "
"%{microsecond:06d} %x"
.format(
# weeknum=dt.isocalendar()[1], # %U/%W {weeknum:02d}
# %w Sunday=0, weekday() Monday=0
weekday=str((dt.weekday() + 1) % 7),
day=dt.day,
month=dt.month,
year=dt.year % 100,
full_year=dt.year,
hour24=dt.hour,
hour12=((dt.hour-1) % 12) + 1,
minute=dt.minute,
second=dt.second,
microsecond=dt.microsecond))
assert_equal(formatter.strftime(dt), formatted_date_str)

try:
# Test strftime("%x") with the current locale.
import locale # Might not exist on some platforms, such as Windows
locale_formatter = mdates.DateFormatter("%x")
locale_d_fmt = locale.nl_langinfo(locale.D_FMT)
expanded_formatter = mdates.DateFormatter(locale_d_fmt)
assert_equal(locale_formatter.strftime(dt),
expanded_formatter.strftime(dt))
except ImportError:
pass

for year in range(1, 3000, 71):
# Iterate through random set of years
test_strftime_fields(datetime.datetime(year, 1, 1))
test_strftime_fields(datetime.datetime(year, 2, 3, 4, 5, 6, 12345))


def test_date_formatter_callable():
scale = -11
locator = mock.Mock(_get_unit=mock.Mock(return_value=scale))
Expand Down