Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 210 additions & 11 deletions crates/derive-impl/src/from_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,13 @@ struct LegacyFromArgs {
fname: String,
}

fn parse_from_args_attr(attrs: &[Attribute]) -> Result<Option<LegacyFromArgs>> {
enum FromArgsMode {
Owned,
Legacy(LegacyFromArgs),
Borrowed,
}

fn parse_from_args_attr(attrs: &[Attribute]) -> Result<FromArgsMode> {
let mut found = None;
for attr in attrs {
if !attr.path().is_ident("from_args") {
Expand All @@ -635,13 +641,20 @@ fn parse_from_args_attr(attrs: &[Attribute]) -> Result<Option<LegacyFromArgs>> {
found = Some(attr);
}
let Some(attr) = found else {
return Ok(None);
return Ok(FromArgsMode::Owned);
};

let mut borrowed = false;
let mut legacy = false;
let mut fname = None;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("legacy") {
if meta.path.is_ident("borrowed") {
if borrowed {
return Err(meta.error("duplicate `borrowed`"));
}
borrowed = true;
Ok(())
} else if meta.path.is_ident("legacy") {
if legacy {
return Err(meta.error("duplicate `legacy`"));
}
Expand All @@ -655,21 +668,28 @@ fn parse_from_args_attr(attrs: &[Attribute]) -> Result<Option<LegacyFromArgs>> {
fname = Some(val.value());
Ok(())
} else {
Err(meta.error("unknown #[from_args] key; expected `legacy` or `fname = \"...\"`"))
Err(meta.error(
"unknown #[from_args] key; expected `borrowed`, `legacy`, or `fname = \"...\"`",
))
}
})?;

match (legacy, fname) {
(true, Some(fname)) => Ok(Some(LegacyFromArgs { fname })),
(true, None) => Err(err_span!(
match (borrowed, legacy, fname) {
(true, false, None) => Ok(FromArgsMode::Borrowed),
(false, true, Some(fname)) => Ok(FromArgsMode::Legacy(LegacyFromArgs { fname })),
(true, _, _) => Err(err_span!(
attr,
"borrowed FromArgs uses Clinic binding; legacy parsing requires owned arguments"
)),
(false, true, None) => Err(err_span!(
attr,
"#[from_args(legacy)] requires fname = \"...\""
)),
(false, Some(_)) => Err(err_span!(
(false, false, Some(_)) => Err(err_span!(
attr,
"#[from_args(fname = \"...\")] requires `legacy`"
)),
(false, None) => Err(err_span!(
(false, false, None) => Err(err_span!(
attr,
"#[from_args] requires `legacy` and fname = \"...\""
)),
Expand Down Expand Up @@ -768,16 +788,139 @@ fn compute_arity_bounds(field_attrs: &[ArgAttribute]) -> (usize, usize) {
(min_arity, max_arity)
}

fn impl_borrowed_from_args(input: &DeriveInput, fields: &syn::Fields) -> Result<TokenStream> {
let lifetimes = input.generics.lifetimes().collect::<Vec<_>>();
if lifetimes.len() != 1 || input.generics.params.len() != 1 {
bail_span!(
input.generics,
"borrowed FromArgs requires exactly one lifetime and no type or const parameters"
);
}
let lifetime = &lifetimes[0].lifetime;
let name = &input.ident;
let mut params = Vec::new();
let mut values = Vec::new();
let attrs = fields
.iter()
.filter(|field| !is_phantom(field))
.map(ArgAttribute::try_from)
.collect::<Result<Vec<_>>>()?;
let (min, max) = compute_arity_bounds(&attrs);
let mut slot = 0usize;
for (i, field) in fields.iter().enumerate() {
let attr = ArgAttribute::try_from(field)?;
if is_phantom(field) {
let member = &field.ident;
values.push(quote!(#member: ::core::marker::PhantomData,));
continue;
}
if attr.kind == ParameterKind::Flatten {
bail_span!(field, "borrowed FromArgs does not support flattened fields");
}
if attr.py_default.is_some() && attr.default.is_none() {
bail_span!(
field,
"a borrowed py_default requires optional or default omission handling"
);
}
params.push(param_token(field, &attr)?);
let member = field.ident.as_ref().map_or_else(
|| syn::Index::from(i).into_token_stream(),
ToTokens::to_token_stream,
);
let pyname = attr
.name
.clone()
.or_else(|| field.ident.as_ref().map(|name| name.unraw().to_string()))
.ok_or_else(|| err_span!(field, "field in tuple struct must have name attribute"))?;
let mut convert = quote! {
::rustpython_vm::convert::TryFromBorrowedObject::try_from_borrowed_argument(vm, x)
};
if let Some(message) = &attr.error_msg {
convert = quote! {
#convert.map_err(|_| ::rustpython_vm::function::ArgumentError::from(vm.new_type_error(#message)))
};
} else if attr.kind != ParameterKind::PositionalOnly {
convert = quote! { #convert.map_err(|err| err.with_argument_name(#pyname)) };
}
let ending = if let Some(default) = attr.default {
let ty = &field.ty;
if let Some(expr) = &default
&& !keeps_direct_literal(ty)
&& !is_wrapped_arg_ty(ty)
&& literal_object_expr(expr).is_some()
{
bail_span!(
field,
"a borrowed default cannot allocate an owned Python object; use OptionalArg with py_default or a Rust converter constant"
);
}
let default = match default {
Some(expr) => match absolute_const_ident(&expr)? {
Some(name) => quote!(::core::convert::Into::into(#name)),
None => quote!(#expr),
},
None => quote!(::core::default::Default::default()),
};
quote! {
.map(<#ty as ::rustpython_vm::function::FromBorrowedArgOptional<#lifetime>>::from_inner)
.unwrap_or_else(|| #default)
}
} else {
quote! { .expect("Clinic checked the required argument") }
};
let i = slot;
slot += 1;
let pos = i + 1;
values.push(quote! {
#member: slots[#i].map(|x| #convert)
.transpose()
.map_err(|error| error.into_exception(&(#min..=#max), args.args.len(), #pos, callee, vm))?
#ending,
});
}
let n = slot;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
Ok(quote! {
impl #impl_generics ::rustpython_vm::function::ArgsMetadata for #name #ty_generics #where_clause {
const DISPLAY_PARAMS: ::core::option::Option<&'static [::rustpython_vm::function::Param]> =
Some(&[#(#params),*]);
}

impl ::rustpython_vm::function::BorrowedArgs for #name<'static> {
type Bound<#lifetime> = #name<#lifetime>;

#[inline]
fn bind<#lifetime>(
args: &#lifetime ::rustpython_vm::function::FuncArgs,
vm: &::rustpython_vm::VirtualMachine,
callee: ::rustpython_vm::function::Callee,
) -> ::rustpython_vm::PyResult<Self::Bound<#lifetime>> {
let slots = args.bind_borrowed::<#n, Self>(vm, callee)?;
Ok(#name { #(#values)* })
}
}
})
}

pub(crate) fn impl_from_args(input: DeriveInput) -> Result<TokenStream> {
let legacy = match parse_from_args_attr(&input.attrs)? {
FromArgsMode::Borrowed => {
let Data::Struct(data) = &input.data else {
bail_span!(input, "FromArgs input must be a struct");
};
return impl_borrowed_from_args(&input, &data.fields);
}
FromArgsMode::Legacy(legacy) => Some(legacy),
FromArgsMode::Owned => None,
};
let Data::Struct(syn::DataStruct {
fields: struct_fields,
..
}) = input.data
else {
bail_span!(input, "FromArgs input must be a struct")
};

let legacy = parse_from_args_attr(&input.attrs)?;
if legacy.is_some() {
validate_legacy_fields(&struct_fields)?;
}
Expand Down Expand Up @@ -921,6 +1064,62 @@ pub(crate) fn impl_from_args(input: DeriveInput) -> Result<TokenStream> {
mod tests {
use super::*;

#[test]
fn borrowed_codegen_uses_one_binding_and_no_owners() {
let input = parse_quote! {
#[from_args(borrowed)]
struct Args<'a> {
#[pyarg(any)]
value: &'a PyObject,
#[pyarg(named, optional, py_default = "None")]
optional: OptionalArg<&'a Py<PyDict>>,
}
};
let tokens = impl_from_args(input).unwrap().to_string();
assert_eq!(tokens.matches("bind_borrowed").count(), 1);
assert_eq!(tokens.matches("try_from_borrowed_argument").count(), 2);
for forbidden in [
"clone",
"Vec",
"ArgSpec",
"take_keyword",
"take_positional",
"check_before_convert",
] {
assert!(!tokens.contains(forbidden), "{forbidden}: {tokens}");
}
assert!(tokens.contains("BorrowedArgs"));
assert!(tokens.contains("ArgsMetadata"));
}

#[test]
fn a_lifetime_alone_does_not_change_owned_binding() {
let input = parse_quote! {
struct Args<'a> {
#[pyarg(flatten)]
value: ExistingOwnedArgs<'a>,
}
};
let tokens = impl_from_args(input).unwrap().to_string();
assert!(tokens.contains("FromArgs for Args"));
assert!(!tokens.contains("BorrowedArgs"));
}

#[test]
fn borrowed_binding_rejects_unsupported_layouts_and_defaults() {
for input in [
parse_quote! { #[from_args(borrowed)] struct Args { value: PyObjectRef } },
parse_quote! { #[from_args(borrowed)] struct Args<'a> { #[pyarg(any, py_default = "None")] value: &'a PyObject } },
parse_quote! { #[from_args(borrowed)] struct Args<'a, T> { value: &'a T } },
parse_quote! { #[from_args(borrowed)] struct Args<'a> { #[pyarg(flatten)] value: &'a PyObject } },
parse_quote! { #[from_args(borrowed)] struct Args<'a> { #[pyarg(any, default = "x")] value: &'a Py<PyStr> } },
parse_quote! { #[from_args(borrowed)] #[from_args(legacy, fname = "f")] struct Args<'a> { value: &'a PyObject } },
parse_quote! { #[from_args(borrowed, legacy)] struct Args<'a> { value: &'a PyObject } },
] {
assert!(impl_from_args(input).is_err());
}
}

#[test]
fn option_none_default_keeps_rust_value_and_metadata() {
let types: [Type; 2] = [
Expand Down
29 changes: 29 additions & 0 deletions crates/derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,35 @@ use syn::punctuated::Punctuated;
/// | `Option<T>` | `None` or a value. A missing argument and an explicit `None` are the same | `= None` | `None` |
/// | `OptionalOption<T>` (`OptionalArg<Option<T>>`) | missing, `None`, and a value are all distinct | `= NULL`, and `None` is accepted | `<unrepresentable>` |
///
/// # Borrowed arguments
/// `#[from_args(borrowed)]` selects borrowing with the same strict Clinic rules:
///
/// ```rust,ignore
/// #[derive(FromArgs)]
/// #[from_args(borrowed)]
/// struct CallArgs<'a> {
/// #[pyarg(any)]
/// object: &'a PyObject,
/// #[pyarg(named, default = ArgIntoBool::FALSE, py_default = "False")]
/// flag: ArgIntoBool,
/// }
///
/// #[pyfunction]
/// fn call(args: CallArgs<'_>, vm: &VirtualMachine) -> PyResult { /* ... */ }
/// ```
///
/// Binding uses stack slots and `TryFromBorrowedObject`, leaving the native
/// call's original owners alive through the implementation and return conversion.
/// It adds no owning copies or heap allocation. All Clinic binding errors are
/// checked before converting fields in declaration order. `OptionalArg<&Py<T>>`
/// distinguishes omission from an explicitly supplied value without constructing
/// a default Python object. Rust converter constants can supply scalar defaults.
/// A borrowed `py_default` requires `optional` or `default` omission handling.
///
/// Borrowed structs currently support fixed fields, one lifetime, and no other
/// generic parameters. Flattened and legacy bindings remain owned. A borrowed
/// struct is passed as the sole arguments struct followed by `&VirtualMachine`.
///
/// # `#[from_args(legacy, fname = "...")]`
/// Bind arguments the way `PyArg_ParseTupleAndKeywords` does: convert each
/// supplied value in parameter order, then report a missing argument, a leftover
Expand Down
1 change: 1 addition & 0 deletions crates/vm/src/builtins/tuple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ thread_local! {
impl PyPayload for PyTuple {
const MAX_FREELIST: usize = 2000;
const HAS_FREELIST: bool = true;
const REVERSE_DEALLOC: bool = true;

#[inline]
fn class(ctx: &Context) -> &'static Py<PyType> {
Expand Down
35 changes: 35 additions & 0 deletions crates/vm/src/convert/try_from.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ where
{
/// Attempt to convert a Python object to a value of this type.
fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult<Self>;

#[inline]
fn try_from_borrowed_argument(
vm: &VirtualMachine,
obj: &'a PyObject,
) -> Result<Self, ArgumentError> {
Self::try_from_borrowed_object(vm, obj).map_err(Into::into)
}
}

impl<'a> TryFromBorrowedObject<'a> for &'a PyObject {
#[inline]
fn try_from_borrowed_object(_vm: &VirtualMachine, obj: &'a PyObject) -> PyResult<Self> {
Ok(obj)
}
}

impl<T> TryFromObject for PyRef<T>
Expand Down Expand Up @@ -155,6 +170,26 @@ impl<'a, T: PyPayload> TryFromBorrowedObject<'a> for &'a Py<T> {
Err(vm.new_downcast_type_error(class, &obj))
}
}

#[inline]
fn try_from_borrowed_argument(
vm: &VirtualMachine,
obj: &'a PyObject,
) -> Result<Self, ArgumentError> {
let class = T::class(&vm.ctx);
if obj.fast_isinstance(class) {
obj.try_downcast_ref::<T>(vm).map_err(Into::into)
} else {
Err(ArgumentError::BadArgument {
expected: class,
or_none: false,
// Only an error needs an owning diagnostic, never the call's
// successful borrowed conversion path.
found: obj.to_owned(),
name: None,
})
}
}
}

impl TryFromObject for core::time::Duration {
Expand Down
Loading
Loading