Skip to content

gh-154387: fix structseq_repr by labeling unnamed fields in repr as <unnamed@N>#154687

Open
XuehaiPan wants to merge 8 commits into
python:mainfrom
XuehaiPan:fix-structseq-unnamed
Open

gh-154387: fix structseq_repr by labeling unnamed fields in repr as <unnamed@N>#154687
XuehaiPan wants to merge 8 commits into
python:mainfrom
XuehaiPan:fix-structseq-unnamed

Conversation

@XuehaiPan

@XuehaiPan XuehaiPan commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

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.

Before this PR, the member slots are accessed incorrectly in repr when there are unnamed fields. It can cause a spurious SystemError when the index reaches the number of named fields (n_fields - n_unnamed_fields), or display the unnamed fields with <name>=<value> using a later field's name.

For example, a structseq type with WithUnnamed(first, <unnamed>, third, <unnamed>) + [hidden .fifth], before this PR:

>>> seq = WithUnnamed(range(5))
>>> repr(seq)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    repr(cls(range(5)))
    ~~~~^^^^^^^^^^^^^^^
SystemError: In structseq_repr(), member 3 name is NULL for type WithUnnamed
# -> WithUnnamed(first=0, third=1, fifth=2, <reach NULL-terminated tp_members' end for member 3>)

After this PR:

>>> seq = WithUnnamed(range(5))
>>> seq
WithUnnamed(first=0, <unnamed@1>=1, third=2, <unnamed@3>=3)
>>> seq.fifth
4

Before this PR, for os.stat_result, the unnamed slots printed as the following field names st_atime/st_mtime/st_ctime.

>>> st = os.stat_result(range(os.stat_result.n_fields))
>>> st
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=7, st_mtime=8, st_ctime=9)  # borrow the following fields' names but use the unnamed fields' values
>>> st[7]
7
>>> st.st_atime
10

>>> st = os.stat_result(tuple(range(10)) + (7.1, 8.2, 9.3))
>>> st
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=7, st_mtime=8, st_ctime=9)
>>> st[7]        # index = 7 is int
7
>>> st.st_atime  # index = 10 is float
7.1

After this PR, a new custom statresult_repr is added analogously to statresult_new. The custom repr shows the fields 10-12 (always initialized in statresult_new) instead of generic <unnamed@N>.

>>> st = os.stat_result(range(os.stat_result.n_fields))
>>> st
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)  # make the field names and values match
>>> st[7]
7
>>> st.st_atime
10

>>> st = os.stat_result(tuple(range(10)) + (7.1, 8.2, 9.3))
>>> st
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=7.1, st_mtime=8.2, st_ctime=9.3)
>>> st[7]        # index = 7 is int
7
>>> st.st_atime  # index = 10 is float
7.1

>>> os.stat(os.curdir)
os.stat_result(st_mode=16877, st_ino=429294614, st_dev=16777232, st_nlink=58, st_uid=501, st_gid=20, st_size=1856,
               st_atime=1784996316.4868717, st_mtime=1784996316.472091, st_ctime=1784996316.472091)

Without the custom statresult_repr, the generic <unnamed@N> will be used, which is not that legible.

>>> st = os.stat_result(range(os.stat_result.n_fields))
>>> st
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)

>>> os.stat(os.curdir)
os.stat_result(st_mode=16877, st_ino=429294614, st_dev=16777232, st_nlink=58, st_uid=501, st_gid=20, st_size=1856,
               <unnamed@7>=1784996316, <unnamed@8>=1784996316, <unnamed@9>=1784996316)

Fixes #154387

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.
@XuehaiPan XuehaiPan changed the title gh-154387: label unnamed fields in repr as <unnamed@N> gh-154387: fix structseq_repr by labeling unnamed fields in repr as <unnamed@N> Jul 25, 2026
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).
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 <unnamed@7..9>, 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 <unnamed@N>.
@XuehaiPan
XuehaiPan marked this pull request as ready for review July 25, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

structseq_repr mislabels unnamed PyStructSequence fields (e.g. os.stat_result slots 7–9)

1 participant