Repository navigation
Introspection: add pyprotocol! to declare protocols in the stubs - #6456
jonasdedden wants to merge 23 commits into
Conversation
e0f3f1e to
42e298b
Compare
|
@Tpt did you have a chance to look at this PR yet, and if yes, what do you generally think? It's a bit clunky, but hopefully a first step in a direction of freely defining |
|
@jonasdedden Sorry for the slow reaction. Thank you for pushing this. A possible downside I see: we will need to be very careful to not generate conflicting "introspection ids" to properly map the generated class chunk with its usage. If we have stuff like A way to circumvent that and avoid the need for The downside I see is that we move our type expression introspection AST beyond what Python supports. WDYT? |
pyprotocol! to declare protocols in the stubs
c4daea1 to
3c7fe6e
Compare
|
@Tpt thanks for your remarks! I reworked this a bit (which comes with a much larger PR, sorry 😢). Library crates now use a The generated protocols are marked
With that actually, everything from the original proposal that I could think of for |
|
@Tpt any thoughts on this? :) (sorry for pinging 😅 ) |
There was a problem hiding this comment.
@jonasdedden Sorry for the answer lag.
Overall, I would love to get a better overview of where we want to go in #6399 to make sure we heading in the right direction to support what we need to support, both in term of UX and implementation. But it seems it's heading in the right direction.
Two (large) open questions:
-
what about using protocols defined this way in custom type annotations? How would it work? A common use case I see is stating that an argument must comply with a protocol.
-
[crazy idea] do we want protocols to go further than stubs? For example I think that from:
pyo3::pyprotocol! {
/// A binary stream that can be read from and written to
pub SupportsRead {
#[pyo3(signature = (n = -1, /))]
fn read(&self, n: isize) -> PyResult<Py<PyBytes>>;
}
}we might actually codegen something that would allow to use the type in the code like:
#[pyfunction]
fn do_io(io_object: Bound<'_, SupportsRead>) -> PyResult<()> {
let read = io_object.read(10)?;
Ok(())
}
It might look something like:
#[repr(transparent)]
pub struct SupportsRead(PyAny);
impl PyTypeInfo for SupportsRead { ... }
pub trait SupportsReadMethods {
fn read(&self, n: isize) -> PyResult<Py<PyBytes>>;
}
impl SupportsReadMethods for Bound<'_, SupportsRead> {
fn read(&self, n: isize) -> PyResult<Py<PyBytes>> { ... }
}This would go in the same line of what other tools like wasm_bindgen do.
Some stuff like default arguments and named arguments might be "fun" to implement though...
A class chunk with `"attach_to_root": true` is adopted by the root module if an annotation reachable from it refers to the class.
497e7b2 to
0e0fd92
Compare
Right now a string annotation like |
I briefly explored your ideas, but I think it has some rough edges. I haven't had time to dig into details yet, here is a Claude exploration of why things aren't ideal, with at least what I tried out:
|
Tpt
left a comment
There was a problem hiding this comment.
Thank you for the iteration. It's looking much better
Merging this PR will not alter performance
Comparing Footnotes
|
Tpt
left a comment
There was a problem hiding this comment.
Thank you for the iteration and the improvements!
I still need to review the pyo3-introspection code carefully and I think this PR is good to go in my point of view
Co-authored-by: Thomas Tanon <thomas@pellissier-tanon.fr>
Tpt
left a comment
There was a problem hiding this comment.
Thanks! It looks good to me. Just to last nit/open questions
|
Do I understand correctly that only |
Yes! This is definitely something we can add later (generic is something not tackled yet at all in our stub generation). |
| ```rust,ignore | ||
| pyo3::pyprotocol! { | ||
| /// A binary stream that can be read from and written to | ||
| pub SupportsReadWrite { |
There was a problem hiding this comment.
Since it's @type_check_only, it might be a bit cleaner to prefix this with _, otherwise someone, or some tool, might misinterpret this as part of the public API.
There was a problem hiding this comment.
This was already part of another discussion, have a look here: #6456 (comment) I gave an example of a typical use case I often have, where one would actually import Protocol from an external package. This is nice, as this negates the need to somehow re-wrap or re-create a Protocol internally (introducing the possibility of getting the Protocols out of sync in the worst and introducing code duplication in the best case).
So I'd say, at least my primary intention behind bringing Protocol support actually IS to make it importable by external packages, i.e. to make it part of a (type-checking-only) public API. The decorator @type_check_only fits this use case perfectly IMHO and as far as I understand it's specifically made for this. That means, at least from the Python docs, I wouldn't consider this to be an anti-pattern.
There was a problem hiding this comment.
Seems a bit risky though, given
- A protocol only appears in the stubs if a type hint refers to it.
It is declared in the root module, so that using it in more modules doesn't move it.
So this could easily lead to accidental breakage if internally that protocol isn't used anymore, but downstream libraries still use it.
There was a problem hiding this comment.
Happy to remove the feature that drops a Protocol if it's unused, if people are generally fine with the type-check-only Protocols.
I believe exporting the Protocols in general (make them runtime-importable) is a bit tricky, as much of this is behind the experimental-inspect feature, which you wouldn't have enabled generally for building the release wheels. Correct me if I'm wrong here.
There was a problem hiding this comment.
I think that keeping protocols private and type-check-only by default makes sense. We also have a lot of such protocols in the NumPy stubs, for example, and would like to keep it that way, so that we don't have to worry about year-long deprecation periods when we remove or change something about them. For those that explicitly make their protocols part of their public API, of which optype is a rather extreme example (it has 337 runtime-accessible protocols in its public API), then they'll likely won't use pyo3 for that, since there's no real advantage in doing so, and just define them in a .py directly.
There was a problem hiding this comment.
For those that explicitly make their protocols part of their public API, [...], then they'll likely won't use pyo3 for that, since there's no real advantage in doing so, and just define them in a .py directly.
A very concrete example would be pyo3-file; here, a PyO3 file object type can generic over it being readable or not, writeable or not, seekable or not, with any combination of that being possible; In theory, one could manually write a Python package and expose it to PyPI where every possible Protocol is defined that exactly matches the specific type requirements of that crate, and ensure the exact type definitions somehow are kept in sync; also, the built PyO3 package then depends on this Python packge, or this is somehow automatically re-exported into every PyO3 package that uses pyo3-file internally as part of the wheel build step. I believe there is value in doing this entirely through PyO3 instead, as AFAIS this is removing a whole lot of failure modes, and instead we have one source of truth for everything.
We also have a lot of such protocols in the NumPy stubs, for example, and would like to keep it that way, so that we don't have to worry about year-long deprecation periods when we remove or change something about them.
As you and also @Tpt raised this issue though, I agree that above use case may be a very niche requirement, and it may be nicer to let the default be way more defensive.
I'd propose the following:
- Let Protocols ALWAYS be
type-check-only(as this is a reflection of what's actually happening) - Let Protocols be private (i.e.
_-prefixed) by default- If they are private, they actually could be placed as close to the actual usage as possible again, i.e. not be placed into the root
__init__.pyiby default
- If they are private, they actually could be placed as close to the actual usage as possible again, i.e. not be placed into the root
- Provide an opt-in way to expose a Protocol as part of a (type-check-only) public API if the user chooses so
- In this case, the user has to provide a name explicitly (as otherwise, name changes are a breaking change for users); I guess it would be nice to enforce exposing it to
__init__.pyithen (as otherwise, moving defintion into other modules also is a breaking change).
- In this case, the user has to provide a name explicitly (as otherwise, name changes are a breaking change for users); I guess it would be nice to enforce exposing it to
There was a problem hiding this comment.
I really like this proposal too! Thank you for iterating on it!
An API proposal: if the module = "foo.bar" macro parameter is set then the protocol is exposed publicly in the corresponding module. If not it is private. This way the place where the protocol is exposed is an explicit decision
There was a problem hiding this comment.
Thanks! I went with the same idea (private by default, explicit opt-in), but made the opt-in an export instead of a module option: #[pymodule_export] use some_crate::SupportsRead; makes it public in that module.
The reason is library crates: something like pyo3-file declares the protocols but can't know the module names of the extensions using it, so module = "..." on the declaration wouldn't work there. With the export, the extension that owns the Python API decides where (and whether) it's public, the same way as for classes. A library can also bundle its protocols in a #[pymodule], so an extension can export them all with one line.
Private protocols go as _Name into the deepest module containing all their users. I also dropped the automatic renaming: any name clash is now an error, and #[pyo3(name = "...")] resolves it.
I also added the limitations jorenham mentioned (methods only, no attributes, properties or generics) to the guide.
Maybe it's an idea to document this as "Limitations" or something then? |
Part of #6399 (protocols).
Adds
pyo3::pyprotocol!(withexperimental-inspect), which declares atyping.Protocolthat only exists in the generated stubs:By default the protocol is private, next to where it's used:
A module can make it public by exporting it, like a class. It's then declared there as
SupportsReadWrite, even if nothing uses it, and can be imported in anif TYPE_CHECKING:block:protocolchunk with its methods inline.pyo3-introspectionputs a private one as_Namein the deepest module containing all modules that use it, and leaves out unused ones. An exported one goes in the exporting module under its own name. The extension owning the Python API decides what's public, so a library likepyo3-filedoesn't have to know its users' modules, and it can bundle its protocols in a#[pymodule]that an extension exports in one line.#[pyo3(name = "...")]sets another name. A protocol can only be exported by one module.@type_check_onlyand not listed in__all__, so the stubs pass stubtest.module_path!()plus the name, so there is one chunk per protocol. Astaticcan't depend on a generic parameter, so protocols that depend onTcan't be expressed.IntoPyObject::OUTPUT_TYPEand return valuesFromPyObject::INPUT_TYPE, since Rust is the caller. Parameters are positional-only unless asignatureis given, and annotations from thesignatureare kept. Methods must take&self.#[pyo3]options:nameandcratefor protocols,nameandsignaturefor methods.