From ffad12d947c3c5172676cffc894ae96be052afee Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Wed, 5 Aug 2026 22:35:12 +0200 Subject: [PATCH] `experimental-inspect`: cover the `Option`-returning `__next__` / `__anext__` shapes Route A: one wrapper carrying both the conversion and the type hint Review pass on the `IterNextOutput` wrapper pytests: assert the generated `__next__` / `__anext__` hints --- guide/src/class/protocols.md | 2 +- newsfragments/6274.fixed.md | 1 + pyo3-macros-backend/src/py_expr.rs | 50 +++++- pyo3-macros-backend/src/pyimpl.rs | 9 + pyo3-macros-backend/src/pymethod.rs | 44 ++--- pytests/pyproject.toml | 3 +- pytests/src/awaitable.rs | 2 +- pytests/src/pyclasses.rs | 91 +++++++++- pytests/stubs/pyclasses.pyi | 28 +++ pytests/tests/test_pyclasses.py | 36 ++++ src/impl_/pymethods.rs | 257 ++++++++++++---------------- 11 files changed, 352 insertions(+), 171 deletions(-) create mode 100644 newsfragments/6274.fixed.md diff --git a/guide/src/class/protocols.md b/guide/src/class/protocols.md index d4a9801f73e..1d32a98fa1e 100644 --- a/guide/src/class/protocols.md +++ b/guide/src/class/protocols.md @@ -166,7 +166,7 @@ The given signatures should be interpreted as follows: Iterators can be defined using these methods: - `__iter__() -> object` -- `__next__() -> Option or IterNextOutput` ([see details](#returning-a-value-from-iteration)) +- `__next__() -> Option` ([see details](#returning-a-value-from-iteration)) Returning `None` from `__next__` indicates that that there are no further items. diff --git a/newsfragments/6274.fixed.md b/newsfragments/6274.fixed.md new file mode 100644 index 00000000000..a28fe7c3f3f --- /dev/null +++ b/newsfragments/6274.fixed.md @@ -0,0 +1 @@ +`experimental-inspect`: `__next__` and `__anext__` returning `Option` (or `PyResult>`) are now introspected as returning `T`, since `None` stops the iteration instead of being yielded. diff --git a/pyo3-macros-backend/src/py_expr.rs b/pyo3-macros-backend/src/py_expr.rs index 893addcc8b2..3a58fadb15d 100644 --- a/pyo3-macros-backend/src/py_expr.rs +++ b/pyo3-macros-backend/src/py_expr.rs @@ -2,7 +2,7 @@ use crate::utils::PyO3CratePath; use proc_macro2::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; use std::borrow::Cow; use syn::visit_mut::{visit_type_mut, VisitMut}; use syn::{Expr, ExprLit, ExprPath, Lit, Type}; @@ -22,6 +22,10 @@ pub enum PyExpr { ArgumentType(Type), /// The Python type matching the given Rust type given as a function returned value ReturnType(Type), + /// The Python type `__next__` yields, without the `Option` meaning `StopIteration` + IterNextReturnType(Type), + /// The Python type `__anext__` yields, without the `Option` meaning `StopAsyncIteration` + AsyncIterNextReturnType(Type), /// The Python type matching the given Rust type Type(Type), /// A name @@ -116,6 +120,20 @@ impl PyExpr { Self::ReturnType(clean_type(t, self_type)) } + /// The type hint of the Rust type used as the output type of `__next__` + /// + /// If self_type is set, self_type will replace Self in the given type + pub fn from_iter_next_return_type(t: Type, self_type: Option<&Type>) -> Self { + Self::IterNextReturnType(clean_type(t, self_type)) + } + + /// The type hint of the Rust type used as the output type of `__anext__` + /// + /// If self_type is set, self_type will replace Self in the given type + pub fn from_async_iter_next_return_type(t: Type, self_type: Option<&Type>) -> Self { + Self::AsyncIterNextReturnType(clean_type(t, self_type)) + } + /// The type hint of the Rust type `PyTypeCheck` trait. /// /// If self_type is set, self_type will replace Self in the given type @@ -228,6 +246,15 @@ impl PyExpr { TYPE }} } + Self::IterNextReturnType(t) => { + iter_next_output_type(pyo3_crate_path, t, "IterNextOutput", "IterNextTypeFallback") + } + Self::AsyncIterNextReturnType(t) => iter_next_output_type( + pyo3_crate_path, + t, + "AsyncIterNextOutput", + "AsyncIterNextTypeFallback", + ), Self::Type(t) => { quote! { <#t as #pyo3_crate_path::type_object::PyTypeCheck>::TYPE_HINT } } @@ -287,6 +314,27 @@ impl PyExpr { } } +/// The type hint of what `__next__` / `__anext__` yields, read off the same wrapper the slot uses +/// to convert the returned value so that the stub and the runtime agree on which return types say +/// "iteration is over" with `None`. +fn iter_next_output_type( + pyo3_crate_path: &PyO3CratePath, + t: &Type, + wrapper: &str, + fallback: &str, +) -> TokenStream { + let wrapper = format_ident!("{wrapper}"); + let fallback = format_ident!("{fallback}"); + quote! {{ + #[allow( + unused_imports, + reason = "the fallback trait is unused when the inherent const applies" + )] + use #pyo3_crate_path::impl_::pymethods::#fallback as _; + #pyo3_crate_path::impl_::pymethods::#wrapper::<#t>::OUTPUT_TYPE + }} +} + fn clean_type(mut t: Type, self_type: Option<&Type>) -> Type { if let Some(self_type) = self_type { replace_self(&mut t, self_type); diff --git a/pyo3-macros-backend/src/pyimpl.rs b/pyo3-macros-backend/src/pyimpl.rs index 05dfd44e3d4..3f9bd6be3be 100644 --- a/pyo3-macros-backend/src/pyimpl.rs +++ b/pyo3-macros-backend/src/pyimpl.rs @@ -503,6 +503,15 @@ pub fn method_introspection_code( PyExpr::from_return_type(parse_quote!(#pyo3_path::PyClassGuard), Some(parent)) } else { match spec.output.clone() { + // `__next__` and `__anext__` may say "iteration is over" with `None`, in which case + // that `Option` is not part of the Python-visible return type. This is about the value + // the slot returns, so it does not apply to the coroutine an `async fn` returns. + ReturnType::Type(_, t) if spec.asyncness.is_none() && name.as_str() == "__next__" => { + PyExpr::from_iter_next_return_type(*t, Some(parent)) + } + ReturnType::Type(_, t) if spec.asyncness.is_none() && name.as_str() == "__anext__" => { + PyExpr::from_async_iter_next_return_type(*t, Some(parent)) + } ReturnType::Type(_, t) => PyExpr::from_return_type(*t, Some(parent)), ReturnType::Default => PyExpr::none(), } diff --git a/pyo3-macros-backend/src/pymethod.rs b/pyo3-macros-backend/src/pymethod.rs index 64564b418f4..f1c59254db7 100644 --- a/pyo3-macros-backend/src/pymethod.rs +++ b/pyo3-macros-backend/src/pymethod.rs @@ -1096,18 +1096,15 @@ pub const __RICHCMP__: SlotDef = SlotDef::new("Py_tp_richcompare", "richcmpfunc" .extract_error_mode(ExtractErrorMode::NotImplemented); const __GET__: SlotDef = SlotDef::new("Py_tp_descr_get", "descrgetfunc"); const __ITER__: SlotDef = SlotDef::new("Py_tp_iter", "getiterfunc"); -const __NEXT__: SlotDef = SlotDef::new("Py_tp_iternext", "iternextfunc") - .return_specialized_conversion( - TokenGenerator(|_| quote! { IterBaseKind, IterOptionKind, IterResultOptionKind }), - TokenGenerator(|_| quote! { iter_tag }), - ); +const __NEXT__: SlotDef = SlotDef::new("Py_tp_iternext", "iternextfunc").return_iter_conversion( + TokenGenerator(|_| quote! { IterNextOutput }), + TokenGenerator(|_| quote! { IterNextConvertFallback }), +); const __AWAIT__: SlotDef = SlotDef::new("Py_am_await", "unaryfunc"); const __AITER__: SlotDef = SlotDef::new("Py_am_aiter", "unaryfunc"); -const __ANEXT__: SlotDef = SlotDef::new("Py_am_anext", "unaryfunc").return_specialized_conversion( - TokenGenerator( - |_| quote! { AsyncIterBaseKind, AsyncIterOptionKind, AsyncIterResultOptionKind }, - ), - TokenGenerator(|_| quote! { async_iter_tag }), +const __ANEXT__: SlotDef = SlotDef::new("Py_am_anext", "unaryfunc").return_iter_conversion( + TokenGenerator(|_| quote! { AsyncIterNextOutput }), + TokenGenerator(|_| quote! { AsyncIterNextConvertFallback }), ); pub const __LEN__: SlotDef = SlotDef::new("Py_mp_length", "lenfunc"); const __CONTAINS__: SlotDef = SlotDef::new("Py_sq_contains", "objobjproc"); @@ -1299,7 +1296,10 @@ fn extract_object( enum ReturnMode { ReturnSelf, Conversion(TokenGenerator), - SpecializedConversion(TokenGenerator, TokenGenerator), + /// `__next__` / `__anext__`: the return value goes through the wrapper named by the first + /// generator, whose inherent `convert` handles the return types saying "iteration is over" + /// with `None`, and whose fallback trait, named by the second, handles all the others. + IterConversion(TokenGenerator, TokenGenerator), } impl ReturnMode { @@ -1313,13 +1313,17 @@ impl ReturnMode { #pyo3_path::impl_::callback::convert(py, _result) } } - ReturnMode::SpecializedConversion(traits, tag) => { - let traits = TokenGeneratorCtx(*traits, ctx); - let tag = TokenGeneratorCtx(*tag, ctx); + ReturnMode::IterConversion(wrapper, fallback) => { + let wrapper = TokenGeneratorCtx(*wrapper, ctx); + let fallback = TokenGeneratorCtx(*fallback, ctx); quote! { let _result = #call; - use #pyo3_path::impl_::pymethods::{#traits}; - (&_result).#tag().convert(py, _result) + #[allow( + unused_imports, + reason = "the fallback trait is unused when the inherent `convert` applies" + )] + use #pyo3_path::impl_::pymethods::#fallback as _; + #pyo3_path::impl_::pymethods::#wrapper(_result).convert(py) } } ReturnMode::ReturnSelf => quote! { @@ -1434,12 +1438,12 @@ impl SlotDef { self } - const fn return_specialized_conversion( + const fn return_iter_conversion( mut self, - traits: TokenGenerator, - tag: TokenGenerator, + wrapper: TokenGenerator, + fallback: TokenGenerator, ) -> Self { - self.return_mode = Some(ReturnMode::SpecializedConversion(traits, tag)); + self.return_mode = Some(ReturnMode::IterConversion(wrapper, fallback)); self } diff --git a/pytests/pyproject.toml b/pytests/pyproject.toml index 593a11ae6ba..5d3c9f8a3da 100644 --- a/pytests/pyproject.toml +++ b/pytests/pyproject.toml @@ -34,5 +34,6 @@ dev = [ "pytest-asyncio>=0.21,<2", "pytest-benchmark>=3.4", "pytest>=7", - "typing_extensions>=4.0.0" + # 4.2 for `assert_type` + "typing_extensions>=4.2.0" ] diff --git a/pytests/src/awaitable.rs b/pytests/src/awaitable.rs index e13a569c3c6..e3ea38bd730 100644 --- a/pytests/src/awaitable.rs +++ b/pytests/src/awaitable.rs @@ -21,7 +21,7 @@ pub mod awaitable { #[pymethods] impl IterAwaitable { #[new] - fn new(result: Py) -> Self { + pub(crate) fn new(result: Py) -> Self { IterAwaitable { result: Some(Ok(result)), } diff --git a/pytests/src/pyclasses.rs b/pytests/src/pyclasses.rs index dd8063de0c2..c5232e35c54 100644 --- a/pytests/src/pyclasses.rs +++ b/pytests/src/pyclasses.rs @@ -7,6 +7,8 @@ use pyo3::types::{PyComplex, PyType}; #[cfg(not(any(Py_LIMITED_API, GraalPy)))] use pyo3::types::{PyDict, PyTuple}; +use crate::awaitable::awaitable::IterAwaitable; + #[pyclass(from_py_object)] #[derive(Clone, Default)] pub struct EmptyClass {} @@ -50,6 +52,92 @@ impl PyClassIter { } } +/// This is for demonstrating how to stop iteration by returning `None` from __next__ +#[pyclass] +#[derive(Default)] +struct PyClassOptionIter { + count: usize, +} + +#[pymethods] +impl PyClassOptionIter { + #[new] + pub fn new() -> Self { + Default::default() + } + + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __next__(&mut self) -> Option { + if self.count < 5 { + self.count += 1; + Some(self.count) + } else { + None + } + } +} + +/// This is for demonstrating how to stop iteration by returning `None` from a fallible __next__ +#[pyclass] +#[derive(Default)] +struct PyClassResultOptionIter { + count: usize, +} + +#[pymethods] +impl PyClassResultOptionIter { + #[new] + pub fn new() -> Self { + Default::default() + } + + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + #[expect(clippy::unnecessary_wraps, reason = "covering the fallible signature")] + fn __next__(&mut self) -> PyResult> { + if self.count < 5 { + self.count += 1; + Ok(Some(self.count)) + } else { + Ok(None) + } + } +} + +/// This is for demonstrating how to stop iteration by returning `None` from __anext__ +#[pyclass] +#[derive(Default)] +struct PyClassOptionAsyncIter { + count: usize, +} + +#[pymethods] +impl PyClassOptionAsyncIter { + #[new] + pub fn new() -> Self { + Default::default() + } + + fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __anext__(&mut self, py: Python<'_>) -> PyResult> { + if self.count >= 5 { + return Ok(None); + } + self.count += 1; + // `__anext__` hands back an awaitable, which `async for` awaits for the next value. + let value = self.count.into_pyobject(py)?.into_any().unbind(); + Ok(Some(IterAwaitable::new(value))) + } +} + #[pyclass] #[derive(Default)] struct PyClassThreadIter { @@ -341,6 +429,7 @@ pub mod pyclasses { #[pymodule_export] use super::{ map_a_class, AssertingBaseClass, ClassWithDecorators, ClassWithoutConstructor, EmptyClass, - Number, PlainObject, PyClassIter, PyClassThreadIter, + Number, PlainObject, PyClassIter, PyClassOptionAsyncIter, PyClassOptionIter, + PyClassResultOptionIter, PyClassThreadIter, }; } diff --git a/pytests/stubs/pyclasses.pyi b/pytests/stubs/pyclasses.pyi index 64692e0dc9c..e385b2f886b 100644 --- a/pytests/stubs/pyclasses.pyi +++ b/pytests/stubs/pyclasses.pyi @@ -1,3 +1,4 @@ +from .awaitable import IterAwaitable from _typeshed import Incomplete from typing import Final, final @@ -121,6 +122,33 @@ class PyClassIter: """ def __next__(self, /) -> int: ... +@final +class PyClassOptionAsyncIter: + """ + This is for demonstrating how to stop iteration by returning `None` from __anext__ + """ + def __aiter__(self, /) -> PyClassOptionAsyncIter: ... + def __anext__(self, /) -> IterAwaitable: ... + def __new__(cls, /) -> PyClassOptionAsyncIter: ... + +@final +class PyClassOptionIter: + """ + This is for demonstrating how to stop iteration by returning `None` from __next__ + """ + def __iter__(self, /) -> PyClassOptionIter: ... + def __new__(cls, /) -> PyClassOptionIter: ... + def __next__(self, /) -> int: ... + +@final +class PyClassResultOptionIter: + """ + This is for demonstrating how to stop iteration by returning `None` from a fallible __next__ + """ + def __iter__(self, /) -> PyClassResultOptionIter: ... + def __new__(cls, /) -> PyClassResultOptionIter: ... + def __next__(self, /) -> int: ... + @final class PyClassThreadIter: def __new__(cls, /) -> PyClassThreadIter: ... diff --git a/pytests/tests/test_pyclasses.py b/pytests/tests/test_pyclasses.py index 024e1ece33e..c9862a30765 100644 --- a/pytests/tests/test_pyclasses.py +++ b/pytests/tests/test_pyclasses.py @@ -1,8 +1,12 @@ +import asyncio import platform import sys +from collections.abc import Iterator import pytest from pyo3_pytests import pyclasses +from pyo3_pytests.awaitable import IterAwaitable +from typing_extensions import assert_type def test_empty_class_init(benchmark): @@ -54,6 +58,38 @@ def test_iter(): assert excinfo.value.value == "Ended" +@pytest.mark.parametrize( + "cls", [pyclasses.PyClassOptionIter, pyclasses.PyClassResultOptionIter] +) +def test_option_iter(cls): + assert list(cls()) == [1, 2, 3, 4, 5] + + i = cls() + for _ in range(5): + next(i) + with pytest.raises(StopIteration): + next(i) + + +def test_option_async_iter(): + async def collect(): + return [value async for value in pyclasses.PyClassOptionAsyncIter()] + + assert asyncio.run(collect()) == [1, 2, 3, 4, 5] + + +def test_option_iter_type_hints() -> None: + # `None` stops the iteration rather than being yielded, so these classes are `Iterator[int]` + # and not `Iterator[int | None]` + plain: Iterator[int] = pyclasses.PyClassOptionIter() + fallible: Iterator[int] = pyclasses.PyClassResultOptionIter() + assert_type(next(plain), int) + assert_type(next(fallible), int) + + # `__anext__` likewise hands back the awaitable itself, not `IterAwaitable | None` + assert_type(pyclasses.PyClassOptionAsyncIter().__anext__(), IterAwaitable) + + @pytest.mark.skipif( platform.machine() in ["wasm32", "wasm64"], reason="not supporting threads in CI for WASM yet", diff --git a/src/impl_/pymethods.rs b/src/impl_/pymethods.rs index 07e1e929508..ad18f99b3c5 100644 --- a/src/impl_/pymethods.rs +++ b/src/impl_/pymethods.rs @@ -3,9 +3,13 @@ use crate::exceptions::PyStopAsyncIteration; use crate::impl_::callback::IntoPyCallbackOutput; +#[cfg(feature = "experimental-inspect")] +use crate::impl_::introspection::PyReturnType; use crate::impl_::panic::PanicTrap; use crate::impl_::pycell::PyClassObjectBaseLayout; use crate::impl_::pyclass::PyClassDict as _; +#[cfg(feature = "experimental-inspect")] +use crate::inspect::PyStaticExpr; use crate::internal::get_slot::{get_slot, TP_BASE, TP_CLEAR, TP_TRAVERSE}; use crate::internal::pyclass_init::PyClassInit; use crate::internal::state::ForbidAttaching; @@ -610,167 +614,101 @@ unsafe fn call_super_clear( 0 } -// Autoref-based specialization for handling `__next__` returning `Option` - -pub struct IterBaseTag; - -impl IterBaseTag { - #[inline] - pub fn convert<'py, Value, Target>(self, py: Python<'py>, value: Value) -> PyResult - where - Value: IntoPyCallbackOutput<'py, Target>, - { - value.convert(py) - } -} - -pub trait IterBaseKind { - #[inline] - fn iter_tag(&self) -> IterBaseTag { - IterBaseTag - } -} - -impl IterBaseKind for &Value {} - -pub struct IterOptionTag; - -impl IterOptionTag { - #[inline] - pub fn convert<'py, Value>( - self, - py: Python<'py>, - value: Option, - ) -> PyResult<*mut ffi::PyObject> - where - Value: IntoPyCallbackOutput<'py, *mut ffi::PyObject>, - { - match value { - Some(value) => value.convert(py), - None => Ok(null_mut()), +// `__next__` and `__anext__` may say "iteration is over" by returning `None`, written either as +// `Option` or as `Result, E>`. The slot conversion and the `experimental-inspect` +// type hint both read that off the same wrapper: the inherent items below match those two shapes +// and win over the blanket fallback impls, which cover every other return type. The sync and the +// async wrapper come from one macro so they cannot drift apart either. +macro_rules! iter_next_output { + ($wrapper:ident, $convert_fallback:ident, $type_fallback:ident, exhausted: $exhausted:expr) => { + pub struct $wrapper(pub T); + + // The conversion bound sits on the method rather than on the impl, so that a return type + // which cannot be converted at all is reported as the missing `IntoPyCallbackOutput` + // rather than as this trait not being implemented. + pub trait $convert_fallback { + type Value; + + fn convert<'py, Target>(self, py: Python<'py>) -> PyResult + where + Self::Value: IntoPyCallbackOutput<'py, Target>; } - } -} -pub trait IterOptionKind { - #[inline] - fn iter_tag(&self) -> IterOptionTag { - IterOptionTag - } -} - -impl IterOptionKind for Option {} + impl $convert_fallback for $wrapper { + type Value = Value; -pub struct IterResultOptionTag; - -impl IterResultOptionTag { - #[inline] - pub fn convert<'py, Value, Error>( - self, - py: Python<'py>, - value: Result, Error>, - ) -> PyResult<*mut ffi::PyObject> - where - Value: IntoPyCallbackOutput<'py, *mut ffi::PyObject>, - Error: Into, - { - match value { - Ok(Some(value)) => value.convert(py), - Ok(None) => Ok(null_mut()), - Err(err) => Err(err.into()), + #[inline] + fn convert<'py, Target>(self, py: Python<'py>) -> PyResult + where + Value: IntoPyCallbackOutput<'py, Target>, + { + self.0.convert(py) + } } - } -} -pub trait IterResultOptionKind { - #[inline] - fn iter_tag(&self) -> IterResultOptionTag { - IterResultOptionTag - } -} - -impl IterResultOptionKind for Result, Error> {} - -// Autoref-based specialization for handling `__anext__` returning `Option` - -pub struct AsyncIterBaseTag; - -impl AsyncIterBaseTag { - #[inline] - pub fn convert<'py, Value, Target>(self, py: Python<'py>, value: Value) -> PyResult - where - Value: IntoPyCallbackOutput<'py, Target>, - { - value.convert(py) - } -} - -pub trait AsyncIterBaseKind { - #[inline] - fn async_iter_tag(&self) -> AsyncIterBaseTag { - AsyncIterBaseTag - } -} - -impl AsyncIterBaseKind for &Value {} - -pub struct AsyncIterOptionTag; + #[cfg(feature = "experimental-inspect")] + pub trait $type_fallback { + const OUTPUT_TYPE: PyStaticExpr; + } -impl AsyncIterOptionTag { - #[inline] - pub fn convert<'py, Value>( - self, - py: Python<'py>, - value: Option, - ) -> PyResult<*mut ffi::PyObject> - where - Value: IntoPyCallbackOutput<'py, *mut ffi::PyObject>, - { - match value { - Some(value) => value.convert(py), - None => Err(PyStopAsyncIteration::new_err(())), + #[cfg(feature = "experimental-inspect")] + impl $type_fallback for $wrapper { + const OUTPUT_TYPE: PyStaticExpr = ::OUTPUT_TYPE; } - } -} -pub trait AsyncIterOptionKind { - #[inline] - fn async_iter_tag(&self) -> AsyncIterOptionTag { - AsyncIterOptionTag - } -} + impl $wrapper> { + #[inline] + pub fn convert<'py>(self, py: Python<'py>) -> PyResult<*mut ffi::PyObject> + where + Value: IntoPyCallbackOutput<'py, *mut ffi::PyObject>, + { + match self.0 { + Some(value) => value.convert(py), + None => $exhausted, + } + } + } -impl AsyncIterOptionKind for Option {} + #[cfg(feature = "experimental-inspect")] + impl $wrapper> { + pub const OUTPUT_TYPE: PyStaticExpr = ::OUTPUT_TYPE; + } -pub struct AsyncIterResultOptionTag; + impl $wrapper, Error>> { + #[inline] + pub fn convert<'py>(self, py: Python<'py>) -> PyResult<*mut ffi::PyObject> + where + Value: IntoPyCallbackOutput<'py, *mut ffi::PyObject>, + Error: Into, + { + match self.0 { + Ok(Some(value)) => value.convert(py), + Ok(None) => $exhausted, + Err(err) => Err(err.into()), + } + } + } -impl AsyncIterResultOptionTag { - #[inline] - pub fn convert<'py, Value, Error>( - self, - py: Python<'py>, - value: Result, Error>, - ) -> PyResult<*mut ffi::PyObject> - where - Value: IntoPyCallbackOutput<'py, *mut ffi::PyObject>, - Error: Into, - { - match value { - Ok(Some(value)) => value.convert(py), - Ok(None) => Err(PyStopAsyncIteration::new_err(())), - Err(err) => Err(err.into()), + #[cfg(feature = "experimental-inspect")] + impl $wrapper, Error>> { + pub const OUTPUT_TYPE: PyStaticExpr = ::OUTPUT_TYPE; } - } + }; } -pub trait AsyncIterResultOptionKind { - #[inline] - fn async_iter_tag(&self) -> AsyncIterResultOptionTag { - AsyncIterResultOptionTag - } -} +iter_next_output!( + IterNextOutput, + IterNextConvertFallback, + IterNextTypeFallback, + exhausted: Ok(null_mut()) +); -impl AsyncIterResultOptionKind for Result, Error> {} +iter_next_output!( + AsyncIterNextOutput, + AsyncIterNextConvertFallback, + AsyncIterNextTypeFallback, + exhausted: Err(PyStopAsyncIteration::new_err(())) +); /// Re-exported so that `#[new]` generated code can resolve the type tag for `tp_new_impl` pub use crate::internal::pyclass_init::tp_new_resolver; @@ -799,6 +737,33 @@ mod tests { #[allow(unused_imports, reason = "conditionally used")] use crate::platform::prelude::*; + #[test] + #[cfg(feature = "experimental-inspect")] + fn iter_next_output_type() { + use super::{AsyncIterNextOutput, AsyncIterNextTypeFallback as _}; + use super::{IterNextOutput, IterNextTypeFallback as _}; + use crate::PyResult; + + // `None` ends the iteration instead of being yielded, so it is not part of the type + for hint in [ + IterNextOutput::>::OUTPUT_TYPE, + IterNextOutput::>>::OUTPUT_TYPE, + AsyncIterNextOutput::>::OUTPUT_TYPE, + AsyncIterNextOutput::>>::OUTPUT_TYPE, + // and a return type without that encoding is left as it is + IterNextOutput::>::OUTPUT_TYPE, + AsyncIterNextOutput::::OUTPUT_TYPE, + ] { + assert_eq!(hint.to_string(), "builtins.int"); + } + + // only the outermost `Option` is the one meaning "iteration is over" + assert_eq!( + IterNextOutput::>>::OUTPUT_TYPE.to_string(), + "builtins.list[builtins.int | None]" + ); + } + #[test] #[cfg(any(Py_3_10, not(Py_LIMITED_API)))] fn test_fastcall_function_with_keywords() {