From 34b2b44a619da95e25ad05b0f4d83a75a6786ac7 Mon Sep 17 00:00:00 2001 From: Xuehai Pan Date: Sat, 25 Jul 2026 17:56:01 +0800 Subject: [PATCH 1/6] fix(structseq): label unnamed fields in repr as structseq_repr() labeled each visible slot by indexing tp_members by slot position, but tp_members is packed with unnamed fields skipped, so every slot at or after the first unnamed field borrowed the name of a later hidden field. For os.stat_result the integer time slots printed as st_atime/st_mtime/st_ctime (which are actually separate hidden float fields), so the repr disagreed with both st[i] and the .st_atime attribute. Walk the members alongside the visible slots and label unnamed slots positionally as "", which is correct regardless of where the unnamed fields sit among the visible ones. --- Lib/test/test_structseq.py | 34 +++++++++++++++++ ...-07-25-14-06-33.gh-issue-154387.t2fIXs.rst | 6 +++ Modules/_testcapimodule.c | 23 ++++++++++++ Objects/structseq.c | 37 +++++++++++++------ 4 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-07-25-14-06-33.gh-issue-154387.t2fIXs.rst diff --git a/Lib/test/test_structseq.py b/Lib/test/test_structseq.py index 74506fc54de50e7..c2094b1c46df6f1 100644 --- a/Lib/test/test_structseq.py +++ b/Lib/test/test_structseq.py @@ -48,6 +48,40 @@ def test_repr(self): self.assertIn("st_ino=", rep) self.assertIn("st_dev=", rep) + def test_repr_with_unnamed_fields(self): + # gh-154387: unnamed fields must not borrow the name of a later hidden + # field in repr; they are shown as "" instead. + self.assertEqual(os.stat_result.n_sequence_fields, 10) + self.assertEqual(os.stat_result.n_unnamed_fields, 3) + + r = os.stat_result(range(os.stat_result.n_sequence_fields)) + rep = repr(r) + self.assertEqual(rep, + "os.stat_result(st_mode=0, st_ino=1, st_dev=2, st_nlink=3, " + "st_uid=4, st_gid=5, st_size=6, =7, =8, " + "=9)") + + # Regression guard: the hidden field names must not leak into the repr. + self.assertNotIn("st_atime=", rep) + self.assertNotIn("st_mtime=", rep) + self.assertNotIn("st_ctime=", rep) + + # Supplying the hidden fields too must not change the repr. + r_full = os.stat_result(range(os.stat_result.n_fields)) + self.assertEqual(repr(r_full), rep) + self.assertEqual(r_full[7], 7) + self.assertNotEqual(r_full[7], r_full.st_atime) + + def test_repr_with_interspersed_unnamed_fields(self): + # gh-154387: unnamed fields need not be contiguous or trailing; each is + # shown at its own index rather than borrowing a neighbour's name. + _testcapi = import_helper.import_module("_testcapi") + cls = _testcapi.test_structseq_newtype_interspersed_unnamed() + self.assertEqual(cls.n_unnamed_fields, 2) + self.assertEqual(repr(cls(range(5))), + "_testcapi.Interspersed(first=0, =1, third=2, " + "=3, fifth=4)") + def test_concat(self): t1 = time.gmtime() t2 = t1 + tuple(t1) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-07-25-14-06-33.gh-issue-154387.t2fIXs.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-25-14-06-33.gh-issue-154387.t2fIXs.rst new file mode 100644 index 000000000000000..325fc101a62bc07 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-25-14-06-33.gh-issue-154387.t2fIXs.rst @@ -0,0 +1,6 @@ +Fix the :func:`repr` of struct sequence objects that contain unnamed fields, +such as :class:`os.stat_result`. Previously each unnamed field borrowed the +name of a later, hidden field (for example the integer time slots of +``os.stat_result`` were shown as ``st_atime``, ``st_mtime`` and ``st_ctime``, +which are in fact separate hidden float fields); unnamed fields are now shown +as ````, where ``N`` is the sequence index. Patch by Xuehai Pan. diff --git a/Modules/_testcapimodule.c b/Modules/_testcapimodule.c index fb18a866e628128..c03d8c23306fb29 100644 --- a/Modules/_testcapimodule.c +++ b/Modules/_testcapimodule.c @@ -1304,6 +1304,27 @@ test_structseq_newtype_null_descr_doc(PyObject *Py_UNUSED(self), Py_RETURN_NONE; } +static PyObject * +test_structseq_newtype_interspersed_unnamed(PyObject *Py_UNUSED(self), + PyObject *Py_UNUSED(args)) +{ + // gh-154387: unnamed fields may appear anywhere among the visible fields, + // not only contiguous or trailing. + PyStructSequence_Field descr_fields[] = { + {"first", "field 0"}, + {PyStructSequence_UnnamedField, "field 1"}, + {"third", "field 2"}, + {PyStructSequence_UnnamedField, "field 3"}, + {"fifth", "field 4"}, + {NULL, NULL}, + }; + PyStructSequence_Desc descr = { + "_testcapi.Interspersed", NULL, descr_fields, 5, + }; + + return (PyObject *)PyStructSequence_NewType(&descr); +} + typedef struct { PyThread_type_lock start_event; PyThread_type_lock exit_event; @@ -3007,6 +3028,8 @@ static PyMethodDef TestMethods[] = { test_structseq_newtype_doesnt_leak, METH_NOARGS}, {"test_structseq_newtype_null_descr_doc", test_structseq_newtype_null_descr_doc, METH_NOARGS}, + {"test_structseq_newtype_interspersed_unnamed", + test_structseq_newtype_interspersed_unnamed, METH_NOARGS}, {"pyobject_repr_from_null", pyobject_repr_from_null, METH_NOARGS}, {"pyobject_str_from_null", pyobject_str_from_null, METH_NOARGS}, {"pyobject_bytes_from_null", pyobject_bytes_from_null, METH_NOARGS}, diff --git a/Objects/structseq.c b/Objects/structseq.c index 9130fe6a133b1e4..221a1b267430e96 100644 --- a/Objects/structseq.c +++ b/Objects/structseq.c @@ -275,11 +275,11 @@ structseq_repr(PyObject *op) { PyStructSequence *obj = (PyStructSequence *)op; PyTypeObject *typ = Py_TYPE(obj); + Py_ssize_t n_visible_fields = VISIBLE_SIZE(obj); // count 5 characters per item: "x=1, " Py_ssize_t type_name_len = strlen(typ->tp_name); - Py_ssize_t prealloc = (type_name_len + 1 - + VISIBLE_SIZE(obj) * 5 + 1); + Py_ssize_t prealloc = type_name_len + 1 + n_visible_fields * 5 + 1; PyUnicodeWriter *writer = PyUnicodeWriter_Create(prealloc); if (writer == NULL) { return NULL; @@ -293,7 +293,10 @@ structseq_repr(PyObject *op) goto error; } - for (Py_ssize_t i=0; i < VISIBLE_SIZE(obj); i++) { + // Unnamed slots have no tp_members entry; match each member to its slot by + // offset so unnamed slots print "", not a later name (gh-154387). + Py_ssize_t member_index = 0; + for (Py_ssize_t i = 0; i < n_visible_fields; i++) { if (i > 0) { // Write ", " if (PyUnicodeWriter_WriteChar(writer, ',') < 0) { @@ -304,16 +307,26 @@ structseq_repr(PyObject *op) } } - // Write name - const char *name_utf8 = typ->tp_members[i].name; - if (name_utf8 == NULL) { - PyErr_Format(PyExc_SystemError, - "In structseq_repr(), member %zd name is NULL" - " for type %.500s", i, typ->tp_name); - goto error; + // The named member's slot is recovered from its offset. + const PyMemberDef *member = &typ->tp_members[member_index]; + Py_ssize_t named_slot = -1; + if (member->name != NULL) { + // Recover the true slot index. See also function `initialize_members` below. + named_slot = (member->offset - (Py_ssize_t)offsetof(PyStructSequence, ob_item)) + / sizeof(PyObject *); } - if (PyUnicodeWriter_WriteUTF8(writer, name_utf8, -1) < 0) { - goto error; + + // Write the field name, or "" for an unnamed field. + if (named_slot == i) { + if (PyUnicodeWriter_WriteUTF8(writer, member->name, -1) < 0) { + goto error; + } + member_index++; + } + else { + if (PyUnicodeWriter_Format(writer, "", i) < 0) { + goto error; + } } // Write "=" + repr(value) From 23411b4fe0a6f7551819bf032013a1025e3c4fe0 Mon Sep 17 00:00:00 2001 From: Xuehai Pan Date: Sat, 25 Jul 2026 18:25:43 +0800 Subject: [PATCH 2/6] fix: fix missing import due to git rebase --- Lib/test/test_structseq.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_structseq.py b/Lib/test/test_structseq.py index c2094b1c46df6f1..3636f4c588d2a5b 100644 --- a/Lib/test/test_structseq.py +++ b/Lib/test/test_structseq.py @@ -6,7 +6,7 @@ import textwrap import time import unittest -from test.support import script_helper +from test.support import import_helper, script_helper class StructSeqTest(unittest.TestCase): From 9b3f8fe293d09176f039a59756425f2624461957 Mon Sep 17 00:00:00 2001 From: Xuehai Pan Date: Sat, 25 Jul 2026 19:58:21 +0800 Subject: [PATCH 3/6] test(structseq): cover a hidden named field in the interspersed test Lower n_in_sequence to 4 in the _testcapi interspersed helper so the trailing named field ("fifth") is hidden, and expand test_repr_with_interspersed_unnamed_fields to assert the full surface: repr (hidden field excluded), field counts, __match_args__, tuple()/len(), and attribute access to visible and hidden fields (the latter both provided and defaulted to None). --- Lib/test/test_structseq.py | 32 +++++++++++++++++++++++++++++--- Modules/_testcapimodule.c | 7 ++++--- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_structseq.py b/Lib/test/test_structseq.py index 3636f4c588d2a5b..86db91c7308a96f 100644 --- a/Lib/test/test_structseq.py +++ b/Lib/test/test_structseq.py @@ -74,13 +74,39 @@ def test_repr_with_unnamed_fields(self): def test_repr_with_interspersed_unnamed_fields(self): # gh-154387: unnamed fields need not be contiguous or trailing; each is - # shown at its own index rather than borrowing a neighbour's name. + # shown at its own index rather than borrowing a neighbour's name. The + # trailing named field is hidden and must not appear in the repr. _testcapi = import_helper.import_module("_testcapi") cls = _testcapi.test_structseq_newtype_interspersed_unnamed() + self.assertEqual(cls.n_fields, 5) + self.assertEqual(cls.n_sequence_fields, 4) self.assertEqual(cls.n_unnamed_fields, 2) - self.assertEqual(repr(cls(range(5))), + self.assertEqual(cls.__match_args__, ("first", "third")) + + t1 = cls(range(5)) + self.assertEqual(repr(t1), + "_testcapi.Interspersed(first=0, =1, third=2, " + "=3)") + # Only the visible fields form the tuple; the hidden "fifth" is excluded. + self.assertEqual(tuple(t1), (0, 1, 2, 3)) + self.assertEqual(len(t1), 4) + # Named fields are accessible by attribute, including the hidden one. + self.assertEqual(t1.first, 0) + self.assertEqual(t1.third, 2) + self.assertEqual(t1.fifth, 4) + + t2 = cls(range(4)) + self.assertEqual(repr(t2), "_testcapi.Interspersed(first=0, =1, third=2, " - "=3, fifth=4)") + "=3)") + # Only the visible fields form the tuple; the hidden "fifth" is excluded. + self.assertEqual(tuple(t2), (0, 1, 2, 3)) + self.assertEqual(len(t2), 4) + # Named fields are accessible by attribute, including the hidden one. + # The hidden "fifth" is not set, so it returns None. + self.assertEqual(t2.first, 0) + self.assertEqual(t2.third, 2) + self.assertIsNone(t2.fifth) def test_concat(self): t1 = time.gmtime() diff --git a/Modules/_testcapimodule.c b/Modules/_testcapimodule.c index c03d8c23306fb29..43d8ff1f48538e6 100644 --- a/Modules/_testcapimodule.c +++ b/Modules/_testcapimodule.c @@ -1308,8 +1308,9 @@ static PyObject * test_structseq_newtype_interspersed_unnamed(PyObject *Py_UNUSED(self), PyObject *Py_UNUSED(args)) { - // gh-154387: unnamed fields may appear anywhere among the visible fields, - // not only contiguous or trailing. + // gh-154387: unnamed fields may appear anywhere among the visible fields + // (here at indices 1 and 3), and a named field may be hidden (here "fifth" + // at index 4, since n_in_sequence is 4). PyStructSequence_Field descr_fields[] = { {"first", "field 0"}, {PyStructSequence_UnnamedField, "field 1"}, @@ -1319,7 +1320,7 @@ test_structseq_newtype_interspersed_unnamed(PyObject *Py_UNUSED(self), {NULL, NULL}, }; PyStructSequence_Desc descr = { - "_testcapi.Interspersed", NULL, descr_fields, 5, + "_testcapi.Interspersed", NULL, descr_fields, 4, }; return (PyObject *)PyStructSequence_NewType(&descr); From 227e08904d8741bb7b10a9e51b6d00c15ec0e6cd Mon Sep 17 00:00:00 2001 From: Xuehai Pan Date: Sat, 25 Jul 2026 22:00:01 +0800 Subject: [PATCH 4/6] fix: remove `test_` prefix for functions in `_testcapi` --- Lib/test/test_structseq.py | 2 +- Modules/_testcapimodule.c | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_structseq.py b/Lib/test/test_structseq.py index 86db91c7308a96f..487ad7b39c48f8b 100644 --- a/Lib/test/test_structseq.py +++ b/Lib/test/test_structseq.py @@ -77,7 +77,7 @@ def test_repr_with_interspersed_unnamed_fields(self): # shown at its own index rather than borrowing a neighbour's name. The # trailing named field is hidden and must not appear in the repr. _testcapi = import_helper.import_module("_testcapi") - cls = _testcapi.test_structseq_newtype_interspersed_unnamed() + cls = _testcapi.structseq_newtype_interspersed_unnamed() self.assertEqual(cls.n_fields, 5) self.assertEqual(cls.n_sequence_fields, 4) self.assertEqual(cls.n_unnamed_fields, 2) diff --git a/Modules/_testcapimodule.c b/Modules/_testcapimodule.c index 43d8ff1f48538e6..ac57615cd7a78cd 100644 --- a/Modules/_testcapimodule.c +++ b/Modules/_testcapimodule.c @@ -1305,7 +1305,7 @@ test_structseq_newtype_null_descr_doc(PyObject *Py_UNUSED(self), } static PyObject * -test_structseq_newtype_interspersed_unnamed(PyObject *Py_UNUSED(self), +structseq_newtype_interspersed_unnamed(PyObject *Py_UNUSED(self), PyObject *Py_UNUSED(args)) { // gh-154387: unnamed fields may appear anywhere among the visible fields @@ -3029,8 +3029,8 @@ static PyMethodDef TestMethods[] = { test_structseq_newtype_doesnt_leak, METH_NOARGS}, {"test_structseq_newtype_null_descr_doc", test_structseq_newtype_null_descr_doc, METH_NOARGS}, - {"test_structseq_newtype_interspersed_unnamed", - test_structseq_newtype_interspersed_unnamed, METH_NOARGS}, + {"structseq_newtype_interspersed_unnamed", + structseq_newtype_interspersed_unnamed, METH_NOARGS}, {"pyobject_repr_from_null", pyobject_repr_from_null, METH_NOARGS}, {"pyobject_str_from_null", pyobject_str_from_null, METH_NOARGS}, {"pyobject_bytes_from_null", pyobject_bytes_from_null, METH_NOARGS}, From 50a2e22bde628a00b15f9a3e67412e78a2e476ef Mon Sep 17 00:00:00 2001 From: Xuehai Pan Date: Sat, 25 Jul 2026 23:59:46 +0800 Subject: [PATCH 5/6] feat(os): custom repr for os.stat_result showing time fields by name os.stat_result has three unnamed integer time slots (7-9) whose values are mirrored by the named float fields st_atime/st_mtime/st_ctime (statresult_new fills the latter from the former). The generic struct sequence repr shows those unnamed slots as , which is uninformative. Add statresult_repr showing the named fields (st_mode..st_size and the float st_atime/st_mtime/st_ctime) by name, matching the attributes. Install it through the type dict via PyObject_SetAttrString so repr() and st.__repr__() resolve to the same function; a raw tp_repr assignment would leave the __repr__ wrapper readied by PyStructSequence_NewType pointing at the generic repr. Other struct sequences keep the generic . --- Lib/test/test_structseq.py | 93 +++++++++++++------ ...-07-25-14-06-33.gh-issue-154387.t2fIXs.rst | 10 +- Modules/posixmodule.c | 73 +++++++++++++++ 3 files changed, 144 insertions(+), 32 deletions(-) diff --git a/Lib/test/test_structseq.py b/Lib/test/test_structseq.py index 487ad7b39c48f8b..96aa3b337369bc5 100644 --- a/Lib/test/test_structseq.py +++ b/Lib/test/test_structseq.py @@ -49,33 +49,9 @@ def test_repr(self): self.assertIn("st_dev=", rep) def test_repr_with_unnamed_fields(self): - # gh-154387: unnamed fields must not borrow the name of a later hidden - # field in repr; they are shown as "" instead. - self.assertEqual(os.stat_result.n_sequence_fields, 10) - self.assertEqual(os.stat_result.n_unnamed_fields, 3) - - r = os.stat_result(range(os.stat_result.n_sequence_fields)) - rep = repr(r) - self.assertEqual(rep, - "os.stat_result(st_mode=0, st_ino=1, st_dev=2, st_nlink=3, " - "st_uid=4, st_gid=5, st_size=6, =7, =8, " - "=9)") - - # Regression guard: the hidden field names must not leak into the repr. - self.assertNotIn("st_atime=", rep) - self.assertNotIn("st_mtime=", rep) - self.assertNotIn("st_ctime=", rep) - - # Supplying the hidden fields too must not change the repr. - r_full = os.stat_result(range(os.stat_result.n_fields)) - self.assertEqual(repr(r_full), rep) - self.assertEqual(r_full[7], 7) - self.assertNotEqual(r_full[7], r_full.st_atime) - - def test_repr_with_interspersed_unnamed_fields(self): - # gh-154387: unnamed fields need not be contiguous or trailing; each is - # shown at its own index rather than borrowing a neighbour's name. The - # trailing named field is hidden and must not appear in the repr. + # gh-154387: each unnamed field is shown at its own index rather than + # borrowing a neighbour's name. Unnamed fields need not be contiguous or + # trailing. _testcapi = import_helper.import_module("_testcapi") cls = _testcapi.structseq_newtype_interspersed_unnamed() self.assertEqual(cls.n_fields, 5) @@ -108,6 +84,69 @@ def test_repr_with_interspersed_unnamed_fields(self): self.assertEqual(t2.third, 2) self.assertIsNone(t2.fifth) + def test_os_stat_result_has_custom_repr(self): + # gh-154387: os.stat_result has a custom repr (statresult_repr) showing + # the float times st_atime/st_mtime/st_ctime by name, not the unnamed + # integer slots with generic "". + self.assertEqual(os.stat_result.n_sequence_fields, 10) + self.assertEqual(os.stat_result.n_unnamed_fields, 3) + + # All fields supplied: st_atime/st_mtime/st_ctime are the float slots. + r1 = os.stat_result(range(os.stat_result.n_fields)) + rep = repr(r1) + self.assertEqual(rep, + "os.stat_result(st_mode=0, st_ino=1, st_dev=2, st_nlink=3, " + "st_uid=4, st_gid=5, st_size=6, st_atime=10, st_mtime=11, " + "st_ctime=12)") + self.assertNotIn("