From 57327ab673e24c87bbfb4df3e54bc4d6de24286f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Jun 2024 15:44:23 +0200 Subject: [PATCH 01/13] add C implementation --- Modules/_datetimemodule.c | 80 ++++++++++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index cb4622893375d7..5650421ccb3bf3 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3471,10 +3471,15 @@ date_repr(PyDateTime_Date *self) } static PyObject * -date_isoformat(PyDateTime_Date *self, PyObject *Py_UNUSED(ignored)) +date_isoformat(PyDateTime_Date *self, PyObject *args, PyObject *kw) { - return PyUnicode_FromFormat("%04d-%02d-%02d", - GET_YEAR(self), GET_MONTH(self), GET_DAY(self)); + int basic = 0; + static char *keywords[] = {"basic", NULL}; + if (!PyArg_ParseTupleAndKeywords(args, kw, "|p:isoformat", keywords, &basic)) { + return NULL; + } + const char *format = basic ? "%04d%02d%02d" : "%04d-%02d-%02d"; + return PyUnicode_FromFormat(format, GET_YEAR(self), GET_MONTH(self), GET_DAY(self)); } /* str() calls the appropriate isoformat() method. */ @@ -3868,8 +3873,9 @@ static PyMethodDef date_methods[] = { PyDoc_STR("Return a named tuple containing ISO year, week number, and " "weekday.")}, - {"isoformat", (PyCFunction)date_isoformat, METH_NOARGS, - PyDoc_STR("Return string in ISO 8601 format, YYYY-MM-DD.")}, + {"isoformat", (PyCFunction)date_isoformat, METH_VARARGS | METH_KEYWORDS, + PyDoc_STR("Return string in ISO 8601 format, YYYY-MM-DD.\n" + "If basic is true, uses the basic format, YYYYMMDD.")}, {"isoweekday", (PyCFunction)date_isoweekday, METH_NOARGS, PyDoc_STR("Return the day of the week represented by the date.\n" @@ -4654,20 +4660,33 @@ time_isoformat(PyDateTime_Time *self, PyObject *args, PyObject *kw) { char buf[100]; const char *timespec = NULL; - static char *keywords[] = {"timespec", NULL}; + int basic = 0; + static char *keywords[] = {"timespec", "basic", NULL}; PyObject *result; int us = TIME_GET_MICROSECOND(self); - static const char *specs[][2] = { + static const char *specs_extended[][2] = { {"hours", "%02d"}, {"minutes", "%02d:%02d"}, {"seconds", "%02d:%02d:%02d"}, {"milliseconds", "%02d:%02d:%02d.%03d"}, {"microseconds", "%02d:%02d:%02d.%06d"}, }; - size_t given_spec; + static const char *specs_basic[][2] = { + {"hours", "%02d"}, + {"minutes", "%02d%02d"}, + {"seconds", "%02d%02d%02d"}, + {"milliseconds", "%02d%02d%02d.%03d"}, + {"microseconds", "%02d%02d%02d.%06d"}, + }; - if (!PyArg_ParseTupleAndKeywords(args, kw, "|s:isoformat", keywords, ×pec)) + if (!PyArg_ParseTupleAndKeywords(args, kw, "|sp:isoformat", keywords, ×pec, &basic)) { return NULL; + } + + const char *(*specs)[2] = basic ? specs_basic : specs_extended; + // due to array decaying, Py_ARRAY_LENGTH(specs) would return 0 + size_t specs_count = basic ? Py_ARRAY_LENGTH(specs_basic) : Py_ARRAY_LENGTH(specs_extended); + size_t given_spec; if (timespec == NULL || strcmp(timespec, "auto") == 0) { if (us == 0) { @@ -4680,7 +4699,7 @@ time_isoformat(PyDateTime_Time *self, PyObject *args, PyObject *kw) } } else { - for (given_spec = 0; given_spec < Py_ARRAY_LENGTH(specs); given_spec++) { + for (given_spec = 0; given_spec < specs_count; given_spec++) { if (strcmp(timespec, specs[given_spec][0]) == 0) { if (given_spec == 3) { /* milliseconds */ @@ -4691,7 +4710,7 @@ time_isoformat(PyDateTime_Time *self, PyObject *args, PyObject *kw) } } - if (given_spec == Py_ARRAY_LENGTH(specs)) { + if (given_spec == specs_count) { PyErr_Format(PyExc_ValueError, "Unknown timespec value"); return NULL; } @@ -4705,8 +4724,8 @@ time_isoformat(PyDateTime_Time *self, PyObject *args, PyObject *kw) return result; /* We need to append the UTC offset. */ - if (format_utcoffset(buf, sizeof(buf), ":", self->tzinfo, - Py_None) < 0) { + const char *offset_sep = basic ? "" : ":"; + if (format_utcoffset(buf, sizeof(buf), offset_sep, self->tzinfo, Py_None) < 0) { Py_DECREF(result); return NULL; } @@ -5006,6 +5025,8 @@ static PyMethodDef time_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" + "If basic is true, separators ':' are removed " + "from the output (e.g., HHMMSS).\n" "The optional argument timespec specifies the number " "of additional terms\nof the time to include. Valid " "options are 'auto', 'hours', 'minutes',\n'seconds', " @@ -6045,21 +6066,34 @@ datetime_isoformat(PyDateTime_DateTime *self, PyObject *args, PyObject *kw) { int sep = 'T'; char *timespec = NULL; - static char *keywords[] = {"sep", "timespec", NULL}; + int basic = 0; + static char *keywords[] = {"sep", "timespec", "basic", NULL}; char buffer[100]; PyObject *result = NULL; int us = DATE_GET_MICROSECOND(self); - static const char *specs[][2] = { + static const char *specs_extended[][2] = { {"hours", "%04d-%02d-%02d%c%02d"}, {"minutes", "%04d-%02d-%02d%c%02d:%02d"}, {"seconds", "%04d-%02d-%02d%c%02d:%02d:%02d"}, {"milliseconds", "%04d-%02d-%02d%c%02d:%02d:%02d.%03d"}, {"microseconds", "%04d-%02d-%02d%c%02d:%02d:%02d.%06d"}, }; - size_t given_spec; + static const char *specs_basic[][2] = { + {"hours", "%04d%02d%02d%c%02d"}, + {"minutes", "%04d%02d%02d%c%02d%02d"}, + {"seconds", "%04d%02d%02d%c%02d%02d%02d"}, + {"milliseconds", "%04d%02d%02d%c%02d%02d%02d.%03d"}, + {"microseconds", "%04d%02d%02d%c%02d%02d%02d.%06d"}, + }; - if (!PyArg_ParseTupleAndKeywords(args, kw, "|Cs:isoformat", keywords, &sep, ×pec)) + if (!PyArg_ParseTupleAndKeywords(args, kw, "|Csp:isoformat", keywords, &sep, ×pec, &basic)) { return NULL; + } + + const char *(*specs)[2] = basic ? specs_basic : specs_extended; + // due to array decaying, Py_ARRAY_LENGTH(specs) would return 0 + size_t specs_count = basic ? Py_ARRAY_LENGTH(specs_basic) : Py_ARRAY_LENGTH(specs_extended); + size_t given_spec; if (timespec == NULL || strcmp(timespec, "auto") == 0) { if (us == 0) { @@ -6072,7 +6106,7 @@ datetime_isoformat(PyDateTime_DateTime *self, PyObject *args, PyObject *kw) } } else { - for (given_spec = 0; given_spec < Py_ARRAY_LENGTH(specs); given_spec++) { + for (given_spec = 0; given_spec < specs_count; given_spec++) { if (strcmp(timespec, specs[given_spec][0]) == 0) { if (given_spec == 3) { us = us / 1000; @@ -6082,7 +6116,7 @@ datetime_isoformat(PyDateTime_DateTime *self, PyObject *args, PyObject *kw) } } - if (given_spec == Py_ARRAY_LENGTH(specs)) { + if (given_spec == specs_count) { PyErr_Format(PyExc_ValueError, "Unknown timespec value"); return NULL; } @@ -6098,8 +6132,8 @@ datetime_isoformat(PyDateTime_DateTime *self, PyObject *args, PyObject *kw) return result; /* We need to append the UTC offset. */ - if (format_utcoffset(buffer, sizeof(buffer), ":", self->tzinfo, - (PyObject *)self) < 0) { + const char *offset_sep = basic ? "" : ":"; + if (format_utcoffset(buffer, sizeof(buffer), offset_sep, self->tzinfo, (PyObject *)self) < 0) { Py_DECREF(result); return NULL; } @@ -6863,9 +6897,11 @@ static PyMethodDef datetime_methods[] = { {"isoformat", _PyCFunction_CAST(datetime_isoformat), METH_VARARGS | METH_KEYWORDS, PyDoc_STR("[sep] -> string in ISO 8601 format, " - "YYYY-MM-DDT[HH[:MM[:SS[.mmm[uuu]]]]][+HH:MM].\n" + "YYYY-MM-DDT[HH[:MM[:SS[.mmm[uuu]]]]][+HH:MM].\n\n" "sep is used to separate the year from the time, and " "defaults to 'T'.\n" + "If basic is true, separators ':' and '-' are removed " + "from the output (e.g., YYYYMMDDTHHMMSS).\n" "The optional argument timespec specifies the number " "of additional terms\nof the time to include. Valid " "options are 'auto', 'hours', 'minutes',\n'seconds', " From 88d2eb016c3737833b4a27621a3a1dd8c0b6f028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Jun 2024 15:44:31 +0200 Subject: [PATCH 02/13] add Python implementation --- Lib/_pydatetime.py | 64 +++++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index 34ccb2da13d0f3..cfdd0f690647e9 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -165,14 +165,23 @@ def _build_struct_time(y, m, d, hh, mm, ss, dstflag): dnum = _days_before_month(y, m) + d return _time.struct_time((y, m, d, hh, mm, ss, wday, dnum, dstflag)) -def _format_time(hh, mm, ss, us, timespec='auto'): - specs = { - 'hours': '{:02d}', - 'minutes': '{:02d}:{:02d}', - 'seconds': '{:02d}:{:02d}:{:02d}', - 'milliseconds': '{:02d}:{:02d}:{:02d}.{:03d}', - 'microseconds': '{:02d}:{:02d}:{:02d}.{:06d}' - } +def _format_time(hh, mm, ss, us, timespec='auto', basic=False): + if basic: + specs = { + 'hours': '{:02d}', + 'minutes': '{:02d}{:02d}', + 'seconds': '{:02d}{:02d}{:02d}', + 'milliseconds': '{:02d}{:02d}{:02d}.{:03d}', + 'microseconds': '{:02d}{:02d}{:02d}.{:06d}' + } + else: + specs = { + 'hours': '{:02d}', + 'minutes': '{:02d}:{:02d}', + 'seconds': '{:02d}:{:02d}:{:02d}', + 'milliseconds': '{:02d}:{:02d}:{:02d}.{:03d}', + 'microseconds': '{:02d}:{:02d}:{:02d}.{:06d}' + } if timespec == 'auto': # Skip trailing microseconds when us==0. @@ -1051,16 +1060,18 @@ def __format__(self, fmt): return self.strftime(fmt) return str(self) - def isoformat(self): + def isoformat(self, basic=False): """Return the date formatted according to ISO. - This is 'YYYY-MM-DD'. + This is 'YYYY-MM-DD' or 'YYYYMMDD' if *basic* is true. References: - http://www.w3.org/TR/NOTE-datetime - http://www.cl.cam.ac.uk/~mgk25/iso-time.html """ - return "%04d-%02d-%02d" % (self._year, self._month, self._day) + if basic: + return f"{self._year:04d}{self._month:02d}{self._day:02d}" + return f"{self._year:04d}-{self._month:02d}-{self._day:02d}" __str__ = isoformat @@ -1501,10 +1512,11 @@ def __hash__(self): # Conversion to string - def _tzstr(self): + def _tzstr(self, basic): """Return formatted timezone offset (+xx:xx) or an empty string.""" off = self.utcoffset() - return _format_offset(off) + sep = '' if basic else ':' + return _format_offset(off, sep) def __repr__(self): """Convert to formal string, for repr().""" @@ -1525,19 +1537,21 @@ def __repr__(self): s = s[:-1] + ", fold=1)" return s - def isoformat(self, timespec='auto'): + def isoformat(self, timespec='auto', basic=False): """Return the time formatted according to ISO. The full format is 'HH:MM:SS.mmmmmm+zz:zz'. By default, the fractional part is omitted if self.microsecond == 0. + If *basic* is true, separators ':' are removed from the output. + The optional argument timespec specifies the number of additional terms of the time to include. Valid options are 'auto', 'hours', 'minutes', 'seconds', 'milliseconds' and 'microseconds'. """ s = _format_time(self._hour, self._minute, self._second, - self._microsecond, timespec) - tz = self._tzstr() + self._microsecond, timespec, basic) + tz = self._tzstr(basic) if tz: s += tz return s @@ -2028,6 +2042,12 @@ def astimezone(self, tz=None): # Ways to produce a string. + def _tzstr(self, basic): + """Return formatted timezone offset (+xx:xx) or an empty string.""" + off = self.utcoffset() + sep = '' if basic else ':' + return _format_offset(off, sep) + def ctime(self): "Return ctime() style string." weekday = self.toordinal() % 7 or 7 @@ -2038,12 +2058,14 @@ def ctime(self): self._hour, self._minute, self._second, self._year) - def isoformat(self, sep='T', timespec='auto'): + def isoformat(self, sep='T', timespec='auto', basic=False): """Return the time formatted according to ISO. The full format looks like 'YYYY-MM-DD HH:MM:SS.mmmmmm'. By default, the fractional part is omitted if self.microsecond == 0. + If *basic* is true, separators ':' and '-' are removed from the output. + If self.tzinfo is not None, the UTC offset is also attached, giving giving a full format of 'YYYY-MM-DD HH:MM:SS.mmmmmm+HH:MM'. @@ -2054,12 +2076,12 @@ def isoformat(self, sep='T', timespec='auto'): terms of the time to include. Valid options are 'auto', 'hours', 'minutes', 'seconds', 'milliseconds' and 'microseconds'. """ - s = ("%04d-%02d-%02d%c" % (self._year, self._month, self._day, sep) + + fmt = "%04d%02d%02d%c" if basic else "%04d-%02d-%02d%c" + s = (fmt % (self._year, self._month, self._day, sep) + _format_time(self._hour, self._minute, self._second, - self._microsecond, timespec)) + self._microsecond, timespec, basic)) - off = self.utcoffset() - tz = _format_offset(off) + tz = self._tzstr(basic) if tz: s += tz From 68a3a19994ce97fc22e3dcff0afc868a42b46854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Jun 2024 15:44:59 +0200 Subject: [PATCH 03/13] add tests --- Lib/test/datetimetester.py | 130 +++++++++++++++++++++++++++++++------ 1 file changed, 109 insertions(+), 21 deletions(-) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 28f75a803b4e04..bdf1ec74b3d98d 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1461,6 +1461,7 @@ def test_iso_long_years(self): def test_isoformat(self): t = self.theclass(2, 3, 2) self.assertEqual(t.isoformat(), "0002-03-02") + self.assertEqual(t.isoformat(basic=True), "00020302") def test_ctime(self): t = self.theclass(2002, 3, 2) @@ -2081,16 +2082,36 @@ def test_isoformat(self): self.assertEqual(t.isoformat('T'), "0001-02-03T04:05:01.000123") self.assertEqual(t.isoformat(' '), "0001-02-03 04:05:01.000123") self.assertEqual(t.isoformat('\x00'), "0001-02-03\x0004:05:01.000123") + + self.assertEqual(t.isoformat(basic=True), "00010203T040501.000123") + self.assertEqual(t.isoformat('T', basic=True), "00010203T040501.000123") + self.assertEqual(t.isoformat(' ', basic=True), "00010203 040501.000123") + self.assertEqual(t.isoformat('\x00', basic=True), "00010203\x00040501.000123") + # bpo-34482: Check that surrogates are handled properly. self.assertEqual(t.isoformat('\ud800'), "0001-02-03\ud80004:05:01.000123") self.assertEqual(t.isoformat(timespec='hours'), "0001-02-03T04") + self.assertEqual(t.isoformat(timespec='hours', basic=True), "00010203T04") + self.assertEqual(t.isoformat(timespec='minutes'), "0001-02-03T04:05") + self.assertEqual(t.isoformat(timespec='minutes', basic=True), "00010203T0405") + self.assertEqual(t.isoformat(timespec='seconds'), "0001-02-03T04:05:01") + self.assertEqual(t.isoformat(timespec='seconds', basic=True), "00010203T040501") + self.assertEqual(t.isoformat(timespec='milliseconds'), "0001-02-03T04:05:01.000") + self.assertEqual(t.isoformat(timespec='milliseconds', basic=True), "00010203T040501.000") + self.assertEqual(t.isoformat(timespec='microseconds'), "0001-02-03T04:05:01.000123") + self.assertEqual(t.isoformat(timespec='microseconds', basic=True), "00010203T040501.000123") + self.assertEqual(t.isoformat(timespec='auto'), "0001-02-03T04:05:01.000123") + self.assertEqual(t.isoformat(timespec='auto', basic=True), "00010203T040501.000123") + self.assertEqual(t.isoformat(sep=' ', timespec='minutes'), "0001-02-03 04:05") + self.assertEqual(t.isoformat(sep=' ', timespec='minutes', basic=True), "00010203 0405") + self.assertRaises(ValueError, t.isoformat, timespec='foo') # bpo-34482: Check that surrogates are handled properly. self.assertRaises(ValueError, t.isoformat, timespec='\ud800') @@ -2099,55 +2120,71 @@ def test_isoformat(self): t = self.theclass(1, 2, 3, 4, 5, 1, 999500, tzinfo=timezone.utc) self.assertEqual(t.isoformat(timespec='milliseconds'), "0001-02-03T04:05:01.999+00:00") + self.assertEqual(t.isoformat(timespec='milliseconds', basic=True), "00010203T040501.999+0000") t = self.theclass(1, 2, 3, 4, 5, 1, 999500) self.assertEqual(t.isoformat(timespec='milliseconds'), "0001-02-03T04:05:01.999") + self.assertEqual(t.isoformat(timespec='milliseconds', basic=True), "00010203T040501.999") t = self.theclass(1, 2, 3, 4, 5, 1) self.assertEqual(t.isoformat(timespec='auto'), "0001-02-03T04:05:01") + self.assertEqual(t.isoformat(timespec='auto', basic=True), "00010203T040501") self.assertEqual(t.isoformat(timespec='milliseconds'), "0001-02-03T04:05:01.000") + self.assertEqual(t.isoformat(timespec='milliseconds', basic=True), "00010203T040501.000") self.assertEqual(t.isoformat(timespec='microseconds'), "0001-02-03T04:05:01.000000") + self.assertEqual(t.isoformat(timespec='microseconds', basic=True), "00010203T040501.000000") t = self.theclass(2, 3, 2) self.assertEqual(t.isoformat(), "0002-03-02T00:00:00") + self.assertEqual(t.isoformat(basic=True), "00020302T000000") self.assertEqual(t.isoformat('T'), "0002-03-02T00:00:00") + self.assertEqual(t.isoformat('T', basic=True), "00020302T000000") self.assertEqual(t.isoformat(' '), "0002-03-02 00:00:00") + self.assertEqual(t.isoformat(' ', basic=True), "00020302 000000") # str is ISO format with the separator forced to a blank. self.assertEqual(str(t), "0002-03-02 00:00:00") # ISO format with timezone tz = FixedOffset(timedelta(seconds=16), 'XXX') t = self.theclass(2, 3, 2, tzinfo=tz) self.assertEqual(t.isoformat(), "0002-03-02T00:00:00+00:00:16") + self.assertEqual(t.isoformat(basic=True), "00020302T000000+000016") def test_isoformat_timezone(self): tzoffsets = [ - ('05:00', timedelta(hours=5)), - ('02:00', timedelta(hours=2)), - ('06:27', timedelta(hours=6, minutes=27)), - ('12:32:30', timedelta(hours=12, minutes=32, seconds=30)), - ('02:04:09.123456', timedelta(hours=2, minutes=4, seconds=9, microseconds=123456)) + (('05:00', '0500'), timedelta(hours=5)), + (('02:00', '0200'), timedelta(hours=2)), + (('06:27', '0627'), timedelta(hours=6, minutes=27)), + (('12:32:30', '123230'), timedelta(hours=12, minutes=32, seconds=30)), + (('02:04:09.123456', '020409.123456'), + timedelta(hours=2, minutes=4, seconds=9, microseconds=123456)) ] tzinfos = [ - ('', None), - ('+00:00', timezone.utc), - ('+00:00', timezone(timedelta(0))), + (('', ''), None), + (('+00:00', '+0000'), timezone.utc), + (('+00:00', '+0000'), timezone(timedelta(0))), ] tzinfos += [ - (prefix + expected, timezone(sign * td)) - for expected, td in tzoffsets + ((prefix + expected_extended, prefix + expected_basic), timezone(sign * td)) + for (expected_extended, expected_basic), td in tzoffsets for prefix, sign in [('-', -1), ('+', 1)] ] dt_base = self.theclass(2016, 4, 1, 12, 37, 9) - exp_base = '2016-04-01T12:37:09' + exp_base_ext = '2016-04-01T12:37:09' + exp_base_basic = '20160401T123709' - for exp_tz, tzi in tzinfos: + for (exp_tz_ext, exp_tz_basic), tzi in tzinfos: dt = dt_base.replace(tzinfo=tzi) - exp = exp_base + exp_tz - with self.subTest(tzi=tzi): + with self.subTest(tzi=tzi, basic=False): + exp = exp_base_ext + exp_tz_ext assert dt.isoformat() == exp + assert dt.isoformat(basic=False) == exp + + with self.subTest(tzi=tzi, basic=True): + exp = exp_base_basic + exp_tz_basic + assert dt.isoformat(basic=True) == exp def test_format(self): dt = self.theclass(2007, 9, 10, 4, 5, 1, 123) @@ -4048,10 +4085,15 @@ def test_zones(self): self.assertEqual(str(t5), "00:00:00.000040+00:00") self.assertEqual(t1.isoformat(), "07:47:00-05:00") + self.assertEqual(t1.isoformat(basic=True), "074700-0500") self.assertEqual(t2.isoformat(), "12:47:00+00:00") + self.assertEqual(t2.isoformat(basic=True), "124700+0000") self.assertEqual(t3.isoformat(), "13:47:00+01:00") + self.assertEqual(t3.isoformat(basic=True), "134700+0100") self.assertEqual(t4.isoformat(), "00:00:00.000040") + self.assertEqual(t4.isoformat(basic=True), "000000.000040") self.assertEqual(t5.isoformat(), "00:00:00.000040+00:00") + self.assertEqual(t5.isoformat(basic=True), "000000.000040+0000") d = 'datetime.time' self.assertEqual(repr(t1), d + "(7, 47, tzinfo=est)") @@ -4957,25 +4999,71 @@ def utcoffset(self, dt): self.assertRaises(OverflowError, huge.utctimetuple) def test_tzinfo_isoformat(self): + offsets = [ + (("+00:00", "+0000"), 0), + (("+03:40", "+0340"), 220), + (("-03:51", "-0351"), -231), + (("", ""), None), + ] + zero = FixedOffset(0, "+00:00") plus = FixedOffset(220, "+03:40") minus = FixedOffset(-231, "-03:51") unknown = FixedOffset(None, "") cls = self.theclass - datestr = '0001-02-03' + datestr_ext = '0001-02-03' + datestr_basic = '00010203' + for (name_ext, name_basic), value in offsets: + for us in 0, 987001: + timestr_suffix = (us and '.987001' or '') + + offset_ext = FixedOffset(value, name_ext) + d = cls(1, 2, 3, 4, 5, 59, us, tzinfo=offset_ext) + ofsstr_ext = offset_ext is not None and d.tzname() or '' + timestr_ext = '04:05:59' + timestr_suffix + tailstr_ext = timestr_ext + ofsstr_ext + + iso = d.isoformat() + self.assertEqual(iso, d.isoformat(basic=False)) + self.assertEqual(iso, datestr_ext + 'T' + tailstr_ext) + self.assertEqual(iso, d.isoformat('T')) + self.assertEqual(d.isoformat('k'), datestr_ext + 'k' + tailstr_ext) + self.assertEqual(d.isoformat('\u1234'), datestr_ext + '\u1234' + tailstr_ext) + self.assertEqual(str(d), datestr_ext + ' ' + tailstr_ext) + + offset_basic = FixedOffset(value, name_basic) + d = cls(1, 2, 3, 4, 5, 59, us, tzinfo=offset_basic) + ofsstr_basic = offset_basic is not None and d.tzname() or '' + timestr_basic = '040559' + timestr_suffix + tailstr_basic = timestr_basic + ofsstr_basic + + iso = d.isoformat(basic=True) + self.assertEqual(iso, datestr_basic + 'T' + tailstr_basic) + self.assertEqual(iso, d.isoformat('T', basic=True)) + self.assertEqual(d.isoformat('k', basic=True), datestr_basic + 'k' + tailstr_basic) + self.assertEqual(d.isoformat('\u1234', basic=True), datestr_basic + '\u1234' + tailstr_basic) + + def test_tzinfo_isoformat_basic(self): + zero = FixedOffset(0, "+0000") + plus = FixedOffset(220, "+0340") + minus = FixedOffset(-231, "-0351") + unknown = FixedOffset(None, "") + + cls = self.theclass + datestr = '00010203' for ofs in None, zero, plus, minus, unknown: for us in 0, 987001: d = cls(1, 2, 3, 4, 5, 59, us, tzinfo=ofs) - timestr = '04:05:59' + (us and '.987001' or '') + timestr_suffix = us and '.987001' or '' + timestr = '040559' + timestr_suffix ofsstr = ofs is not None and d.tzname() or '' tailstr = timestr + ofsstr - iso = d.isoformat() + iso = d.isoformat(basic=True) self.assertEqual(iso, datestr + 'T' + tailstr) - self.assertEqual(iso, d.isoformat('T')) - self.assertEqual(d.isoformat('k'), datestr + 'k' + tailstr) - self.assertEqual(d.isoformat('\u1234'), datestr + '\u1234' + tailstr) - self.assertEqual(str(d), datestr + ' ' + tailstr) + self.assertEqual(iso, d.isoformat('T', basic=True)) + self.assertEqual(d.isoformat('k', basic=True), datestr + 'k' + tailstr) + self.assertEqual(d.isoformat('\u1234', basic=True), datestr + '\u1234' + tailstr) def test_replace(self): cls = self.theclass From 1756b572795d4205e47a66d2bffc1f6937ffb9ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Jun 2024 15:45:11 +0200 Subject: [PATCH 04/13] add documentation --- Doc/library/datetime.rst | 44 ++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index b6d8e6e6df07fa..21f4f8820ae203 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -719,13 +719,25 @@ Instance methods: .. versionchanged:: 3.9 Result changed from a tuple to a :term:`named tuple`. -.. method:: date.isoformat() - Return a string representing the date in ISO 8601 format, ``YYYY-MM-DD``:: +.. method:: date.isoformat(basic=False) + + Return a string representing the date in: + + - ISO 8601 extended format ``YYYY-MM-DD`` (the default), or + - ISO 8601 basic format ``YYYYMMDD`` via the *basic* argument. + + Examples: >>> from datetime import date >>> date(2002, 12, 4).isoformat() '2002-12-04' + >>> date(2002, 12, 4).isoformat(basic=True) + '20021204' + + .. versionchanged:: 3.14 + Added the *basic* parameter. + .. method:: date.__str__() @@ -1479,9 +1491,9 @@ Instance methods: and ``weekday``. The same as ``self.date().isocalendar()``. -.. method:: datetime.isoformat(sep='T', timespec='auto') +.. method:: datetime.isoformat(sep='T', timespec='auto', basic=False) - Return a string representing the date and time in ISO 8601 format: + Return a string representing the date and time in ISO 8601 extended format: - ``YYYY-MM-DDTHH:MM:SS.ffffff``, if :attr:`microsecond` is not 0 - ``YYYY-MM-DDTHH:MM:SS``, if :attr:`microsecond` is 0 @@ -1493,13 +1505,20 @@ Instance methods: is not 0 - ``YYYY-MM-DDTHH:MM:SS+HH:MM[:SS[.ffffff]]``, if :attr:`microsecond` is 0 + If *basic* is true, this uses the ISO 8601 basic format for the date, + time and offset components. + Examples:: >>> from datetime import datetime, timezone >>> datetime(2019, 5, 18, 15, 17, 8, 132263).isoformat() '2019-05-18T15:17:08.132263' + >>> datetime(2019, 5, 18, 15, 17, 8, 132263).isoformat(basic=True) + '20190518T151708.132263' >>> datetime(2019, 5, 18, 15, 17, tzinfo=timezone.utc).isoformat() '2019-05-18T15:17:00+00:00' + >>> datetime(2019, 5, 18, 15, 17, tzinfo=timezone.utc).isoformat(basic=True) + '20190518T151700+0000' The optional argument *sep* (default ``'T'``) is a one-character separator, placed between the date and time portions of the result. For example:: @@ -1546,6 +1565,9 @@ Instance methods: .. versionchanged:: 3.6 Added the *timespec* parameter. + .. versionadded:: 3.14 + Added the *basic* parameter. + .. method:: datetime.__str__() @@ -1881,15 +1903,22 @@ Instance methods: Added the *fold* parameter. -.. method:: time.isoformat(timespec='auto') +.. method:: time.isoformat(timespec='auto', basic=False) - Return a string representing the time in ISO 8601 format, one of: + Return a string representing the time in ISO 8601 (extended) format, one of: - ``HH:MM:SS.ffffff``, if :attr:`microsecond` is not 0 - ``HH:MM:SS``, if :attr:`microsecond` is 0 - ``HH:MM:SS.ffffff+HH:MM[:SS[.ffffff]]``, if :meth:`utcoffset` does not return ``None`` - ``HH:MM:SS+HH:MM[:SS[.ffffff]]``, if :attr:`microsecond` is 0 and :meth:`utcoffset` does not return ``None`` + If *basic* is true, this uses the ISO 8601 basic format, one of: + + - ``HHMMSS`` + - ``HHMMSS.ffffff`` + - ``HHMMSS+HHMM[SS[.ffffff]]`` + - ``HHMMSS.ffffff+HHMM[SS[.ffffff]]`` + The optional argument *timespec* specifies the number of additional components of the time to include (the default is ``'auto'``). It can be one of the following: @@ -1924,6 +1953,9 @@ Instance methods: .. versionchanged:: 3.6 Added the *timespec* parameter. + .. versionchanged:: 3.14 + Added the *basic* parameter. + .. method:: time.__str__() From e8e9ad2c5240f6f64e3e061894f4d9cb10196467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Jun 2024 15:45:23 +0200 Subject: [PATCH 05/13] blurb --- .../Library/2024-06-15-13-08-56.gh-issue-118948.s5aW4U.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2024-06-15-13-08-56.gh-issue-118948.s5aW4U.rst diff --git a/Misc/NEWS.d/next/Library/2024-06-15-13-08-56.gh-issue-118948.s5aW4U.rst b/Misc/NEWS.d/next/Library/2024-06-15-13-08-56.gh-issue-118948.s5aW4U.rst new file mode 100644 index 00000000000000..a041261309c828 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-06-15-13-08-56.gh-issue-118948.s5aW4U.rst @@ -0,0 +1,4 @@ +The :meth:`date.isoformat `, +:meth:`datetime.isoformat ` and +:meth:`time.isoformat ` methods now +support ISO 8601 basic format. Patch by Bénédikt Tran. From 266125fdda917c1f0c5766617e9ad976a3b74542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Jun 2024 15:48:18 +0200 Subject: [PATCH 06/13] add WhatsNew --- Doc/whatsnew/3.14.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index b357553735e8bb..32f02da353c788 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -92,6 +92,17 @@ ast Added :func:`ast.compare` for comparing two ASTs. (Contributed by Batuhan Taskaya and Jeremy Hylton in :issue:`15987`.) +datetime +-------- + +* Add support for ISO 8601 basic format for the following methods: + + - :meth:`date.isoformat ` + - :meth:`datetime.isoformat ` + - :meth:`time.isoformat ` + + (Contributed by Bénédikt Tran in :gh:`118948`.) + os -- From a3e4d59a9b1daef67d1a734db939fc5437e0d2d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Jun 2024 16:35:59 +0200 Subject: [PATCH 07/13] update ignored static const char* values --- Tools/c-analyzer/cpython/ignored.tsv | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tools/c-analyzer/cpython/ignored.tsv b/Tools/c-analyzer/cpython/ignored.tsv index 466f25daa14dc6..afcefc2125e349 100644 --- a/Tools/c-analyzer/cpython/ignored.tsv +++ b/Tools/c-analyzer/cpython/ignored.tsv @@ -214,9 +214,11 @@ Modules/_ctypes/cfield.c - ffi_type_uint8 - Modules/_ctypes/cfield.c - ffi_type_void - Modules/_datetimemodule.c - epoch - Modules/_datetimemodule.c - max_fold_seconds - -Modules/_datetimemodule.c datetime_isoformat specs - +Modules/_datetimemodule.c datetime_isoformat specs_basic - +Modules/_datetimemodule.c datetime_isoformat specs_extended - Modules/_datetimemodule.c parse_hh_mm_ss_ff correction - -Modules/_datetimemodule.c time_isoformat specs - +Modules/_datetimemodule.c time_isoformat specs_basic - +Modules/_datetimemodule.c time_isoformat specs_extended - Modules/_datetimemodule.c - capi_types - Modules/_decimal/_decimal.c - cond_map_template - Modules/_decimal/_decimal.c - dec_signal_string - From e79bf0eeb4ed39d7f3c828f631873d0397f2f014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 15 Jun 2024 16:57:30 +0200 Subject: [PATCH 08/13] fix C warnings --- Modules/_datetimemodule.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 5650421ccb3bf3..8946457fe11fd0 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3873,7 +3873,7 @@ static PyMethodDef date_methods[] = { PyDoc_STR("Return a named tuple containing ISO year, week number, and " "weekday.")}, - {"isoformat", (PyCFunction)date_isoformat, METH_VARARGS | METH_KEYWORDS, + {"isoformat", _PyCFunction_CAST(date_isoformat), METH_VARARGS | METH_KEYWORDS, PyDoc_STR("Return string in ISO 8601 format, YYYY-MM-DD.\n" "If basic is true, uses the basic format, YYYYMMDD.")}, From 9686b179445cad67919a6a550546771b9411b378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 17 Jun 2024 09:24:34 +0200 Subject: [PATCH 09/13] normalize test order --- Lib/test/datetimetester.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index bdf1ec74b3d98d..24bd9ccaffd9ff 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -2079,18 +2079,21 @@ def test_roundtrip(self): def test_isoformat(self): t = self.theclass(1, 2, 3, 4, 5, 1, 123) self.assertEqual(t.isoformat(), "0001-02-03T04:05:01.000123") - self.assertEqual(t.isoformat('T'), "0001-02-03T04:05:01.000123") - self.assertEqual(t.isoformat(' '), "0001-02-03 04:05:01.000123") - self.assertEqual(t.isoformat('\x00'), "0001-02-03\x0004:05:01.000123") - self.assertEqual(t.isoformat(basic=True), "00010203T040501.000123") + + self.assertEqual(t.isoformat('T'), "0001-02-03T04:05:01.000123") self.assertEqual(t.isoformat('T', basic=True), "00010203T040501.000123") + + self.assertEqual(t.isoformat(' '), "0001-02-03 04:05:01.000123") self.assertEqual(t.isoformat(' ', basic=True), "00010203 040501.000123") + + self.assertEqual(t.isoformat('\x00'), "0001-02-03\x0004:05:01.000123") self.assertEqual(t.isoformat('\x00', basic=True), "00010203\x00040501.000123") # bpo-34482: Check that surrogates are handled properly. - self.assertEqual(t.isoformat('\ud800'), - "0001-02-03\ud80004:05:01.000123") + self.assertEqual(t.isoformat('\ud800'), "0001-02-03\ud80004:05:01.000123") + self.assertEqual(t.isoformat('\ud800', basic=True), "00010203\ud800040501.000123") + self.assertEqual(t.isoformat(timespec='hours'), "0001-02-03T04") self.assertEqual(t.isoformat(timespec='hours', basic=True), "00010203T04") @@ -2113,8 +2116,10 @@ def test_isoformat(self): self.assertEqual(t.isoformat(sep=' ', timespec='minutes', basic=True), "00010203 0405") self.assertRaises(ValueError, t.isoformat, timespec='foo') + self.assertRaises(ValueError, t.isoformat, timespec='foo', basic=True) # bpo-34482: Check that surrogates are handled properly. self.assertRaises(ValueError, t.isoformat, timespec='\ud800') + self.assertRaises(ValueError, t.isoformat, timespec='\ud800', basic=True) # str is ISO format with the separator forced to a blank. self.assertEqual(str(t), "0001-02-03 04:05:01.000123") From 3e883c98b8a43e14324317c4fcc29c6d237c7e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 22 Mar 2025 18:09:20 +0100 Subject: [PATCH 10/13] Apply suggestions from code review Co-authored-by: Stan Ulbrych <89152624+StanFromIreland@users.noreply.github.com> --- Doc/whatsnew/3.14.rst | 2 +- Lib/_pydatetime.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 4150a763dcf909..c7f5ea54f0c347 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -427,7 +427,7 @@ operator datetime -------- -* Add support for ISO 8601 basic format for the following methods: +* Add support for the ISO 8601 basic format for the following methods: - :meth:`date.isoformat ` - :meth:`datetime.isoformat ` diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index 06f977a0139b42..c9ed3539a73d2d 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -1111,13 +1111,13 @@ def __format__(self, fmt): return str(self) def isoformat(self, basic=False): - """Return the date formatted according to ISO. + """Return the date formatted according to ISO 8601. This is 'YYYY-MM-DD' or 'YYYYMMDD' if *basic* is true. References: - - http://www.w3.org/TR/NOTE-datetime - - http://www.cl.cam.ac.uk/~mgk25/iso-time.html + - https://www.w3.org/TR/NOTE-datetime + - https://www.cl.cam.ac.uk/~mgk25/iso-time.html """ if basic: return f"{self._year:04d}{self._month:02d}{self._day:02d}" @@ -2138,7 +2138,7 @@ def isoformat(self, sep='T', timespec='auto', basic=False): The full format looks like 'YYYY-MM-DD HH:MM:SS.mmmmmm'. By default, the fractional part is omitted if self.microsecond == 0. - If *basic* is true, separators ':' and '-' are removed from the output. + If *basic* is true, separators ':' and '-' are omitted. If self.tzinfo is not None, the UTC offset is also attached, giving giving a full format of 'YYYY-MM-DD HH:MM:SS.mmmmmm+HH:MM'. From c9d4fc260343f754045008cdce16863d9cee26c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 22 Mar 2025 18:14:19 +0100 Subject: [PATCH 11/13] Apply suggestions from code review Co-authored-by: Stan Ulbrych <89152624+StanFromIreland@users.noreply.github.com> --- Doc/library/datetime.rst | 13 ++++--------- Doc/whatsnew/3.14.rst | 1 + Lib/_pydatetime.py | 6 ++++-- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index bb0e16f00870d8..b8c8a165fa4475 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -788,7 +788,7 @@ Instance methods: >>> date(2002, 12, 4).isoformat(basic=True) '20021204' - .. versionchanged:: 3.14 + .. versionchanged:: next Added the *basic* parameter. @@ -1622,7 +1622,7 @@ Instance methods: .. versionchanged:: 3.6 Added the *timespec* parameter. - .. versionadded:: 3.14 + .. versionadded:: next Added the *basic* parameter. @@ -1985,12 +1985,7 @@ Instance methods: - ``HH:MM:SS.ffffff+HH:MM[:SS[.ffffff]]``, if :meth:`utcoffset` does not return ``None`` - ``HH:MM:SS+HH:MM[:SS[.ffffff]]``, if :attr:`microsecond` is 0 and :meth:`utcoffset` does not return ``None`` - If *basic* is true, this uses the ISO 8601 basic format, one of: - - - ``HHMMSS`` - - ``HHMMSS.ffffff`` - - ``HHMMSS+HHMM[SS[.ffffff]]`` - - ``HHMMSS.ffffff+HHMM[SS[.ffffff]]`` + If *basic* is true, this uses the ISO 8601 basic format which omits the colons. The optional argument *timespec* specifies the number of additional components of the time to include (the default is ``'auto'``). @@ -2026,7 +2021,7 @@ Instance methods: .. versionchanged:: 3.6 Added the *timespec* parameter. - .. versionchanged:: 3.14 + .. versionchanged:: next Added the *basic* parameter. diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index c7f5ea54f0c347..1514044f903885 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -435,6 +435,7 @@ datetime (Contributed by Bénédikt Tran in :gh:`118948`.) + os -- diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index c9ed3539a73d2d..23bde37a4d5c7a 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -1570,7 +1570,9 @@ def __hash__(self): # Conversion to string def _tzstr(self, basic): - """Return formatted timezone offset (+xx:xx) or an empty string.""" + """Return formatted timezone offset (+xx:xx) or an empty string. + The colon separator is omitted if *basic* is true. + """ off = self.utcoffset() sep = '' if basic else ':' return _format_offset(off, sep) @@ -1600,7 +1602,7 @@ def isoformat(self, timespec='auto', basic=False): The full format is 'HH:MM:SS.mmmmmm+zz:zz'. By default, the fractional part is omitted if self.microsecond == 0. - If *basic* is true, separators ':' are removed from the output. + If *basic* is true, separators ':' are omitted. The optional argument timespec specifies the number of additional terms of the time to include. Valid options are 'auto', 'hours', From 29b506be25722fadf7d9ad510ab0bd3623d02ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 22 Mar 2025 18:14:55 +0100 Subject: [PATCH 12/13] Update Lib/_pydatetime.py --- Lib/_pydatetime.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index 23bde37a4d5c7a..6efa9eabd8f100 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -2119,7 +2119,9 @@ def astimezone(self, tz=None): # Ways to produce a string. def _tzstr(self, basic): - """Return formatted timezone offset (+xx:xx) or an empty string.""" + """Return formatted timezone offset (+xx:xx) or an empty string. + The colon separator is omitted if *basic* is true. + """ off = self.utcoffset() sep = '' if basic else ':' return _format_offset(off, sep) From 698fa16f88bb618b6b6eae178d4dce6042c4ead1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 22 Mar 2025 18:36:33 +0100 Subject: [PATCH 13/13] Update Modules/_datetimemodule.c --- Modules/_datetimemodule.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 0e9a6b3df3ebbb..1438c608da7357 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3535,7 +3535,7 @@ date_repr(PyObject *op) } static PyObject * -date_isoformat(PyObject *self, PyObject *args, PyObject *kw) +date_isoformat(PyObject *op, PyObject *args, PyObject *kw) { int basic = 0; static char *keywords[] = {"basic", NULL};