From 3f6838691c917625961290cd3105c6272eceb832 Mon Sep 17 00:00:00 2001 From: "thomas.pellissier-tanon" Date: Mon, 31 Mar 2025 11:57:02 +0200 Subject: [PATCH 1/6] Introspection: add function signatures No annotations or explicit default values yet Fixes an issue related to object identifiers path --- pyo3-introspection/src/introspection.rs | 45 +++++++- pyo3-introspection/src/model.rs | 23 +++++ pyo3-introspection/src/stubs.rs | 33 +++++- pyo3-macros-backend/src/introspection.rs | 124 ++++++++++++++++++----- pyo3-macros-backend/src/module.rs | 12 ++- pyo3-macros-backend/src/pyclass.rs | 12 ++- pyo3-macros-backend/src/pyfunction.rs | 25 +++-- pytests/src/pyfunctions.rs | 26 +++-- pytests/stubs/pyfunctions.pyi | 8 ++ 9 files changed, 258 insertions(+), 50 deletions(-) diff --git a/pyo3-introspection/src/introspection.rs b/pyo3-introspection/src/introspection.rs index 15fd9d92b84..e137b65e9da 100644 --- a/pyo3-introspection/src/introspection.rs +++ b/pyo3-introspection/src/introspection.rs @@ -1,4 +1,4 @@ -use crate::model::{Class, Function, Module}; +use crate::model::{Argument, Class, Function, Module, ParameterKind}; use anyhow::{bail, ensure, Context, Result}; use goblin::elf::Elf; use goblin::mach::load_command::CommandVariant; @@ -69,7 +69,29 @@ fn parse_module( modules.push(parse_module(name, members, chunks_by_id)?); } Chunk::Class { name, id: _ } => classes.push(Class { name: name.into() }), - Chunk::Function { name, id: _ } => functions.push(Function { name: name.into() }), + Chunk::Function { + name, + id: _, + arguments, + } => functions.push(Function { + name: name.into(), + arguments: arguments + .iter() + .map(|arg| Argument { + name: arg.name.clone(), + kind: match arg.kind { + ChunkArgumentKind::PositionalOnly => ParameterKind::PositionalOnly, + ChunkArgumentKind::PositionalOrKeyword => { + ParameterKind::PositionalOrKeyword + } + ChunkArgumentKind::VarPositional => ParameterKind::VarPositional, + ChunkArgumentKind::KeywordOnly => ParameterKind::KeywordOnly, + ChunkArgumentKind::VarKeyword => ParameterKind::VarKeyword, + }, + default_value: arg.default_value.clone(), + }) + .collect(), + }), } } } @@ -252,5 +274,24 @@ enum Chunk { Function { id: String, name: String, + arguments: Vec, }, } + +#[derive(Deserialize)] +struct ChunkArgument { + name: String, + kind: ChunkArgumentKind, + #[serde(default)] + default_value: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +enum ChunkArgumentKind { + PositionalOnly, + PositionalOrKeyword, + VarPositional, + KeywordOnly, + VarKeyword, +} diff --git a/pyo3-introspection/src/model.rs b/pyo3-introspection/src/model.rs index 73a4c27d082..3e857d4ad10 100644 --- a/pyo3-introspection/src/model.rs +++ b/pyo3-introspection/src/model.rs @@ -14,4 +14,27 @@ pub struct Class { #[derive(Debug, Eq, PartialEq, Clone, Hash)] pub struct Function { pub name: String, + pub arguments: Vec, +} + +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +pub struct Argument { + pub name: String, + pub kind: ParameterKind, + /// Default value as a Python expression + pub default_value: Option, +} + +#[derive(Debug, Eq, PartialEq, Clone, Copy, Hash)] +pub enum ParameterKind { + /// Before / + PositionalOnly, + /// Between / and * + PositionalOrKeyword, + /// *args + VarPositional, + /// After * + KeywordOnly, + /// *kwargs + VarKeyword, } diff --git a/pyo3-introspection/src/stubs.rs b/pyo3-introspection/src/stubs.rs index 0705911032f..a7683d57168 100644 --- a/pyo3-introspection/src/stubs.rs +++ b/pyo3-introspection/src/stubs.rs @@ -1,4 +1,4 @@ -use crate::model::{Class, Function, Module}; +use crate::model::{Class, Function, Module, ParameterKind}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -48,5 +48,34 @@ fn class_stubs(class: &Class) -> String { } fn function_stubs(function: &Function) -> String { - format!("def {}(*args, **kwargs): ...", function.name) + // Signature + let mut positional_only = true; + let mut keyword_only = false; + let mut parameters = Vec::new(); + for argument in &function.arguments { + if positional_only && !matches!(argument.kind, ParameterKind::PositionalOnly) { + if !parameters.is_empty() { + parameters.push("/".into()); + } + positional_only = false; + } + if !keyword_only && matches!(argument.kind, ParameterKind::KeywordOnly) { + parameters.push("*".into()); + keyword_only = true; + } + let mut parameter_str = match argument.kind { + ParameterKind::VarPositional => { + keyword_only = true; + format!("*{}", argument.name) + } + ParameterKind::VarKeyword => format!("**{}", argument.name), + _ => argument.name.clone(), + }; + if let Some(default_value) = &argument.default_value { + parameter_str.push('='); + parameter_str.push_str(default_value); + } + parameters.push(parameter_str); + } + format!("def {}({}): ...", function.name, parameters.join(", ")) } diff --git a/pyo3-macros-backend/src/introspection.rs b/pyo3-macros-backend/src/introspection.rs index 17ae19ee8d9..e74bc9f26e4 100644 --- a/pyo3-macros-backend/src/introspection.rs +++ b/pyo3-macros-backend/src/introspection.rs @@ -8,6 +8,8 @@ //! The JSON blobs format must be synchronized with the `pyo3_introspection::introspection.rs::Chunk` //! type that is used to parse them. +use crate::method::{FnArg, RegularArg}; +use crate::pyfunction::FunctionSignature; use crate::utils::PyO3CratePath; use proc_macro2::{Span, TokenStream}; use quote::{format_ident, quote, ToTokens}; @@ -26,7 +28,7 @@ pub fn module_introspection_code<'a>( members: impl IntoIterator, members_cfg_attrs: impl IntoIterator>, ) -> TokenStream { - let stub = IntrospectionNode::Map( + IntrospectionNode::Map( [ ("type", IntrospectionNode::String("module")), ("id", IntrospectionNode::IntrospectionId(None)), @@ -50,12 +52,7 @@ pub fn module_introspection_code<'a>( ] .into(), ) - .emit(pyo3_crate_path); - let introspection_id = introspection_id_const(); - quote! { - #stub - #introspection_id - } + .emit(pyo3_crate_path) } pub fn class_introspection_code( @@ -63,7 +60,7 @@ pub fn class_introspection_code( ident: &Ident, name: &str, ) -> TokenStream { - let stub = IntrospectionNode::Map( + IntrospectionNode::Map( [ ("type", IntrospectionNode::String("class")), ("id", IntrospectionNode::IntrospectionId(Some(ident))), @@ -71,31 +68,107 @@ pub fn class_introspection_code( ] .into(), ) - .emit(pyo3_crate_path); - let introspection_id = introspection_id_const(); - quote! { - #stub - impl #ident { - #introspection_id - } - } + .emit(pyo3_crate_path) } -pub fn function_introspection_code(pyo3_crate_path: &PyO3CratePath, name: &str) -> TokenStream { - let stub = IntrospectionNode::Map( +pub fn function_introspection_code( + pyo3_crate_path: &PyO3CratePath, + ident: &Ident, + name: &str, + signature: &FunctionSignature<'_>, +) -> TokenStream { + IntrospectionNode::Map( [ ("type", IntrospectionNode::String("function")), - ("id", IntrospectionNode::IntrospectionId(None)), + ("id", IntrospectionNode::IntrospectionId(Some(ident))), ("name", IntrospectionNode::String(name)), + ("arguments", arguments_introspection_data(signature)), ] .into(), ) - .emit(pyo3_crate_path); - let introspection_id = introspection_id_const(); - quote! { - #stub - #introspection_id + .emit(pyo3_crate_path) +} + +fn arguments_introspection_data<'a>(signature: &'a FunctionSignature<'a>) -> IntrospectionNode<'a> { + let mut argument_desc = signature.arguments.iter().filter_map(|arg| { + if let FnArg::Regular(arg) = arg { + Some(arg) + } else { + None + } + }); + + let mut arguments = Vec::new(); + + for (i, param) in signature + .python_signature + .positional_parameters + .iter() + .enumerate() + { + let arg_desc = if let Some(arg_desc) = argument_desc.next() { + arg_desc + } else { + panic!("Less arguments than in python signature"); + }; + arguments.push(argument_introspection_data( + param, + if i < signature.python_signature.positional_only_parameters { + "POSITIONAL_ONLY" + } else { + "POSITIONAL_OR_KEYWORD" + }, + arg_desc, + )); + } + + if let Some(param) = &signature.python_signature.varargs { + arguments.push(IntrospectionNode::Map( + [ + ("name", IntrospectionNode::String(param)), + ("kind", IntrospectionNode::String("VAR_POSITIONAL")), + ] + .into(), + )); + } + + for (param, _) in &signature.python_signature.keyword_only_parameters { + let arg_desc = if let Some(arg_desc) = argument_desc.next() { + arg_desc + } else { + panic!("Less arguments than in python signature"); + }; + arguments.push(argument_introspection_data(param, "KEYWORD_ONLY", arg_desc)); + } + + if let Some(param) = &signature.python_signature.kwargs { + arguments.push(IntrospectionNode::Map( + [ + ("name", IntrospectionNode::String(param)), + ("kind", IntrospectionNode::String("VAR_KEYWORD")), + ] + .into(), + )); + } + + IntrospectionNode::List(arguments) +} + +fn argument_introspection_data<'a>( + name: &'a str, + kind: &'a str, + desc: &'a RegularArg<'_>, +) -> IntrospectionNode<'a> { + let mut params: HashMap<_, _> = [ + ("name", IntrospectionNode::String(name)), + ("kind", IntrospectionNode::String(kind)), + ] + .into(); + if desc.default_value.is_some() { + // TODO: generate a nice default values for literals (None, false, 0, ""...) + params.insert("default_value", IntrospectionNode::String("...")); } + IntrospectionNode::Map(params) } enum IntrospectionNode<'a> { @@ -232,7 +305,8 @@ impl ToTokens for ConcatenationBuilderElement { } } -fn introspection_id_const() -> TokenStream { +/// Generates a new unique identifier for linking introspection objects together +pub fn introspection_id_const() -> TokenStream { let id = unique_element_id().to_string(); quote! { #[doc(hidden)] diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 7ee165cbd27..aa8a2a82b95 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -1,7 +1,7 @@ //! Code generation for the function that initializes a python module and adds classes and function. #[cfg(feature = "experimental-inspect")] -use crate::introspection::module_introspection_code; +use crate::introspection::{introspection_id_const, module_introspection_code}; use crate::{ attributes::{ self, kw, take_attributes, take_pyo3_options, CrateAttribute, GILUsedAttribute, @@ -348,6 +348,10 @@ pub fn pymodule_module_impl( ); #[cfg(not(feature = "experimental-inspect"))] let introspection = quote! {}; + #[cfg(feature = "experimental-inspect")] + let introspection_id = introspection_id_const(); + #[cfg(not(feature = "experimental-inspect"))] + let introspection_id = quote! {}; let module_def = quote! {{ use #pyo3_path::impl_::pymodule as impl_; @@ -375,6 +379,7 @@ pub fn pymodule_module_impl( #initialization #introspection + #introspection_id fn __pyo3_pymodule(module: &#pyo3_path::Bound<'_, #pyo3_path::types::PyModule>) -> #pyo3_path::PyResult<()> { use #pyo3_path::impl_::pymodule::PyAddToModule; @@ -418,6 +423,10 @@ pub fn pymodule_function_impl( let introspection = module_introspection_code(pyo3_path, &name.to_string(), &[], &[]); #[cfg(not(feature = "experimental-inspect"))] let introspection = quote! {}; + #[cfg(feature = "experimental-inspect")] + let introspection_id = introspection_id_const(); + #[cfg(not(feature = "experimental-inspect"))] + let introspection_id = quote! {}; // Module function called with optional Python<'_> marker as first arg, followed by the module. let mut module_args = Vec::new(); @@ -432,6 +441,7 @@ pub fn pymodule_function_impl( #vis mod #ident { #initialization #introspection + #introspection_id } // Generate the definition inside an anonymous function in the same scope as the original function - diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs index ed15917279e..82d4d54c7ec 100644 --- a/pyo3-macros-backend/src/pyclass.rs +++ b/pyo3-macros-backend/src/pyclass.rs @@ -15,7 +15,7 @@ use crate::attributes::{ StrFormatterAttribute, }; #[cfg(feature = "experimental-inspect")] -use crate::introspection::class_introspection_code; +use crate::introspection::{class_introspection_code, introspection_id_const}; use crate::konst::{ConstAttributes, ConstSpec}; use crate::method::{FnArg, FnSpec, PyArg, RegularArg}; use crate::pyfunction::ConstructorAttribute; @@ -2388,7 +2388,15 @@ impl<'a> PyClassImplsBuilder<'a> { fn impl_introspection(&self, ctx: &Ctx) -> TokenStream { let Ctx { pyo3_path, .. } = ctx; let name = get_class_python_name(self.cls, self.attr).to_string(); - class_introspection_code(pyo3_path, self.cls, &name) + let ident = self.cls; + let static_introspection = class_introspection_code(pyo3_path, ident, &name); + let introspection_id = introspection_id_const(); + quote! { + #static_introspection + impl #ident { + #introspection_id + } + } } #[cfg(not(feature = "experimental-inspect"))] diff --git a/pyo3-macros-backend/src/pyfunction.rs b/pyo3-macros-backend/src/pyfunction.rs index 68976190fbe..301819f42dd 100644 --- a/pyo3-macros-backend/src/pyfunction.rs +++ b/pyo3-macros-backend/src/pyfunction.rs @@ -1,5 +1,5 @@ #[cfg(feature = "experimental-inspect")] -use crate::introspection::function_introspection_code; +use crate::introspection::{function_introspection_code, introspection_id_const}; use crate::utils::Ctx; use crate::{ attributes::{ @@ -226,6 +226,18 @@ pub fn impl_wrap_pyfunction( FunctionSignature::from_arguments(arguments) }; + let vis = &func.vis; + let name = &func.sig.ident; + + #[cfg(feature = "experimental-inspect")] + let introspection = function_introspection_code(pyo3_path, name, &name.to_string(), &signature); + #[cfg(not(feature = "experimental-inspect"))] + let introspection = quote! {}; + #[cfg(feature = "experimental-inspect")] + let introspection_id = introspection_id_const(); + #[cfg(not(feature = "experimental-inspect"))] + let introspection_id = quote! {}; + let spec = method::FnSpec { tp, name: &func.sig.ident, @@ -237,16 +249,9 @@ pub fn impl_wrap_pyfunction( unsafety: func.sig.unsafety, }; - let vis = &func.vis; - let name = &func.sig.ident; - let wrapper_ident = format_ident!("__pyfunction_{}", spec.name); let wrapper = spec.get_wrapper_function(&wrapper_ident, None, ctx)?; let methoddef = spec.get_methoddef(wrapper_ident, &spec.get_doc(&func.attrs, ctx), ctx); - #[cfg(feature = "experimental-inspect")] - let introspection = function_introspection_code(pyo3_path, &name.to_string()); - #[cfg(not(feature = "experimental-inspect"))] - let introspection = quote! {}; let wrapped_pyfunction = quote! { // Create a module with the same name as the `#[pyfunction]` - this way `use ` @@ -255,7 +260,7 @@ pub fn impl_wrap_pyfunction( #vis mod #name { pub(crate) struct MakeDef; pub const _PYO3_DEF: #pyo3_path::impl_::pymethods::PyMethodDef = MakeDef::_PYO3_DEF; - #introspection + #introspection_id } // Generate the definition inside an anonymous function in the same scope as the original function - @@ -269,6 +274,8 @@ pub fn impl_wrap_pyfunction( #[allow(non_snake_case)] #wrapper + + #introspection }; Ok(wrapped_pyfunction) } diff --git a/pytests/src/pyfunctions.rs b/pytests/src/pyfunctions.rs index 024641d3d2e..19e30712909 100644 --- a/pytests/src/pyfunctions.rs +++ b/pytests/src/pyfunctions.rs @@ -67,13 +67,21 @@ fn args_kwargs<'py>( (args, kwargs) } -#[pymodule(gil_used = false)] -pub fn pyfunctions(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(none, m)?)?; - m.add_function(wrap_pyfunction!(simple, m)?)?; - m.add_function(wrap_pyfunction!(simple_args, m)?)?; - m.add_function(wrap_pyfunction!(simple_kwargs, m)?)?; - m.add_function(wrap_pyfunction!(simple_args_kwargs, m)?)?; - m.add_function(wrap_pyfunction!(args_kwargs, m)?)?; - Ok(()) +#[pyfunction(signature = (a, /, b))] +fn positional_only<'py>(a: Any<'py>, b: Any<'py>) -> (Any<'py>, Any<'py>) { + (a, b) +} + +#[pyfunction(signature = (a = false, b = 0, c = 0.0, d = ""))] +fn with_typed_args(a: bool, b: u64, c: f64, d: &str) -> (bool, u64, f64, &str) { + (a, b, c, d) +} + +#[pymodule] +pub mod pyfunctions { + #[pymodule_export] + use super::{ + args_kwargs, none, positional_only, simple, simple_args, simple_args_kwargs, simple_kwargs, + with_typed_args, + }; } diff --git a/pytests/stubs/pyfunctions.pyi b/pytests/stubs/pyfunctions.pyi index e69de29bb2d..4324ec63217 100644 --- a/pytests/stubs/pyfunctions.pyi +++ b/pytests/stubs/pyfunctions.pyi @@ -0,0 +1,8 @@ +def args_kwargs(*args, **kwargs): ... +def none(): ... +def positional_only(a, /, b): ... +def simple(a, b=..., *, c=...): ... +def simple_args(a, b=..., *args, c=...): ... +def simple_args_kwargs(a, b=..., *args, c=..., **kwargs): ... +def simple_kwargs(a, b=..., c=..., **kwargs): ... +def with_typed_args(a=..., b=..., c=..., d=...): ... From ab7abe576747c3db8e402eded242579e5f3801c7 Mon Sep 17 00:00:00 2001 From: Thomas Pellissier-Tanon Date: Fri, 4 Apr 2025 11:02:26 +0200 Subject: [PATCH 2/6] Better default value --- pyo3-macros-backend/src/introspection.rs | 35 ++++++------ pyo3-macros-backend/src/method.rs | 54 +++++++++++++++++++ .../src/pyfunction/signature.rs | 44 ++------------- pytests/stubs/pyfunctions.pyi | 10 ++-- 4 files changed, 81 insertions(+), 62 deletions(-) diff --git a/pyo3-macros-backend/src/introspection.rs b/pyo3-macros-backend/src/introspection.rs index e74bc9f26e4..8eb9e07c64a 100644 --- a/pyo3-macros-backend/src/introspection.rs +++ b/pyo3-macros-backend/src/introspection.rs @@ -13,6 +13,7 @@ use crate::pyfunction::FunctionSignature; use crate::utils::PyO3CratePath; use proc_macro2::{Span, TokenStream}; use quote::{format_ident, quote, ToTokens}; +use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; use std::collections::HashMap; use std::hash::{Hash, Hasher}; @@ -30,9 +31,9 @@ pub fn module_introspection_code<'a>( ) -> TokenStream { IntrospectionNode::Map( [ - ("type", IntrospectionNode::String("module")), + ("type", IntrospectionNode::String("module".into())), ("id", IntrospectionNode::IntrospectionId(None)), - ("name", IntrospectionNode::String(name)), + ("name", IntrospectionNode::String(name.into())), ( "members", IntrospectionNode::List( @@ -62,9 +63,9 @@ pub fn class_introspection_code( ) -> TokenStream { IntrospectionNode::Map( [ - ("type", IntrospectionNode::String("class")), + ("type", IntrospectionNode::String("class".into())), ("id", IntrospectionNode::IntrospectionId(Some(ident))), - ("name", IntrospectionNode::String(name)), + ("name", IntrospectionNode::String(name.into())), ] .into(), ) @@ -79,9 +80,9 @@ pub fn function_introspection_code( ) -> TokenStream { IntrospectionNode::Map( [ - ("type", IntrospectionNode::String("function")), + ("type", IntrospectionNode::String("function".into())), ("id", IntrospectionNode::IntrospectionId(Some(ident))), - ("name", IntrospectionNode::String(name)), + ("name", IntrospectionNode::String(name.into())), ("arguments", arguments_introspection_data(signature)), ] .into(), @@ -125,8 +126,8 @@ fn arguments_introspection_data<'a>(signature: &'a FunctionSignature<'a>) -> Int if let Some(param) = &signature.python_signature.varargs { arguments.push(IntrospectionNode::Map( [ - ("name", IntrospectionNode::String(param)), - ("kind", IntrospectionNode::String("VAR_POSITIONAL")), + ("name", IntrospectionNode::String(param.into())), + ("kind", IntrospectionNode::String("VAR_POSITIONAL".into())), ] .into(), )); @@ -144,8 +145,8 @@ fn arguments_introspection_data<'a>(signature: &'a FunctionSignature<'a>) -> Int if let Some(param) = &signature.python_signature.kwargs { arguments.push(IntrospectionNode::Map( [ - ("name", IntrospectionNode::String(param)), - ("kind", IntrospectionNode::String("VAR_KEYWORD")), + ("name", IntrospectionNode::String(param.into())), + ("kind", IntrospectionNode::String("VAR_KEYWORD".into())), ] .into(), )); @@ -160,19 +161,21 @@ fn argument_introspection_data<'a>( desc: &'a RegularArg<'_>, ) -> IntrospectionNode<'a> { let mut params: HashMap<_, _> = [ - ("name", IntrospectionNode::String(name)), - ("kind", IntrospectionNode::String(kind)), + ("name", IntrospectionNode::String(name.into())), + ("kind", IntrospectionNode::String(kind.into())), ] .into(); if desc.default_value.is_some() { - // TODO: generate a nice default values for literals (None, false, 0, ""...) - params.insert("default_value", IntrospectionNode::String("...")); + params.insert( + "default_value", + IntrospectionNode::String(desc.default_value().into()), + ); } IntrospectionNode::Map(params) } enum IntrospectionNode<'a> { - String(&'a str), + String(Cow<'a, str>), IntrospectionId(Option<&'a Ident>), Map(HashMap<&'static str, IntrospectionNode<'a>>), List(Vec>), @@ -198,7 +201,7 @@ impl IntrospectionNode<'_> { fn add_to_serialization(self, content: &mut ConcatenationBuilder) { match self { Self::String(string) => { - content.push_str_to_escape(string); + content.push_str_to_escape(&string); } Self::IntrospectionId(ident) => { content.push_str("\""); diff --git a/pyo3-macros-backend/src/method.rs b/pyo3-macros-backend/src/method.rs index bc117874a5b..dfcd5dd2203 100644 --- a/pyo3-macros-backend/src/method.rs +++ b/pyo3-macros-backend/src/method.rs @@ -27,6 +27,52 @@ pub struct RegularArg<'a> { pub option_wrapped_type: Option<&'a syn::Type>, } +impl RegularArg<'_> { + pub fn default_value(&self) -> String { + if let Self { + default_value: Some(arg_default), + .. + } = self + { + match arg_default { + // literal values + syn::Expr::Lit(syn::ExprLit { lit, .. }) => match lit { + syn::Lit::Str(s) => s.token().to_string(), + syn::Lit::Char(c) => c.token().to_string(), + syn::Lit::Int(i) => i.base10_digits().to_string(), + syn::Lit::Float(f) => f.base10_digits().to_string(), + syn::Lit::Bool(b) => { + if b.value() { + "True".to_string() + } else { + "False".to_string() + } + } + _ => "...".to_string(), + }, + // None + syn::Expr::Path(syn::ExprPath { qself, path, .. }) + if qself.is_none() && path.is_ident("None") => + { + "None".to_string() + } + // others, unsupported yet so defaults to `...` + _ => "...".to_string(), + } + } else if let RegularArg { + option_wrapped_type: Some(..), + .. + } = self + { + // functions without a `#[pyo3(signature = (...))]` option + // will treat trailing `Option` arguments as having a default of `None` + "None".to_string() + } else { + "...".to_string() + } + } +} + /// Pythons *args argument #[derive(Clone, Debug)] pub struct VarargsArg<'a> { @@ -177,6 +223,14 @@ impl<'a> FnArg<'a> { } } } + + pub fn default_value(&self) -> String { + if let Self::Regular(args) = self { + args.default_value() + } else { + "...".to_string() + } + } } fn handle_argument_error(pat: &syn::Pat) -> syn::Error { diff --git a/pyo3-macros-backend/src/pyfunction/signature.rs b/pyo3-macros-backend/src/pyfunction/signature.rs index deea3dfa052..fac1541bdf1 100644 --- a/pyo3-macros-backend/src/pyfunction/signature.rs +++ b/pyo3-macros-backend/src/pyfunction/signature.rs @@ -491,49 +491,11 @@ impl<'a> FunctionSignature<'a> { } fn default_value_for_parameter(&self, parameter: &str) -> String { - let mut default = "...".to_string(); if let Some(fn_arg) = self.arguments.iter().find(|arg| arg.name() == parameter) { - if let FnArg::Regular(RegularArg { - default_value: Some(arg_default), - .. - }) = fn_arg - { - match arg_default { - // literal values - syn::Expr::Lit(syn::ExprLit { lit, .. }) => match lit { - syn::Lit::Str(s) => default = s.token().to_string(), - syn::Lit::Char(c) => default = c.token().to_string(), - syn::Lit::Int(i) => default = i.base10_digits().to_string(), - syn::Lit::Float(f) => default = f.base10_digits().to_string(), - syn::Lit::Bool(b) => { - default = if b.value() { - "True".to_string() - } else { - "False".to_string() - } - } - _ => {} - }, - // None - syn::Expr::Path(syn::ExprPath { - qself: None, path, .. - }) if path.is_ident("None") => { - default = "None".to_string(); - } - // others, unsupported yet so defaults to `...` - _ => {} - } - } else if let FnArg::Regular(RegularArg { - option_wrapped_type: Some(..), - .. - }) = fn_arg - { - // functions without a `#[pyo3(signature = (...))]` option - // will treat trailing `Option` arguments as having a default of `None` - default = "None".to_string(); - } + fn_arg.default_value() + } else { + "...".to_string() } - default } pub fn text_signature(&self, self_argument: Option<&str>) -> String { diff --git a/pytests/stubs/pyfunctions.pyi b/pytests/stubs/pyfunctions.pyi index 4324ec63217..5fb5e6c474c 100644 --- a/pytests/stubs/pyfunctions.pyi +++ b/pytests/stubs/pyfunctions.pyi @@ -1,8 +1,8 @@ def args_kwargs(*args, **kwargs): ... def none(): ... def positional_only(a, /, b): ... -def simple(a, b=..., *, c=...): ... -def simple_args(a, b=..., *args, c=...): ... -def simple_args_kwargs(a, b=..., *args, c=..., **kwargs): ... -def simple_kwargs(a, b=..., c=..., **kwargs): ... -def with_typed_args(a=..., b=..., c=..., d=...): ... +def simple(a, b=None, *, c=None): ... +def simple_args(a, b=None, *args, c=None): ... +def simple_args_kwargs(a, b=None, *args, c=None, **kwargs): ... +def simple_kwargs(a, b=None, c=None, **kwargs): ... +def with_typed_args(a=False, b=0, c=0.0, d=""): ... From 49be236adc5165e543acde86aa737db09e26565d Mon Sep 17 00:00:00 2001 From: Thomas Pellissier-Tanon Date: Fri, 4 Apr 2025 11:49:34 +0200 Subject: [PATCH 3/6] Refine arguments struct --- pyo3-introspection/src/introspection.rs | 73 +++++++++++++----------- pyo3-introspection/src/model.rs | 29 +++++----- pyo3-introspection/src/stubs.rs | 56 +++++++++--------- pyo3-macros-backend/src/introspection.rs | 60 ++++++++++--------- 4 files changed, 118 insertions(+), 100 deletions(-) diff --git a/pyo3-introspection/src/introspection.rs b/pyo3-introspection/src/introspection.rs index e137b65e9da..cc57569993d 100644 --- a/pyo3-introspection/src/introspection.rs +++ b/pyo3-introspection/src/introspection.rs @@ -1,4 +1,4 @@ -use crate::model::{Argument, Class, Function, Module, ParameterKind}; +use crate::model::{Argument, Arguments, Class, Function, Module}; use anyhow::{bail, ensure, Context, Result}; use goblin::elf::Elf; use goblin::mach::load_command::CommandVariant; @@ -43,14 +43,14 @@ fn parse_chunks(chunks: &[Chunk], main_module_name: &str) -> Result { } = chunk { if name == main_module_name { - return parse_module(name, members, &chunks_by_id); + return convert_module(name, members, &chunks_by_id); } } } bail!("No module named {main_module_name} found") } -fn parse_module( +fn convert_module( name: &str, members: &[String], chunks_by_id: &HashMap<&String, &Chunk>, @@ -66,7 +66,7 @@ fn parse_module( members, id: _, } => { - modules.push(parse_module(name, members, chunks_by_id)?); + modules.push(convert_module(name, members, chunks_by_id)?); } Chunk::Class { name, id: _ } => classes.push(Class { name: name.into() }), Chunk::Function { @@ -75,22 +75,21 @@ fn parse_module( arguments, } => functions.push(Function { name: name.into(), - arguments: arguments - .iter() - .map(|arg| Argument { - name: arg.name.clone(), - kind: match arg.kind { - ChunkArgumentKind::PositionalOnly => ParameterKind::PositionalOnly, - ChunkArgumentKind::PositionalOrKeyword => { - ParameterKind::PositionalOrKeyword - } - ChunkArgumentKind::VarPositional => ParameterKind::VarPositional, - ChunkArgumentKind::KeywordOnly => ParameterKind::KeywordOnly, - ChunkArgumentKind::VarKeyword => ParameterKind::VarKeyword, - }, - default_value: arg.default_value.clone(), - }) - .collect(), + arguments: Arguments { + positional_only_arguments: arguments + .posonlyargs + .iter() + .map(convert_argument) + .collect(), + arguments: arguments.args.iter().map(convert_argument).collect(), + vararg: arguments.vararg.as_ref().map(convert_argument), + keyword_only_arguments: arguments + .kwonlyargs + .iter() + .map(convert_argument) + .collect(), + kwarg: arguments.kwarg.as_ref().map(convert_argument), + }, }), } } @@ -103,6 +102,13 @@ fn parse_module( }) } +fn convert_argument(arg: &ChunkArgument) -> Argument { + Argument { + name: arg.name.clone(), + default_value: arg.default.clone(), + } +} + fn find_introspection_chunks_in_binary_object(path: &Path) -> Result> { let library_content = fs::read(path).with_context(|| format!("Failed to read {}", path.display()))?; @@ -274,24 +280,27 @@ enum Chunk { Function { id: String, name: String, - arguments: Vec, + arguments: ChunkArguments, }, } #[derive(Deserialize)] -struct ChunkArgument { - name: String, - kind: ChunkArgumentKind, +struct ChunkArguments { + #[serde(default)] + posonlyargs: Vec, #[serde(default)] - default_value: Option, + args: Vec, + #[serde(default)] + vararg: Option, + #[serde(default)] + kwonlyargs: Vec, + #[serde(default)] + kwarg: Option, } #[derive(Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -enum ChunkArgumentKind { - PositionalOnly, - PositionalOrKeyword, - VarPositional, - KeywordOnly, - VarKeyword, +struct ChunkArgument { + name: String, + #[serde(default)] + default: Option, } diff --git a/pyo3-introspection/src/model.rs b/pyo3-introspection/src/model.rs index 3e857d4ad10..f8e694cd440 100644 --- a/pyo3-introspection/src/model.rs +++ b/pyo3-introspection/src/model.rs @@ -14,27 +14,26 @@ pub struct Class { #[derive(Debug, Eq, PartialEq, Clone, Hash)] pub struct Function { pub name: String, + pub arguments: Arguments, +} + +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +pub struct Arguments { + /// Arguments before / + pub positional_only_arguments: Vec, + /// Regular arguments (between / and *) pub arguments: Vec, + /// *vararg + pub vararg: Option, + /// Arguments after * + pub keyword_only_arguments: Vec, + /// **kwarg + pub kwarg: Option, } #[derive(Debug, Eq, PartialEq, Clone, Hash)] pub struct Argument { pub name: String, - pub kind: ParameterKind, /// Default value as a Python expression pub default_value: Option, } - -#[derive(Debug, Eq, PartialEq, Clone, Copy, Hash)] -pub enum ParameterKind { - /// Before / - PositionalOnly, - /// Between / and * - PositionalOrKeyword, - /// *args - VarPositional, - /// After * - KeywordOnly, - /// *kwargs - VarKeyword, -} diff --git a/pyo3-introspection/src/stubs.rs b/pyo3-introspection/src/stubs.rs index a7683d57168..241c833526c 100644 --- a/pyo3-introspection/src/stubs.rs +++ b/pyo3-introspection/src/stubs.rs @@ -1,4 +1,4 @@ -use crate::model::{Class, Function, Module, ParameterKind}; +use crate::model::{Argument, Class, Function, Module}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -49,33 +49,35 @@ fn class_stubs(class: &Class) -> String { fn function_stubs(function: &Function) -> String { // Signature - let mut positional_only = true; - let mut keyword_only = false; let mut parameters = Vec::new(); - for argument in &function.arguments { - if positional_only && !matches!(argument.kind, ParameterKind::PositionalOnly) { - if !parameters.is_empty() { - parameters.push("/".into()); - } - positional_only = false; - } - if !keyword_only && matches!(argument.kind, ParameterKind::KeywordOnly) { - parameters.push("*".into()); - keyword_only = true; - } - let mut parameter_str = match argument.kind { - ParameterKind::VarPositional => { - keyword_only = true; - format!("*{}", argument.name) - } - ParameterKind::VarKeyword => format!("**{}", argument.name), - _ => argument.name.clone(), - }; - if let Some(default_value) = &argument.default_value { - parameter_str.push('='); - parameter_str.push_str(default_value); - } - parameters.push(parameter_str); + for argument in &function.arguments.positional_only_arguments { + parameters.push(argument_stub(argument)); + } + if !function.arguments.positional_only_arguments.is_empty() { + parameters.push("/".into()); + } + for argument in &function.arguments.arguments { + parameters.push(argument_stub(argument)); + } + if let Some(argument) = &function.arguments.vararg { + parameters.push(format!("*{}", argument_stub(argument))); + } else if !function.arguments.keyword_only_arguments.is_empty() { + parameters.push("*".into()); + } + for argument in &function.arguments.keyword_only_arguments { + parameters.push(argument_stub(argument)); + } + if let Some(argument) = &function.arguments.kwarg { + parameters.push(format!("**{}", argument_stub(argument))); } format!("def {}({}): ...", function.name, parameters.join(", ")) } + +fn argument_stub(argument: &Argument) -> String { + let mut output = argument.name.clone(); + if let Some(default_value) = &argument.default_value { + output.push('='); + output.push_str(default_value); + } + output +} diff --git a/pyo3-macros-backend/src/introspection.rs b/pyo3-macros-backend/src/introspection.rs index 8eb9e07c64a..4888417cb08 100644 --- a/pyo3-macros-backend/src/introspection.rs +++ b/pyo3-macros-backend/src/introspection.rs @@ -99,7 +99,11 @@ fn arguments_introspection_data<'a>(signature: &'a FunctionSignature<'a>) -> Int } }); - let mut arguments = Vec::new(); + let mut posonlyargs = Vec::new(); + let mut args = Vec::new(); + let mut vararg = None; + let mut kwonlyargs = Vec::new(); + let mut kwarg = None; for (i, param) in signature .python_signature @@ -112,24 +116,17 @@ fn arguments_introspection_data<'a>(signature: &'a FunctionSignature<'a>) -> Int } else { panic!("Less arguments than in python signature"); }; - arguments.push(argument_introspection_data( - param, - if i < signature.python_signature.positional_only_parameters { - "POSITIONAL_ONLY" - } else { - "POSITIONAL_OR_KEYWORD" - }, - arg_desc, - )); + let arg = argument_introspection_data(param, arg_desc); + if i < signature.python_signature.positional_only_parameters { + posonlyargs.push(arg); + } else { + args.push(arg) + } } if let Some(param) = &signature.python_signature.varargs { - arguments.push(IntrospectionNode::Map( - [ - ("name", IntrospectionNode::String(param.into())), - ("kind", IntrospectionNode::String("VAR_POSITIONAL".into())), - ] - .into(), + vararg = Some(IntrospectionNode::Map( + [("name", IntrospectionNode::String(param.into()))].into(), )); } @@ -139,11 +136,11 @@ fn arguments_introspection_data<'a>(signature: &'a FunctionSignature<'a>) -> Int } else { panic!("Less arguments than in python signature"); }; - arguments.push(argument_introspection_data(param, "KEYWORD_ONLY", arg_desc)); + kwonlyargs.push(argument_introspection_data(param, arg_desc)); } if let Some(param) = &signature.python_signature.kwargs { - arguments.push(IntrospectionNode::Map( + kwarg = Some(IntrospectionNode::Map( [ ("name", IntrospectionNode::String(param.into())), ("kind", IntrospectionNode::String("VAR_KEYWORD".into())), @@ -152,22 +149,33 @@ fn arguments_introspection_data<'a>(signature: &'a FunctionSignature<'a>) -> Int )); } - IntrospectionNode::List(arguments) + let mut map = HashMap::new(); + if !posonlyargs.is_empty() { + map.insert("posonlyargs", IntrospectionNode::List(posonlyargs)); + } + if !args.is_empty() { + map.insert("args", IntrospectionNode::List(args)); + } + if let Some(vararg) = vararg { + map.insert("vararg", vararg); + } + if !kwonlyargs.is_empty() { + map.insert("kwonlyargs", IntrospectionNode::List(kwonlyargs)); + } + if let Some(kwarg) = kwarg { + map.insert("kwarg", kwarg); + } + IntrospectionNode::Map(map) } fn argument_introspection_data<'a>( name: &'a str, - kind: &'a str, desc: &'a RegularArg<'_>, ) -> IntrospectionNode<'a> { - let mut params: HashMap<_, _> = [ - ("name", IntrospectionNode::String(name.into())), - ("kind", IntrospectionNode::String(kind.into())), - ] - .into(); + let mut params: HashMap<_, _> = [("name", IntrospectionNode::String(name.into()))].into(); if desc.default_value.is_some() { params.insert( - "default_value", + "default", IntrospectionNode::String(desc.default_value().into()), ); } From c12ff4614ef54b8f2f31053582ff2baf0ba50dca Mon Sep 17 00:00:00 2001 From: Thomas Pellissier-Tanon Date: Tue, 8 Apr 2025 20:06:21 +0200 Subject: [PATCH 4/6] Introduce VariableLengthArgument --- pyo3-introspection/src/introspection.rs | 18 +++++++++++++++--- pyo3-introspection/src/model.rs | 10 ++++++++-- pyo3-introspection/src/stubs.rs | 10 +++++++--- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/pyo3-introspection/src/introspection.rs b/pyo3-introspection/src/introspection.rs index cc57569993d..e4f49d5e0e3 100644 --- a/pyo3-introspection/src/introspection.rs +++ b/pyo3-introspection/src/introspection.rs @@ -1,4 +1,4 @@ -use crate::model::{Argument, Arguments, Class, Function, Module}; +use crate::model::{Argument, Arguments, Class, Function, Module, VariableLengthArgument}; use anyhow::{bail, ensure, Context, Result}; use goblin::elf::Elf; use goblin::mach::load_command::CommandVariant; @@ -82,13 +82,19 @@ fn convert_module( .map(convert_argument) .collect(), arguments: arguments.args.iter().map(convert_argument).collect(), - vararg: arguments.vararg.as_ref().map(convert_argument), + vararg: arguments + .vararg + .as_ref() + .map(convert_variable_length_argument), keyword_only_arguments: arguments .kwonlyargs .iter() .map(convert_argument) .collect(), - kwarg: arguments.kwarg.as_ref().map(convert_argument), + kwarg: arguments + .kwarg + .as_ref() + .map(convert_variable_length_argument), }, }), } @@ -109,6 +115,12 @@ fn convert_argument(arg: &ChunkArgument) -> Argument { } } +fn convert_variable_length_argument(arg: &ChunkArgument) -> VariableLengthArgument { + VariableLengthArgument { + name: arg.name.clone(), + } +} + fn find_introspection_chunks_in_binary_object(path: &Path) -> Result> { let library_content = fs::read(path).with_context(|| format!("Failed to read {}", path.display()))?; diff --git a/pyo3-introspection/src/model.rs b/pyo3-introspection/src/model.rs index f8e694cd440..7705a0006a4 100644 --- a/pyo3-introspection/src/model.rs +++ b/pyo3-introspection/src/model.rs @@ -24,11 +24,11 @@ pub struct Arguments { /// Regular arguments (between / and *) pub arguments: Vec, /// *vararg - pub vararg: Option, + pub vararg: Option, /// Arguments after * pub keyword_only_arguments: Vec, /// **kwarg - pub kwarg: Option, + pub kwarg: Option, } #[derive(Debug, Eq, PartialEq, Clone, Hash)] @@ -37,3 +37,9 @@ pub struct Argument { /// Default value as a Python expression pub default_value: Option, } + +/// A variable length argument ie. *vararg or **kwarg +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +pub struct VariableLengthArgument { + pub name: String, +} diff --git a/pyo3-introspection/src/stubs.rs b/pyo3-introspection/src/stubs.rs index 241c833526c..dfa6a53a50f 100644 --- a/pyo3-introspection/src/stubs.rs +++ b/pyo3-introspection/src/stubs.rs @@ -1,4 +1,4 @@ -use crate::model::{Argument, Class, Function, Module}; +use crate::model::{Argument, Class, Function, Module, VariableLengthArgument}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -60,7 +60,7 @@ fn function_stubs(function: &Function) -> String { parameters.push(argument_stub(argument)); } if let Some(argument) = &function.arguments.vararg { - parameters.push(format!("*{}", argument_stub(argument))); + parameters.push(format!("*{}", variable_length_argument_stub(argument))); } else if !function.arguments.keyword_only_arguments.is_empty() { parameters.push("*".into()); } @@ -68,7 +68,7 @@ fn function_stubs(function: &Function) -> String { parameters.push(argument_stub(argument)); } if let Some(argument) = &function.arguments.kwarg { - parameters.push(format!("**{}", argument_stub(argument))); + parameters.push(format!("**{}", variable_length_argument_stub(argument))); } format!("def {}({}): ...", function.name, parameters.join(", ")) } @@ -81,3 +81,7 @@ fn argument_stub(argument: &Argument) -> String { } output } + +fn variable_length_argument_stub(argument: &VariableLengthArgument) -> String { + argument.name.clone() +} From 57ef77c81cd1eb5cfec05f3799eb1c4d7917b104 Mon Sep 17 00:00:00 2001 From: Thomas Pellissier-Tanon Date: Tue, 8 Apr 2025 20:15:11 +0200 Subject: [PATCH 5/6] Adds pyfunctions tests --- pytests/tests/test_pyfunctions.py | 36 +++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/pytests/tests/test_pyfunctions.py b/pytests/tests/test_pyfunctions.py index c6fb448248b..21ff3ba362e 100644 --- a/pytests/tests/test_pyfunctions.py +++ b/pytests/tests/test_pyfunctions.py @@ -1,3 +1,5 @@ +from typing import Tuple + from pyo3_pytests import pyfunctions @@ -58,7 +60,7 @@ def test_simple_kwargs_rs(benchmark): def simple_args_kwargs_py(a, b=None, *args, c=None, **kwargs): - return (a, b, args, c, kwargs) + return a, b, args, c, kwargs def test_simple_args_kwargs_py(benchmark): @@ -72,7 +74,7 @@ def test_simple_args_kwargs_rs(benchmark): def args_kwargs_py(*args, **kwargs): - return (args, kwargs) + return args, kwargs def test_args_kwargs_py(benchmark): @@ -83,3 +85,33 @@ def test_args_kwargs_rs(benchmark): rust = benchmark(pyfunctions.args_kwargs, 1, "foo", {1: 2}, bar=4, foo=10) py = args_kwargs_py(1, "foo", {1: 2}, bar=4, foo=10) assert rust == py + + +def positional_only_py(a, /, b): + return a, b + + +def test_positional_only_py(benchmark): + benchmark(positional_only_py, 1, "foo") + + +def test_positional_only_rs(benchmark): + rust = benchmark(pyfunctions.simple_positional_only, 1, "foo") + py = positional_only_py(1, "foo") + assert rust == py + + +def with_typed_args_py( + a: bool, b: int, c: float, d: str +) -> Tuple[bool, int, float, str]: + return a, b, c, d + + +def test_with_typed_args_py(benchmark): + benchmark(with_typed_args_py, True, 1, 1.2, "foo") + + +def test_with_typed_args_rs(benchmark): + rust = benchmark(pyfunctions.with_typed_args, True, 1, 1.2, "foo") + py = with_typed_args_py(True, 1, 1.2, "foo") + assert rust == py From 9d63c36a48e72db74fe909cb104eaa23a320793d Mon Sep 17 00:00:00 2001 From: Thomas Pellissier-Tanon Date: Wed, 9 Apr 2025 07:44:52 +0200 Subject: [PATCH 6/6] Adds some serialization tests --- pyo3-introspection/src/stubs.rs | 64 +++++++++++++++++++++++++++++++ pytests/tests/test_pyfunctions.py | 2 +- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/pyo3-introspection/src/stubs.rs b/pyo3-introspection/src/stubs.rs index dfa6a53a50f..2312d7d37ac 100644 --- a/pyo3-introspection/src/stubs.rs +++ b/pyo3-introspection/src/stubs.rs @@ -85,3 +85,67 @@ fn argument_stub(argument: &Argument) -> String { fn variable_length_argument_stub(argument: &VariableLengthArgument) -> String { argument.name.clone() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Arguments; + + #[test] + fn function_stubs_with_variable_length() { + let function = Function { + name: "func".into(), + arguments: Arguments { + positional_only_arguments: vec![Argument { + name: "posonly".into(), + default_value: None, + }], + arguments: vec![Argument { + name: "arg".into(), + default_value: None, + }], + vararg: Some(VariableLengthArgument { + name: "varargs".into(), + }), + keyword_only_arguments: vec![Argument { + name: "karg".into(), + default_value: None, + }], + kwarg: Some(VariableLengthArgument { + name: "kwarg".into(), + }), + }, + }; + assert_eq!( + "def func(posonly, /, arg, *varargs, karg, **kwarg): ...", + function_stubs(&function) + ) + } + + #[test] + fn function_stubs_without_variable_length() { + let function = Function { + name: "afunc".into(), + arguments: Arguments { + positional_only_arguments: vec![Argument { + name: "posonly".into(), + default_value: Some("1".into()), + }], + arguments: vec![Argument { + name: "arg".into(), + default_value: Some("True".into()), + }], + vararg: None, + keyword_only_arguments: vec![Argument { + name: "karg".into(), + default_value: Some("\"foo\"".into()), + }], + kwarg: None, + }, + }; + assert_eq!( + "def afunc(posonly=1, /, arg=True, *, karg=\"foo\"): ...", + function_stubs(&function) + ) + } +} diff --git a/pytests/tests/test_pyfunctions.py b/pytests/tests/test_pyfunctions.py index 21ff3ba362e..b01239f7468 100644 --- a/pytests/tests/test_pyfunctions.py +++ b/pytests/tests/test_pyfunctions.py @@ -96,7 +96,7 @@ def test_positional_only_py(benchmark): def test_positional_only_rs(benchmark): - rust = benchmark(pyfunctions.simple_positional_only, 1, "foo") + rust = benchmark(pyfunctions.positional_only, 1, "foo") py = positional_only_py(1, "foo") assert rust == py