Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Collecting multiple attribute error #4243

Merged
merged 12 commits into from
Jul 20, 2024
Merged

Conversation

Cheukting
Copy link
Contributor

@Cheukting Cheukting commented Jun 13, 2024

Attempt to clos e #2892 (Edit by @davidhewitt - I think there's more we can do with #[pymethods] probably, though might be a fair bit more work.)

I am not sure how to add the test in CI but I have tested manually with the following:

use pyo3::prelude::*;

#[pyclass]
struct Example {
    #[pyo3(foo)]
    #[pyo3(blah)]
    x: i32,
    #[pyo3(pop)]
    y: i32,
}

Originally it gave:

error: expected one of: `get`, `set`, `name`
 --> pytests/src/pyclasses.rs:7:12
  |
7 |     #[pyo3(foo)]
  |            ^^^

error: cannot find attribute `pyo3` in this scope
  --> pytests/src/pyclasses.rs:10:7
   |
10 |     #[pyo3(pop)]
   |       ^^^^
   |
   = note: `pyo3` is in scope, but it is a crate, not an attribute

error: could not compile `pyo3-pytests` (lib) due to 2 previous errors

Now it gives:

error: expected one of: `get`, `set`, `name`
 --> pytests/src/pyclasses.rs:7:12
  |
7 |     #[pyo3(foo)]
  |            ^^^

error: expected one of: `get`, `set`, `name`
 --> pytests/src/pyclasses.rs:8:12
  |
8 |     #[pyo3(blah)]
  |            ^^^^

error: expected one of: `get`, `set`, `name`
  --> pytests/src/pyclasses.rs:10:12
   |
10 |     #[pyo3(pop)]
   |            ^^^

error: could not compile `pyo3-pytests` (lib) due to 3 previous errors

@Cheukting Cheukting changed the title Collecting multiple attribute errore Collecting multiple attribute error Jun 13, 2024
Copy link
Member

@davidhewitt davidhewitt left a comment

Choose a reason for hiding this comment

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

Thanks, this looks like a great discovery!

To test this, we should update the UI tests to make use of these attributes. For example, see tests/ui/invalid_pyclass_args.rs.

At the moment it has a separate class for each invalid entry. We could instead write pieces which can be combined together, for example:

#[pyclass(
    extend = pyo3::types::PyDict,  // typo in the key
    extends = "PyDict", // Needs to be an ident, not a string
)]
struct InvalidExtends;

... and so on.

See also invalid_pymethods.rs - we have loads of #[pymethods] impl MyClass blocks, and we should be able to combine them all into one with this functionality!

Comment on lines 251 to 259
if !allerr.is_empty() {
let mut error = allerr[0].clone();
for err in &allerr[1..] {
error.combine(err.clone());
}
Err(error)
} else {
Ok(out)
}
Copy link
Member

Choose a reason for hiding this comment

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

Ah, this error.combine() function is extremely handy!

I think now that we know about this, to avoid the allerr vec we can probably have something like this:

struct ErrorCombiner(Option<syn::Error>);

impl ErrorCombiner {
    fn combine(&mut self, error: syn::Error) {
        if let Some(existing) = &mut self.0 {
            existing.combine(error);
        } else {
            self.0 = Some(error);
        }
    }

    fn ensure_empty(self) -> Result<()> {
        if let Some(error) = self.0 {
            Err(error)
        } else {
            None
        }
}

Above, instead of extending the Vec we would combine the error immediately, and then at the end of the function we could just check the combiner is empty:

Suggested change
if !allerr.is_empty() {
let mut error = allerr[0].clone();
for err in &allerr[1..] {
error.combine(err.clone());
}
Err(error)
} else {
Ok(out)
}
error_combiner.ensure_empty()?;
Ok(out)

I think this pattern would be useful in a lot of places in the macro code!

@Cheukting
Copy link
Contributor Author

Thanks for the suggestions @davidhewitt i have added the test and have refactored as suggested.

Copy link
Member

@davidhewitt davidhewitt left a comment

Choose a reason for hiding this comment

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

Thanks, I love these kinds of UX improvements! Just a couple of thoughts related to the implementation still.

(And sorry it took me a few days here, was focussing on #4266 for a while.)

Comment on lines 233 to 276
let mut field_options_res: Vec<Result<(&syn::Field, FieldPyO3Options)>> =
match &mut class.fields {
syn::Fields::Named(fields) => fields
.named
.iter_mut()
.map(|field| {
FieldPyO3Options::take_pyo3_options(&mut field.attrs)
.map(move |options| (&*field, options))
})
.collect::<Vec<_>>(),
syn::Fields::Unnamed(fields) => fields
.unnamed
.iter_mut()
.map(|field| {
FieldPyO3Options::take_pyo3_options(&mut field.attrs)
.map(move |options| (&*field, options))
})
.collect::<Vec<_>>(),
syn::Fields::Unit => {
if let Some(attr) = args.options.set_all {
return Err(syn::Error::new_spanned(attr, UNIT_SET));
};
if let Some(attr) = args.options.get_all {
return Err(syn::Error::new_spanned(attr, UNIT_GET));
};
// No fields for unit struct
Vec::new()
}
};

// handle error here

let mut all_error = ErrorCombiner(None);

let mut field_options: Vec<(&syn::Field, FieldPyO3Options)> = field_options_res
.drain(..)
.filter_map(|result| match result {
Err(err) => {
all_error.combine(err);
None
}
Ok(options) => Some(options),
})
.collect::<Vec<_>>();
Copy link
Member

Choose a reason for hiding this comment

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

I think we should now be able to simplify here and go back to something more like the original implementation and drop the need for the intermediate field_options_res.

I think the idea would be to set up all_errors before the match &mut class.fields. And then instead of collecting into vec-of-results I think it would be possible to swap the .map for .filter_map.

Something like

                .filter_map(|field| {
                    match FieldPyO3Options::take_pyo3_options(&mut field.attrs) {
                        Ok(options) => Some((&*field, options)),
                        Err(e) => {
                            all_errors.combine(e);
                            None
                        }
                    }                    
                })

@Cheukting
Copy link
Contributor Author

Thank you for your patience. I was busy with EuroPython. I plan to finish this PR this weekend or next week.

Cheukting and others added 5 commits July 13, 2024 10:41
Co-authored-by: David Hewitt <mail@davidhewitt.dev>
Co-authored-by: David Hewitt <mail@davidhewitt.dev>
Co-authored-by: David Hewitt <mail@davidhewitt.dev>
Copy link
Member

@davidhewitt davidhewitt left a comment

Choose a reason for hiding this comment

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

Thanks, I'm excited to merge this and hope I can find some time to also play with ErrorCombiner in more places across the codebase :)

@davidhewitt
Copy link
Member

(I took the liberty to fix the new merge conflicts.)

@davidhewitt davidhewitt enabled auto-merge July 20, 2024 06:09
@davidhewitt davidhewitt added this pull request to the merge queue Jul 20, 2024
Merged via the queue into PyO3:main with commit a84dae0 Jul 20, 2024
40 checks passed
@Cheukting
Copy link
Contributor Author

Thank you @davidhewitt

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants