Skip to content
Draft
Changes from 1 commit
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
Prev Previous commit
Next Next commit
fixup! Add property tests for strftime/strptime
Correct the round-trip preconditions and preserve the minimized failures
as explicit examples. Generate delimited fields, account for ignored week
numbers and two-digit years, and keep locale composites in the C locale.
Check small years and ambiguous fields against independent expected values.
  • Loading branch information
pganssle committed Sep 26, 2026
commit ad213ec6ddc3fc3dd996bf382aa3d6eb2d9456b2
112 changes: 78 additions & 34 deletions Lib/test/test_datetime_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def __repr__(self):
(
EachFormatFrom(
(
OneFormatFrom(("%Y", "%y")),
OneFormatFrom(("%Y", "%y"), must_use=True),
OneFormatFrom(
(
# Month and Day
Expand All @@ -62,14 +62,14 @@ def __repr__(self):
),
# Julian day of year
OneFormatFrom(("%j",)),
# Week number and day of week (Sunday = 0)
# Monday-based week number and weekday
EachFormatFrom(
(
OneFormatFrom(("%W",)),
OneFormatFrom(("%w",)),
),
),
# Week number and day of week (Monday = 0)
# Sunday-based week number and weekday
EachFormatFrom(
(
OneFormatFrom(("%U",)),
Expand Down Expand Up @@ -141,7 +141,7 @@ def __init__(self, format_codes, format_str):
self.fmt = format_str

def __repr__(self):
return f"{self.__class__.__name__}(format_codes={self.codes}, format_str={self.fmt})"
return f"{self.__class__.__name__}(format_codes={self.codes!r}, format_str={self.fmt!r})"

def __bool__(self):
return bool(self.fmt)
Expand All @@ -163,7 +163,7 @@ def _exclude_codes(code_set):
new_sub_node = _exclude_codes(child)
if new_sub_node is None:
continue
new_children.append(_exclude_codes(child))
new_children.append(new_sub_node)

if (
not new_children
Expand Down Expand Up @@ -209,7 +209,11 @@ def _strftime_strategy(draw):

def _make_interstitial():
if draw(st.booleans()):
interstitial_text = draw(st.text()).replace("%", "%%")
# gh-124531: Some strftime implementations truncate at NUL.
interstitial_text = draw(st.text(
alphabet=st.characters(exclude_characters="\x00"),
max_size=10,
)).replace("%", "%%")
else:
interstitial_text = ""
return interstitial_text
Expand All @@ -219,7 +223,9 @@ def _make_interstitial():
components.append(component)
components.append(_make_interstitial())

format_str = "".join(components)
# Variable-width numeric directives can consume digits belonging to
# adjacent fields. Round trips require unambiguous field boundaries.
format_str = "|".join(components)
return DatetimeFormat(
format_codes=frozenset(selected_formats), format_str=format_str
)
Expand All @@ -231,46 +237,77 @@ def datetime_strftimes(*args, **kwargs):
return _make_strftime_strategy(DATETIME_FORMAT_CODES, *args, **kwargs)()


all_timezones = st.one_of(
st.timezones(),
st.none(),
st.tuples(
st.timedeltas(
min_value=-datetime.timedelta(hours=24),
max_value=datetime.timedelta(hours=24),
),
st.one_of(st.none(), st.text()),
).map(lambda x: datetime.timezone(*x)),
)
def strptime_inputs():
# isoformat pads small years consistently across platforms, so these also
# cover the years that the platform strftime can't always round-trip.
return st.datetimes().map(lambda dt: (
dt.isoformat(" "),
"%Y-%m-%d %H:%M:%S" + (".%f" if dt.microsecond else ""),
dt,
))


class DateTimeTest(unittest.TestCase):
theclass = datetime.datetime

@support.run_with_locale("LC_TIME", "C")
@hypothesis.given(case=strptime_inputs())
@hypothesis.example(case=(
"0001-01-01 00:00:00", "%Y-%m-%d %H:%M:%S", datetime.datetime.min,
))
@hypothesis.example(case=(
"+000000000001AM00", "%z%M%f%p%H",
datetime.datetime(1900, 1, 1, microsecond=100,
tzinfo=datetime.timezone.utc),
))
@hypothesis.example(case=(
"+000000000100", "%z%f%S",
datetime.datetime(1900, 1, 1, microsecond=100,
tzinfo=datetime.timezone.utc),
))
def test_strptime_fields(self, case):
value, fmt, expected = case
actual = self.theclass.strptime(value, fmt)
# Comparing components also works across the C and Python classes.
self.assertEqual(actual.isoformat(), expected.isoformat())

# Locale composites may include fields such as %Z that aren't invertible.
# Keep this property in the C locale, where their expansions are known.
@support.run_with_locale("LC_TIME", "C")
@hypothesis.given(
dt=st.datetimes(timezones=st.timezones()),
# gh-12137: strptime does not accept "%:z"
fmt=datetime_strftimes(exclude={"%:z"}).filter(lambda x: x),
# Some strftime implementations don't pad %Y or %G to four digits.
dt=st.datetimes(min_value=datetime.datetime(1000, 1, 8),
timezones=st.timezones()),
# gh-66571: Arbitrary IANA zone names cannot be parsed with %Z.
fmt=datetime_strftimes(exclude={"%Z"}).filter(lambda x: x),
)
@hypothesis.example(
dt=datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc),
fmt=DatetimeFormat(frozenset({"%z", "%H", "%d", "%m"}), "%z%H%d%m"),
)
@hypothesis.example(
dt=datetime.datetime(2000, 1, 3, tzinfo=datetime.timezone.utc),
fmt=DatetimeFormat(frozenset({"%W", "%Y"}), "%W%Y"),
)
@hypothesis.example(
dt=datetime.datetime(1968, 1, 1, tzinfo=datetime.timezone.utc),
fmt=DatetimeFormat(frozenset({"%A", "%j", "%y"}), "%A%j%y"),
)
@hypothesis.example(
dt=datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc),
fmt=DatetimeFormat(frozenset({"%c"}), "|%c|"),
)
def test_strftime_strptime_property(self, dt, fmt):
fmt_code = fmt.fmt
# gh-124531: \x00 terminates format strings
hypothesis.assume("\x00" not in fmt_code)

# This first step can be lossy so without more extensive logic, we
# cannot directly make assertions about what this does.
dt_str = dt.strftime(fmt_code)

# gh-124529: %c does not work for years < 1000
hypothesis.assume(not ("%c" in fmt.codes and dt.year < 1000))

# gh-66571: These can only be parsed back in specific situations
hypothesis.assume(not "%Z" in fmt.codes)
# Day-of-month parsing without a year is no longer supported.
if "%d" in fmt.codes and not ({"%Y", "%y"} & fmt.codes):
with self.assertRaises(ValueError):
self.theclass.strptime(dt_str, fmt_code)
return

# From here on out strptime/strftime rounds should be idempotent
dt_rt = self.theclass.strptime(dt_str, fmt_code)
Expand All @@ -280,15 +317,18 @@ def test_strftime_strptime_property(self, dt, fmt):
(
not ({"%a", "%A", "%w", "%u"} & fmt.codes)
or (
("%Y" in fmt.codes or "%y" in fmt.codes)
and ("%j" in fmt.codes or ("%m" in fmt.codes and "%b" in fmt.codes))
("%Y" in fmt.codes or
("%y" in fmt.codes and 1969 <= dt.year <= 2068))
and ("%j" in fmt.codes or
("%d" in fmt.codes and {"%m", "%b", "%B"} & fmt.codes))
)
or ("%G" in fmt.codes)
)
and not ("%p" in fmt.codes and not ({"%H", "%I"} & fmt.codes))
and not any(
x in fmt_code
for x in ("%z%j", "%z%H", "%z%I", "%z%f", "%z%d", "%z%m", "%z%S")
# Week numbers are ignored unless a weekday is also supplied.
and not (
{"%U", "%W"} & fmt.codes
and not {"%a", "%A", "%w", "%u"} & fmt.codes
)
):
self.assertEqual(dt_rt_str, dt_str)
Expand All @@ -313,3 +353,7 @@ def setUpClass(cls):
with support.swap_item(sys.modules, "datetime", cls.datetime_module):
strptime = import_helper.import_fresh_module("_strptime")
cls.enterClassContext(support.swap_item(sys.modules, "_strptime", strptime))


if __name__ == "__main__":
unittest.main()