Skip to content

Malformed builtin __text_signature__ makes inspect.signature(len/sorted/open) raise ValueError #8383

Closed

Description

@youknowone

Summary

inspect.signature() fails on common builtins (len, sorted, open, …) with ValueError: <...> builtin has invalid signature, where CPython returns a proper Signature. The cause is that the auto-generated __text_signature__ values are malformed: they expose the Rust parameter names and omit the positional-only (/) and keyword-only (*) markers, so they are either rejected by inspect or semantically wrong.

Reproduction

import inspect
print(inspect.signature(len))
# RustPython: ValueError: <built-in function len> builtin has invalid signature
# CPython:    (obj, /)

Same ValueError for inspect.signature(sorted) and inspect.signature(open).

The underlying __text_signature__ strings:

callable RustPython CPython 3.14
len '($module, obj)' '($module, obj, /)'
abs '($module, x)' '($module, x, /)'
sorted '($module, iterable, opts)' '($module, iterable, /, *, key=None, reverse=False)'
open '($module, args)' full signature with defaults
iter '($module, iter_target, sentinel)' None

RustPython actually populates __text_signature__ for more builtins than CPython, but the values are generated from the Rust function's parameters (opts, args, iter_target, …) and lack the / / * separators, so:

  • inspect.signature raises ValueError instead of returning a signature, and
  • where it would parse, the signature is wrong (e.g. sorted would show (iterable, opts) rather than (iterable, /, *, key=None, reverse=False)).

Context

This is a follow-up to #2410 ("Builtin functions and methods lack a .__text_signature__ property"), which is closed — the attribute now exists, but its values do not match CPython and break inspect.signature.

Producing fully accurate signatures for every builtin is a large effort (CPython hand-authors them via Argument Clinic), so a reasonable minimum would be that inspect.signature() returns something usable (or raises the same way CPython does for genuinely signature-less builtins) rather than ValueError on len/sorted/open.

Reference

Verified against CPython 3.14.5.


Investigated and drafted by Claude; reviewed before filing.

Activity

  1. leehanjeong commented on Jul 26, 2026

    @leehanjeong
    Contributor

    take

  2. leehanjeong commented on Aug 2, 2026

    @leehanjeong
    Contributor

    Status: the reported ValueError no longer reproduces

    >>> import inspect; inspect.signature(len)
    (obj, /)             # CPython 3.14
    (module, /, obj)     # RustPython today; was ValueError when this issue was filed

    #8390 (1613741f8) made TokenKind::Unknown map to TOKEN_OP, so tokenize yields $ as an OP token and _signature_strip_non_python_syntax removes it. Only the parse changed; the signature is still wrong. The same commit added an expectedFailure to test_autospec_on_bound_builtin_function for this reason, since time.ctime gained a parseable but incorrect signature. That is the one currently failing CPython test attributable to this issue.

    Current state

    All 45 builtin functions common to both, against CPython 3.14:

    Behaviour matching CPython 0 / 45
    Phantom module parameter 45 / 45
    Missing / marker 45 / 45
    Still raising ValueError 2 (round, sum)

    Root cause

    Every builtin signature comes from text_signature() in crates/derive-impl/src/util.rs, which reads the Rust parameter list and joins the names with ", ". Four defects.

    (1) $module is emitted with nothing able to strip it. CPython emits it too, but its C functions receive the module as self, so __self__ is the module and _signature_fromstr drops the parameter. RustPython's #[pyfunction]s take no module argument, PyNativeFunction.zelf (the field behind __self__) is None, and the parameter survives as a real one.

    (2) / is never emitted. util.rs contains no code that produces it. Plain arguments bind through take_positional() (function/argument.rs) and are positional-only in practice, so inspect reporting POSITIONAL_OR_KEYWORD contradicts len(obj=[1,2]) raising TypeError.

    (3) Non-identifier patterns are stringified verbatim.

    fn round(RoundArgs { number, ndigits }: RoundArgs, vm: &VirtualMachine) -> ...
    >>> round.__text_signature__
    '($module, RoundArgs { number, ndigits })'

    func_sig() falls back to quote!(#other).to_string() for any non-ident pattern, producing text that is not parseable Python. round and sum here, plus os.pathconf, binascii.b2a_base64 and binascii.b2a_uu elsewhere in the stdlib.

    (4) FromArgs structs collapse to a single name.

    fn sorted(iterable: PyObjectRef, opts: SortOptions, vm: &VirtualMachine) -> ...
    >>> sorted.__text_signature__
    '($module, iterable, opts)'   # CPython: '($module, iterable, /, *, key=None, reverse=False)'

    opts is not a parameter. SortOptions expands to key=None, reverse=False, both keyword-only. The proc macro sees only the token SortOptions and cannot resolve the type, so it can neither expand it nor tell it apart from an ordinary PyObjectRef. Option A below cannot address this.

    Options considered

    A. Improve the generator. Bail on non-ident patterns and OptionalArg, drop $module, emit /, rename parameters to CPython's.

    B. Source signatures from the doc DB. crates/doc/generate.py already introspects real CPython for docstrings, and __text_signature__ sits on the same objects. Under B, (3) and (4) do not arise: the string never passes through the macro, so opts and RoundArgs { .. } never appear. Copying alone does not fix (1), since __self__ is still None, but stripping the leading parameter at store time does:

    remove the first ($-prefixed) parameter
    if a bare `/` is then leading, remove it too
    

    The second step applies to signatures shaped ($module, /, ...), where the / marked only the removed parameter (176 across the stdlib).

    Measured on the 45 builtins:

    A B
    Behaviour matching CPython 24 / 45 45 / 45
    ValueError remaining 0 0
    Struct cases (sorted, open, …) wrong correct

    RustPython binds some of these through FromArgs structs CPython knows nothing about, so I checked the copied signatures are truthful: all 37 keyword-capable parameters documented across the 10 affected builtins are accepted or rejected identically by both, and the defaults agree behaviourally.

    Two further notes on B. generate.py can reassemble name(sig)\n--\n\ndoc, the format get_text_signature_from_internal_doc already parses, so the consuming side needs no change. Scope reaches beyond builtins to the rest of the stdlib and to type methods (6 of 10,135 currently carry a signature), at +186 KB on data.inc.rs.

    Proposal

    Take B, keeping A's generator as the fallback for entries the DB does not cover. Staged so the first PR is behaviour-neutral:

    1. generate.py emits the combined form, with the leading parameter stripped for module-level functions.
    2. The macro uses a DB entry as-is when it already carries a signature, otherwise falls back to the current generator.
    3. Regenerating data.inc.rs turns it on.

    Methods need no stripping, since __self__ is already correct for bound methods and $self signatures resolve identically to CPython.

    Questions

    1. Is extending the doc DB to carry signatures acceptable?
    2. Stripping $module makes __text_signature__ differ textually from CPython ('(obj, /)' vs '($module, obj, /)') while inspect.signature() matches exactly. Acceptable, or would you prefer fixing __self__? Setting zelf to the module would prepend it on every call and disable the CallLen/CallIsinstance specializations that key on native.zelf.is_none(), so I would rather file that separately.
    3. Could you run update-doc-db once the generator change lands? Contributors cannot dispatch it.

    Investigated and drafted by Claude; reviewed before posting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

C-compatA discrepancy between RustPython and CPythonz-ca-2026Tag to track Contribution Academy 2026

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions