From 6725e25a326be501dde7111dcf7b73b94af894d6 Mon Sep 17 00:00:00 2001 From: leehanjeong Date: Mon, 10 Aug 2026 16:49:24 +0900 Subject: [PATCH 1/6] Drop the $module marker from generated __text_signature__ CPython's C functions receive the module as their first argument, so PyCFunction.__self__ is the module and inspect strips the $module parameter when building a Signature. A #[pyfunction] takes no such argument, PyNativeFunction::zelf is None, and inspect has nothing to strip, so the marker surfaced as a parameter that does not exist: inspect.signature(len) (module, /, obj) # was (obj) # now All 45 builtins shared with CPython carried it. Methods are unaffected; their $self marker comes from func_sig and both branches now produce the same string. Assisted-by: Claude Code:claude-opus-5 --- crates/derive-impl/src/util.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/derive-impl/src/util.rs b/crates/derive-impl/src/util.rs index 1ee878c1313..4713215670d 100644 --- a/crates/derive-impl/src/util.rs +++ b/crates/derive-impl/src/util.rs @@ -732,13 +732,12 @@ where // Best effort attempt to generate a template from which a // __text_signature__ can be created. +// +// Unlike CPython, a `#[pyfunction]` doesn't take the module as an argument, +// so there's no module to mark with `$module`. pub(crate) fn text_signature(sig: &Signature, name: &str) -> String { let signature = func_sig(sig); - if signature.starts_with("$self") { - format!("{name}({signature})") - } else { - format!("{}({}, {})", name, "$module", signature) - } + format!("{name}({signature})") } pub(crate) fn infer_native_call_flags(sig: &Signature, drop_first_typed: usize) -> TokenStream { From 7978ea46629565f60ae3d479dc7f9c42a5fad485 Mon Sep 17 00:00:00 2001 From: leehanjeong Date: Mon, 10 Aug 2026 16:55:22 +0900 Subject: [PATCH 2/6] Mark generated __text_signature__ parameters positional-only Arguments bind through `FuncArgs::take_positional`, which pops from the positional list and never consults the keyword map, so a #[pyfunction] argument cannot be passed by name: >>> len(obj=[1, 2]) TypeError The generated signature omitted the `/` marker, so inspect reported those parameters as POSITIONAL_OR_KEYWORD, contradicting the call above. Emit the marker, except for `*args`/`**kwargs`, which cannot be followed by `/`, and for empty parameter lists. 14 of the 45 builtins shared with CPython now report an identical signature, up from 0. Assisted-by: Claude Code:claude-opus-5 --- crates/derive-impl/src/util.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/derive-impl/src/util.rs b/crates/derive-impl/src/util.rs index 4713215670d..a22165b0a36 100644 --- a/crates/derive-impl/src/util.rs +++ b/crates/derive-impl/src/util.rs @@ -737,7 +737,14 @@ where // so there's no module to mark with `$module`. pub(crate) fn text_signature(sig: &Signature, name: &str) -> String { let signature = func_sig(sig); - format!("{name}({signature})") + // Arguments bind through `FuncArgs::take_positional`, which never consults + // the keyword map, so they are positional-only. `*args`/`**kwargs` cannot be + // followed by `/`, and an empty parameter list has nothing to mark. + if signature.is_empty() || signature.contains('*') { + format!("{name}({signature})") + } else { + format!("{name}({signature}, /)") + } } pub(crate) fn infer_native_call_flags(sig: &Signature, drop_first_typed: usize) -> TokenStream { From 34eab71c78de4c46ab1d7f0ba146e882580ebac7 Mon Sep 17 00:00:00 2001 From: leehanjeong Date: Mon, 10 Aug 2026 17:01:25 +0900 Subject: [PATCH 3/6] Emit no __text_signature__ when an argument has no name Arguments bound by a destructuring pattern, e.g. fn round(RoundArgs { number, ndigits }: RoundArgs, ..) have no name to report, and func_sig stringified the pattern verbatim: >>> round.__text_signature__ '($module, RoundArgs { number, ndigits })' That is not valid Python, so inspect.signature() raised "builtin has invalid signature". Return None instead, which leaves __text_signature__ unset and makes inspect raise "no signature found", the same as for a CPython builtin that has no signature. Affects round, sum, os.pathconf, binascii.b2a_base64 and binascii.b2a_uu. Their docstrings are unchanged; only the signature prefix is dropped. Assisted-by: Claude Code:claude-opus-5 --- crates/derive-impl/src/pyclass.rs | 5 +- crates/derive-impl/src/pymodule.rs | 16 ++++--- crates/derive-impl/src/util.rs | 77 +++++++++++++++++------------- 3 files changed, 56 insertions(+), 42 deletions(-) diff --git a/crates/derive-impl/src/pyclass.rs b/crates/derive-impl/src/pyclass.rs index 809d3164b4a..c612572d3dc 100644 --- a/crates/derive-impl/src/pyclass.rs +++ b/crates/derive-impl/src/pyclass.rs @@ -1096,7 +1096,10 @@ where args.attrs.push(allow_attr); } - let doc = args.attrs.doc().map(|doc| format_doc(&sig_doc, &doc)); + let doc = args.attrs.doc().map(|doc| match &sig_doc { + Some(sig_doc) => format_doc(sig_doc, &doc), + None => doc, + }); args.context.method_items.add_item(MethodNurseryItem { py_name, cfgs: args.cfgs.to_vec(), diff --git a/crates/derive-impl/src/pymodule.rs b/crates/derive-impl/src/pymodule.rs index 32d7a0fa6bf..20c94abe94f 100644 --- a/crates/derive-impl/src/pymodule.rs +++ b/crates/derive-impl/src/pymodule.rs @@ -524,7 +524,7 @@ struct FunctionNurseryItem { py_names: Vec, cfgs: Vec, ident: Ident, - doc: String, + doc: Option, call_flags: TokenStream, } @@ -556,8 +556,10 @@ impl ToTokens for ValidatedFunctionNursery { let cfgs = &item.cfgs; let cfgs = quote!(#(#cfgs)*); let py_names = &item.py_names; - let doc = &item.doc; - let doc = quote!(Some(#doc)); + let doc = match &item.doc { + Some(doc) => quote!(Some(#doc)), + None => quote!(None), + }; let flags = &item.call_flags; inner_tokens.extend(quote![ @@ -671,10 +673,10 @@ impl ModuleItem for FunctionItem { .copied() .map(str::to_owned) }); - let doc = if let Some(doc) = doc { - format_doc(&sig_doc, &doc) - } else { - sig_doc + let doc = match (sig_doc, doc) { + (Some(sig_doc), Some(doc)) => Some(format_doc(&sig_doc, &doc)), + (Some(sig_doc), None) => Some(sig_doc), + (None, doc) => doc, }; let py_names = { diff --git a/crates/derive-impl/src/util.rs b/crates/derive-impl/src/util.rs index a22165b0a36..1e734d94a50 100644 --- a/crates/derive-impl/src/util.rs +++ b/crates/derive-impl/src/util.rs @@ -735,16 +735,17 @@ where // // Unlike CPython, a `#[pyfunction]` doesn't take the module as an argument, // so there's no module to mark with `$module`. -pub(crate) fn text_signature(sig: &Signature, name: &str) -> String { - let signature = func_sig(sig); +pub(crate) fn text_signature(sig: &Signature, name: &str) -> Option { + let signature = func_sig(sig)?; // Arguments bind through `FuncArgs::take_positional`, which never consults // the keyword map, so they are positional-only. `*args`/`**kwargs` cannot be // followed by `/`, and an empty parameter list has nothing to mark. - if signature.is_empty() || signature.contains('*') { + let signature = if signature.is_empty() || signature.contains('*') { format!("{name}({signature})") } else { format!("{name}({signature}, /)") - } + }; + Some(signature) } pub(crate) fn infer_native_call_flags(sig: &Signature, drop_first_typed: usize) -> TokenStream { @@ -818,37 +819,45 @@ pub(crate) fn infer_native_call_flags(sig: &Signature, drop_first_typed: usize) } } -fn func_sig(sig: &Signature) -> String { - sig.inputs - .iter() - .filter_map(|arg| { - let arg = match arg { - FnArg::Typed(typed) => typed, - FnArg::Receiver(_) => return Some("$self".to_owned()), - }; - let ty = arg.ty.as_ref(); - let ty = quote!(#ty).to_string(); - if ty == "FuncArgs" { - return Some("*args, **kwargs".to_owned()); - } - if ty.starts_with('&') && ty.ends_with("VirtualMachine") { - return None; - } - let ident = match arg.pat.as_ref() { - syn::Pat::Ident(p) => p.ident.to_string(), - // FIXME: other => unreachable!("function arg pattern must be ident but found `{}`", quote!(fn #ident(.. #other ..))), - other => quote!(#other).to_string(), - }; - if ident == "zelf" { - return Some("$self".to_owned()); - } - if ident == "vm" { - unreachable!("type &VirtualMachine(`{ty}`) must be filtered already"); +/// Returns None when an argument has no name to report, in which case no +/// signature can be generated for the function. +fn func_sig(sig: &Signature) -> Option { + let mut params = Vec::new(); + for arg in &sig.inputs { + let arg = match arg { + FnArg::Typed(typed) => typed, + FnArg::Receiver(_) => { + params.push("$self".to_owned()); + continue; } - Some(ident) - }) - .collect::>() - .join(", ") + }; + let ty = arg.ty.as_ref(); + let ty = quote!(#ty).to_string(); + if ty == "FuncArgs" { + params.push("*args, **kwargs".to_owned()); + continue; + } + if ty.starts_with('&') && ty.ends_with("VirtualMachine") { + continue; + } + // An argument bound by a destructuring pattern, e.g. + // `fn round(RoundArgs { number, ndigits }: RoundArgs, ..)`, has no name + // to report. Stringifying the pattern would emit Rust syntax, which + // makes inspect.signature() raise "invalid signature". + let syn::Pat::Ident(pat) = arg.pat.as_ref() else { + return None; + }; + let ident = pat.ident.to_string(); + if ident == "zelf" { + params.push("$self".to_owned()); + continue; + } + if ident == "vm" { + unreachable!("type &VirtualMachine(`{ty}`) must be filtered already"); + } + params.push(ident); + } + Some(params.join(", ")) } pub(crate) fn format_doc(sig: &str, doc: &str) -> String { From 7b614ca57cacd58707eb6f428dde6554659727eb Mon Sep 17 00:00:00 2001 From: leehanjeong Date: Mon, 10 Aug 2026 17:07:16 +0900 Subject: [PATCH 4/6] Name builtin parameters after CPython These parameters are positional-only, so their names only ever appear in __text_signature__ and cannot be used at a call site. Naming them after CPython makes the generated signatures directly comparable: bin x -> number ord string -> character divmod a, b -> x, y setattr attr -> name delattr attr -> name hasattr attr -> name isinstance typ -> class_or_tuple issubclass subclass,typ -> cls, class_or_tuple aiter iter_target -> async_iterable 23 of the 45 builtins shared with CPython now report an identical signature, up from 0 before this branch. The remainder need FromArgs to report the parameters of its own structs, which is left for a follow-up. Add extra_tests/snippets/builtin_signature.py covering the phantom module parameter, the positional-only marker, the names above, and the signature-less builtins. Assisted-by: Claude Code:claude-opus-5 --- crates/vm/src/stdlib/builtins.rs | 50 ++++++++++-------- extra_tests/snippets/builtin_signature.py | 64 +++++++++++++++++++++++ 2 files changed, 93 insertions(+), 21 deletions(-) create mode 100644 extra_tests/snippets/builtin_signature.py diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 35f404f0f3b..e5d4854434a 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -74,8 +74,8 @@ mod builtins { } #[pyfunction] - fn bin(x: PyIntRef) -> String { - let x = x.as_bigint(); + fn bin(number: PyIntRef) -> String { + let x = number.as_bigint(); if x.is_negative() { format!("-0b{:b}", x.abs()) } else { @@ -391,11 +391,11 @@ mod builtins { } #[pyfunction] - fn delattr(obj: PyObjectRef, attr: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - let attr = attr.try_to_ref::(vm).map_err(|_e| { + fn delattr(obj: PyObjectRef, name: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + let attr = name.try_to_ref::(vm).map_err(|_e| { vm.new_type_error(format!( "attribute name must be string, not '{}'", - attr.class().name() + name.class().name() )) })?; obj.del_attr(attr, vm) @@ -407,8 +407,8 @@ mod builtins { } #[pyfunction] - fn divmod(a: PyObjectRef, b: PyObjectRef, vm: &VirtualMachine) -> PyResult { - vm._divmod(&a, &b) + fn divmod(x: PyObjectRef, y: PyObjectRef, vm: &VirtualMachine) -> PyResult { + vm._divmod(&x, &y) } #[derive(FromArgs)] @@ -712,11 +712,11 @@ mod builtins { } #[pyfunction] - fn hasattr(obj: PyObjectRef, attr: PyObjectRef, vm: &VirtualMachine) -> PyResult { - let attr = attr.try_to_ref::(vm).map_err(|_e| { + fn hasattr(obj: PyObjectRef, name: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let attr = name.try_to_ref::(vm).map_err(|_e| { vm.new_type_error(format!( "attribute name must be string, not '{}'", - attr.class().name() + name.class().name() )) })?; Ok(vm.get_attribute_opt(obj, attr)?.is_some()) @@ -818,13 +818,21 @@ mod builtins { } #[pyfunction] - fn isinstance(obj: PyObjectRef, typ: PyObjectRef, vm: &VirtualMachine) -> PyResult { - obj.is_instance(&typ, vm) + fn isinstance( + obj: PyObjectRef, + class_or_tuple: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + obj.is_instance(&class_or_tuple, vm) } #[pyfunction] - fn issubclass(subclass: PyObjectRef, typ: PyObjectRef, vm: &VirtualMachine) -> PyResult { - subclass.is_subclass(&typ, vm) + fn issubclass( + cls: PyObjectRef, + class_or_tuple: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + cls.is_subclass(&class_or_tuple, vm) } #[pyfunction] @@ -845,8 +853,8 @@ mod builtins { } #[pyfunction] - fn aiter(iter_target: PyObjectRef, vm: &VirtualMachine) -> PyResult { - iter_target.get_aiter(vm) + fn aiter(async_iterable: PyObjectRef, vm: &VirtualMachine) -> PyResult { + async_iterable.get_aiter(vm) } #[pyfunction] @@ -994,8 +1002,8 @@ mod builtins { } #[pyfunction] - fn ord(string: Either, vm: &VirtualMachine) -> PyResult { - match string { + fn ord(character: Either, vm: &VirtualMachine) -> PyResult { + match character { Either::A(bytes) => bytes.with_ref(|bytes| { let bytes_len = bytes.len(); if bytes_len != 1 { @@ -1138,14 +1146,14 @@ mod builtins { #[pyfunction] fn setattr( obj: PyObjectRef, - attr: PyObjectRef, + name: PyObjectRef, value: PyObjectRef, vm: &VirtualMachine, ) -> PyResult<()> { - let attr = attr.try_to_ref::(vm).map_err(|_e| { + let attr = name.try_to_ref::(vm).map_err(|_e| { vm.new_type_error(format!( "attribute name must be string, not '{}'", - attr.class().name() + name.class().name() )) })?; obj.set_attr(attr, value, vm)?; diff --git a/extra_tests/snippets/builtin_signature.py b/extra_tests/snippets/builtin_signature.py new file mode 100644 index 00000000000..53f2ad463e6 --- /dev/null +++ b/extra_tests/snippets/builtin_signature.py @@ -0,0 +1,64 @@ +import inspect + +# __text_signature__ is generated from the Rust parameter list, so it must not +# describe parameters the function does not actually take, and must mark the +# ones it does take as positional-only. + +# No phantom `module` parameter. RustPython's #[pyfunction]s take no module +# argument, so `__self__` is None and inspect has nothing to strip. +for f in (len, abs, hash, id, repr, bin, ord, divmod, hex, oct, chr, callable): + assert "module" not in inspect.signature(f).parameters, f.__name__ + +# Plain arguments bind through take_positional(), so they are positional-only. +try: + len(obj=[1, 2]) +except TypeError: + pass +else: + raise AssertionError("len() should not accept keyword arguments") + +assert str(inspect.signature(len)) == "(obj, /)" +assert str(inspect.signature(abs)) == "(x, /)" +assert str(inspect.signature(hash)) == "(obj, /)" +assert str(inspect.signature(chr)) == "(i, /)" +assert str(inspect.signature(callable)) == "(obj, /)" + +assert ( + inspect.signature(len).parameters["obj"].kind == inspect.Parameter.POSITIONAL_ONLY +) + +# *args/**kwargs cannot be followed by `/`. The parameter names themselves still +# differ from CPython here, which is out of scope. +breakpoint_kinds = [p.kind for p in inspect.signature(breakpoint).parameters.values()] +assert breakpoint_kinds == [ + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, +], breakpoint_kinds + +# Parameter names follow CPython, so signatures are directly comparable. +assert str(inspect.signature(bin)) == "(number, /)" +assert str(inspect.signature(ord)) == "(character, /)" +assert str(inspect.signature(divmod)) == "(x, y, /)" +assert str(inspect.signature(hasattr)) == "(obj, name, /)" +assert str(inspect.signature(setattr)) == "(obj, name, value, /)" +assert str(inspect.signature(delattr)) == "(obj, name, /)" +assert str(inspect.signature(isinstance)) == "(obj, class_or_tuple, /)" +assert str(inspect.signature(issubclass)) == "(cls, class_or_tuple, /)" +assert str(inspect.signature(aiter)) == "(async_iterable, /)" + +# Functions whose Rust arguments are destructuring patterns rather than plain +# names get no signature at all, instead of emitting text that is not valid +# Python and makes inspect.signature() raise "invalid signature". +# +# CPython does have signatures for these, hand-written via Argument Clinic. We +# cannot derive them until FromArgs reports its own parameters, so until then we +# report no signature, which is at least how CPython behaves for the builtins it +# has no signature for. +for f in (round, sum): + assert f.__text_signature__ is None, f.__name__ + try: + inspect.signature(f) + except ValueError as e: + assert "no signature found" in str(e), str(e) + else: + raise AssertionError(f"{f.__name__} should have no signature") From 811b5b3a69bbff30bdd19a50c9dcb192d8fb9e84 Mon Sep 17 00:00:00 2001 From: leehanjeong Date: Mon, 10 Aug 2026 17:52:28 +0900 Subject: [PATCH 5/6] Drop expectedFailure from test_module_level_callable_noargs pydoc's summary line for time.time was "time(module)" because the generated signature carried a $module parameter that inspect could not strip. It now reads "time()", as the test expects. Assisted-by: Claude Code:claude-opus-5 --- Lib/test/test_pydoc/test_pydoc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_pydoc/test_pydoc.py b/Lib/test/test_pydoc/test_pydoc.py index d206e5a910d..f44c56da652 100644 --- a/Lib/test/test_pydoc/test_pydoc.py +++ b/Lib/test/test_pydoc/test_pydoc.py @@ -1582,7 +1582,6 @@ def test_module_level_callable(self): self.assertEqual(self._get_summary_line(os.stat), "stat(path, *, dir_fd=None, follow_symlinks=True)") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_module_level_callable_noargs(self): self.assertEqual(self._get_summary_line(time.time), "time()") From 4e41ace230a27ee4d9f400336aa54c578502ebcf Mon Sep 17 00:00:00 2001 From: leehanjeong Date: Mon, 10 Aug 2026 18:19:59 +0900 Subject: [PATCH 6/6] Guard the signature-less assertions to RustPython test_snippets runs every snippet under CPython as well, and CPython does have Argument Clinic signatures for round and sum, so that block only holds for RustPython. Assisted-by: Claude Code:claude-opus-5 --- extra_tests/snippets/builtin_signature.py | 34 ++++++++++++----------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/extra_tests/snippets/builtin_signature.py b/extra_tests/snippets/builtin_signature.py index 53f2ad463e6..320a395d882 100644 --- a/extra_tests/snippets/builtin_signature.py +++ b/extra_tests/snippets/builtin_signature.py @@ -1,4 +1,5 @@ import inspect +import sys # __text_signature__ is generated from the Rust parameter list, so it must not # describe parameters the function does not actually take, and must mark the @@ -46,19 +47,20 @@ assert str(inspect.signature(issubclass)) == "(cls, class_or_tuple, /)" assert str(inspect.signature(aiter)) == "(async_iterable, /)" -# Functions whose Rust arguments are destructuring patterns rather than plain -# names get no signature at all, instead of emitting text that is not valid -# Python and makes inspect.signature() raise "invalid signature". -# -# CPython does have signatures for these, hand-written via Argument Clinic. We -# cannot derive them until FromArgs reports its own parameters, so until then we -# report no signature, which is at least how CPython behaves for the builtins it -# has no signature for. -for f in (round, sum): - assert f.__text_signature__ is None, f.__name__ - try: - inspect.signature(f) - except ValueError as e: - assert "no signature found" in str(e), str(e) - else: - raise AssertionError(f"{f.__name__} should have no signature") +if sys.implementation.name == "rustpython": + # Functions whose Rust arguments are destructuring patterns rather than + # plain names get no signature at all, instead of emitting text that is not + # valid Python and makes inspect.signature() raise "invalid signature". + # + # CPython does have signatures for these, hand-written via Argument Clinic. + # We cannot derive them until FromArgs reports the parameters of its own + # structs, so until then we report no signature, which is at least how + # CPython behaves for the builtins it has no signature for. + for f in (round, sum): + assert f.__text_signature__ is None, f.__name__ + try: + inspect.signature(f) + except ValueError as e: + assert "no signature found" in str(e), str(e) + else: + raise AssertionError(f"{f.__name__} should have no signature")