Skip to content
Open
Show file tree
Hide file tree
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
Next Next commit
fix(structseq): label unnamed fields in repr as <unnamed@N>
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 "<unnamed@N>", which is correct regardless of where the
unnamed fields sit among the visible ones.
  • Loading branch information
XuehaiPan committed Jul 25, 2026
commit 34b2b44a619da95e25ad05b0f4d83a75a6786ac7
34 changes: 34 additions & 0 deletions Lib/test/test_structseq.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<unnamed@N>" 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, <unnamed@7>=7, <unnamed@8>=8, "
"<unnamed@9>=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, <unnamed@1>=1, third=2, "
"<unnamed@3>=3, fifth=4)")

def test_concat(self):
t1 = time.gmtime()
t2 = t1 + tuple(t1)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 ``<unnamed@N>``, where ``N`` is the sequence index. Patch by Xuehai Pan.
23 changes: 23 additions & 0 deletions Modules/_testcapimodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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},
Expand Down
37 changes: 25 additions & 12 deletions Objects/structseq.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 "<unnamed@i>", 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) {
Expand All @@ -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 "<unnamed@i>" 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, "<unnamed@%zd>", i) < 0) {
goto error;
}
}

// Write "=" + repr(value)
Expand Down
Loading