Skip to content

Commit 9140d68

Browse files
committed
Add property tests for strftime/strptime
1 parent 950fab4 commit 9140d68

File tree

1 file changed

+290
-0
lines changed

1 file changed

+290
-0
lines changed

Lib/test/test_datetime_property.py

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import datetime
2+
import unittest
3+
from test.support.hypothesis_helper import hypothesis
4+
5+
st = hypothesis.strategies
6+
7+
8+
class FormatSet:
9+
def __init__(self, children, must_use=False):
10+
if children is None:
11+
self.children = ()
12+
else:
13+
if must_use:
14+
self.children = tuple(children)
15+
else:
16+
self.children = (*children, NoComponent)
17+
18+
def __repr__(self):
19+
import textwrap
20+
21+
children = textwrap.indent(str(self.children), prefix=" ")
22+
return f"{self.__class__.__name__}(\n{children}\n)"
23+
24+
def replace_children(self, children):
25+
return type(self)(children, must_use=True)
26+
27+
28+
class OneFormatFrom(FormatSet):
29+
pass
30+
31+
32+
class EachFormatFrom(FormatSet):
33+
pass
34+
35+
36+
class NoComponentClass:
37+
def __repr__(self):
38+
return "NoComponent()"
39+
40+
41+
NoComponent = NoComponentClass()
42+
43+
# Composite dates
44+
DATE_FORMAT_CODES = OneFormatFrom(
45+
(
46+
EachFormatFrom(
47+
(
48+
EachFormatFrom(
49+
(
50+
OneFormatFrom(("%Y", "%y")),
51+
OneFormatFrom(
52+
(
53+
# Month and Day
54+
EachFormatFrom(
55+
(
56+
OneFormatFrom(("%m", "%B", "%b")),
57+
OneFormatFrom(("%d",)),
58+
)
59+
),
60+
# Julian day of year
61+
OneFormatFrom(("%j",)),
62+
# Week number and day of week (Sunday = 0)
63+
EachFormatFrom(
64+
(
65+
OneFormatFrom(("%W",)),
66+
OneFormatFrom(("%w",)),
67+
),
68+
),
69+
# Week number and day of week (Monday = 0)
70+
EachFormatFrom(
71+
(
72+
OneFormatFrom(("%U",)),
73+
OneFormatFrom(("%u",)),
74+
),
75+
),
76+
),
77+
),
78+
),
79+
),
80+
OneFormatFrom(("%A", "%a")),
81+
),
82+
),
83+
# Full spec
84+
OneFormatFrom(("%x",)),
85+
# ISO 8601
86+
EachFormatFrom(
87+
(
88+
OneFormatFrom(("%G",), must_use=True),
89+
OneFormatFrom(("%V",), must_use=True),
90+
OneFormatFrom(("%u", "%a", "%A"), must_use=True),
91+
)
92+
),
93+
)
94+
)
95+
96+
97+
TIME_FORMAT_CODES = EachFormatFrom(
98+
(
99+
OneFormatFrom(
100+
(
101+
EachFormatFrom(
102+
(
103+
OneFormatFrom(("%H", "%I")),
104+
OneFormatFrom(("%p",)),
105+
OneFormatFrom(("%M",)),
106+
OneFormatFrom(("%S",)),
107+
OneFormatFrom(("%f",)),
108+
)
109+
),
110+
OneFormatFrom(("%X",), must_use=True),
111+
)
112+
),
113+
EachFormatFrom(
114+
(
115+
OneFormatFrom(("%z", "%:z")),
116+
OneFormatFrom(("%Z",)),
117+
),
118+
),
119+
)
120+
)
121+
122+
DATETIME_FORMAT_CODES = OneFormatFrom(
123+
(
124+
EachFormatFrom(
125+
(
126+
DATE_FORMAT_CODES,
127+
TIME_FORMAT_CODES,
128+
),
129+
),
130+
OneFormatFrom(("%c",)),
131+
),
132+
)
133+
134+
135+
class DatetimeFormat:
136+
def __init__(self, format_codes, format_str):
137+
self.codes = format_codes
138+
self.fmt = format_str
139+
140+
def __repr__(self):
141+
return f"{self.__class__.__name__}(format_codes={self.codes}, format_str={self.fmt})"
142+
143+
def __bool__(self):
144+
return bool(self.fmt)
145+
146+
147+
def _make_strftime_strategy(format_codes, exclude=frozenset()):
148+
"""Generates a strategy that generates valid strftime strings."""
149+
150+
def _exclude_codes(code_set):
151+
new_children = []
152+
for child in code_set.children:
153+
if child is NoComponent:
154+
new_children.append(child)
155+
elif isinstance(child, str):
156+
if child in exclude:
157+
continue
158+
new_children.append(child)
159+
else:
160+
new_sub_node = _exclude_codes(child)
161+
if new_sub_node is None:
162+
continue
163+
new_children.append(_exclude_codes(child))
164+
165+
if (
166+
not new_children
167+
or len(new_children) == 1
168+
and new_children[0] is NoComponent
169+
):
170+
return None
171+
172+
return code_set.replace_children(children=new_children)
173+
174+
def select_formats(draw, code_set):
175+
stack = [code_set]
176+
output = []
177+
while stack:
178+
node = stack.pop()
179+
if node is NoComponent:
180+
continue
181+
182+
if isinstance(node, str):
183+
output.append(node)
184+
elif isinstance(node, EachFormatFrom):
185+
for child in node.children:
186+
stack.append(child)
187+
elif isinstance(node, OneFormatFrom):
188+
stack.append(draw(st.sampled_from(node.children)))
189+
else:
190+
raise TypeError(f"Unknown node type: {type(node)}")
191+
192+
return output
193+
194+
format_codes = _exclude_codes(format_codes)
195+
196+
@st.composite
197+
def _strftime_strategy(draw):
198+
# Randomly select one format code from each date component category
199+
selected_formats = select_formats(draw, format_codes)
200+
201+
# Choose a random order
202+
selected_formats = draw(st.permutations(selected_formats))
203+
204+
# Add interstitial components
205+
components = []
206+
207+
def _make_interstitial():
208+
if draw(st.booleans()):
209+
interstitial_text = draw(st.text()).replace("%", "%%")
210+
else:
211+
interstitial_text = ""
212+
return interstitial_text
213+
214+
for component in selected_formats:
215+
components.append(_make_interstitial())
216+
components.append(component)
217+
components.append(_make_interstitial())
218+
219+
format_str = "".join(components)
220+
return DatetimeFormat(
221+
format_codes=frozenset(selected_formats), format_str=format_str
222+
)
223+
224+
return _strftime_strategy
225+
226+
227+
def datetime_strftimes(*args, **kwargs):
228+
return _make_strftime_strategy(DATETIME_FORMAT_CODES, *args, **kwargs)()
229+
230+
231+
all_timezones = st.one_of(
232+
st.timezones(),
233+
st.none(),
234+
st.tuples(
235+
st.timedeltas(
236+
min_value=-datetime.timedelta(hours=24),
237+
max_value=datetime.timedelta(hours=24),
238+
),
239+
st.one_of(st.none(), st.text()),
240+
).map(lambda x: datetime.timezone(*x)),
241+
)
242+
243+
244+
class DateTimeTest(unittest.TestCase):
245+
theclass = datetime.datetime
246+
247+
@hypothesis.given(
248+
dt=st.datetimes(timezones=st.timezones()),
249+
fmt=datetime_strftimes(exclude={"%:z"}).filter(lambda x: x),
250+
)
251+
def test_strftime_strptime_property(self, dt, fmt):
252+
fmt_code = fmt.fmt
253+
# gh-124531: \x00 terminates format strings
254+
hypothesis.assume("\x00" not in fmt_code)
255+
256+
# This first step can be lossy so without more extensive logic, we
257+
# cannot directly make assertions about what this does.
258+
dt_str = dt.strftime(fmt_code)
259+
260+
# gh-124529: %c does not work for years < 1000
261+
hypothesis.assume(not ("%c" in fmt.codes and dt.year < 1000))
262+
263+
# gh-66571: These can only be parsed back in specific situations
264+
hypothesis.assume(not "%Z" in fmt.codes)
265+
266+
# From here on out strptime/strftime rounds should be idempotent
267+
dt_rt = self.theclass.strptime(dt_str, fmt_code)
268+
269+
dt_rt_str = dt_rt.strftime(fmt_code)
270+
if (
271+
(
272+
not ({"%a", "%A", "%w", "%u"} & fmt.codes)
273+
or (
274+
("%Y" in fmt.codes or "%y" in fmt.codes)
275+
and ("%j" in fmt.codes or ("%m" in fmt.codes and "%b" in fmt.codes))
276+
)
277+
or ("%G" in fmt.codes)
278+
)
279+
and not ("%p" in fmt.codes and not ({"%H", "%I"} & fmt.codes))
280+
and not any(
281+
x in fmt_code
282+
for x in ("%z%j", "%z%H", "%z%I", "%z%f", "%z%d", "%z%m", "%z%S")
283+
)
284+
):
285+
self.assertEqual(dt_rt_str, dt_str)
286+
287+
# Normally we would need to worry about whether or not one of these
288+
# is ambiguous, but strptime can only generate code with fixed offsets.
289+
dt_rt_2 = self.theclass.strptime(dt_rt_str, fmt_code)
290+
self.assertEqual(dt_rt_2, dt_rt)

0 commit comments

Comments
 (0)