Skip to content

Commit 251db3c

Browse files
committed
gh-158252: Clarify strptime helpers and extend parser tests
Separate locale name normalization from matching and AM/PM support checks. Share the month table and leap-year calculation, and document the cases where parsed metadata deliberately differs from the calendar date. Check that representative formats use the C parser through all four public entry points. Add generated comparisons with the Python parser, retaining explicit examples for runs without Hypothesis.
1 parent 045177d commit 251db3c

6 files changed

Lines changed: 201 additions & 85 deletions

File tree

‎Include/internal/pycore_time.h‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,9 +348,11 @@ typedef struct {
348348
int year, month, day;
349349
int hour, minute, second, fraction;
350350
int gmtoff; // INT_MIN means no offset was supplied.
351-
int gmtoff_fraction;
351+
int gmtoff_fraction; // Microseconds, with the same sign as gmtoff.
352352
Py_ssize_t zone_start, zone_length; // Original spelling of a %Z name.
353-
int yday; // January 1 is day 1.
353+
// January 1 is day 1. A %j spill retains the original day of year,
354+
// which need not agree with the resulting calendar date.
355+
int yday;
354356
// Monday is 0; a supplied weekday need not agree with the date.
355357
// If negative, the caller must compute it from year and yday if needed.
356358
int weekday;

‎Lib/test/test_strptime.py‎

Lines changed: 108 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""PyUnit testing against strptime"""
22

33
import unittest
4+
from unittest import mock
5+
from contextlib import nullcontext
46
import datetime
57
import time
68
import locale
@@ -9,13 +11,118 @@
911
import platform
1012
import sys
1113
from test import support
12-
from test.support import skip_if_buggy_ucrt_strfptime, run_with_locales
14+
from test.support import (
15+
skip_if_buggy_ucrt_strfptime, run_with_locales, warnings_helper,
16+
)
17+
from test.support.hypothesis_helper import hypothesis
1318
from datetime import date as datetime_date
1419

1520
import _strptime
1621

1722

23+
_strptime_formats = hypothesis.strategies.lists(
24+
hypothesis.strategies.sampled_from((
25+
'%Y', '%y', '%m', '%d', '%H', '%M', '%S', '%f', '%b', '%B',
26+
'%a', '%A', '%w', '%u', '%I', '%p', '%k', '%l', '%P',
27+
'%z', '%:z', '%Z', '%F', '%D', '%T', '%R', '%e', '%n', '%t',
28+
'%j', '%U', '%W', '%G', '%V', '%%',
29+
'-', '/', ':', '.', ' ', '\t', '\n', '\x1c', 'T', 'Z', '年',
30+
)), max_size=8,
31+
).map(''.join)
32+
33+
1834
class PublicStrptimeTests(unittest.TestCase):
35+
def assert_matches_python(self, text, fmt, *, accelerated=False):
36+
methods = (
37+
(time.strptime, '_strptime_time', None),
38+
(datetime.date.strptime, '_strptime_datetime_date', datetime.date),
39+
(datetime.time.strptime, '_strptime_datetime_time', datetime.time),
40+
(datetime.datetime.strptime, '_strptime_datetime_datetime',
41+
datetime.datetime),
42+
)
43+
for parse, fallback, cls in methods:
44+
message = f'{parse.__qualname__}({text!r}, {fmt!r})'
45+
reference = getattr(_strptime, fallback)
46+
args = (text, fmt) if cls is None else (cls, text, fmt)
47+
try:
48+
expected = reference(*args)
49+
except (ValueError, re.PatternError, OverflowError) as error:
50+
self.assertFalse(accelerated, message)
51+
with self.assertRaises(type(error), msg=message) as caught:
52+
parse(text, fmt)
53+
self.assertEqual(str(caught.exception), str(error), message)
54+
else:
55+
# Guard against accidentally falling back for every input.
56+
# This mock can be removed if implementation changes make
57+
# patching the fallback impractical; keep the comparisons.
58+
guard = mock.patch.object(
59+
_strptime, fallback, autospec=True,
60+
side_effect=AssertionError('used the Python parser'),
61+
) if accelerated else nullcontext()
62+
with guard:
63+
actual = parse(text, fmt)
64+
self.assertEqual(actual, expected, message)
65+
if isinstance(actual, time.struct_time):
66+
self.assertEqual(actual.tm_zone, expected.tm_zone, message)
67+
self.assertEqual(actual.tm_gmtoff, expected.tm_gmtoff,
68+
message)
69+
elif isinstance(actual, (datetime.time, datetime.datetime)):
70+
self.assertEqual(actual.tzname(), expected.tzname(), message)
71+
72+
@support.cpython_only
73+
@support.run_with_locale('LC_TIME', 'C')
74+
def test_accelerated_formats(self):
75+
for text, fmt in (
76+
('2024-02-29 12:34:56.123456', '%Y-%m-%d %H:%M:%S.%f'),
77+
('Jan 02 2024', '%b %d %Y'),
78+
('Tuesday 2024 January 02', '%A %Y %B %d'),
79+
('Tue 2024-01-02', '%a %Y-%m-%d'),
80+
('12 PM', '%I %p'), ('12', '%I'), (' 1 pm', '%l %P'),
81+
(' 1', '%k'), ('2024 2', '%Y %e'),
82+
('+01:02:03.456789', '%:z'), ('-000001.5', '%z'),
83+
('UTC', '%Z'), ('+0530 gMt', '%z %Z'),
84+
('2024-02-29 12:34:56', '%F %T'),
85+
('02/29/24 12:34', '%D %R'),
86+
('2024\t02\n29', '%Y%t%m%n%d'),
87+
('2023 366', '%Y %j'),
88+
('2024 00 0', '%Y %W %w'),
89+
('2024 01 1', '%Y %U %u'),
90+
('2020-W53-7', '%G-W%V-%u'),
91+
):
92+
self.assert_matches_python(text, fmt, accelerated=True)
93+
94+
# Missing-year formats deliberately exercise the deprecated behavior.
95+
@support.run_with_locale('LC_TIME', 'C')
96+
@warnings_helper.ignore_warnings(category=DeprecationWarning)
97+
@hypothesis.given(value=hypothesis.strategies.datetimes(),
98+
fmt=_strptime_formats)
99+
@hypothesis.example(value=datetime.datetime(2024, 2, 29, 13, 24, 35),
100+
fmt='%Y%m%d%H%M%S')
101+
@hypothesis.example(value=datetime.datetime(2021, 1, 1), fmt='%G-W%V-%u')
102+
@hypothesis.example(value=datetime.datetime(2024, 2, 29, 13, 24, 35),
103+
fmt='%a %d %B %Y %I:%M:%S %p')
104+
def test_strftime_output_matches_python(self, value, fmt):
105+
try:
106+
text = value.strftime(fmt)
107+
except ValueError:
108+
# Some strptime directives are unsupported by the platform's
109+
# strftime. Arbitrary input tests still exercise their parsing.
110+
hypothesis.reject()
111+
self.assert_matches_python(text, fmt)
112+
113+
@support.run_with_locale('LC_TIME', 'C')
114+
@warnings_helper.ignore_warnings(category=DeprecationWarning)
115+
@hypothesis.given(text=hypothesis.strategies.text(max_size=80),
116+
fmt=_strptime_formats)
117+
@hypothesis.example(text='2024\x1c 112', fmt='%Y\x1c%d%m')
118+
@hypothesis.example(text='34\x1c 123', fmt='%M%n%H%S')
119+
@hypothesis.example(text='2023 366', fmt='%Y %j')
120+
@hypothesis.example(text='2024 01 02 1', fmt='%Y %U %W %u')
121+
@hypothesis.example(text='+01:0203', fmt='%z')
122+
@hypothesis.example(text='1 pM', fmt='%I %p')
123+
def test_arbitrary_input_matches_python(self, text, fmt):
124+
self.assert_matches_python(text, fmt)
125+
19126
def test_inferred_weekday(self):
20127
for text, fmt in (
21128
('0001-01-01', '%Y-%m-%d'),
@@ -134,34 +241,6 @@ def test_locale_names(self):
134241
for fmt in ('%b %d %Y', '%B %d %Y', '%a %Y-%m-%d', '%A %Y-%m-%d'):
135242
self.assert_matches_python(value.strftime(fmt), fmt)
136243

137-
def assert_matches_python(self, text, fmt):
138-
methods = (
139-
(time.strptime, _strptime._strptime_time),
140-
(datetime.date.strptime,
141-
lambda s, f: _strptime._strptime_datetime_date(datetime.date, s, f)),
142-
(datetime.time.strptime,
143-
lambda s, f: _strptime._strptime_datetime_time(datetime.time, s, f)),
144-
(datetime.datetime.strptime,
145-
lambda s, f: _strptime._strptime_datetime_datetime(
146-
datetime.datetime, s, f)),
147-
)
148-
for parse, reference in methods:
149-
with self.subTest(method=parse, text=text, format=fmt):
150-
try:
151-
expected = reference(text, fmt)
152-
except (ValueError, re.PatternError) as error:
153-
with self.assertRaises(type(error)) as caught:
154-
parse(text, fmt)
155-
self.assertEqual(str(caught.exception), str(error))
156-
else:
157-
actual = parse(text, fmt)
158-
self.assertEqual(actual, expected)
159-
if isinstance(actual, time.struct_time):
160-
self.assertEqual(actual.tm_zone, expected.tm_zone)
161-
self.assertEqual(actual.tm_gmtoff, expected.tm_gmtoff)
162-
elif isinstance(actual, (datetime.time, datetime.datetime)):
163-
self.assertEqual(actual.tzname(), expected.tzname())
164-
165244
@support.run_with_locale('LC_TIME', 'C')
166245
def test_names_and_weekdays(self):
167246
for month in range(1, 13):
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Speed up parsing month and weekday names, 12-hour clocks, UTC offsets,
1+
Speed up parsing ASCII month and weekday names, 12-hour clocks, UTC offsets,
22
numeric format aliases, and ordinal and week dates with :func:`time.strptime`,
33
:meth:`datetime.date.strptime`, :meth:`datetime.time.strptime`, and
44
:meth:`datetime.datetime.strptime`.

‎Modules/_datetimemodule.c‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,6 +1688,9 @@ static PyObject *
16881688
tzinfo_from_strptime_results(const _PyTime_StrptimeFields *fields,
16891689
PyObject *string)
16901690
{
1691+
/* As in _strptime.py, a name without a numeric offset leaves the result
1692+
* naive. Only pass the name to the timezone constructor when %z is present.
1693+
*/
16911694
if (!fields->zone_length || fields->gmtoff == INT_MIN) {
16921695
return tzinfo_from_isoformat_results(fields->gmtoff != INT_MIN,
16931696
fields->gmtoff,

‎Modules/timemodule.c‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,9 @@ time_strptime(PyObject *self, PyObject *args)
10201020
if (weekday < 0) {
10211021
weekday = _PyTime_Weekday(fields.year, fields.yday);
10221022
}
1023+
/* Only UTC and GMT names are parsed here; neither observes DST.
1024+
* Numeric offsets don't supply DST information.
1025+
*/
10231026
const int values[] = {
10241027
fields.year, fields.month, fields.day,
10251028
fields.hour, fields.minute, fields.second,

0 commit comments

Comments
 (0)