diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index f3c4ef9199075c9..073675e24c9804a 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2704,6 +2704,9 @@ convenience. | | (empty string if the object is | +06:34:15, | | | | naive). | -03:07:12.345216 | | +-----------+--------------------------------+------------------------+-------+ +| ``%s`` | UNIX timestamp (the number of | 1404212722 | \(11) | +| | seconds since the Epoch). | | | ++-----------+--------------------------------+------------------------+-------+ The full set of format codes supported varies across platforms, because Python calls the platform C library's :c:func:`strftime` function, and platform @@ -2924,6 +2927,12 @@ Notes: :meth:`~.datetime.strptime` calls using a format string containing ``%e`` without a year now emit a :exc:`DeprecationWarning`. +(11) + .. versionadded:: 3.15 + ``%s`` format code support is added for only :meth:`~.datetime.strftime` + and :meth:`~.date.strftime`. In previous versions, ``%s`` format code + behavior was undefined and varied across platforms. + .. rubric:: Footnotes .. [#] If, that is, we ignore the effects of relativity. diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index c47f4e671b39def..9cee02c7c0fb7c3 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -220,6 +220,7 @@ def _wrap_strftime(object, format, timetuple): zreplace = None # the string to use for %z colonzreplace = None # the string to use for %:z Zreplace = None # the string to use for %Z + sreplace = None # the string to use for %s # Scan format for %z, %:z and %Z escapes, replacing as needed. newformat = [] @@ -270,6 +271,10 @@ def _wrap_strftime(object, format, timetuple): # strftime is going to have at this: escape % Zreplace = s.replace('%', '%%') newformat.append(Zreplace) + elif ch == 's' and hasattr(object, "timestamp"): + if sreplace is None: + sreplace = str(_math.floor(object.timestamp())) + newformat.append(sreplace) # Note that datetime(1000, 1, 1).strftime('%G') == '1000' so # year 1000 for %G can go on the fast path. elif (ch in 'YGFC' and timetuple[0] < 1000 and diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index c11e9c068bed3bd..e8546d97c3e5701 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -3214,6 +3214,31 @@ def test_strftime_special(self): self.assertEqual(t.strftime('\0%c\0%B'), f'\0{s1}\0{s2}') self.assertEqual(t.strftime('%c\0%B\0'), f'{s1}\0{s2}\0') + @support.run_with_tz('EST+05EDT,M3.2.0,M11.1.0') + def test_strftime_naive_s(self): + t = self.theclass(1970, 1, 1) + self.assertEqual(t.strftime('%s'), '18000') + t = self.theclass(1970, 1, 1, 1, 2, 3, 4) + self.assertEqual(t.strftime('%s'), + str(18000 + 3600 + 2*60 + 3)) + + def test_strftime_respect_timezone_s(self): + t = self.theclass(1970, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc) + self.assertEqual(t.strftime('%s'), '0') + + t = self.theclass(1970, 1, 1, 1, 2, 3, 600000, tzinfo=timezone.utc) + self.assertEqual(t.strftime('%s'), + str(3600 + 2*60 + 3)) + + t = self.theclass(1970, 1, 1, 1, 2, 3, 400000, + tzinfo=timezone(timedelta(hours=-5), 'EST')) + self.assertEqual(t.strftime('%s'), + str(18000 + 3600 + 2*60 + 3)) + + t = self.theclass(1969, 1, 1, 0, 0, 0, 700000, + tzinfo=timezone.utc) + self.assertEqual(t.strftime('%s'), '-31536000') + def test_extract(self): dt = self.theclass(2002, 3, 4, 18, 45, 3, 1234) self.assertEqual(dt.date(), date(2002, 3, 4)) diff --git a/Misc/NEWS.d/next/Library/2026-08-17-23-11-40.gh-issue-56959.abcdef.rst b/Misc/NEWS.d/next/Library/2026-08-17-23-11-40.gh-issue-56959.abcdef.rst new file mode 100644 index 000000000000000..767f78a98cc6ce6 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-17-23-11-40.gh-issue-56959.abcdef.rst @@ -0,0 +1 @@ +Add cross-platform support for the ``%s`` strftime format code to the :mod:`datetime` module. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index cd02b298b406e6a..f3bca496124cff5 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1871,6 +1871,32 @@ make_freplacement(PyObject *object) return PyUnicode_FromString(freplacement); } +static PyObject * +make_sreplacement(PyObject *object) +{ + PyObject *timestamp = PyObject_CallMethodNoArgs(object, &_Py_ID(timestamp)); + if (timestamp == NULL) { + return NULL; + } + + PyObject *math = PyImport_ImportModule("math"); + if (math == NULL) { + Py_DECREF(timestamp); + return NULL; + } + + PyObject *temp = PyObject_CallMethod(math, "floor", "O", timestamp); + Py_DECREF(math); + Py_DECREF(timestamp); + if (temp == NULL) { + return NULL; + } + + PyObject *sreplacement = PyObject_Str(temp); + Py_DECREF(temp); + return sreplacement; +} + /* I sure don't want to reproduce the strftime code from the time module, * so this imports the module and calls it. All the hair is due to * giving special meanings to the %z, %:z, %Z and %f format codes via a @@ -1888,6 +1914,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, PyObject *colonzreplacement = NULL; /* py string, replacement for %:z */ PyObject *Zreplacement = NULL; /* py string, replacement for %Z */ PyObject *freplacement = NULL; /* py string, replacement for %f */ + PyObject *sreplacement = NULL; /* py string, replacement for %s */ assert(object && format && timetuple); assert(PyUnicode_Check(format)); @@ -1964,6 +1991,15 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, } replacement = freplacement; } + else if (ch == 's' && PyDateTime_Check(object)) { + /* format timestamp */ + if (sreplacement == NULL) { + sreplacement = make_sreplacement(object); + if (sreplacement == NULL) + goto Error; + } + replacement = sreplacement; + } else if (normalize_century() && (ch == 'Y' || ch == 'G' || ch == 'F' || ch == 'C')) { @@ -2055,6 +2091,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, Py_XDECREF(zreplacement); Py_XDECREF(colonzreplacement); Py_XDECREF(Zreplacement); + Py_XDECREF(sreplacement); Py_XDECREF(strftime); return result;