Skip to content

Correct the reported signatures of several public callables - #1197

Merged
sobolevn merged 3 commits into
mainfrom
fix-text-signatures
Sep 29, 2026
Merged

sobolevn merged 3 commits into
mainfrom
fix-text-signatures

Conversation

@Siyet

@Siyet Siyet commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

inspect.signature and help() are built from a text signature: the first line of a C-level callable's docstring, before the -- separator. That line has to parse as a Python parameter list, and it has to match how the callable is really called. Fourteen of those lines in _core.c fail one or the other, and between them they cover sixteen callables.

Three do not parse. msgspec.Raw declares Raw(msg="", /), which is two adjacent C string literals concatenating to Raw(msg=, /). msgspec.StructMeta declares *, **struct_config. msgspec.structs.replace declares replace(struct, / **changes), missing a comma. For Raw, inspect.signature raises ValueError: <class 'msgspec.Raw'> builtin has invalid signature on every supported version, and the API docs render class msgspec.Raw with no parameters. replace behaves that way from Python 3.12 onwards: up to 3.11 the stray / was stripped along with the rest of the non-Python syntax, leaving something that parsed, and from 3.12 it is kept. StructMeta has a __signature__ getset, so its string only reached help() and docs/api.rst, which hard-codes the same text.

Four more parse, but describe the wrong calling convention. Keyword arguments are rejected at runtime by msgspec.msgpack.Ext, msgspec.structs.asdict, msgspec.structs.astuple and msgspec.structs.force_setattr, yet their parameters are marked as positional-or-keyword. The stubs carry the same omission, so msgspec.structs.asdict(struct=p) passes type checking and raises TypeError when run.

The remaining seven begin with a bare self rather than CPython's $self marker, so the instance becomes a parameter of the bound method: inspect.signature(encoder.encode) gives (self, obj, /), and binding a single argument to that fails although encoder.encode(obj) is the correct call. Nine bound methods are affected: encode, encode_into and encode_lines on json.Encoder, encode and encode_into on msgpack.Encoder, decode and decode_lines on json.Decoder, decode on msgpack.Decoder, and Raw.copy. In Raw.copy that self carries no trailing / either, so it was reported as keyword-addressable on top of being reported at all.

Ten stub declarations gain the matching /.

The unit tests assert the exact reported signature of fifteen callables against a literal, and compile the StructMeta text signature. Five negative checks in tests/typing/basic_typing_examples.py pin the tightened stubs in that file's existing idiom. stubtest couples four of the ten stub edits to the C side by itself; the Ext.__init__ edit is pinned by nothing else, and the five self edits have no observable effect to pin. On current main, fifteen of the sixteen unit cases fail on Python 3.11 and all sixteen on 3.14, and mypy, pyright and pyrefly each flag all five negative checks as unused ignores.

Not addressed here: four decoder strings give type='Any' as a default, where the real default is typing.Any rather than that string. A text signature cannot carry that value. A default written as a name is evaluated, and the result is kept only if it is one of a handful of scalar types, so type=Any and type=typing.Any alike make the whole signature invalid and inspect.signature then raises for the callable. Correcting this one means going outside the text signature.

This continues #1116, which corrected nine such strings.

@Siyet

Siyet commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

@sobolevn, this one touches the type stubs, so a look from you would be welcome if you have time. Ten declarations gain a /. For nine of them the runtime already rejects the keyword form; the tenth is Struct.__replace__, where self= is routed into **changes and only fails because no field is named self. The five negative checks added to basic_typing_examples.py are the part I would most like a second opinion on.

@sobolevn sobolevn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Two questions.

Comment thread tests/unit/test_common.py

def test_structmeta_text_signature_is_valid():
"""`StructMeta` has a `__signature__` getset, so only the text form is checkable"""
text_signature = msgspec.StructMeta.__text_signature__

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to not test __text_signature__, it is an implementation detail. Only test the inspect.signature() result.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is what the suite does: fifteen callables are asserted through inspect.signature, and __text_signature__ appears in one test, for StructMeta. It is the one declaration inspect.signature cannot reach:

>>> inspect.signature(msgspec.StructMeta)
TypeError: unexpected object <attribute '__signature__' of 'msgspec._core.StructMeta' objects> in __signature__ attribute

>>> help(msgspec.StructMeta)   # on main
class StructMeta(builtins.type)
 |  StructMeta(name, bases, namespace, /, *, **struct_config)

pydoc catches that TypeError and falls back to __text_signature__, so the broken line still reaches users. Of fourteen corrected declarations, thirteen do show through inspect.signature; this is the one that does not.

So it is the attribute, the help() output that renders it, or no coverage for that line. I took the attribute as the least indirect, and I am happy to switch.

Comment thread src/msgspec/__init__.pyi
) -> None: ...
def __rich_repr__(self) -> list[tuple[str, Any]]: ...
def __replace__(self, **changes: Any) -> Self: ...
def __rich_repr__(self, /) -> list[tuple[str, Any]]: ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we treat self this way? Should it be a regular parameter unless there are other pos-only parameters?

@provinzkraut provinzkraut Sep 21, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it's technically more correct I guess since self can only be passed positionally? Passing it as self=... doesn't work. I'm not sure it's necessary, but it's not technically wrong (just a bit odd, because you usually don't pass self explicitly at all since you don't do Struct.__replace__(<struct instance>)).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It really depends on the exact method / class:

>>> class Unset(enum.Enum):
...     def __bool__(self): 
...         print(self)
...         return False
...         
>>> Unset.__bool__(self=1)
1
False

Some allow this, C methods usually don't 😅

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, interesting. StructMeta doesn't allow it, which is what I'd expected. Hm. Well, we shouldn't lie about this then. Maybe just get rid of it, since it's not really beneficial?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five reject it:

>>> msgspec.Struct.__replace__(self=Example(1))
TypeError: unbound method _StructMixin.__replace__() needs an argument
>>> msgspec.UnsetType.__bool__(self=msgspec.UNSET)
TypeError: descriptor '__bool__' of 'msgspec.UnsetType' object needs an argument

Struct.__rich_repr__, Meta.__rich_repr__ and Raw.copy say the same. Your Unset.__bool__(self=1) is a Python level function, where self is ordinary parameter; these five are C level.

self= is not dead as a keyword on __replace__ though. It just never means the instance:

>>> class WithSelf(msgspec.Struct):
...     self: int
>>> WithSelf(1).__replace__(self=7)
WithSelf(self=7)

def __replace__(self, /, **changes) says exactly that. Without the slash, mypy accepts Struct.__replace__(self=instance), which raises at runtime.

@provinzkraut that is where the slash earns its keep. On the other four it rules out a call nobody writes, so I am fine dropping those if you prefer. Either way Struct.__replace__.__text_signature__ is None here and on main, so the slash rests on call behavior alone.

@sobolevn sobolevn mentioned this pull request Sep 29, 2026

@sobolevn sobolevn left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this is fine to be merged as-is, but later we would need to add possible skips when python / extension is compiled without docstrings or when -OO is used. I remember that this can cause problems with __text_signature__ :)

@sobolevn
sobolevn added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 2f0b50d Sep 29, 2026
24 checks passed
@sobolevn
sobolevn deleted the fix-text-signatures branch September 29, 2026 10:51
pull Bot pushed a commit to Mu-L/msgspec that referenced this pull request Sep 29, 2026
Entries for msgspec#1194, msgspec#1196, msgspec#1197, msgspec#1199 and msgspec#1209 are missing from the
changelog of the 0.22.0 release. Of the changes merged after msgspec#1211
moved the unreleased entries into the 0.22.0 section, only msgspec#1207 added
its own entry. This adds the missing entries next to the related
entries in that section, and marks msgspec#1196 as a breaking change: objects
that expose `__dataclass_fields__` only through instance attribute
access are no longer encoded as dataclasses.

Co-authored-by: Tseluiko Aleksandr <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants