From 36658a7ba1e734f346d74c5d27765f1c18b8aa3f Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 12:28:15 +0800 Subject: [PATCH 01/11] Move Python delegate projection into delegates.rs Collect the delegate callable annotation, event callback adapter, and delegate input conversion in one module so they can be derived from delegate signatures. Generated output is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/codegen/winrt/python/delegates.rs | 175 ++++++++++++++++++ .../src/codegen/winrt/python/method.rs | 130 ++----------- .../src/codegen/winrt/python/mod.rs | 1 + .../src/codegen/winrt/python/stub_helpers.rs | 5 +- .../src/codegen/winrt/python/type_helpers.rs | 49 +---- 5 files changed, 199 insertions(+), 161 deletions(-) create mode 100644 tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs new file mode 100644 index 00000000..3d3c19f2 --- /dev/null +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Python projection of WinRT delegates. +//! +//! This module owns every conversion between a Python callable and a native +//! delegate: the `Callable[...]` annotation offered to callers and the adapter +//! that projects a delegate's native arguments before the callable runs. + +use crate::types::{TypeIdentity, TypeIdentityKind, TypeMeta}; + +use super::naming::PythonProjectionContext; +use super::signature::{py_convert_return, py_runtime_named_symbol, py_runtime_symbol}; +use super::type_helpers::py_return_type_safe; + +/// Generated symbols that describe a delegate's native ABI. +pub(crate) struct DelegateAbi { + /// Expression evaluating to the delegate IID. + pub(crate) iid: String, + /// Expression evaluating to the delegate's `Invoke` parameter types. + pub(crate) param_types: String, +} + +/// Resolve the generated IID and parameter-type symbols of a delegate type. +pub(crate) fn delegate_abi( + typ: &TypeMeta, + context: &PythonProjectionContext, +) -> Option { + if !context.is_delegate_type(typ) { + return None; + } + let identity = context.identity_for_type(typ); + let projected_name = context.projected_name(&identity); + Some(DelegateAbi { + iid: py_runtime_symbol(context, &identity, &format!("IID_{projected_name}")), + param_types: py_runtime_symbol( + context, + &identity, + &format!("{projected_name}_PARAM_TYPES"), + ), + }) +} + +/// Produce a typed Python annotation for a delegate parameter, with +/// `TypedEventHandler` / `EventHandler` unwrapped. Bespoke non-parametric +/// delegates fall back to `Callable[..., object]`. +pub(crate) fn py_delegate_callable_type( + typ: &TypeMeta, + context: &PythonProjectionContext, +) -> String { + match typ { + TypeMeta::Parameterized { name, args, .. } + if name.split('`').next() == Some("TypedEventHandler") && args.len() == 2 => + { + let sender = py_return_type_safe(Some(&args[0]), context); + let arg = py_return_type_safe(Some(&args[1]), context); + format!("Callable[[{}, {}], object]", sender, arg) + } + TypeMeta::Parameterized { name, args, .. } + if name.split('`').next() == Some("EventHandler") && args.len() == 1 => + { + let arg = py_return_type_safe(Some(&args[0]), context); + format!("Callable[[object, {}], object]", arg) + } + TypeMeta::Parameterized { name, args, .. } + if name.split('`').next() == Some("VectorChangedEventHandler") && args.len() == 1 => + { + let observable_identity = TypeIdentity::closed_generic( + TypeIdentityKind::Interface, + crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, + "IObservableVector", + args.iter().map(TypeMeta::type_identity), + ); + let observable = context.reference_name(&observable_identity); + format!( + "Callable[['{}', 'IVectorChangedEventArgs'], object]", + observable + ) + } + _ => "Callable[..., object]".to_string(), + } +} + +/// Annotation for a delegate-typed input: a Python callable or an existing +/// native delegate value. +pub(crate) fn py_delegate_param_type(typ: &TypeMeta, context: &PythonProjectionContext) -> String { + let sig = py_delegate_callable_type(typ, context); + format!("{sig} | 'DynWinRTValue'") +} + +/// Build a Python callback signature + wrapper expression for an event delegate. +/// +/// Returns `(signature, wrapper)`: +/// - `signature` is a Python type annotation (e.g., `Callable[['Foo', 'Bar'], object]`). +/// - `wrapper` is an expression that produces the ABI-facing callable, unwrapping +/// raw `DynWinRTValue` sender/args back into projected Python objects before +/// invoking the user's `callback`. +/// +/// The wrapper falls back to a passthrough (`callback`) for unknown delegate shapes. +pub(crate) fn py_event_callback( + typ: Option<&TypeMeta>, + context: &PythonProjectionContext, +) -> (String, String) { + match typ { + Some(typ @ TypeMeta::Parameterized { name, args, .. }) + if name.split('`').next() == Some("TypedEventHandler") && args.len() == 2 => + { + let sender_conv = py_convert_return("__sender__", Some(&args[0]), false, context); + let args_conv = py_convert_return("__args__", Some(&args[1]), false, context); + let sig = py_delegate_callable_type(typ, context); + let wrapper = format!( + "(lambda callback=callback: (lambda __sender__, __args__: callback({}, {})))()", + sender_conv, args_conv + ); + (sig, wrapper) + } + Some(typ @ TypeMeta::Parameterized { name, args, .. }) + if name.split('`').next() == Some("EventHandler") && args.len() == 1 => + { + let args_conv = py_convert_return("__args__", Some(&args[0]), false, context); + let sig = py_delegate_callable_type(typ, context); + let wrapper = format!( + "(lambda callback=callback: (lambda __sender__, __args__: callback(__sender__, {})))()", + args_conv + ); + (sig, wrapper) + } + Some(typ @ TypeMeta::Parameterized { name, args, .. }) + if name.split('`').next() == Some("VectorChangedEventHandler") && args.len() == 1 => + { + let observable_identity = TypeIdentity::closed_generic( + TypeIdentityKind::Interface, + crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, + "IObservableVector", + args.iter().map(TypeMeta::type_identity), + ); + let observable_name = context.projected_name(&observable_identity); + let sender = format!( + "(lambda value: None if value.is_null() else {}(value))(__sender__)", + py_runtime_symbol(context, &observable_identity, &observable_name) + ); + let event_args = format!( + "(lambda value: None if value.is_null() else {}(value))(__args__)", + py_runtime_named_symbol( + context, + TypeIdentityKind::Interface, + crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, + "IVectorChangedEventArgs", + "IVectorChangedEventArgs", + ) + ); + let sig = py_delegate_callable_type(typ, context); + let wrapper = format!( + "(lambda callback=callback: (lambda __sender__, __args__: callback({}, {})))()", + sender, event_args + ); + (sig, wrapper) + } + _ => ("Callable[..., object]".to_string(), "callback".to_string()), + } +} + +/// Convert a delegate-typed method, static, or setter argument: an existing +/// native delegate passes through; a Python callable becomes a new delegate. +pub(crate) fn py_delegate_input_arg( + name: &str, + typ: &TypeMeta, + context: &PythonProjectionContext, +) -> Option { + let abi = delegate_abi(typ, context)?; + Some(format!( + "_dynwinrt_delegate({name}, {}, {})", + abi.iid, abi.param_types + )) +} diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index 3cb9aea5..54c870bb 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -2,21 +2,23 @@ // Licensed under the MIT License. use crate::meta::{ClassMeta, InterfaceMeta, MethodMeta}; -use crate::types::{TypeIdentity, TypeIdentityKind, TypeMeta}; +use crate::types::TypeMeta; use crate::codegen::winrt::extensions::winui::{self, WinUiCallBehavior}; use crate::codegen::winrt::shared::imports::{ fill_array_output_index, fill_array_uses_retval_count, get_in_params, }; -use super::naming::{PythonProjectionContext, PythonTypeIdentity, to_snake_case}; +use super::delegates::{ + delegate_abi, py_delegate_input_arg, py_delegate_param_type, py_event_callback, +}; +use super::naming::{PythonProjectionContext, to_snake_case}; use super::signature::{ - py_convert_return, py_runtime_named_symbol, py_runtime_symbol, py_type_guard, py_wrap_arg, - py_wrap_async, py_wrap_async_with_converters, + py_convert_return, py_type_guard, py_wrap_arg, py_wrap_async, py_wrap_async_with_converters, }; use super::type_helpers::{ - method_pydoc, py_delegate_callable_type, py_factory_return_type, py_method_abi_output_count, - py_method_outputs, py_method_return_type, py_output_type, py_param_list, + method_pydoc, py_factory_return_type, py_method_abi_output_count, py_method_outputs, + py_method_return_type, py_output_type, py_param_list, }; fn is_delegate_type(typ: &TypeMeta, context: &PythonProjectionContext) -> bool { @@ -38,99 +40,13 @@ fn delegate_value_converter(typ: &TypeMeta, context: &PythonProjectionContext) - None } -/// Build a Python callback signature + wrapper expression for an event delegate. -/// -/// Returns `(signature, wrapper)`: -/// - `signature` is a Python type annotation (e.g., `Callable[['Foo', 'Bar'], object]`). -/// - `wrapper` is an expression that produces the ABI-facing callable, unwrapping -/// raw `DynWinRTValue` sender/args back into projected Python objects before -/// invoking the user's `callback`. -/// -/// The wrapper falls back to a passthrough (`callback`) for unknown delegate shapes. -fn build_event_wrapper( - typ: Option<&TypeMeta>, - context: &PythonProjectionContext, -) -> (String, String) { - match typ { - Some(typ @ TypeMeta::Parameterized { name, args, .. }) - if name.split('`').next() == Some("TypedEventHandler") && args.len() == 2 => - { - let sender_conv = py_convert_return("__sender__", Some(&args[0]), false, context); - let args_conv = py_convert_return("__args__", Some(&args[1]), false, context); - let sig = py_delegate_callable_type(typ, context); - let wrapper = format!( - "(lambda callback=callback: (lambda __sender__, __args__: callback({}, {})))()", - sender_conv, args_conv - ); - (sig, wrapper) - } - Some(typ @ TypeMeta::Parameterized { name, args, .. }) - if name.split('`').next() == Some("EventHandler") && args.len() == 1 => - { - let args_conv = py_convert_return("__args__", Some(&args[0]), false, context); - let sig = py_delegate_callable_type(typ, context); - let wrapper = format!( - "(lambda callback=callback: (lambda __sender__, __args__: callback(__sender__, {})))()", - args_conv - ); - (sig, wrapper) - } - Some(typ @ TypeMeta::Parameterized { name, args, .. }) - if name.split('`').next() == Some("VectorChangedEventHandler") && args.len() == 1 => - { - let observable_identity = TypeIdentity::closed_generic( - TypeIdentityKind::Interface, - crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, - "IObservableVector", - args.iter().map(TypeMeta::type_identity), - ); - let observable_name = context.projected_name(&observable_identity); - let sender = format!( - "(lambda value: None if value.is_null() else {}(value))(__sender__)", - py_runtime_symbol(context, &observable_identity, &observable_name) - ); - let event_args = format!( - "(lambda value: None if value.is_null() else {}(value))(__args__)", - py_runtime_named_symbol( - context, - TypeIdentityKind::Interface, - crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, - "IVectorChangedEventArgs", - "IVectorChangedEventArgs", - ) - ); - let sig = py_delegate_callable_type(typ, context); - let wrapper = format!( - "(lambda callback=callback: (lambda __sender__, __args__: callback({}, {})))()", - sender, event_args - ); - (sig, wrapper) - } - _ => ("Callable[..., object]".to_string(), "callback".to_string()), - } -} - -fn delegate_identity( - typ: &TypeMeta, - context: &PythonProjectionContext, -) -> Option { - context - .is_delegate_type(typ) - .then(|| context.identity_for_type(typ)) -} - pub(crate) fn py_wrap_method_arg( name: &str, typ: &TypeMeta, context: &PythonProjectionContext, ) -> String { - if let Some(delegate) = delegate_identity(typ, context) { - let projected_name = context.projected_name(&delegate); - return format!( - "_dynwinrt_delegate({name}, {}, {})", - py_runtime_symbol(context, &delegate, &format!("IID_{projected_name}")), - py_runtime_symbol(context, &delegate, &format!("{projected_name}_PARAM_TYPES")) - ); + if let Some(delegate) = py_delegate_input_arg(name, typ, context) { + return delegate; } py_wrap_arg(name, typ, context) } @@ -668,7 +584,7 @@ pub(crate) fn generate_method_body( let suffix = method.name.strip_prefix("add_").unwrap_or(&method.name); let event_name = to_snake_case(suffix); let delegate_typ = in_params.first().map(|p| &p.typ); - let delegate_identity = delegate_typ.and_then(|typ| delegate_identity(typ, context)); + let delegate = delegate_typ.and_then(|typ| delegate_abi(typ, context)); // Find matching remove_ in the same interface to know its vtable index. let remove_target = format!("remove_{}", suffix); let remove_idx = sibling_methods.and_then(|methods| { @@ -678,9 +594,8 @@ pub(crate) fn generate_method_body( .map(|m| m.vtable_index) }); - // Compute callback wrapper: for TypedEventHandler / EventHandler, - // wrap raw ABI args back into projected values before invoking the user callback. - let (callback_signature, wrapper) = build_event_wrapper(delegate_typ, context); + // Project raw ABI arguments before invoking the user callback. + let (callback_signature, wrapper) = py_event_callback(delegate_typ, context); out.push_str(&format!( " def on_{}(self, callback: {}):\n", @@ -689,14 +604,10 @@ pub(crate) fn generate_method_body( out.push_str(&method_pydoc(method, &in_params)); // Wrapping expression bound to `_wrapped` before delegate construction. out.push_str(&format!(" _wrapped = {}\n", wrapper)); - if let Some(ref identity) = delegate_identity { - let delegate_name = context.projected_name(identity); - let iid = py_runtime_symbol(context, identity, &format!("IID_{delegate_name}")); - let param_types = - py_runtime_symbol(context, identity, &format!("{delegate_name}_PARAM_TYPES")); + if let Some(delegate) = delegate { out.push_str(&format!( " _handler = _dynwinrt_create_delegate({}, {}, _wrapped)\n", - iid, param_types + delegate.iid, delegate.param_types )); } else { out.push_str( @@ -793,15 +704,7 @@ pub(crate) fn generate_method_body( .first() .map(|p| { if is_delegate_type(&p.typ, context) { - // Reuse py_delegate_param_type via a temporary param_list call. - let params = - super::type_helpers::py_param_list(std::slice::from_ref(p), context); - // params is "name: Type" — extract the "Type" part. - params - .splitn(2, ": ") - .nth(1) - .map(|s| s.to_string()) - .unwrap_or_else(|| "Callable[..., object] | 'DynWinRTValue'".to_string()) + py_delegate_param_type(&p.typ, context) } else { super::type_helpers::py_param_type_safe(&p.typ, context) } @@ -858,6 +761,7 @@ pub(crate) fn generate_method_body( mod tests { use super::*; use crate::meta::{ParamDirection, ParamMeta}; + use crate::types::{TypeIdentity, TypeIdentityKind}; use std::process::Command; fn overloaded_method(name: &str, vtable_index: usize, typ: TypeMeta) -> MethodMeta { diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs index 84a04006..b4efd8b0 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. pub(crate) mod collections; +pub(crate) mod delegates; mod docs; mod generator; mod implementation; diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs index 316b4319..600a457d 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs @@ -7,12 +7,13 @@ use crate::codegen::winrt::shared::imports::get_in_params; use crate::meta::MethodMeta; use crate::types::{FieldMeta, TypeMeta}; +use super::delegates::py_delegate_callable_type; use super::naming::{PythonProjectionContext, PythonSymbol, STRUCT_SYMBOLS, to_snake_case}; use super::native_types::{FoundationType, foundation_type}; use super::structs::{py_struct_field_read_type, py_struct_field_type}; use super::type_helpers::{ - method_pydoc_with_indent, py_delegate_callable_type, py_factory_return_type, - py_method_return_type, py_output_type, py_param_list, py_param_type_safe, + method_pydoc_with_indent, py_factory_return_type, py_method_return_type, py_output_type, + py_param_list, py_param_type_safe, }; use crate::codegen::winrt::shared::imports::ireference_inner_type; diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs index 34d75531..17302ef0 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs @@ -528,7 +528,9 @@ pub(super) fn py_param_list( .iter() .map(|p| { let param_type = match &p.typ { - typ if context.is_delegate_type(typ) => py_delegate_param_type(typ, context), + typ if context.is_delegate_type(typ) => { + super::delegates::py_delegate_param_type(typ, context) + } _ => py_param_type_safe(&p.typ, context), }; format!("{}: {}", to_snake_case(&p.name), param_type) @@ -537,51 +539,6 @@ pub(super) fn py_param_list( .join(", ") } -/// Produce a typed Python annotation for a delegate parameter, with -/// `TypedEventHandler` / `EventHandler` unwrapped. Bespoke non-parametric -/// delegates fall back to `Callable[..., object]`. -pub(crate) fn py_delegate_callable_type( - typ: &TypeMeta, - context: &PythonProjectionContext, -) -> String { - match typ { - TypeMeta::Parameterized { name, args, .. } - if name.split('`').next() == Some("TypedEventHandler") && args.len() == 2 => - { - let sender = py_return_type_safe(Some(&args[0]), context); - let arg = py_return_type_safe(Some(&args[1]), context); - format!("Callable[[{}, {}], object]", sender, arg) - } - TypeMeta::Parameterized { name, args, .. } - if name.split('`').next() == Some("EventHandler") && args.len() == 1 => - { - let arg = py_return_type_safe(Some(&args[0]), context); - format!("Callable[[object, {}], object]", arg) - } - TypeMeta::Parameterized { name, args, .. } - if name.split('`').next() == Some("VectorChangedEventHandler") && args.len() == 1 => - { - let observable_identity = crate::types::TypeIdentity::closed_generic( - crate::types::TypeIdentityKind::Interface, - crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, - "IObservableVector", - args.iter().map(TypeMeta::type_identity), - ); - let observable = context.reference_name(&observable_identity); - format!( - "Callable[['{}', 'IVectorChangedEventArgs'], object]", - observable - ) - } - _ => "Callable[..., object]".to_string(), - } -} - -fn py_delegate_param_type(typ: &TypeMeta, context: &PythonProjectionContext) -> String { - let sig = py_delegate_callable_type(typ, context); - format!("{sig} | 'DynWinRTValue'") -} - #[cfg(test)] mod tests { use super::*; From 47e7ecefa73cdf9ef1e1d91d202555876b4e9001 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 13:28:19 +0800 Subject: [PATCH 02/11] Derive Python delegate callbacks from Invoke signatures Python callables are converted to delegates in one place. The callback annotation and the projection of each native argument now come from the delegate's Invoke signature, with generic arguments substituted, instead of per-name branches for TypedEventHandler, EventHandler, and VectorChangedEventHandler. The same adapter now applies to instance events, static events, callback parameters, and delegate-typed properties through _dynwinrt_delegate(value, iid, types, project); an existing DynWinRTValue delegate still passes through unchanged. Callback arguments are annotated non-null except WinRT Object and IReference, through a single py_delegate_argument_type hook. Modules import the types named by those annotations from the delegate signatures, which replaces the IObservableVector-specific import blocks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/codegen/winrt/python/delegates.rs | 404 +++++++++++++----- .../codegen/winrt/python/generator/class.rs | 28 -- .../src/codegen/winrt/python/generator/mod.rs | 4 +- .../codegen/winrt/python/generator/types.rs | 12 - .../src/codegen/winrt/python/naming.rs | 28 +- .../src/codegen/winrt/python/stub_helpers.rs | 19 +- .../src/codegen/winrt/python/stubs.rs | 36 -- .../src/codegen/winrt/python/type_helpers.rs | 2 +- .../src/codegen/winrt/shared/imports.rs | 28 +- tools/dynwinrt-codegen/src/main.rs | 11 + tools/dynwinrt-codegen/tests/common/mod.rs | 54 ++- .../tests/observable_vector_test.rs | 53 ++- .../tests/python_delegate_callback_test.rs | 197 +++++++++ 13 files changed, 670 insertions(+), 206 deletions(-) create mode 100644 tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs index 3d3c19f2..9457d49d 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs @@ -4,14 +4,17 @@ //! Python projection of WinRT delegates. //! //! This module owns every conversion between a Python callable and a native -//! delegate: the `Callable[...]` annotation offered to callers and the adapter -//! that projects a delegate's native arguments before the callable runs. +//! delegate. The delegate's `Invoke` signature, with generic arguments +//! substituted, drives both the `Callable[...]` annotation offered to callers +//! and the adapter that projects native arguments before the callable runs. -use crate::types::{TypeIdentity, TypeIdentityKind, TypeMeta}; +use crate::codegen::winrt::shared::imports::ireference_inner_type; +use crate::meta::{ParamDirection, ParamMeta}; +use crate::types::TypeMeta; -use super::naming::PythonProjectionContext; -use super::signature::{py_convert_return, py_runtime_named_symbol, py_runtime_symbol}; -use super::type_helpers::py_return_type_safe; +use super::naming::{PythonProjectionContext, to_snake_case}; +use super::signature::{py_convert_return, py_runtime_symbol}; +use super::type_helpers::{py_output_type, py_return_type, py_return_type_safe}; /// Generated symbols that describe a delegate's native ABI. pub(crate) struct DelegateAbi { @@ -41,44 +44,68 @@ pub(crate) fn delegate_abi( }) } -/// Produce a typed Python annotation for a delegate parameter, with -/// `TypedEventHandler` / `EventHandler` unwrapped. Bespoke non-parametric -/// delegates fall back to `Callable[..., object]`. -pub(crate) fn py_delegate_callable_type( +/// The native arguments a Python callable receives for a delegate: its +/// `Invoke` inputs. `None` when the signature is unknown or has outputs. +fn callback_params<'a>( + typ: &TypeMeta, + context: &'a PythonProjectionContext, +) -> Option<&'a [ParamMeta]> { + let invoke = context.delegate_invoke(typ)?; + invoke + .params + .iter() + .all(|param| param.direction == ParamDirection::In) + .then_some(invoke.params.as_slice()) +} + +/// Annotation of one argument passed to a Python callback. +/// +/// WinRT passes null delegate arguments only for `Object` and `IReference`. +/// Every callback-argument annotation goes through this function so a +/// position-aware output-nullability policy can take it over. +pub(crate) fn py_delegate_argument_type( typ: &TypeMeta, context: &PythonProjectionContext, ) -> String { - match typ { - TypeMeta::Parameterized { name, args, .. } - if name.split('`').next() == Some("TypedEventHandler") && args.len() == 2 => - { - let sender = py_return_type_safe(Some(&args[0]), context); - let arg = py_return_type_safe(Some(&args[1]), context); - format!("Callable[[{}, {}], object]", sender, arg) - } - TypeMeta::Parameterized { name, args, .. } - if name.split('`').next() == Some("EventHandler") && args.len() == 1 => - { - let arg = py_return_type_safe(Some(&args[0]), context); - format!("Callable[[object, {}], object]", arg) - } - TypeMeta::Parameterized { name, args, .. } - if name.split('`').next() == Some("VectorChangedEventHandler") && args.len() == 1 => - { - let observable_identity = TypeIdentity::closed_generic( - TypeIdentityKind::Interface, - crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, - "IObservableVector", - args.iter().map(TypeMeta::type_identity), - ); - let observable = context.reference_name(&observable_identity); - format!( - "Callable[['{}', 'IVectorChangedEventArgs'], object]", - observable - ) - } - _ => "Callable[..., object]".to_string(), + if context.is_delegate_type(typ) { + return py_output_type(typ, context); + } + let unknown = matches!( + typ, + TypeMeta::RuntimeClass { .. } + | TypeMeta::Interface { .. } + | TypeMeta::Enum { .. } + | TypeMeta::Parameterized { .. } + ) && !context.is_known_type(typ); + if unknown || matches!(typ, TypeMeta::Object) || ireference_inner_type(typ).is_some() { + py_return_type_safe(Some(typ), context) + } else { + py_return_type(Some(typ), context) + } +} + +/// Project one native callback argument the way a method return is projected. +fn py_delegate_argument(expr: &str, typ: &TypeMeta, context: &PythonProjectionContext) -> String { + if context.is_delegate_type(typ) { + return format!("(lambda value: None if value.is_null() else value)({expr})"); } + py_convert_return(expr, Some(typ), typ.is_async(), context) +} + +/// `Callable[[...], object]` derived from the delegate's `Invoke` signature, or +/// `Callable[..., object]` when the signature is unavailable. +pub(crate) fn py_delegate_callable_type( + typ: &TypeMeta, + context: &PythonProjectionContext, +) -> String { + let Some(params) = callback_params(typ, context) else { + return "Callable[..., object]".to_string(); + }; + let arguments = params + .iter() + .map(|param| py_delegate_argument_type(¶m.typ, context)) + .collect::>(); + format!("Callable[[{}], object]", arguments.join(", ")) } /// Annotation for a delegate-typed input: a Python callable or an existing @@ -88,88 +115,253 @@ pub(crate) fn py_delegate_param_type(typ: &TypeMeta, context: &PythonProjectionC format!("{sig} | 'DynWinRTValue'") } +/// `lambda : callback()`, adapting a Python +/// callable named `callback` to the delegate's native arguments. `None` when +/// there is nothing to project. +fn py_callback_adapter(typ: &TypeMeta, context: &PythonProjectionContext) -> Option { + let params = callback_params(typ, context)?; + if params.is_empty() { + return None; + } + let mut names = Vec::::new(); + for (index, param) in params.iter().enumerate() { + let name = format!("__{}__", to_snake_case(¶m.name)); + names.push(if param.name.is_empty() || names.contains(&name) { + format!("__arg{index}__") + } else { + name + }); + } + let arguments = params + .iter() + .zip(&names) + .map(|(param, name)| py_delegate_argument(name, ¶m.typ, context)) + .collect::>(); + Some(format!( + "lambda {}: callback({})", + names.join(", "), + arguments.join(", ") + )) +} + /// Build a Python callback signature + wrapper expression for an event delegate. /// /// Returns `(signature, wrapper)`: /// - `signature` is a Python type annotation (e.g., `Callable[['Foo', 'Bar'], object]`). -/// - `wrapper` is an expression that produces the ABI-facing callable, unwrapping -/// raw `DynWinRTValue` sender/args back into projected Python objects before -/// invoking the user's `callback`. +/// - `wrapper` is an expression that produces the ABI-facing callable, projecting +/// raw `DynWinRTValue` arguments into Python values before invoking the user's +/// `callback`. /// -/// The wrapper falls back to a passthrough (`callback`) for unknown delegate shapes. +/// The wrapper falls back to a passthrough (`callback`) when the delegate +/// signature is unknown. pub(crate) fn py_event_callback( typ: Option<&TypeMeta>, context: &PythonProjectionContext, ) -> (String, String) { - match typ { - Some(typ @ TypeMeta::Parameterized { name, args, .. }) - if name.split('`').next() == Some("TypedEventHandler") && args.len() == 2 => - { - let sender_conv = py_convert_return("__sender__", Some(&args[0]), false, context); - let args_conv = py_convert_return("__args__", Some(&args[1]), false, context); - let sig = py_delegate_callable_type(typ, context); - let wrapper = format!( - "(lambda callback=callback: (lambda __sender__, __args__: callback({}, {})))()", - sender_conv, args_conv - ); - (sig, wrapper) - } - Some(typ @ TypeMeta::Parameterized { name, args, .. }) - if name.split('`').next() == Some("EventHandler") && args.len() == 1 => - { - let args_conv = py_convert_return("__args__", Some(&args[0]), false, context); - let sig = py_delegate_callable_type(typ, context); - let wrapper = format!( - "(lambda callback=callback: (lambda __sender__, __args__: callback(__sender__, {})))()", - args_conv - ); - (sig, wrapper) - } - Some(typ @ TypeMeta::Parameterized { name, args, .. }) - if name.split('`').next() == Some("VectorChangedEventHandler") && args.len() == 1 => - { - let observable_identity = TypeIdentity::closed_generic( - TypeIdentityKind::Interface, - crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, - "IObservableVector", - args.iter().map(TypeMeta::type_identity), - ); - let observable_name = context.projected_name(&observable_identity); - let sender = format!( - "(lambda value: None if value.is_null() else {}(value))(__sender__)", - py_runtime_symbol(context, &observable_identity, &observable_name) - ); - let event_args = format!( - "(lambda value: None if value.is_null() else {}(value))(__args__)", - py_runtime_named_symbol( - context, - TypeIdentityKind::Interface, - crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, - "IVectorChangedEventArgs", - "IVectorChangedEventArgs", - ) - ); - let sig = py_delegate_callable_type(typ, context); - let wrapper = format!( - "(lambda callback=callback: (lambda __sender__, __args__: callback({}, {})))()", - sender, event_args - ); - (sig, wrapper) - } - _ => ("Callable[..., object]".to_string(), "callback".to_string()), - } + let Some(typ) = typ else { + return ("Callable[..., object]".to_string(), "callback".to_string()); + }; + let wrapper = py_callback_adapter(typ, context).map_or_else( + || "callback".to_string(), + |adapter| format!("(lambda callback=callback: ({adapter}))()"), + ); + (py_delegate_callable_type(typ, context), wrapper) } /// Convert a delegate-typed method, static, or setter argument: an existing -/// native delegate passes through; a Python callable becomes a new delegate. +/// native delegate passes through; a Python callable becomes a new delegate +/// whose arguments are projected like event arguments. pub(crate) fn py_delegate_input_arg( name: &str, typ: &TypeMeta, context: &PythonProjectionContext, ) -> Option { let abi = delegate_abi(typ, context)?; - Some(format!( - "_dynwinrt_delegate({name}, {}, {})", - abi.iid, abi.param_types - )) + Some(match py_callback_adapter(typ, context) { + Some(adapter) => format!( + "_dynwinrt_delegate({name}, {}, {}, lambda callback: ({adapter}))", + abi.iid, abi.param_types + ), + None => format!( + "_dynwinrt_delegate({name}, {}, {})", + abi.iid, abi.param_types + ), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::meta::{ImplementationDelegateMeta, InterfaceMeta, MethodMeta}; + use crate::types::{TypeIdentity, TypeIdentityKind}; + + fn named(kind: TypeIdentityKind, name: &str) -> TypeIdentity { + TypeIdentity::named(kind, "Contoso", name) + } + + fn class(name: &str) -> TypeMeta { + TypeMeta::RuntimeClass { + namespace: "Contoso".into(), + name: name.into(), + default_interface: None, + } + } + + fn delegate(name: &str) -> TypeMeta { + TypeMeta::Interface { + namespace: "Contoso".into(), + name: name.into(), + iid: "11111111-1111-1111-1111-111111111111".into(), + } + } + + fn input(name: &str, typ: TypeMeta) -> ParamMeta { + ParamMeta { + name: name.into(), + typ, + direction: ParamDirection::In, + } + } + + /// A context that generates `types` and knows the `Invoke` signature of + /// every `(delegate, inputs)` pair. + fn context( + types: &[TypeIdentity], + delegates: Vec<(TypeMeta, Vec)>, + ) -> PythonProjectionContext { + let mut identities = types.to_vec(); + identities.extend( + delegates + .iter() + .map(|(typ, _)| typ.type_identity().with_kind(TypeIdentityKind::Delegate)), + ); + let mut context = PythonProjectionContext::standalone(identities).unwrap(); + let owner = InterfaceMeta { + implementation_metadata: crate::meta::InterfaceImplementationMetadata { + delegates: delegates + .into_iter() + .map(|(typ, params)| ImplementationDelegateMeta { + typ, + invoke: MethodMeta { + name: "Invoke".into(), + params, + ..Default::default() + }, + }) + .collect(), + ..Default::default() + }, + ..Default::default() + }; + context.register_delegate_invokes([&owner]); + context + } + + #[test] + fn invoke_signature_types_and_projects_bespoke_delegates() { + let handler = delegate("ClickedHandler"); + let context = context( + &[named(TypeIdentityKind::Class, "ClickedEventArgs")], + vec![( + handler.clone(), + vec![ + input("sender", TypeMeta::Object), + input("e", class("ClickedEventArgs")), + ], + )], + ); + + assert_eq!( + py_delegate_callable_type(&handler, &context), + "Callable[[DynWinRTValue | None, 'ClickedEventArgs'], object]" + ); + let (signature, wrapper) = py_event_callback(Some(&handler), &context); + assert_eq!(signature, py_delegate_callable_type(&handler, &context)); + assert_eq!( + wrapper, + "(lambda callback=callback: (lambda __sender__, __e__: callback(\ + (lambda value: None if value.is_null() else value)(__sender__), \ + (lambda value: None if value.is_null() else \ + _dynwinrt_symbol('contoso__clicked_event_args', 'ClickedEventArgs')._from_native(value))(__e__))))()" + ); + assert_eq!( + py_delegate_input_arg("handler", &handler, &context).unwrap(), + format!( + "_dynwinrt_delegate(handler, \ + _dynwinrt_symbol('clicked_handler', 'IID_ClickedHandler'), \ + _dynwinrt_symbol('clicked_handler', 'ClickedHandler_PARAM_TYPES'), \ + lambda callback: ({}))", + wrapper + .strip_prefix("(lambda callback=callback: (") + .and_then(|body| body.strip_suffix("))()")) + .unwrap() + ) + ); + } + + #[test] + fn argument_annotations_are_non_null_except_object_and_references() { + let reference = TypeMeta::Parameterized { + namespace: "Windows.Foundation".into(), + name: "IReference`1".into(), + piid: "61c17706-2d65-11e0-9ae8-d48564015472".into(), + args: vec![TypeMeta::I32], + }; + let mode = TypeMeta::Enum { + namespace: "Contoso".into(), + name: "Mode".into(), + underlying: Box::new(TypeMeta::I32), + members: Vec::new(), + is_flags: false, + doc: None, + deprecated: None, + }; + let handler = delegate("ChangedHandler"); + let context = context( + &[ + named(TypeIdentityKind::Class, "Widget"), + named(TypeIdentityKind::Enum, "Mode"), + reference.type_identity(), + ], + vec![( + handler.clone(), + vec![ + input("sender", class("Widget")), + input("mode", mode), + input("value", reference), + input("peer", class("Unknown")), + ], + )], + ); + + assert_eq!( + py_delegate_callable_type(&handler, &context), + "Callable[['Widget', 'Mode', int | None, DynWinRTValue | None], object]" + ); + } + + #[test] + fn delegates_without_arguments_or_signatures_pass_callables_through() { + let empty = delegate("DispatchedHandler"); + let unknown = delegate("UnregisteredHandler"); + let context = context(&[], vec![(empty.clone(), Vec::new())]); + + assert_eq!( + py_delegate_callable_type(&empty, &context), + "Callable[[], object]" + ); + assert_eq!(py_event_callback(Some(&empty), &context).1, "callback"); + assert_eq!( + py_delegate_input_arg("handler", &empty, &context).unwrap(), + "_dynwinrt_delegate(handler, \ + _dynwinrt_symbol('dispatched_handler', 'IID_DispatchedHandler'), \ + _dynwinrt_symbol('dispatched_handler', 'DispatchedHandler_PARAM_TYPES'))" + ); + assert_eq!( + py_delegate_callable_type(&unknown, &context), + "Callable[..., object]" + ); + assert_eq!(py_event_callback(Some(&unknown), &context).1, "callback"); + assert!(py_delegate_input_arg("handler", &unknown, &context).is_none()); + } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs index 1a670f54..ea321ed2 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -142,34 +142,6 @@ pub fn generate_class( imported_names.insert(reference_name); } } - for iface in class.all_interfaces() { - if iface.generic_piid.as_deref() - == Some(crate::codegen::winrt::python::collections::IOBSERVABLE_VECTOR_PIID) - { - let identity = iface.type_identity(); - let reference_name = context.reference_name(&identity); - if imported_names.insert(reference_name) { - type_checking_imports.push(format_py_type_import( - context, - &iface.namespace, - &iface.name, - crate::types::TypeKind::Interface, - )); - } - let event_args = "IVectorChangedEventArgs"; - if imported_names.insert(event_args.into()) { - let identity = TypeIdentity::named( - TypeIdentityKind::Interface, - crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, - event_args, - ); - type_checking_imports.push(format!( - "from .{} import {event_args} # noqa: F401\n", - context.implementation_module(&identity) - )); - } - } - } // Import delegate IID + PARAM_TYPES let mut sorted_delegates: Vec<_> = runtime_delegate_names.iter().collect(); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs index 76351781..b24b2acb 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -111,12 +111,14 @@ def _dynwinrt_create_delegate(iid, parameter_types, callback): _dynwinrt_wrap_delegate_callback(callback), ) -def _dynwinrt_delegate(value, iid, parameter_types): +def _dynwinrt_delegate(value, iid, parameter_types, project=None): raw = getattr(value, '_obj', value) if isinstance(raw, DynWinRTValue): return raw if not callable(value): raise TypeError('delegate value must be callable or a DynWinRTValue') + if project is not None: + value = project(value) return _dynwinrt_create_delegate(iid, parameter_types, value).to_value() def _dynwinrt_can_cast(value, iid): raw = getattr(value, '_obj', value) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs index 4eb5dd40..c9fead53 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs @@ -164,18 +164,6 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe type_checking_imports.push(format!("from .{module} import {import} # noqa: F401\n")); } } - if observable_vector.is_some() { - let event_args = "IVectorChangedEventArgs"; - let identity = TypeIdentity::named( - TypeIdentityKind::Interface, - crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, - event_args, - ); - let module = context.implementation_module(&identity); - type_checking_imports.push(format!( - "from .{module} import {event_args} # noqa: F401\n" - )); - } // Import delegate IID + PARAM_TYPES let mut sorted_delegates: Vec<_> = runtime_delegate_names.iter().collect(); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/naming.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/naming.rs index 3dedf02b..a84f5241 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/naming.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/naming.rs @@ -5,11 +5,12 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::sync::Arc; use super::super::shared::implementation_symbols::{ HelperOwner, ImplementationHelper, allocate_helpers, interface_helpers, }; -use crate::meta::InterfaceMeta; +use crate::meta::{InterfaceMeta, MethodMeta}; use crate::types::{TypeIdentity, TypeIdentityKind, TypeKind, TypeMeta, TypeRef}; pub type PythonTypeIdentity = TypeIdentity; @@ -516,6 +517,8 @@ pub struct PythonProjectionContext { implementation_helpers: BTreeMap>, module_symbols: HashMap<(PythonTypeIdentity, PythonSymbol), String>, module_support_symbols: HashMap, + // Shared by module contexts, which clone the projection state. + delegate_invokes: Arc>, } impl PythonProjectionContext { @@ -641,6 +644,7 @@ impl PythonProjectionContext { implementation_helpers: BTreeMap::new(), module_symbols: HashMap::new(), module_support_symbols: HashMap::new(), + delegate_invokes: Arc::default(), }) } @@ -1050,6 +1054,28 @@ impl PythonProjectionContext { .map_or(&[], Vec::as_slice) } + /// Record the `Invoke` signatures, with generic arguments substituted, of + /// the delegates referenced by `interfaces`. + pub fn register_delegate_invokes<'a>( + &mut self, + interfaces: impl IntoIterator, + ) { + let entries = interfaces + .into_iter() + .flat_map(|interface| &interface.implementation_metadata.delegates) + .map(|delegate| (self.identity_for_type(&delegate.typ), &delegate.invoke)) + .collect::>(); + let invokes = Arc::make_mut(&mut self.delegate_invokes); + for (identity, invoke) in entries { + invokes.entry(identity).or_insert_with(|| invoke.clone()); + } + } + + /// The `Invoke` signature of a registered delegate type. + pub(crate) fn delegate_invoke(&self, typ: &TypeMeta) -> Option<&MethodMeta> { + self.delegate_invokes.get(&self.identity_for_type(typ)) + } + pub(crate) fn implementation_helper_name( &self, interface: &InterfaceMeta, diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs index 600a457d..3998d2ba 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs @@ -287,17 +287,16 @@ pub(super) fn emit_method_stub_named( ); } else if method.is_property_setter { let prop_name = to_snake_case(method.name.strip_prefix("put_").unwrap_or(&method.name)); - let param_type = if in_params + let param_type = in_params .first() - .is_some_and(|p| is_delegate_type(Some(&p.typ))) - { - "Callable[..., object] | 'DynWinRTValue'".to_string() - } else { - in_params - .first() - .map(|p| py_param_type_safe(&p.typ, context)) - .unwrap_or_else(|| "object".to_string()) - }; + .map(|p| { + if is_delegate_type(Some(&p.typ)) { + super::delegates::py_delegate_param_type(&p.typ, context) + } else { + py_param_type_safe(&p.typ, context) + } + }) + .unwrap_or_else(|| "object".to_string()); if property_has_getter { out.push_str(&format!("{indent}@{}.setter\n", prop_name)); emit_documented_stub( diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs index 0568bbf9..fab0e88b 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs @@ -353,18 +353,6 @@ pub fn generate_interface_stub(context: &PythonProjectionContext, iface: &Interf out.push_str(&format!("from .{module} import {import} # noqa: F401\n")); } } - if observable_vector.is_some() { - let event_args = "IVectorChangedEventArgs"; - let identity = crate::types::TypeIdentity::named( - crate::types::TypeIdentityKind::Interface, - crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, - event_args, - ); - let module = context.implementation_module(&identity); - out.push_str(&format!( - "from .{module} import {event_args} # noqa: F401\n" - )); - } let mut sorted_delegates: Vec<_> = runtime_delegate_names.iter().collect(); sorted_delegates.sort(); @@ -720,30 +708,6 @@ pub fn generate_class_stub( imported_names.insert(reference_name); } } - for iface in class.all_interfaces() { - if iface.generic_piid.as_deref() == Some(super::collections::IOBSERVABLE_VECTOR_PIID) { - let identity = iface.type_identity(); - let projected_name = context.projected_name(&identity); - if imported_names.insert(projected_name.clone()) { - let module = context.implementation_module(&identity); - out.push_str(&format!( - "from .{module} import {projected_name} # noqa: F401\n" - )); - } - let event_args = "IVectorChangedEventArgs"; - if imported_names.insert(event_args.into()) { - let identity = crate::types::TypeIdentity::named( - crate::types::TypeIdentityKind::Interface, - crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, - event_args, - ); - let module = context.implementation_module(&identity); - out.push_str(&format!( - "from .{module} import {event_args} # noqa: F401\n" - )); - } - } - } let mut sorted_delegates: Vec<_> = runtime_delegate_names.iter().collect(); sorted_delegates.sort(); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs index 17302ef0..7748ae55 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs @@ -324,7 +324,7 @@ pub(super) fn py_method_return_type( } } -fn py_return_type(typ: Option<&TypeMeta>, context: &PythonProjectionContext) -> String { +pub(super) fn py_return_type(typ: Option<&TypeMeta>, context: &PythonProjectionContext) -> String { match typ { Some(TypeMeta::String) => "str".to_string(), Some(TypeMeta::Guid) => "UUID".to_string(), diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/shared/imports.rs b/tools/dynwinrt-codegen/src/codegen/winrt/shared/imports.rs index a8abb5d6..642aa37d 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/shared/imports.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/shared/imports.rs @@ -94,11 +94,34 @@ pub(crate) fn collect_used_generic_identities_from_methods( pub(crate) fn collect_used_generic_identities_from_class(class: &ClassMeta) -> Vec { let methods = class .all_interfaces() - .flat_map(|interface| interface.methods.iter()) + .flat_map(|interface| { + interface + .methods + .iter() + .chain(input_delegate_invokes(interface)) + }) .collect::>(); collect_used_generic_identities_from_methods_inner(&methods) } +/// `Invoke` signatures of the delegates an interface accepts as inputs. Python +/// callback annotations name their parameter types. +fn input_delegate_invokes(interface: &InterfaceMeta) -> impl Iterator { + interface + .implementation_metadata + .delegates + .iter() + .filter(|delegate| { + interface.methods.iter().any(|method| { + method + .params + .iter() + .any(|param| param.direction == ParamDirection::In && param.typ == delegate.typ) + }) + }) + .map(|delegate| &delegate.invoke) +} + // ====================================================================== // Import collection helpers // ====================================================================== @@ -231,6 +254,9 @@ pub(crate) fn collect_class_type_imports_by_identity(class: &ClassMeta) -> HashS let mut imports = HashSet::new(); for iface in class.all_interfaces() { collect_methods_type_imports(&iface.methods, "", true, &mut imports); + for invoke in input_delegate_invokes(iface) { + collect_methods_type_imports(std::slice::from_ref(invoke), "", true, &mut imports); + } } imports.retain(|reference| { reference.kind != TypeKind::Class diff --git a/tools/dynwinrt-codegen/src/main.rs b/tools/dynwinrt-codegen/src/main.rs index 983b7524..9bbdd142 100644 --- a/tools/dynwinrt-codegen/src/main.rs +++ b/tools/dynwinrt-codegen/src/main.rs @@ -4038,6 +4038,17 @@ fn generate_py_files( .map_or_else(Vec::new, |item| item.helpers.clone()), ) })); + configured.register_delegate_invokes( + all_classes + .iter() + .flat_map(|class| { + class + .all_interfaces() + .chain(class.overridable_interfaces.iter()) + }) + .chain(all_interfaces) + .chain(shared_interfaces), + ); let context = &configured; let current_identities = current .iter() diff --git a/tools/dynwinrt-codegen/tests/common/mod.rs b/tools/dynwinrt-codegen/tests/common/mod.rs index 40b07d41..4723dc3e 100644 --- a/tools/dynwinrt-codegen/tests/common/mod.rs +++ b/tools/dynwinrt-codegen/tests/common/mod.rs @@ -4,9 +4,33 @@ use std::collections::HashSet; use dynwinrt_codegen::codegen::python::{self, PythonProjectionContext}; use dynwinrt_codegen::codegen::python_stub; -use dynwinrt_codegen::meta::{ClassMeta, InterfaceMeta, MethodMeta}; +use dynwinrt_codegen::meta::{ + ClassMeta, ImplementationDelegateMeta, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta, +}; use dynwinrt_codegen::types::{TypeIdentity, TypeIdentityKind, TypeMeta}; +/// Metadata for a delegate type and its `Invoke(inputs...)` signature, as +/// recorded on the interfaces that reference it. +pub fn delegate_invoke(typ: TypeMeta, inputs: &[(&str, TypeMeta)]) -> ImplementationDelegateMeta { + ImplementationDelegateMeta { + typ, + invoke: MethodMeta { + name: "Invoke".into(), + raw_name: "Invoke".into(), + vtable_index: 3, + params: inputs + .iter() + .map(|(name, typ)| ParamMeta { + name: (*name).into(), + typ: typ.clone(), + direction: ParamDirection::In, + }) + .collect(), + ..Default::default() + }, + } +} + fn compatibility_name(typ: &TypeMeta) -> String { match typ { TypeMeta::RuntimeClass { name, .. } @@ -88,6 +112,23 @@ fn collect_methods( } } +/// Generation also emits the types named by delegate `Invoke` signatures. +fn collect_delegate_invokes( + interface: &InterfaceMeta, + known: &HashSet, + delegates: &HashSet, + identities: &mut HashSet, +) { + for delegate in &interface.implementation_metadata.delegates { + collect_methods( + std::slice::from_ref(&delegate.invoke), + known, + delegates, + identities, + ); + } +} + fn context( classes: &[&ClassMeta], interfaces: &[&InterfaceMeta], @@ -117,13 +158,22 @@ fn context( { identities.insert(interface.type_identity()); collect_methods(&interface.methods, known, delegates, &mut identities); + collect_delegate_invokes(interface, known, delegates, &mut identities); } } for interface in interfaces { identities.insert(interface.type_identity()); collect_methods(&interface.methods, known, delegates, &mut identities); + collect_delegate_invokes(interface, known, delegates, &mut identities); } - PythonProjectionContext::new(identities, packaged).unwrap() + let mut context = PythonProjectionContext::new(identities, packaged).unwrap(); + context.register_delegate_invokes( + classes + .iter() + .flat_map(|class| class.all_interfaces()) + .chain(interfaces.iter().copied()), + ); + context } pub fn projection_context( diff --git a/tools/dynwinrt-codegen/tests/observable_vector_test.rs b/tools/dynwinrt-codegen/tests/observable_vector_test.rs index 2d2b788a..23a78068 100644 --- a/tools/dynwinrt-codegen/tests/observable_vector_test.rs +++ b/tools/dynwinrt-codegen/tests/observable_vector_test.rs @@ -53,7 +53,18 @@ fn observable_vector_projects_python_mutable_sequence_and_typed_events() { piid: "0c051752-9fbf-4c70-aa0c-0e4c82d9a761".into(), args: vec![TypeMeta::Object], }; - let interface = InterfaceMeta { + let observable_type = TypeMeta::Parameterized { + namespace: "Windows.Foundation.Collections".into(), + name: "IObservableVector`1".into(), + piid: "5917eb53-50b4-4a0d-b309-65862b3f1dbc".into(), + args: vec![TypeMeta::Object], + }; + let event_args_type = TypeMeta::Interface { + namespace: "Windows.Foundation.Collections".into(), + name: "IVectorChangedEventArgs".into(), + iid: "575933df-34fe-4480-af15-07691f3d5d9b".into(), + }; + let mut interface = InterfaceMeta { name: "IObservableVector_Object".into(), namespace: "Windows.Foundation.Collections".into(), iid: String::new(), @@ -66,7 +77,7 @@ fn observable_vector_projects_python_mutable_sequence_and_typed_events() { vtable_index: 6, params: vec![ParamMeta { name: "handler".into(), - typ: handler_type, + typ: handler_type.clone(), direction: ParamDirection::In, }], is_event_add: true, @@ -87,12 +98,20 @@ fn observable_vector_projects_python_mutable_sequence_and_typed_events() { ], ..Default::default() }; + interface + .implementation_metadata + .delegates + .push(common::delegate_invoke( + handler_type, + &[("sender", observable_type), ("event", event_args_type)], + )); let known_types = HashSet::from([ "IObservableVector_Object".into(), "IVector_Object".into(), "IVectorChangedEventArgs".into(), ]); let delegate_types = HashSet::from(["VectorChangedEventHandler_Object".into()]); + let callback = "Callable[['IObservableVector_Object', 'IVectorChangedEventArgs'], object]"; let py = common::generate_interface(&interface, &known_types, &delegate_types); assert!(py.contains( @@ -111,9 +130,22 @@ fn observable_vector_projects_python_mutable_sequence_and_typed_events() { "{py}" ); assert!(py.contains("def as_vector(self) -> 'IVector_Object':")); - assert!(py.contains("def on_vector_changed(self, callback: Callable[[")); - assert!(py.contains("'IObservableVector_Object'")); - assert!(py.contains("'IVectorChangedEventArgs'")); + assert!( + py.contains(&format!( + "def on_vector_changed(self, callback: {callback}):" + )), + "{py}" + ); + assert!( + py.contains( + "(lambda callback=callback: (lambda __sender__, __event__: callback(\ + (lambda value: None if value.is_null() else \ + _dynwinrt_symbol('i_observable_vector_object', 'IObservableVector_Object')(value))(__sender__), \ + (lambda value: None if value.is_null() else \ + _dynwinrt_symbol('windows__foundation__collections__i_vector_changed_event_args', 'IVectorChangedEventArgs')(value))(__event__))))()" + ), + "{py}" + ); assert!(py.contains("_dynwinrt_create_delegate(")); assert!(py.contains("_IObservableVector_Object.method(6).invoke(self._observable_obj")); @@ -125,15 +157,20 @@ fn observable_vector_projects_python_mutable_sequence_and_typed_events() { "{pyi}" ); assert!(pyi.contains( - "from .windows__foundation__collections__i_vector_changed_event_args import IVectorChangedEventArgs" - )); + "from .windows__foundation__collections__i_vector_changed_event_args import IID_IVectorChangedEventArgs, IVectorChangedEventArgs" + ), "{pyi}"); assert!(pyi.contains(&format!("{create_signature} ...")), "{pyi}"); assert!( pyi.contains("def __getitem__(self, index: int) -> DynWinRTValue | None: ..."), "{pyi}" ); assert!(pyi.contains("def as_vector(self) -> 'IVector_Object': ...")); - assert!(pyi.contains("def on_vector_changed(self, callback: Callable[[")); + assert!( + pyi.contains(&format!( + "def on_vector_changed(self, callback: {callback}) -> 'DynWinRTValue': ..." + )), + "{pyi}" + ); } #[test] diff --git a/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs b/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs new file mode 100644 index 00000000..3fa33eef --- /dev/null +++ b/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Python delegate callbacks derive their annotation and argument projection +//! from the delegate `Invoke` signature, wherever a callable becomes a delegate. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +const WINDOWS_WINMD: &str = + r"C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.26100.0\Windows.winmd"; + +static NEXT: AtomicU64 = AtomicU64::new(0); + +struct Output(PathBuf); + +impl Output { + fn generate(classes: &str) -> Option { + let winmd = Path::new(WINDOWS_WINMD); + if !winmd.is_file() { + eprintln!("Skipping Windows.winmd delegate checks: SDK metadata unavailable."); + return None; + } + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target") + .join(format!( + "dc{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let output = Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")) + .args(["generate", "--winmd"]) + .arg(winmd) + .args(["--class-name", classes, "--lang", "py", "--output"]) + .arg(&path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Some(Self(path)) + } + + fn read(&self, module: &str, extension: &str) -> String { + let file = format!("{module}.{extension}"); + fs::read_to_string(self.0.join(&file)).unwrap_or_else(|error| panic!("{file}: {error}")) + } +} + +impl Drop for Output { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn class_wrapper(module: &str, class: &str, argument: &str) -> String { + format!( + "(lambda value: None if value.is_null() else \ + _dynwinrt_symbol('{module}', '{class}')._from_native(value))({argument})" + ) +} + +#[test] +fn bespoke_event_delegates_are_typed_and_projected() { + let Some(output) = + Output::generate("Windows.ApplicationModel.Background.BackgroundTaskRegistration") + else { + return; + }; + let module = "windows__application_model__background__background_task_registration"; + let callback = + "Callable[['BackgroundTaskRegistration', 'BackgroundTaskCompletedEventArgs'], object]"; + let py = output.read(module, "py"); + assert!( + py.contains(&format!("def on_completed(self, callback: {callback}):")), + "{py}" + ); + assert!( + py.contains(&format!( + "_wrapped = (lambda callback=callback: (lambda __sender__, __args__: callback({}, {})))()", + class_wrapper(module, "BackgroundTaskRegistration", "__sender__"), + class_wrapper( + "windows__application_model__background__background_task_completed_event_args", + "BackgroundTaskCompletedEventArgs", + "__args__" + ) + )), + "{py}" + ); + let pyi = output.read(module, "pyi"); + assert!( + pyi.contains(&format!( + "def subscribe_completed(self, callback: {callback}) -> Callable[[], None]: ..." + )), + "{pyi}" + ); + assert!(!pyi.contains("Callable[..., object]"), "{pyi}"); +} + +#[test] +fn static_events_callback_parameters_and_setters_project_callables() { + let Some(output) = Output::generate( + "Windows.Gaming.Input.Gamepad,Windows.System.Threading.ThreadPool,\ + Windows.System.Threading.ThreadPoolTimer,Windows.UI.Popups.UICommand", + ) else { + return; + }; + + let gamepad = output.read("windows__gaming__input__gamepad", "py"); + assert!( + gamepad.contains( + "def add_gamepad_added(value: Callable[[DynWinRTValue | None, 'Gamepad'], object] | 'DynWinRTValue')" + ), + "{gamepad}" + ); + assert!( + gamepad.contains(&format!( + "'EventHandler_Gamepad_PARAM_TYPES'), lambda callback: (lambda __sender__, __args__: \ + callback((lambda value: None if value.is_null() else value)(__sender__), {})))", + class_wrapper("windows__gaming__input__gamepad", "Gamepad", "__args__") + )), + "{gamepad}" + ); + + let thread_pool = output.read("windows__system__threading__thread_pool", "py"); + assert!( + thread_pool.contains( + "def run_async(handler: Callable[[WinRTCoroutine[None]], object] | 'DynWinRTValue')" + ), + "{thread_pool}" + ); + assert!( + thread_pool.contains( + "'WorkItemHandler_PARAM_TYPES'), lambda callback: (lambda __operation__: \ + callback(_dynwinrt_track_projected(_DynWinRTAsync(__operation__, lambda _value: None), 'WinRTAsync'))))" + ), + "{thread_pool}" + ); + + let timer_callback = "Callable[['ThreadPoolTimer'], object] | 'DynWinRTValue'"; + let timer = output.read("windows__system__threading__thread_pool_timer", "py"); + assert!( + timer.contains(&format!( + "def create_timer(handler: {timer_callback}, delay: timedelta)" + )), + "{timer}" + ); + assert!( + timer.contains(&format!( + "'TimerElapsedHandler_PARAM_TYPES'), lambda callback: (lambda __timer__: callback({})))", + class_wrapper( + "windows__system__threading__thread_pool_timer", + "ThreadPoolTimer", + "__timer__" + ) + )), + "{timer}" + ); + let timer_stub = output.read("windows__system__threading__thread_pool_timer", "pyi"); + assert!( + timer_stub.contains(&format!( + "def create_timer(handler: {timer_callback}, delay: timedelta)" + )), + "{timer_stub}" + ); + + let command_callback = "Callable[['IUICommand'], object] | 'DynWinRTValue'"; + let command = output.read("windows__ui__popups__ui_command", "py"); + assert!( + command.contains(&format!("def invoked(self, value: {command_callback}):")), + "{command}" + ); + assert!( + command.contains( + "'UICommandInvokedHandler_PARAM_TYPES'), lambda callback: (lambda __command__: \ + callback((lambda value: None if value.is_null() else \ + _dynwinrt_symbol('windows__ui__popups__iui_command', 'IUICommand')(value))(__command__))))" + ), + "{command}" + ); + let command_stub = output.read("windows__ui__popups__ui_command", "pyi"); + assert!( + command_stub.contains(&format!( + "def invoked(self, value: {command_callback}) -> None" + )), + "{command_stub}" + ); +} From a3966e83a594207eff2b49981f36286ca3532a8e Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 13:40:33 +0800 Subject: [PATCH 03/11] Project IObservableMap as a Python mutable mapping Like IObservableVector over IVector, the Python IObservableMap wrapper now extends the IMap projection, keeps its own interface for event registration, and its stub is a MutableMapping. MapChanged handlers therefore receive a sender that supports len(), indexing, membership, and iteration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/codegen/winrt/python/collections.rs | 27 + .../codegen/winrt/python/generator/types.rs | 19 +- .../src/codegen/winrt/python/stubs.rs | 5 +- .../tests/observable_map_test.rs | 625 ++++++++++++++++++ 4 files changed, 665 insertions(+), 11 deletions(-) create mode 100644 tools/dynwinrt-codegen/tests/observable_map_test.rs diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/collections.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/collections.rs index aae58a1c..c4df0121 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/collections.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/collections.rs @@ -10,6 +10,7 @@ pub(crate) const IVECTOR_PIID: &str = "913337e9-11a1-4345-a3a2-4e7f956e222d"; pub(crate) const IVECTOR_VIEW_PIID: &str = "bbe1fa4c-b0e3-4583-baef-1f1b2e483e56"; pub(crate) const IOBSERVABLE_VECTOR_PIID: &str = "5917eb53-50b4-4a0d-b309-65862b3f1dbc"; pub(crate) const IMAP_PIID: &str = "3c2925fe-8519-45c1-aa79-197b6718c1c1"; +pub(crate) const IOBSERVABLE_MAP_PIID: &str = "65df2bf5-bf39-41b5-aebc-5a9d865e472b"; pub(crate) const IMAP_VIEW_PIID: &str = "e480ce40-a338-4ada-adcf-272272e48cb9"; pub(crate) const IKEY_VALUE_PAIR_PIID: &str = "02b51929-c1c4-4a7e-8940-0312b5c18500"; @@ -42,6 +43,13 @@ pub(crate) fn interface_kind(iface: &InterfaceMeta) -> Option { iface.generic_piid.as_deref().and_then(kind_from_piid) } +/// Collection protocol of an interface's own Python projection. An +/// `IObservableMap` projection extends its `IMap` companion. +pub(crate) fn projected_interface_kind(iface: &InterfaceMeta) -> Option { + interface_kind(iface) + .or_else(|| observable_map_identity(iface).map(|_| CollectionKind::MutableMapping)) +} + pub(crate) fn class_interface(class: &ClassMeta) -> Option<&InterfaceMeta> { class .default_interface @@ -126,6 +134,25 @@ pub(crate) fn observable_vector_identity(iface: &InterfaceMeta) -> Option` an `IObservableMap` projection extends. +pub(crate) fn observable_map_identity(iface: &InterfaceMeta) -> Option { + (iface.generic_piid.as_deref() == Some(IOBSERVABLE_MAP_PIID) && iface.generic_args.len() == 2) + .then(|| { + TypeIdentity::closed_generic( + TypeIdentityKind::Interface, + WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, + "IMap", + iface.generic_args.iter().map(TypeMeta::type_identity), + ) + }) +} + +/// Observable collections extend their mutable companion in Python, so an +/// event sender still behaves as a sequence or mapping. +pub(crate) fn observable_collection_identity(iface: &InterfaceMeta) -> Option { + observable_vector_identity(iface).or_else(|| observable_map_identity(iface)) +} + pub(crate) fn is_mapping_input(kind: CollectionKind, args: &[TypeMeta]) -> bool { matches!( kind, diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs index c9fead53..f8f784bd 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs @@ -7,8 +7,8 @@ use super::imports::{emit_type_checking_imports, format_py_type_import}; use super::structs::{generate_struct_helpers, generate_struct_imports}; use super::*; use crate::codegen::winrt::python::collections::{ - CollectionKind, interface_kind, map_iterable_identity, observable_vector_identity, - runtime_mixin, + CollectionKind, interface_kind, map_iterable_identity, observable_collection_identity, + observable_vector_identity, runtime_mixin, }; use crate::types::{TypeIdentity, TypeIdentityKind}; @@ -103,6 +103,7 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe } let collection_kind = interface_kind(iface); let observable_vector = observable_vector_identity(iface); + let observable_collection = observable_collection_identity(iface); if observable_vector.is_none() && let Some(mixin) = collection_kind.and_then(runtime_mixin) { @@ -248,12 +249,12 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe out.push_str(&implementation.support_code); // Wrapper class - if let Some(identity) = &observable_vector { - let vector_name = context.projected_name(identity); + if let Some(identity) = &observable_collection { + let companion_name = context.projected_name(identity); out.push_str(&format!( "\nclass {}({}):\n", iface.name, - py_runtime_symbol(context, identity, &vector_name) + py_runtime_symbol(context, identity, &companion_name) )); } else if let Some(mixin) = collection_kind.and_then(runtime_mixin) { out.push_str(&format!("\nclass {}({mixin}):\n", iface.name)); @@ -287,11 +288,11 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe \x20 return super().__new__(cls)\n\n", ); out.push_str(" def _set_native(self, obj: DynWinRTValue, *, cache=True):\n"); - if let Some(identity) = &observable_vector { - let vector_name = context.projected_name(identity); + if let Some(identity) = &observable_collection { + let companion_name = context.projected_name(identity); out.push_str(&format!( " {}._set_native(self, obj)\n", - py_runtime_symbol(context, identity, &vector_name) + py_runtime_symbol(context, identity, &companion_name) )); out.push_str(&format!( " self._observable_obj = obj.cast(IID_{})\n", @@ -561,7 +562,7 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe // Instance methods (reorder so @property comes before @x.setter) let iface_var = registration_symbol; - let obj_expr = if observable_vector.is_some() { + let obj_expr = if observable_collection.is_some() { "self._observable_obj" } else { "self._obj" diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs index fab0e88b..11cfcfbe 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs @@ -26,6 +26,7 @@ use crate::codegen::winrt::shared::structs::{ use super::collections::{ CollectionKind, abc_name, class_interface, interface_kind, observable_vector_identity, + projected_interface_kind, }; use super::naming::{PythonProjectionContext, PythonSupportSymbol, is_py_reserved, to_snake_case}; use super::native_types::foundation_type; @@ -265,7 +266,7 @@ pub fn generate_interface_stub(context: &PythonProjectionContext, iface: &Interf return out; } let implementation = super::implementation::project(context, iface); - let collection_kind = interface_kind(iface); + let collection_kind = projected_interface_kind(iface); let is_protocol = collection_kind.is_none(); let has_projection = !iface.iid.is_empty() || iface.generic_piid.is_some(); let has_factory = implementation.supported || (is_protocol && has_projection); @@ -1255,7 +1256,7 @@ fn collection_protocol_stubs( context: &PythonProjectionContext, indent_spaces: usize, ) -> String { - let Some(kind) = interface_kind(iface) else { + let Some(kind) = projected_interface_kind(iface) else { return String::new(); }; let indent = " ".repeat(indent_spaces); diff --git a/tools/dynwinrt-codegen/tests/observable_map_test.rs b/tools/dynwinrt-codegen/tests/observable_map_test.rs new file mode 100644 index 00000000..8352ad32 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/observable_map_test.rs @@ -0,0 +1,625 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod common; + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +use dynwinrt_codegen::meta::{ClassMeta, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta}; +use dynwinrt_codegen::types::TypeMeta; + +const COLLECTIONS: &str = "Windows.Foundation.Collections"; +const WINDOWS_WINMD: &str = + r"C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.26100.0\Windows.winmd"; + +fn collections_type(name: &str, piid: &str, args: Vec) -> TypeMeta { + TypeMeta::Parameterized { + namespace: COLLECTIONS.into(), + name: name.into(), + piid: piid.into(), + args, + } +} + +fn map_changed_handler(key: TypeMeta, value: TypeMeta) -> TypeMeta { + collections_type( + "MapChangedEventHandler`2", + "179517f3-94ee-41f8-bddc-768a895544f3", + vec![key, value], + ) +} + +fn vector_changed_handler(element: TypeMeta) -> TypeMeta { + collections_type( + "VectorChangedEventHandler`1", + "0c051752-9fbf-4c70-aa0c-0e4c82d9a761", + vec![element], + ) +} + +fn event_methods(event: &str, handler: TypeMeta) -> Vec { + vec![ + MethodMeta { + name: format!("add_{event}"), + raw_name: format!("add_{event}"), + vtable_index: 6, + params: vec![ParamMeta { + name: "handler".into(), + typ: handler, + direction: ParamDirection::In, + }], + is_event_add: true, + ..Default::default() + }, + MethodMeta { + name: format!("remove_{event}"), + raw_name: format!("remove_{event}"), + vtable_index: 7, + params: vec![ParamMeta { + name: "token".into(), + typ: TypeMeta::I64, + direction: ParamDirection::In, + }], + is_event_remove: true, + ..Default::default() + }, + ] +} + +/// `IObservableMap` whose `MapChanged` delegate carries the +/// `Invoke(IObservableMap sender, IMapChangedEventArgs event)` metadata. +fn observable_map(key: TypeMeta, value: TypeMeta, name: &str) -> InterfaceMeta { + let handler = map_changed_handler(key.clone(), value.clone()); + let mut interface = InterfaceMeta { + name: name.into(), + namespace: COLLECTIONS.into(), + iid: "65df2bf5-bf39-41b5-aebc-5a9d865e472b".into(), + generic_piid: Some("65df2bf5-bf39-41b5-aebc-5a9d865e472b".into()), + generic_args: vec![key.clone(), value.clone()], + methods: event_methods("MapChanged", handler.clone()), + ..Default::default() + }; + interface + .implementation_metadata + .delegates + .push(common::delegate_invoke( + handler, + &[ + ( + "sender", + collections_type( + "IObservableMap`2", + "65df2bf5-bf39-41b5-aebc-5a9d865e472b", + vec![key.clone(), value], + ), + ), + ( + "event", + collections_type( + "IMapChangedEventArgs`1", + "9939f4df-050a-4c0f-aa60-77075f9c4777", + vec![key], + ), + ), + ], + )); + interface +} + +/// `IObservableVector` whose `VectorChanged` delegate carries the +/// `Invoke(IObservableVector sender, IVectorChangedEventArgs event)` metadata. +fn observable_vector(element: TypeMeta, name: &str) -> InterfaceMeta { + let handler = vector_changed_handler(element.clone()); + let mut interface = InterfaceMeta { + name: name.into(), + namespace: COLLECTIONS.into(), + iid: "5917eb53-50b4-4a0d-b309-65862b3f1dbc".into(), + generic_piid: Some("5917eb53-50b4-4a0d-b309-65862b3f1dbc".into()), + generic_args: vec![element.clone()], + methods: event_methods("VectorChanged", handler.clone()), + ..Default::default() + }; + interface + .implementation_metadata + .delegates + .push(common::delegate_invoke( + handler, + &[ + ( + "sender", + collections_type( + "IObservableVector`1", + "5917eb53-50b4-4a0d-b309-65862b3f1dbc", + vec![element], + ), + ), + ( + "event", + TypeMeta::Interface { + namespace: COLLECTIONS.into(), + name: "IVectorChangedEventArgs".into(), + iid: "575933df-34fe-4480-af15-07691f3d5d9b".into(), + }, + ), + ], + )); + interface +} + +fn string_object_map() -> InterfaceMeta { + InterfaceMeta { + name: "IMap_String_Object".into(), + namespace: COLLECTIONS.into(), + iid: "3c2925fe-8519-45c1-aa79-197b6718c1c1".into(), + generic_piid: Some("3c2925fe-8519-45c1-aa79-197b6718c1c1".into()), + generic_args: vec![TypeMeta::String, TypeMeta::Object], + methods: vec![MethodMeta { + name: "get_Size".into(), + raw_name: "get_Size".into(), + vtable_index: 7, + return_type: Some(TypeMeta::U32), + is_property_getter: true, + ..Default::default() + }], + ..Default::default() + } +} + +fn map_known_types() -> HashSet { + HashSet::from([ + "IObservableMap_String_Object".into(), + "IMap_String_Object".into(), + "IMapChangedEventArgs_String".into(), + ]) +} + +fn map_delegates() -> HashSet { + HashSet::from(["MapChangedEventHandler_String_Object".into()]) +} + +const MAP_CALLBACK: &str = + "Callable[['IObservableMap_String_Object', 'IMapChangedEventArgs_String'], object]"; + +#[test] +fn observable_map_projects_python_mutable_mapping_and_typed_events() { + let interface = observable_map( + TypeMeta::String, + TypeMeta::Object, + "IObservableMap_String_Object", + ); + + let py = common::generate_interface(&interface, &map_known_types(), &map_delegates()); + let map_base = "_dynwinrt_symbol('i_map_string_object', 'IMap_String_Object')"; + assert!( + py.contains(&format!("class IObservableMap_String_Object({map_base}):")), + "{py}" + ); + assert!( + py.contains(&format!(" {map_base}._set_native(self, obj)\n")), + "{py}" + ); + assert!( + py.contains("self._observable_obj = obj.cast(IID_IObservableMap_String_Object)"), + "{py}" + ); + for helper in ["on", "subscribe", "once"] { + assert!( + py.contains(&format!( + "def {helper}_map_changed(self, callback: {MAP_CALLBACK}):" + )), + "{py}" + ); + } + assert!( + py.contains( + "_wrapped = (lambda callback=callback: (lambda __sender__, __event__: callback(\ + (lambda value: None if value.is_null() else \ + _dynwinrt_symbol('i_observable_map_string_object', 'IObservableMap_String_Object')(value))(__sender__), \ + (lambda value: None if value.is_null() else \ + _dynwinrt_symbol('i_map_changed_event_args_string', 'IMapChangedEventArgs_String')(value))(__event__))))()" + ), + "{py}" + ); + assert!(!py.contains("_wrapped = callback\n"), "{py}"); + assert!( + py.contains("_IObservableMap_String_Object.method(6).invoke(self._observable_obj"), + "{py}" + ); + assert!( + py.contains("_IObservableMap_String_Object.method(7).invoke(self._observable_obj"), + "{py}" + ); + assert!( + py.contains("import IMapChangedEventArgs_String # noqa: F401"), + "{py}" + ); + + let pyi = common::generate_interface_stub(&interface, &map_known_types(), &map_delegates()); + assert!( + pyi.contains( + "class IObservableMap_String_Object(_IObservableMap_String_ObjectIdentity, MutableMapping[str, DynWinRTValue | None]):" + ), + "{pyi}" + ); + assert!( + pyi.contains(" def __init__(self, obj: DynWinRTValue) -> None: ..."), + "{pyi}" + ); + assert!(pyi.contains(" def __len__(self) -> int: ..."), "{pyi}"); + assert!( + pyi.contains(" def __getitem__(self, key: str) -> DynWinRTValue | None: ..."), + "{pyi}" + ); + assert!( + pyi.contains(" def __delitem__(self, key: str) -> None: ..."), + "{pyi}" + ); + assert!( + pyi.contains(&format!( + "def on_map_changed(self, callback: {MAP_CALLBACK}) -> 'DynWinRTValue': ..." + )), + "{pyi}" + ); + for helper in ["subscribe", "once"] { + assert!( + pyi.contains(&format!( + "def {helper}_map_changed(self, callback: {MAP_CALLBACK}) -> Callable[[], None]: ..." + )), + "{pyi}" + ); + } + assert_eq!( + pyi.matches("import IMapChangedEventArgs_String # noqa: F401") + .count(), + 1, + "{pyi}" + ); + assert!(!pyi.contains("Callable[..., object]"), "{pyi}"); +} + +fn runtime_class(name: &str, required_interfaces: Vec) -> ClassMeta { + ClassMeta { + name: name.into(), + namespace: COLLECTIONS.into(), + full_name: format!("{COLLECTIONS}.{name}"), + default_interface: Some(InterfaceMeta { + name: format!("I{name}"), + namespace: COLLECTIONS.into(), + iid: "8a43ed9f-f4e6-4421-acf9-1dab2986820c".into(), + ..Default::default() + }), + required_interfaces, + is_referenced_as_value: true, + ..Default::default() + } +} + +#[test] +fn runtime_class_map_changed_events_project_observable_sender_and_arguments() { + let class = runtime_class( + "PropertySet", + vec![ + observable_map( + TypeMeta::String, + TypeMeta::Object, + "IObservableMap_String_Object", + ), + string_object_map(), + ], + ); + + let py = common::generate_class( + &class, + &map_known_types(), + &map_delegates(), + &HashSet::new(), + ); + for helper in ["on", "subscribe", "once"] { + assert!( + py.contains(&format!( + "def {helper}_map_changed(self, callback: {MAP_CALLBACK}):" + )), + "{py}" + ); + } + assert!( + py.contains( + "_dynwinrt_symbol('i_observable_map_string_object', 'IObservableMap_String_Object')(value))(__sender__)" + ), + "{py}" + ); + assert!( + py.contains( + "_dynwinrt_symbol('i_map_changed_event_args_string', 'IMapChangedEventArgs_String')(value))(__event__)" + ), + "{py}" + ); + assert!( + py.contains( + "_IObservableMap_String_Object.method(6).invoke(self._obj.cast(IID_IObservableMap_String_Object)" + ), + "{py}" + ); + let type_checking = py + .split_once("if TYPE_CHECKING:\n") + .map(|(_, rest)| rest.split_once("\n\n").map_or(rest, |(block, _)| block)) + .unwrap_or_else(|| panic!("missing TYPE_CHECKING imports:\n{py}")); + for imported in [ + "import IObservableMap_String_Object # noqa: F401", + "import IMapChangedEventArgs_String # noqa: F401", + ] { + assert!(type_checking.contains(imported), "{py}"); + } + // The projected sender comes from the standalone observable-map module, + // so the class module no longer embeds a protocol-less duplicate. + assert!(!py.contains("\nclass IObservableMap_String_Object"), "{py}"); + assert!( + py.contains("\nclass IMap_String_Object(_WinRTMutableMappingMixin):"), + "{py}" + ); + + let pyi = common::generate_class_stub( + &class, + &map_known_types(), + &map_delegates(), + &HashSet::new(), + ); + for imported in [ + "import IObservableMap_String_Object # noqa: F401\n", + "import IMapChangedEventArgs_String # noqa: F401\n", + ] { + assert_eq!(pyi.matches(imported).count(), 1, "{pyi}"); + } + assert!( + !pyi.contains("\nclass IObservableMap_String_Object"), + "{pyi}" + ); + assert!( + pyi.contains(&format!( + "def on_map_changed(self, callback: {MAP_CALLBACK}) -> 'DynWinRTValue': ..." + )), + "{pyi}" + ); + assert_eq!( + pyi.matches(&format!( + "def subscribe_map_changed(self, callback: {MAP_CALLBACK}) -> Callable[[], None]: ..." + )) + .count(), + 2, + "the Like protocol and the class both expose the typed helper:\n{pyi}" + ); + assert!(!pyi.contains("Callable[..., object]"), "{pyi}"); +} + +#[test] +fn runtime_class_vector_changed_events_import_observable_sender_and_arguments() { + let class = runtime_class( + "StringCollection", + vec![ + observable_vector(TypeMeta::String, "IObservableVector_String"), + InterfaceMeta { + name: "IVector_String".into(), + namespace: COLLECTIONS.into(), + iid: "913337e9-11a1-4345-a3a2-4e7f956e222d".into(), + generic_piid: Some("913337e9-11a1-4345-a3a2-4e7f956e222d".into()), + generic_args: vec![TypeMeta::String], + ..Default::default() + }, + ], + ); + let known_types = HashSet::from([ + "IObservableVector_String".into(), + "IVector_String".into(), + "IVectorChangedEventArgs".into(), + ]); + let delegates = HashSet::from(["VectorChangedEventHandler_String".into()]); + let callback = "Callable[['IObservableVector_String', 'IVectorChangedEventArgs'], object]"; + + let py = common::generate_class(&class, &known_types, &delegates, &HashSet::new()); + for helper in ["on", "subscribe", "once"] { + assert!( + py.contains(&format!( + "def {helper}_vector_changed(self, callback: {callback}):" + )), + "{py}" + ); + } + assert!( + py.contains( + "_dynwinrt_symbol('i_observable_vector_string', 'IObservableVector_String')(value))(__sender__)" + ), + "{py}" + ); + assert!( + py.contains( + "_dynwinrt_symbol('windows__foundation__collections__i_vector_changed_event_args', 'IVectorChangedEventArgs')(value))(__event__)" + ), + "{py}" + ); + for imported in [ + "import IObservableVector_String # noqa: F401", + "IVectorChangedEventArgs # noqa: F401", + ] { + assert!(py.contains(imported), "{py}"); + } + assert!(!py.contains("\nclass IObservableVector_String"), "{py}"); + + let pyi = common::generate_class_stub(&class, &known_types, &delegates, &HashSet::new()); + for imported in [ + "import IObservableVector_String # noqa: F401\n", + "IVectorChangedEventArgs # noqa: F401\n", + ] { + assert_eq!(pyi.matches(imported).count(), 1, "{pyi}"); + } + assert!( + pyi.contains(&format!( + "def once_vector_changed(self, callback: {callback}) -> Callable[[], None]: ..." + )), + "{pyi}" + ); + assert!(!pyi.contains("\nclass IObservableVector_String"), "{pyi}"); + assert!(!pyi.contains("Callable[..., object]"), "{pyi}"); +} + +static NEXT: AtomicU64 = AtomicU64::new(0); + +struct Output(PathBuf); + +impl Output { + fn generate(classes: &str) -> Option { + let winmd = Path::new(WINDOWS_WINMD); + if !winmd.is_file() { + eprintln!("Skipping Windows.winmd observable map checks: SDK metadata unavailable."); + return None; + } + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target") + .join(format!( + "om{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let output = Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")) + .args(["generate", "--winmd"]) + .arg(winmd) + .args(["--class-name", classes, "--lang", "py", "--output"]) + .arg(&path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Some(Self(path)) + } + + fn read(&self, file: &str) -> String { + fs::read_to_string(self.0.join(file)).unwrap_or_else(|error| panic!("{file}: {error}")) + } +} + +impl Drop for Output { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn windows_observable_maps_type_and_project_map_changed_handlers() { + let Some(output) = Output::generate( + "Windows.Foundation.Collections.PropertySet,Windows.Foundation.Collections.StringMap", + ) else { + return; + }; + + for (class, value) in [("property_set", "Object"), ("string_map", "String")] { + let callback = format!( + "Callable[['IObservableMap_String_{value}', 'IMapChangedEventArgs_String'], object]" + ); + let observable_module = format!( + "windows__foundation__collections__i_observable_map_string_{}", + value.to_lowercase() + ); + let sender = format!( + "(lambda value: None if value.is_null() else _dynwinrt_symbol('{observable_module}', 'IObservableMap_String_{value}')(value))(__sender__)" + ); + let args = "(lambda value: None if value.is_null() else _dynwinrt_symbol('windows__foundation__collections__i_map_changed_event_args_string', 'IMapChangedEventArgs_String')(value))(__event__)"; + + let class_py = output.read(&format!("windows__foundation__collections__{class}.py")); + let interface_py = output.read(&format!("{observable_module}.py")); + for py in [&class_py, &interface_py] { + for helper in ["on", "subscribe", "once"] { + assert!( + py.contains(&format!( + "def {helper}_map_changed(self, callback: {callback}):" + )), + "{py}" + ); + } + assert!(py.contains(&sender), "{py}"); + assert!(py.contains(args), "{py}"); + assert!(!py.contains("_wrapped = callback\n"), "{py}"); + } + assert!( + interface_py.contains(&format!( + "class IObservableMap_String_{value}(_dynwinrt_symbol('windows__foundation__collections__i_map_string_{}', 'IMap_String_{value}')):", + value.to_lowercase() + )), + "{interface_py}" + ); + assert!( + class_py.contains(&format!( + "import IObservableMap_String_{value} # noqa: F401" + )), + "{class_py}" + ); + + let class_pyi = output.read(&format!("windows__foundation__collections__{class}.pyi")); + let interface_pyi = output.read(&format!("{observable_module}.pyi")); + for pyi in [&class_pyi, &interface_pyi] { + assert!( + pyi.contains(&format!( + "def on_map_changed(self, callback: {callback}) -> 'DynWinRTValue': ..." + )), + "{pyi}" + ); + for helper in ["subscribe", "once"] { + assert!( + pyi.contains(&format!( + "def {helper}_map_changed(self, callback: {callback}) -> Callable[[], None]: ..." + )), + "{pyi}" + ); + } + assert!(!pyi.contains("Callable[..., object]"), "{pyi}"); + } + let python_value = if value == "Object" { + "DynWinRTValue | None" + } else { + "str" + }; + assert!( + interface_pyi.contains(&format!( + "class IObservableMap_String_{value}(_IObservableMap_String_{value}Identity, MutableMapping[str, {python_value}]):" + )), + "{interface_pyi}" + ); + assert!( + interface_pyi.contains(&format!( + "def __getitem__(self, key: str) -> {python_value}: ..." + )), + "{interface_pyi}" + ); + } +} + +#[test] +fn windows_observable_map_returns_emit_their_mutable_map_base() { + let Some(output) = Output::generate("Windows.ApplicationModel.Resources.Core.ResourceContext") + else { + return; + }; + let observable = + output.read("windows__foundation__collections__i_observable_map_string_string.py"); + assert!( + observable.contains( + "class IObservableMap_String_String(_dynwinrt_symbol('windows__foundation__collections__i_map_string_string', 'IMap_String_String')):" + ), + "{observable}" + ); + let map = output.read("windows__foundation__collections__i_map_string_string.py"); + assert!( + map.contains("class IMap_String_String(_WinRTMutableMappingMixin):"), + "{map}" + ); +} From 62cf2a720a04a5bafd69ad42de866e0dfe7b3380 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 14:02:31 +0800 Subject: [PATCH 04/11] Test projected delegate callbacks end to end Add Python E2E checks that PropertySet and StringMap map_changed handlers receive the observable map and IMapChangedEventArgs for inserts, updates, and removals, and that ThreadPool.run_async and ThreadPoolTimer.create_timer handlers receive projected IAsyncAction and ThreadPoolTimer arguments. Strict mypy consumers cover the handler annotations. The async cancellation E2E now passes an explicit native delegate, which still receives raw arguments, so its work item can poll IAsyncInfo.Status. Document the projected callback arguments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 21 +++ docs/status/PYTHON_CHECKLIST.md | 5 +- tests/e2e/e2e_specs.json | 52 +++++++ tests/e2e/e2e_specs.schema.json | 3 + tests/e2e/runners/py_runner.py | 138 +++++++++++++++++- tests/e2e/typecheck/python_generated_api.py | 52 ++++++- tools/dynwinrt-codegen/TYPE_COVERAGE.md | 12 +- .../tests/python_consumer_typing_test.rs | 126 ++++++++++++++++ 8 files changed, 402 insertions(+), 7 deletions(-) diff --git a/bindings/py/README.md b/bindings/py/README.md index 2a1cb72c..075b050d 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -101,6 +101,27 @@ registration thread or an asyncio event-loop thread. Keep each token returned by needed. For callback-style cleanup, `subscribe_*` returns an idempotent unsubscribe function. `once_*` subscribes for at most one callback invocation. +A Python callable passed as a delegate (an event handler, a callback +parameter such as `ThreadPool.run_async(handler)`, or a delegate-typed +property) receives the delegate's arguments as projected Python values, typed +from the delegate's `Invoke` signature. WinRT `Object` arguments stay +`DynWinRTValue | None`, and `IReference` arguments are native values or +`None`. For example, `map_changed` handlers of `PropertySet`, `StringMap`, +`ValueSet`, and other `IObservableMap` implementations receive the +`IObservableMap` projection, which is a mutable mapping, and an +`IMapChangedEventArgs` with `collection_change` and `key`. An existing +native delegate, such as one built with `DynWinRtDelegate.create`, is passed +through unchanged, and its callback keeps receiving raw `DynWinRTValue` +arguments. + +```python +def changed(sender: IObservableMap_String_Object, args: IMapChangedEventArgs_String) -> None: + if args.collection_change == CollectionChange.ItemInserted: + print(args.key, sender[args.key]) + +unsubscribe = properties.subscribe_map_changed(changed) +``` + WinRT flags enums are projected as `enum.IntFlag`. Overloaded methods share one Python name with runtime type/arity dispatch and `typing.overload` declarations. Activatable runtime classes use normal constructors, for example diff --git a/docs/status/PYTHON_CHECKLIST.md b/docs/status/PYTHON_CHECKLIST.md index f2fc4ab1..596693e5 100644 --- a/docs/status/PYTHON_CHECKLIST.md +++ b/docs/status/PYTHON_CHECKLIST.md @@ -127,8 +127,9 @@ of a dynamic projection. - [x] Document callback threads and require explicit event unsubscription. - [x] Preserve token-based `on_*` / `off_*` compatibility and provide idempotent `subscribe_*` and reentrancy-safe `once_*` helpers. -- [x] Convert `TypedEventHandler` / `EventHandler` callback arguments to typed - projected Python values. +- [x] Convert delegate callback arguments to typed projected Python values + derived from each delegate's `Invoke` signature, for events, callback + parameters, and delegate-typed properties. - [x] Implement Python collection protocols for iterable, vector, and map projections. - [x] Accept normal Python sequences, mappings, bytes, UUIDs, datetimes, and diff --git a/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index 709bf04f..88ff5022 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -343,6 +343,58 @@ { "kind": "value_set_event_lifecycle", "member": "map_changed", "langs": ["py"] } ] }, + { + "id": "property_set_map_changed_projection", + "namespace": "Windows.Foundation.Collections", + "class": "PropertySet", + "langs": ["py"], + "instantiate": { "kind": "constructor", "args": [] }, + "checks": [ + { + "kind": "map_changed_event_projection", + "member": "map_changed", + "set_key": "k", + "values": [1, 2], + "expected_type": "IObservableMap_String_Object" + } + ] + }, + { + "id": "string_map_map_changed_projection", + "namespace": "Windows.Foundation.Collections", + "class": "StringMap", + "langs": ["py"], + "instantiate": { "kind": "constructor", "args": [] }, + "checks": [ + { + "kind": "map_changed_event_projection", + "member": "map_changed", + "set_key": "k", + "values": ["first", "second"], + "expected_type": "IObservableMap_String_String" + } + ] + }, + { + "id": "thread_pool_work_item_projection", + "namespace": "Windows.System.Threading", + "class": "ThreadPool", + "langs": ["py"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "work_item_callback_projection", "member": "run_async" } + ] + }, + { + "id": "thread_pool_timer_callback_projection", + "namespace": "Windows.System.Threading", + "class": "ThreadPoolTimer", + "langs": ["py"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "timer_callback_projection", "member": "create_timer" } + ] + }, { "id": "notification_data_mapping", "namespace": "Windows.UI.Notifications", diff --git a/tests/e2e/e2e_specs.schema.json b/tests/e2e/e2e_specs.schema.json index fc2ecf9e..9d23ecf2 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -100,6 +100,9 @@ "calendar_comprehensive", "storage_query_temp_folder", "value_set_event_lifecycle", + "map_changed_event_projection", + "work_item_callback_projection", + "timer_callback_projection", "nested_struct_runtime", "generated_helper_matrix" ] diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 9bde333f..bb09f18a 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -859,6 +859,132 @@ def fail(*_args): else: cr['pass'] = True + elif kind == 'map_changed_event_projection': + key = check['set_key'] + sender_type = generated_type(pkg_name, check['expected_type']) + args_type = generated_type(pkg_name, 'IMapChangedEventArgs_String') + change_type = generated_type(pkg_name, 'CollectionChange') + + def box(value): + if isinstance(value, str): + return value + return generated_type(pkg_name, 'PropertyValue').create_int32(value) + + def same_value(actual, expected): + if isinstance(expected, str): + return actual == expected + expected = getattr(expected, '_obj', expected) + return ( + isinstance(actual, dw.DynWinRTValue) + and actual.identity_raw() == expected.identity_raw() + ) + + first, second = (box(value) for value in check['values']) + observed = [] + + def handler(sender, args): + change = args.collection_change + observed.append(( + sender, + args, + change, + args.key, + len(sender), + key in sender, + None if change == change_type.ItemRemoved else sender[key], + )) + + once_changes = [] + token_keys = [] + unsubscribe = getattr(obj, f'subscribe_{member}')(handler) + getattr(obj, f'once_{member}')( + lambda _sender, args: once_changes.append(args.collection_change) + ) + token = getattr(obj, f'on_{member}')( + lambda _sender, args: token_keys.append(args.key) + ) + obj[key] = first + obj[key] = second + del obj[key] + getattr(obj, f'off_{member}')(token) + unsubscribe() + unsubscribe() + obj[key] = first + del obj[key] + + expected = [ + (change_type.ItemInserted, 1, True, first), + (change_type.ItemChanged, 1, True, second), + (change_type.ItemRemoved, 0, False, None), + ] + if len(observed) != len(expected): + cr['error'] = f'expected {len(expected)} map changes, got {len(observed)}' + return cr + for (sender, args, change, changed_key, size, present, value), ( + expected_change, expected_size, expected_present, expected_value + ) in zip(observed, expected): + if not isinstance(sender, sender_type): + cr['error'] = f'sender was {type(sender).__name__}, not {sender_type.__name__}' + return cr + if not isinstance(args, args_type): + cr['error'] = f'args were {type(args).__name__}, not {args_type.__name__}' + return cr + if not isinstance(change, change_type) or change != expected_change: + cr['error'] = f'expected {expected_change!r}, got {change!r}' + return cr + if changed_key != key: + cr['error'] = f'expected changed key {key!r}, got {changed_key!r}' + return cr + if size != expected_size or present != expected_present: + cr['error'] = ( + f'{expected_change.name}: sender had size {size} and ' + f'membership {present}' + ) + return cr + if expected_value is not None and not same_value(value, expected_value): + cr['error'] = f'{expected_change.name}: sender[{key!r}] was {value!r}' + return cr + if once_changes != [change_type.ItemInserted]: + cr['error'] = f'once handler observed {once_changes!r}' + elif token_keys != [key, key, key]: + cr['error'] = f'token handler observed {token_keys!r}' + else: + cr['pass'] = True + + elif kind == 'work_item_callback_projection': + received = [] + await getattr(cls, member)(received.append) + operation_type = type(received[0]).__name__ if received else None + if len(received) != 1: + cr['error'] = f'work item ran {len(received)} times' + elif operation_type != '_DynWinRTAsync': + cr['error'] = f'work item received {operation_type}, not a projected IAsyncAction' + else: + cr['pass'] = True + + elif kind == 'timer_callback_projection': + from datetime import timedelta + + delay = timedelta(milliseconds=10) + fired = threading.Event() + received = [] + + def elapsed(timer): + received.append((timer, timer.delay)) + fired.set() + + timer = getattr(cls, member)(elapsed, delay) + if not fired.wait(10): + cr['error'] = 'timer handler was not invoked' + elif not isinstance(received[0][0], cls): + cr['error'] = f'timer handler received {type(received[0][0]).__name__}' + elif received[0][0]._obj.identity_raw() != timer._obj.identity_raw(): + cr['error'] = 'timer handler received a different timer' + elif received[0][1] != delay: + cr['error'] = f'timer delay was {received[0][1]!r}' + else: + cr['pass'] = True + elif kind == 'mutable_sequence_protocol': sequence = getattr(obj, member) values = check['set_value'] @@ -1522,7 +1648,17 @@ def work(action): except BaseException as error: worker_errors.append(error) - operation = cls.run_async(work) + # A native delegate passes through unchanged, so this work item + # receives its raw IAsyncAction and can poll IAsyncInfo.Status. + threading_namespace = importlib.import_module( + namespace_module_name(pkg_name, 'Windows.System.Threading') + ) + raw_work = dw.DynWinRtDelegate.create( + threading_namespace.IID_WorkItemHandler, + threading_namespace.WorkItemHandler_PARAM_TYPES, + work, + ).to_value() + operation = cls.run_async(raw_work) loop = asyncio.get_running_loop() if not await loop.run_in_executor(None, started.wait, 2.0): diff --git a/tests/e2e/typecheck/python_generated_api.py b/tests/e2e/typecheck/python_generated_api.py index 3902d0c9..ccd97e25 100644 --- a/tests/e2e/typecheck/python_generated_api.py +++ b/tests/e2e/typecheck/python_generated_api.py @@ -2,8 +2,9 @@ # Licensed under the MIT License. import asyncio -from collections.abc import Coroutine, Generator, Sequence -from typing import Any, Awaitable, List, Tuple +from collections.abc import Callable, Coroutine, Generator, Sequence +from datetime import timedelta +from typing import Any, Awaitable, List, Tuple, assert_type from dynwinrt import ( DynWinRTArray, @@ -22,6 +23,15 @@ IWwwFormUrlDecoderEntry, Uri, ) +from python_bindings.windows.foundation.collections import ( + CollectionChange, + IMapChangedEventArgs_String, + IObservableMap_String_Object, + IObservableMap_String_String, + PropertySet, + StringMap, +) +from python_bindings.windows.system.threading import ThreadPool, ThreadPoolTimer from python_bindings.windows.globalization import Calendar from python_bindings.windows.storage.streams import ( Buffer as WinRTBuffer, @@ -152,3 +162,41 @@ def check_ibuffer_bytes() -> None: interface_bytes: bytes = interface_buffer.to_bytes() runtime_bytes: bytes = runtime_buffer.to_bytes() _: Tuple[bytes, bytes] = (interface_bytes, runtime_bytes) + + +def check_map_changed_handlers(properties: PropertySet, strings: StringMap) -> None: + def on_properties( + sender: IObservableMap_String_Object, args: IMapChangedEventArgs_String + ) -> None: + size: int = len(sender) + value: DynWinRTValue | None = sender[args.key] + change: CollectionChange = args.collection_change + _: Tuple[int, DynWinRTValue | None, CollectionChange] = (size, value, change) + + unsubscribe: Callable[[], None] = properties.subscribe_map_changed(on_properties) + # Lambda parameters are inferred from the typed callback, not Any. + properties.once_map_changed( + lambda sender, args: assert_type(sender, IObservableMap_String_Object) + ) + token: DynWinRTValue = properties.on_map_changed( + lambda sender, args: assert_type(args, IMapChangedEventArgs_String) + ) + properties.off_map_changed(token) + strings.subscribe_map_changed( + lambda sender, args: assert_type( + (sender, sender[args.key], args.collection_change), + Tuple[IObservableMap_String_String, str, CollectionChange], + ) + ) + unsubscribe() + + +def check_delegate_callback_parameters() -> None: + work: WinRTCoroutine[None] = ThreadPool.run_async( + lambda operation: assert_type(operation, WinRTCoroutine[None]) + ) + timer: ThreadPoolTimer | None = ThreadPoolTimer.create_timer( + lambda elapsed: assert_type(elapsed.delay, timedelta), + timedelta(milliseconds=1), + ) + _: Tuple[WinRTCoroutine[None], ThreadPoolTimer | None] = (work, timer) diff --git a/tools/dynwinrt-codegen/TYPE_COVERAGE.md b/tools/dynwinrt-codegen/TYPE_COVERAGE.md index 26b54b1e..f8c6413d 100644 --- a/tools/dynwinrt-codegen/TYPE_COVERAGE.md +++ b/tools/dynwinrt-codegen/TYPE_COVERAGE.md @@ -75,12 +75,13 @@ Parameterized collections are generated as concrete interfaces: - `IIterable` and `IIterator`; - `IVector` and `IVectorView`; -- `IObservableVector`; +- `IObservableVector` and `IObservableMap`; - `IMap`, `IMapView`, and `IKeyValuePair`. JavaScript exposes the projected WinRT methods and convenience helpers. Python implements the matching `collections.abc` sequence, mutable-sequence, mapping, -mutable-mapping, iterable, and iterator protocols. +mutable-mapping, iterable, and iterator protocols. Python observable vectors and +maps extend their `IVector` and `IMap` projections. ## Async operations @@ -102,6 +103,13 @@ Generated event methods retain the WinRT token model. JavaScript and Python bindings marshal callbacks through their host runtimes and report callback failures as failing HRESULTs instead of unconditional success. +Python derives each callback's annotation and argument projection from the +delegate's `Invoke` signature, with generic arguments substituted. This applies +wherever a Python callable becomes a delegate: instance and static events, +callback parameters, and delegate-typed properties. Callback arguments are +non-null except WinRT `Object` (`DynWinRTValue | None`) and `IReference` +(`T | None`). + The shared dynamic WinRT delegate currently supports up to two ABI parameters. This covers common handlers such as `TypedEventHandler`, `EventHandler`, and async completion/progress handlers. Delegates with more diff --git a/tools/dynwinrt-codegen/tests/python_consumer_typing_test.rs b/tools/dynwinrt-codegen/tests/python_consumer_typing_test.rs index 622a464b..104f2764 100644 --- a/tools/dynwinrt-codegen/tests/python_consumer_typing_test.rs +++ b/tools/dynwinrt-codegen/tests/python_consumer_typing_test.rs @@ -813,6 +813,132 @@ print("collection-subscript-native-ok", flush=True) } } +#[test] +fn map_changed_handlers_receive_typed_observable_maps_and_arguments() { + let winmd = Path::new( + r"C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.26100.0\Windows.winmd", + ); + if !winmd.is_file() || !has_mypy() { + eprintln!("Skipping SDK map events: Windows.winmd or mypy unavailable."); + return; + } + let fixture = Fixture::new(); + let output = Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")) + .args(["generate", "--winmd"]) + .arg(winmd) + .args([ + "--class-name", + "Windows.Foundation.Collections.PropertySet,Windows.Foundation.Collections.StringMap,\ + Windows.Foundation.PropertyValue", + "--lang", + "py", + "--output", + ]) + .arg(fixture.0.join("sdk")) + .output() + .unwrap(); + assert!(output.status.success(), "{}", diagnostics(&output)); + typecheck( + &fixture, + &["sdk"], + r#"from typing import assert_type +from dynwinrt import DynWinRTValue +from sdk.windows.foundation.collections import ( + CollectionChange, IMapChangedEventArgs_String, IObservableMap_String_Object, + IObservableMap_String_String, PropertySet, StringMap, +) + +def typed(properties: PropertySet, strings: StringMap) -> None: + def on_properties( + sender: IObservableMap_String_Object, args: IMapChangedEventArgs_String + ) -> None: + assert_type(len(sender), int) + assert_type(sender[args.key], DynWinRTValue | None) + assert_type(args.collection_change, CollectionChange) + + properties.subscribe_map_changed(on_properties) + properties.once_map_changed( + lambda sender, args: assert_type(sender, IObservableMap_String_Object) + ) + token = properties.on_map_changed( + lambda sender, args: assert_type(args, IMapChangedEventArgs_String) + ) + properties.off_map_changed(token) + strings.subscribe_map_changed( + lambda sender, args: assert_type(sender, IObservableMap_String_String) + ) + strings.subscribe_map_changed(lambda sender, args: assert_type(sender[args.key], str)) +"#, + &[], + ); + typecheck( + &fixture, + &["sdk"], + r#"from sdk.windows.foundation.collections import PropertySet, StringMap + +def wrong_sender(sender: int, args: object) -> None: ... +def wrong_args(sender: object, args: str) -> None: ... + +def untyped(properties: PropertySet, strings: StringMap) -> None: + properties.subscribe_map_changed(wrong_sender) + strings.once_map_changed(wrong_args) + properties.on_map_changed(lambda sender, args: args.index) + strings.subscribe_map_changed(lambda sender, args: sender[0]) +"#, + &["[arg-type]", "[arg-type]", "[attr-defined]", "[index]"], + ); + if has_implementation_runtime() { + fs::write( + fixture.0.join("map_events_runtime.py"), + r#"from dynwinrt import RoApartment, projected_lifetime_scope +from sdk.windows.foundation import PropertyValue +from sdk.windows.foundation.collections import ( + CollectionChange, IMapChangedEventArgs_String, IObservableMap_String_Object, + IObservableMap_String_String, PropertySet, StringMap, +) + +with RoApartment(1), projected_lifetime_scope(): + for collection, sender_type, values in ( + (PropertySet(), IObservableMap_String_Object, + (PropertyValue.create_int32(1), PropertyValue.create_int32(2))), + (StringMap(), IObservableMap_String_String, ("first", "second")), + ): + changes = [] + + def handler(sender, args): + assert isinstance(sender, sender_type), type(sender) + assert isinstance(args, IMapChangedEventArgs_String), type(args) + changes.append((args.collection_change, args.key, len(sender), "k" in sender)) + + unsubscribe = collection.subscribe_map_changed(handler) + collection["k"] = values[0] + collection["k"] = values[1] + del collection["k"] + unsubscribe() + collection["k"] = values[0] + assert changes == [ + (CollectionChange.ItemInserted, "k", 1, True), + (CollectionChange.ItemChanged, "k", 1, True), + (CollectionChange.ItemRemoved, "k", 0, False), + ], changes +print("map-changed-native-ok", flush=True) +"#, + ) + .unwrap(); + let output = Command::new(python()) + .args(["-B", "map_events_runtime.py"]) + .current_dir(&fixture.0) + .output() + .unwrap(); + assert!(output.status.success(), "{}", diagnostics(&output)); + assert!( + String::from_utf8_lossy(&output.stdout).contains("map-changed-native-ok"), + "{}", + diagnostics(&output) + ); + } +} + #[test] fn native_object_inputs_keep_projection_factories_and_context_lifetimes() { if !has_implementation_runtime() { From dcd042c82f7380caf78a9e3f00b18ced81bac72b Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 17:29:08 +0800 Subject: [PATCH 05/11] Pass native delegates through Python event helpers Route on_ and subscribe_ through the shared delegate-input helper so DynWinRtDelegate objects, DynWinRTValue delegate values, and wrappers with _obj register unchanged. once_ remains callable-only and raises a clear TypeError for native delegates. Use positional names for internal projection lambdas to prevent collisions after Python name normalization, and keep async-operation callback arguments raw so projection cannot take over their completion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/codegen/winrt/python/delegates.rs | 252 ++++++++++++------ .../src/codegen/winrt/python/generator/mod.rs | 41 ++- .../src/codegen/winrt/python/method.rs | 51 ++-- .../src/codegen/winrt/python/stub_helpers.rs | 23 +- .../src/codegen/winrt/python/type_helpers.rs | 2 +- tools/dynwinrt-codegen/tests/common/mod.rs | 27 ++ .../tests/observable_map_test.rs | 127 +++------ .../tests/observable_vector_test.rs | 26 +- 8 files changed, 328 insertions(+), 221 deletions(-) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs index 9457d49d..96b7ef4d 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs @@ -12,7 +12,7 @@ use crate::codegen::winrt::shared::imports::ireference_inner_type; use crate::meta::{ParamDirection, ParamMeta}; use crate::types::TypeMeta; -use super::naming::{PythonProjectionContext, to_snake_case}; +use super::naming::PythonProjectionContext; use super::signature::{py_convert_return, py_runtime_symbol}; use super::type_helpers::{py_output_type, py_return_type, py_return_type_safe}; @@ -45,11 +45,15 @@ pub(crate) fn delegate_abi( } /// The native arguments a Python callable receives for a delegate: its -/// `Invoke` inputs. `None` when the signature is unknown or has outputs. +/// `Invoke` inputs. `None` when the type is not a generated delegate, or its +/// signature is unknown or has outputs. fn callback_params<'a>( typ: &TypeMeta, context: &'a PythonProjectionContext, ) -> Option<&'a [ParamMeta]> { + if !context.is_delegate_type(typ) { + return None; + } let invoke = context.delegate_invoke(typ)?; invoke .params @@ -60,13 +64,19 @@ fn callback_params<'a>( /// Annotation of one argument passed to a Python callback. /// -/// WinRT passes null delegate arguments only for `Object` and `IReference`. -/// Every callback-argument annotation goes through this function so a -/// position-aware output-nullability policy can take it over. +/// Callback arguments are annotated as non-null, except WinRT `Object` and +/// `IReference`. WinMD metadata does not record nullability, so this is an +/// optimistic policy shared with method outputs: the runtime still passes +/// `None` for a null reference. Every callback-argument annotation goes through +/// this function so a position-aware output-nullability policy can take it +/// over. pub(crate) fn py_delegate_argument_type( typ: &TypeMeta, context: &PythonProjectionContext, ) -> String { + if typ.is_async() { + return "DynWinRTValue".to_string(); + } if context.is_delegate_type(typ) { return py_output_type(typ, context); } @@ -86,10 +96,15 @@ pub(crate) fn py_delegate_argument_type( /// Project one native callback argument the way a method return is projected. fn py_delegate_argument(expr: &str, typ: &TypeMeta, context: &PythonProjectionContext) -> String { + // An awaitable wrapper would take over the operation's completion and + // cancel it on release, so async arguments stay raw values. + if typ.is_async() { + return expr.to_string(); + } if context.is_delegate_type(typ) { return format!("(lambda value: None if value.is_null() else value)({expr})"); } - py_convert_return(expr, Some(typ), typ.is_async(), context) + py_convert_return(expr, Some(typ), false, context) } /// `Callable[[...], object]` derived from the delegate's `Invoke` signature, or @@ -109,77 +124,47 @@ pub(crate) fn py_delegate_callable_type( } /// Annotation for a delegate-typed input: a Python callable or an existing -/// native delegate value. +/// native delegate object/value. pub(crate) fn py_delegate_param_type(typ: &TypeMeta, context: &PythonProjectionContext) -> String { let sig = py_delegate_callable_type(typ, context); - format!("{sig} | 'DynWinRTValue'") + format!("{sig} | 'DynWinRTValue | DynWinRtDelegate'") } -/// `lambda : callback()`, adapting a Python -/// callable named `callback` to the delegate's native arguments. `None` when -/// there is nothing to project. -fn py_callback_adapter(typ: &TypeMeta, context: &PythonProjectionContext) -> Option { +/// `lambda : ()`, projecting a delegate's native +/// arguments for a Python callable. `None` when no argument needs projection. +fn py_callback_projection(typ: &TypeMeta, context: &PythonProjectionContext) -> Option { let params = callback_params(typ, context)?; - if params.is_empty() { - return None; - } - let mut names = Vec::::new(); - for (index, param) in params.iter().enumerate() { - let name = format!("__{}__", to_snake_case(¶m.name)); - names.push(if param.name.is_empty() || names.contains(&name) { - format!("__arg{index}__") - } else { - name - }); - } + // Positional names stay distinct even when metadata names normalize alike. + let names = (0..params.len()) + .map(|index| format!("__p{index}__")) + .collect::>(); let arguments = params .iter() .zip(&names) .map(|(param, name)| py_delegate_argument(name, ¶m.typ, context)) .collect::>(); - Some(format!( - "lambda {}: callback({})", - names.join(", "), - arguments.join(", ") - )) -} - -/// Build a Python callback signature + wrapper expression for an event delegate. -/// -/// Returns `(signature, wrapper)`: -/// - `signature` is a Python type annotation (e.g., `Callable[['Foo', 'Bar'], object]`). -/// - `wrapper` is an expression that produces the ABI-facing callable, projecting -/// raw `DynWinRTValue` arguments into Python values before invoking the user's -/// `callback`. -/// -/// The wrapper falls back to a passthrough (`callback`) when the delegate -/// signature is unknown. -pub(crate) fn py_event_callback( - typ: Option<&TypeMeta>, - context: &PythonProjectionContext, -) -> (String, String) { - let Some(typ) = typ else { - return ("Callable[..., object]".to_string(), "callback".to_string()); + if arguments.iter().zip(&names).all(|(argument, name)| argument == name) { + return None; + } + let tuple = if arguments.len() == 1 { + format!("({},)", arguments[0]) + } else { + format!("({})", arguments.join(", ")) }; - let wrapper = py_callback_adapter(typ, context).map_or_else( - || "callback".to_string(), - |adapter| format!("(lambda callback=callback: ({adapter}))()"), - ); - (py_delegate_callable_type(typ, context), wrapper) + Some(format!("lambda {}: {tuple}", names.join(", "))) } -/// Convert a delegate-typed method, static, or setter argument: an existing -/// native delegate passes through; a Python callable becomes a new delegate -/// whose arguments are projected like event arguments. +/// Convert a delegate-typed input: an existing native delegate passes through; +/// a Python callable becomes a new delegate whose arguments are projected. pub(crate) fn py_delegate_input_arg( name: &str, typ: &TypeMeta, context: &PythonProjectionContext, ) -> Option { let abi = delegate_abi(typ, context)?; - Some(match py_callback_adapter(typ, context) { - Some(adapter) => format!( - "_dynwinrt_delegate({name}, {}, {}, lambda callback: ({adapter}))", + Some(match py_callback_projection(typ, context) { + Some(projection) => format!( + "_dynwinrt_delegate({name}, {}, {}, {projection})", abi.iid, abi.param_types ), None => format!( @@ -189,6 +174,31 @@ pub(crate) fn py_delegate_input_arg( }) } +/// The delegate value an `on_` method registers for `callback`. +pub(crate) fn py_event_handler_arg( + name: &str, + typ: Option<&TypeMeta>, + context: &PythonProjectionContext, +) -> String { + typ.and_then(|typ| py_delegate_input_arg(name, typ, context)) + .unwrap_or_else(|| { + format!( + "_dynwinrt_delegate({name}, DynWinRTType.object().iid(), \ + [DynWinRTType.object(), DynWinRTType.object()])" + ) + }) +} + +/// Reject native delegates in `once_`, which must wrap a Python +/// callable to remove the subscription after its first invocation. +pub(crate) fn py_once_callback_check(name: &str, event: &str, indent: &str) -> String { + format!( + "{indent}if not callable({name}) or isinstance(getattr({name}, '_obj', {name}), DynWinRTValue):\n\ + {indent} raise TypeError('once_{event} requires a Python callable; \ + use on_{event} or subscribe_{event} for native delegates')\n" + ) +} + #[cfg(test)] mod tests { use super::*; @@ -275,27 +285,100 @@ mod tests { py_delegate_callable_type(&handler, &context), "Callable[[DynWinRTValue | None, 'ClickedEventArgs'], object]" ); - let (signature, wrapper) = py_event_callback(Some(&handler), &context); - assert_eq!(signature, py_delegate_callable_type(&handler, &context)); - assert_eq!( - wrapper, - "(lambda callback=callback: (lambda __sender__, __e__: callback(\ - (lambda value: None if value.is_null() else value)(__sender__), \ + let handler_arg = "_dynwinrt_delegate(callback, \ + _dynwinrt_symbol('clicked_handler', 'IID_ClickedHandler'), \ + _dynwinrt_symbol('clicked_handler', 'ClickedHandler_PARAM_TYPES'), \ + lambda __p0__, __p1__: (\ + (lambda value: None if value.is_null() else value)(__p0__), \ (lambda value: None if value.is_null() else \ - _dynwinrt_symbol('contoso__clicked_event_args', 'ClickedEventArgs')._from_native(value))(__e__))))()" + _dynwinrt_symbol('contoso__clicked_event_args', 'ClickedEventArgs')._from_native(value))(__p1__)))"; + assert_eq!( + py_delegate_input_arg("callback", &handler, &context).unwrap(), + handler_arg ); assert_eq!( - py_delegate_input_arg("handler", &handler, &context).unwrap(), - format!( - "_dynwinrt_delegate(handler, \ - _dynwinrt_symbol('clicked_handler', 'IID_ClickedHandler'), \ - _dynwinrt_symbol('clicked_handler', 'ClickedHandler_PARAM_TYPES'), \ - lambda callback: ({}))", - wrapper - .strip_prefix("(lambda callback=callback: (") - .and_then(|body| body.strip_suffix("))()")) - .unwrap() - ) + py_event_handler_arg("callback", Some(&handler), &context), + handler_arg + ); + } + + #[test] + fn projection_parameters_are_positional_when_metadata_names_collide() { + let handler = delegate("PairHandler"); + let context = context( + &[], + vec![( + handler.clone(), + vec![input("arg1", TypeMeta::I32), input("_arg1", TypeMeta::I32)], + )], + ); + + let delegate = py_delegate_input_arg("handler", &handler, &context).unwrap(); + assert!( + delegate.ends_with( + "lambda __p0__, __p1__: (__p0__.to_number(), __p1__.to_number()))" + ), + "{delegate}" + ); + } + + #[test] + fn async_arguments_stay_raw_values() { + let completed = delegate("AsyncActionCompletedHandler"); + let work = delegate("WorkItemHandler"); + let status = TypeMeta::Enum { + namespace: "Contoso".into(), + name: "AsyncStatus".into(), + underlying: Box::new(TypeMeta::I32), + members: Vec::new(), + is_flags: false, + doc: None, + deprecated: None, + }; + let context = context( + &[named(TypeIdentityKind::Enum, "AsyncStatus")], + vec![ + ( + completed.clone(), + vec![ + input("asyncInfo", TypeMeta::AsyncAction), + input("asyncStatus", status), + ], + ), + ( + work.clone(), + vec![input( + "operation", + TypeMeta::AsyncOperationWithProgress( + Box::new(TypeMeta::U32), + Box::new(TypeMeta::U32), + ), + )], + ), + ], + ); + + assert_eq!( + py_delegate_callable_type(&completed, &context), + "Callable[[DynWinRTValue, 'AsyncStatus'], object]" + ); + assert!( + py_delegate_input_arg("handler", &completed, &context) + .unwrap() + .ends_with( + "lambda __p0__, __p1__: (__p0__, \ + _dynwinrt_enum('contoso__async_status', 'AsyncStatus', __p1__.to_number())))" + ) + ); + assert_eq!( + py_delegate_callable_type(&work, &context), + "Callable[[DynWinRTValue], object]" + ); + assert_eq!( + py_delegate_input_arg("handler", &work, &context).unwrap(), + "_dynwinrt_delegate(handler, \ + _dynwinrt_symbol('work_item_handler', 'IID_WorkItemHandler'), \ + _dynwinrt_symbol('work_item_handler', 'WorkItemHandler_PARAM_TYPES'))" ); } @@ -350,7 +433,6 @@ mod tests { py_delegate_callable_type(&empty, &context), "Callable[[], object]" ); - assert_eq!(py_event_callback(Some(&empty), &context).1, "callback"); assert_eq!( py_delegate_input_arg("handler", &empty, &context).unwrap(), "_dynwinrt_delegate(handler, \ @@ -361,7 +443,21 @@ mod tests { py_delegate_callable_type(&unknown, &context), "Callable[..., object]" ); - assert_eq!(py_event_callback(Some(&unknown), &context).1, "callback"); assert!(py_delegate_input_arg("handler", &unknown, &context).is_none()); + assert_eq!( + py_event_handler_arg("callback", Some(&unknown), &context), + "_dynwinrt_delegate(callback, DynWinRTType.object().iid(), \ + [DynWinRTType.object(), DynWinRTType.object()])" + ); + } + + #[test] + fn once_rejects_native_delegates_before_subscribing() { + assert_eq!( + py_once_callback_check("callback", "changed", " "), + " if not callable(callback) or isinstance(getattr(callback, '_obj', callback), DynWinRTValue):\n\ + \x20 raise TypeError('once_changed requires a Python callable; \ + use on_changed or subscribe_changed for native delegates')\n" + ); } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs index b24b2acb..f30b021c 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -15,10 +15,10 @@ use crate::types::{TypeKind, TypeMeta}; use crate::codegen::winrt::shared::imports::{ collect_iface_type_imports_by_identity, collect_struct_field_type_imports, collect_used_generic_identities_from_class, collect_used_generic_identities_from_methods, - collect_used_generic_identities_from_type, ireference_inner_type, + collect_used_generic_identities_from_type, get_in_params, ireference_inner_type, }; use crate::codegen::winrt::shared::structs::{ - collect_used_structs_from_class, collect_used_structs_from_iface, + collect_used_structs_from_class_and_callbacks, collect_used_structs_from_iface, collect_used_structs_from_struct, }; @@ -81,7 +81,7 @@ from dynwinrt.dynwinrt import ( _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, _dynwinrt_ticks_to_timedelta, _dynwinrt_timedelta_to_ticks, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, _dynwinrt_track_projected, _dynwinrt_uuid, _dynwinrt_vector, - _dynwinrt_wrap_delegate_callback, + _dynwinrt_wrap_delegate_callback, _active_projected_lifetime_scope, ) @@ -112,14 +112,29 @@ def _dynwinrt_create_delegate(iid, parameter_types, callback): ) def _dynwinrt_delegate(value, iid, parameter_types, project=None): + if isinstance(value, DynWinRtDelegate): + return value.to_value() raw = getattr(value, '_obj', value) if isinstance(raw, DynWinRTValue): return raw if not callable(value): raise TypeError('delegate value must be callable or a DynWinRTValue') if project is not None: - value = project(value) + value = _dynwinrt_project_callback(value, project) return _dynwinrt_create_delegate(iid, parameter_types, value).to_value() + + +def _dynwinrt_project_callback(callback, project): + # Projected arguments belong to the callback, not to a lifetime scope that + # was active when it subscribed. + def invoke(*args): + token = _active_projected_lifetime_scope.set(None) + try: + arguments = project(*args) + finally: + _active_projected_lifetime_scope.reset(token) + return callback(*arguments) + return invoke def _dynwinrt_can_cast(value, iid): raw = getattr(value, '_obj', value) if not isinstance(raw, DynWinRTValue): @@ -219,4 +234,22 @@ mod tests { assert!(runtime.contains("_dynwinrt_wrap_delegate_callback(callback),")); assert!(!runtime.contains("copy_context")); } + + #[test] + fn delegate_inputs_pass_native_delegates_and_project_outside_lifetime_scopes() { + let runtime = generate_runtime_support_module(); + + assert!(runtime.contains( + " if isinstance(value, DynWinRtDelegate):\n return value.to_value()\n" + )); + assert!(runtime.contains(" value = _dynwinrt_project_callback(value, project)\n")); + assert!(runtime.contains( + " token = _active_projected_lifetime_scope.set(None)\n\ + \x20 try:\n\ + \x20 arguments = project(*args)\n\ + \x20 finally:\n\ + \x20 _active_projected_lifetime_scope.reset(token)\n\ + \x20 return callback(*arguments)\n" + )); + } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index 54c870bb..9fb80465 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -10,7 +10,8 @@ use crate::codegen::winrt::shared::imports::{ }; use super::delegates::{ - delegate_abi, py_delegate_input_arg, py_delegate_param_type, py_event_callback, + py_delegate_callable_type, py_delegate_input_arg, py_delegate_param_type, + py_event_handler_arg, py_once_callback_check, }; use super::naming::{PythonProjectionContext, to_snake_case}; use super::signature::{ @@ -584,7 +585,6 @@ pub(crate) fn generate_method_body( let suffix = method.name.strip_prefix("add_").unwrap_or(&method.name); let event_name = to_snake_case(suffix); let delegate_typ = in_params.first().map(|p| &p.typ); - let delegate = delegate_typ.and_then(|typ| delegate_abi(typ, context)); // Find matching remove_ in the same interface to know its vtable index. let remove_target = format!("remove_{}", suffix); let remove_idx = sibling_methods.and_then(|methods| { @@ -594,29 +594,30 @@ pub(crate) fn generate_method_body( .map(|m| m.vtable_index) }); - // Project raw ABI arguments before invoking the user callback. - let (callback_signature, wrapper) = py_event_callback(delegate_typ, context); + // on_/subscribe_ accept Python callables, whose arguments are projected, + // and native delegates, which are registered unchanged. + let (input_signature, callable_signature) = match delegate_typ { + Some(typ) => ( + py_delegate_param_type(typ, context), + py_delegate_callable_type(typ, context), + ), + None => ( + "Callable[..., object] | 'DynWinRTValue | DynWinRtDelegate'".to_string(), + "Callable[..., object]".to_string(), + ), + }; out.push_str(&format!( " def on_{}(self, callback: {}):\n", - event_name, callback_signature, + event_name, input_signature, )); out.push_str(&method_pydoc(method, &in_params)); - // Wrapping expression bound to `_wrapped` before delegate construction. - out.push_str(&format!(" _wrapped = {}\n", wrapper)); - if let Some(delegate) = delegate { - out.push_str(&format!( - " _handler = _dynwinrt_create_delegate({}, {}, _wrapped)\n", - delegate.iid, delegate.param_types - )); - } else { - out.push_str( - " _handler = _dynwinrt_create_delegate(DynWinRTType.object().iid(), [DynWinRTType.object(), DynWinRTType.object()], _wrapped)\n" - ); - } out.push_str(&format!( - " return {}.method({}).invoke({}, [_handler.to_value()])\n", - iface_var, method.vtable_index, obj_expr + " return {}.method({}).invoke({}, [{}])\n", + iface_var, + method.vtable_index, + obj_expr, + py_event_handler_arg("callback", delegate_typ, context) )); // subscribe_: ergonomic, idempotent cancellation while keeping @@ -625,7 +626,7 @@ pub(crate) fn generate_method_body( out.push('\n'); out.push_str(&format!( " def subscribe_{}(self, callback: {}):\n", - event_name, callback_signature, + event_name, input_signature, )); out.push_str(&format!( " _token = self.on_{}(callback)\n", @@ -651,8 +652,9 @@ pub(crate) fn generate_method_body( out.push('\n'); out.push_str(&format!( " def once_{}(self, callback: {}):\n", - event_name, callback_signature, + event_name, callable_signature, )); + out.push_str(&py_once_callback_check("callback", &event_name, " ")); out.push_str(" _state = [True, None]\n"); out.push_str(" def _once(*args, **kwargs):\n"); out.push_str(" if not _state[0]:\n"); @@ -1154,13 +1156,16 @@ mod tests { ); assert!(code.contains("def on_changed(self, callback:")); - assert!(code.contains("_dynwinrt_create_delegate(")); - assert!(code.contains("return _IWidget.method(6).invoke(")); + assert!(code.contains("return _IWidget.method(6).invoke(self._obj, [_dynwinrt_delegate(callback, ")); assert!(code.contains("def subscribe_changed(self, callback:")); assert!(code.contains("if not _active[0]:")); assert!(code.contains("self.off_changed(_token)")); assert!(code.contains("except Exception:\n _active[0] = True")); assert!(code.contains("def once_changed(self, callback:")); + assert!(code.contains( + "raise TypeError('once_changed requires a Python callable; \ + use on_changed or subscribe_changed for native delegates')" + )); assert!(code.contains("if not _state[0]:")); assert!(code.contains("_state[0] = False")); assert!(code.contains("if not _state[0]:\n _unsubscribe()")); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs index 3998d2ba..c9f41b74 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/stub_helpers.rs @@ -7,7 +7,7 @@ use crate::codegen::winrt::shared::imports::get_in_params; use crate::meta::MethodMeta; use crate::types::{FieldMeta, TypeMeta}; -use super::delegates::py_delegate_callable_type; +use super::delegates::{py_delegate_callable_type, py_delegate_param_type}; use super::naming::{PythonProjectionContext, PythonSymbol, STRUCT_SYMBOLS, to_snake_case}; use super::native_types::{FoundationType, foundation_type}; use super::structs::{py_struct_field_read_type, py_struct_field_type}; @@ -230,17 +230,24 @@ pub(super) fn emit_method_stub_named( if method.is_event_add { let suffix = method.name.strip_prefix("add_").unwrap_or(&method.name); let event_name = to_snake_case(suffix); - // Build a typed callback signature matching the runtime .py side. + // on_/subscribe_ also accept native delegates; once_ requires a callable. let delegate_typ = in_params.first().map(|p| &p.typ); - let callback_sig = delegate_typ - .map(|typ| py_delegate_callable_type(typ, context)) - .unwrap_or_else(|| "Callable[..., object]".to_string()); + let (input_sig, callable_sig) = match delegate_typ { + Some(typ) => ( + py_delegate_param_type(typ, context), + py_delegate_callable_type(typ, context), + ), + None => ( + "Callable[..., object] | 'DynWinRTValue | DynWinRtDelegate'".to_string(), + "Callable[..., object]".to_string(), + ), + }; emit_documented_stub( &mut out, &indent, &format!( "def on_{}(self, callback: {}) -> 'DynWinRTValue'", - event_name, callback_sig + event_name, input_sig ), &doc, "", @@ -248,11 +255,11 @@ pub(super) fn emit_method_stub_named( if event_has_remove { out.push_str(&format!( "{indent}def subscribe_{}(self, callback: {}) -> Callable[[], None]: ...\n", - event_name, callback_sig + event_name, input_sig )); out.push_str(&format!( "{indent}def once_{}(self, callback: {}) -> Callable[[], None]: ...\n", - event_name, callback_sig + event_name, callable_sig )); } return out; diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs index 7748ae55..9d7ba6e4 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs @@ -759,7 +759,7 @@ mod tests { .unwrap(); assert_eq!( py_param_list(&[¶m], &context), - "handler: Callable[..., object] | 'DynWinRTValue'" + "handler: Callable[..., object] | 'DynWinRTValue | DynWinRtDelegate'" ); } diff --git a/tools/dynwinrt-codegen/tests/common/mod.rs b/tools/dynwinrt-codegen/tests/common/mod.rs index 4723dc3e..c9e2d06e 100644 --- a/tools/dynwinrt-codegen/tests/common/mod.rs +++ b/tools/dynwinrt-codegen/tests/common/mod.rs @@ -9,6 +9,33 @@ use dynwinrt_codegen::meta::{ }; use dynwinrt_codegen::types::{TypeIdentity, TypeIdentityKind, TypeMeta}; +/// Generated `on_`, `subscribe_` and `once_` signatures for an event. `on_` and +/// `subscribe_` also accept native delegates. +pub fn event_signatures(event: &str, callback: &str) -> [String; 3] { + [ + format!( + "def on_{event}(self, callback: {callback} | 'DynWinRTValue | DynWinRtDelegate'):" + ), + format!( + "def subscribe_{event}(self, callback: {callback} | 'DynWinRTValue | DynWinRtDelegate'):" + ), + format!("def once_{event}(self, callback: {callback}):"), + ] +} + +/// Stub declarations matching [`event_signatures`]. +pub fn event_stub_signatures(event: &str, callback: &str) -> [String; 3] { + [ + format!( + "def on_{event}(self, callback: {callback} | 'DynWinRTValue | DynWinRtDelegate') -> 'DynWinRTValue'" + ), + format!( + "def subscribe_{event}(self, callback: {callback} | 'DynWinRTValue | DynWinRtDelegate') -> Callable[[], None]: ..." + ), + format!("def once_{event}(self, callback: {callback}) -> Callable[[], None]: ..."), + ] +} + /// Metadata for a delegate type and its `Invoke(inputs...)` signature, as /// recorded on the interfaces that reference it. pub fn delegate_invoke(typ: TypeMeta, inputs: &[(&str, TypeMeta)]) -> ImplementationDelegateMeta { diff --git a/tools/dynwinrt-codegen/tests/observable_map_test.rs b/tools/dynwinrt-codegen/tests/observable_map_test.rs index 8352ad32..37d357b1 100644 --- a/tools/dynwinrt-codegen/tests/observable_map_test.rs +++ b/tools/dynwinrt-codegen/tests/observable_map_test.rs @@ -206,29 +206,22 @@ fn observable_map_projects_python_mutable_mapping_and_typed_events() { py.contains("self._observable_obj = obj.cast(IID_IObservableMap_String_Object)"), "{py}" ); - for helper in ["on", "subscribe", "once"] { - assert!( - py.contains(&format!( - "def {helper}_map_changed(self, callback: {MAP_CALLBACK}):" - )), - "{py}" - ); + for signature in common::event_signatures("map_changed", MAP_CALLBACK) { + assert!(py.contains(&signature), "{signature}\n{py}"); } assert!( py.contains( - "_wrapped = (lambda callback=callback: (lambda __sender__, __event__: callback(\ + " return _IObservableMap_String_Object.method(6).invoke(self._observable_obj, [_dynwinrt_delegate(callback, \ + _dynwinrt_symbol('map_changed_event_handler_string_object', 'IID_MapChangedEventHandler_String_Object'), \ + _dynwinrt_symbol('map_changed_event_handler_string_object', 'MapChangedEventHandler_String_Object_PARAM_TYPES'), \ + lambda __p0__, __p1__: (\ (lambda value: None if value.is_null() else \ - _dynwinrt_symbol('i_observable_map_string_object', 'IObservableMap_String_Object')(value))(__sender__), \ + _dynwinrt_symbol('i_observable_map_string_object', 'IObservableMap_String_Object')(value))(__p0__), \ (lambda value: None if value.is_null() else \ - _dynwinrt_symbol('i_map_changed_event_args_string', 'IMapChangedEventArgs_String')(value))(__event__))))()" + _dynwinrt_symbol('i_map_changed_event_args_string', 'IMapChangedEventArgs_String')(value))(__p1__)))])\n" ), "{py}" ); - assert!(!py.contains("_wrapped = callback\n"), "{py}"); - assert!( - py.contains("_IObservableMap_String_Object.method(6).invoke(self._observable_obj"), - "{py}" - ); assert!( py.contains("_IObservableMap_String_Object.method(7).invoke(self._observable_obj"), "{py}" @@ -258,19 +251,8 @@ fn observable_map_projects_python_mutable_mapping_and_typed_events() { pyi.contains(" def __delitem__(self, key: str) -> None: ..."), "{pyi}" ); - assert!( - pyi.contains(&format!( - "def on_map_changed(self, callback: {MAP_CALLBACK}) -> 'DynWinRTValue': ..." - )), - "{pyi}" - ); - for helper in ["subscribe", "once"] { - assert!( - pyi.contains(&format!( - "def {helper}_map_changed(self, callback: {MAP_CALLBACK}) -> Callable[[], None]: ..." - )), - "{pyi}" - ); + for signature in common::event_stub_signatures("map_changed", MAP_CALLBACK) { + assert!(pyi.contains(&signature), "{signature}\n{pyi}"); } assert_eq!( pyi.matches("import IMapChangedEventArgs_String # noqa: F401") @@ -318,23 +300,18 @@ fn runtime_class_map_changed_events_project_observable_sender_and_arguments() { &map_delegates(), &HashSet::new(), ); - for helper in ["on", "subscribe", "once"] { - assert!( - py.contains(&format!( - "def {helper}_map_changed(self, callback: {MAP_CALLBACK}):" - )), - "{py}" - ); + for signature in common::event_signatures("map_changed", MAP_CALLBACK) { + assert!(py.contains(&signature), "{signature}\n{py}"); } assert!( py.contains( - "_dynwinrt_symbol('i_observable_map_string_object', 'IObservableMap_String_Object')(value))(__sender__)" + "_dynwinrt_symbol('i_observable_map_string_object', 'IObservableMap_String_Object')(value))(__p0__)" ), "{py}" ); assert!( py.contains( - "_dynwinrt_symbol('i_map_changed_event_args_string', 'IMapChangedEventArgs_String')(value))(__event__)" + "_dynwinrt_symbol('i_map_changed_event_args_string', 'IMapChangedEventArgs_String')(value))(__p1__)" ), "{py}" ); @@ -378,20 +355,13 @@ fn runtime_class_map_changed_events_project_observable_sender_and_arguments() { !pyi.contains("\nclass IObservableMap_String_Object"), "{pyi}" ); - assert!( - pyi.contains(&format!( - "def on_map_changed(self, callback: {MAP_CALLBACK}) -> 'DynWinRTValue': ..." - )), - "{pyi}" - ); - assert_eq!( - pyi.matches(&format!( - "def subscribe_map_changed(self, callback: {MAP_CALLBACK}) -> Callable[[], None]: ..." - )) - .count(), - 2, - "the Like protocol and the class both expose the typed helper:\n{pyi}" - ); + for signature in common::event_stub_signatures("map_changed", MAP_CALLBACK) { + assert_eq!( + pyi.matches(&signature).count(), + 2, + "the Like protocol and the class both expose the typed helper {signature}:\n{pyi}" + ); + } assert!(!pyi.contains("Callable[..., object]"), "{pyi}"); } @@ -420,23 +390,18 @@ fn runtime_class_vector_changed_events_import_observable_sender_and_arguments() let callback = "Callable[['IObservableVector_String', 'IVectorChangedEventArgs'], object]"; let py = common::generate_class(&class, &known_types, &delegates, &HashSet::new()); - for helper in ["on", "subscribe", "once"] { - assert!( - py.contains(&format!( - "def {helper}_vector_changed(self, callback: {callback}):" - )), - "{py}" - ); + for signature in common::event_signatures("vector_changed", callback) { + assert!(py.contains(&signature), "{signature}\n{py}"); } assert!( py.contains( - "_dynwinrt_symbol('i_observable_vector_string', 'IObservableVector_String')(value))(__sender__)" + "_dynwinrt_symbol('i_observable_vector_string', 'IObservableVector_String')(value))(__p0__)" ), "{py}" ); assert!( py.contains( - "_dynwinrt_symbol('windows__foundation__collections__i_vector_changed_event_args', 'IVectorChangedEventArgs')(value))(__event__)" + "_dynwinrt_symbol('windows__foundation__collections__i_vector_changed_event_args', 'IVectorChangedEventArgs')(value))(__p1__)" ), "{py}" ); @@ -455,12 +420,9 @@ fn runtime_class_vector_changed_events_import_observable_sender_and_arguments() ] { assert_eq!(pyi.matches(imported).count(), 1, "{pyi}"); } - assert!( - pyi.contains(&format!( - "def once_vector_changed(self, callback: {callback}) -> Callable[[], None]: ..." - )), - "{pyi}" - ); + for signature in common::event_stub_signatures("vector_changed", callback) { + assert!(pyi.contains(&signature), "{signature}\n{pyi}"); + } assert!(!pyi.contains("\nclass IObservableVector_String"), "{pyi}"); assert!(!pyi.contains("Callable[..., object]"), "{pyi}"); } @@ -531,24 +493,18 @@ fn windows_observable_maps_type_and_project_map_changed_handlers() { value.to_lowercase() ); let sender = format!( - "(lambda value: None if value.is_null() else _dynwinrt_symbol('{observable_module}', 'IObservableMap_String_{value}')(value))(__sender__)" + "(lambda value: None if value.is_null() else _dynwinrt_symbol('{observable_module}', 'IObservableMap_String_{value}')(value))(__p0__)" ); - let args = "(lambda value: None if value.is_null() else _dynwinrt_symbol('windows__foundation__collections__i_map_changed_event_args_string', 'IMapChangedEventArgs_String')(value))(__event__)"; + let args = "(lambda value: None if value.is_null() else _dynwinrt_symbol('windows__foundation__collections__i_map_changed_event_args_string', 'IMapChangedEventArgs_String')(value))(__p1__)"; + let projection = format!("lambda __p0__, __p1__: ({sender}, {args})"); let class_py = output.read(&format!("windows__foundation__collections__{class}.py")); let interface_py = output.read(&format!("{observable_module}.py")); for py in [&class_py, &interface_py] { - for helper in ["on", "subscribe", "once"] { - assert!( - py.contains(&format!( - "def {helper}_map_changed(self, callback: {callback}):" - )), - "{py}" - ); + for signature in common::event_signatures("map_changed", &callback) { + assert!(py.contains(&signature), "{signature}\n{py}"); } - assert!(py.contains(&sender), "{py}"); - assert!(py.contains(args), "{py}"); - assert!(!py.contains("_wrapped = callback\n"), "{py}"); + assert!(py.contains(&projection), "{py}"); } assert!( interface_py.contains(&format!( @@ -567,19 +523,8 @@ fn windows_observable_maps_type_and_project_map_changed_handlers() { let class_pyi = output.read(&format!("windows__foundation__collections__{class}.pyi")); let interface_pyi = output.read(&format!("{observable_module}.pyi")); for pyi in [&class_pyi, &interface_pyi] { - assert!( - pyi.contains(&format!( - "def on_map_changed(self, callback: {callback}) -> 'DynWinRTValue': ..." - )), - "{pyi}" - ); - for helper in ["subscribe", "once"] { - assert!( - pyi.contains(&format!( - "def {helper}_map_changed(self, callback: {callback}) -> Callable[[], None]: ..." - )), - "{pyi}" - ); + for signature in common::event_stub_signatures("map_changed", &callback) { + assert!(pyi.contains(&signature), "{signature}\n{pyi}"); } assert!(!pyi.contains("Callable[..., object]"), "{pyi}"); } diff --git a/tools/dynwinrt-codegen/tests/observable_vector_test.rs b/tools/dynwinrt-codegen/tests/observable_vector_test.rs index 23a78068..63d436df 100644 --- a/tools/dynwinrt-codegen/tests/observable_vector_test.rs +++ b/tools/dynwinrt-codegen/tests/observable_vector_test.rs @@ -130,23 +130,20 @@ fn observable_vector_projects_python_mutable_sequence_and_typed_events() { "{py}" ); assert!(py.contains("def as_vector(self) -> 'IVector_Object':")); - assert!( - py.contains(&format!( - "def on_vector_changed(self, callback: {callback}):" - )), - "{py}" - ); + for signature in common::event_signatures("vector_changed", callback) { + assert!(py.contains(&signature), "{signature}\n{py}"); + } assert!( py.contains( - "(lambda callback=callback: (lambda __sender__, __event__: callback(\ + "lambda __p0__, __p1__: (\ (lambda value: None if value.is_null() else \ - _dynwinrt_symbol('i_observable_vector_object', 'IObservableVector_Object')(value))(__sender__), \ + _dynwinrt_symbol('i_observable_vector_object', 'IObservableVector_Object')(value))(__p0__), \ (lambda value: None if value.is_null() else \ - _dynwinrt_symbol('windows__foundation__collections__i_vector_changed_event_args', 'IVectorChangedEventArgs')(value))(__event__))))()" + _dynwinrt_symbol('windows__foundation__collections__i_vector_changed_event_args', 'IVectorChangedEventArgs')(value))(__p1__))" ), "{py}" ); - assert!(py.contains("_dynwinrt_create_delegate(")); + assert!(py.contains("_dynwinrt_delegate(callback,")); assert!(py.contains("_IObservableVector_Object.method(6).invoke(self._observable_obj")); let pyi = common::generate_interface_stub(&interface, &known_types, &delegate_types); @@ -165,12 +162,9 @@ fn observable_vector_projects_python_mutable_sequence_and_typed_events() { "{pyi}" ); assert!(pyi.contains("def as_vector(self) -> 'IVector_Object': ...")); - assert!( - pyi.contains(&format!( - "def on_vector_changed(self, callback: {callback}) -> 'DynWinRTValue': ..." - )), - "{pyi}" - ); + for signature in common::event_stub_signatures("vector_changed", callback) { + assert!(pyi.contains(&signature), "{signature}\n{pyi}"); + } } #[test] From 783a3fe1ac23eb366089b0f8a1d4dd7458317f6e Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 17:29:43 +0800 Subject: [PATCH 06/11] Collect callback projection helpers for every Python module Include delegate Invoke parameters when collecting structs and other callback annotation dependencies for runtime-class modules, including static and factory interfaces. Add missing runtime-class IID constants for caller-filled arrays. The synthetic struct-event regression generates a class module and executes its extracted adapter, and callback integration checks cover events, static events, callback parameters, setters, async ownership, and colliding metadata names. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../codegen/winrt/python/generator/class.rs | 8 +- .../codegen/winrt/python/generator/types.rs | 6 +- .../src/codegen/winrt/python/mod.rs | 8 +- .../src/codegen/winrt/python/stubs.rs | 4 +- .../src/codegen/winrt/shared/imports.rs | 7 +- .../src/codegen/winrt/shared/structs.rs | 22 ++ .../tests/python_delegate_callback_test.rs | 220 ++++++++++++++++-- 7 files changed, 239 insertions(+), 36 deletions(-) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs index ea321ed2..bd2cbea5 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -33,7 +33,7 @@ pub fn generate_class( class: &ClassMeta, shared_iids: &HashSet, ) -> String { - let used_structs = collect_used_structs_from_class(class); + let used_structs = collect_used_structs_from_class_and_callbacks(class); let context = context.for_class_module(class, &used_structs); let context = context.as_ref(); let collection_iface = class_interface(class); @@ -232,10 +232,8 @@ pub fn generate_class( let mut argument_iids = Vec::new(); for iface in &all_class_ifaces { for method in &iface.methods { - for parameter in &method.params { - if parameter.direction == ParamDirection::In { - py_collect_runtime_class_iid_consts(¶meter.typ, &mut argument_iids); - } + for parameter in get_in_params(method) { + py_collect_runtime_class_iid_consts(¶meter.typ, &mut argument_iids); } } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs index f8f784bd..a9ca856a 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs @@ -216,10 +216,8 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe } let mut argument_iids = Vec::new(); for method in &iface.methods { - for parameter in &method.params { - if parameter.direction == ParamDirection::In { - py_collect_runtime_class_iid_consts(¶meter.typ, &mut argument_iids); - } + for parameter in get_in_params(method) { + py_collect_runtime_class_iid_consts(¶meter.typ, &mut argument_iids); } } argument_iids.sort(); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs index b4efd8b0..47ede943 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs @@ -164,7 +164,7 @@ pub fn package_structs( interfaces: &[crate::meta::InterfaceMeta], ) -> Vec { use crate::codegen::winrt::shared::structs::{ - collect_used_structs_from_class, collect_used_structs_from_iface, + collect_used_structs_from_class_and_callbacks, collect_used_structs_from_iface, }; use crate::types::TypeMeta; use std::collections::BTreeMap; @@ -172,7 +172,7 @@ pub fn package_structs( let mut structs = BTreeMap::new(); for typ in classes .iter() - .flat_map(collect_used_structs_from_class) + .flat_map(collect_used_structs_from_class_and_callbacks) .chain(interfaces.iter().flat_map(collect_used_structs_from_iface)) { if let TypeMeta::Struct { @@ -207,7 +207,7 @@ pub fn validate_struct_symbol_uniqueness( interfaces: &[crate::meta::InterfaceMeta], ) -> Result<(), String> { use crate::codegen::winrt::shared::structs::{ - collect_used_structs_from_class, collect_used_structs_from_iface, + collect_used_structs_from_class_and_callbacks, collect_used_structs_from_iface, collect_used_structs_from_struct, }; use crate::types::TypeMeta; @@ -238,7 +238,7 @@ pub fn validate_struct_symbol_uniqueness( } for class in classes { - validate(&class.full_name, collect_used_structs_from_class(class))?; + validate(&class.full_name, collect_used_structs_from_class_and_callbacks(class))?; } for interface in interfaces { validate( diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs index 11cfcfbe..6edff862 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs @@ -20,7 +20,7 @@ use crate::codegen::winrt::shared::imports::{ collect_used_generic_identities_from_type, }; use crate::codegen::winrt::shared::structs::{ - collect_used_structs_from_class, collect_used_structs_from_iface, + collect_used_structs_from_class_and_callbacks, collect_used_structs_from_iface, collect_used_structs_from_struct, }; @@ -612,7 +612,7 @@ pub fn generate_class_stub( class: &ClassMeta, shared_iids: &HashSet, ) -> String { - let used_structs = collect_used_structs_from_class(class); + let used_structs = collect_used_structs_from_class_and_callbacks(class); let context = context.for_class_module(class, &used_structs); let context = context.as_ref(); let collection_iface = class_interface(class); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/shared/imports.rs b/tools/dynwinrt-codegen/src/codegen/winrt/shared/imports.rs index 642aa37d..ba8cf14a 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/shared/imports.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/shared/imports.rs @@ -105,8 +105,11 @@ pub(crate) fn collect_used_generic_identities_from_class(class: &ClassMeta) -> V } /// `Invoke` signatures of the delegates an interface accepts as inputs. Python -/// callback annotations name their parameter types. -fn input_delegate_invokes(interface: &InterfaceMeta) -> impl Iterator { +/// callback annotations name their parameter types, and callback adapters +/// project those parameters. +pub(crate) fn input_delegate_invokes( + interface: &InterfaceMeta, +) -> impl Iterator { interface .implementation_metadata .delegates diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/shared/structs.rs b/tools/dynwinrt-codegen/src/codegen/winrt/shared/structs.rs index 8ecaf02a..dc50a802 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/shared/structs.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/shared/structs.rs @@ -5,6 +5,7 @@ use std::collections::HashSet; +use crate::codegen::winrt::shared::imports::input_delegate_invokes; use crate::meta::{ClassMeta, InterfaceMeta}; use crate::types::TypeMeta; @@ -88,6 +89,27 @@ pub(crate) fn collect_used_structs_from_class(class: &ClassMeta) -> Vec Vec { + let mut result = collect_used_structs_from_class(class); + let mut seen = result + .iter() + .filter_map(|typ| match typ { + TypeMeta::Struct { + namespace, name, .. + } => Some(format!("{namespace}.{name}")), + _ => None, + }) + .collect::>(); + for invoke in class.all_interfaces().flat_map(input_delegate_invokes) { + for p in &invoke.params { + collect_used_structs_from_type(&p.typ, &mut seen, &mut result); + } + } + result +} + pub(crate) fn collect_used_structs_from_iface(iface: &InterfaceMeta) -> Vec { let mut seen = HashSet::new(); let mut result = Vec::new(); diff --git a/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs b/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs index 3fa33eef..6698a49b 100644 --- a/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs +++ b/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs @@ -4,11 +4,19 @@ //! Python delegate callbacks derive their annotation and argument projection //! from the delegate `Invoke` signature, wherever a callable becomes a delegate. +mod common; + +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; +use dynwinrt_codegen::meta::{ + ClassMeta, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta, +}; +use dynwinrt_codegen::types::{FieldMeta, TypeMeta}; + const WINDOWS_WINMD: &str = r"C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.26100.0\Windows.winmd"; @@ -81,17 +89,19 @@ fn bespoke_event_delegates_are_typed_and_projected() { "Callable[['BackgroundTaskRegistration', 'BackgroundTaskCompletedEventArgs'], object]"; let py = output.read(module, "py"); assert!( - py.contains(&format!("def on_completed(self, callback: {callback}):")), + py.contains(&format!( + "def on_completed(self, callback: {callback} | 'DynWinRTValue | DynWinRtDelegate'):" + )), "{py}" ); assert!( py.contains(&format!( - "_wrapped = (lambda callback=callback: (lambda __sender__, __args__: callback({}, {})))()", - class_wrapper(module, "BackgroundTaskRegistration", "__sender__"), + "'BackgroundTaskCompletedEventHandler_PARAM_TYPES'), lambda __p0__, __p1__: ({}, {}))", + class_wrapper(module, "BackgroundTaskRegistration", "__p0__"), class_wrapper( "windows__application_model__background__background_task_completed_event_args", "BackgroundTaskCompletedEventArgs", - "__args__" + "__p1__" ) )), "{py}" @@ -99,7 +109,13 @@ fn bespoke_event_delegates_are_typed_and_projected() { let pyi = output.read(module, "pyi"); assert!( pyi.contains(&format!( - "def subscribe_completed(self, callback: {callback}) -> Callable[[], None]: ..." + "def subscribe_completed(self, callback: {callback} | 'DynWinRTValue | DynWinRtDelegate') -> Callable[[], None]: ..." + )), + "{pyi}" + ); + assert!( + pyi.contains(&format!( + "def once_completed(self, callback: {callback}) -> Callable[[], None]: ..." )), "{pyi}" ); @@ -118,15 +134,15 @@ fn static_events_callback_parameters_and_setters_project_callables() { let gamepad = output.read("windows__gaming__input__gamepad", "py"); assert!( gamepad.contains( - "def add_gamepad_added(value: Callable[[DynWinRTValue | None, 'Gamepad'], object] | 'DynWinRTValue')" + "def add_gamepad_added(value: Callable[[DynWinRTValue | None, 'Gamepad'], object] | 'DynWinRTValue | DynWinRtDelegate')" ), "{gamepad}" ); assert!( gamepad.contains(&format!( - "'EventHandler_Gamepad_PARAM_TYPES'), lambda callback: (lambda __sender__, __args__: \ - callback((lambda value: None if value.is_null() else value)(__sender__), {})))", - class_wrapper("windows__gaming__input__gamepad", "Gamepad", "__args__") + "'EventHandler_Gamepad_PARAM_TYPES'), lambda __p0__, __p1__: (\ + (lambda value: None if value.is_null() else value)(__p0__), {}))", + class_wrapper("windows__gaming__input__gamepad", "Gamepad", "__p1__") )), "{gamepad}" ); @@ -134,19 +150,23 @@ fn static_events_callback_parameters_and_setters_project_callables() { let thread_pool = output.read("windows__system__threading__thread_pool", "py"); assert!( thread_pool.contains( - "def run_async(handler: Callable[[WinRTCoroutine[None]], object] | 'DynWinRTValue')" + "def run_async(handler: Callable[[DynWinRTValue], object] | 'DynWinRTValue | DynWinRtDelegate')" ), "{thread_pool}" ); assert!( thread_pool.contains( - "'WorkItemHandler_PARAM_TYPES'), lambda callback: (lambda __operation__: \ - callback(_dynwinrt_track_projected(_DynWinRTAsync(__operation__, lambda _value: None), 'WinRTAsync'))))" + "'WorkItemHandler_PARAM_TYPES'))" ), "{thread_pool}" ); + assert!( + !thread_pool.contains("'WorkItemHandler_PARAM_TYPES'), lambda "), + "{thread_pool}" + ); - let timer_callback = "Callable[['ThreadPoolTimer'], object] | 'DynWinRTValue'"; + let timer_callback = + "Callable[['ThreadPoolTimer'], object] | 'DynWinRTValue | DynWinRtDelegate'"; let timer = output.read("windows__system__threading__thread_pool_timer", "py"); assert!( timer.contains(&format!( @@ -156,11 +176,11 @@ fn static_events_callback_parameters_and_setters_project_callables() { ); assert!( timer.contains(&format!( - "'TimerElapsedHandler_PARAM_TYPES'), lambda callback: (lambda __timer__: callback({})))", + "'TimerElapsedHandler_PARAM_TYPES'), lambda __p0__: ({},))", class_wrapper( "windows__system__threading__thread_pool_timer", "ThreadPoolTimer", - "__timer__" + "__p0__" ) )), "{timer}" @@ -173,7 +193,8 @@ fn static_events_callback_parameters_and_setters_project_callables() { "{timer_stub}" ); - let command_callback = "Callable[['IUICommand'], object] | 'DynWinRTValue'"; + let command_callback = + "Callable[['IUICommand'], object] | 'DynWinRTValue | DynWinRtDelegate'"; let command = output.read("windows__ui__popups__ui_command", "py"); assert!( command.contains(&format!("def invoked(self, value: {command_callback}):")), @@ -181,9 +202,9 @@ fn static_events_callback_parameters_and_setters_project_callables() { ); assert!( command.contains( - "'UICommandInvokedHandler_PARAM_TYPES'), lambda callback: (lambda __command__: \ - callback((lambda value: None if value.is_null() else \ - _dynwinrt_symbol('windows__ui__popups__iui_command', 'IUICommand')(value))(__command__))))" + "'UICommandInvokedHandler_PARAM_TYPES'), lambda __p0__: (\ + (lambda value: None if value.is_null() else \ + _dynwinrt_symbol('windows__ui__popups__iui_command', 'IUICommand')(value))(__p0__),))" ), "{command}" ); @@ -195,3 +216,164 @@ fn static_events_callback_parameters_and_setters_project_callables() { "{command_stub}" ); } + +#[test] +fn class_event_struct_adapter_has_helpers_and_executes() { + let payload = TypeMeta::Struct { + namespace: "Contoso".into(), + name: "Payload".into(), + fields: vec![FieldMeta { + name: "Value".into(), + typ: TypeMeta::I32, + }], + }; + let handler = TypeMeta::Interface { + namespace: "Contoso".into(), + name: "ChangedHandler".into(), + iid: "11111111-1111-1111-1111-111111111111".into(), + }; + let mut events = InterfaceMeta { + namespace: "Contoso".into(), + name: "IWidgetEvents".into(), + iid: "22222222-2222-2222-2222-222222222222".into(), + methods: vec![ + MethodMeta { + name: "add_Changed".into(), + raw_name: "add_Changed".into(), + vtable_index: 6, + params: vec![ParamMeta { + name: "handler".into(), + typ: handler.clone(), + direction: ParamDirection::In, + }], + is_event_add: true, + ..Default::default() + }, + MethodMeta { + name: "remove_Changed".into(), + raw_name: "remove_Changed".into(), + vtable_index: 7, + params: vec![ParamMeta { + name: "token".into(), + typ: TypeMeta::I64, + direction: ParamDirection::In, + }], + is_event_remove: true, + ..Default::default() + }, + ], + ..Default::default() + }; + events + .implementation_metadata + .delegates + .push(common::delegate_invoke( + handler, + &[("payload", payload.clone())], + )); + let class = ClassMeta { + namespace: "Contoso".into(), + name: "Widget".into(), + full_name: "Contoso.Widget".into(), + default_interface: Some(InterfaceMeta { + namespace: "Contoso".into(), + name: "IWidget".into(), + iid: "33333333-3333-3333-3333-333333333333".into(), + ..Default::default() + }), + required_interfaces: vec![events], + is_referenced_as_value: true, + ..Default::default() + }; + let source = common::generate_class( + &class, + &HashSet::from(["Payload".into()]), + &HashSet::from(["ChangedHandler".into()]), + &HashSet::new(), + ); + assert!(source.contains("\nclass Payload:\n"), "{source}"); + assert!( + source.contains("\ndef unpack_payload(v: DynWinRTValue) -> Payload:\n"), + "{source}" + ); + assert!(source.contains("_unpack_payload = unpack_payload\n"), "{source}"); + assert!( + source.contains("lambda __p0__: (_unpack_payload(__p0__),)"), + "{source}" + ); + + let directory = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!( + "delegate-struct-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&directory).unwrap(); + let generated = directory.join("widget.py"); + fs::write(&generated, source).unwrap(); + let script = r#" +import ast +import pathlib +import sys + +source = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") +tree = ast.parse(source) +payload = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Payload") +unpack = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "unpack_payload") +alias = next( + node for node in tree.body + if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "_unpack_payload" for target in node.targets) +) +event = next( + node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "on_changed" +) +delegate = next( + node for node in ast.walk(event) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "_dynwinrt_delegate" +) +projection = delegate.args[3] + +class FakeStruct: + def get_i32(self, index): + assert index == 0 + return 42 + +class FakeValue: + def as_struct(self): + return FakeStruct() + +namespace = {"DynWinRTValue": object} +helpers = ast.Module(body=[payload, unpack, alias], type_ignores=[]) +ast.fix_missing_locations(helpers) +exec(compile(helpers, sys.argv[1], "exec"), namespace) +ast.fix_missing_locations(projection) +project = eval(compile(ast.Expression(projection), sys.argv[1], "eval"), namespace) +result = project(FakeValue()) +assert len(result) == 1 +assert isinstance(result[0], namespace["Payload"]) +assert result[0].value == 42 +print("delegate-struct-adapter-ok") +"#; + let output = Command::new("python") + .args(["-c", script]) + .arg(&generated) + .output() + .unwrap(); + let _ = fs::remove_dir_all(&directory); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("delegate-struct-adapter-ok"), + "{}", + String::from_utf8_lossy(&output.stdout) + ); +} From ced34b8ea38ca2fa4d2190375bff9e6686d8e26c Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 17:55:35 +0800 Subject: [PATCH 07/11] Validate native delegate compatibility and generated names Exercise native delegate objects and values through on_, subscribe_, static events, and ThreadPool callbacks, and verify once_ rejects native delegates clearly. Document optimistic callback nullability and the raw async-operation exception. Add a generated Python analyzer that checks compilation, global name resolution in every scope, relative imports, lazy symbols, facades, and stubs. Run it in Python E2E; corpus validation covers all 328 Windows namespaces. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 13 +- tests/e2e/check_generated_python.py | 504 ++++++++++++++++++ tests/e2e/e2e_preparation.tests.ps1 | 1 + tests/e2e/e2e_specs.json | 17 +- tests/e2e/e2e_specs.schema.json | 3 +- tests/e2e/e2e_test.ps1 | 4 + tests/e2e/prebuilt_codegen.tests.ps1 | 8 +- tests/e2e/runners/py_runner.py | 127 ++++- tests/e2e/typecheck/python_generated_api.py | 18 +- tools/dynwinrt-codegen/TYPE_COVERAGE.md | 3 +- .../tests/python_consumer_typing_test.rs | 31 +- 11 files changed, 710 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/check_generated_python.py diff --git a/bindings/py/README.md b/bindings/py/README.md index 075b050d..ee5a50c1 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -106,13 +106,22 @@ parameter such as `ThreadPool.run_async(handler)`, or a delegate-typed property) receives the delegate's arguments as projected Python values, typed from the delegate's `Invoke` signature. WinRT `Object` arguments stay `DynWinRTValue | None`, and `IReference` arguments are native values or -`None`. For example, `map_changed` handlers of `PropertySet`, `StringMap`, +`None`. Async-operation arguments stay raw `DynWinRTValue` objects so a +callback projection cannot take over or cancel the operation's completion. +For example, `map_changed` handlers of `PropertySet`, `StringMap`, `ValueSet`, and other `IObservableMap` implementations receive the `IObservableMap` projection, which is a mutable mapping, and an `IMapChangedEventArgs` with `collection_change` and `key`. An existing native delegate, such as one built with `DynWinRtDelegate.create`, is passed through unchanged, and its callback keeps receiving raw `DynWinRTValue` -arguments. +arguments. `on_*` and `subscribe_*` accept those native delegates. +`once_*` requires a Python callable because it must wrap the callback to remove +the subscription after the first invocation. + +Callback parameter annotations are non-null by default, matching generated +method-output typing. This is an intentionally optimistic typing policy, not a +guarantee from the `Invoke` metadata: WinMD carries no nullability information, +and the runtime still passes `None` when WinRT supplies a null reference. ```python def changed(sender: IObservableMap_String_Object, args: IMapChangedEventArgs_String) -> None: diff --git a/tests/e2e/check_generated_python.py b/tests/e2e/check_generated_python.py new file mode 100644 index 00000000..d0610614 --- /dev/null +++ b/tests/e2e/check_generated_python.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Check generated Python packages for names that cannot resolve. + +Every generated ``.py`` module must compile, and every global name it reads at +runtime, in any scope including lambdas, must be defined or imported at module +level outside ``if TYPE_CHECKING:`` blocks, or be a builtin. Lazy references +such as ``_dynwinrt_symbol('module', 'Name')`` and relative imports must name a +generated module that defines the name. + +Every ``.pyi`` stub must compile, and every name in its annotations (including +quoted forward references) and expressions must be defined or imported. + +Usage: python check_generated_python.py PACKAGE_DIR [PACKAGE_DIR ...] +""" + +import argparse +import ast +import builtins +import os +import sys + +BUILTINS = frozenset(dir(builtins)) | { + "__builtins__", + "__class__", + "__file__", + "__module__", + "__path__", + "__qualname__", +} +LAZY_SYMBOL_HELPERS = frozenset({"_dynwinrt_symbol", "_dynwinrt_enum", "_dynwinrt_wrap_values"}) +SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda) +COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp) + + +def long_path(path): + path = os.path.abspath(path) + if os.name == "nt" and not path.startswith("\\\\?\\"): + return "\\\\?\\" + path + return path + + +def is_type_checking(test): + return (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or ( + isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING" + ) + + +def target_names(target, names): + if isinstance(target, ast.Name): + names.add(target.id) + elif isinstance(target, (ast.Tuple, ast.List)): + for element in target.elts: + target_names(element, names) + elif isinstance(target, ast.Starred): + target_names(target.value, names) + + +def expression_bindings(node, names): + """Walrus targets bind in the enclosing function, even inside comprehensions.""" + for child in ast.walk(node): + if isinstance(child, ast.NamedExpr): + target_names(child.target, names) + + +def statement_bindings(statements, names, type_only=None, module=False): + """Collect names bound by statements, without entering nested scopes. + + At module level, names bound under ``if TYPE_CHECKING:`` go to ``type_only``. + """ + for node in statements: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + elif isinstance(node, ast.Import): + for alias in node.names: + names.add(alias.asname or alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom): + for alias in node.names: + if alias.name != "*": + names.add(alias.asname or alias.name) + elif isinstance(node, ast.Assign): + for target in node.targets: + target_names(target, names) + expression_bindings(node.value, names) + elif isinstance(node, (ast.AugAssign, ast.AnnAssign)): + target_names(node.target, names) + elif isinstance(node, ast.Delete): + for target in node.targets: + target_names(target, names) + elif isinstance(node, (ast.For, ast.AsyncFor)): + target_names(node.target, names) + statement_bindings(node.body + node.orelse, names, type_only, module) + elif isinstance(node, (ast.With, ast.AsyncWith)): + for item in node.items: + if item.optional_vars is not None: + target_names(item.optional_vars, names) + statement_bindings(node.body, names, type_only, module) + elif isinstance(node, ast.If): + if module and type_only is not None and is_type_checking(node.test): + statement_bindings(node.body, type_only, None, module) + statement_bindings(node.orelse, names, type_only, module) + else: + statement_bindings(node.body + node.orelse, names, type_only, module) + elif isinstance(node, ast.While): + statement_bindings(node.body + node.orelse, names, type_only, module) + elif isinstance(node, (ast.Try, getattr(ast, "TryStar", ast.Try))): + statement_bindings(node.body, names, type_only, module) + for handler in node.handlers: + if handler.name: + names.add(handler.name) + statement_bindings(handler.body, names, type_only, module) + statement_bindings(node.orelse + node.finalbody, names, type_only, module) + elif isinstance(node, (ast.Expr, ast.Return)) and node.value is not None: + expression_bindings(node.value, names) + + +def declared(statements, kind): + names = set() + for node in statements: + for child in ast.walk(node): + if isinstance(child, kind): + names.update(child.names) + return names + + +def function_arguments(arguments): + names = {argument.arg for argument in arguments.posonlyargs + arguments.args + arguments.kwonlyargs} + for extra in (arguments.vararg, arguments.kwarg): + if extra is not None: + names.add(extra.arg) + return names + + +class Scope: + def __init__(self, kind, bound, globals_=(), nonlocals=()): + self.kind = kind + self.bound = set(bound) - set(globals_) + self.globals = set(globals_) + self.nonlocals = set(nonlocals) + + +class Module: + """Parsed facts about one generated module.""" + + def __init__(self, path, source): + self.path = path + self.tree = ast.parse(source, filename=path) + self.runtime_names = set() + self.type_only_names = set() + statement_bindings(self.tree.body, self.runtime_names, self.type_only_names, module=True) + self.star_import = any( + isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names) + for node in ast.walk(self.tree) + ) + self.future_annotations = any( + isinstance(node, ast.ImportFrom) + and node.module == "__future__" + and any(alias.name == "annotations" for alias in node.names) + for node in self.tree.body + ) + + @property + def all_names(self): + return self.runtime_names | self.type_only_names + + +class NameChecker: + """Resolve name reads through Python's scoping rules.""" + + def __init__(self, module, stub, report): + self.module = module + self.stub = stub + self.report = report + self.module_names = module.all_names if stub else module.runtime_names + + def check(self): + if self.module.star_import: + return + self.body(self.module.tree.body, [], module_level=True) + + def resolve(self, name, scopes, lineno): + for index, scope in enumerate(reversed(scopes)): + innermost = index == 0 + if name in scope.globals: + break + if scope.kind == "class" and not innermost: + continue + if name in scope.bound or name in scope.nonlocals: + return + if name in self.module_names or name in BUILTINS: + return + if not self.stub and name in self.module.type_only_names: + self.report(self.module.path, lineno, f"'{name}' is imported only under TYPE_CHECKING") + else: + self.report(self.module.path, lineno, f"undefined name '{name}'") + + def body(self, statements, scopes, module_level=False): + for node in statements: + if module_level and not self.stub and isinstance(node, ast.If) and is_type_checking(node.test): + # Not executed at runtime; the imported names serve type checkers. + self.body(node.orelse, scopes, module_level) + continue + self.statement(node, scopes) + + def annotation(self, node, scopes): + if node is None: + return + if self.stub: + self.type_expression(node, scopes) + elif not self.module.future_annotations: + self.expression(node, scopes) + + def type_expression(self, node, scopes): + """Check a stub annotation, including quoted forward references.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + try: + parsed = ast.parse(node.value, mode="eval").body + except SyntaxError: + self.report(self.module.path, node.lineno, f"invalid forward reference {node.value!r}") + return + ast.increment_lineno(parsed, node.lineno - 1) + self.type_expression(parsed, scopes) + elif isinstance(node, ast.Subscript) and ( + (isinstance(node.value, ast.Name) and node.value.id == "Literal") + or (isinstance(node.value, ast.Attribute) and node.value.attr == "Literal") + ): + self.expression(node.value, scopes) + elif isinstance(node, ast.Name): + self.resolve(node.id, scopes, node.lineno) + else: + for child in ast.iter_child_nodes(node): + if isinstance(child, ast.expr): + self.type_expression(child, scopes) + + def arguments(self, arguments, scopes): + for default in arguments.defaults + [value for value in arguments.kw_defaults if value is not None]: + self.expression(default, scopes) + for argument in arguments.posonlyargs + arguments.args + arguments.kwonlyargs: + self.annotation(argument.annotation, scopes) + for extra in (arguments.vararg, arguments.kwarg): + if extra is not None: + self.annotation(extra.annotation, scopes) + + def statement(self, node, scopes): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + for decorator in node.decorator_list: + self.expression(decorator, scopes) + self.arguments(node.args, scopes) + self.annotation(node.returns, scopes) + bound = function_arguments(node.args) + statement_bindings(node.body, bound) + scope = Scope("function", bound, declared(node.body, ast.Global), declared(node.body, ast.Nonlocal)) + self.body(node.body, scopes + [scope]) + elif isinstance(node, ast.ClassDef): + for expression in node.decorator_list + node.bases + [keyword.value for keyword in node.keywords]: + (self.type_expression if self.stub else self.expression)(expression, scopes) + bound = set() + statement_bindings(node.body, bound) + self.body(node.body, scopes + [Scope("class", bound, declared(node.body, ast.Global))]) + elif isinstance(node, ast.AnnAssign): + self.annotation(node.annotation, scopes) + if node.value is not None: + self.expression(node.value, scopes) + self.target(node.target, scopes) + elif isinstance(node, ast.AugAssign): + if isinstance(node.target, ast.Name): + self.resolve(node.target.id, scopes, node.lineno) + else: + self.target(node.target, scopes) + self.expression(node.value, scopes) + elif isinstance(node, ast.Assign): + self.expression(node.value, scopes) + for target in node.targets: + self.target(target, scopes) + elif isinstance(node, ast.Delete): + for target in node.targets: + self.target(target, scopes) + elif isinstance(node, (ast.For, ast.AsyncFor)): + self.expression(node.iter, scopes) + self.target(node.target, scopes) + self.body(node.body + node.orelse, scopes) + elif isinstance(node, (ast.With, ast.AsyncWith)): + for item in node.items: + self.expression(item.context_expr, scopes) + if item.optional_vars is not None: + self.target(item.optional_vars, scopes) + self.body(node.body, scopes) + elif isinstance(node, (ast.If, ast.While)): + self.expression(node.test, scopes) + self.body(node.body + node.orelse, scopes) + elif isinstance(node, (ast.Try, getattr(ast, "TryStar", ast.Try))): + self.body(node.body, scopes) + for handler in node.handlers: + if handler.type is not None: + self.expression(handler.type, scopes) + self.body(handler.body, scopes) + self.body(node.orelse + node.finalbody, scopes) + elif isinstance(node, (ast.Import, ast.ImportFrom, ast.Global, ast.Nonlocal, ast.Pass, ast.Break, ast.Continue)): + return + else: + for child in ast.iter_child_nodes(node): + if isinstance(child, ast.expr): + self.expression(child, scopes) + elif isinstance(child, ast.stmt): + self.statement(child, scopes) + + def target(self, node, scopes): + """Visit the reads inside an assignment target.""" + if isinstance(node, (ast.Tuple, ast.List)): + for element in node.elts: + self.target(element, scopes) + elif isinstance(node, ast.Starred): + self.target(node.value, scopes) + elif isinstance(node, (ast.Attribute, ast.Subscript)): + self.expression(node, scopes) + + def expression(self, node, scopes): + if isinstance(node, ast.Name): + if isinstance(node.ctx, ast.Load): + self.resolve(node.id, scopes, node.lineno) + elif isinstance(node, ast.Lambda): + self.arguments(node.args, scopes) + bound = function_arguments(node.args) + expression_bindings(node.body, bound) + self.expression(node.body, scopes + [Scope("function", bound)]) + elif isinstance(node, COMPREHENSIONS): + generators = node.generators + self.expression(generators[0].iter, scopes) + bound = set() + for generator in generators: + target_names(generator.target, bound) + inner = scopes + [Scope("function", bound)] + for index, generator in enumerate(generators): + if index: + self.expression(generator.iter, inner) + for condition in generator.ifs: + self.expression(condition, inner) + if isinstance(node, ast.DictComp): + self.expression(node.key, inner) + self.expression(node.value, inner) + else: + self.expression(node.elt, inner) + else: + for child in ast.iter_child_nodes(node): + if isinstance(child, ast.expr): + self.expression(child, scopes) + elif isinstance(child, ast.keyword): + self.expression(child.value, scopes) + + +class PackageChecker: + def __init__(self, root): + self.root = long_path(root) + self.modules = {} + self.problems = [] + + def report(self, path, lineno, message): + self.problems.append(f"{os.path.relpath(path, self.root)}:{lineno}: {message}") + + def load(self, path): + if path not in self.modules: + try: + with open(path, encoding="utf-8") as handle: + source = handle.read() + compile(source, path, "exec", dont_inherit=True) + self.modules[path] = Module(path, source) + except SyntaxError as error: + self.report(path, error.lineno or 0, f"cannot compile: {error.msg}") + self.modules[path] = None + return self.modules[path] + + def module_file(self, directory, dotted, stub): + """The generated file for a module named relative to `directory`.""" + base = os.path.join(directory, *dotted.split(".")) if dotted else directory + suffixes = (".pyi", ".py") if stub else (".py",) + for candidate in [base + suffix for suffix in suffixes] + [ + os.path.join(base, "__init__" + suffix) for suffix in suffixes + ]: + if os.path.isfile(candidate): + return candidate + return None + + def check_import(self, path, node, stub): + directory = os.path.dirname(path) + for _ in range(node.level - 1): + directory = os.path.dirname(directory) + target = self.module_file(directory, node.module or "", stub) + if target is None: + self.report(path, node.lineno, f"relative import of missing module '{'.' * node.level}{node.module or ''}'") + return + module = self.load(target) + if module is None or module.star_import: + return + names = module.all_names if stub else module.runtime_names + for alias in node.names: + if alias.name == "*" or alias.name in names: + continue + if node.module is None and self.module_file(directory, alias.name, stub): + continue + self.report(path, node.lineno, f"'{alias.name}' is not defined by {os.path.relpath(target, self.root)}") + + def check_imports(self, path, module, stub): + type_checking_imports = set() + for node in module.tree.body: + if isinstance(node, ast.If) and is_type_checking(node.test): + type_checking_imports.update( + id(child) for child in ast.walk(node) if isinstance(child, ast.ImportFrom) + ) + for node in ast.walk(module.tree): + if isinstance(node, ast.ImportFrom) and node.level: + # TYPE_CHECKING imports serve type checkers, which read stubs first. + self.check_import(path, node, stub or id(node) in type_checking_imports) + + def check_exports(self, path, module): + """Facade `_EXPORTS` maps names to `('.module', 'symbol')` lazy imports.""" + for node in module.tree.body: + if not ( + isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "_EXPORTS" for target in node.targets) + and isinstance(node.value, ast.Dict) + ): + continue + for value in node.value.values: + if not ( + isinstance(value, ast.Tuple) + and len(value.elts) == 2 + and all(isinstance(item, ast.Constant) and isinstance(item.value, str) for item in value.elts) + ): + continue + relative, symbol = value.elts[0].value, value.elts[1].value + level = len(relative) - len(relative.lstrip(".")) + self.check_import( + path, + ast.ImportFrom( + module=relative[level:] or None, + names=[ast.alias(name=symbol)], + level=level, + lineno=value.lineno, + ), + stub=False, + ) + + def check_lazy_symbols(self, path, module): + for node in ast.walk(module.tree): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in LAZY_SYMBOL_HELPERS + and len(node.args) >= 2 + and all(isinstance(arg, ast.Constant) and isinstance(arg.value, str) for arg in node.args[:2]) + ): + continue + module_name, symbol = node.args[0].value, node.args[1].value + target = self.module_file(self.root, module_name, stub=False) + if target is None: + self.report(path, node.lineno, f"{node.func.id} names missing module '{module_name}'") + continue + target_module = self.load(target) + if target_module is not None and not target_module.star_import and symbol not in target_module.runtime_names: + self.report(path, node.lineno, f"{node.func.id} names '{symbol}', which {module_name}.py does not define") + + def check(self): + for directory, _, names in os.walk(self.root): + for name in sorted(names): + if not name.endswith((".py", ".pyi")): + continue + path = os.path.join(directory, name) + stub = name.endswith(".pyi") + module = self.load(path) + if module is None: + continue + NameChecker(module, stub, self.report).check() + self.check_imports(path, module, stub) + if not stub: + self.check_lazy_symbols(path, module) + if name == "__init__.py": + self.check_exports(path, module) + return sorted(set(self.problems)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("packages", nargs="+", help="generated Python package directories") + parser.add_argument("--limit", type=int, default=200, help="maximum problems to print") + args = parser.parse_args() + + problems = [] + for package in args.packages: + if not os.path.isfile(os.path.join(long_path(package), "_runtime.py")): + print(f"{package}: not a generated Python package (no _runtime.py)", file=sys.stderr) + return 2 + problems.extend(f"{package}: {problem}" for problem in PackageChecker(package).check()) + for problem in problems[: args.limit]: + print(problem) + if len(problems) > args.limit: + print(f"... {len(problems) - args.limit} more") + print(f"Checked {len(args.packages)} generated Python package(s): {len(problems)} problem(s).") + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/e2e/e2e_preparation.tests.ps1 b/tests/e2e/e2e_preparation.tests.ps1 index 2144028f..833d4528 100644 --- a/tests/e2e/e2e_preparation.tests.ps1 +++ b/tests/e2e/e2e_preparation.tests.ps1 @@ -18,6 +18,7 @@ $shell = (Get-Process -Id $PID).Path try { New-Item -ItemType Directory -Path $scripts, (Split-Path $venv), (Split-Path $standard) -Force | Out-Null Copy-Item -LiteralPath (Join-Path $PSScriptRoot "e2e_test.ps1") -Destination $scripts + Copy-Item -LiteralPath (Join-Path $PSScriptRoot "check_generated_python.py") -Destination $scripts Copy-Item -LiteralPath (Join-Path $PSScriptRoot "codegen.ps1") -Destination $scripts New-Item -ItemType File -Path $venv, $explicit, $codegen | Out-Null Set-Content -LiteralPath $standard -Value '{"retained": true}' -NoNewline diff --git a/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index 88ff5022..6d9048f2 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -376,13 +376,13 @@ ] }, { - "id": "thread_pool_work_item_projection", + "id": "thread_pool_work_item_passthrough", "namespace": "Windows.System.Threading", "class": "ThreadPool", "langs": ["py"], "instantiate": { "kind": "none" }, "checks": [ - { "kind": "work_item_callback_projection", "member": "run_async" } + { "kind": "work_item_callback_passthrough", "member": "run_async" } ] }, { @@ -395,6 +395,19 @@ { "kind": "timer_callback_projection", "member": "create_timer" } ] }, + { + "id": "gamepad_static_event_native_delegate", + "namespace": "Windows.Gaming.Input", + "class": "Gamepad", + "langs": ["py"], + "instantiate": { "kind": "none" }, + "checks": [ + { + "kind": "static_event_native_delegate_passthrough", + "member": "gamepad_added" + } + ] + }, { "id": "notification_data_mapping", "namespace": "Windows.UI.Notifications", diff --git a/tests/e2e/e2e_specs.schema.json b/tests/e2e/e2e_specs.schema.json index 9d23ecf2..6d04145b 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -101,8 +101,9 @@ "storage_query_temp_folder", "value_set_event_lifecycle", "map_changed_event_projection", - "work_item_callback_projection", + "work_item_callback_passthrough", "timer_callback_projection", + "static_event_native_delegate_passthrough", "nested_struct_runtime", "generated_helper_matrix" ] diff --git a/tests/e2e/e2e_test.ps1 b/tests/e2e/e2e_test.ps1 index b02b8318..1d87fc47 100644 --- a/tests/e2e/e2e_test.ps1 +++ b/tests/e2e/e2e_test.ps1 @@ -396,6 +396,10 @@ $totalFail = 0 $allResults = @() if ("py" -in $Lang) { + Write-Host "`n--- Generated Python name check ---" -ForegroundColor Yellow + & $pythonExe (Join-Path $PSScriptRoot "check_generated_python.py") $pyBindingsDir + if ($LASTEXITCODE -ne 0) { Write-Error "Generated Python name check failed"; exit 1 } + Write-Host "`n--- Python static type check ---" -ForegroundColor Yellow $previousMypyPath = $env:MYPYPATH try { diff --git a/tests/e2e/prebuilt_codegen.tests.ps1 b/tests/e2e/prebuilt_codegen.tests.ps1 index 74e7e17e..e84a6872 100644 --- a/tests/e2e/prebuilt_codegen.tests.ps1 +++ b/tests/e2e/prebuilt_codegen.tests.ps1 @@ -14,7 +14,13 @@ $prebuilt = Join-Path $scratch "artifact with spaces\codegen.ps1" $python = Join-Path $scratch "python.ps1" try { New-Item -ItemType Directory -Path $scripts, (Split-Path $prebuilt) -Force | Out-Null - foreach ($file in @("e2e_test.ps1", "implementation_test.ps1", "codegen.ps1", "e2e_specs.json")) { + foreach ($file in @( + "e2e_test.ps1", + "implementation_test.ps1", + "codegen.ps1", + "e2e_specs.json", + "check_generated_python.py" + )) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot $file) -Destination $scripts } Set-Content -LiteralPath (Join-Path $scratch "metadata.winmd") -Value "external metadata boundary" diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index bb09f18a..761a829b 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -949,16 +949,133 @@ def handler(sender, args): elif token_keys != [key, key, key]: cr['error'] = f'token handler observed {token_keys!r}' else: - cr['pass'] = True + collection_namespace = importlib.import_module( + namespace_module_name( + pkg_name, 'Windows.Foundation.Collections' + ) + ) + suffix = check['expected_type'].removeprefix( + 'IObservableMap_' + ) + delegate = dw.DynWinRtDelegate.create( + getattr( + collection_namespace, + f'IID_MapChangedEventHandler_{suffix}', + ), + getattr( + collection_namespace, + f'MapChangedEventHandler_{suffix}_PARAM_TYPES', + ), + lambda *args: raw_events.append(args), + ) + raw_events = [] + raw_value = delegate.to_value() + native_token = getattr(obj, f'on_{member}')(delegate) + native_unsubscribe = getattr(obj, f'subscribe_{member}')( + raw_value + ) + for native in (delegate, raw_value): + try: + getattr(obj, f'once_{member}')(native) + except TypeError as error: + expected_error = ( + f'once_{member} requires a Python callable; ' + f'use on_{member} or subscribe_{member} ' + 'for native delegates' + ) + if str(error) != expected_error: + cr['error'] = ( + 'native once rejection was unclear: ' + f'{error}' + ) + return cr + else: + cr['error'] = ( + f'once_{member} accepted a native delegate' + ) + return cr + obj[key] = second + getattr(obj, f'off_{member}')(native_token) + native_unsubscribe() + native_unsubscribe() + del obj[key] + raw_value.release() + if len(raw_events) != 2 or any( + len(args) != 2 + or not all( + isinstance(arg, dw.DynWinRTValue) + for arg in args + ) + for args in raw_events + ): + cr['error'] = ( + 'native MapChanged delegate did not receive raw ' + f'values: {raw_events!r}' + ) + else: + cr['pass'] = True - elif kind == 'work_item_callback_projection': + elif kind == 'work_item_callback_passthrough': received = [] await getattr(cls, member)(received.append) - operation_type = type(received[0]).__name__ if received else None if len(received) != 1: cr['error'] = f'work item ran {len(received)} times' - elif operation_type != '_DynWinRTAsync': - cr['error'] = f'work item received {operation_type}, not a projected IAsyncAction' + return cr + if not isinstance(received[0], dw.DynWinRTValue): + cr['error'] = ( + 'work item callable did not receive the raw IAsyncAction: ' + f'{type(received[0]).__name__}' + ) + return cr + + threading_namespace = importlib.import_module( + namespace_module_name(pkg_name, 'Windows.System.Threading') + ) + native_received = [] + delegate = dw.DynWinRtDelegate.create( + threading_namespace.IID_WorkItemHandler, + threading_namespace.WorkItemHandler_PARAM_TYPES, + native_received.append, + ) + await getattr(cls, member)(delegate) + raw_value = delegate.to_value() + await getattr(cls, member)(raw_value) + raw_value.release() + if len(native_received) != 2 or not all( + isinstance(value, dw.DynWinRTValue) + for value in native_received + ): + cr['error'] = ( + 'native work-item delegate did not pass through: ' + f'{native_received!r}' + ) + else: + cr['pass'] = True + + elif kind == 'static_event_native_delegate_passthrough': + gaming_namespace = importlib.import_module( + namespace_module_name(pkg_name, 'Windows.Gaming.Input') + ) + foundation_namespace = importlib.import_module( + namespace_module_name(pkg_name, 'Windows.Foundation') + ) + delegate = dw.DynWinRtDelegate.create( + foundation_namespace.IID_EventHandler_Gamepad, + foundation_namespace.EventHandler_Gamepad_PARAM_TYPES, + lambda *_args: None, + ) + add = getattr(cls, f'add_{member}') + remove = getattr(cls, f'remove_{member}') + token = add(delegate) + remove(token) + raw_value = delegate.to_value() + token = add(raw_value) + remove(token) + raw_value.release() + # Keep the namespace module import live: it is also the intended + # public home of the Gamepad static event. + if cls is not gaming_namespace.Gamepad: + cr['error'] = 'Gamepad namespace export was inconsistent' else: cr['pass'] = True diff --git a/tests/e2e/typecheck/python_generated_api.py b/tests/e2e/typecheck/python_generated_api.py index ccd97e25..2e076248 100644 --- a/tests/e2e/typecheck/python_generated_api.py +++ b/tests/e2e/typecheck/python_generated_api.py @@ -8,6 +8,7 @@ from dynwinrt import ( DynWinRTArray, + DynWinRtDelegate, WinRTAsync, WinRTAsyncWithProgress, WinRTCoroutine, @@ -17,6 +18,7 @@ DynWinRTValue, WinGUID, ) +from python_bindings.windows.gaming.input import Gamepad from python_bindings.windows.application_model.contacts import ContactDate from python_bindings.windows.foundation import ( IReference_UInt32, @@ -193,10 +195,24 @@ def on_properties( def check_delegate_callback_parameters() -> None: work: WinRTCoroutine[None] = ThreadPool.run_async( - lambda operation: assert_type(operation, WinRTCoroutine[None]) + lambda operation: assert_type(operation, DynWinRTValue) ) timer: ThreadPoolTimer | None = ThreadPoolTimer.create_timer( lambda elapsed: assert_type(elapsed.delay, timedelta), timedelta(milliseconds=1), ) _: Tuple[WinRTCoroutine[None], ThreadPoolTimer | None] = (work, timer) + + +def check_native_delegate_inputs( + native: DynWinRtDelegate, + raw: DynWinRTValue, + properties: PropertySet, +) -> None: + token = properties.on_map_changed(native) + properties.off_map_changed(token) + properties.subscribe_map_changed(raw)() + ThreadPool.run_async(native) + ThreadPool.run_async(raw) + static_token = Gamepad.add_gamepad_added(native) + Gamepad.remove_gamepad_added(static_token) diff --git a/tools/dynwinrt-codegen/TYPE_COVERAGE.md b/tools/dynwinrt-codegen/TYPE_COVERAGE.md index f8c6413d..4f71a029 100644 --- a/tools/dynwinrt-codegen/TYPE_COVERAGE.md +++ b/tools/dynwinrt-codegen/TYPE_COVERAGE.md @@ -108,7 +108,8 @@ delegate's `Invoke` signature, with generic arguments substituted. This applies wherever a Python callable becomes a delegate: instance and static events, callback parameters, and delegate-typed properties. Callback arguments are non-null except WinRT `Object` (`DynWinRTValue | None`) and `IReference` -(`T | None`). +(`T | None`). Async-operation arguments stay raw `DynWinRTValue` objects so +callback projection does not take ownership of their completion. The shared dynamic WinRT delegate currently supports up to two ABI parameters. This covers common handlers such as `TypedEventHandler`, diff --git a/tools/dynwinrt-codegen/tests/python_consumer_typing_test.rs b/tools/dynwinrt-codegen/tests/python_consumer_typing_test.rs index 104f2764..36b50b9b 100644 --- a/tools/dynwinrt-codegen/tests/python_consumer_typing_test.rs +++ b/tools/dynwinrt-codegen/tests/python_consumer_typing_test.rs @@ -531,7 +531,7 @@ fn real_windows_consumers_accept_file_stream_content_and_composition_instances() &fixture, &["sdk"], r#"from typing import assert_type -from dynwinrt import DynWinRTValue +from dynwinrt import DynWinRTValue, DynWinRtDelegate from sdk.windows.storage import FileIO, StorageFile from sdk.windows.media.playback import MediaPlayer from sdk.windows.media.speech_synthesis import SpeechSynthesisStream @@ -842,13 +842,18 @@ fn map_changed_handlers_receive_typed_observable_maps_and_arguments() { &fixture, &["sdk"], r#"from typing import assert_type -from dynwinrt import DynWinRTValue +from dynwinrt import DynWinRTValue, DynWinRtDelegate from sdk.windows.foundation.collections import ( CollectionChange, IMapChangedEventArgs_String, IObservableMap_String_Object, IObservableMap_String_String, PropertySet, StringMap, ) -def typed(properties: PropertySet, strings: StringMap) -> None: +def typed( + properties: PropertySet, + strings: StringMap, + native: DynWinRtDelegate, + raw: DynWinRTValue, +) -> None: def on_properties( sender: IObservableMap_String_Object, args: IMapChangedEventArgs_String ) -> None: @@ -864,6 +869,8 @@ def typed(properties: PropertySet, strings: StringMap) -> None: lambda sender, args: assert_type(args, IMapChangedEventArgs_String) ) properties.off_map_changed(token) + properties.off_map_changed(properties.on_map_changed(native)) + properties.subscribe_map_changed(raw)() strings.subscribe_map_changed( lambda sender, args: assert_type(sender, IObservableMap_String_String) ) @@ -874,18 +881,30 @@ def typed(properties: PropertySet, strings: StringMap) -> None: typecheck( &fixture, &["sdk"], - r#"from sdk.windows.foundation.collections import PropertySet, StringMap + r#"from dynwinrt import DynWinRtDelegate +from sdk.windows.foundation.collections import PropertySet, StringMap def wrong_sender(sender: int, args: object) -> None: ... def wrong_args(sender: object, args: str) -> None: ... -def untyped(properties: PropertySet, strings: StringMap) -> None: +def untyped( + properties: PropertySet, + strings: StringMap, + native: DynWinRtDelegate, +) -> None: properties.subscribe_map_changed(wrong_sender) strings.once_map_changed(wrong_args) properties.on_map_changed(lambda sender, args: args.index) strings.subscribe_map_changed(lambda sender, args: sender[0]) + strings.once_map_changed(native) "#, - &["[arg-type]", "[arg-type]", "[attr-defined]", "[index]"], + &[ + "[arg-type]", + "[arg-type]", + "[attr-defined]", + "[index]", + "[arg-type]", + ], ); if has_implementation_runtime() { fs::write( From ead8aaac16c0355dfb22b017f887ef1340e43eff Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 17:56:25 +0800 Subject: [PATCH 08/11] Apply Rust formatting to review fixes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/codegen/winrt/python/delegates.rs | 10 ++++++---- .../src/codegen/winrt/python/method.rs | 8 +++++--- .../src/codegen/winrt/python/mod.rs | 5 ++++- tools/dynwinrt-codegen/tests/common/mod.rs | 4 +--- .../tests/python_delegate_callback_test.rs | 16 +++++++--------- 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs index 96b7ef4d..d21028f6 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs @@ -143,7 +143,11 @@ fn py_callback_projection(typ: &TypeMeta, context: &PythonProjectionContext) -> .zip(&names) .map(|(param, name)| py_delegate_argument(name, ¶m.typ, context)) .collect::>(); - if arguments.iter().zip(&names).all(|(argument, name)| argument == name) { + if arguments + .iter() + .zip(&names) + .all(|(argument, name)| argument == name) + { return None; } let tuple = if arguments.len() == 1 { @@ -315,9 +319,7 @@ mod tests { let delegate = py_delegate_input_arg("handler", &handler, &context).unwrap(); assert!( - delegate.ends_with( - "lambda __p0__, __p1__: (__p0__.to_number(), __p1__.to_number()))" - ), + delegate.ends_with("lambda __p0__, __p1__: (__p0__.to_number(), __p1__.to_number()))"), "{delegate}" ); } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index 9fb80465..42769fd1 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -10,8 +10,8 @@ use crate::codegen::winrt::shared::imports::{ }; use super::delegates::{ - py_delegate_callable_type, py_delegate_input_arg, py_delegate_param_type, - py_event_handler_arg, py_once_callback_check, + py_delegate_callable_type, py_delegate_input_arg, py_delegate_param_type, py_event_handler_arg, + py_once_callback_check, }; use super::naming::{PythonProjectionContext, to_snake_case}; use super::signature::{ @@ -1156,7 +1156,9 @@ mod tests { ); assert!(code.contains("def on_changed(self, callback:")); - assert!(code.contains("return _IWidget.method(6).invoke(self._obj, [_dynwinrt_delegate(callback, ")); + assert!(code.contains( + "return _IWidget.method(6).invoke(self._obj, [_dynwinrt_delegate(callback, " + )); assert!(code.contains("def subscribe_changed(self, callback:")); assert!(code.contains("if not _active[0]:")); assert!(code.contains("self.off_changed(_token)")); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs index 47ede943..e4065102 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs @@ -238,7 +238,10 @@ pub fn validate_struct_symbol_uniqueness( } for class in classes { - validate(&class.full_name, collect_used_structs_from_class_and_callbacks(class))?; + validate( + &class.full_name, + collect_used_structs_from_class_and_callbacks(class), + )?; } for interface in interfaces { validate( diff --git a/tools/dynwinrt-codegen/tests/common/mod.rs b/tools/dynwinrt-codegen/tests/common/mod.rs index c9e2d06e..436ff03e 100644 --- a/tools/dynwinrt-codegen/tests/common/mod.rs +++ b/tools/dynwinrt-codegen/tests/common/mod.rs @@ -13,9 +13,7 @@ use dynwinrt_codegen::types::{TypeIdentity, TypeIdentityKind, TypeMeta}; /// `subscribe_` also accept native delegates. pub fn event_signatures(event: &str, callback: &str) -> [String; 3] { [ - format!( - "def on_{event}(self, callback: {callback} | 'DynWinRTValue | DynWinRtDelegate'):" - ), + format!("def on_{event}(self, callback: {callback} | 'DynWinRTValue | DynWinRtDelegate'):"), format!( "def subscribe_{event}(self, callback: {callback} | 'DynWinRTValue | DynWinRtDelegate'):" ), diff --git a/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs b/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs index 6698a49b..342288cf 100644 --- a/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs +++ b/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs @@ -12,9 +12,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; -use dynwinrt_codegen::meta::{ - ClassMeta, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta, -}; +use dynwinrt_codegen::meta::{ClassMeta, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta}; use dynwinrt_codegen::types::{FieldMeta, TypeMeta}; const WINDOWS_WINMD: &str = @@ -155,9 +153,7 @@ fn static_events_callback_parameters_and_setters_project_callables() { "{thread_pool}" ); assert!( - thread_pool.contains( - "'WorkItemHandler_PARAM_TYPES'))" - ), + thread_pool.contains("'WorkItemHandler_PARAM_TYPES'))"), "{thread_pool}" ); assert!( @@ -193,8 +189,7 @@ fn static_events_callback_parameters_and_setters_project_callables() { "{timer_stub}" ); - let command_callback = - "Callable[['IUICommand'], object] | 'DynWinRTValue | DynWinRtDelegate'"; + let command_callback = "Callable[['IUICommand'], object] | 'DynWinRTValue | DynWinRtDelegate'"; let command = output.read("windows__ui__popups__ui_command", "py"); assert!( command.contains(&format!("def invoked(self, value: {command_callback}):")), @@ -296,7 +291,10 @@ fn class_event_struct_adapter_has_helpers_and_executes() { source.contains("\ndef unpack_payload(v: DynWinRTValue) -> Payload:\n"), "{source}" ); - assert!(source.contains("_unpack_payload = unpack_payload\n"), "{source}"); + assert!( + source.contains("_unpack_payload = unpack_payload\n"), + "{source}" + ); assert!( source.contains("lambda __p0__: (_unpack_payload(__p0__),)"), "{source}" From f18a9669f76141cdffa62cb1a6ec53a9f936be7a Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 18:52:41 +0800 Subject: [PATCH 09/11] Cover callback lifetimes and generated cancellation Verify one thousand projected MapChanged deliveries do not grow the active projected lifetime scope and that a retained sender/event argument stays valid after the scope closes. Document ownership and deterministic release. Run async cancellation through the generated ThreadPool.run_async callable path, keeping its IAsyncAction raw and inspecting cancellation through generated IAsyncInfo as documented. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 28 +++++++++++ tests/e2e/e2e_specs.json | 1 + tests/e2e/runners/py_runner.py | 85 +++++++++++++++++++++++----------- 3 files changed, 87 insertions(+), 27 deletions(-) diff --git a/bindings/py/README.md b/bindings/py/README.md index ee5a50c1..ea199314 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -123,6 +123,14 @@ method-output typing. This is an intentionally optimistic typing policy, not a guarantee from the `Invoke` metadata: WinMD carries no nullability information, and the runtime still passes `None` when WinRT supplies a null reference. +Projected callback arguments are created outside the lifetime scope that was +active when the callback was subscribed. Short-lived arguments are therefore +released when their Python wrappers are dropped instead of accumulating in a +long-lived UI/application scope. If a callback retains an argument, its wrapper +owns the native reference and remains valid after the callback and after that +subscription-time scope closes; drop it normally or call `release_projected()` +when deterministic release is needed. + ```python def changed(sender: IObservableMap_String_Object, args: IMapChangedEventArgs_String) -> None: if args.collection_change == CollectionChange.ItemInserted: @@ -131,6 +139,26 @@ def changed(sender: IObservableMap_String_Object, args: IMapChangedEventArgs_Str unsubscribe = properties.subscribe_map_changed(changed) ``` +Async-operation arguments deliberately stay raw. A work item can inspect its +`IAsyncInfo` status without installing another completion handler: + +```python +from dynwinrt import DynWinRTValue, release_projected +from generated.windows.foundation import AsyncStatus, IAsyncInfo +from generated.windows.system.threading import ThreadPool + +def work(action: DynWinRTValue) -> None: + info = IAsyncInfo.from_value(action) + try: + if info.status == AsyncStatus.Canceled: + return + # Do work, polling info.status when cooperative cancellation is needed. + finally: + release_projected(info) + +operation = ThreadPool.run_async(work) +``` + WinRT flags enums are projected as `enum.IntFlag`. Overloaded methods share one Python name with runtime type/arity dispatch and `typing.overload` declarations. Activatable runtime classes use normal constructors, for example diff --git a/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index 6d9048f2..8a1c303e 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -548,6 +548,7 @@ "namespace": "Windows.System.Threading", "class": "ThreadPool", "langs": ["py"], + "extra_classes": ["Windows.Foundation.IAsyncInfo"], "instantiate": { "kind": "none" }, "checks": [ { "kind": "async_cancellation", "member": "run_async" } diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 761a829b..5c8cd945 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -1012,8 +1012,50 @@ def handler(sender, args): 'native MapChanged delegate did not receive raw ' f'values: {raw_events!r}' ) - else: - cr['pass'] = True + return cr + + # Project callback arguments outside the subscription-time + # lifetime scope. Ephemeral events must not accumulate native + # refs there, while a retained argument remains owned and + # usable after the scope closes. + if check['expected_type'] == 'IObservableMap_String_String': + retained = [] + + def retain_first(sender, args): + if not retained: + retained.append((sender, args)) + + with dw.projected_lifetime_scope() as scope: + before = len(scope._registry) + scoped_unsubscribe = getattr( + obj, f'subscribe_{member}' + )(retain_first) + for index in range(1000): + obj[key] = ( + check['values'][index % len(check['values'])] + ) + scoped_unsubscribe() + growth = len(scope._registry) - before + if growth != 0: + cr['error'] = ( + 'callback projections accumulated ' + f'{growth} native refs in the active scope' + ) + return cr + retained_sender, retained_args = retained[0] + if ( + retained_args.key != key + or retained_sender[key] != check['values'][-1] + ): + cr['error'] = ( + 'retained callback arguments were invalid after ' + 'the lifetime scope closed' + ) + return cr + dw.release_projected(retained_args) + dw.release_projected(retained_sender) + del obj[key] + cr['pass'] = True elif kind == 'work_item_callback_passthrough': received = [] @@ -1736,19 +1778,11 @@ def progress_without_loop(): elif kind == 'async_cancellation': import dynwinrt as dw - info_iid = dw.WinGUID.parse('00000036-0000-0000-c000-000000000046') - info_type = ( - dw.DynWinRTType.register_interface('IAsyncInfoE2E', info_iid) - .add_method( - 'get_Id', - dw.DynWinRTMethodSig().add_out(dw.DynWinRTType.u32_type()), - ) - .add_method( - 'get_Status', - dw.DynWinRTMethodSig().add_out(dw.DynWinRTType.i32_type()), - ) + foundation_namespace = importlib.import_module( + namespace_module_name(pkg_name, 'Windows.Foundation') ) - status_method = info_type.method(7) + async_info_type = foundation_namespace.IAsyncInfo + async_status_type = foundation_namespace.AsyncStatus started = threading.Event() release = threading.Event() cancel_seen = threading.Event() @@ -1756,26 +1790,23 @@ def progress_without_loop(): def work(action): started.set() + info = None try: - action = action.cast(info_iid) + info = async_info_type.from_value(action) while not release.wait(0.01): - if status_method.invoke(action, []).to_number() == 2: + if info.status == async_status_type.Canceled: cancel_seen.set() break except BaseException as error: worker_errors.append(error) + finally: + if info is not None: + dw.release_projected(info) - # A native delegate passes through unchanged, so this work item - # receives its raw IAsyncAction and can poll IAsyncInfo.Status. - threading_namespace = importlib.import_module( - namespace_module_name(pkg_name, 'Windows.System.Threading') - ) - raw_work = dw.DynWinRtDelegate.create( - threading_namespace.IID_WorkItemHandler, - threading_namespace.WorkItemHandler_PARAM_TYPES, - work, - ).to_value() - operation = cls.run_async(raw_work) + # Generated ThreadPool.run_async keeps the IAsyncAction argument + # raw, so the work item can inspect IAsyncInfo without installing + # another completion handler. + operation = cls.run_async(work) loop = asyncio.get_running_loop() if not await loop.run_in_executor(None, started.wait, 2.0): From 6254a04cb628719865eb720ce38f37636155221b Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 19:43:43 +0800 Subject: [PATCH 10/11] Accept native delegates in constructor dispatch Treat DynWinRtDelegate as a valid delegate input in constructor and overload guards. Constructor stubs intentionally omit raw DynWinRTValue delegate inputs because the single-value native-wrapper shortcut claims them before constructor dispatch. Execute generated constructor and overload dispatch with native delegate objects, and cover additional generated Gamepad value/event paths to preserve Python coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/e2e/runners/py_runner.py | 21 +++++++++ .../src/codegen/winrt/python/delegates.rs | 11 +++++ .../codegen/winrt/python/generator/class.rs | 47 +++++++++++++++++++ .../src/codegen/winrt/python/method.rs | 32 ++++++++++++- .../src/codegen/winrt/python/stubs.rs | 2 +- .../src/codegen/winrt/python/type_helpers.rs | 18 +++++++ .../tests/python_delegate_callback_test.rs | 21 ++++++++- 7 files changed, 148 insertions(+), 4 deletions(-) diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 5c8cd945..ce4c57eb 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -1114,10 +1114,31 @@ def retain_first(sender, args): token = add(raw_value) remove(token) raw_value.release() + removed_token = cls.add_gamepad_removed(delegate) + cls.remove_gamepad_removed(removed_token) + gamepads = cls.get_gamepads() + + reading_type = generated_type(pkg_name, 'GamepadReading') + reading = reading_type(timestamp=7, left_trigger=0.5) + same_reading = reading_type(timestamp=7, left_trigger=0.5) + vibration_type = generated_type(pkg_name, 'GamepadVibration') + vibration = vibration_type(left_motor=0.25, right_motor=0.75) + same_vibration = vibration_type( + left_motor=0.25, right_motor=0.75 + ) # Keep the namespace module import live: it is also the intended # public home of the Gamepad static event. if cls is not gaming_namespace.Gamepad: cr['error'] = 'Gamepad namespace export was inconsistent' + elif gamepads is None: + cr['error'] = 'Gamepad.gamepads returned null' + elif reading != same_reading or 'timestamp=7' not in repr(reading): + cr['error'] = 'GamepadReading value semantics failed' + elif ( + vibration != same_vibration + or 'left_motor=0.25' not in repr(vibration) + ): + cr['error'] = 'GamepadVibration value semantics failed' else: cr['pass'] = True diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs index d21028f6..bd32d908 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/delegates.rs @@ -130,6 +130,17 @@ pub(crate) fn py_delegate_param_type(typ: &TypeMeta, context: &PythonProjectionC format!("{sig} | 'DynWinRTValue | DynWinRtDelegate'") } +/// Constructor overload resolution cannot distinguish a raw delegate value +/// from the one-argument native-wrapper shortcut in `__new__`, so constructor +/// stubs advertise callable or delegate-object inputs only. +pub(crate) fn py_delegate_constructor_param_type( + typ: &TypeMeta, + context: &PythonProjectionContext, +) -> String { + let sig = py_delegate_callable_type(typ, context); + format!("{sig} | 'DynWinRtDelegate'") +} + /// `lambda : ()`, projecting a delegate's native /// arguments for a Python callable. `None` when no argument needs projection. fn py_callback_projection(typ: &TypeMeta, context: &PythonProjectionContext) -> Option { diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs index bd2cbea5..80880297 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -1830,4 +1830,51 @@ print(json.dumps([exercise(WidgetForward), exercise(WidgetReverse)])) r#"[["enum", "i32", "TypeError"], ["enum", "i32", "TypeError"]]"# ); } + + #[test] + fn delegate_constructor_accepts_native_delegate_object() { + let delegate = TypeMeta::Delegate { + namespace: "Contoso".into(), + name: "WorkItemHandler".into(), + iid: "11111111-1111-1111-1111-111111111111".into(), + }; + let code = generate_python_constructor( + &PythonProjectionContext::standalone([delegate.type_identity()]).unwrap(), + &constructor_class(vec![constructor_method("Create", 6, delegate)]), + None, + false, + ); + assert!( + code.contains("isinstance(_bound[0], DynWinRtDelegate)"), + "{code}" + ); + let script = format!( + r#" +class DynWinRTValue: + pass + +class DynWinRtDelegate: + pass + +def _dynwinrt_bind_overload(parameter_names, args, kwargs): + if kwargs or len(args) != len(parameter_names): + return None + return args + +class _CtorResult: + def __init__(self, value): + self._obj = value + +class Widget: + @staticmethod + def create(value): + return _CtorResult("delegate") + +{code} + +print(Widget(DynWinRtDelegate())._obj) +"# + ); + assert_eq!(run_python(&script), "delegate"); + } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index 42769fd1..da44be32 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -70,7 +70,8 @@ pub(crate) fn py_method_type_guard( ) -> String { if is_delegate_type(typ, context) { return format!( - "(callable({name}) or isinstance(getattr({name}, '_obj', {name}), DynWinRTValue))" + "(callable({name}) or isinstance({name}, DynWinRtDelegate) or \ + isinstance(getattr({name}, '_obj', {name}), DynWinRTValue))" ); } py_type_guard(name, typ, context) @@ -1045,7 +1046,7 @@ mod tests { } #[test] - fn delegate_overload_accepts_python_callable() { + fn delegate_overload_accepts_callable_value_and_native_delegate() { let callback = MethodMeta { name: "Run".into(), raw_name: "Run".into(), @@ -1093,9 +1094,36 @@ mod tests { PythonProjectionContext::standalone([callback.params[0].typ.type_identity()]).unwrap(); let code = generate_instance_method_group(&overloads, &context); assert!(code.contains("callable(_bound[0])")); + assert!(code.contains("isinstance(_bound[0], DynWinRtDelegate)")); assert!(code.contains("_dynwinrt_delegate(handler,")); assert!(code.contains("'work_item_handler', 'IID_WorkItemHandler'")); assert!(code.contains("'work_item_handler', 'WorkItemHandler_PARAM_TYPES'")); + + let public = extract_generated_block(&code, " def run(self, *args, **kwargs):"); + let script = format!( + r#" +def _dynwinrt_bind_overload(names, args, kwargs): + if kwargs or len(args) != len(names): + return None + return args + +class DynWinRTValue: + pass + +class DynWinRtDelegate: + pass + +class Runner: + def _run_6(self, handler): + return "delegate" + def _run_7(self, value): + return "text" +{public} + +print(Runner().run(DynWinRtDelegate())) +"# + ); + assert_eq!(run_python(&script), "delegate"); } #[test] diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs index 6edff862..b34a08ed 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs @@ -1427,7 +1427,7 @@ fn emit_constructor_stubs(class: &ClassMeta, context: &PythonProjectionContext) if count > 1 { out.push_str(" @overload\n"); } - let param_str = super::type_helpers::py_param_list(params, context); + let param_str = super::type_helpers::py_constructor_param_list(params, context); if param_str.is_empty() { out.push_str(" def __init__(self) -> None: ...\n"); } else { diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs index 9d7ba6e4..e2c44672 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/type_helpers.rs @@ -539,6 +539,24 @@ pub(super) fn py_param_list( .join(", ") } +pub(super) fn py_constructor_param_list( + in_params: &[&crate::meta::ParamMeta], + context: &PythonProjectionContext, +) -> String { + in_params + .iter() + .map(|param| { + let param_type = if context.is_delegate_type(¶m.typ) { + super::delegates::py_delegate_constructor_param_type(¶m.typ, context) + } else { + py_param_type_safe(¶m.typ, context) + }; + format!("{}: {}", to_snake_case(¶m.name), param_type) + }) + .collect::>() + .join(", ") +} + #[cfg(test)] mod tests { use super::*; diff --git a/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs b/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs index 342288cf..006dfa62 100644 --- a/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs +++ b/tools/dynwinrt-codegen/tests/python_delegate_callback_test.rs @@ -124,7 +124,9 @@ fn bespoke_event_delegates_are_typed_and_projected() { fn static_events_callback_parameters_and_setters_project_callables() { let Some(output) = Output::generate( "Windows.Gaming.Input.Gamepad,Windows.System.Threading.ThreadPool,\ - Windows.System.Threading.ThreadPoolTimer,Windows.UI.Popups.UICommand", + Windows.System.Threading.ThreadPoolTimer,\ + Windows.System.Threading.Core.PreallocatedWorkItem,\ + Windows.UI.Popups.UICommand", ) else { return; }; @@ -210,6 +212,23 @@ fn static_events_callback_parameters_and_setters_project_callables() { )), "{command_stub}" ); + + let work_item_stub = output.read( + "windows__system__threading__core__preallocated_work_item", + "pyi", + ); + assert!( + work_item_stub.contains( + "def __init__(self, handler: Callable[[DynWinRTValue], object] | 'DynWinRtDelegate') -> None: ..." + ), + "{work_item_stub}" + ); + assert!( + !work_item_stub.contains( + "def __init__(self, handler: Callable[[DynWinRTValue], object] | 'DynWinRTValue" + ), + "{work_item_stub}" + ); } #[test] From da80ad870152cda73faedf8eacecf47ec9f2053b Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 19:59:02 +0800 Subject: [PATCH 11/11] Document raw delegate constructor limitation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bindings/py/README.md b/bindings/py/README.md index ea199314..77db3687 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -118,6 +118,12 @@ arguments. `on_*` and `subscribe_*` accept those native delegates. `once_*` requires a Python callable because it must wrap the callback to remove the subscription after the first invocation. +Delegate-accepting constructors accept the `DynWinRtDelegate` object, but their +stubs intentionally do not advertise its raw `DynWinRTValue`. A single raw +value passed to a runtime class is reserved for wrapping an existing native +instance before constructor overload dispatch. Keep the delegate object for a +constructor, or pass the raw delegate to a named factory/method instead. + Callback parameter annotations are non-null by default, matching generated method-output typing. This is an intentionally optimistic typing policy, not a guarantee from the `Invoke` metadata: WinMD carries no nullability information,