gh-41431: Add `datetime.time.strptime()` and `datetime.date.strptime()` (#120752)

* Python implementation

* C implementation

* Test `date.strptime`

* Test `time.strptime`

* 📜🤖 Added by blurb_it.

* Update whatsnew

* Update documentation

* Add leap year note

* Update 2024-06-19-19-53-42.gh-issue-41431.gnkUc5.rst

* Apply suggestions from code review

Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>

* Remove parentheses

* Use helper function

* Remove bad return

* Link to github issue

* Fix directive

* Apply suggestions from code review

Co-authored-by: Paul Ganssle <1377457+pganssle@users.noreply.github.com>

* Fix test cases

---------

Co-authored-by: blurb-it[bot] <43283697+blurb-it[bot]@users.noreply.github.com>
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
Co-authored-by: Paul Ganssle <1377457+pganssle@users.noreply.github.com>
This commit is contained in:
Nice Zombies 2024-09-25 23:43:58 +02:00 committed by GitHub
parent b0c6cf5f17
commit 9968caa0cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 350 additions and 36 deletions

View File

@ -548,6 +548,39 @@ Other constructors, all class methods:
.. versionadded:: 3.8
.. classmethod:: date.strptime(date_string, format)
Return a :class:`.date` corresponding to *date_string*, parsed according to
*format*. This is equivalent to::
date(*(time.strptime(date_string, format)[0:3]))
:exc:`ValueError` is raised if the date_string and format
can't be parsed by :func:`time.strptime` or if it returns a value which isn't a
time tuple. See also :ref:`strftime-strptime-behavior` and
:meth:`date.fromisoformat`.
.. note::
If *format* specifies a day of month without a year a
:exc:`DeprecationWarning` is emitted. This is to avoid a quadrennial
leap year bug in code seeking to parse only a month and day as the
default year used in absence of one in the format is not a leap year.
Such *format* values may raise an error as of Python 3.15. The
workaround is to always include a year in your *format*. If parsing
*date_string* values that do not have a year, explicitly add a year that
is a leap year before parsing:
.. doctest::
>>> from datetime import date
>>> date_string = "02/29"
>>> when = date.strptime(f"{date_string};1984", "%m/%d;%Y") # Avoids leap year bug.
>>> when.strftime("%B %d") # doctest: +SKIP
'February 29'
.. versionadded:: 3.14
Class attributes:
@ -1827,7 +1860,7 @@ In Boolean contexts, a :class:`.time` object is always considered to be true.
details.
Other constructor:
Other constructors:
.. classmethod:: time.fromisoformat(time_string)
@ -1869,6 +1902,22 @@ Other constructor:
Previously, this method only supported formats that could be emitted by
:meth:`time.isoformat`.
.. classmethod:: time.strptime(date_string, format)
Return a :class:`.time` corresponding to *date_string*, parsed according to
*format*.
If *format* does not contain microseconds or timezone information, this is equivalent to::
time(*(time.strptime(date_string, format)[3:6]))
:exc:`ValueError` is raised if the *date_string* and *format*
cannot be parsed by :func:`time.strptime` or if it returns a value which is not a
time tuple. See also :ref:`strftime-strptime-behavior` and
:meth:`time.fromisoformat`.
.. versionadded:: 3.14
Instance methods:
@ -2367,24 +2416,22 @@ Class attributes:
``strftime(format)`` method, to create a string representing the time under the
control of an explicit format string.
Conversely, the :meth:`datetime.strptime` class method creates a
:class:`.datetime` object from a string representing a date and time and a
corresponding format string.
Conversely, the :meth:`date.strptime`, :meth:`datetime.strptime` and
:meth:`time.strptime` class methods create an object from a string
representing the time and a corresponding format string.
The table below provides a high-level comparison of :meth:`~.datetime.strftime`
versus :meth:`~.datetime.strptime`:
+----------------+--------------------------------------------------------+------------------------------------------------------------------------------+
| | ``strftime`` | ``strptime`` |
+================+========================================================+==============================================================================+
| Usage | Convert object to a string according to a given format | Parse a string into a :class:`.datetime` object given a corresponding format |
+----------------+--------------------------------------------------------+------------------------------------------------------------------------------+
| Type of method | Instance method | Class method |
+----------------+--------------------------------------------------------+------------------------------------------------------------------------------+
| Method of | :class:`date`; :class:`.datetime`; :class:`.time` | :class:`.datetime` |
+----------------+--------------------------------------------------------+------------------------------------------------------------------------------+
| Signature | ``strftime(format)`` | ``strptime(date_string, format)`` |
+----------------+--------------------------------------------------------+------------------------------------------------------------------------------+
+----------------+--------------------------------------------------------+------------------------------------------------------------+
| | ``strftime`` | ``strptime`` |
+================+========================================================+============================================================+
| Usage | Convert object to a string according to a given format | Parse a string into an object given a corresponding format |
+----------------+--------------------------------------------------------+------------------------------------------------------------+
| Type of method | Instance method | Class method |
+----------------+--------------------------------------------------------+------------------------------------------------------------+
| Signature | ``strftime(format)`` | ``strptime(date_string, format)`` |
+----------------+--------------------------------------------------------+------------------------------------------------------------+
.. _format-codes:

View File

@ -285,6 +285,12 @@ operator
(Contributed by Raymond Hettinger and Nico Mexis in :gh:`115808`.)
datetime
--------
Add :meth:`datetime.time.strptime` and :meth:`datetime.date.strptime`.
(Contributed by Wannes Boeykens in :gh:`41431`.)
os
--

View File

@ -768,7 +768,9 @@ _PyStaticObjects_CheckRefcnt(PyInterpreterState *interp) {
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_shutdown));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_slotnames));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_strptime));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_strptime_datetime));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_strptime_datetime_date));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_strptime_datetime_datetime));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_strptime_datetime_time));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_type_));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_uninitialized_submodules));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_warn_unawaited_coroutine));

View File

@ -257,7 +257,9 @@ struct _Py_global_strings {
STRUCT_FOR_ID(_shutdown)
STRUCT_FOR_ID(_slotnames)
STRUCT_FOR_ID(_strptime)
STRUCT_FOR_ID(_strptime_datetime)
STRUCT_FOR_ID(_strptime_datetime_date)
STRUCT_FOR_ID(_strptime_datetime_datetime)
STRUCT_FOR_ID(_strptime_datetime_time)
STRUCT_FOR_ID(_type_)
STRUCT_FOR_ID(_uninitialized_submodules)
STRUCT_FOR_ID(_warn_unawaited_coroutine)

View File

@ -766,7 +766,9 @@ extern "C" {
INIT_ID(_shutdown), \
INIT_ID(_slotnames), \
INIT_ID(_strptime), \
INIT_ID(_strptime_datetime), \
INIT_ID(_strptime_datetime_date), \
INIT_ID(_strptime_datetime_datetime), \
INIT_ID(_strptime_datetime_time), \
INIT_ID(_type_), \
INIT_ID(_uninitialized_submodules), \
INIT_ID(_warn_unawaited_coroutine), \

View File

@ -828,7 +828,15 @@ _PyUnicode_InitStaticStrings(PyInterpreterState *interp) {
_PyUnicode_InternStatic(interp, &string);
assert(_PyUnicode_CheckConsistency(string, 1));
assert(PyUnicode_GET_LENGTH(string) != 1);
string = &_Py_ID(_strptime_datetime);
string = &_Py_ID(_strptime_datetime_date);
_PyUnicode_InternStatic(interp, &string);
assert(_PyUnicode_CheckConsistency(string, 1));
assert(PyUnicode_GET_LENGTH(string) != 1);
string = &_Py_ID(_strptime_datetime_datetime);
_PyUnicode_InternStatic(interp, &string);
assert(_PyUnicode_CheckConsistency(string, 1));
assert(PyUnicode_GET_LENGTH(string) != 1);
string = &_Py_ID(_strptime_datetime_time);
_PyUnicode_InternStatic(interp, &string);
assert(_PyUnicode_CheckConsistency(string, 1));
assert(PyUnicode_GET_LENGTH(string) != 1);

View File

@ -951,6 +951,7 @@ class date:
fromtimestamp()
today()
fromordinal()
strptime()
Operators:
@ -1051,6 +1052,12 @@ class date:
This is the inverse of the date.isocalendar() function"""
return cls(*_isoweek_to_gregorian(year, week, day))
@classmethod
def strptime(cls, date_string, format):
"""Parse a date string according to the given format (like time.strptime())."""
import _strptime
return _strptime._strptime_datetime_date(cls, date_string, format)
# Conversions to string
def __repr__(self):
@ -1371,6 +1378,7 @@ class time:
Constructors:
__new__()
strptime()
Operators:
@ -1429,6 +1437,12 @@ class time:
self._fold = fold
return self
@classmethod
def strptime(cls, date_string, format):
"""string, format -> new time parsed from a string (like time.strptime())."""
import _strptime
return _strptime._strptime_datetime_time(cls, date_string, format)
# Read-only field accessors
@property
def hour(self):
@ -2152,7 +2166,7 @@ class datetime(date):
def strptime(cls, date_string, format):
'string, format -> new datetime parsed from a string (like time.strptime()).'
import _strptime
return _strptime._strptime_datetime(cls, date_string, format)
return _strptime._strptime_datetime_datetime(cls, date_string, format)
def utcoffset(self):
"""Return the timezone offset as timedelta positive east of UTC (negative west of

View File

@ -567,18 +567,40 @@ def _strptime_time(data_string, format="%a %b %d %H:%M:%S %Y"):
tt = _strptime(data_string, format)[0]
return time.struct_time(tt[:time._STRUCT_TM_ITEMS])
def _strptime_datetime(cls, data_string, format="%a %b %d %H:%M:%S %Y"):
"""Return a class cls instance based on the input string and the
def _strptime_datetime_date(cls, data_string, format="%a %b %d %Y"):
"""Return a date instance based on the input string and the
format string."""
tt, _, _ = _strptime(data_string, format)
args = tt[:3]
return cls(*args)
def _parse_tz(tzname, gmtoff, gmtoff_fraction):
tzdelta = datetime_timedelta(seconds=gmtoff, microseconds=gmtoff_fraction)
if tzname:
return datetime_timezone(tzdelta, tzname)
else:
return datetime_timezone(tzdelta)
def _strptime_datetime_time(cls, data_string, format="%H:%M:%S"):
"""Return a time instance based on the input string and the
format string."""
tt, fraction, gmtoff_fraction = _strptime(data_string, format)
tzname, gmtoff = tt[-2:]
args = tt[3:6] + (fraction,)
if gmtoff is None:
return cls(*args)
else:
tz = _parse_tz(tzname, gmtoff, gmtoff_fraction)
return cls(*args, tz)
def _strptime_datetime_datetime(cls, data_string, format="%a %b %d %H:%M:%S %Y"):
"""Return a datetime instance based on the input string and the
format string."""
tt, fraction, gmtoff_fraction = _strptime(data_string, format)
tzname, gmtoff = tt[-2:]
args = tt[:6] + (fraction,)
if gmtoff is not None:
tzdelta = datetime_timedelta(seconds=gmtoff, microseconds=gmtoff_fraction)
if tzname:
tz = datetime_timezone(tzdelta, tzname)
else:
tz = datetime_timezone(tzdelta)
args += (tz,)
return cls(*args)
if gmtoff is None:
return cls(*args)
else:
tz = _parse_tz(tzname, gmtoff, gmtoff_fraction)
return cls(*args, tz)

View File

@ -1106,6 +1106,85 @@ class TestDateOnly(unittest.TestCase):
dt2 = dt - delta
self.assertEqual(dt2, dt - days)
def test_strptime(self):
inputs = [
# Basic valid cases
(date(1998, 2, 3), '1998-02-03', '%Y-%m-%d'),
(date(2004, 12, 2), '2004-12-02', '%Y-%m-%d'),
# Edge cases: Leap year
(date(2020, 2, 29), '2020-02-29', '%Y-%m-%d'), # Valid leap year date
# bpo-34482: Handle surrogate pairs
(date(2004, 12, 2), '2004-12\ud80002', '%Y-%m\ud800%d'),
(date(2004, 12, 2), '2004\ud80012-02', '%Y\ud800%m-%d'),
# Month/day variations
(date(2004, 2, 1), '2004-02', '%Y-%m'), # No day provided
(date(2004, 2, 1), '02-2004', '%m-%Y'), # Month and year swapped
# Different day-month-year formats
(date(2004, 12, 2), '02/12/2004', '%d/%m/%Y'), # Day/Month/Year
(date(2004, 12, 2), '12/02/2004', '%m/%d/%Y'), # Month/Day/Year
# Different separators
(date(2023, 9, 24), '24.09.2023', '%d.%m.%Y'), # Dots as separators
(date(2023, 9, 24), '24-09-2023', '%d-%m-%Y'), # Dashes
(date(2023, 9, 24), '2023/09/24', '%Y/%m/%d'), # Slashes
# Handling years with fewer digits
(date(127, 2, 3), '0127-02-03', '%Y-%m-%d'),
(date(99, 2, 3), '0099-02-03', '%Y-%m-%d'),
(date(5, 2, 3), '0005-02-03', '%Y-%m-%d'),
# Variations on ISO 8601 format
(date(2023, 9, 25), '2023-W39-1', '%G-W%V-%u'), # ISO week date (Week 39, Monday)
(date(2023, 9, 25), '2023-268', '%Y-%j'), # Year and day of the year (Julian)
]
for expected, string, format in inputs:
with self.subTest(string=string, format=format):
got = date.strptime(string, format)
self.assertEqual(expected, got)
self.assertIs(type(got), date)
def test_strptime_single_digit(self):
# bpo-34903: Check that single digit dates are allowed.
strptime = date.strptime
with self.assertRaises(ValueError):
# %y does require two digits.
newdate = strptime('01/02/3', '%d/%m/%y')
d1 = date(2003, 2, 1)
d2 = date(2003, 1, 2)
d3 = date(2003, 1, 25)
inputs = [
('%d', '1/02/03', '%d/%m/%y', d1),
('%m', '01/2/03', '%d/%m/%y', d1),
('%j', '2/03', '%j/%y', d2),
('%w', '6/04/03', '%w/%U/%y', d1),
# %u requires a single digit.
('%W', '6/4/2003', '%u/%W/%Y', d1),
('%V', '6/4/2003', '%u/%V/%G', d3),
]
for reason, string, format, target in inputs:
reason = 'test single digit ' + reason
with self.subTest(reason=reason,
string=string,
format=format,
target=target):
newdate = strptime(string, format)
self.assertEqual(newdate, target, msg=reason)
@warnings_helper.ignore_warnings(category=DeprecationWarning)
def test_strptime_leap_year(self):
# GH-70647: warns if parsing a format with a day and no year.
with self.assertRaises(ValueError):
# The existing behavior that GH-70647 seeks to change.
date.strptime('02-29', '%m-%d')
with self._assertNotWarns(DeprecationWarning):
date.strptime('20-03-14', '%y-%m-%d')
date.strptime('02-29,2024', '%m-%d,%Y')
class SubclassDate(date):
sub_var = 1
@ -2732,7 +2811,8 @@ class TestDateTime(TestDate):
def test_strptime(self):
string = '2004-12-01 13:02:47.197'
format = '%Y-%m-%d %H:%M:%S.%f'
expected = _strptime._strptime_datetime(self.theclass, string, format)
expected = _strptime._strptime_datetime_datetime(self.theclass, string,
format)
got = self.theclass.strptime(string, format)
self.assertEqual(expected, got)
self.assertIs(type(expected), self.theclass)
@ -2746,8 +2826,8 @@ class TestDateTime(TestDate):
]
for string, format in inputs:
with self.subTest(string=string, format=format):
expected = _strptime._strptime_datetime(self.theclass, string,
format)
expected = _strptime._strptime_datetime_datetime(self.theclass,
string, format)
got = self.theclass.strptime(string, format)
self.assertEqual(expected, got)
@ -3749,6 +3829,78 @@ class TestTime(HarmlessMixedComparison, unittest.TestCase):
derived = loads(data, encoding='latin1')
self.assertEqual(derived, expected)
def test_strptime(self):
# bpo-34482: Check that surrogates are handled properly.
inputs = [
(self.theclass(13, 2, 47, 197000), '13:02:47.197', '%H:%M:%S.%f'),
(self.theclass(13, 2, 47, 197000), '13:02\ud80047.197', '%H:%M\ud800%S.%f'),
(self.theclass(13, 2, 47, 197000), '13\ud80002:47.197', '%H\ud800%M:%S.%f'),
]
for expected, string, format in inputs:
with self.subTest(string=string, format=format):
got = self.theclass.strptime(string, format)
self.assertEqual(expected, got)
self.assertIs(type(got), self.theclass)
def test_strptime_tz(self):
strptime = self.theclass.strptime
self.assertEqual(strptime("+0002", "%z").utcoffset(), 2 * MINUTE)
self.assertEqual(strptime("-0002", "%z").utcoffset(), -2 * MINUTE)
self.assertEqual(
strptime("-00:02:01.000003", "%z").utcoffset(),
-timedelta(minutes=2, seconds=1, microseconds=3)
)
# Only local timezone and UTC are supported
for tzseconds, tzname in ((0, 'UTC'), (0, 'GMT'),
(-_time.timezone, _time.tzname[0])):
if tzseconds < 0:
sign = '-'
seconds = -tzseconds
else:
sign ='+'
seconds = tzseconds
hours, minutes = divmod(seconds//60, 60)
tstr = "{}{:02d}{:02d} {}".format(sign, hours, minutes, tzname)
with self.subTest(tstr=tstr):
t = strptime(tstr, "%z %Z")
self.assertEqual(t.utcoffset(), timedelta(seconds=tzseconds))
self.assertEqual(t.tzname(), tzname)
self.assertIs(type(t), self.theclass)
# Can produce inconsistent time
tstr, fmt = "+1234 UTC", "%z %Z"
t = strptime(tstr, fmt)
self.assertEqual(t.utcoffset(), 12 * HOUR + 34 * MINUTE)
self.assertEqual(t.tzname(), 'UTC')
# yet will roundtrip
self.assertEqual(t.strftime(fmt), tstr)
# Produce naive time if no %z is provided
self.assertEqual(strptime("UTC", "%Z").tzinfo, None)
def test_strptime_errors(self):
for tzstr in ("-2400", "-000", "z"):
with self.assertRaises(ValueError):
self.theclass.strptime(tzstr, "%z")
def test_strptime_single_digit(self):
# bpo-34903: Check that single digit times are allowed.
t = self.theclass(4, 5, 6)
inputs = [
('%H', '4:05:06', '%H:%M:%S', t),
('%M', '04:5:06', '%H:%M:%S', t),
('%S', '04:05:6', '%H:%M:%S', t),
('%I', '4am:05:06', '%I%p:%M:%S', t),
]
for reason, string, format, target in inputs:
reason = 'test single digit ' + reason
with self.subTest(reason=reason,
string=string,
format=format,
target=target):
newdate = self.theclass.strptime(string, format)
self.assertEqual(newdate, target, msg=reason)
def test_bool(self):
# time is always True.
cls = self.theclass

View File

@ -0,0 +1,2 @@
Add :meth:`datetime.time.strptime` and :meth:`datetime.date.strptime`.
Contributed by Wannes Boeykens.

View File

@ -3445,6 +3445,27 @@ date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw)
return new_date_subclass_ex(year, month, day, cls);
}
/* Return new date from _strptime.strptime_datetime_date(). */
static PyObject *
date_strptime(PyObject *cls, PyObject *args)
{
PyObject *string, *format, *result;
if (!PyArg_ParseTuple(args, "UU:strptime", &string, &format)) {
return NULL;
}
PyObject *module = PyImport_Import(&_Py_ID(_strptime));
if (module == NULL) {
return NULL;
}
result = PyObject_CallMethodObjArgs(module,
&_Py_ID(_strptime_datetime_date), cls,
string, format, NULL);
Py_DECREF(module);
return result;
}
/*
* Date arithmetic.
@ -3910,6 +3931,11 @@ static PyMethodDef date_methods[] = {
"number and weekday.\n\n"
"This is the inverse of the date.isocalendar() function")},
{"strptime", (PyCFunction)date_strptime,
METH_VARARGS | METH_CLASS,
PyDoc_STR("string, format -> new date parsed from a string "
"(like time.strptime()).")},
{"today", (PyCFunction)date_today, METH_NOARGS | METH_CLASS,
PyDoc_STR("Current date or datetime: same as "
"self.__class__.fromtimestamp(time.time()).")},
@ -4644,6 +4670,27 @@ time_new(PyTypeObject *type, PyObject *args, PyObject *kw)
return self;
}
/* Return new time from _strptime.strptime_datetime_time(). */
static PyObject *
time_strptime(PyObject *cls, PyObject *args)
{
PyObject *string, *format, *result;
if (!PyArg_ParseTuple(args, "UU:strptime", &string, &format)) {
return NULL;
}
PyObject *module = PyImport_Import(&_Py_ID(_strptime));
if (module == NULL) {
return NULL;
}
result = PyObject_CallMethodObjArgs(module,
&_Py_ID(_strptime_datetime_time), cls,
string, format, NULL);
Py_DECREF(module);
return result;
}
/*
* Destructor.
*/
@ -5079,6 +5126,15 @@ time_reduce(PyDateTime_Time *self, PyObject *arg)
static PyMethodDef time_methods[] = {
/* Class method: */
{"strptime", (PyCFunction)time_strptime,
METH_VARARGS | METH_CLASS,
PyDoc_STR("string, format -> new time parsed from a string "
"(like time.strptime()).")},
/* Instance methods: */
{"isoformat", _PyCFunction_CAST(time_isoformat), METH_VARARGS | METH_KEYWORDS,
PyDoc_STR("Return string in ISO 8601 format, [HH[:MM[:SS[.mmm[uuu]]]]]"
"[+HH:MM].\n\n"
@ -5586,7 +5642,7 @@ datetime_utcfromtimestamp(PyObject *cls, PyObject *args)
return result;
}
/* Return new datetime from _strptime.strptime_datetime(). */
/* Return new datetime from _strptime.strptime_datetime_datetime(). */
static PyObject *
datetime_strptime(PyObject *cls, PyObject *args)
{
@ -5599,7 +5655,8 @@ datetime_strptime(PyObject *cls, PyObject *args)
if (module == NULL) {
return NULL;
}
result = PyObject_CallMethodObjArgs(module, &_Py_ID(_strptime_datetime),
result = PyObject_CallMethodObjArgs(module,
&_Py_ID(_strptime_datetime_datetime),
cls, string, format, NULL);
Py_DECREF(module);
return result;