Skip to content
New issue

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

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

Already on GitHub? Sign in 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.)

tests/ui/invalid_pyclass_args.rs Outdated Show resolved Hide resolved
pyo3-macros-backend/src/attributes.rs Outdated Show resolved Hide resolved
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
                        }
                    }                    
                })

newsfragments/4243.changed.md Outdated Show resolved Hide resolved
@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.

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

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

Successfully merging this pull request may close these issues.

2 participants