Skip to content

Commit 8609886

Browse files
committed
Merge pull request #3242 from azjps/microsecondformatter
ENH: DateFormatter for years <= 1900
2 parents f0f2d55 + 268ca34 commit 8609886

File tree

3 files changed

+143
-35
lines changed

3 files changed

+143
-35
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
DateFormatter strftime
2+
----------------------
3+
Date formatters' (:class:`~matplotlib.dates.DateFormatter`)
4+
:meth:`~matplotlib.dates.DateFormatter.strftime` method will format
5+
a :class:`datetime.datetime` object with the format string passed to
6+
the formatter's constructor. This method accepts datetimes with years
7+
before 1900, unlike :meth:`datetime.datetime.strftime`.

lib/matplotlib/dates.py

+87-35
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ class DateFormatter(ticker.Formatter):
420420

421421
def __init__(self, fmt, tz=None):
422422
"""
423-
*fmt* is an :func:`strftime` format string; *tz* is the
423+
*fmt* is a :func:`strftime` format string; *tz* is the
424424
:class:`tzinfo` instance.
425425
"""
426426
if tz is None:
@@ -440,28 +440,54 @@ def __call__(self, x, pos=0):
440440
def set_tzinfo(self, tz):
441441
self.tz = tz
442442

443-
def _findall(self, text, substr):
444-
# Also finds overlaps
445-
sites = []
443+
def _replace_common_substr(self, s1, s2, sub1, sub2, replacement):
444+
"""Helper function for replacing substrings sub1 and sub2
445+
located at the same indexes in strings s1 and s2 respectively,
446+
with the string replacement. It is expected that sub1 and sub2
447+
have the same length. Returns the pair s1, s2 after the
448+
substitutions.
449+
"""
450+
# Find common indexes of substrings sub1 in s1 and sub2 in s2
451+
# and make substitutions inplace. Because this is inplace,
452+
# it is okay if len(replacement) != len(sub1), len(sub2).
446453
i = 0
447-
while 1:
448-
j = text.find(substr, i)
454+
while True:
455+
j = s1.find(sub1, i)
449456
if j == -1:
450457
break
451-
sites.append(j)
458+
452459
i = j + 1
453-
return sites
460+
if s2[j:j + len(sub2)] != sub2:
461+
continue
454462

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

460-
def strftime(self, dt, fmt):
461-
fmt = self.illegal_s.sub(r"\1", fmt)
462-
fmt = fmt.replace("%s", "s")
463-
if dt.year > 1900:
464-
return cbook.unicode_safe(dt.strftime(fmt))
466+
return s1, s2
467+
468+
def strftime_pre_1900(self, dt, fmt=None):
469+
"""Call time.strftime for years before 1900 by rolling
470+
forward a multiple of 28 years.
471+
472+
*fmt* is a :func:`strftime` format string.
473+
474+
Dalke: I hope I did this math right. Every 28 years the
475+
calendar repeats, except through century leap years excepting
476+
the 400 year leap years. But only if you're using the Gregorian
477+
calendar.
478+
"""
479+
if fmt is None:
480+
fmt = self.fmt
481+
482+
# Since python's time module's strftime implementation does not
483+
# support %f microsecond (but the datetime module does), use a
484+
# regular expression substitution to replace instances of %f.
485+
# Note that this can be useful since python's floating-point
486+
# precision representation for datetime causes precision to be
487+
# more accurate closer to year 0 (around the year 2000, precision
488+
# can be at 10s of microseconds).
489+
fmt = re.sub(r'((^|[^%])(%%)*)%f',
490+
r'\g<1>{0:06d}'.format(dt.microsecond), fmt)
465491

466492
year = dt.year
467493
# For every non-leap year century, advance by
@@ -470,26 +496,52 @@ def strftime(self, dt, fmt):
470496
off = 6 * (delta // 100 + delta // 400)
471497
year = year + off
472498

473-
# Move to around the year 2000
474-
year = year + ((2000 - year) // 28) * 28
499+
# Move to between the years 1973 and 2000
500+
year1 = year + ((2000 - year) // 28) * 28
501+
year2 = year1 + 28
475502
timetuple = dt.timetuple()
476-
s1 = time.strftime(fmt, (year,) + timetuple[1:])
477-
sites1 = self._findall(s1, str(year))
478-
479-
s2 = time.strftime(fmt, (year + 28,) + timetuple[1:])
480-
sites2 = self._findall(s2, str(year + 28))
481-
482-
sites = []
483-
for site in sites1:
484-
if site in sites2:
485-
sites.append(site)
486-
487-
s = s1
488-
syear = "%4d" % (dt.year,)
489-
for site in sites:
490-
s = s[:site] + syear + s[site + 4:]
503+
# Generate timestamp string for year and year+28
504+
s1 = time.strftime(fmt, (year1,) + timetuple[1:])
505+
s2 = time.strftime(fmt, (year2,) + timetuple[1:])
506+
507+
# Replace instances of respective years (both 2-digit and 4-digit)
508+
# that are located at the same indexes of s1, s2 with dt's year.
509+
# Note that C++'s strftime implementation does not use padded
510+
# zeros or padded whitespace for %y or %Y for years before 100, but
511+
# uses padded zeros for %x. (For example, try the runnable examples
512+
# with .tm_year in the interval [-1900, -1800] on
513+
# http://en.cppreference.com/w/c/chrono/strftime.) For ease of
514+
# implementation, we always use padded zeros for %y, %Y, and %x.
515+
s1, s2 = self._replace_common_substr(s1, s2,
516+
"{0:04d}".format(year1),
517+
"{0:04d}".format(year2),
518+
"{0:04d}".format(dt.year))
519+
s1, s2 = self._replace_common_substr(s1, s2,
520+
"{0:02d}".format(year1 % 100),
521+
"{0:02d}".format(year2 % 100),
522+
"{0:02d}".format(dt.year % 100))
523+
return cbook.unicode_safe(s1)
524+
525+
def strftime(self, dt, fmt=None):
526+
"""Refer to documentation for datetime.strftime.
527+
528+
*fmt* is a :func:`strftime` format string.
529+
530+
Warning: For years before 1900, depending upon the current
531+
locale it is possible that the year displayed with %x might
532+
be incorrect. For years before 100, %y and %Y will yield
533+
zero-padded strings.
534+
"""
535+
if fmt is None:
536+
fmt = self.fmt
537+
fmt = self.illegal_s.sub(r"\1", fmt)
538+
fmt = fmt.replace("%s", "s")
539+
if dt.year >= 1900:
540+
# Note: in python 3.3 this is okay for years >= 1000,
541+
# refer to http://bugs.python.org/issue177742
542+
return cbook.unicode_safe(dt.strftime(fmt))
491543

492-
return cbook.unicode_safe(s)
544+
return self.strftime_pre_1900(dt, fmt)
493545

494546

495547
class IndexDateFormatter(ticker.Formatter):

lib/matplotlib/tests/test_dates.py

+49
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,55 @@ def test_DateFormatter():
159159
fig.autofmt_xdate()
160160

161161

162+
def test_date_formatter_strftime():
163+
"""
164+
Tests that DateFormatter matches datetime.strftime,
165+
check microseconds for years before 1900 for bug #3179
166+
as well as a few related issues for years before 1900.
167+
"""
168+
def test_strftime_fields(dt):
169+
"""For datetime object dt, check DateFormatter fields"""
170+
# Note: the last couple of %%s are to check multiple %s are handled
171+
# properly; %% should get replaced by %.
172+
formatter = mdates.DateFormatter("%w %d %m %y %Y %H %I %M %S %%%f %%x")
173+
# Compute date fields without using datetime.strftime,
174+
# since datetime.strftime does not work before year 1900
175+
formatted_date_str = (
176+
"{weekday} {day:02d} {month:02d} {year:02d} {full_year:04d} "
177+
"{hour24:02d} {hour12:02d} {minute:02d} {second:02d} "
178+
"%{microsecond:06d} %x"
179+
.format(
180+
# weeknum=dt.isocalendar()[1], # %U/%W {weeknum:02d}
181+
# %w Sunday=0, weekday() Monday=0
182+
weekday=str((dt.weekday() + 1) % 7),
183+
day=dt.day,
184+
month=dt.month,
185+
year=dt.year % 100,
186+
full_year=dt.year,
187+
hour24=dt.hour,
188+
hour12=((dt.hour-1) % 12) + 1,
189+
minute=dt.minute,
190+
second=dt.second,
191+
microsecond=dt.microsecond))
192+
assert_equal(formatter.strftime(dt), formatted_date_str)
193+
194+
try:
195+
# Test strftime("%x") with the current locale.
196+
import locale # Might not exist on some platforms, such as Windows
197+
locale_formatter = mdates.DateFormatter("%x")
198+
locale_d_fmt = locale.nl_langinfo(locale.D_FMT)
199+
expanded_formatter = mdates.DateFormatter(locale_d_fmt)
200+
assert_equal(locale_formatter.strftime(dt),
201+
expanded_formatter.strftime(dt))
202+
except ImportError:
203+
pass
204+
205+
for year in range(1, 3000, 71):
206+
# Iterate through random set of years
207+
test_strftime_fields(datetime.datetime(year, 1, 1))
208+
test_strftime_fields(datetime.datetime(year, 2, 3, 4, 5, 6, 12345))
209+
210+
162211
def test_date_formatter_callable():
163212
scale = -11
164213
locator = mock.Mock(_get_unit=mock.Mock(return_value=scale))

0 commit comments

Comments
 (0)