From 168859885e55d808c6d274da8ea5ae31eb2750b7 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 12:52:48 +0800 Subject: [PATCH 01/15] Plan Python overload members once per class Overload groups, public method names, private dispatch names, and compatibility aliases were recomputed ad hoc at every call site: `method_group_key` ran with different name sets for statics, instance methods, constructors, required-interface wrappers, interface modules, and stubs; `generate_*_method_group` re-derived the public name from the group alone; constructors regrouped all statics to recover private names; and the runtime `.py` and `.pyi` generators regrouped independently. Every rule change had to be repeated in all of those places. Add `member_plan`, which computes a `ScopePlan` (ordered candidates with their private attribute names, group names, and aliases) once per generated Python class, and render both the runtime and stub output from it. Generators keep their established member order and emit a group at its first method, so the generated output does not change. Overload dispatch is emitted by one `emit_dispatch` for instance, static, and constructor (`__new__`/`__init__`) dispatchers. Each parameter now has a structured `ParamGuard` with a strict guard and an optional permissive guard for a second pass that runs only after every strict guard failed. No guard is permissive yet. Verified: `.py`/`.pyi` generated for all 142 Windows.winmd namespaces are byte-identical to the previous generator. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../codegen/winrt/python/generator/class.rs | 449 +++++-------- .../src/codegen/winrt/python/generator/mod.rs | 14 +- .../codegen/winrt/python/generator/types.rs | 53 +- .../src/codegen/winrt/python/member_plan.rs | 453 +++++++++++++ .../src/codegen/winrt/python/method.rs | 626 ++++++++---------- .../src/codegen/winrt/python/mod.rs | 2 +- .../src/codegen/winrt/python/overloads.rs | 184 ----- .../src/codegen/winrt/python/stubs.rs | 421 ++++++------ 8 files changed, 1164 insertions(+), 1038 deletions(-) create mode 100644 tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs delete mode 100644 tools/dynwinrt-codegen/src/codegen/winrt/python/overloads.rs 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..1e68c15a 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -10,6 +10,9 @@ use crate::codegen::winrt::extensions::winui::{self, WinUiAbiType}; use crate::codegen::winrt::python::collections::{ CollectionKind, class_interface, interface_kind, map_iterable_identity, runtime_mixin, }; +use crate::codegen::winrt::python::member_plan::{ + ClassMemberPlan, PlannedMember, ScopePlan, class_instance_interfaces, interface_member_plan, +}; use crate::meta::{ConstructorKind, ParamMeta}; use crate::types::{TypeIdentity, TypeIdentityKind}; @@ -28,9 +31,9 @@ fn interface_symbol(context: &PythonProjectionContext, interface: &InterfaceMeta } /// Generate a Python file for a single RuntimeClass. -pub fn generate_class( +pub fn generate_class<'a>( context: &PythonProjectionContext, - class: &ClassMeta, + class: &'a ClassMeta, shared_iids: &HashSet, ) -> String { let used_structs = collect_used_structs_from_class(class); @@ -59,6 +62,7 @@ pub fn generate_class( }; let projectable = super::super::has_projectable_default_interface(class); let native_projectable = super::super::has_native_projector(class); + let plan = ClassMemberPlan::new(class); let mut out = String::new(); // Header @@ -337,6 +341,7 @@ pub fn generate_class( class, collection_iface, collection_uses_default, + &plan.statics, )); if crate::codegen::winrt::is_buffer_class(&class.namespace, &class.name) { @@ -406,59 +411,39 @@ pub fn generate_class( out.push('\n'); } - let static_methods = class + let static_overload = |iface: &'a InterfaceMeta, method: &'a MethodMeta| StaticOverload { + class, + iface, + method, + kind: if class + .factory_interfaces + .iter() + .any(|factory| std::ptr::eq(factory, iface)) + { + StaticOverloadKind::Factory + } else { + StaticOverloadKind::Static + }, + }; + let static_members = class .factory_interfaces .iter() - .flat_map(|iface| iface.methods.iter()) - .chain( - class - .static_interfaces - .iter() - .flat_map(|iface| iface.methods.iter()), - ) - .collect::>(); - let static_method_names = - crate::codegen::winrt::python::overloads::method_names(static_methods.iter().copied()); - let mut static_groups: Vec<(String, Vec>)> = Vec::new(); - for (kind, interfaces) in [ - (StaticOverloadKind::Factory, &class.factory_interfaces), - (StaticOverloadKind::Static, &class.static_interfaces), - ] { - for iface in interfaces { - for method in &iface.methods { - let mut key = crate::codegen::winrt::python::overloads::method_group_key( - method, - &static_method_names, - ); - if method.is_property_getter - || method.is_property_setter - || method.is_event_add - || method.is_event_remove - { - key = format!("{}#{key}", interface_symbol(context, iface)); - } - let overload = StaticOverload { - class, - iface, - method, - kind, - }; - if let Some((_, group)) = static_groups - .iter_mut() - .find(|(group_key, _)| group_key == &key) - { - group.push(overload); - } else { - static_groups.push((key, vec![overload])); - } - } - } - } - for (_, overloads) in static_groups { + .chain(class.static_interfaces.iter()) + .flat_map(|iface| iface.methods.iter().map(move |method| (iface, method))); + for member in plan.statics.members(static_members) { out.push('\n'); - out.push_str(&generate_static_method_group(&overloads, context)); + out.push_str(&match member { + PlannedMember::Accessor(iface, method) => { + generate_static_accessor(&static_overload(iface, method), context) + } + PlannedMember::Group(group) => generate_static_method_group( + group, + |candidate| static_overload(candidate.interface, candidate.method), + context, + ), + }); } - let static_aliases = generate_compatibility_aliases(static_methods.iter().copied()); + let static_aliases = generate_compatibility_aliases(&plan.statics); if !static_aliases.is_empty() { out.push('\n'); out.push_str(&static_aliases); @@ -607,18 +592,7 @@ pub fn generate_class( out.push_str(" return _app\n"); } - let mut method_groups: Vec<(String, Vec>)> = Vec::new(); - let instance_ifaces = class - .default_interface - .iter() - .chain(class.required_interfaces.iter()) - .filter(|iface| iface.iid != "30d5a829-7fa4-4026-83bb-d75bae4ea99e") - .collect::>(); - let instance_method_names = crate::codegen::winrt::python::overloads::method_names( - instance_ifaces - .iter() - .flat_map(|iface| iface.methods.iter()), - ); + let instance_ifaces = class_instance_interfaces(class).collect::>(); let property_getters = instance_ifaces .iter() .flat_map(|iface| iface.methods.iter()) @@ -626,68 +600,59 @@ pub fn generate_class( .filter_map(|method| method.name.strip_prefix("get_")) .map(str::to_string) .collect::>(); + let instance_overload = |iface: &'a InterfaceMeta, method: &'a MethodMeta| { + let obj_expr = if collection_iface + .is_some_and(|collection| collection.type_identity() == iface.type_identity()) + { + collection_obj_expr.to_string() + } else if class + .default_interface + .as_ref() + .is_some_and(|default_iface| default_iface.type_identity() == iface.type_identity()) + { + "self._obj".to_string() + } else { + format!("self._obj.cast(IID_{})", interface_symbol(context, iface)) + }; + InstanceOverload { + iface_var: context.registration_symbol(iface), + obj_expr, + method, + sibling_methods: Some(iface.methods.as_slice()), + property_has_getter: !method.is_property_setter + || method + .name + .strip_prefix("put_") + .is_some_and(|suffix| property_getters.contains(suffix)), + } + }; // Python evaluates decorators while building the class. Emit every getter // before any cross-interface setter that references it. + let mut instance_members = Vec::new(); for setter_phase in [false, true] { for iface in &instance_ifaces { - let obj_expr = if collection_iface - .is_some_and(|collection| collection.type_identity() == iface.type_identity()) - { - collection_obj_expr - } else if class - .default_interface - .as_ref() - .is_some_and(|default_iface| default_iface.type_identity() == iface.type_identity()) - { - "self._obj" - } else { - "" - }; - let iface_symbol = interface_symbol(context, iface); - let obj_expr = if obj_expr.is_empty() { - format!("self._obj.cast(IID_{iface_symbol})") - } else { - obj_expr.to_string() - }; - for method in reorder_getters_before_setters(&iface.methods) - .into_iter() - .filter(|method| method.is_property_setter == setter_phase) - { - let key = crate::codegen::winrt::python::overloads::method_group_key( - method, - &instance_method_names, - ); - let overload = InstanceOverload { - iface_var: context.registration_symbol(iface), - obj_expr: obj_expr.clone(), - method, - sibling_methods: Some(iface.methods.as_slice()), - property_has_getter: !method.is_property_setter - || method - .name - .strip_prefix("put_") - .is_some_and(|suffix| property_getters.contains(suffix)), - }; - if let Some((_, group)) = method_groups - .iter_mut() - .find(|(group_key, _)| group_key == &key) - { - group.push(overload); - } else { - method_groups.push((key, vec![overload])); - } - } + instance_members.extend( + reorder_getters_before_setters(&iface.methods) + .into_iter() + .filter(|method| method.is_property_setter == setter_phase) + .map(|method| (*iface, method)), + ); } } - for (_, overloads) in method_groups { + for member in plan.instance.members(instance_members) { out.push('\n'); - out.push_str(&generate_instance_method_group(&overloads, context)); + out.push_str(&match member { + PlannedMember::Accessor(iface, method) => { + generate_instance_accessor(&instance_overload(iface, method), context) + } + PlannedMember::Group(group) => generate_instance_method_group( + group, + |candidate| instance_overload(candidate.interface, candidate.method), + context, + ), + }); } - let instance_aliases = generate_compatibility_aliases( - instance_ifaces - .iter() - .flat_map(|iface| iface.methods.iter()), - ); + let instance_aliases = generate_compatibility_aliases(&plan.instance); if !instance_aliases.is_empty() { out.push('\n'); out.push_str(&instance_aliases); @@ -892,29 +857,37 @@ pub fn generate_class( out.push('\n'); out.push_str(" def as_interface(self, interface_class):\n"); out.push_str(" return interface_class.from_value(self._obj)\n"); - for methods in crate::codegen::winrt::python::overloads::grouped_methods( - reorder_getters_before_setters(&req_iface.methods), - ) { + let iface_plan = interface_member_plan(req_iface); + let overload = |method: &'a MethodMeta| InstanceOverload { + iface_var: reg_var.clone(), + obj_expr: "self._obj".into(), + method, + sibling_methods: Some(req_iface.methods.as_slice()), + property_has_getter: !method.is_property_setter + || method.name.strip_prefix("put_").is_some_and(|suffix| { + req_iface + .methods + .iter() + .any(|candidate| candidate.name == format!("get_{suffix}")) + }), + }; + let members = reorder_getters_before_setters(&req_iface.methods) + .into_iter() + .map(|method| (req_iface, method)); + for member in iface_plan.members(members) { out.push('\n'); - let overloads = methods - .into_iter() - .map(|method| InstanceOverload { - iface_var: reg_var.clone(), - obj_expr: "self._obj".into(), - method, - sibling_methods: Some(req_iface.methods.as_slice()), - property_has_getter: !method.is_property_setter - || method.name.strip_prefix("put_").is_some_and(|suffix| { - req_iface - .methods - .iter() - .any(|candidate| candidate.name == format!("get_{suffix}")) - }), - }) - .collect::>(); - out.push_str(&generate_instance_method_group(&overloads, context)); + out.push_str(&match member { + PlannedMember::Accessor(_, method) => { + generate_instance_accessor(&overload(method), context) + } + PlannedMember::Group(group) => generate_instance_method_group( + group, + |candidate| overload(candidate.method), + context, + ), + }); } - let aliases = generate_compatibility_aliases(req_iface.methods.iter()); + let aliases = generate_compatibility_aliases(&iface_plan); if !aliases.is_empty() { out.push('\n'); out.push_str(&aliases); @@ -990,7 +963,7 @@ struct PyCtorCandidate<'a> { fn build_ctor_candidates<'a>( context: &PythonProjectionContext, class: &'a ClassMeta, - factory_names: &HashSet, + statics: &ScopePlan<'_>, ) -> Vec> { fn push_unique<'a>(candidates: &mut Vec>, candidate: PyCtorCandidate<'a>) { if let Some(existing) = candidates.iter_mut().find(|existing| { @@ -1044,8 +1017,11 @@ fn build_ctor_candidates<'a>( continue; } let in_params = crate::codegen::winrt::shared::imports::get_in_params(method); - let call_expr = - build_factory_call_expr(class, method, &in_params, None, factory_names); + let call_expr = build_factory_call_expr( + static_attribute(statics, method), + &in_params, + None, + ); push_unique( &mut candidates, PyCtorCandidate { @@ -1076,11 +1052,9 @@ fn build_ctor_candidates<'a>( continue; }; let call_expr = build_factory_call_expr( - class, - method, + static_attribute(statics, method), &in_params, Some(outer_index), - factory_names, ); let inner_output_index = method .params @@ -1130,47 +1104,18 @@ fn build_ctor_candidates<'a>( candidates } +fn static_attribute<'p>(statics: &'p ScopePlan<'_>, method: &MethodMeta) -> &'p str { + statics + .attribute(method) + .expect("constructor factory methods are planned static methods") +} + /// Build a `type(self).(_bound[0], _bound[1], ..., None_for_outer)` call. fn build_factory_call_expr( - class: &ClassMeta, - method: &MethodMeta, + call_name: &str, in_params: &[&ParamMeta], outer_index: Option, - factory_names: &HashSet, ) -> String { - let public_name = - crate::codegen::winrt::python::overloads::method_group_key(method, factory_names); - let mut overloads = class - .factory_interfaces - .iter() - .flat_map(|interface| interface.methods.iter()) - .chain( - class - .static_interfaces - .iter() - .flat_map(|interface| interface.methods.iter()), - ) - .filter(|candidate| { - crate::codegen::winrt::python::overloads::method_group_key(candidate, factory_names) - == public_name - }) - .collect::>(); - let call_name = if overloads.len() > 1 { - overloads.sort_by(|left, right| { - crate::codegen::winrt::python::overloads::cmp_python_dispatch_methods(left, right) - }); - let private_names = crate::codegen::winrt::python::method::private_overload_names( - &public_name, - overloads.iter().copied(), - ); - let index = overloads - .iter() - .position(|candidate| std::ptr::eq(*candidate, method)) - .expect("constructor method must be present in its static overload group"); - private_names[index].clone() - } else { - to_snake_case(&method.name) - }; let mut public_idx = 0usize; let args = in_params .iter() @@ -1250,6 +1195,7 @@ fn generate_python_constructor( class: &ClassMeta, collection_iface: Option<&InterfaceMeta>, collection_uses_default: bool, + statics: &ScopePlan<'_>, ) -> String { let mut out = String::new(); let native_projectable = super::super::has_native_projector(class); @@ -1296,22 +1242,9 @@ fn generate_python_constructor( supported_override_names.sort(); supported_override_names.dedup(); let supported_override_names_expr = python_tuple(&supported_override_names); - let static_methods = class - .factory_interfaces - .iter() - .flat_map(|iface| iface.methods.iter()) - .chain( - class - .static_interfaces - .iter() - .flat_map(|iface| iface.methods.iter()), - ) - .collect::>(); - let factory_names = - crate::codegen::winrt::python::overloads::method_names(static_methods.iter().copied()); - let mut candidates = build_ctor_candidates(context, class, &factory_names); + let mut candidates = build_ctor_candidates(context, class, statics); candidates.sort_by(|left, right| { - crate::codegen::winrt::python::overloads::cmp_python_dispatch_params( + crate::codegen::winrt::python::member_plan::cmp_python_dispatch_params( &left.public_params, &right.public_params, ) @@ -1330,39 +1263,17 @@ fn generate_python_constructor( " if cls is {}:\n", context.class_name(class) )); - for candidate in &candidates { - let parameter_names = candidate - .public_params - .iter() - .map(|param| format!("'{}'", to_snake_case(¶m.name))) - .collect::>() - .join(", "); - let parameter_names = if parameter_names.is_empty() { - "()".to_string() - } else { - format!("({parameter_names},)") - }; - out.push_str(&format!( - " _bound = _dynwinrt_bind_overload({parameter_names}, args, kwargs)\n" - )); - let guards = candidate - .public_params - .iter() - .enumerate() - .map(|(index, param)| { - py_method_type_guard(&format!("_bound[{index}]"), ¶m.typ, context) - }) - .collect::>(); - let condition = if guards.is_empty() { - "_bound is not None".to_string() - } else { - format!("_bound is not None and {}", guards.join(" and ")) - }; - let call_expr = candidate.call_expr.replace("type(self)", "cls"); - out.push_str(&format!( - " if {condition}:\n return {call_expr}\n" - )); - } + let dispatch = candidates + .iter() + .map(|candidate| DispatchCandidate { + params: candidate.public_params.clone(), + body: vec![format!( + "return {}", + candidate.call_expr.replace("type(self)", "cls") + )], + }) + .collect::>(); + emit_dispatch(&mut out, " ", &dispatch, context); } out.push_str(" return super().__new__(cls)\n\n"); @@ -1545,54 +1456,32 @@ fn generate_python_constructor( )); } } - for candidate in &candidates { - let parameter_names = candidate - .public_params - .iter() - .map(|param| format!("'{}'", to_snake_case(¶m.name))) - .collect::>() - .join(", "); - let parameter_names = if parameter_names.is_empty() { - "()".to_string() - } else { - format!("({parameter_names},)") - }; - out.push_str(&format!( - " _bound = _dynwinrt_bind_overload({parameter_names}, args, kwargs)\n" - )); - let guards = candidate - .public_params - .iter() - .enumerate() - .map(|(index, param)| { - py_method_type_guard(&format!("_bound[{index}]"), ¶m.typ, context) - }) - .collect::>(); - let condition = if guards.is_empty() { - "_bound is not None".to_string() - } else { - format!("_bound is not None and {}", guards.join(" and ")) - }; - out.push_str(&format!(" if {condition}:\n")); - if let Some(composed_call) = &candidate.composed_call_expr { - out.push_str(" if _is_python_subclass:\n"); - out.push_str(&format!( - " self._set_native({composed_call}, _allow_native_overrides=True)\n\ - \x20 return\n" - )); - } else if has_public_composition { - out.push_str(" if _is_python_subclass:\n"); - out.push_str(&format!( - " raise TypeError(\"{} does not support Python subclass construction for this constructor\")\n", - context.class_name(class) - )); - } - out.push_str(&format!( - " self._set_native({}._obj)\n\ - \x20 return\n", - candidate.call_expr - )); - } + let dispatch = candidates + .iter() + .map(|candidate| { + let mut body = Vec::new(); + if let Some(composed_call) = &candidate.composed_call_expr { + body.push("if _is_python_subclass:".to_string()); + body.push(format!( + " self._set_native({composed_call}, _allow_native_overrides=True)" + )); + body.push(" return".to_string()); + } else if has_public_composition { + body.push("if _is_python_subclass:".to_string()); + body.push(format!( + " raise TypeError(\"{} does not support Python subclass construction for this constructor\")", + context.class_name(class) + )); + } + body.push(format!("self._set_native({}._obj)", candidate.call_expr)); + body.push("return".to_string()); + DispatchCandidate { + params: candidate.public_params.clone(), + body, + } + }) + .collect::>(); + emit_dispatch(&mut out, " ", &dispatch, context); if candidates.is_empty() { out.push_str(&format!( " raise TypeError(\"{} cannot be constructed directly\")\n\n", @@ -1753,17 +1642,21 @@ mod tests { let context = PythonProjectionContext::packaged([enum_type("Mode").type_identity()]).unwrap(); + let forward_class = constructor_class(vec![integer.clone(), enumeration.clone()]); + let reverse_class = constructor_class(vec![enumeration, integer]); let forward = generate_python_constructor( &context, - &constructor_class(vec![integer.clone(), enumeration.clone()]), + &forward_class, None, false, + &ClassMemberPlan::new(&forward_class).statics, ); let reverse = generate_python_constructor( &context, - &constructor_class(vec![enumeration, integer]), + &reverse_class, None, false, + &ClassMemberPlan::new(&reverse_class).statics, ); assert_eq!(forward, reverse); 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..b182794d 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -22,9 +22,11 @@ use crate::codegen::winrt::shared::structs::{ collect_used_structs_from_struct, }; +use super::member_plan::ScopePlan; use super::method::{ - InstanceOverload, StaticOverload, StaticOverloadKind, generate_instance_method_group, - generate_static_method_group, py_method_type_guard, + DispatchCandidate, InstanceOverload, StaticOverload, StaticOverloadKind, emit_dispatch, + generate_instance_accessor, generate_instance_method_group, generate_static_accessor, + generate_static_method_group, }; use super::naming::{PythonProjectionContext, PythonSupportSymbol, is_py_reserved, to_snake_case}; use super::shared::reorder_getters_before_setters; @@ -190,10 +192,10 @@ fn has_ireference_struct_field(structs: &[TypeMeta]) -> bool { structs.iter().any(contains) } -fn generate_compatibility_aliases<'a>(methods: impl IntoIterator) -> String { - super::overloads::compatibility_aliases(methods) - .into_iter() - .map(|(legacy, canonical)| format!(" {legacy} = {canonical}\n")) +fn generate_compatibility_aliases(plan: &ScopePlan<'_>) -> String { + plan.aliases() + .iter() + .map(|alias| format!(" {} = {}\n", alias.name, alias.target)) .collect() } 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..952a1dd3 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs @@ -10,6 +10,7 @@ use crate::codegen::winrt::python::collections::{ CollectionKind, interface_kind, map_iterable_identity, observable_vector_identity, runtime_mixin, }; +use crate::codegen::winrt::python::member_plan::{PlannedMember, interface_member_plan}; use crate::types::{TypeIdentity, TypeIdentityKind}; /// Generate a Python file for a single enum. @@ -578,29 +579,37 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe } else { "self._obj" }; - for methods in crate::codegen::winrt::python::overloads::grouped_methods( - reorder_getters_before_setters(&iface.methods), - ) { + let plan = interface_member_plan(iface); + let overload = |method| InstanceOverload { + iface_var: iface_var.clone(), + obj_expr: obj_expr.to_string(), + method, + sibling_methods: Some(iface.methods.as_slice()), + property_has_getter: !method.is_property_setter + || method.name.strip_prefix("put_").is_some_and(|suffix| { + iface + .methods + .iter() + .any(|candidate| candidate.name == format!("get_{suffix}")) + }), + }; + let members = reorder_getters_before_setters(&iface.methods) + .into_iter() + .map(|method| (iface, method)); + for member in plan.members(members) { out.push('\n'); - let overloads = methods - .into_iter() - .map(|method| InstanceOverload { - iface_var: iface_var.clone(), - obj_expr: obj_expr.to_string(), - method, - sibling_methods: Some(iface.methods.as_slice()), - property_has_getter: !method.is_property_setter - || method.name.strip_prefix("put_").is_some_and(|suffix| { - iface - .methods - .iter() - .any(|candidate| candidate.name == format!("get_{suffix}")) - }), - }) - .collect::>(); - out.push_str(&generate_instance_method_group(&overloads, context)); - } - let aliases = generate_compatibility_aliases(iface.methods.iter()); + out.push_str(&match member { + PlannedMember::Accessor(_, method) => { + generate_instance_accessor(&overload(method), context) + } + PlannedMember::Group(group) => generate_instance_method_group( + group, + |candidate| overload(candidate.method), + context, + ), + }); + } + let aliases = generate_compatibility_aliases(&plan); if !aliases.is_empty() { out.push('\n'); out.push_str(&aliases); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs new file mode 100644 index 00000000..66f611be --- /dev/null +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs @@ -0,0 +1,453 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Python member plan: overload groups, public method names, private dispatch +//! names, and compatibility aliases. +//! +//! A plan is computed once per generated Python class from its WinRT +//! interfaces. The runtime (`.py`) and stub (`.pyi`) generators both render the +//! same plan, so they cannot disagree about which methods share a Python name, +//! the dispatch order of the overloads, the private name of each overload, or +//! which earlier names remain as aliases. Generators keep their own member +//! order and emit a group where its first method appears. +//! +//! Interface implementation handlers keep one name per ABI slot and do not use +//! this module. + +use std::cmp::Ordering; +use std::collections::{BTreeMap, HashMap, HashSet}; + +use crate::codegen::winrt::shared::imports::get_in_params; +use crate::meta::{ClassMeta, InterfaceMeta, MethodMeta, ParamMeta}; + +use super::naming::to_snake_case; +use super::signature::py_dispatch_type_sort_key; + +const ICLOSABLE_IID: &str = "30d5a829-7fa4-4026-83bb-d75bae4ea99e"; + +/// One overload of a planned method group. +pub(crate) struct Candidate<'a> { + pub(crate) interface: &'a InterfaceMeta, + pub(crate) method: &'a MethodMeta, + /// Attribute implementing this overload: the group name when it is the only + /// candidate, otherwise its private dispatch name. + pub(crate) attribute: String, +} + +/// Methods projected as one Python method, in dispatch order. +pub(crate) struct MethodGroup<'a> { + pub(crate) name: String, + pub(crate) candidates: Vec>, +} + +/// A previously emitted method name kept as a class attribute alias. +pub(crate) struct Alias<'a> { + pub(crate) name: String, + /// Attribute the alias is bound to. + pub(crate) target: String, + /// Methods whose signatures the stub declares for this name. + pub(crate) signatures: Vec<&'a MethodMeta>, +} + +/// A member of a Python class in generator order. +pub(crate) enum PlannedMember<'p, 'a> { + /// Property accessor or event method, emitted by its own rules. + Accessor(&'a InterfaceMeta, &'a MethodMeta), + /// The first appearance of a method group. + Group(&'p MethodGroup<'a>), +} + +/// Member plan for one scope (static or instance members) of a Python class. +pub(crate) struct ScopePlan<'a> { + groups: Vec>, + group_of: HashMap<*const MethodMeta, usize>, + aliases: Vec>, +} + +impl<'a> ScopePlan<'a> { + /// Walk members in generator order, yielding each method group once, at its + /// first method. + pub(crate) fn members<'p>( + &'p self, + methods: impl IntoIterator, + ) -> Vec> { + let mut emitted = HashSet::new(); + let mut members = Vec::new(); + for (interface, method) in methods { + match self.group_of.get(&(method as *const MethodMeta)) { + Some(&index) => { + if emitted.insert(index) { + members.push(PlannedMember::Group(&self.groups[index])); + } + } + None => { + assert!( + is_accessor(method), + "method {} is not part of this member plan", + method.name + ); + members.push(PlannedMember::Accessor(interface, method)); + } + } + } + members + } + + /// The attribute implementing `method`, or `None` for accessors. + pub(crate) fn attribute(&self, method: &MethodMeta) -> Option<&str> { + let group = &self.groups[*self.group_of.get(&(method as *const MethodMeta))?]; + group + .candidates + .iter() + .find(|candidate| std::ptr::eq(candidate.method, method)) + .map(|candidate| candidate.attribute.as_str()) + } + + /// Previously emitted names kept as aliases, sorted by name. + pub(crate) fn aliases(&self) -> &[Alias<'a>] { + &self.aliases + } +} + +/// Member plans for a runtime class; its static and instance members share +/// one Python class namespace. +pub(crate) struct ClassMemberPlan<'a> { + pub(crate) statics: ScopePlan<'a>, + pub(crate) instance: ScopePlan<'a>, +} + +impl<'a> ClassMemberPlan<'a> { + pub(crate) fn new(class: &'a ClassMeta) -> Self { + let statics = class + .factory_interfaces + .iter() + .chain(class.static_interfaces.iter()) + .collect(); + let instance = class_instance_interfaces(class).collect(); + let mut scopes = plan_scopes(&[statics, instance]).into_iter(); + Self { + statics: scopes.next().expect("static scope"), + instance: scopes.next().expect("instance scope"), + } + } +} + +/// Member plan for an interface wrapper class. +pub(crate) fn interface_member_plan(interface: &InterfaceMeta) -> ScopePlan<'_> { + plan_scopes(&[vec![interface]]) + .pop() + .expect("interface scope") +} + +/// Interfaces whose methods are projected as instance members of a runtime class. +pub(crate) fn class_instance_interfaces(class: &ClassMeta) -> impl Iterator { + class + .default_interface + .iter() + .chain(class.required_interfaces.iter()) + .filter(|interface| interface.iid != ICLOSABLE_IID) +} + +pub(crate) fn is_accessor(method: &MethodMeta) -> bool { + method.is_property_getter + || method.is_property_setter + || method.is_event_add + || method.is_event_remove +} + +/// Snake-case ABI name of a method. +fn abi_name(method: &MethodMeta) -> String { + to_snake_case(&method.name) +} + +/// Merge `foo2`, `foo_overload...`, and `foo_with_options` into an existing `foo`. +fn suffix_group_key(name: &str, names: &HashSet) -> String { + let mut candidates = Vec::new(); + if let Some((base, _)) = name.split_once("_overload") { + candidates.push(base); + } + if let Some(base) = name.strip_suffix("_with_options") { + candidates.push(base); + } + let numeric_base = name.trim_end_matches(|character: char| character.is_ascii_digit()); + if numeric_base.len() < name.len() { + candidates.push(numeric_base); + } + candidates + .into_iter() + .find(|base| !base.is_empty() && names.contains(*base)) + .map(str::to_string) + .unwrap_or_else(|| name.to_string()) +} + +pub(crate) fn private_overload_names<'a>( + public_name: &str, + methods: impl IntoIterator, +) -> Vec { + let base_names = methods + .into_iter() + .map(|method| format!("_{public_name}_{}", method.vtable_index)) + .collect::>(); + base_names + .iter() + .enumerate() + .map(|(index, base)| { + if base_names + .iter() + .filter(|candidate| *candidate == base) + .count() + > 1 + { + format!("{base}_{index}") + } else { + base.clone() + } + }) + .collect() +} + +/// Plan scopes that share one Python class namespace. +fn plan_scopes<'a>(scopes: &[Vec<&'a InterfaceMeta>]) -> Vec> { + scopes + .iter() + .map(|interfaces| { + let methods = interfaces + .iter() + .flat_map(|interface| { + interface + .methods + .iter() + .map(move |method| (*interface, method)) + }) + .filter(|(_, method)| !is_accessor(method)) + .collect::>(); + let names = methods + .iter() + .map(|(_, method)| abi_name(method)) + .collect::>(); + let keys = methods + .iter() + .map(|(_, method)| suffix_group_key(&abi_name(method), &names)) + .collect::>(); + build_scope(&methods, &keys) + }) + .collect() +} + +/// Build a scope plan from methods (in scope order) and their group keys. +fn build_scope<'a>( + methods: &[(&'a InterfaceMeta, &'a MethodMeta)], + keys: &[String], +) -> ScopePlan<'a> { + let mut grouped: Vec<(String, Vec<(&'a InterfaceMeta, &'a MethodMeta)>)> = Vec::new(); + for (&(interface, method), key) in methods.iter().zip(keys) { + match grouped.iter_mut().find(|(name, _)| name == key) { + Some((_, members)) => members.push((interface, method)), + None => grouped.push((key.clone(), vec![(interface, method)])), + } + } + let mut groups = Vec::with_capacity(grouped.len()); + let mut group_of = HashMap::new(); + for (index, (name, mut members)) in grouped.into_iter().enumerate() { + members.sort_by(|(_, left), (_, right)| cmp_python_dispatch_methods(left, right)); + let attributes = if members.len() == 1 { + vec![name.clone()] + } else { + private_overload_names(&name, members.iter().map(|(_, method)| *method)) + }; + let candidates = members + .into_iter() + .zip(attributes) + .map(|((interface, method), attribute)| { + group_of.insert(method as *const MethodMeta, index); + Candidate { + interface, + method, + attribute, + } + }) + .collect(); + groups.push(MethodGroup { name, candidates }); + } + + let canonical = keys.iter().collect::>(); + let mut targets = BTreeMap::new(); + for (&(_, method), key) in methods.iter().zip(keys) { + let name = abi_name(method); + if &name != key && !canonical.contains(&name) { + targets.entry(name).or_insert_with(|| key.clone()); + } + } + let aliases = targets + .into_iter() + .map(|(name, target)| Alias { + signatures: methods + .iter() + .filter(|(_, method)| abi_name(method) == name) + .map(|(_, method)| *method) + .collect(), + name, + target, + }) + .collect(); + ScopePlan { + groups, + group_of, + aliases, + } +} + +pub(crate) fn cmp_python_dispatch_methods(left: &MethodMeta, right: &MethodMeta) -> Ordering { + cmp_python_dispatch_params(&get_in_params(left), &get_in_params(right)) + .then_with(|| left.raw_name.cmp(&right.raw_name)) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.vtable_index.cmp(&right.vtable_index)) +} + +pub(crate) fn cmp_python_dispatch_params(left: &[&ParamMeta], right: &[&ParamMeta]) -> Ordering { + let sort_key = |params: &[&ParamMeta]| { + params + .iter() + .map(|param| py_dispatch_type_sort_key(¶m.typ)) + .collect::>() + }; + sort_key(left).cmp(&sort_key(right)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::meta::{ParamDirection, ParamMeta}; + use crate::types::TypeMeta; + + fn method(name: &str, vtable_index: usize, typ: TypeMeta) -> MethodMeta { + MethodMeta { + name: name.into(), + raw_name: name.into(), + vtable_index, + params: vec![ParamMeta { + name: "value".into(), + typ, + direction: ParamDirection::In, + }], + ..Default::default() + } + } + + fn interface(methods: Vec) -> InterfaceMeta { + InterfaceMeta { + name: "IWidget".into(), + namespace: "Contoso".into(), + methods, + ..Default::default() + } + } + + fn group_names(plan: &ScopePlan<'_>, interface: &InterfaceMeta) -> Vec { + plan.members(interface.methods.iter().map(|method| (interface, method))) + .into_iter() + .map(|member| match member { + PlannedMember::Group(group) => group.name.clone(), + PlannedMember::Accessor(_, method) => method.name.clone(), + }) + .collect() + } + + #[test] + fn python_numeric_overload_method_cmp_prefers_narrower_and_signed_ranges() { + let i8 = method("Read", 6, TypeMeta::I8); + let u8 = method("Read2", 7, TypeMeta::U8); + let i16 = method("Read3", 8, TypeMeta::I16); + + assert_eq!(cmp_python_dispatch_methods(&i8, &i16), Ordering::Less); + assert_eq!(cmp_python_dispatch_methods(&i8, &u8), Ordering::Less); + } + + #[test] + fn python_numeric_overload_method_cmp_prefers_char16_integer_and_f64() { + let char16 = method("Pick", 6, TypeMeta::Char16); + let string = method("Pick2", 7, TypeMeta::String); + let int = method("Pick3", 8, TypeMeta::I32); + let f64 = method("Pick4", 9, TypeMeta::F64); + let f32 = method("Pick5", 10, TypeMeta::F32); + + assert_eq!( + cmp_python_dispatch_methods(&char16, &string), + Ordering::Less + ); + assert_eq!(cmp_python_dispatch_methods(&int, &f64), Ordering::Less); + assert_eq!(cmp_python_dispatch_methods(&f64, &f32), Ordering::Less); + } + + #[test] + fn python_overload_suffixes_merge_only_when_base_method_exists() { + let widget = interface(vec![ + method("CreateFileAsync", 6, TypeMeta::String), + method("CreateFileAsyncOverloadDefaultOptions", 7, TypeMeta::String), + method("RunEventLoopWithOptions", 8, TypeMeta::String), + ]); + let plan = interface_member_plan(&widget); + + assert_eq!( + group_names(&plan, &widget), + ["create_file_async", "run_event_loop_with_options"] + ); + let aliases = plan + .aliases() + .iter() + .map(|alias| (alias.name.as_str(), alias.target.as_str())) + .collect::>(); + assert_eq!( + aliases, + [( + "create_file_async_overload_default_options", + "create_file_async" + )] + ); + assert_eq!( + plan.attribute(&widget.methods[1]), + Some("_create_file_async_7") + ); + assert_eq!( + plan.attribute(&widget.methods[2]), + Some("run_event_loop_with_options") + ); + } + + #[test] + fn plan_orders_candidates_for_dispatch_and_names_private_overloads() { + let first = interface(vec![method("Register", 6, TypeMeta::String)]); + let second = interface(vec![method("Register", 6, TypeMeta::I32)]); + let widget = interface(vec![ + method("Read2", 7, TypeMeta::F64), + method("Read", 6, TypeMeta::I8), + ]); + let mut plans = plan_scopes(&[vec![&first, &second], vec![&widget]]).into_iter(); + let registered = plans.next().unwrap(); + let read = plans.next().unwrap(); + + let PlannedMember::Group(group) = + ®istered.members([(&first, &first.methods[0]), (&second, &second.methods[0])])[0] + else { + panic!("expected a method group"); + }; + let attributes = group + .candidates + .iter() + .map(|candidate| candidate.attribute.as_str()) + .collect::>(); + assert_eq!(attributes, ["_register_6_0", "_register_6_1"]); + assert_eq!(group.candidates[0].method.params[0].typ, TypeMeta::String); + + let PlannedMember::Group(group) = &read.members([(&widget, &widget.methods[0])])[0] else { + panic!("expected a method group"); + }; + assert_eq!(group.name, "read"); + let order = group + .candidates + .iter() + .map(|candidate| candidate.method.name.as_str()) + .collect::>(); + assert_eq!(order, ["Read", "Read2"]); + } +} diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index 3cb9aea5..c052a9d6 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -9,6 +9,7 @@ use crate::codegen::winrt::shared::imports::{ fill_array_output_index, fill_array_uses_retval_count, get_in_params, }; +use super::member_plan::{Candidate, MethodGroup}; use super::naming::{PythonProjectionContext, PythonTypeIdentity, to_snake_case}; use super::signature::{ py_convert_return, py_runtime_named_symbol, py_runtime_symbol, py_type_guard, py_wrap_arg, @@ -159,6 +160,102 @@ pub(crate) fn py_method_type_guard( py_type_guard(name, typ, context) } +/// Dispatch guard for one bound argument of an overload candidate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ParamGuard { + /// Exact guard used by the first dispatch pass. + pub(crate) strict: String, + /// Broader guard for a second pass that runs only after every candidate's + /// strict guards failed. `None` keeps the strict guard. + pub(crate) permissive: Option, +} + +pub(crate) fn param_guard( + name: &str, + typ: &TypeMeta, + context: &PythonProjectionContext, +) -> ParamGuard { + ParamGuard { + strict: py_method_type_guard(name, typ, context), + permissive: None, + } +} + +/// One candidate of a generated `*args, **kwargs` overload dispatcher. +pub(crate) struct DispatchCandidate<'a> { + /// Python-visible input parameters, in call order. + pub(crate) params: Vec<&'a crate::meta::ParamMeta>, + /// Statements run when the candidate matches, relative to its `if` block. + pub(crate) body: Vec, +} + +/// Emit argument binding and guards for an overload dispatcher. +/// +/// The first pass tries every candidate, in order, with its strict guards. +/// Candidates with permissive guards are retried in a second pass that runs +/// only after the first pass matched nothing, so a permissive guard can never +/// change which overload an already-matching call reaches. +pub(crate) fn emit_dispatch( + out: &mut String, + indent: &str, + candidates: &[DispatchCandidate<'_>], + context: &PythonProjectionContext, +) { + let guards = candidates + .iter() + .map(|candidate| { + candidate + .params + .iter() + .enumerate() + .map(|(index, param)| param_guard(&format!("_bound[{index}]"), ¶m.typ, context)) + .collect::>() + }) + .collect::>(); + for (candidate, guards) in candidates.iter().zip(&guards) { + let strict = guards.iter().map(|guard| guard.strict.as_str()); + emit_dispatch_candidate(out, indent, candidate, strict); + } + for (candidate, guards) in candidates.iter().zip(&guards) { + if guards.iter().any(|guard| guard.permissive.is_some()) { + let permissive = guards + .iter() + .map(|guard| guard.permissive.as_deref().unwrap_or(&guard.strict)); + emit_dispatch_candidate(out, indent, candidate, permissive); + } + } +} + +fn emit_dispatch_candidate<'g>( + out: &mut String, + indent: &str, + candidate: &DispatchCandidate<'_>, + guards: impl Iterator, +) { + let parameter_names = candidate + .params + .iter() + .map(|param| format!("'{}'", to_snake_case(¶m.name))) + .collect::>() + .join(", "); + let parameter_names = if parameter_names.is_empty() { + "()".to_string() + } else { + format!("({parameter_names},)") + }; + out.push_str(&format!( + "{indent}_bound = _dynwinrt_bind_overload({parameter_names}, args, kwargs)\n" + )); + let condition = std::iter::once("_bound is not None") + .chain(guards) + .collect::>() + .join(" and "); + out.push_str(&format!("{indent}if {condition}:\n")); + for line in &candidate.body { + out.push_str(&format!("{indent} {line}\n")); + } +} + fn convert_method_output(expr: &str, typ: &TypeMeta, context: &PythonProjectionContext) -> String { if let Some(converter) = delegate_value_converter(typ, context) { return format!("({converter})({expr})"); @@ -263,15 +360,6 @@ fn emit_method_result( // ====================================================================== // Method generation — Python call pattern -pub(crate) fn generate_factory_method_invoke( - class: &ClassMeta, - iface: &InterfaceMeta, - method: &MethodMeta, - context: &PythonProjectionContext, -) -> String { - generate_factory_method_invoke_named(class, iface, method, context, None) -} - fn generate_factory_method_invoke_named( class: &ClassMeta, iface: &InterfaceMeta, @@ -424,111 +512,63 @@ pub(crate) struct InstanceOverload<'a> { pub(crate) property_has_getter: bool, } -pub(crate) fn private_overload_names<'a>( - public_name: &str, - methods: impl IntoIterator, -) -> Vec { - let base_names = methods - .into_iter() - .map(|method| format!("_{public_name}_{}", method.vtable_index)) - .collect::>(); - base_names - .iter() - .enumerate() - .map(|(index, base)| { - if base_names - .iter() - .filter(|candidate| *candidate == base) - .count() - > 1 - { - format!("{base}_{index}") - } else { - base.clone() - } - }) - .collect() +/// Render an instance accessor (property or event method). +pub(crate) fn generate_instance_accessor( + overload: &InstanceOverload<'_>, + context: &PythonProjectionContext, +) -> String { + generate_method_body( + &overload.iface_var, + &overload.obj_expr, + overload.method, + context, + None, + overload.sibling_methods, + overload.property_has_getter, + ) } -pub(crate) fn generate_instance_method_group( - overloads: &[InstanceOverload<'_>], +/// Render a planned instance method group; `overload` supplies each candidate's +/// interface binding. +pub(crate) fn generate_instance_method_group<'a>( + group: &MethodGroup<'a>, + overload: impl Fn(&Candidate<'a>) -> InstanceOverload<'a>, context: &PythonProjectionContext, ) -> String { - if overloads.len() == 1 { - let overload = &overloads[0]; - return generate_method_body( - &overload.iface_var, - &overload.obj_expr, - overload.method, - context, - None, - overload.sibling_methods, - overload.property_has_getter, - ); - } - - let mut ordered_overloads = overloads.iter().collect::>(); - ordered_overloads.sort_by(|left, right| { - super::overloads::cmp_python_dispatch_methods(left.method, right.method) - }); - - let overload_names = - super::overloads::method_names(ordered_overloads.iter().map(|overload| overload.method)); - let public_name = - super::overloads::method_group_key(ordered_overloads[0].method, &overload_names); + let overloads = group + .candidates + .iter() + .map(|candidate| (overload(candidate), candidate.attribute.as_str())) + .collect::>(); let mut out = String::new(); - let private_names = private_overload_names( - &public_name, - ordered_overloads.iter().map(|overload| overload.method), - ); - for (overload, private_name) in ordered_overloads.iter().zip(&private_names) { + for (overload, attribute) in &overloads { out.push_str(&generate_method_body( &overload.iface_var, &overload.obj_expr, overload.method, context, - Some(private_name), + Some(attribute), overload.sibling_methods, overload.property_has_getter, )); + if overloads.len() == 1 { + return out; + } out.push('\n'); } + let public_name = &group.name; out.push_str(&format!(" def {public_name}(self, *args, **kwargs):\n")); - let public_params = get_in_params(ordered_overloads[0].method); - out.push_str(&method_pydoc(ordered_overloads[0].method, &public_params)); - for (overload, private_name) in ordered_overloads.iter().zip(private_names) { - let in_params = get_in_params(overload.method); - let parameter_names = in_params - .iter() - .map(|param| format!("'{}'", to_snake_case(¶m.name))) - .collect::>() - .join(", "); - let parameter_names = if parameter_names.is_empty() { - "()".to_string() - } else { - format!("({parameter_names},)") - }; - out.push_str(&format!( - " _bound = _dynwinrt_bind_overload({}, args, kwargs)\n", - parameter_names - )); - let guards = in_params - .iter() - .enumerate() - .map(|(index, param)| { - py_method_type_guard(&format!("_bound[{index}]"), ¶m.typ, context) - }) - .collect::>(); - let condition = if guards.is_empty() { - "_bound is not None".to_string() - } else { - format!("_bound is not None and {}", guards.join(" and ")) - }; - out.push_str(&format!( - " if {condition}:\n return self.{private_name}(*_bound)\n" - )); - } + let public_params = get_in_params(group.candidates[0].method); + out.push_str(&method_pydoc(group.candidates[0].method, &public_params)); + let candidates = overloads + .iter() + .map(|(overload, attribute)| DispatchCandidate { + params: get_in_params(overload.method), + body: vec![format!("return self.{attribute}(*_bound)")], + }) + .collect::>(); + emit_dispatch(&mut out, " ", &candidates, context); out.push_str(&format!( " raise TypeError(\"No matching overload for {public_name}\")\n" )); @@ -548,99 +588,77 @@ pub(crate) struct StaticOverload<'a> { pub(crate) kind: StaticOverloadKind, } -pub(crate) fn generate_static_method_group( - overloads: &[StaticOverload<'_>], +/// Render a static accessor (property or event method). +pub(crate) fn generate_static_accessor( + overload: &StaticOverload<'_>, context: &PythonProjectionContext, ) -> String { - if overloads.len() == 1 { - let overload = &overloads[0]; - return match overload.kind { - StaticOverloadKind::Factory => generate_factory_method_invoke( - overload.class, - overload.iface, - overload.method, - context, - ), - StaticOverloadKind::Static => generate_static_method_invoke( - overload.class, - overload.iface, - overload.method, - context, - ), - }; + match overload.kind { + StaticOverloadKind::Factory => generate_factory_method_invoke_named( + overload.class, + overload.iface, + overload.method, + context, + None, + ), + StaticOverloadKind::Static => { + generate_static_method_invoke(overload.class, overload.iface, overload.method, context) + } } +} - let mut ordered_overloads = overloads.iter().collect::>(); - ordered_overloads.sort_by(|left, right| { - super::overloads::cmp_python_dispatch_methods(left.method, right.method) - }); - - let overload_names = - super::overloads::method_names(ordered_overloads.iter().map(|overload| overload.method)); - let public_name = - super::overloads::method_group_key(ordered_overloads[0].method, &overload_names); +/// Render a planned static or factory method group; `overload` supplies each +/// candidate's class binding. +pub(crate) fn generate_static_method_group<'a>( + group: &MethodGroup<'a>, + overload: impl Fn(&Candidate<'a>) -> StaticOverload<'a>, + context: &PythonProjectionContext, +) -> String { + let overloads = group + .candidates + .iter() + .map(|candidate| (overload(candidate), candidate.attribute.as_str())) + .collect::>(); let mut out = String::new(); - let private_names = private_overload_names( - &public_name, - ordered_overloads.iter().map(|overload| overload.method), - ); - for (overload, private_name) in ordered_overloads.iter().zip(&private_names) { - let code = match overload.kind { + for (overload, attribute) in &overloads { + out.push_str(&match overload.kind { StaticOverloadKind::Factory => generate_factory_method_invoke_named( overload.class, overload.iface, overload.method, context, - Some(private_name), + Some(attribute), ), StaticOverloadKind::Static => generate_static_method_invoke_named( overload.class, overload.iface, overload.method, context, - Some(private_name), + Some(attribute), ), - }; - out.push_str(&code); + }); + if overloads.len() == 1 { + return out; + } out.push('\n'); } + let public_name = &group.name; out.push_str(" @staticmethod\n"); out.push_str(&format!(" def {public_name}(*args, **kwargs):\n")); - let public_params = get_in_params(ordered_overloads[0].method); - out.push_str(&method_pydoc(ordered_overloads[0].method, &public_params)); - for (overload, private_name) in ordered_overloads.iter().zip(private_names) { - let in_params = get_in_params(overload.method); - let parameter_names = in_params - .iter() - .map(|param| format!("'{}'", to_snake_case(¶m.name))) - .collect::>() - .join(", "); - let parameter_names = if parameter_names.is_empty() { - "()".to_string() - } else { - format!("({parameter_names},)") - }; - out.push_str(&format!( - " _bound = _dynwinrt_bind_overload({parameter_names}, args, kwargs)\n" - )); - let guards = in_params - .iter() - .enumerate() - .map(|(index, param)| { - py_method_type_guard(&format!("_bound[{index}]"), ¶m.typ, context) - }) - .collect::>(); - let condition = if guards.is_empty() { - "_bound is not None".to_string() - } else { - format!("_bound is not None and {}", guards.join(" and ")) - }; - out.push_str(&format!( - " if {condition}:\n return {}.{private_name}(*_bound)\n", - context.class_name(overload.class) - )); - } + let public_params = get_in_params(group.candidates[0].method); + out.push_str(&method_pydoc(group.candidates[0].method, &public_params)); + let candidates = overloads + .iter() + .map(|(overload, attribute)| DispatchCandidate { + params: get_in_params(overload.method), + body: vec![format!( + "return {}.{attribute}(*_bound)", + context.class_name(overload.class) + )], + }) + .collect::>(); + emit_dispatch(&mut out, " ", &candidates, context); out.push_str(&format!( " raise TypeError(\"No matching overload for {public_name}\")\n" )); @@ -857,6 +875,9 @@ pub(crate) fn generate_method_body( #[cfg(test)] mod tests { use super::*; + use crate::codegen::winrt::python::member_plan::{ + ClassMemberPlan, PlannedMember, class_instance_interfaces, + }; use crate::meta::{ParamDirection, ParamMeta}; use std::process::Command; @@ -874,56 +895,99 @@ mod tests { } } - fn instance_overload(method: &MethodMeta) -> InstanceOverload<'_> { - InstanceOverload { - iface_var: "_IReader".into(), - obj_expr: "self._obj".into(), - method, - sibling_methods: None, - property_has_getter: true, + fn interface(name: &str, methods: &[&MethodMeta]) -> InterfaceMeta { + InterfaceMeta { + name: name.into(), + namespace: "Contoso".into(), + methods: methods.iter().map(|method| (*method).clone()).collect(), + ..Default::default() } } - #[test] - fn overloads_with_the_same_vtable_slot_get_unique_private_names() { - let first = overloaded_method("Register", 6, TypeMeta::String); - let second = overloaded_method("Register", 6, TypeMeta::I32); - let overloads = [ - InstanceOverload { - iface_var: "_IFirst".into(), + /// Plan `interfaces` as the instance members of a class and render the first + /// method group. + fn instance_group_on( + interfaces: &[(&str, &[&MethodMeta])], + context: &PythonProjectionContext, + ) -> String { + let class = ClassMeta { + name: "Reader".into(), + required_interfaces: interfaces + .iter() + .map(|(name, methods)| interface(name, methods)) + .collect(), + ..Default::default() + }; + let plan = ClassMemberPlan::new(&class); + let members = class_instance_interfaces(&class) + .flat_map(|iface| iface.methods.iter().map(move |method| (iface, method))); + let Some(PlannedMember::Group(group)) = plan.instance.members(members).into_iter().next() + else { + panic!("expected a method group"); + }; + generate_instance_method_group( + group, + |candidate| InstanceOverload { + iface_var: format!("_{}", candidate.interface.name), obj_expr: "self._obj".into(), - method: &first, + method: candidate.method, sibling_methods: None, property_has_getter: true, }, - InstanceOverload { - iface_var: "_ISecond".into(), - obj_expr: "self._obj".into(), - method: &second, - sibling_methods: None, - property_has_getter: true, + context, + ) + } + + fn instance_group(methods: &[&MethodMeta], context: &PythonProjectionContext) -> String { + instance_group_on(&[("IReader", methods)], context) + } + + /// Plan `methods` as the statics of `class_name` and render the first method group. + fn static_group( + class_name: &str, + methods: &[&MethodMeta], + context: &PythonProjectionContext, + ) -> String { + let class = ClassMeta { + name: class_name.into(), + static_interfaces: vec![interface("IFactoryStatics", methods)], + ..Default::default() + }; + let plan = ClassMemberPlan::new(&class); + let members = class + .static_interfaces + .iter() + .flat_map(|iface| iface.methods.iter().map(move |method| (iface, method))); + let Some(PlannedMember::Group(group)) = plan.statics.members(members).into_iter().next() + else { + panic!("expected a method group"); + }; + generate_static_method_group( + group, + |candidate| StaticOverload { + class: &class, + iface: candidate.interface, + method: candidate.method, + kind: StaticOverloadKind::Static, }, - ]; + context, + ) + } + + #[test] + fn overloads_with_the_same_vtable_slot_get_unique_private_names() { + let first = overloaded_method("Register", 6, TypeMeta::String); + let second = overloaded_method("Register", 6, TypeMeta::I32); - let code = generate_instance_method_group(&overloads, &PythonProjectionContext::default()); + let code = instance_group_on( + &[("IFirst", &[&first]), ("ISecond", &[&second])], + &PythonProjectionContext::default(), + ); assert_eq!(code.matches("def _register_6_").count(), 2, "{code}"); assert!(code.contains("self._register_6_0(*_bound)"), "{code}"); assert!(code.contains("self._register_6_1(*_bound)"), "{code}"); } - fn static_overload<'a>( - class: &'a ClassMeta, - iface: &'a InterfaceMeta, - method: &'a MethodMeta, - ) -> StaticOverload<'a> { - StaticOverload { - class, - iface, - method, - kind: StaticOverloadKind::Static, - } - } - fn enum_type(name: &str, is_flags: bool) -> TypeMeta { TypeMeta::Enum { namespace: "Contoso".into(), @@ -1111,24 +1175,7 @@ mod tests { }], ..Default::default() }; - let overloads = vec![ - InstanceOverload { - iface_var: "_IReader".into(), - obj_expr: "self._obj".into(), - method: &first, - sibling_methods: None, - property_has_getter: true, - }, - InstanceOverload { - iface_var: "_IReader".into(), - obj_expr: "self._obj".into(), - method: &second, - sibling_methods: None, - property_has_getter: true, - }, - ]; - - let code = generate_instance_method_group(&overloads, &PythonProjectionContext::default()); + let code = instance_group(&[&first, &second], &PythonProjectionContext::default()); assert!(code.contains("def _read_6(self, value: str)")); assert!(code.contains("def _read_7(self, value: int)")); assert!(code.contains("def read(self, *args, **kwargs)")); @@ -1166,26 +1213,9 @@ mod tests { }], ..Default::default() }; - let overloads = vec![ - InstanceOverload { - iface_var: "_IRunner".into(), - obj_expr: "self._obj".into(), - method: &callback, - sibling_methods: None, - property_has_getter: true, - }, - InstanceOverload { - iface_var: "_IRunner".into(), - obj_expr: "self._obj".into(), - method: &text, - sibling_methods: None, - property_has_getter: true, - }, - ]; - let context = PythonProjectionContext::standalone([callback.params[0].typ.type_identity()]).unwrap(); - let code = generate_instance_method_group(&overloads, &context); + let code = instance_group_on(&[("IRunner", &[&callback, &text])], &context); assert!(code.contains("callable(_bound[0])")); assert!(code.contains("_dynwinrt_delegate(handler,")); assert!(code.contains("'work_item_handler', 'IID_WorkItemHandler'")); @@ -1302,14 +1332,6 @@ mod tests { #[test] fn static_overloads_generate_one_dispatcher() { - let class = ClassMeta { - name: "Factory".into(), - ..Default::default() - }; - let iface = InterfaceMeta { - name: "IFactoryStatics".into(), - ..Default::default() - }; let first = MethodMeta { name: "Create".into(), raw_name: "Create".into(), @@ -1327,22 +1349,11 @@ mod tests { }], ..Default::default() }; - let overloads = vec![ - StaticOverload { - class: &class, - iface: &iface, - method: &first, - kind: StaticOverloadKind::Static, - }, - StaticOverload { - class: &class, - iface: &iface, - method: &second, - kind: StaticOverloadKind::Static, - }, - ]; - - let code = generate_static_method_group(&overloads, &PythonProjectionContext::default()); + let code = static_group( + "Factory", + &[&first, &second], + &PythonProjectionContext::default(), + ); assert!(code.contains("def _create_6()")); assert!(code.contains("def _create_7(value: str)")); assert!(code.contains("def create(*args, **kwargs)")); @@ -1353,14 +1364,8 @@ mod tests { let wide = overloaded_method("Read2", 7, TypeMeta::I32); let narrow = overloaded_method("Read", 6, TypeMeta::I8); - let forward = generate_instance_method_group( - &[instance_overload(&wide), instance_overload(&narrow)], - &PythonProjectionContext::default(), - ); - let reverse = generate_instance_method_group( - &[instance_overload(&narrow), instance_overload(&wide)], - &PythonProjectionContext::default(), - ); + let forward = instance_group(&[&wide, &narrow], &PythonProjectionContext::default()); + let reverse = instance_group(&[&narrow, &wide], &PythonProjectionContext::default()); assert_eq!(forward, reverse); assert_contains_in_order( @@ -1378,16 +1383,10 @@ mod tests { let char16 = overloaded_method("Pick3", 8, TypeMeta::Char16); let boolean = overloaded_method("Pick2", 7, TypeMeta::Bool); let signed = overloaded_method("Pick", 6, TypeMeta::I8); - let overloads = vec![ - instance_overload(&float), - instance_overload(&unsigned), - instance_overload(&string), - instance_overload(&char16), - instance_overload(&boolean), - instance_overload(&signed), - ]; - - let code = generate_instance_method_group(&overloads, &PythonProjectionContext::default()); + let code = instance_group( + &[&float, &unsigned, &string, &char16, &boolean, &signed], + &PythonProjectionContext::default(), + ); assert_contains_in_order( &code, @@ -1420,29 +1419,17 @@ mod tests { #[test] fn python_numeric_overload_static_dispatch_is_declaration_order_independent() { - let class = ClassMeta { - name: "Factory".into(), - ..Default::default() - }; - let iface = InterfaceMeta { - name: "IFactoryStatics".into(), - ..Default::default() - }; let integer = overloaded_method("Create", 6, TypeMeta::I16); let float = overloaded_method("Create2", 7, TypeMeta::F64); - let forward = generate_static_method_group( - &[ - static_overload(&class, &iface, &float), - static_overload(&class, &iface, &integer), - ], + let forward = static_group( + "Factory", + &[&float, &integer], &PythonProjectionContext::default(), ); - let reverse = generate_static_method_group( - &[ - static_overload(&class, &iface, &integer), - static_overload(&class, &iface, &float), - ], + let reverse = static_group( + "Factory", + &[&integer, &float], &PythonProjectionContext::default(), ); @@ -1462,14 +1449,8 @@ mod tests { PythonProjectionContext::standalone([enum_type("Mode", false).type_identity()]) .unwrap(); - let forward = generate_instance_method_group( - &[instance_overload(&integer), instance_overload(&enumeration)], - &context, - ); - let reverse = generate_instance_method_group( - &[instance_overload(&enumeration), instance_overload(&integer)], - &context, - ); + let forward = instance_group(&[&integer, &enumeration], &context); + let reverse = instance_group(&[&enumeration, &integer], &context); let forward_dispatcher = extract_generated_block(&forward, " def read(self, *args, **kwargs):\n"); @@ -1546,33 +1527,8 @@ print(json.dumps([exercise(ReaderForward), exercise(ReaderReverse)])) let context = PythonProjectionContext::standalone([enum_type("Options", true).type_identity()]) .unwrap(); - let iface = InterfaceMeta { - name: "IFactoryStatics".into(), - ..Default::default() - }; - let class_forward = ClassMeta { - name: "FactoryForward".into(), - ..Default::default() - }; - let class_reverse = ClassMeta { - name: "FactoryReverse".into(), - ..Default::default() - }; - - let forward = generate_static_method_group( - &[ - static_overload(&class_forward, &iface, &integer), - static_overload(&class_forward, &iface, &flags), - ], - &context, - ); - let reverse = generate_static_method_group( - &[ - static_overload(&class_reverse, &iface, &flags), - static_overload(&class_reverse, &iface, &integer), - ], - &context, - ); + let forward = static_group("FactoryForward", &[&integer, &flags], &context); + let reverse = static_group("FactoryReverse", &[&flags, &integer], &context); let forward_dispatcher = extract_generated_block( &forward, diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs index 84a04006..9610f7e9 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/mod.rs @@ -5,10 +5,10 @@ pub(crate) mod collections; mod docs; mod generator; mod implementation; +pub(crate) mod member_plan; pub(crate) mod method; pub(crate) mod naming; mod native_types; -pub(crate) mod overloads; mod shared; pub(crate) mod signature; pub(crate) mod structs; diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/overloads.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/overloads.rs deleted file mode 100644 index a0bd7990..00000000 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/overloads.rs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use crate::codegen::winrt::shared::imports::get_in_params; -use crate::meta::{MethodMeta, ParamMeta}; -use std::cmp::Ordering; -use std::collections::{BTreeMap, HashSet}; - -use super::naming::to_snake_case; -use super::signature::py_dispatch_type_sort_key; - -pub(crate) fn grouped_methods<'a>( - methods: impl IntoIterator, -) -> Vec> { - let methods = methods.into_iter().collect::>(); - let names = method_names(methods.iter().copied()); - let mut groups: Vec<(String, Vec<&MethodMeta>)> = Vec::new(); - for method in methods { - let key = method_group_key(method, &names); - if let Some((_, group)) = groups.iter_mut().find(|(name, _)| name == &key) { - group.push(method); - } else { - groups.push((key, vec![method])); - } - } - groups.into_iter().map(|(_, methods)| methods).collect() -} - -pub(crate) fn method_names<'a>( - methods: impl IntoIterator, -) -> HashSet { - methods - .into_iter() - .filter(|method| { - !method.is_property_getter - && !method.is_property_setter - && !method.is_event_add - && !method.is_event_remove - }) - .map(|method| to_snake_case(&method.name)) - .collect() -} - -pub(crate) fn method_group_key(method: &MethodMeta, names: &HashSet) -> String { - if method.is_property_getter - || method.is_property_setter - || method.is_event_add - || method.is_event_remove - { - format!("{}#{}", method.name, method.vtable_index) - } else { - let name = to_snake_case(&method.name); - let mut candidates = Vec::new(); - if let Some((base, _)) = name.split_once("_overload") { - candidates.push(base); - } - if let Some(base) = name.strip_suffix("_with_options") { - candidates.push(base); - } - let numeric_base = name.trim_end_matches(|character: char| character.is_ascii_digit()); - if numeric_base.len() < name.len() { - candidates.push(numeric_base); - } - candidates - .into_iter() - .find(|base| !base.is_empty() && names.contains(*base)) - .map(str::to_string) - .unwrap_or(name) - } -} - -pub(crate) fn compatibility_aliases<'a>( - methods: impl IntoIterator, -) -> Vec<(String, String)> { - let methods = methods.into_iter().collect::>(); - let names = method_names(methods.iter().copied()); - let canonical_names = methods - .iter() - .map(|method| method_group_key(method, &names)) - .collect::>(); - let mut aliases = BTreeMap::new(); - for method in methods { - if method.is_property_getter - || method.is_property_setter - || method.is_event_add - || method.is_event_remove - { - continue; - } - let legacy = to_snake_case(&method.name); - let canonical = method_group_key(method, &names); - if legacy != canonical && !canonical_names.contains(&legacy) { - aliases.entry(legacy).or_insert(canonical); - } - } - aliases.into_iter().collect() -} - -pub(crate) fn cmp_python_dispatch_methods(left: &MethodMeta, right: &MethodMeta) -> Ordering { - cmp_python_dispatch_params(&get_in_params(left), &get_in_params(right)) - .then_with(|| left.raw_name.cmp(&right.raw_name)) - .then_with(|| left.name.cmp(&right.name)) - .then_with(|| left.vtable_index.cmp(&right.vtable_index)) -} - -pub(crate) fn cmp_python_dispatch_params(left: &[&ParamMeta], right: &[&ParamMeta]) -> Ordering { - let sort_key = |params: &[&ParamMeta]| { - params - .iter() - .map(|param| py_dispatch_type_sort_key(¶m.typ)) - .collect::>() - }; - sort_key(left).cmp(&sort_key(right)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::meta::{ParamDirection, ParamMeta}; - use crate::types::TypeMeta; - - fn method(name: &str, vtable_index: usize, typ: TypeMeta) -> MethodMeta { - MethodMeta { - name: name.into(), - raw_name: name.into(), - vtable_index, - params: vec![ParamMeta { - name: "value".into(), - typ, - direction: ParamDirection::In, - }], - ..Default::default() - } - } - - #[test] - fn python_numeric_overload_method_cmp_prefers_narrower_and_signed_ranges() { - let i8 = method("Read", 6, TypeMeta::I8); - let u8 = method("Read2", 7, TypeMeta::U8); - let i16 = method("Read3", 8, TypeMeta::I16); - - assert_eq!(cmp_python_dispatch_methods(&i8, &i16), Ordering::Less); - assert_eq!(cmp_python_dispatch_methods(&i8, &u8), Ordering::Less); - } - - #[test] - fn python_numeric_overload_method_cmp_prefers_char16_integer_and_f64() { - let char16 = method("Pick", 6, TypeMeta::Char16); - let string = method("Pick2", 7, TypeMeta::String); - let int = method("Pick3", 8, TypeMeta::I32); - let f64 = method("Pick4", 9, TypeMeta::F64); - let f32 = method("Pick5", 10, TypeMeta::F32); - - assert_eq!( - cmp_python_dispatch_methods(&char16, &string), - Ordering::Less - ); - assert_eq!(cmp_python_dispatch_methods(&int, &f64), Ordering::Less); - assert_eq!(cmp_python_dispatch_methods(&f64, &f32), Ordering::Less); - } - - #[test] - fn python_overload_suffixes_merge_only_when_base_method_exists() { - let base = method("CreateFileAsync", 6, TypeMeta::String); - let default = method("CreateFileAsyncOverloadDefaultOptions", 7, TypeMeta::String); - let unrelated = method("RunEventLoopWithOptions", 8, TypeMeta::String); - let methods = [&base, &default, &unrelated]; - let names = method_names(methods); - - assert_eq!(method_group_key(&base, &names), "create_file_async"); - assert_eq!(method_group_key(&default, &names), "create_file_async"); - assert_eq!( - method_group_key(&unrelated, &names), - "run_event_loop_with_options" - ); - assert_eq!( - compatibility_aliases(methods), - vec![( - "create_file_async_overload_default_options".into(), - "create_file_async".into(), - )] - ); - } -} diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs index 0568bbf9..6b9b14b8 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs @@ -27,6 +27,10 @@ use crate::codegen::winrt::shared::structs::{ use super::collections::{ CollectionKind, abc_name, class_interface, interface_kind, observable_vector_identity, }; +use super::member_plan::{ + ClassMemberPlan, MethodGroup, PlannedMember, ScopePlan, class_instance_interfaces, + interface_member_plan, +}; use super::naming::{PythonProjectionContext, PythonSupportSymbol, is_py_reserved, to_snake_case}; use super::native_types::foundation_type; use super::shared::reorder_getters_before_setters; @@ -578,37 +582,46 @@ pub fn generate_interface_stub(context: &PythonProjectionContext, iface: &Interf )); } - for methods in super::overloads::grouped_methods(reorder_getters_before_setters(&iface.methods)) - { - let event_has_remove = methods.first().is_some_and(|method| { - method.name.strip_prefix("add_").is_some_and(|suffix| { - iface - .methods - .iter() - .any(|candidate| candidate.name == format!("remove_{suffix}")) - }) - }); - let property_has_getter = methods.first().is_none_or(|method| { - !method.is_property_setter - || method.name.strip_prefix("put_").is_some_and(|suffix| { + let plan = interface_member_plan(iface); + let members = reorder_getters_before_setters(&iface.methods) + .into_iter() + .map(|method| (iface, method)); + for member in plan.members(members) { + out.push('\n'); + out.push_str(&match member { + PlannedMember::Accessor(_, method) => { + let event_has_remove = method.name.strip_prefix("add_").is_some_and(|suffix| { iface .methods .iter() - .any(|candidate| candidate.name == format!("get_{suffix}")) - }) + .any(|candidate| candidate.name == format!("remove_{suffix}")) + }); + let property_has_getter = !method.is_property_setter + || method.name.strip_prefix("put_").is_some_and(|suffix| { + iface + .methods + .iter() + .any(|candidate| candidate.name == format!("get_{suffix}")) + }); + emit_method_stub( + method, + context, + 4, + event_has_remove, + property_has_getter, + collection_kind == Some(CollectionKind::MutableSequence), + ) + } + PlannedMember::Group(group) => emit_instance_stub_group( + group, + context, + 4, + collection_kind == Some(CollectionKind::MutableSequence), + ), }); - out.push('\n'); - out.push_str(&emit_instance_stub_group( - &methods, - context, - 4, - event_has_remove, - property_has_getter, - collection_kind == Some(CollectionKind::MutableSequence), - )); } out.push_str(&emit_instance_compatibility_alias_stubs( - iface.methods.iter(), + &plan, context, 4, collection_kind == Some(CollectionKind::MutableSequence), @@ -618,9 +631,9 @@ pub fn generate_interface_stub(context: &PythonProjectionContext, iface: &Interf } /// Generate a `.pyi` stub for a runtime class. -pub fn generate_class_stub( +pub fn generate_class_stub<'a>( context: &PythonProjectionContext, - class: &ClassMeta, + class: &'a ClassMeta, shared_iids: &HashSet, ) -> String { let used_structs = collect_used_structs_from_class(class); @@ -647,6 +660,7 @@ pub fn generate_class_stub( let projectable = super::has_projectable_default_interface(class); let native_projectable = super::has_native_projector(class); let supports_interface_projection = projectable || !class.required_interfaces.is_empty(); + let plan = ClassMemberPlan::new(class); let mut out = String::new(); out.push_str(HEADER); @@ -879,8 +893,14 @@ pub fn generate_class_stub( )), _ => None, }); - let mut instance_stub_body = - emit_class_instance_stubs(class, context, collection_iface, false, has_closable); + let mut instance_stub_body = emit_class_instance_stubs( + class, + context, + &plan.instance, + collection_iface, + false, + has_closable, + ); if crate::codegen::winrt::is_buffer_class(&class.namespace, &class.name) { instance_stub_body.push_str(" def to_bytes(self) -> bytes: ...\n"); } @@ -951,6 +971,7 @@ pub fn generate_class_stub( out.push_str(&emit_class_instance_stubs( class, context, + &plan.instance, collection_iface, collection_kind == Some(CollectionKind::MutableSequence), has_closable, @@ -979,28 +1000,29 @@ pub fn generate_class_stub( )); } - let static_methods = class + let static_members = class .factory_interfaces .iter() - .flat_map(|iface| iface.methods.iter().map(|method| (method, true))) - .chain( - class - .static_interfaces - .iter() - .flat_map(|iface| iface.methods.iter().map(|method| (method, false))), - ) - .collect::>(); - for group in grouped_static_stubs(&static_methods) { + .chain(class.static_interfaces.iter()) + .flat_map(|iface| iface.methods.iter().map(move |method| (iface, method))); + for member in plan.statics.members(static_members) { out.push('\n'); - out.push_str(&emit_static_stub_group( - &context.class_name(class), - &group, - context, - )); + out.push_str(&match member { + PlannedMember::Accessor(iface, method) => emit_static_method_stub( + &context.class_name(class), + method, + context, + is_factory_interface(class, iface), + ), + PlannedMember::Group(group) => { + emit_static_stub_group(&context.class_name(class), class, group, context) + } + }); } out.push_str(&emit_static_compatibility_alias_stubs( &context.class_name(class), - static_methods.iter().copied(), + class, + &plan.statics, context, 4, )); @@ -1089,38 +1111,46 @@ pub fn generate_class_stub( out.push_str( " def as_interface(self, interface_class: _DynWinRTProjector[_InterfaceT]) -> _InterfaceT: ...\n", ); - for methods in - super::overloads::grouped_methods(reorder_getters_before_setters(&req_iface.methods)) - { - let event_has_remove = methods.first().is_some_and(|method| { - method.name.strip_prefix("add_").is_some_and(|suffix| { - req_iface - .methods - .iter() - .any(|candidate| candidate.name == format!("remove_{suffix}")) - }) - }); - let property_has_getter = methods.first().is_none_or(|method| { - !method.is_property_setter - || method.name.strip_prefix("put_").is_some_and(|suffix| { + let iface_plan = interface_member_plan(req_iface); + let members = reorder_getters_before_setters(&req_iface.methods) + .into_iter() + .map(|method| (req_iface, method)); + for member in iface_plan.members(members) { + out.push('\n'); + out.push_str(&match member { + PlannedMember::Accessor(_, method) => { + let event_has_remove = method.name.strip_prefix("add_").is_some_and(|suffix| { req_iface .methods .iter() - .any(|candidate| candidate.name == format!("get_{suffix}")) - }) + .any(|candidate| candidate.name == format!("remove_{suffix}")) + }); + let property_has_getter = !method.is_property_setter + || method.name.strip_prefix("put_").is_some_and(|suffix| { + req_iface + .methods + .iter() + .any(|candidate| candidate.name == format!("get_{suffix}")) + }); + emit_method_stub( + method, + context, + 4, + event_has_remove, + property_has_getter, + interface_kind(req_iface) == Some(CollectionKind::MutableSequence), + ) + } + PlannedMember::Group(group) => emit_instance_stub_group( + group, + context, + 4, + interface_kind(req_iface) == Some(CollectionKind::MutableSequence), + ), }); - out.push('\n'); - out.push_str(&emit_instance_stub_group( - &methods, - context, - 4, - event_has_remove, - property_has_getter, - interface_kind(req_iface) == Some(CollectionKind::MutableSequence), - )); } out.push_str(&emit_instance_compatibility_alias_stubs( - req_iface.methods.iter(), + &iface_plan, context, 4, interface_kind(req_iface) == Some(CollectionKind::MutableSequence), @@ -1131,9 +1161,10 @@ pub fn generate_class_stub( out } -fn emit_class_instance_stubs( - class: &ClassMeta, +fn emit_class_instance_stubs<'a>( + class: &'a ClassMeta, context: &PythonProjectionContext, + plan: &ScopePlan<'a>, collection_iface: Option<&InterfaceMeta>, mutable_sequence_override: bool, has_closable: bool, @@ -1146,12 +1177,7 @@ fn emit_class_instance_stubs( out.push_str(&collection_protocol_stubs(collection_iface, context, 4)); } - let instance_ifaces = class - .default_interface - .iter() - .chain(class.required_interfaces.iter()) - .filter(|iface| iface.iid != "30d5a829-7fa4-4026-83bb-d75bae4ea99e") - .collect::>(); + let instance_ifaces = class_instance_interfaces(class).collect::>(); let paired_events = instance_ifaces .iter() .flat_map(|iface| { @@ -1174,12 +1200,16 @@ fn emit_class_instance_stubs( .collect::>(); let original_instance_methods = instance_ifaces .iter() - .flat_map(|iface| reorder_getters_before_setters(&iface.methods)) + .flat_map(|iface| { + reorder_getters_before_setters(&iface.methods) + .into_iter() + .map(move |method| (*iface, method)) + }) .collect::>(); let mut emitted = HashSet::<*const MethodMeta>::new(); let mut instance_methods = Vec::with_capacity(original_instance_methods.len()); - for method in &original_instance_methods { - if emitted.contains(&(*method as *const MethodMeta)) { + for &(iface, method) in &original_instance_methods { + if emitted.contains(&(method as *const MethodMeta)) { continue; } if method.is_property_setter @@ -1191,53 +1221,54 @@ fn emit_class_instance_stubs( continue; } - instance_methods.push(*method); - emitted.insert(*method as *const MethodMeta); + instance_methods.push((iface, method)); + emitted.insert(method as *const MethodMeta); if let Some(suffix) = method .is_property_getter .then(|| method.name.strip_prefix("get_")) .flatten() { - for setter in original_instance_methods - .iter() - .copied() - .filter(|candidate| { + for &(setter_iface, setter) in + original_instance_methods.iter().filter(|(_, candidate)| { candidate.is_property_setter && candidate.name.strip_prefix("put_") == Some(suffix) }) { if emitted.insert(setter as *const MethodMeta) { - instance_methods.push(setter); + instance_methods.push((setter_iface, setter)); } } } } - for methods in super::overloads::grouped_methods(instance_methods) { - let event_has_remove = methods.first().is_some_and(|method| { - method - .name - .strip_prefix("add_") - .is_some_and(|suffix| paired_events.contains(suffix)) - }); - let property_has_getter = methods.first().is_none_or(|method| { - !method.is_property_setter - || method + for member in plan.members(instance_methods) { + out.push('\n'); + out.push_str(&match member { + PlannedMember::Accessor(_, method) => { + let event_has_remove = method .name - .strip_prefix("put_") - .is_some_and(|suffix| property_getters.contains(suffix)) + .strip_prefix("add_") + .is_some_and(|suffix| paired_events.contains(suffix)); + let property_has_getter = !method.is_property_setter + || method + .name + .strip_prefix("put_") + .is_some_and(|suffix| property_getters.contains(suffix)); + emit_method_stub( + method, + context, + 4, + event_has_remove, + property_has_getter, + mutable_sequence_override, + ) + } + PlannedMember::Group(group) => { + emit_instance_stub_group(group, context, 4, mutable_sequence_override) + } }); - out.push('\n'); - out.push_str(&emit_instance_stub_group( - &methods, - context, - 4, - event_has_remove, - property_has_getter, - mutable_sequence_override, - )); } out.push_str(&emit_instance_compatibility_alias_stubs( - original_instance_methods.iter().copied(), + plan, context, 4, mutable_sequence_override, @@ -1455,7 +1486,7 @@ fn emit_constructor_stubs(class: &ClassMeta, context: &PythonProjectionContext) out.push_str(" def __init__(self, _not_constructible: NoReturn) -> None: ...\n"); return out; } - overloads.sort_by(|left, right| super::overloads::cmp_python_dispatch_params(left, right)); + overloads.sort_by(|left, right| super::member_plan::cmp_python_dispatch_params(left, right)); let count = overloads.len(); for params in &overloads { @@ -1532,74 +1563,53 @@ fn has_constructor_stub_overload(class: &ClassMeta) -> bool { } fn emit_instance_stub_group( - methods: &[&MethodMeta], + group: &MethodGroup<'_>, context: &PythonProjectionContext, indent_spaces: usize, - event_has_remove: bool, - property_has_getter: bool, overrides_mutable_sequence: bool, ) -> String { - let mut ordered_methods = methods.iter().copied().collect::>(); - ordered_methods - .sort_by(|left, right| super::overloads::cmp_python_dispatch_methods(left, right)); - - if ordered_methods.len() == 1 { - return emit_method_stub( - ordered_methods[0], - context, - indent_spaces, - event_has_remove, - property_has_getter, - overrides_mutable_sequence, - ); - } - let names = super::overloads::method_names(ordered_methods.iter().copied()); - let public_name = super::overloads::method_group_key(ordered_methods[0], &names); let indent = " ".repeat(indent_spaces); - ordered_methods + let overloaded = group.candidates.len() > 1; + group + .candidates .iter() - .map(|method| { - format!( - "{indent}@overload\n{}", - emit_method_stub_named( - method, - context, - indent_spaces, - Some(&public_name), - event_has_remove, - property_has_getter, - overrides_mutable_sequence, - ) - ) + .map(|candidate| { + let stub = emit_method_stub_named( + candidate.method, + context, + indent_spaces, + Some(&group.name), + false, + true, + overrides_mutable_sequence, + ); + if overloaded { + format!("{indent}@overload\n{stub}") + } else { + stub + } }) .collect() } -fn emit_instance_compatibility_alias_stubs<'a>( - methods: impl IntoIterator, +fn emit_instance_compatibility_alias_stubs( + plan: &ScopePlan<'_>, context: &PythonProjectionContext, indent_spaces: usize, overrides_mutable_sequence: bool, ) -> String { - let methods = methods.into_iter().collect::>(); - let aliases = super::overloads::compatibility_aliases(methods.iter().copied()); let indent = " ".repeat(indent_spaces); let mut out = String::new(); - for (legacy, _) in aliases { - let matching = methods - .iter() - .copied() - .filter(|method| to_snake_case(&method.name) == legacy) - .collect::>(); - for method in &matching { - if matching.len() > 1 { + for alias in plan.aliases() { + for method in &alias.signatures { + if alias.signatures.len() > 1 { out.push_str(&format!("{indent}@overload\n")); } out.push_str(&emit_method_stub_named( method, context, indent_spaces, - Some(&legacy), + Some(&alias.name), false, true, overrides_mutable_sequence, @@ -1609,83 +1619,70 @@ fn emit_instance_compatibility_alias_stubs<'a>( out } -fn emit_static_compatibility_alias_stubs<'a>( +fn is_factory_interface(class: &ClassMeta, iface: &InterfaceMeta) -> bool { + class + .factory_interfaces + .iter() + .any(|factory| std::ptr::eq(factory, iface)) +} + +fn is_factory_method(class: &ClassMeta, method: &MethodMeta) -> bool { + class + .factory_interfaces + .iter() + .flat_map(|factory| factory.methods.iter()) + .any(|candidate| std::ptr::eq(candidate, method)) +} + +fn emit_static_compatibility_alias_stubs( class_name: &str, - methods: impl IntoIterator, + class: &ClassMeta, + plan: &ScopePlan<'_>, context: &PythonProjectionContext, indent_spaces: usize, ) -> String { - let methods = methods.into_iter().collect::>(); - let aliases = - super::overloads::compatibility_aliases(methods.iter().map(|(method, _)| *method)); let indent = " ".repeat(indent_spaces); let mut out = String::new(); - for (legacy, _) in aliases { - let matching = methods - .iter() - .filter(|(method, _)| to_snake_case(&method.name) == legacy) - .collect::>(); - for (method, is_factory) in &matching { - if matching.len() > 1 { + for alias in plan.aliases() { + for method in &alias.signatures { + if alias.signatures.len() > 1 { out.push_str(&format!("{indent}@overload\n")); } out.push_str(&emit_static_method_stub_named( class_name, method, context, - *is_factory, - Some(&legacy), + is_factory_method(class, method), + Some(&alias.name), )); } } out } -fn grouped_static_stubs<'a>( - methods: &[(&'a MethodMeta, bool)], -) -> Vec> { - let names = super::overloads::method_names(methods.iter().map(|(method, _)| *method)); - let mut groups: Vec<(String, Vec<(&MethodMeta, bool)>)> = Vec::new(); - for &(method, is_factory) in methods { - let key = super::overloads::method_group_key(method, &names); - if let Some((_, group)) = groups.iter_mut().find(|(name, _)| name == &key) { - group.push((method, is_factory)); - } else { - groups.push((key, vec![(method, is_factory)])); - } - } - groups.into_iter().map(|(_, group)| group).collect() -} - fn emit_static_stub_group( class_name: &str, - methods: &[(&MethodMeta, bool)], + class: &ClassMeta, + group: &MethodGroup<'_>, context: &PythonProjectionContext, ) -> String { - let mut ordered_methods = methods.iter().copied().collect::>(); - ordered_methods.sort_by(|(left, _), (right, _)| { - super::overloads::cmp_python_dispatch_methods(left, right) - }); - - if ordered_methods.len() == 1 { - let (method, is_factory) = ordered_methods[0]; - return emit_static_method_stub(class_name, method, context, is_factory); - } - let names = super::overloads::method_names(ordered_methods.iter().map(|(method, _)| *method)); - let public_name = super::overloads::method_group_key(ordered_methods[0].0, &names); - ordered_methods + let overloaded = group.candidates.len() > 1; + group + .candidates .iter() - .map(|(method, is_factory)| { - format!( - " @overload\n{}", - emit_static_method_stub_named( - class_name, - method, - context, - *is_factory, - Some(&public_name), - ) - ) + .map(|candidate| { + let stub = emit_static_method_stub_named( + class_name, + candidate.method, + context, + is_factory_interface(class, candidate.interface), + Some(&group.name), + ); + if overloaded { + format!(" @overload\n{stub}") + } else { + stub + } }) .collect() } From 105a09a8be2842c4c9ed328a57b38b81cea50e47 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 13:19:50 +0800 Subject: [PATCH 02/15] Accept runtime-class objects for interface overload parameters Generated runtime-class wrappers do not inherit interface wrapper classes, so the `isinstance(x, IFoo)` guard of a known interface parameter rejected every runtime-class object in overload dispatch. For example, `DataWriter(InMemoryRandomAccessStream())` and `DataReader(stream)` raised `TypeError: No matching constructor` even though the stubs accept them. Give known interface parameters a permissive guard that also accepts any projected object or raw DynWinRTValue supporting the interface through QueryInterface, while keeping the `isinstance` check for interface wrappers and Python implementations. Permissive guards only run in the dispatcher's second pass, after every candidate's strict guards failed, so calls that dispatch today keep their overload and only calls that raised TypeError can now match. This covers constructors, static and factory methods, instance methods, and interface wrappers. Interface IID constants (`IID_ARG__`) are emitted locally, like the runtime-class ones, so guards do not import interface modules at call time and also work for interfaces that are not generated. IID constants are now collected from every wrapped parameter, including FillArray buffers, which fixes an undefined `IID_ARG_*` reference in `IIterator.GetMany`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/e2e/e2e_specs.json | 3 +- tests/e2e/e2e_specs.schema.json | 1 + tests/e2e/runners/py_runner.py | 51 ++++ .../codegen/winrt/python/generator/class.rs | 6 +- .../src/codegen/winrt/python/generator/mod.rs | 2 +- .../codegen/winrt/python/generator/types.rs | 6 +- .../src/codegen/winrt/python/method.rs | 219 ++++++++++++++++- .../src/codegen/winrt/python/signature.rs | 120 +++++++++- .../tests/python_interface_dispatch_test.rs | 224 ++++++++++++++++++ .../snapshots/data_writer_py/data_writer.py | 9 + .../snapshots/uri_py/www_form_url_decoder.py | 1 + 11 files changed, 618 insertions(+), 24 deletions(-) create mode 100644 tools/dynwinrt-codegen/tests/python_interface_dispatch_test.rs diff --git a/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index 709bf04f..129de5e6 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -429,7 +429,8 @@ "instantiate": { "kind": "none" }, "checks": [ { "kind": "async_memory_roundtrip", "member": "roundtrip", "write_value": 42 }, - { "kind": "data_stream_scalar_roundtrip", "member": "roundtrip", "langs": ["py"] } + { "kind": "data_stream_scalar_roundtrip", "member": "roundtrip", "langs": ["py"] }, + { "kind": "data_stream_constructor_roundtrip", "member": "__init__", "langs": ["py"] } ] }, { diff --git a/tests/e2e/e2e_specs.schema.json b/tests/e2e/e2e_specs.schema.json index fc2ecf9e..4bba8043 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -97,6 +97,7 @@ "constructor_overload_dispatch", "value_set_mapping", "data_stream_scalar_roundtrip", + "data_stream_constructor_roundtrip", "calendar_comprehensive", "storage_query_temp_folder", "value_set_event_lifecycle", diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 9bde333f..b00a6551 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -1292,6 +1292,57 @@ def progress_without_loop(): else: cr['pass'] = True + elif kind == 'data_stream_constructor_roundtrip': + writer_cls = generated_type(pkg_name, 'DataWriter') + reader_cls = generated_type(pkg_name, 'DataReader') + stream = cls() + # The factories take IOutputStream/IInputStream; runtime-class streams + # must dispatch through QueryInterface instead of raising TypeError. + writer = writer_cls(stream) + writer.write_int32(-7) + writer.write_string('dynwinrt') + stored = await writer.store_async() + keyword_writer = writer_cls(output_stream=cls()) + keyword_writer.write_byte(1) + keyword_stored = await keyword_writer.store_async() + + reader = reader_cls(stream.get_input_stream_at(0)) + loaded = await reader.load_async(stored) + values = (reader.read_int32(), reader.read_string(8)) + stream.seek(0) + runtime_class_reader = reader_cls(stream) + runtime_class_loaded = await runtime_class_reader.load_async(stored) + runtime_class_values = ( + runtime_class_reader.read_int32(), + runtime_class_reader.read_string(8), + ) + + rejected = [] + for invalid in ((42,), ('stream',), (object(),)): + try: + writer_cls(*invalid) + rejected.append(f'accepted {invalid!r}') + except TypeError as error: + if 'No matching constructor for DataWriter' not in str(error): + rejected.append(f'unexpected error {error!r}') + if ( + stored != 12 + or keyword_stored != 1 + or loaded != stored + or runtime_class_loaded != stored + or values != (-7, 'dynwinrt') + or runtime_class_values != values + or rejected + ): + cr['error'] = ( + 'runtime-class stream constructor dispatch failed: ' + f'stored={stored}, keyword_stored={keyword_stored}, loaded={loaded}, ' + f'runtime_class_loaded={runtime_class_loaded}, values={values!r}, ' + f'runtime_class_values={runtime_class_values!r}, rejected={rejected!r}' + ) + else: + cr['pass'] = True + elif kind == 'calendar_comprehensive': obj.year = 2024 obj.month = 1 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 1e68c15a..f6d6cf42 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -264,10 +264,8 @@ pub fn generate_class<'a>( 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 crate::codegen::winrt::shared::imports::get_in_params(method) { + py_collect_argument_iid_consts(¶meter.typ, &mut argument_iids); } } } 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 b182794d..ecf26606 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -31,7 +31,7 @@ use super::method::{ use super::naming::{PythonProjectionContext, PythonSupportSymbol, is_py_reserved, to_snake_case}; use super::shared::reorder_getters_before_setters; use super::signature::{ - py_collect_runtime_class_iid_consts, py_dynwinrt_type, py_generate_interface_registration, + py_collect_argument_iid_consts, py_dynwinrt_type, py_generate_interface_registration, py_interface_iid_expr, py_runtime_named_symbol, py_runtime_symbol, py_wrap_native_value, }; use super::structs::{ 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 952a1dd3..629adcc9 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs @@ -228,10 +228,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 crate::codegen::winrt::shared::imports::get_in_params(method) { + py_collect_argument_iid_consts(¶meter.typ, &mut argument_iids); } } argument_iids.sort(); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index c052a9d6..8033916c 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -12,8 +12,8 @@ use crate::codegen::winrt::shared::imports::{ use super::member_plan::{Candidate, MethodGroup}; use super::naming::{PythonProjectionContext, PythonTypeIdentity, 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_interface_cast_guard, py_runtime_named_symbol, py_runtime_symbol, + 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, @@ -170,6 +170,13 @@ pub(crate) struct ParamGuard { pub(crate) permissive: Option, } +/// Guards for one overload parameter. +/// +/// Generated runtime-class wrappers do not inherit interface wrappers, so the +/// strict `isinstance` guard of a known interface parameter rejects runtime +/// class instances and raw `DynWinRTValue`s that implement the interface. Its +/// permissive guard also accepts anything that supports the interface through +/// QueryInterface. pub(crate) fn param_guard( name: &str, typ: &TypeMeta, @@ -177,7 +184,9 @@ pub(crate) fn param_guard( ) -> ParamGuard { ParamGuard { strict: py_method_type_guard(name, typ, context), - permissive: None, + permissive: (!is_delegate_type(typ, context)) + .then(|| py_interface_cast_guard(name, typ, context)) + .flatten(), } } @@ -1605,4 +1614,208 @@ print(json.dumps([exercise(FactoryForward), exercise(FactoryReverse)])) r#"[["enum", "i32", "TypeError"], ["enum", "i32", "TypeError"]]"# ); } + + fn interface_type(name: &str, iid: &str) -> TypeMeta { + TypeMeta::Interface { + namespace: "Contoso".into(), + name: name.into(), + iid: iid.into(), + } + } + + fn widget_type() -> TypeMeta { + TypeMeta::RuntimeClass { + namespace: "Contoso".into(), + name: "Widget".into(), + default_interface: Some(Box::new(interface_type( + "IWidget", + "22222222-2222-2222-2222-222222222222", + ))), + } + } + + #[test] + fn interface_overloads_accept_query_interface_only_after_exact_guards_fail() { + let foo = interface_type("IFoo", "11111111-1111-1111-1111-111111111111"); + let by_interface = overloaded_method("Write", 6, foo.clone()); + let by_class = overloaded_method("Write2", 7, widget_type()); + let by_text = overloaded_method("Write3", 8, TypeMeta::String); + let context = PythonProjectionContext::standalone([ + foo.type_identity(), + widget_type().type_identity(), + ]) + .unwrap(); + + let code = instance_group(&[&by_text, &by_class, &by_interface], &context); + let exact_interface = "if _bound is not None and isinstance(_bound[0], _dynwinrt_symbol('contoso__i_foo', 'IFoo')):"; + let relaxed_interface = "if _bound is not None and (isinstance(_bound[0], _dynwinrt_symbol('contoso__i_foo', 'IFoo')) or _dynwinrt_can_cast(_bound[0], IID_ARG_Contoso_IFoo)):"; + assert_eq!(code.matches(exact_interface).count(), 1, "{code}"); + assert_eq!(code.matches(relaxed_interface).count(), 1, "{code}"); + assert_eq!( + code.matches("_dynwinrt_can_cast(_bound[0], IID_ARG_Contoso_Widget)") + .count(), + 1, + "only interface candidates are retried: {code}" + ); + for first_pass in [ + "isinstance(_bound[0], str):", + exact_interface, + "_dynwinrt_can_cast(_bound[0], IID_ARG_Contoso_Widget):", + ] { + assert_contains_in_order(&code, first_pass, relaxed_interface); + } + + let dispatcher = extract_generated_block(&code, " def write(self, *args, **kwargs):\n"); + let script = format!( + r#"import json + +class DynWinRTValue: + def __init__(self, *interfaces): + self.interfaces = set(interfaces) + + def cast(self, iid): + if iid not in self.interfaces: + raise OSError('E_NOINTERFACE') + return DynWinRTValue(*self.interfaces) + + def release(self): + pass + +IID_ARG_Contoso_IFoo = 'IFoo' +IID_ARG_Contoso_Widget = 'IWidget' + +def _dynwinrt_bind_overload(parameter_names, args, kwargs): + if len(args) > len(parameter_names): + return None + bound = list(args) + for name in parameter_names[len(args):]: + if name not in kwargs: + return None + bound.append(kwargs[name]) + if len(kwargs) != len(parameter_names) - len(args): + return None + return tuple(bound) + +def _dynwinrt_can_cast(value, iid): + raw = getattr(value, '_obj', value) + if not isinstance(raw, DynWinRTValue): + return False + try: + projected = raw.cast(iid) + except OSError: + return False + projected.release() + return True + +def _dynwinrt_symbol(module, name): + return globals()[name] + +class IFoo: + def __init__(self, obj): + self._obj = obj + +class Widget: + def __init__(self, obj): + self._obj = obj + +class PythonFoo(IFoo): + def __init__(self): + pass + +class Writer: + def _write_6(self, value): + return "interface" + + def _write_7(self, value): + return "runtime class" + + def _write_8(self, value): + return "text" + +{dispatcher} + +writer = Writer() +results = [ + writer.write(Widget(DynWinRTValue('IWidget', 'IFoo'))), + writer.write(IFoo(DynWinRTValue('IFoo'))), + writer.write(PythonFoo()), + writer.write(Widget(DynWinRTValue('IFoo'))), + writer.write(DynWinRTValue('IFoo')), + writer.write(value=DynWinRTValue('IFoo')), + writer.write('text'), +] +for rejected in (DynWinRTValue(), object(), None): + try: + writer.write(rejected) + except TypeError: + results.append("TypeError") + else: + results.append("unexpected") +print(json.dumps(results)) +"# + ); + + assert_eq!( + run_python(&script), + r#"["runtime class", "interface", "interface", "interface", "interface", "interface", "text", "TypeError", "TypeError", "TypeError"]"# + ); + } + + #[test] + fn static_interface_overloads_retry_with_query_interface() { + let foo = interface_type("IFoo", "11111111-1111-1111-1111-111111111111"); + let by_interface = overloaded_method("Create", 6, foo.clone()); + let by_text = overloaded_method("Create2", 7, TypeMeta::String); + let context = PythonProjectionContext::standalone([foo.type_identity()]).unwrap(); + + let code = static_group("Factory", &[&by_interface, &by_text], &context); + + assert_contains_in_order( + &code, + "if _bound is not None and isinstance(_bound[0], str):", + "if _bound is not None and (isinstance(_bound[0], _dynwinrt_symbol('contoso__i_foo', 'IFoo')) or _dynwinrt_can_cast(_bound[0], IID_ARG_Contoso_IFoo)):\n return Factory._create_6(*_bound)\n", + ); + } + + #[test] + fn param_guards_are_permissive_only_for_known_interfaces() { + let foo = interface_type("IFoo", "11111111-1111-1111-1111-111111111111"); + let handler = interface_type("Handler", "33333333-3333-3333-3333-333333333333"); + let context = PythonProjectionContext::standalone([ + foo.type_identity(), + widget_type().type_identity(), + handler + .type_identity() + .with_kind(TypeIdentityKind::Delegate), + ]) + .unwrap(); + + let guard = param_guard("value", &foo, &context); + assert_eq!( + guard.strict, + "isinstance(value, _dynwinrt_symbol('contoso__i_foo', 'IFoo'))" + ); + assert_eq!( + guard.permissive.as_deref(), + Some( + "(isinstance(value, _dynwinrt_symbol('contoso__i_foo', 'IFoo')) or _dynwinrt_can_cast(value, IID_ARG_Contoso_IFoo))" + ) + ); + for strict_only in [ + widget_type(), + handler, + TypeMeta::String, + TypeMeta::Object, + interface_type( + "IUnknownToProjection", + "44444444-4444-4444-4444-444444444444", + ), + ] { + assert_eq!( + param_guard("value", &strict_only, &context).permissive, + None, + "{strict_only:?}" + ); + } + } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs index 9bbb5ba6..42a828b8 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs @@ -126,6 +126,20 @@ pub(crate) fn py_dispatch_type_sort_key(typ: &TypeMeta) -> (u8, u16, u8, u8, Str // Python type expression // ====================================================================== +fn py_argument_iid_const_name(namespace: &str, name: &str) -> String { + let qualified = format!("{}_{}", namespace, name) + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '_' + } + }) + .collect::(); + format!("IID_ARG_{}", qualified) +} + pub(crate) fn py_runtime_class_iid_const(typ: &TypeMeta) -> Option<(String, String)> { let TypeMeta::RuntimeClass { namespace, @@ -141,17 +155,32 @@ pub(crate) fn py_runtime_class_iid_const(typ: &TypeMeta) -> Option<(String, Stri if iid.is_empty() { return None; } - let qualified = format!("{}_{}", namespace, name) - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character - } else { - '_' - } - }) - .collect::(); - Some((format!("IID_ARG_{}", qualified), iid.clone())) + Some((py_argument_iid_const_name(namespace, name), iid.clone())) +} + +/// Module-level IID constant used to QueryInterface-check an interface-typed argument. +pub(crate) fn py_interface_iid_const(typ: &TypeMeta) -> Option<(String, String)> { + let TypeMeta::Interface { + namespace, + name, + iid, + } = typ + else { + return None; + }; + if iid.is_empty() { + return None; + } + Some((py_argument_iid_const_name(namespace, name), iid.clone())) +} + +/// Collect the module-level IID constants needed by an input parameter: runtime-class +/// casts (including collection elements) and interface-parameter dispatch guards. +pub(crate) fn py_collect_argument_iid_consts(typ: &TypeMeta, output: &mut Vec<(String, String)>) { + if let Some(value) = py_interface_iid_const(typ) { + output.push(value); + } + py_collect_runtime_class_iid_consts(typ, output); } pub(crate) fn py_collect_runtime_class_iid_consts( @@ -636,6 +665,29 @@ pub(crate) fn py_type_guard( } } +/// QueryInterface-based guard for a known interface-typed overload parameter. +/// +/// Generated runtime-class wrappers do not inherit interface wrapper classes, so the +/// exact `isinstance` guard from [`py_type_guard`] rejects runtime-class objects and raw +/// `DynWinRTValue`s that implement the interface. This relaxed guard keeps the exact +/// check (for interface wrappers and Python implementations) and also accepts any +/// object that supports the interface through QueryInterface. Returns `None` when the +/// parameter keeps its exact guard. +pub(crate) fn py_interface_cast_guard( + name: &str, + typ: &TypeMeta, + context: &PythonProjectionContext, +) -> Option { + if !matches!(typ, TypeMeta::Interface { .. }) || !context.is_known_type(typ) { + return None; + } + let (iid, _) = py_interface_iid_const(typ)?; + Some(format!( + "({} or _dynwinrt_can_cast({name}, {iid}))", + py_type_guard(name, typ, context) + )) +} + /// Convert a Python return expression, given the raw `.call()` result expression. pub(crate) fn py_convert_return( expr: &str, @@ -950,6 +1002,52 @@ mod tests { ); } + #[test] + fn known_interface_inputs_get_a_query_interface_dispatch_guard() { + let stream = TypeMeta::Interface { + namespace: "Windows.Storage.Streams".into(), + name: "IOutputStream".into(), + iid: "905a0fe6-bc53-11df-8c49-001e4fc686da".into(), + }; + let context = PythonProjectionContext::packaged([stream.type_identity()]).unwrap(); + let exact = "isinstance(value, _dynwinrt_symbol('windows__storage__streams__i_output_stream', 'IOutputStream'))"; + + assert_eq!(py_type_guard("value", &stream, &context), exact); + assert_eq!( + py_interface_cast_guard("value", &stream, &context), + Some(format!( + "({exact} or _dynwinrt_can_cast(value, IID_ARG_Windows_Storage_Streams_IOutputStream))" + )) + ); + assert_eq!( + py_interface_cast_guard("value", &stream, &PythonProjectionContext::default()), + None, + "unknown interfaces keep their permissive native-object guard" + ); + assert_eq!( + py_interface_cast_guard("value", &geometry_type(), &context), + None, + "runtime classes already use a QueryInterface guard" + ); + let mut constants = Vec::new(); + py_collect_argument_iid_consts(&stream, &mut constants); + py_collect_argument_iid_consts( + &TypeMeta::Interface { + namespace: "Contoso".into(), + name: "IUnnamed".into(), + iid: String::new(), + }, + &mut constants, + ); + assert_eq!( + constants, + vec![( + "IID_ARG_Windows_Storage_Streams_IOutputStream".into(), + "905a0fe6-bc53-11df-8c49-001e4fc686da".into(), + )] + ); + } + #[test] fn python_numeric_overload_integer_guards_use_exact_ranges() { let context = PythonProjectionContext::default(); diff --git a/tools/dynwinrt-codegen/tests/python_interface_dispatch_test.rs b/tools/dynwinrt-codegen/tests/python_interface_dispatch_test.rs new file mode 100644 index 00000000..572f0993 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/python_interface_dispatch_test.rs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod common; + +use std::collections::{BTreeSet, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +use dynwinrt_codegen::meta; +use dynwinrt_codegen::types::TypeMeta; + +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 Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target") + .join(format!( + "pid{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn generate_real_class(namespace: &str, name: &str) -> Option { + let class = meta::parse_class(WINDOWS_WINMD, namespace, name)?; + let deps = meta::resolve_python_dependencies(WINDOWS_WINMD, &[class.clone()], &[], &[]); + let mut known = HashSet::new(); + known.insert(class.name.clone()); + known.extend(deps.classes.iter().map(|class| class.name.clone())); + known.extend( + deps.interfaces + .iter() + .map(|interface| interface.name.clone()), + ); + known.extend(deps.enums.iter().filter_map(|typ| match typ { + TypeMeta::Enum { name, .. } => Some(name.clone()), + _ => None, + })); + Some(common::generate_class( + &class, + &known, + &HashSet::new(), + &HashSet::new(), + )) +} + +/// Return the `if _bound ...` guard lines of the generated block starting at `marker`. +fn guard_lines<'a>(code: &'a str, marker: &str) -> Vec<&'a str> { + let start = code + .find(marker) + .unwrap_or_else(|| panic!("missing `{marker}` in:\n{code}")); + let block = &code[start + marker.len()..]; + let end = block.find("\n def ").unwrap_or(block.len()); + block[..end] + .lines() + .map(str::trim) + .filter(|line| line.starts_with("if _bound is not None")) + .collect() +} + +#[test] +fn data_stream_constructors_accept_runtime_class_streams_through_query_interface() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + for (class, interface, module, iid) in [ + ( + "DataWriter", + "IOutputStream", + "windows__storage__streams__i_output_stream", + "905a0fe6-bc53-11df-8c49-001e4fc686da", + ), + ( + "DataReader", + "IInputStream", + "windows__storage__streams__i_input_stream", + "905a0fe2-bc53-11df-8c49-001e4fc686da", + ), + ] { + let code = generate_real_class("Windows.Storage.Streams", class).expect("class metadata"); + let constant = format!("IID_ARG_Windows_Storage_Streams_{interface}"); + let exact = format!("isinstance(_bound[0], _dynwinrt_symbol('{module}', '{interface}'))"); + let relaxed = format!("({exact} or _dynwinrt_can_cast(_bound[0], {constant}))"); + + assert!( + code.contains(&format!("\n{constant} = WinGUID.parse('{iid}')\n")), + "{code}" + ); + for marker in [ + " def __new__(cls, *args, **kwargs):\n", + " def __init__(self, *args, **kwargs):\n", + ] { + let guards = guard_lines(&code, marker); + let first_relaxed = guards + .iter() + .position(|guard| guard.contains(&relaxed)) + .unwrap_or_else(|| panic!("{class} {marker} lacks a QI guard:\n{code}")); + assert!( + guards[..first_relaxed] + .iter() + .any(|guard| guard.contains(&format!("{exact}:"))), + "{class} {marker} must keep its exact guard first:\n{code}" + ); + assert!( + guards[first_relaxed..] + .iter() + .all(|guard| guard.contains("_dynwinrt_can_cast(_bound[")), + "{class} {marker} must try every exact guard before QueryInterface:\n{code}" + ); + } + } +} + +fn identifiers(text: &str, prefix: &str) -> BTreeSet { + let mut found = BTreeSet::new(); + let mut rest = text; + while let Some(index) = rest.find(prefix) { + let preceded_by_identifier = rest[..index] + .chars() + .next_back() + .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_'); + let tail = &rest[index..]; + let end = tail + .find(|character: char| !(character.is_ascii_alphanumeric() || character == '_')) + .unwrap_or(tail.len()); + if !preceded_by_identifier { + found.insert(tail[..end].to_string()); + } + rest = &tail[end..]; + } + found +} + +#[test] +fn generated_python_modules_define_every_argument_iid_they_reference() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + let fixture = Fixture::new(); + let output = Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")) + .args(["generate", "--winmd", WINDOWS_WINMD, "--class-name"]) + .arg( + "Windows.Storage.Streams.DataWriter,Windows.Storage.Streams.DataReader,\ + Windows.Storage.Streams.RandomAccessStream,Windows.Storage.StorageFile,\ + Windows.System.Launcher,Windows.Data.Xml.Dom.XmlDocument,\ + Windows.Web.Http.HttpClient,Windows.UI.Notifications.ToastNotifier", + ) + .args(["--lang", "py", "--no-pyi", "--output"]) + .arg(&fixture.0) + .output() + .expect("run dynwinrt-codegen"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let mut modules = 0; + let mut guarded_modules = 0; + let mut pending = vec![fixture.0.clone()]; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(&directory).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + pending.push(path); + continue; + } + if path.extension().and_then(|extension| extension.to_str()) != Some("py") { + continue; + } + let code = fs::read_to_string(&path).unwrap(); + modules += 1; + let defined = code + .lines() + .filter_map(|line| line.split_once(" = WinGUID.parse(").map(|(name, _)| name)) + .filter(|name| name.starts_with("IID_ARG_")) + .map(str::to_string) + .collect::>(); + let referenced = identifiers(&code, "IID_ARG_"); + let missing = referenced.difference(&defined).collect::>(); + assert!( + missing.is_empty(), + "{} references undefined argument IIDs {missing:?}", + path.display() + ); + if code.contains("_dynwinrt_can_cast(_bound[") { + guarded_modules += 1; + } + } + } + assert!( + modules > 20, + "expected a generated package, found {modules} modules" + ); + assert!( + guarded_modules > 0, + "expected QueryInterface dispatch guards in the generated package" + ); +} diff --git a/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py b/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py index 42b7b420..860cd9f9 100644 --- a/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py +++ b/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py @@ -26,6 +26,8 @@ IID_IDataWriter = WinGUID.parse('64b89265-d341-4922-b38a-dd4af8808c4e') IID_IDataWriterFactory = WinGUID.parse('338c67c2-8b84-4c2b-9c50-7b8767847a1f') IID_IClosable = WinGUID.parse('30d5a829-7fa4-4026-83bb-d75bae4ea99e') +IID_ARG_Windows_Storage_Streams_IBuffer = WinGUID.parse('905a0fe0-bc53-11df-8c49-001e4fc686da') +IID_ARG_Windows_Storage_Streams_IOutputStream = WinGUID.parse('905a0fe6-bc53-11df-8c49-001e4fc686da') _IDataWriter = DynWinRTType.register_interface( "IDataWriter", IID_IDataWriter) \ @@ -108,6 +110,9 @@ def __new__(cls, *args, **kwargs): _bound = _dynwinrt_bind_overload(('output_stream',), args, kwargs) if _bound is not None and isinstance(_bound[0], _dynwinrt_symbol('windows__storage__streams__i_output_stream', 'IOutputStream')): return cls.create_data_writer(_bound[0]) + _bound = _dynwinrt_bind_overload(('output_stream',), args, kwargs) + if _bound is not None and (isinstance(_bound[0], _dynwinrt_symbol('windows__storage__streams__i_output_stream', 'IOutputStream')) or _dynwinrt_can_cast(_bound[0], IID_ARG_Windows_Storage_Streams_IOutputStream)): + return cls.create_data_writer(_bound[0]) return super().__new__(cls) def _set_native(self, obj: DynWinRTValue): @@ -135,6 +140,10 @@ def __init__(self, *args, **kwargs): if _bound is not None and isinstance(_bound[0], _dynwinrt_symbol('windows__storage__streams__i_output_stream', 'IOutputStream')): self._set_native(type(self).create_data_writer(_bound[0])._obj) return + _bound = _dynwinrt_bind_overload(('output_stream',), args, kwargs) + if _bound is not None and (isinstance(_bound[0], _dynwinrt_symbol('windows__storage__streams__i_output_stream', 'IOutputStream')) or _dynwinrt_can_cast(_bound[0], IID_ARG_Windows_Storage_Streams_IOutputStream)): + self._set_native(type(self).create_data_writer(_bound[0])._obj) + return raise TypeError("No matching constructor for DataWriter") @staticmethod diff --git a/tools/dynwinrt-codegen/tests/snapshots/uri_py/www_form_url_decoder.py b/tools/dynwinrt-codegen/tests/snapshots/uri_py/www_form_url_decoder.py index f9baae09..33a56db2 100644 --- a/tools/dynwinrt-codegen/tests/snapshots/uri_py/www_form_url_decoder.py +++ b/tools/dynwinrt-codegen/tests/snapshots/uri_py/www_form_url_decoder.py @@ -24,6 +24,7 @@ IID_IWwwFormUrlDecoderRuntimeClassFactory = WinGUID.parse('5b8c6b3d-24ae-41b5-a1bf-f0c3d544845b') IID_IVectorView_IWwwFormUrlDecoderEntry = DynWinRTType.parameterized(WinGUID.parse('bbe1fa4c-b0e3-4583-baef-1f1b2e483e56'), [DynWinRTType.interface(WinGUID.parse('125e7431-f678-4e8e-b670-20a9b06c512d'))]).iid() IID_IIterable_IWwwFormUrlDecoderEntry = DynWinRTType.parameterized(WinGUID.parse('faa585ea-6214-4217-afda-7f46de5869b3'), [DynWinRTType.interface(WinGUID.parse('125e7431-f678-4e8e-b670-20a9b06c512d'))]).iid() +IID_ARG_Windows_Foundation_IWwwFormUrlDecoderEntry = WinGUID.parse('125e7431-f678-4e8e-b670-20a9b06c512d') _IWwwFormUrlDecoderRuntimeClass = DynWinRTType.register_interface( "IWwwFormUrlDecoderRuntimeClass", IID_IWwwFormUrlDecoderRuntimeClass) \ From 6d11ba7eb411615f009a55de29797fdcbe53cc10 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 15:07:38 +0800 Subject: [PATCH 03/15] Group Python overloads by documented CLR method name WinMD gives all overloads of a method one CLR/MethodDef name and assigns each ABI slot a unique `[Overload]` name. Python grouped on those unique ABI names, then used suffix heuristics only when a base ABI name happened to exist. As a result, hundreds of documented Python methods were incomplete or missing: `StorageFile.CopyAsync` became three `copy_overload*` methods, for example. Use each non-accessor method's snake-cased `raw_name` as the member plan's primary key, with the established suffix heuristics on top. Accessors and events keep their previous names. Static and instance scopes are planned together so CLR names that collide with a property, event helper, generated member, the other scope, or an existing name with different behavior fall back to the prior name. All old method names remain. A former standalone ABI method aliases its exact private implementation, preserving its guard-free behavior and ABI slot; former dispatch aliases continue to alias the dispatcher. The stubs declare all metadata overloads. When two ABI overloads collapse to the same typed Python signature, the later declaration gets a targeted `overload-cannot-match` ignore so strict mypy accepts the metadata-exact count. Regression coverage checks the representative SDK classes, every old public name in those classes, collision fallbacks, exact compatibility-alias behavior, strict mypy, and live Python calls for StorageFile, RandomAccessStream, DecimalFormatter, XmlDocument, and Calendar. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 8 + tests/e2e/e2e_specs.json | 37 +- tests/e2e/e2e_specs.schema.json | 5 + tests/e2e/runners/py_runner.py | 165 +++ tests/e2e/typecheck/python_generated_api.py | 52 + .../codegen/winrt/python/generator/class.rs | 47 +- .../src/codegen/winrt/python/member_plan.rs | 1273 +++++++++++++++-- .../src/codegen/winrt/python/method.rs | 4 +- .../src/codegen/winrt/python/stubs.rs | 149 +- .../tests/python_constructor_boundary_test.rs | 9 + .../python_overload_normalization_test.rs | 646 ++++++++- .../snapshots/data_writer_py/data_writer.py | 21 +- 12 files changed, 2216 insertions(+), 200 deletions(-) diff --git a/bindings/py/README.md b/bindings/py/README.md index 2a1cb72c..9024c749 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -103,6 +103,14 @@ unsubscribe function. `once_*` subscribes for at most one callback invocation. WinRT flags enums are projected as `enum.IntFlag`. Overloaded methods share one Python name with runtime type/arity dispatch and `typing.overload` declarations. +That name is the documented (CLR) method name, so `StorageFile.CopyAsync` +overloads are all `copy_async(...)`. The unique `[Overload]` ABI names emitted by +earlier releases, such as `copy_overload` or `launch_file_with_options_async`, +remain available as compatibility aliases that keep calling the same overload. +A method keeps its earlier name when the documented name would clash with +another member, such as a property or the generated `close()`. Interface +parameters of overloads accept any object that implements the interface, such +as a runtime class instance or a `DynWinRTValue`. Activatable runtime classes use normal constructors, for example `Uri("https://example.com")`. Constructor overloads come only from WinMD `ActivatableAttribute` and public `ComposableAttribute` declarations. Classes diff --git a/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index 129de5e6..6fc1c96e 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -326,7 +326,8 @@ "checks": [ { "kind": "sequence_protocol", "member": "languages", "expected_size": 1 }, { "kind": "datetime_roundtrip", "member": "date_time", "langs": ["py"] }, - { "kind": "calendar_comprehensive", "member": "self" } + { "kind": "calendar_comprehensive", "member": "self" }, + { "kind": "calendar_documented_overloads", "member": "month_as_string", "langs": ["py"] } ] }, { @@ -453,6 +454,37 @@ { "kind": "storage_query_temp_folder", "member": "query" } ] }, + { + "id": "python_storage_file_copy_overloads", + "namespace": "Windows.Storage", + "class": "StorageFile", + "langs": ["py"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "storage_file_copy_overloads", "member": "copy_async" } + ] + }, + { + "id": "python_stream_copy_overloads", + "namespace": "Windows.Storage.Streams", + "class": "RandomAccessStream", + "langs": ["py"], + "extra_classes": ["InMemoryRandomAccessStream", "DataWriter", "DataReader"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "random_access_stream_copy_overloads", "member": "copy_async" } + ] + }, + { + "id": "python_decimal_formatter_overloads", + "namespace": "Windows.Globalization.NumberFormatting", + "class": "DecimalFormatter", + "langs": ["py"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "decimal_formatter_overloads", "member": "format" } + ] + }, { "id": "issue_69_device_information_async_collection", "namespace": "Windows.Devices.Enumeration", @@ -570,7 +602,8 @@ "args": [""], "property_path": ["document_element", "tag_name"], "expected": "root" - } + }, + { "kind": "xml_document_load_overloads", "member": "load_xml", "langs": ["py"] } ] }, { diff --git a/tests/e2e/e2e_specs.schema.json b/tests/e2e/e2e_specs.schema.json index 4bba8043..c8881c73 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -100,6 +100,11 @@ "data_stream_constructor_roundtrip", "calendar_comprehensive", "storage_query_temp_folder", + "storage_file_copy_overloads", + "random_access_stream_copy_overloads", + "decimal_formatter_overloads", + "xml_document_load_overloads", + "calendar_documented_overloads", "value_set_event_lifecycle", "nested_struct_runtime", "generated_helper_matrix" diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index b00a6551..434f7712 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -1541,6 +1541,171 @@ def progress_without_loop(): else: cr['pass'] = True + elif kind == 'storage_file_copy_overloads': + from pathlib import Path + from tempfile import TemporaryDirectory + + folder_cls = generated_type(pkg_name, 'StorageFolder') + collision = generated_type(pkg_name, 'NameCollisionOption') + with TemporaryDirectory(prefix='dynwinrt-copy-') as temp_dir: + root = Path(temp_dir) + (root / 'source.txt').write_text('payload', encoding='utf-8') + for name in ('documented', 'legacy'): + (root / name).mkdir() + source = await cls.get_file_from_path_async(str(root / 'source.txt')) + documented = await folder_cls.get_folder_from_path_async( + str(root / 'documented') + ) + legacy = await folder_cls.get_folder_from_path_async(str(root / 'legacy')) + + # CopyAsync overloads share the documented name; the folder argument is a + # runtime class passed to an IStorageFolder parameter. + copies = [ + await source.copy_async(documented), + await source.copy_async(documented, 'named.txt'), + await source.copy_async( + documented, 'named.txt', collision.ReplaceExisting + ), + await source.copy_async( + destination_folder=documented, + desired_new_name='keyword.txt', + ), + ] + # Former [Overload] names keep calling the same ABI overload, + # including a plain int for the NameCollisionOption parameter. + legacy_copies = [ + await source.copy_overload_default_name_and_options(legacy), + await source.copy_overload_default_options(legacy, 'named.txt'), + await source.copy_overload(legacy, 'named.txt', collision.ReplaceExisting), + await source.copy_overload(legacy, 'named.txt', 1), + ] + rejected = [] + for arguments in (('not a folder',), (documented, 42)): + try: + await source.copy_async(*arguments) + rejected.append(f'accepted {arguments!r}') + except TypeError as error: + if 'No matching overload for copy_async' not in str(error): + rejected.append(f'unexpected error {error!r}') + + names = [copy.name for copy in copies] + legacy_names = [copy.name for copy in legacy_copies] + contents = sorted( + path.name + for path in (root / 'documented').iterdir() + if path.read_text(encoding='utf-8') == 'payload' + ) + if ( + names != ['source.txt', 'named.txt', 'named.txt', 'keyword.txt'] + or legacy_names != ['source.txt', 'named.txt', 'named.txt', 'named.txt'] + or contents != ['keyword.txt', 'named.txt', 'source.txt'] + or rejected + ): + cr['error'] = ( + f'StorageFile copy overloads failed: names={names!r}, ' + f'legacy={legacy_names!r}, contents={contents!r}, ' + f'rejected={rejected!r}' + ) + else: + cr['pass'] = True + + elif kind == 'random_access_stream_copy_overloads': + stream_cls = generated_type(pkg_name, 'InMemoryRandomAccessStream') + writer_cls = generated_type(pkg_name, 'DataWriter') + reader_cls = generated_type(pkg_name, 'DataReader') + source = stream_cls() + writer = writer_cls(source) + writer.write_string('0123456789') + await writer.store_async() + writer.detach_stream() + + async def read_all(stream): + stream.seek(0) + reader = reader_cls(stream) + loaded = await reader.load_async(stream.size) + return reader.read_string(loaded) + + results = [] + for copy in ( + lambda destination: cls.copy_async(source, destination), + lambda destination: cls.copy_async(source, destination, 4), + lambda destination: cls.copy_size_async(source, destination, 4), + ): + source.seek(0) + destination = stream_cls() + copied = await copy(destination) + results.append((copied, await read_all(destination))) + if results != [(10, '0123456789'), (4, '0123'), (4, '0123')]: + cr['error'] = f'RandomAccessStream copy overloads failed: {results!r}' + else: + cr['pass'] = True + + elif kind == 'decimal_formatter_overloads': + formatter = cls() + beyond_double = 2**53 + 1 + beyond_int64 = 2**64 - 1 + pairs = { + 'int': (formatter.format(5), formatter.format_int(5)), + 'double': (formatter.format(2.5), formatter.format_double(2.5)), + 'int64 precision': ( + formatter.format(beyond_double), + formatter.format_int(beyond_double), + ), + 'uint64': ( + formatter.format(beyond_int64), + formatter.format_u_int(beyond_int64), + ), + } + mismatches = { + name: values for name, values in pairs.items() if values[0] != values[1] + } + if ( + mismatches + or formatter.format(beyond_double) + == formatter.format_double(float(beyond_double)) + ): + cr['error'] = ( + f'DecimalFormatter.format overload dispatch failed: pairs={pairs!r}' + ) + else: + cr['pass'] = True + + elif kind == 'xml_document_load_overloads': + settings = generated_type(pkg_name, 'XmlLoadSettings')() + loaded = [] + for load in ( + lambda: obj.load_xml(''), + lambda: obj.load_xml('', settings), + lambda: obj.load_xml(xml='', load_settings=settings), + lambda: obj.load_xml_with_settings('', settings), + ): + load() + loaded.append(obj.document_element.tag_name) + if loaded != ['documented', 'settings', 'keyword', 'legacy']: + cr['error'] = f'XmlDocument.load_xml overloads failed: {loaded!r}' + else: + cr['pass'] = True + + elif kind == 'calendar_documented_overloads': + results = { + 'month': (obj.month_as_string(), obj.month_as_full_string()), + 'abbreviated month': ( + obj.month_as_string(3), + obj.month_as_string(ideal_length=3), + ), + 'day of week': ( + obj.day_of_week_as_string(), + obj.day_of_week_as_full_string(), + ), + } + if any( + left != right or not isinstance(left, str) or not left + for left, right in results.values() + ): + cr['error'] = f'Calendar documented overloads failed: {results!r}' + else: + cr['pass'] = True + elif kind == 'async_cancellation': import dynwinrt as dw diff --git a/tests/e2e/typecheck/python_generated_api.py b/tests/e2e/typecheck/python_generated_api.py index 3902d0c9..3680cebf 100644 --- a/tests/e2e/typecheck/python_generated_api.py +++ b/tests/e2e/typecheck/python_generated_api.py @@ -17,17 +17,26 @@ WinGUID, ) from python_bindings.windows.application_model.contacts import ContactDate +from python_bindings.windows.data.xml.dom import XmlDocument, XmlLoadSettings from python_bindings.windows.foundation import ( IReference_UInt32, IWwwFormUrlDecoderEntry, Uri, ) from python_bindings.windows.globalization import Calendar +from python_bindings.windows.globalization.number_formatting import DecimalFormatter +from python_bindings.windows.storage import ( + NameCollisionOption, + StorageFile, + StorageFolder, +) from python_bindings.windows.storage.streams import ( Buffer as WinRTBuffer, DataWriter, IBuffer, + InMemoryRandomAccessStream, IOutputStream, + RandomAccessStream, ) @@ -146,6 +155,49 @@ def check_async_types( ) +def check_documented_overload_names( + formatter: DecimalFormatter, + calendar: Calendar, + document: XmlDocument, + settings: XmlLoadSettings, +) -> None: + formatted: List[str] = [ + formatter.format(5), + formatter.format(2.5), + formatter.format_int(5), + formatter.format_u_int(5), + calendar.month_as_string(), + calendar.month_as_string(3), + calendar.month_as_full_string(), + ] + document.load_xml("") + document.load_xml("", settings) + document.load_xml_with_settings("", settings) + _: List[str] = formatted + + +async def check_documented_async_overload_names( + file: StorageFile, + folder: StorageFolder, + source: InMemoryRandomAccessStream, + target: InMemoryRandomAccessStream, +) -> None: + option = NameCollisionOption.ReplaceExisting + copies: List[StorageFile | None] = [ + await file.copy_async(folder), + await file.copy_async(folder, "copy.txt"), + await file.copy_async(folder, "copy.txt", option), + await file.copy_overload(folder, "copy.txt", option), + ] + copied: List[int] = [ + await RandomAccessStream.copy_async(source, target), + await RandomAccessStream.copy_async(source, target, 4), + await RandomAccessStream.copy_size_async(source, target, 4), + ] + writer: DataWriter = DataWriter(source) + _: Tuple[List[StorageFile | None], List[int], DataWriter] = (copies, copied, writer) + + def check_ibuffer_bytes() -> None: interface_buffer: IBuffer = IBuffer.from_bytes(bytearray(b"\x00\xff")) runtime_buffer: WinRTBuffer = WinRTBuffer.from_bytes(b"\x01\x02") 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 f6d6cf42..d8981c97 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -62,7 +62,7 @@ pub fn generate_class<'a>( }; let projectable = super::super::has_projectable_default_interface(class); let native_projectable = super::super::has_native_projector(class); - let plan = ClassMemberPlan::new(class); + let plan = ClassMemberPlan::new(class, context); let mut out = String::new(); // Header @@ -953,6 +953,9 @@ struct PyCtorCandidate<'a> { public_params: Vec<&'a ParamMeta>, /// Full call expression, e.g. `type(self).create_instance(_bound[0], None)`. call_expr: String, + /// Tie-breaker between constructors whose parameters sort equally: the call + /// under pre-CLR-name method names, so renaming never reorders dispatch. + order_key: String, /// Aggregated call for Python subclasses. `None` means subclass activation /// is not semantically available for this constructor shape. composed_call_expr: Option, @@ -992,11 +995,13 @@ fn build_ctor_candidates<'a>( match constructor.kind { ConstructorKind::DefaultActivation => { let ctor_name = default_constructor_name(has_create_factory); + let call_expr = format!("type(self).{}()", ctor_name); push_unique( &mut candidates, PyCtorCandidate { public_params: Vec::new(), - call_expr: format!("type(self).{}()", ctor_name), + order_key: call_expr.clone(), + call_expr, composed_call_expr: None, }, ); @@ -1015,16 +1020,15 @@ fn build_ctor_candidates<'a>( continue; } let in_params = crate::codegen::winrt::shared::imports::get_in_params(method); - let call_expr = build_factory_call_expr( - static_attribute(statics, method), - &in_params, - None, - ); + let (call_name, previous_name) = static_attributes(statics, method); + let call_expr = build_factory_call_expr(call_name, &in_params, None); + let order_key = build_factory_call_expr(previous_name, &in_params, None); push_unique( &mut candidates, PyCtorCandidate { public_params: in_params, call_expr, + order_key, composed_call_expr: None, }, ); @@ -1049,11 +1053,11 @@ fn build_ctor_candidates<'a>( else { continue; }; - let call_expr = build_factory_call_expr( - static_attribute(statics, method), - &in_params, - Some(outer_index), - ); + let (call_name, previous_name) = static_attributes(statics, method); + let call_expr = + build_factory_call_expr(call_name, &in_params, Some(outer_index)); + let order_key = + build_factory_call_expr(previous_name, &in_params, Some(outer_index)); let inner_output_index = method .params .iter() @@ -1090,6 +1094,7 @@ fn build_ctor_candidates<'a>( PyCtorCandidate { public_params, call_expr, + order_key, composed_call_expr: Some(composed_call_expr), }, ); @@ -1102,10 +1107,14 @@ fn build_ctor_candidates<'a>( candidates } -fn static_attribute<'p>(statics: &'p ScopePlan<'_>, method: &MethodMeta) -> &'p str { - statics - .attribute(method) - .expect("constructor factory methods are planned static methods") +/// The attributes implementing a constructor factory method now and before +/// CLR-name grouping. +fn static_attributes<'p>(statics: &'p ScopePlan<'_>, method: &MethodMeta) -> (&'p str, &'p str) { + let planned = "constructor factory methods are planned static methods"; + ( + statics.attribute(method).expect(planned), + statics.previous_attribute(method).expect(planned), + ) } /// Build a `type(self).(_bound[0], _bound[1], ..., None_for_outer)` call. @@ -1246,7 +1255,7 @@ fn generate_python_constructor( &left.public_params, &right.public_params, ) - .then_with(|| left.call_expr.cmp(&right.call_expr)) + .then_with(|| left.order_key.cmp(&right.order_key)) }); out.push_str(" def __new__(cls, *args, **kwargs):\n"); @@ -1647,14 +1656,14 @@ mod tests { &forward_class, None, false, - &ClassMemberPlan::new(&forward_class).statics, + &ClassMemberPlan::new(&forward_class, &context).statics, ); let reverse = generate_python_constructor( &context, &reverse_class, None, false, - &ClassMemberPlan::new(&reverse_class).statics, + &ClassMemberPlan::new(&reverse_class, &context).statics, ); assert_eq!(forward, reverse); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs index 66f611be..011afc38 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs @@ -11,20 +11,67 @@ //! which earlier names remain as aliases. Generators keep their own member //! order and emit a group where its first method appears. //! +//! All overloads of a WinRT method share one CLR (MethodDef) name, while +//! `[Overload]` gives each ABI slot a unique name. Python projects the +//! overloads of a CLR method as one dispatched method named after the CLR +//! name, so `IStorageFile.CopyAsync` is `copy_async` rather than +//! `copy_overload`, `copy_overload_default_options`, and so on. The +//! established suffix heuristics (`Foo2`, `FooOverload...`, `FooWithOptions` +//! next to `foo`) still merge on top of the CLR name. +//! +//! Names are planned per Python class namespace: a runtime class shares one +//! namespace between its static and instance members, properties, event +//! helpers, and generated members. The plan never removes a public name and +//! never changes which overload an existing name reaches. A CLR-name group +//! keeps its previous names when its new name would collide with another +//! member, or when an existing name would lose one of its overloads or gain an +//! overload that could take its calls. Every previously emitted method name +//! that is no longer a public method stays as a compatibility alias: of the +//! exact implementation it called when it was a standalone method, otherwise of +//! the dispatcher that reaches its overloads. +//! //! Interface implementation handlers keep one name per ABI slot and do not use //! this module. use std::cmp::Ordering; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use crate::codegen::winrt::shared::imports::get_in_params; -use crate::meta::{ClassMeta, InterfaceMeta, MethodMeta, ParamMeta}; +use crate::codegen::winrt::extensions::winui; +use crate::codegen::winrt::shared::imports::{get_in_params, ireference_inner_type}; +use crate::meta::{ + ClassMeta, ConstructorKind, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta, +}; +use crate::types::TypeMeta; -use super::naming::to_snake_case; +use super::collections::{ + class_interface, interface_kind, observable_vector_identity, runtime_mixin, type_kind, +}; +use super::naming::{PythonProjectionContext, to_snake_case}; +use super::native_types::{FoundationType, foundation_type}; use super::signature::py_dispatch_type_sort_key; const ICLOSABLE_IID: &str = "30d5a829-7fa4-4026-83bb-d75bae4ea99e"; +/// Members inherited from the `collections.abc` bases of generated collection mixins. +const COLLECTION_MIXIN_MEMBERS: &[&str] = &[ + "append", + "clear", + "count", + "extend", + "get", + "index", + "insert", + "items", + "keys", + "pop", + "popitem", + "remove", + "reverse", + "setdefault", + "update", + "values", +]; + /// One overload of a planned method group. pub(crate) struct Candidate<'a> { pub(crate) interface: &'a InterfaceMeta, @@ -62,6 +109,9 @@ pub(crate) struct ScopePlan<'a> { groups: Vec>, group_of: HashMap<*const MethodMeta, usize>, aliases: Vec>, + previous_attributes: HashMap<*const MethodMeta, String>, + #[cfg(test)] + fallbacks: Vec, } impl<'a> ScopePlan<'a> { @@ -103,10 +153,24 @@ impl<'a> ScopePlan<'a> { .map(|candidate| candidate.attribute.as_str()) } + /// The attribute that implemented `method` before CLR-name grouping. Used + /// only where generated dispatch order depended on attribute names. + pub(crate) fn previous_attribute(&self, method: &MethodMeta) -> Option<&str> { + self.previous_attributes + .get(&(method as *const MethodMeta)) + .map(String::as_str) + } + /// Previously emitted names kept as aliases, sorted by name. pub(crate) fn aliases(&self) -> &[Alias<'a>] { &self.aliases } + + /// CLR names that kept their previous Python names because of a collision. + #[cfg(test)] + pub(crate) fn fallbacks(&self) -> &[String] { + &self.fallbacks + } } /// Member plans for a runtime class; its static and instance members share @@ -117,14 +181,15 @@ pub(crate) struct ClassMemberPlan<'a> { } impl<'a> ClassMemberPlan<'a> { - pub(crate) fn new(class: &'a ClassMeta) -> Self { + pub(crate) fn new(class: &'a ClassMeta, context: &PythonProjectionContext) -> Self { let statics = class .factory_interfaces .iter() .chain(class.static_interfaces.iter()) .collect(); let instance = class_instance_interfaces(class).collect(); - let mut scopes = plan_scopes(&[statics, instance]).into_iter(); + let reserved = class_reserved_names(class, context); + let mut scopes = plan_scopes(&[statics, instance], &reserved).into_iter(); Self { statics: scopes.next().expect("static scope"), instance: scopes.next().expect("instance scope"), @@ -134,7 +199,7 @@ impl<'a> ClassMemberPlan<'a> { /// Member plan for an interface wrapper class. pub(crate) fn interface_member_plan(interface: &InterfaceMeta) -> ScopePlan<'_> { - plan_scopes(&[vec![interface]]) + plan_scopes(&[vec![interface]], &interface_reserved_names(interface)) .pop() .expect("interface scope") } @@ -155,11 +220,20 @@ pub(crate) fn is_accessor(method: &MethodMeta) -> bool { || method.is_event_remove } -/// Snake-case ABI name of a method. +/// Snake-case ABI name of a method: its public name before CLR-name grouping. fn abi_name(method: &MethodMeta) -> String { to_snake_case(&method.name) } +/// Snake-case CLR (MethodDef) name shared by every overload of a method. +fn clr_name(method: &MethodMeta) -> String { + if method.raw_name.is_empty() { + abi_name(method) + } else { + to_snake_case(&method.raw_name) + } +} + /// Merge `foo2`, `foo_overload...`, and `foo_with_options` into an existing `foo`. fn suffix_group_key(name: &str, names: &HashSet) -> String { let mut candidates = Vec::new(); @@ -206,97 +280,543 @@ pub(crate) fn private_overload_names<'a>( .collect() } -/// Plan scopes that share one Python class namespace. -fn plan_scopes<'a>(scopes: &[Vec<&'a InterfaceMeta>]) -> Vec> { - scopes +fn insert_accessor_names(method: &MethodMeta, is_static: bool, names: &mut HashSet) { + if is_static { + if method.is_property_getter && get_in_params(method).is_empty() { + let property = method.name.strip_prefix("get_").unwrap_or(&method.name); + names.insert(format!("get_{}", to_snake_case(property))); + } else if is_accessor(method) { + names.insert(abi_name(method)); + } + } else if method.is_property_getter { + names.insert(to_snake_case( + method.name.strip_prefix("get_").unwrap_or(&method.name), + )); + } else if method.is_property_setter { + let property = to_snake_case(method.name.strip_prefix("put_").unwrap_or(&method.name)); + names.insert(format!("set_{property}")); + names.insert(property); + } else if method.is_event_add || method.is_event_remove { + let event = to_snake_case( + method + .name + .strip_prefix("add_") + .or_else(|| method.name.strip_prefix("remove_")) + .unwrap_or(&method.name), + ); + for prefix in ["on", "off", "subscribe", "once"] { + names.insert(format!("{prefix}_{event}")); + } + } +} + +/// Non-method names in a runtime class namespace: accessors and generated members. +fn class_reserved_names(class: &ClassMeta, context: &PythonProjectionContext) -> HashSet { + let mut names = HashSet::from(["as_interface".to_string()]); + for method in class_instance_interfaces(class).flat_map(|interface| interface.methods.iter()) { + insert_accessor_names(method, false, &mut names); + } + for method in class + .factory_interfaces .iter() - .map(|interfaces| { - let methods = interfaces - .iter() - .flat_map(|interface| { - interface - .methods - .iter() - .map(move |method| (*interface, method)) + .chain(class.static_interfaces.iter()) + .flat_map(|interface| interface.methods.iter()) + { + insert_accessor_names(method, true, &mut names); + } + let factory_names = class + .factory_interfaces + .iter() + .flat_map(|interface| interface.methods.iter()) + .map(abi_name) + .collect::>(); + if class.has_default_activation() { + let default_constructor = if factory_names.iter().any(|name| name.starts_with("create")) { + "create_default" + } else { + "create" + }; + names.insert(default_constructor.to_string()); + } else if !factory_names.iter().any(|name| name == "create") + && class.factory_interfaces.iter().any(|interface| { + class.is_public_constructor_factory(interface) + && interface.methods.iter().any(|method| { + method.name == "CreateInstance" + && get_in_params(method).is_empty() + && matches!( + method.return_type.as_ref(), + Some(TypeMeta::RuntimeClass { namespace, name, .. }) + if namespace == &class.namespace && name == &class.name + ) }) - .filter(|(_, method)| !is_accessor(method)) - .collect::>(); - let names = methods - .iter() - .map(|(_, method)| abi_name(method)) - .collect::>(); - let keys = methods - .iter() - .map(|(_, method)| suffix_group_key(&abi_name(method), &names)) - .collect::>(); - build_scope(&methods, &keys) }) - .collect() + { + names.insert("create".to_string()); + } + if class + .required_interfaces + .iter() + .any(|interface| interface.iid == ICLOSABLE_IID) + { + names.insert("close".to_string()); + } + if crate::codegen::winrt::is_buffer_class(&class.namespace, &class.name) { + names.extend(["from_bytes".to_string(), "to_bytes".to_string()]); + } + if winui::is_dispatcher_queue(class) { + names.extend([ + "enqueue_async".to_string(), + "enqueue_with_priority_async".to_string(), + ]); + } + if winui::resolve_application_bootstrap(class, &context.known_full_names()).is_some() { + names.extend([ + "create".to_string(), + "create_with_metadata_provider".to_string(), + ]); + } + if class + .constructors + .iter() + .any(|constructor| constructor.kind == ConstructorKind::PublicComposition) + { + names.insert("register_xaml_runtime_class".to_string()); + } + if class_interface(class) + .and_then(interface_kind) + .and_then(runtime_mixin) + .is_some() + { + names.extend(COLLECTION_MIXIN_MEMBERS.iter().map(|name| name.to_string())); + } + names } -/// Build a scope plan from methods (in scope order) and their group keys. -fn build_scope<'a>( - methods: &[(&'a InterfaceMeta, &'a MethodMeta)], - keys: &[String], -) -> ScopePlan<'a> { - let mut grouped: Vec<(String, Vec<(&'a InterfaceMeta, &'a MethodMeta)>)> = Vec::new(); - for (&(interface, method), key) in methods.iter().zip(keys) { - match grouped.iter_mut().find(|(name, _)| name == key) { - Some((_, members)) => members.push((interface, method)), - None => grouped.push((key.clone(), vec![(interface, method)])), - } +/// Non-method names in an interface wrapper class: accessors and generated members. +fn interface_reserved_names(interface: &InterfaceMeta) -> HashSet { + let mut names = [ + "as_interface", + "as_vector", + "create", + "from_bytes", + "from_implementation", + "from_value", + "implement", + "implementation", + "release_callbacks", + "to_bytes", + ] + .into_iter() + .map(str::to_string) + .collect::>(); + for method in &interface.methods { + insert_accessor_names(method, false, &mut names); } - let mut groups = Vec::with_capacity(grouped.len()); - let mut group_of = HashMap::new(); - for (index, (name, mut members)) in grouped.into_iter().enumerate() { - members.sort_by(|(_, left), (_, right)| cmp_python_dispatch_methods(left, right)); - let attributes = if members.len() == 1 { - vec![name.clone()] - } else { - private_overload_names(&name, members.iter().map(|(_, method)| *method)) - }; - let candidates = members - .into_iter() - .zip(attributes) - .map(|((interface, method), attribute)| { - group_of.insert(method as *const MethodMeta, index); - Candidate { - interface, - method, - attribute, - } + if interface_kind(interface).and_then(runtime_mixin).is_some() + || observable_vector_identity(interface).is_some() + { + names.extend(COLLECTION_MIXIN_MEMBERS.iter().map(|name| name.to_string())); + } + names +} + +struct Entry<'a> { + interface: &'a InterfaceMeta, + method: &'a MethodMeta, + scope: usize, + abi: String, + abi_key: String, + clr_key: String, +} + +type Groups = Vec>>; + +/// Plan the scopes of one Python class namespace; `reserved` holds its +/// non-method member names. +fn plan_scopes<'a>( + scopes: &[Vec<&'a InterfaceMeta>], + reserved: &HashSet, +) -> Vec> { + let mut entries = Vec::new(); + for (scope, interfaces) in scopes.iter().enumerate() { + let methods = interfaces + .iter() + .flat_map(|interface| { + interface + .methods + .iter() + .map(move |method| (*interface, method)) }) - .collect(); - groups.push(MethodGroup { name, candidates }); + .filter(|(_, method)| !is_accessor(method)) + .collect::>(); + let abi_names = methods + .iter() + .map(|(_, method)| abi_name(method)) + .collect::>(); + let clr_names = methods + .iter() + .map(|(_, method)| clr_name(method)) + .collect::>(); + for (interface, method) in methods { + let abi = abi_name(method); + entries.push(Entry { + interface, + method, + scope, + abi_key: suffix_group_key(&abi, &abi_names), + clr_key: suffix_group_key(&clr_name(method), &clr_names), + abi, + }); + } } + let dispatch_order = |members: &[usize]| { + let mut ordered = members.to_vec(); + ordered.sort_by(|left, right| { + cmp_python_dispatch_methods(entries[*left].method, entries[*right].method) + }); + ordered + }; + let equivalent = |left: usize, right: usize| { + left == right || equivalent_overloads(entries[left].method, entries[right].method) + }; + // The overloads a group dispatched to, without duplicates that dispatch + // always shadowed (the same method on two interfaces). + let reachable = |members: &[usize]| { + let mut kept = Vec::::new(); + for index in dispatch_order(members) { + if !kept.iter().any(|&other| equivalent(other, index)) { + kept.push(index); + } + } + kept + }; - let canonical = keys.iter().collect::>(); - let mut targets = BTreeMap::new(); - for (&(_, method), key) in methods.iter().zip(keys) { - let name = abi_name(method); - if &name != key && !canonical.contains(&name) { - targets.entry(name).or_insert_with(|| key.clone()); + // Names emitted before CLR-name grouping, mapped to the group each reached. + let mut previous_groups: Groups = vec![BTreeMap::new(); scopes.len()]; + for (index, entry) in entries.iter().enumerate() { + previous_groups[entry.scope] + .entry(entry.abi_key.clone()) + .or_default() + .push(index); + } + let mut existing: Vec> = vec![BTreeMap::new(); scopes.len()]; + for (scope, groups) in previous_groups.iter().enumerate() { + for key in groups.keys() { + existing[scope].insert(key.clone(), key.clone()); } } - let aliases = targets - .into_iter() - .map(|(name, target)| Alias { - signatures: methods + for entry in &entries { + existing[entry.scope] + .entry(entry.abi.clone()) + .or_insert_with(|| entry.abi_key.clone()); + } + + let mut fallback = BTreeSet::<(usize, String)>::new(); + let groups = loop { + let key_of = |index: usize| -> &String { + let entry = &entries[index]; + if fallback.contains(&(entry.scope, entry.clr_key.clone())) { + &entry.abi_key + } else { + &entry.clr_key + } + }; + let mut groups: Groups = vec![BTreeMap::new(); scopes.len()]; + for (index, entry) in entries.iter().enumerate() { + groups[entry.scope] + .entry(key_of(index).clone()) + .or_default() + .push(index); + } + let mut blamed = BTreeSet::new(); + let can_fall_back = |members: &[usize]| { + members.iter().any(|&index| { + !fallback.contains(&(entries[index].scope, entries[index].clr_key.clone())) + }) + }; + let mut blame = |members: &[usize]| { + for &index in members { + let key = (entries[index].scope, entries[index].clr_key.clone()); + if !fallback.contains(&key) { + blamed.insert(key); + } + } + }; + let covers = |members: &[usize], index: usize| { + members.iter().any(|&member| equivalent(member, index)) + }; + // Whether a new overload could take a call that reached `expected`. + let takes_calls = |members: &[usize], expected: &[usize]| { + members.iter().any(|&member| { + !covers(expected, member) + && expected.iter().any(|&index| { + overloads_may_overlap(entries[member].method, entries[index].method) + }) + }) + }; + for scope in 0..scopes.len() { + for (name, previous_key) in &existing[scope] { + let previous_members = &previous_groups[scope][previous_key]; + let expected = reachable(previous_members); + if let Some(members) = groups[scope].get(name) { + // An existing public method keeps every overload it reached + // and gains none that could take its calls. + for &index in &expected { + if covers(members, index) { + continue; + } + if can_fall_back(members) { + blame(members); + } else { + // The name already kept its previous meaning; return + // the overload that moved to another CLR-name group. + blame(&groups[scope][key_of(index)]); + } + } + if takes_calls(members, &expected) { + blame(members); + } + } else if previous_members.len() > 1 { + // A former dispatcher becomes an alias of the dispatcher that + // now owns all of its overloads. + let targets = expected + .iter() + .map(|&index| key_of(index)) + .collect::>(); + for target in &targets { + let members = &groups[scope][*target]; + if targets.len() > 1 || takes_calls(members, &expected) { + blame(members); + } + } + } + } + for (name, members) in &groups[scope] { + if existing[scope].contains_key(name) { + continue; + } + let collides = reserved.contains(name) + || (0..scopes.len()).any(|other| { + other != scope + && (existing[other].contains_key(name) + || groups[other].contains_key(name)) + }); + if collides { + blame(members); + } + } + } + if blamed.is_empty() { + break groups; + } + fallback.extend(blamed); + }; + + let attributes = |name: &str, members: &[usize]| { + let ordered = dispatch_order(members); + let names = if ordered.len() == 1 { + vec![name.to_string()] + } else { + private_overload_names(name, ordered.iter().map(|&index| entries[index].method)) + }; + ordered.into_iter().zip(names).collect::>() + }; + (0..scopes.len()) + .map(|scope| { + let mut ordered_groups = groups[scope].iter().collect::>(); + ordered_groups.sort_by_key(|(_, members)| members[0]); + let mut plan_groups = Vec::with_capacity(ordered_groups.len()); + let mut group_of = HashMap::new(); + let mut attribute_of = HashMap::new(); + for (position, (name, members)) in ordered_groups.into_iter().enumerate() { + let candidates = attributes(name, members) + .into_iter() + .map(|(index, attribute)| { + let entry = &entries[index]; + group_of.insert(entry.method as *const MethodMeta, position); + attribute_of.insert(index, attribute.clone()); + Candidate { + interface: entry.interface, + method: entry.method, + attribute, + } + }) + .collect(); + plan_groups.push(MethodGroup { + name: name.clone(), + candidates, + }); + } + let previous_attributes = previous_groups[scope] .iter() - .filter(|(_, method)| abi_name(method) == name) - .map(|(_, method)| *method) - .collect(), - name, - target, + .flat_map(|(name, members)| attributes(name, members)) + .map(|(index, attribute)| (entries[index].method as *const MethodMeta, attribute)) + .collect(); + let aliases = existing[scope] + .iter() + .filter(|(name, _)| !groups[scope].contains_key(*name)) + .map(|(name, previous_key)| { + let previous_members = &previous_groups[scope][previous_key]; + // A former standalone method has no dispatch guards; bind + // its name to the exact implementation it called. + let target = if previous_members.len() == 1 { + attribute_of[&previous_members[0]].clone() + } else { + let expected = reachable(previous_members); + let entry = &entries[expected[0]]; + plan_groups[group_of[&(entry.method as *const MethodMeta)]] + .name + .clone() + }; + // Stubs keep the signatures each name declared before. + let signatures = if name == previous_key { + dispatch_order(previous_members) + } else { + previous_members + .iter() + .copied() + .filter(|&index| &entries[index].abi == name) + .collect() + }; + Alias { + name: name.clone(), + target, + signatures: signatures + .into_iter() + .map(|index| entries[index].method) + .collect(), + } + }) + .collect(); + ScopePlan { + groups: plan_groups, + group_of, + aliases, + previous_attributes, + #[cfg(test)] + fallbacks: fallback + .iter() + .filter(|(fallback_scope, key)| { + *fallback_scope == scope + && entries.iter().any(|entry| { + entry.scope == scope + && &entry.clr_key == key + && &entry.abi_key != key + }) + }) + .map(|(_, key)| key.clone()) + .collect(), + } }) - .collect(); - ScopePlan { - groups, - group_of, - aliases, + .collect() +} + +fn outputs(method: &MethodMeta) -> Vec<&TypeMeta> { + method + .params + .iter() + .filter(|param| param.direction == ParamDirection::Out) + .map(|param| ¶m.typ) + .chain(method.return_type.as_ref()) + .collect() +} + +/// Overloads that bind and behave identically, such as the same projected +/// method on two interfaces (`INumberFormatter.FormatInt` and `INumberFormatter2.FormatInt`). +fn equivalent_overloads(left: &MethodMeta, right: &MethodMeta) -> bool { + let left_params = get_in_params(left); + let right_params = get_in_params(right); + left_params.len() == right_params.len() + && left_params.iter().zip(&right_params).all(|(left, right)| { + to_snake_case(&left.name) == to_snake_case(&right.name) && left.typ == right.typ + }) + && outputs(left) == outputs(right) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum GuardDomain { + Bool, + Number, + Enum, + Text, + Guid, + DateTime, + TimeSpan, + Struct, + Sequence, + Collection, + Object, +} + +fn guard_domain(typ: &TypeMeta) -> GuardDomain { + match typ { + TypeMeta::Bool => GuardDomain::Bool, + TypeMeta::I8 + | TypeMeta::U8 + | TypeMeta::I16 + | TypeMeta::U16 + | TypeMeta::I32 + | TypeMeta::U32 + | TypeMeta::I64 + | TypeMeta::U64 + | TypeMeta::F32 + | TypeMeta::F64 => GuardDomain::Number, + TypeMeta::Enum { .. } => GuardDomain::Enum, + TypeMeta::Char16 | TypeMeta::String => GuardDomain::Text, + TypeMeta::Guid => GuardDomain::Guid, + typ if foundation_type(typ) == Some(FoundationType::DateTime) => GuardDomain::DateTime, + typ if foundation_type(typ) == Some(FoundationType::TimeSpan) => GuardDomain::TimeSpan, + TypeMeta::Struct { .. } => GuardDomain::Struct, + TypeMeta::Array(_) => GuardDomain::Sequence, + typ if type_kind(typ).is_some() => GuardDomain::Collection, + TypeMeta::Object + | TypeMeta::Interface { .. } + | TypeMeta::RuntimeClass { .. } + | TypeMeta::Delegate { .. } + | TypeMeta::Parameterized { .. } + | TypeMeta::AsyncAction + | TypeMeta::AsyncActionWithProgress(_) + | TypeMeta::AsyncOperation(_) + | TypeMeta::AsyncOperationWithProgress(_, _) => GuardDomain::Object, } } +/// Whether one value could satisfy the dispatch guards of both parameter types. +/// +/// Conservative: returns `true` unless the guards are provably disjoint. Enums +/// may use an integer guard when their type is not projected, and projected +/// objects are sequences or collections when they wrap WinRT collections. +fn guard_types_may_overlap(left: &TypeMeta, right: &TypeMeta) -> bool { + if left == right { + return true; + } + match (ireference_inner_type(left), ireference_inner_type(right)) { + (Some(_), Some(_)) => return true, + (Some(inner), None) => return guard_types_may_overlap(inner, right), + (None, Some(inner)) => return guard_types_may_overlap(left, inner), + (None, None) => {} + } + use GuardDomain::*; + match (guard_domain(left), guard_domain(right)) { + (Struct, Struct) => false, + (left, right) if left == right => true, + (Number, Enum) | (Enum, Number) => true, + (Sequence | Collection | Object, Sequence | Collection | Object) => true, + _ => false, + } +} + +/// Whether some positional call could satisfy the guards of both overloads. +fn overloads_may_overlap(left: &MethodMeta, right: &MethodMeta) -> bool { + let left_params = get_in_params(left); + let right_params = get_in_params(right); + left_params.len() == right_params.len() + && left_params + .iter() + .zip(&right_params) + .all(|(left, right)| guard_types_may_overlap(&left.typ, &right.typ)) +} + pub(crate) fn cmp_python_dispatch_methods(left: &MethodMeta, right: &MethodMeta) -> Ordering { cmp_python_dispatch_params(&get_in_params(left), &get_in_params(right)) .then_with(|| left.raw_name.cmp(&right.raw_name)) @@ -321,35 +841,113 @@ mod tests { use crate::types::TypeMeta; fn method(name: &str, vtable_index: usize, typ: TypeMeta) -> MethodMeta { + overload(name, name, vtable_index, &[("value", typ)]) + } + + fn overload( + name: &str, + raw_name: &str, + vtable_index: usize, + params: &[(&str, TypeMeta)], + ) -> MethodMeta { MethodMeta { name: name.into(), - raw_name: name.into(), + raw_name: raw_name.into(), vtable_index, - params: vec![ParamMeta { - name: "value".into(), - typ, - direction: ParamDirection::In, - }], + params: params + .iter() + .map(|(name, typ)| ParamMeta { + name: (*name).into(), + typ: typ.clone(), + direction: ParamDirection::In, + }) + .collect(), + return_type: Some(TypeMeta::String), ..Default::default() } } - fn interface(methods: Vec) -> InterfaceMeta { + fn interface_type(name: &str) -> TypeMeta { + TypeMeta::Interface { + namespace: "Contoso".into(), + name: name.into(), + iid: "11111111-1111-1111-1111-111111111111".into(), + } + } + + fn enumeration(name: &str) -> TypeMeta { + TypeMeta::Enum { + namespace: "Contoso".into(), + name: name.into(), + underlying: Box::new(TypeMeta::I32), + members: Vec::new(), + is_flags: false, + doc: None, + deprecated: None, + } + } + + fn interface(name: &str, methods: Vec) -> InterfaceMeta { InterfaceMeta { - name: "IWidget".into(), + name: name.into(), namespace: "Contoso".into(), methods, ..Default::default() } } - fn group_names(plan: &ScopePlan<'_>, interface: &InterfaceMeta) -> Vec { - plan.members(interface.methods.iter().map(|method| (interface, method))) - .into_iter() - .map(|member| match member { - PlannedMember::Group(group) => group.name.clone(), - PlannedMember::Accessor(_, method) => method.name.clone(), - }) + /// Owned view of a scope plan for assertions. + #[derive(Debug, Default, PartialEq)] + struct Planned { + /// `(ABI name, vtable) -> (group name, attribute)`. + methods: BTreeMap<(String, usize), (String, String)>, + aliases: Vec<(String, String)>, + fallbacks: Vec, + } + + impl Planned { + fn group(&self, name: &str, vtable_index: usize) -> &str { + &self.methods[&(name.to_string(), vtable_index)].0 + } + + fn attribute(&self, name: &str, vtable_index: usize) -> &str { + &self.methods[&(name.to_string(), vtable_index)].1 + } + } + + fn summarize(plan: &ScopePlan<'_>) -> Planned { + Planned { + methods: plan + .groups + .iter() + .flat_map(|group| { + group.candidates.iter().map(|candidate| { + ( + (candidate.method.name.clone(), candidate.method.vtable_index), + (group.name.clone(), candidate.attribute.clone()), + ) + }) + }) + .collect(), + aliases: plan + .aliases() + .iter() + .map(|alias| (alias.name.clone(), alias.target.clone())) + .collect(), + fallbacks: plan.fallbacks().to_vec(), + } + } + + fn plan_scope(methods: Vec, reserved: &[&str]) -> Planned { + let widget = interface("IWidget", methods); + let reserved = reserved.iter().map(|name| name.to_string()).collect(); + summarize(&plan_scopes(&[vec![&widget]], &reserved)[0]) + } + + fn aliases(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(name, target)| (name.to_string(), target.to_string())) .collect() } @@ -381,73 +979,456 @@ mod tests { #[test] fn python_overload_suffixes_merge_only_when_base_method_exists() { - let widget = interface(vec![ - method("CreateFileAsync", 6, TypeMeta::String), - method("CreateFileAsyncOverloadDefaultOptions", 7, TypeMeta::String), - method("RunEventLoopWithOptions", 8, TypeMeta::String), - ]); - let plan = interface_member_plan(&widget); + let planned = plan_scope( + vec![ + method("CreateFileAsync", 6, TypeMeta::String), + method("CreateFileAsyncOverloadDefaultOptions", 7, TypeMeta::String), + method("RunEventLoopWithOptions", 8, TypeMeta::String), + ], + &[], + ); + assert_eq!(planned.group("CreateFileAsync", 6), "create_file_async"); assert_eq!( - group_names(&plan, &widget), - ["create_file_async", "run_event_loop_with_options"] + planned.group("CreateFileAsyncOverloadDefaultOptions", 7), + "create_file_async" + ); + assert_eq!( + planned.group("RunEventLoopWithOptions", 8), + "run_event_loop_with_options" ); - let aliases = plan - .aliases() - .iter() - .map(|alias| (alias.name.as_str(), alias.target.as_str())) - .collect::>(); assert_eq!( - aliases, - [( + planned.aliases, + aliases(&[( "create_file_async_overload_default_options", "create_file_async" - )] + )]), + "a former alias keeps aliasing the dispatcher" ); + } + + #[test] + fn plan_orders_candidates_for_dispatch_and_names_private_overloads() { + let first = interface("IFirst", vec![method("Register", 6, TypeMeta::String)]); + let second = interface("ISecond", vec![method("Register", 6, TypeMeta::I32)]); + let registered = summarize(&plan_scopes(&[vec![&first, &second]], &HashSet::new())[0]); + let values = registered.methods.values().collect::>(); assert_eq!( - plan.attribute(&widget.methods[1]), - Some("_create_file_async_7") + values.len(), + 1, + "same ABI slot on two interfaces: {registered:?}" ); + + let plans = plan_scopes(&[vec![&first, &second]], &HashSet::new()); + let group = &plans[0].groups[0]; + let attributes = group + .candidates + .iter() + .map(|candidate| { + ( + candidate.method.params[0].typ.clone(), + candidate.attribute.as_str(), + ) + }) + .collect::>(); assert_eq!( - plan.attribute(&widget.methods[2]), - Some("run_event_loop_with_options") + attributes, + [ + (TypeMeta::String, "_register_6_0"), + (TypeMeta::I32, "_register_6_1"), + ] + ); + + let read = plan_scope( + vec![ + method("Read2", 7, TypeMeta::F64), + method("Read", 6, TypeMeta::I8), + ], + &[], ); + assert_eq!(read.group("Read2", 7), "read"); + assert_eq!(read.attribute("Read", 6), "_read_6"); + assert_eq!(read.attribute("Read2", 7), "_read_7"); } #[test] - fn plan_orders_candidates_for_dispatch_and_names_private_overloads() { - let first = interface(vec![method("Register", 6, TypeMeta::String)]); - let second = interface(vec![method("Register", 6, TypeMeta::I32)]); - let widget = interface(vec![ - method("Read2", 7, TypeMeta::F64), - method("Read", 6, TypeMeta::I8), - ]); - let mut plans = plan_scopes(&[vec![&first, &second], vec![&widget]]).into_iter(); - let registered = plans.next().unwrap(); - let read = plans.next().unwrap(); - - let PlannedMember::Group(group) = - ®istered.members([(&first, &first.methods[0]), (&second, &second.methods[0])])[0] - else { - panic!("expected a method group"); - }; - let attributes = group - .candidates + fn clr_name_groups_overloads_without_a_documented_python_name() { + let folder = interface_type("IStorageFolder"); + let planned = plan_scope( + vec![ + overload( + "CopyOverloadDefaultNameAndOptions", + "CopyAsync", + 8, + &[("destination_folder", folder.clone())], + ), + overload( + "CopyOverloadDefaultOptions", + "CopyAsync", + 9, + &[ + ("destination_folder", folder.clone()), + ("desired_new_name", TypeMeta::String), + ], + ), + overload( + "CopyOverload", + "CopyAsync", + 10, + &[ + ("destination_folder", folder), + ("desired_new_name", TypeMeta::String), + ("option", enumeration("NameCollisionOption")), + ], + ), + ], + &[], + ); + + for vtable_index in 8..=10 { + let name = [ + "CopyOverloadDefaultNameAndOptions", + "CopyOverloadDefaultOptions", + "CopyOverload", + ][vtable_index - 8]; + assert_eq!(planned.group(name, vtable_index), "copy_async"); + } + assert_eq!( + planned.aliases, + aliases(&[ + ("copy_overload", "_copy_async_10"), + ("copy_overload_default_name_and_options", "_copy_async_8"), + ("copy_overload_default_options", "_copy_async_9"), + ]), + "former standalone methods alias the exact implementation they called" + ); + assert!(planned.fallbacks.is_empty()); + } + + #[test] + fn clr_name_renames_single_overloads_and_keeps_the_abi_name_as_alias() { + let planned = plan_scope( + vec![overload( + "LaunchUriWithDataAsync", + "LaunchUriAsync", + 8, + &[("uri", TypeMeta::String)], + )], + &[], + ); + + assert_eq!( + planned.group("LaunchUriWithDataAsync", 8), + "launch_uri_async" + ); + assert_eq!( + planned.attribute("LaunchUriWithDataAsync", 8), + "launch_uri_async" + ); + assert_eq!( + planned.aliases, + aliases(&[("launch_uri_with_data_async", "launch_uri_async")]) + ); + } + + #[test] + fn clr_name_keeps_real_methods_that_share_an_overload_name() { + let format_int = interface( + "INumberFormatter", + vec![ + overload("FormatInt", "Format", 6, &[("value", TypeMeta::I64)]), + overload("FormatUInt", "Format", 7, &[("value", TypeMeta::U64)]), + ], + ); + let real = interface( + "INumberFormatter2", + vec![ + overload("FormatInt", "FormatInt", 6, &[("value", TypeMeta::I64)]), + overload("FormatUInt", "FormatUInt", 7, &[("value", TypeMeta::U64)]), + ], + ); + let plans = plan_scopes(&[vec![&format_int, &real]], &HashSet::new()); + let planned = summarize(&plans[0]); + + let groups = plans[0] + .groups .iter() - .map(|candidate| candidate.attribute.as_str()) + .map(|group| { + ( + group.name.as_str(), + group + .candidates + .iter() + .map(|candidate| candidate.interface.name.as_str()) + .collect::>(), + ) + }) .collect::>(); - assert_eq!(attributes, ["_register_6_0", "_register_6_1"]); - assert_eq!(group.candidates[0].method.params[0].typ, TypeMeta::String); + assert_eq!( + groups, + [ + ("format", vec!["INumberFormatter", "INumberFormatter"]), + ("format_int", vec!["INumberFormatter2"]), + ("format_u_int", vec!["INumberFormatter2"]), + ] + ); + assert!(planned.aliases.is_empty(), "{planned:?}"); + assert!(planned.fallbacks.is_empty(), "{planned:?}"); + } - let PlannedMember::Group(group) = &read.members([(&widget, &widget.methods[0])])[0] else { - panic!("expected a method group"); - }; - assert_eq!(group.name, "read"); - let order = group + #[test] + fn clr_name_does_not_split_distinct_overloads_of_an_existing_name() { + // Same shape as INumberFormatter/INumberFormatter2, but the overloads + // differ (parameter names), so `format_int` must keep reaching both. + let clr = interface( + "IFirst", + vec![ + overload("FormatInt", "Format", 6, &[("value", TypeMeta::I64)]), + overload("FormatDouble", "Format", 7, &[("value", TypeMeta::F64)]), + ], + ); + let abi = interface( + "ISecond", + vec![overload( + "FormatInt", + "FormatInt", + 6, + &[("number", TypeMeta::I64)], + )], + ); + let plans = plan_scopes(&[vec![&clr, &abi]], &HashSet::new()); + let planned = summarize(&plans[0]); + + assert_eq!(planned.group("FormatDouble", 7), "format_double"); + let format_int = plans[0] + .groups + .iter() + .find(|group| group.name == "format_int") + .expect("format_int group"); + let interfaces = format_int .candidates .iter() - .map(|candidate| candidate.method.name.as_str()) + .map(|candidate| candidate.interface.name.as_str()) .collect::>(); - assert_eq!(order, ["Read", "Read2"]); + assert_eq!(interfaces, ["IFirst", "ISecond"], "{planned:?}"); + assert_eq!(planned.fallbacks, ["format"]); + } + + #[test] + fn clr_name_grouping_keeps_existing_suffix_merges() { + let planned = plan_scope( + vec![ + overload("Read", "Read", 6, &[("value", TypeMeta::String)]), + overload("Read2", "Read2", 7, &[("value", TypeMeta::I32)]), + overload("ReadWithHint", "Read", 8, &[("value", TypeMeta::Bool)]), + ], + &[], + ); + + assert_eq!(planned.group("Read2", 7), "read"); + assert_eq!(planned.group("ReadWithHint", 8), "read"); + assert_eq!(planned.attribute("Read2", 7), "_read_7"); + assert_eq!( + planned.aliases, + aliases(&[("read2", "read"), ("read_with_hint", "_read_8")]) + ); + } + + #[test] + fn clr_name_falls_back_when_the_name_is_a_property_or_generated_member() { + let getter = MethodMeta { + name: "get_Source".into(), + raw_name: "get_Source".into(), + vtable_index: 8, + return_type: Some(TypeMeta::String), + is_property_getter: true, + ..Default::default() + }; + let mut reserved = HashSet::from(["close".to_string()]); + insert_accessor_names(&getter, false, &mut reserved); + let widget = interface( + "IWidget", + vec![ + overload("CloseWithStatus", "Close", 6, &[("code", TypeMeta::U16)]), + overload( + "SetSourceWithOptions", + "Source", + 7, + &[("value", TypeMeta::String)], + ), + getter, + ], + ); + let plans = plan_scopes(&[vec![&widget]], &reserved); + let planned = summarize(&plans[0]); + + assert_eq!(planned.group("CloseWithStatus", 6), "close_with_status"); + assert_eq!( + planned.group("SetSourceWithOptions", 7), + "set_source_with_options" + ); + assert!(planned.aliases.is_empty()); + assert_eq!(planned.fallbacks, ["close", "source"]); + let members = plans[0].members(widget.methods.iter().map(|method| (&widget, method))); + assert!( + matches!(members[2], PlannedMember::Accessor(_, method) if method.name == "get_Source") + ); + } + + #[test] + fn clr_name_falls_back_when_the_other_scope_owns_the_name() { + let statics = interface( + "IWidgetStatics", + vec![ + overload("CopyFromAsync", "CopyAsync", 7, &[("value", TypeMeta::I32)]), + overload("MergeWith", "Merge", 8, &[("value", TypeMeta::I32)]), + ], + ); + let instance = interface( + "IWidget", + vec![ + overload("CopyAsync", "CopyAsync", 6, &[("value", TypeMeta::String)]), + overload("Merge", "Merge", 9, &[("value", TypeMeta::String)]), + ], + ); + let plans = plan_scopes(&[vec![&statics], vec![&instance]], &HashSet::new()); + let static_plan = summarize(&plans[0]); + let instance_plan = summarize(&plans[1]); + + assert_eq!(static_plan.group("CopyFromAsync", 7), "copy_from_async"); + assert_eq!(static_plan.group("MergeWith", 8), "merge_with"); + assert_eq!(instance_plan.group("CopyAsync", 6), "copy_async"); + assert_eq!(instance_plan.group("Merge", 9), "merge"); + assert_eq!(static_plan.fallbacks, ["copy_async", "merge"]); + assert!(static_plan.aliases.is_empty()); + } + + #[test] + fn clr_name_falls_back_when_an_existing_name_would_change_meaning() { + // `CreateUpdater()` is an overload name of `CreateUpdaterForUser`, while + // `CreateUpdater(String)` is named `CreateUpdaterWithId`. + let planned = plan_scope( + vec![ + overload("CreateUpdater", "CreateUpdaterForUser", 6, &[]), + overload( + "CreateUpdaterWithId", + "CreateUpdater", + 7, + &[("id", TypeMeta::String)], + ), + ], + &[], + ); + + assert_eq!(planned.group("CreateUpdater", 6), "create_updater_for_user"); + assert_eq!( + planned.group("CreateUpdaterWithId", 7), + "create_updater_with_id" + ); + assert_eq!( + planned.aliases, + aliases(&[("create_updater", "create_updater_for_user")]) + ); + assert_eq!(planned.fallbacks, ["create_updater"]); + } + + #[test] + fn clr_name_falls_back_when_a_new_overload_could_take_existing_calls() { + let by_interface = overload("Show", "Show", 6, &[("target", interface_type("ITarget"))]); + let by_text = overload( + "ShowText", + "Show", + 8, + &[("target", TypeMeta::String), ("mode", TypeMeta::I32)], + ); + let planned = plan_scope( + vec![ + by_interface.clone(), + overload("ShowObject", "Show", 7, &[("target", TypeMeta::Object)]), + by_text.clone(), + ], + &[], + ); + assert_eq!(planned.group("Show", 6), "show"); + assert_eq!(planned.group("ShowObject", 7), "show_object"); + assert_eq!(planned.group("ShowText", 8), "show_text"); + assert_eq!(planned.fallbacks, ["show"]); + + let planned = plan_scope( + vec![ + by_interface, + overload("ShowKind", "Show", 9, &[("target", enumeration("Kind"))]), + by_text, + ], + &[], + ); + assert_eq!(planned.group("ShowKind", 9), "show"); + assert_eq!(planned.group("ShowText", 8), "show"); + assert_eq!( + planned.aliases, + aliases(&[("show_kind", "_show_9"), ("show_text", "_show_8")]) + ); + } + + #[test] + fn former_dispatchers_alias_the_dispatcher_that_owns_their_overloads() { + let planned = plan_scope( + vec![ + overload( + "TryUpdatePosition", + "TryUpdatePosition", + 6, + &[("value", TypeMeta::F32)], + ), + overload( + "TryUpdatePositionWithOption", + "TryUpdatePosition", + 7, + &[ + ("value", TypeMeta::F32), + ("option", enumeration("Clamping")), + ], + ), + overload( + "TryUpdatePositionWithOption", + "TryUpdatePosition", + 8, + &[ + ("value", TypeMeta::F32), + ("option", enumeration("Clamping")), + ("update", enumeration("Update")), + ], + ), + ], + &[], + ); + + assert_eq!( + planned.group("TryUpdatePositionWithOption", 8), + "try_update_position" + ); + assert_eq!( + planned.aliases, + aliases(&[("try_update_position_with_option", "try_update_position")]) + ); + } + + #[test] + fn previous_attributes_keep_pre_clr_names_for_ordering() { + let widget = interface( + "IWidget", + vec![ + overload("CreateWithName", "Create", 6, &[("name", TypeMeta::String)]), + overload("Create", "Create", 7, &[]), + ], + ); + let plan = &plan_scopes(&[vec![&widget]], &HashSet::new())[0]; + + assert_eq!(plan.attribute(&widget.methods[0]), Some("_create_6")); + assert_eq!( + plan.previous_attribute(&widget.methods[0]), + Some("create_with_name") + ); + assert_eq!(plan.previous_attribute(&widget.methods[1]), Some("create")); } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index 8033916c..e3d43c4d 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -927,7 +927,7 @@ mod tests { .collect(), ..Default::default() }; - let plan = ClassMemberPlan::new(&class); + let plan = ClassMemberPlan::new(&class, context); let members = class_instance_interfaces(&class) .flat_map(|iface| iface.methods.iter().map(move |method| (iface, method))); let Some(PlannedMember::Group(group)) = plan.instance.members(members).into_iter().next() @@ -962,7 +962,7 @@ mod tests { static_interfaces: vec![interface("IFactoryStatics", methods)], ..Default::default() }; - let plan = ClassMemberPlan::new(&class); + let plan = ClassMemberPlan::new(&class, context); let members = class .static_interfaces .iter() diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs index 6b9b14b8..7559e051 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs @@ -660,7 +660,7 @@ pub fn generate_class_stub<'a>( let projectable = super::has_projectable_default_interface(class); let native_projectable = super::has_native_projector(class); let supports_interface_projection = projectable || !class.required_interfaces.is_empty(); - let plan = ClassMemberPlan::new(class); + let plan = ClassMemberPlan::new(class, context); let mut out = String::new(); out.push_str(HEADER); @@ -1487,18 +1487,24 @@ fn emit_constructor_stubs(class: &ClassMeta, context: &PythonProjectionContext) return out; } overloads.sort_by(|left, right| super::member_plan::cmp_python_dispatch_params(left, right)); - let count = overloads.len(); + let mut signatures = HashSet::new(); for params in &overloads { if count > 1 { out.push_str(" @overload\n"); } let param_str = super::type_helpers::py_param_list(params, context); + let duplicate = !signatures.insert(param_str.clone()); + let ignore = if duplicate { + " # type: ignore[overload-cannot-match]" + } else { + "" + }; if param_str.is_empty() { - out.push_str(" def __init__(self) -> None: ...\n"); + out.push_str(&format!(" def __init__(self) -> None: ...{ignore}\n")); } else { out.push_str(&format!( - " def __init__(self, {param_str}) -> None: ...\n" + " def __init__(self, {param_str}) -> None: ...{ignore}\n" )); } } @@ -1562,6 +1568,50 @@ fn has_constructor_stub_overload(class: &ClassMeta) -> bool { }) } +/// Preserve one declaration per WinRT overload. When two overloads collapse to +/// the same Python signature (for example Int64 and UInt64 both become `int`), +/// mark the later declaration so strict type checkers accept the metadata-exact +/// overload count without reporting it as unreachable. +fn typed_signatures<'m>( + methods: impl IntoIterator, + return_type: impl Fn(&MethodMeta) -> String, + context: &PythonProjectionContext, +) -> Vec<(&'m MethodMeta, bool)> { + let mut signatures = HashSet::new(); + methods + .into_iter() + .map(|method| { + let signature = ( + super::type_helpers::py_param_list( + &crate::codegen::winrt::shared::imports::get_in_params(method), + context, + ), + return_type(method), + ); + (method, !signatures.insert(signature)) + }) + .collect() +} + +fn ignore_unreachable_overload(mut stub: String) -> String { + let definition = stub.find("def ").expect("method stub has a definition"); + let line_end = definition + + stub[definition..] + .find('\n') + .unwrap_or(stub.len() - definition); + let line = &stub[definition..line_end]; + if let Some(ignore) = line.find("# type: ignore[") { + let close = line[ignore..] + .find(']') + .map(|offset| definition + ignore + offset) + .expect("type ignore has a closing bracket"); + stub.insert_str(close, ", overload-cannot-match"); + } else { + stub.insert_str(line_end, " # type: ignore[overload-cannot-match]"); + } + stub +} + fn emit_instance_stub_group( group: &MethodGroup<'_>, context: &PythonProjectionContext, @@ -1569,13 +1619,17 @@ fn emit_instance_stub_group( overrides_mutable_sequence: bool, ) -> String { let indent = " ".repeat(indent_spaces); - let overloaded = group.candidates.len() > 1; - group - .candidates - .iter() - .map(|candidate| { - let stub = emit_method_stub_named( - candidate.method, + let methods = typed_signatures( + group.candidates.iter().map(|candidate| candidate.method), + |method| super::type_helpers::py_method_return_type(method, context), + context, + ); + let overloaded = methods.len() > 1; + methods + .into_iter() + .map(|(method, duplicate)| { + let mut stub = emit_method_stub_named( + method, context, indent_spaces, Some(&group.name), @@ -1583,6 +1637,9 @@ fn emit_instance_stub_group( true, overrides_mutable_sequence, ); + if duplicate { + stub = ignore_unreachable_overload(stub); + } if overloaded { format!("{indent}@overload\n{stub}") } else { @@ -1601,11 +1658,16 @@ fn emit_instance_compatibility_alias_stubs( let indent = " ".repeat(indent_spaces); let mut out = String::new(); for alias in plan.aliases() { - for method in &alias.signatures { - if alias.signatures.len() > 1 { + let methods = typed_signatures( + alias.signatures.iter().copied(), + |method| super::type_helpers::py_method_return_type(method, context), + context, + ); + for (method, duplicate) in &methods { + if methods.len() > 1 { out.push_str(&format!("{indent}@overload\n")); } - out.push_str(&emit_method_stub_named( + let mut stub = emit_method_stub_named( method, context, indent_spaces, @@ -1613,7 +1675,11 @@ fn emit_instance_compatibility_alias_stubs( false, true, overrides_mutable_sequence, - )); + ); + if *duplicate { + stub = ignore_unreachable_overload(stub); + } + out.push_str(&stub); } } out @@ -1644,40 +1710,69 @@ fn emit_static_compatibility_alias_stubs( let indent = " ".repeat(indent_spaces); let mut out = String::new(); for alias in plan.aliases() { - for method in &alias.signatures { - if alias.signatures.len() > 1 { + let methods = typed_signatures( + alias.signatures.iter().copied(), + |method| static_return_type(class_name, class, method, context), + context, + ); + for (method, duplicate) in &methods { + if methods.len() > 1 { out.push_str(&format!("{indent}@overload\n")); } - out.push_str(&emit_static_method_stub_named( + let mut stub = emit_static_method_stub_named( class_name, method, context, is_factory_method(class, method), Some(&alias.name), - )); + ); + if *duplicate { + stub = ignore_unreachable_overload(stub); + } + out.push_str(&stub); } } out } +fn static_return_type( + class_name: &str, + class: &ClassMeta, + method: &MethodMeta, + context: &PythonProjectionContext, +) -> String { + if is_factory_method(class, method) { + super::type_helpers::py_factory_return_type(class_name, method, context) + } else { + super::type_helpers::py_method_return_type(method, context) + } +} + fn emit_static_stub_group( class_name: &str, class: &ClassMeta, group: &MethodGroup<'_>, context: &PythonProjectionContext, ) -> String { - let overloaded = group.candidates.len() > 1; - group - .candidates - .iter() - .map(|candidate| { - let stub = emit_static_method_stub_named( + let methods = typed_signatures( + group.candidates.iter().map(|candidate| candidate.method), + |method| static_return_type(class_name, class, method, context), + context, + ); + let overloaded = methods.len() > 1; + methods + .into_iter() + .map(|(method, duplicate)| { + let mut stub = emit_static_method_stub_named( class_name, - candidate.method, + method, context, - is_factory_interface(class, candidate.interface), + is_factory_method(class, method), Some(&group.name), ); + if duplicate { + stub = ignore_unreachable_overload(stub); + } if overloaded { format!(" @overload\n{stub}") } else { diff --git a/tools/dynwinrt-codegen/tests/python_constructor_boundary_test.rs b/tools/dynwinrt-codegen/tests/python_constructor_boundary_test.rs index 9b73889b..e7686ca7 100644 --- a/tools/dynwinrt-codegen/tests/python_constructor_boundary_test.rs +++ b/tools/dynwinrt-codegen/tests/python_constructor_boundary_test.rs @@ -400,7 +400,16 @@ fn numeric_constructor_overloads_dispatch_by_specificity() { ); let pyi = common::generate_class_stub(&class, &known, &HashSet::new(), &HashSet::new()); + assert_eq!( + pyi.matches("def __init__(self, value: int) -> None: ...") + .count(), + 2, + "both ABI constructors stay declared even though I8 and I32 project as `int`:\n{pyi}" + ); assert_eq!(pyi.matches(" @overload\n").count(), 4, "{pyi}"); + assert!(pyi.contains( + "def __init__(self, value: int) -> None: ... # type: ignore[overload-cannot-match]" + )); } #[test] diff --git a/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs b/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs index da6eebdc..1bffadc5 100644 --- a/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs +++ b/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs @@ -3,10 +3,12 @@ mod common; -use std::collections::HashSet; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::Path; -use dynwinrt_codegen::meta::{self, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta}; +use dynwinrt_codegen::meta::{ + self, ClassMeta, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta, +}; use dynwinrt_codegen::types::{TypeMeta, TypeMeta::AsyncOperation}; const WINDOWS_WINMD: &str = @@ -130,3 +132,643 @@ fn real_storage_folder_default_options_method_is_normalized() { ); assert_eq!(stub.matches("def create_file_async(").count(), 2, "{stub}"); } + +/// Port of the generator's `to_snake_case` for the ABI names used below. +fn to_snake_case(name: &str) -> String { + let characters = name.chars().collect::>(); + let mut result = String::new(); + for (index, &character) in characters.iter().enumerate() { + if character.is_uppercase() { + if index > 0 { + let previous = characters[index - 1]; + let next_lower = characters + .get(index + 1) + .is_some_and(|next| next.is_lowercase()); + if previous.is_lowercase() + || previous.is_ascii_digit() + || (next_lower && previous.is_uppercase()) + { + result.push('_'); + } + } + result.extend(character.to_lowercase()); + } else { + result.push(character); + } + } + let tokens = result + .trim_start_matches('_') + .split('_') + .collect::>(); + let mut merged = Vec::new(); + let mut index = 0; + while index < tokens.len() { + if tokens[index] == "u" + && tokens + .get(index + 1) + .is_some_and(|next| ["int8", "int16", "int32", "int64"].contains(next)) + { + merged.push(format!("u{}", tokens[index + 1])); + index += 2; + } else { + merged.push(tokens[index].to_string()); + index += 1; + } + } + let result = merged.join("_"); + if ["from", "import", "global", "print", "lambda", "pass", "del"].contains(&result.as_str()) { + format!("{result}_") + } else { + result + } +} + +/// Public members of `class_name` and its `Like` protocol. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct Member { + definitions: usize, + overloads: usize, + assigned: bool, +} + +fn class_members(code: &str, class_name: &str) -> BTreeMap { + let owners = [class_name.to_string(), format!("{class_name}Like")]; + let mut members = BTreeMap::::new(); + let mut inside = false; + let mut decorated = false; + for line in code.lines() { + if let Some(header) = line.strip_prefix("class ") { + let name = header + .split(|character| character == '(' || character == ':') + .next() + .unwrap_or_default(); + inside = owners.iter().any(|owner| owner == name); + continue; + } + if !line.is_empty() && !line.starts_with(' ') { + inside = false; + } + if !inside { + continue; + } + let Some(member) = line.strip_prefix(" ") else { + continue; + }; + if member == "@overload" { + decorated = true; + continue; + } + if member.starts_with('@') { + continue; + } + if let Some(definition) = member.strip_prefix("def ") { + let name = definition.split('(').next().unwrap_or_default().to_string(); + let entry = members.entry(name).or_default(); + entry.definitions += 1; + entry.overloads += usize::from(decorated); + } else if let Some((name, _)) = member.split_once(" = ") + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') + { + members.entry(name.to_string()).or_default().assigned = true; + } + decorated = false; + } + members.retain(|name, _| !name.starts_with('_')); + members +} + +fn single_definition() -> Member { + Member { + definitions: 1, + overloads: 0, + assigned: false, + } +} + +fn real_class(namespace: &str, name: &str) -> Option<(ClassMeta, String, String)> { + let class = meta::parse_class(WINDOWS_WINMD, namespace, name)?; + let deps = meta::resolve_python_dependencies(WINDOWS_WINMD, &[class.clone()], &[], &[]); + let mut known = HashSet::from([class.name.clone()]); + known.extend(deps.classes.iter().map(|class| class.name.clone())); + known.extend( + deps.interfaces + .iter() + .map(|interface| interface.name.clone()), + ); + known.extend(deps.enums.iter().filter_map(|typ| match typ { + TypeMeta::Enum { name, .. } => Some(name.clone()), + _ => None, + })); + let runtime = common::generate_class(&class, &known, &HashSet::new(), &HashSet::new()); + let stub = common::generate_class_stub(&class, &known, &HashSet::new(), &HashSet::new()); + Some((class, runtime, stub)) +} + +/// Every public method name emitted before CLR-name grouping: the snake-case ABI +/// names, which were either dispatcher names or their compatibility aliases. +fn previous_method_names(class: &ClassMeta) -> BTreeSet { + class + .factory_interfaces + .iter() + .chain(class.static_interfaces.iter()) + .chain(class.default_interface.iter()) + .chain(class.required_interfaces.iter()) + .filter(|interface| interface.iid != "30d5a829-7fa4-4026-83bb-d75bae4ea99e") + .flat_map(|interface| interface.methods.iter()) + .filter(|method| { + !method.is_property_getter + && !method.is_property_setter + && !method.is_event_add + && !method.is_event_remove + }) + .map(|method| to_snake_case(&method.name)) + .collect() +} + +const REPRESENTATIVE_CLASSES: &[(&str, &str)] = &[ + ("Windows.Storage", "StorageFile"), + ("Windows.Storage", "StorageFolder"), + ("Windows.UI.Notifications", "ToastNotifier"), + ("Windows.UI.Notifications", "TileUpdateManagerForUser"), + ("Windows.Globalization.NumberFormatting", "DecimalFormatter"), + ("Windows.System", "Launcher"), + ("Windows.Web.Http", "HttpClient"), + ("Windows.Globalization", "Calendar"), + ("Windows.Data.Xml.Dom", "XmlDocument"), + ("Windows.Storage.Streams", "RandomAccessStream"), + ("Windows.Storage.Streams", "DataWriter"), + ("Windows.Networking.Sockets", "StreamSocket"), + ("Windows.Networking.Sockets", "StreamWebSocket"), + ("Windows.UI.Composition.Interactions", "InteractionTracker"), + ("Windows.UI.Xaml", "PropertyMetadata"), + ("Windows.Devices.Enumeration", "DeviceInformation"), +]; + +#[test] +fn real_classes_keep_every_previous_public_method_name() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + for (namespace, name) in REPRESENTATIVE_CLASSES { + let (class, runtime, stub) = real_class(namespace, name).expect("class metadata"); + let runtime_members = class_members(&runtime, name); + let stub_members = class_members(&stub, name); + for previous in previous_method_names(&class) { + assert!( + runtime_members.contains_key(&previous), + "{namespace}.{name}.py lost `{previous}`:\n{runtime}" + ); + assert!( + stub_members.contains_key(&previous), + "{namespace}.{name}.pyi lost `{previous}`:\n{stub}" + ); + } + } +} + +#[test] +fn real_classes_project_overloads_under_documented_clr_names() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + // (class, documented name, overloads, previous names kept as aliases) + let expectations: &[(&str, &str, &str, usize, &[&str])] = &[ + ( + "Windows.Storage", + "StorageFile", + "copy_async", + 3, + &[ + "copy_overload", + "copy_overload_default_options", + "copy_overload_default_name_and_options", + ], + ), + ( + "Windows.Storage", + "StorageFile", + "move_async", + 3, + &[ + "move_overload", + "move_overload_default_options", + "move_overload_default_name_and_options", + ], + ), + ( + "Windows.UI.Notifications", + "ToastNotifier", + "update", + 2, + &["update_with_tag", "update_with_tag_and_group"], + ), + ( + "Windows.Globalization.NumberFormatting", + "DecimalFormatter", + "format", + 3, + &[], + ), + ( + "Windows.System", + "Launcher", + "launch_file_async", + 2, + &["launch_file_with_options_async"], + ), + ( + "Windows.System", + "Launcher", + "launch_uri_async", + 3, + &[ + "launch_uri_with_options_async", + "launch_uri_with_data_async", + ], + ), + ( + "Windows.Web.Http", + "HttpClient", + "get_async", + 2, + &["get_with_option_async"], + ), + ( + "Windows.Globalization", + "Calendar", + "month_as_string", + 2, + &["month_as_full_string"], + ), + ( + "Windows.Data.Xml.Dom", + "XmlDocument", + "load_xml", + 2, + &["load_xml_with_settings"], + ), + ( + "Windows.Storage.Streams", + "RandomAccessStream", + "copy_async", + 2, + &["copy_size_async"], + ), + ]; + let mut generated = BTreeMap::new(); + for (namespace, name, documented, overloads, aliases) in expectations { + let (_, runtime, stub) = generated + .entry((*namespace, *name)) + .or_insert_with(|| real_class(namespace, name).expect("class metadata")); + let runtime_members = class_members(runtime, name); + let stub_members = class_members(stub, name); + assert_eq!( + runtime_members.get(*documented), + Some(&single_definition()), + "{name}.{documented} must be one runtime dispatcher:\n{runtime}" + ); + assert!( + runtime.contains(&format!("def {documented}(self, *args, **kwargs):")) + || runtime.contains(&format!("def {documented}(*args, **kwargs):")), + "{name}.{documented} must dispatch overloads:\n{runtime}" + ); + assert_eq!( + stub_members.get(*documented), + Some(&Member { + definitions: *overloads, + overloads: *overloads, + assigned: false, + }), + "{name}.{documented} must declare {overloads} overloads:\n{stub}" + ); + for alias in *aliases { + assert_eq!( + runtime_members.get(*alias).map(|member| member.assigned), + Some(true), + "{name}.{alias} must stay available as an alias:\n{runtime}" + ); + assert!( + stub_members.contains_key(*alias), + "{name}.{alias} must stay typed:\n{stub}" + ); + } + } + + // The runtime dispatcher keeps all three ABI overloads, trying Int64 before + // UInt64 (larger values) and Double. + let (_, runtime, _) = + &generated[&("Windows.Globalization.NumberFormatting", "DecimalFormatter")]; + let dispatcher = &runtime[runtime + .find(" def format(self, *args, **kwargs):") + .unwrap()..]; + let order = ["self._format_6(", "self._format_7(", "self._format_8("].map(|call| { + dispatcher + .find(call) + .unwrap_or_else(|| panic!("{call}:\n{runtime}")) + }); + assert!(order.is_sorted(), "{runtime}"); + assert!(runtime.contains("from_i64(value)") && runtime.contains("from_u64(value)")); + + // INumberFormatter2's FormatInt/FormatUInt/FormatDouble are real methods, not + // aliases of the INumberFormatter.Format overloads. + let (_, runtime, _) = + &generated[&("Windows.Globalization.NumberFormatting", "DecimalFormatter")]; + let members = class_members(runtime, "DecimalFormatter"); + for method in ["format_int", "format_u_int", "format_double"] { + assert_eq!(members.get(method), Some(&single_definition()), "{runtime}"); + } + assert!( + runtime.contains("return _INumberFormatter2.method(6)"), + "{runtime}" + ); +} + +#[test] +fn real_collisions_keep_previous_python_names() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + // IWebSocket.CloseWithStatus is a `Close` overload, but `close()` is the + // generated IClosable member of every WebSocket runtime class. + let (_, runtime, stub) = + real_class("Windows.Networking.Sockets", "StreamWebSocket").expect("class metadata"); + let members = class_members(&runtime, "StreamWebSocket"); + assert_eq!( + members.get("close_with_status"), + Some(&single_definition()), + "{runtime}" + ); + assert!(runtime.contains(" def close(self):\n"), "{runtime}"); + assert!( + stub.contains("def close_with_status(self, code: int, reason: str)"), + "{stub}" + ); + + // `CreateTileUpdaterForApplication()` is an overload name of + // `CreateTileUpdaterForApplicationForUser`, so the documented + // `CreateTileUpdaterForApplication(String)` cannot take over that name. + let (_, runtime, stub) = + real_class("Windows.UI.Notifications", "TileUpdateManagerForUser").expect("class metadata"); + let members = class_members(&runtime, "TileUpdateManagerForUser"); + assert_eq!( + members.get("create_tile_updater_for_application_for_user"), + Some(&single_definition()), + "{runtime}" + ); + assert!( + runtime.contains( + " create_tile_updater_for_application = create_tile_updater_for_application_for_user\n" + ), + "{runtime}" + ); + assert_eq!( + members.get("create_tile_updater_for_application_with_id"), + Some(&single_definition()), + "{runtime}" + ); + assert!( + stub.contains("def create_tile_updater_for_application(self) ->"), + "{stub}" + ); +} + +/// The body of `def {name}(` in `code`, up to the next member. +fn member_body<'a>(code: &'a str, name: &str) -> &'a str { + let start = code + .find(&format!(" def {name}(")) + .unwrap_or_else(|| panic!("missing `{name}`:\n{code}")); + let rest = &code[start..]; + let end = rest[1..] + .find("\n def ") + .or_else(|| rest[1..].find("\n @")) + .map_or(rest.len(), |end| end + 1); + &rest[..end] +} + +#[test] +fn real_former_standalone_names_keep_calling_their_own_overload() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + // INumberFormatter.Format(UInt64) was `format_u_int`. Through `format`, + // format_u_int(5) would reach Format(Int64); the alias must keep UInt64. + let interfaces = + meta::parse_interfaces(WINDOWS_WINMD, "Windows.Globalization.NumberFormatting"); + let formatter = interfaces + .iter() + .find(|interface| interface.name == "INumberFormatter") + .expect("INumberFormatter metadata"); + let unsigned = formatter + .methods + .iter() + .find(|method| method.name == "FormatUInt") + .expect("FormatUInt"); + let runtime = common::generate_interface(formatter, &HashSet::new(), &HashSet::new()); + let target = format!("_format_{}", unsigned.vtable_index); + assert!( + runtime.contains(&format!("\n format_u_int = {target}\n")), + "{runtime}" + ); + let body = member_body(&runtime, &target); + assert!( + body.contains(&format!( + "_INumberFormatter.method({})", + unsigned.vtable_index + )) && body.contains("DynWinRTValue.from_u64(value)"), + "{body}" + ); + let stub = common::generate_interface_stub(formatter, &HashSet::new(), &HashSet::new()); + assert!( + stub.contains(" def format_u_int(self, value: int) -> str"), + "{stub}" + ); + + // PropertyMetadata.Create(Object) was `create_with_default_value`. Through + // `create`, a projected object would reach Create(CreateDefaultValueCallback). + let (class, runtime, stub) = + real_class("Windows.UI.Xaml", "PropertyMetadata").expect("class metadata"); + let with_default = class + .static_interfaces + .iter() + .flat_map(|interface| interface.methods.iter()) + .find(|method| method.name == "CreateWithDefaultValue") + .expect("CreateWithDefaultValue"); + let target = format!("_create_{}", with_default.vtable_index); + assert!( + runtime.contains(&format!("\n create_with_default_value = {target}\n")), + "{runtime}" + ); + let body = member_body(&runtime, &target); + assert!( + body.contains(&format!( + "_IPropertyMetadataStatics.method({})", + with_default.vtable_index + )) && body.contains("getattr(default_value, '_obj', default_value)"), + "{body}" + ); + assert_eq!( + class_members(&stub, "PropertyMetadata").get("create_with_default_value"), + Some(&single_definition()), + "{stub}" + ); +} + +static NEXT_FIXTURE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +struct Fixture(std::path::PathBuf); + +impl Fixture { + fn new() -> Self { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("target") + .join(format!( + "ovl{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn python() -> std::path::PathBuf { + std::env::var_os("DYNWINRT_TEST_PYTHON") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from("python")) +} + +fn has_mypy() -> bool { + let available = std::process::Command::new(python()) + .args(["-m", "mypy", "--version"]) + .output() + .is_ok_and(|output| output.status.success()); + assert!( + available || std::env::var("DYNWINRT_REQUIRE_MYPY").as_deref() != Ok("1"), + "DYNWINRT_REQUIRE_MYPY=1 but mypy is unavailable", + ); + available +} + +#[test] +fn real_merged_overload_stubs_pass_strict_mypy() { + if !Path::new(WINDOWS_WINMD).exists() || !has_mypy() { + eprintln!("Skipping: Windows.winmd or mypy unavailable"); + return; + } + let fixture = Fixture::new(); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")) + .args(["generate", "--winmd", WINDOWS_WINMD, "--class-name"]) + .arg( + "Windows.Globalization.NumberFormatting.DecimalFormatter,Windows.Storage.StorageFile,\ + Windows.System.Launcher,Windows.Data.Xml.Dom.XmlDocument,\ + Windows.Globalization.Calendar,Windows.Storage.Streams.RandomAccessStream,\ + Windows.Storage.Streams.InMemoryRandomAccessStream,Windows.Storage.Streams.DataWriter", + ) + .args(["--lang", "py", "--output"]) + .arg(fixture.0.join("sdk")) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + std::fs::write( + fixture.0.join("consumer.py"), + r#"from typing import assert_type +from sdk.windows.data.xml.dom import XmlDocument, XmlLoadSettings +from sdk.windows.foundation import Uri +from sdk.windows.globalization import Calendar +from sdk.windows.globalization.number_formatting import DecimalFormatter +from sdk.windows.storage import NameCollisionOption, StorageFile, StorageFolder +from sdk.windows.storage.streams import DataWriter, InMemoryRandomAccessStream, RandomAccessStream +from sdk.windows.system import Launcher + +def formatting(formatter: DecimalFormatter) -> None: + assert_type(formatter.format(5), str) + assert_type(formatter.format(2.5), str) + assert_type(formatter.format_int(5), str) + assert_type(formatter.format_u_int(5), str) + +async def storage(file: StorageFile, folder: StorageFolder) -> None: + assert_type(await file.copy_async(folder), StorageFile | None) + assert_type(await file.copy_async(folder, "name.txt"), StorageFile | None) + option = NameCollisionOption.ReplaceExisting + assert_type(await file.copy_async(folder, "name.txt", option), StorageFile | None) + assert_type(await file.copy_overload(folder, "name.txt", option), StorageFile | None) + await file.move_async(folder) + +async def launching(file: StorageFile, uri: Uri) -> None: + assert_type(await Launcher.launch_file_async(file), bool) + assert_type(await Launcher.launch_uri_async(uri), bool) + +def xml(document: XmlDocument, settings: XmlLoadSettings) -> None: + document.load_xml("") + document.load_xml("", settings) + document.load_xml_with_settings("", settings) + +def calendar(value: Calendar) -> None: + assert_type(value.month_as_string(), str) + assert_type(value.month_as_string(3), str) + assert_type(value.month_as_full_string(), str) + +async def streams(source: InMemoryRandomAccessStream, target: InMemoryRandomAccessStream) -> None: + assert_type(await RandomAccessStream.copy_async(source, target), int) + assert_type(await RandomAccessStream.copy_async(source, target, 4), int) + assert_type(await RandomAccessStream.copy_size_async(source, target, 4), int) + DataWriter(source) +"#, + ) + .unwrap(); + let output = std::process::Command::new(python()) + .args([ + "-B", + "-m", + "mypy", + "--strict", + "--no-incremental", + "--follow-imports=normal", + "--no-pretty", + "--show-error-codes", + "--cache-dir", + ".mypy_cache", + "sdk", + "consumer.py", + ]) + .env( + "MYPYPATH", + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("bindings") + .join("py"), + ) + .current_dir(&fixture.0) + .output() + .unwrap(); + let text = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !text.contains("overload-cannot-match") && !text.contains("overload-overlap"), + "{text}" + ); + assert!(output.status.success(), "{text}"); +} diff --git a/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py b/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py index 860cd9f9..4f18e7b1 100644 --- a/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py +++ b/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py @@ -177,12 +177,27 @@ def write_byte(self, value: int) -> None: def write_bytes(self, value: DynWinRTArray | bytes | bytearray | Sequence[int]) -> None: _IDataWriter.method(12).invoke(self._obj, [_dynwinrt_array(value, lambda item: DynWinRTValue.from_u8(item), DynWinRTType.u8_type(), True)]) - def write_buffer(self, buffer: 'IBuffer') -> None: + def _write_buffer_13(self, buffer: 'IBuffer') -> None: _IDataWriter.method(13).invoke(self._obj, [getattr(buffer, '_obj', buffer)]) - def write_buffer_range(self, buffer: 'IBuffer', start: int, count: int) -> None: + def _write_buffer_14(self, buffer: 'IBuffer', start: int, count: int) -> None: _IDataWriter.method(14).invoke(self._obj, [getattr(buffer, '_obj', buffer), DynWinRTValue.from_u32(start), DynWinRTValue.from_u32(count)]) + def write_buffer(self, *args, **kwargs): + _bound = _dynwinrt_bind_overload(('buffer',), args, kwargs) + if _bound is not None and isinstance(_bound[0], _dynwinrt_symbol('windows__storage__streams__i_buffer', 'IBuffer')): + return self._write_buffer_13(*_bound) + _bound = _dynwinrt_bind_overload(('buffer', 'start', 'count',), args, kwargs) + if _bound is not None and isinstance(_bound[0], _dynwinrt_symbol('windows__storage__streams__i_buffer', 'IBuffer')) and isinstance(_bound[1], int) and not isinstance(_bound[1], bool) and not isinstance(_bound[1], __import__('enum').Enum) and 0 <= _bound[1] <= 4294967295 and isinstance(_bound[2], int) and not isinstance(_bound[2], bool) and not isinstance(_bound[2], __import__('enum').Enum) and 0 <= _bound[2] <= 4294967295: + return self._write_buffer_14(*_bound) + _bound = _dynwinrt_bind_overload(('buffer',), args, kwargs) + if _bound is not None and (isinstance(_bound[0], _dynwinrt_symbol('windows__storage__streams__i_buffer', 'IBuffer')) or _dynwinrt_can_cast(_bound[0], IID_ARG_Windows_Storage_Streams_IBuffer)): + return self._write_buffer_13(*_bound) + _bound = _dynwinrt_bind_overload(('buffer', 'start', 'count',), args, kwargs) + if _bound is not None and (isinstance(_bound[0], _dynwinrt_symbol('windows__storage__streams__i_buffer', 'IBuffer')) or _dynwinrt_can_cast(_bound[0], IID_ARG_Windows_Storage_Streams_IBuffer)) and isinstance(_bound[1], int) and not isinstance(_bound[1], bool) and not isinstance(_bound[1], __import__('enum').Enum) and 0 <= _bound[1] <= 4294967295 and isinstance(_bound[2], int) and not isinstance(_bound[2], bool) and not isinstance(_bound[2], __import__('enum').Enum) and 0 <= _bound[2] <= 4294967295: + return self._write_buffer_14(*_bound) + raise TypeError("No matching overload for write_buffer") + def write_boolean(self, value: bool) -> None: _IDataWriter.method(15).invoke(self._obj, [DynWinRTValue.from_bool(value)]) @@ -245,6 +260,8 @@ def unicode_encoding(self, value: 'UnicodeEncoding'): def byte_order(self, value: 'ByteOrder'): _IDataWriter.method(10).invoke(self._obj, [DynWinRTValue.enum_value(DynWinRTType.enum_type('Windows.Storage.Streams.ByteOrder', ['LittleEndian', 'BigEndian'], [0, 1]), int(value))]) + write_buffer_range = _write_buffer_14 + def close(self): if self._closed: return From f5e90a91f49525dab42e531b3eaa599f52e1dc03 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 15:26:56 +0800 Subject: [PATCH 04/15] Deduplicate identical Python overload signatures Emit one stub declaration when multiple ABI candidates render to the same full Python signature. This avoids duplicate IDE hover entries and needs no mypy suppression for common Int64/UInt64-style projection collisions. If parameter signatures are identical but return types differ, keep both declarations and attach the targeted overload-cannot-match ignore to the later one. Constructor overloads continue to deduplicate identical signatures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/codegen/winrt/python/stubs.rs | 91 ++++++++++++++----- .../tests/python_constructor_boundary_test.rs | 9 +- .../python_overload_normalization_test.rs | 2 +- 3 files changed, 74 insertions(+), 28 deletions(-) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs index 7559e051..06bfeada 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs @@ -1487,24 +1487,21 @@ fn emit_constructor_stubs(class: &ClassMeta, context: &PythonProjectionContext) return out; } overloads.sort_by(|left, right| super::member_plan::cmp_python_dispatch_params(left, right)); - let count = overloads.len(); let mut signatures = HashSet::new(); + overloads + .retain(|params| signatures.insert(super::type_helpers::py_param_list(params, context))); + + let count = overloads.len(); for params in &overloads { if count > 1 { out.push_str(" @overload\n"); } let param_str = super::type_helpers::py_param_list(params, context); - let duplicate = !signatures.insert(param_str.clone()); - let ignore = if duplicate { - " # type: ignore[overload-cannot-match]" - } else { - "" - }; if param_str.is_empty() { - out.push_str(&format!(" def __init__(self) -> None: ...{ignore}\n")); + out.push_str(" def __init__(self) -> None: ...\n"); } else { out.push_str(&format!( - " def __init__(self, {param_str}) -> None: ...{ignore}\n" + " def __init__(self, {param_str}) -> None: ...\n" )); } } @@ -1568,27 +1565,26 @@ fn has_constructor_stub_overload(class: &ClassMeta) -> bool { }) } -/// Preserve one declaration per WinRT overload. When two overloads collapse to -/// the same Python signature (for example Int64 and UInt64 both become `int`), -/// mark the later declaration so strict type checkers accept the metadata-exact -/// overload count without reporting it as unreachable. +/// Keep one declaration per distinct full Python signature. When parameter +/// signatures are identical but return types differ, keep both declarations +/// and mark the later one so strict type checkers accept it. fn typed_signatures<'m>( methods: impl IntoIterator, return_type: impl Fn(&MethodMeta) -> String, context: &PythonProjectionContext, ) -> Vec<(&'m MethodMeta, bool)> { let mut signatures = HashSet::new(); + let mut parameter_signatures = HashSet::new(); methods .into_iter() - .map(|method| { - let signature = ( - super::type_helpers::py_param_list( - &crate::codegen::winrt::shared::imports::get_in_params(method), - context, - ), - return_type(method), + .filter_map(|method| { + let parameters = super::type_helpers::py_param_list( + &crate::codegen::winrt::shared::imports::get_in_params(method), + context, ); - (method, !signatures.insert(signature)) + signatures + .insert((parameters.clone(), return_type(method))) + .then(|| (method, !parameter_signatures.insert(parameters))) }) .collect() } @@ -2002,3 +1998,56 @@ pub fn generate_public_struct_index_stub( } out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::meta::{ParamDirection, ParamMeta}; + + fn method(vtable_index: usize) -> MethodMeta { + MethodMeta { + name: format!("Read{vtable_index}"), + raw_name: "Read".into(), + vtable_index, + params: vec![ParamMeta { + name: "value".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }], + ..Default::default() + } + } + + #[test] + fn typed_signatures_deduplicate_full_matches_but_keep_distinct_returns() { + let first = method(6); + let different_return = method(7); + let duplicate = method(8); + let context = PythonProjectionContext::default(); + let signatures = typed_signatures( + [&first, &different_return, &duplicate], + |method| { + if method.vtable_index == 7 { + "bytes".to_string() + } else { + "str".to_string() + } + }, + &context, + ); + + assert_eq!( + signatures + .iter() + .map(|(method, duplicate)| (method.vtable_index, *duplicate)) + .collect::>(), + [(6, false), (7, true)] + ); + assert_eq!( + ignore_unreachable_overload( + " def read(self, value: int) -> bytes: ...\n".to_string() + ), + " def read(self, value: int) -> bytes: ... # type: ignore[overload-cannot-match]\n" + ); + } +} diff --git a/tools/dynwinrt-codegen/tests/python_constructor_boundary_test.rs b/tools/dynwinrt-codegen/tests/python_constructor_boundary_test.rs index e7686ca7..1bb07f47 100644 --- a/tools/dynwinrt-codegen/tests/python_constructor_boundary_test.rs +++ b/tools/dynwinrt-codegen/tests/python_constructor_boundary_test.rs @@ -403,13 +403,10 @@ fn numeric_constructor_overloads_dispatch_by_specificity() { assert_eq!( pyi.matches("def __init__(self, value: int) -> None: ...") .count(), - 2, - "both ABI constructors stay declared even though I8 and I32 project as `int`:\n{pyi}" + 1, + "I8 and I32 both project as one typed `int` constructor signature:\n{pyi}" ); - assert_eq!(pyi.matches(" @overload\n").count(), 4, "{pyi}"); - assert!(pyi.contains( - "def __init__(self, value: int) -> None: ... # type: ignore[overload-cannot-match]" - )); + assert_eq!(pyi.matches(" @overload\n").count(), 0, "{pyi}"); } #[test] diff --git a/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs b/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs index 1bffadc5..5cf31383 100644 --- a/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs +++ b/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs @@ -370,7 +370,7 @@ fn real_classes_project_overloads_under_documented_clr_names() { "Windows.Globalization.NumberFormatting", "DecimalFormatter", "format", - 3, + 2, &[], ), ( From a6043992dff5e3559b95c21c592794d85835b385 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 15:53:40 +0800 Subject: [PATCH 05/15] Preserve exact interface targets in compatibility dispatch Do not treat identically shaped methods on different interfaces as the same native overload when checking compatibility. When a documented CLR group occupies an old public name with an identically shaped method from another interface, emit a compatibility dispatcher that tries the exact previously selected interface method first. It reuses the implementation defined by the canonical CLR-name group, so each native method body is generated once, while the newly documented interface method remains projected under its own name. Keep one stub declaration per distinct full Python signature. Only identical parameter signatures with different return types retain a second declaration and targeted mypy suppression. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/codegen/winrt/python/member_plan.rs | 158 ++++++++++++++++-- .../src/codegen/winrt/python/method.rs | 86 ++++++---- .../python_overload_normalization_test.rs | 11 ++ 3 files changed, 207 insertions(+), 48 deletions(-) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs index 011afc38..3ff7461d 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs @@ -79,6 +79,10 @@ pub(crate) struct Candidate<'a> { /// Attribute implementing this overload: the group name when it is the only /// candidate, otherwise its private dispatch name. pub(crate) attribute: String, + /// Whether this group defines the implementation attribute. Compatibility + /// dispatchers may also call an implementation defined by its canonical + /// CLR-name group. + pub(crate) define: bool, } /// Methods projected as one Python method, in dispatch order. @@ -542,13 +546,14 @@ fn plan_scopes<'a>( } } }; - let covers = |members: &[usize], index: usize| { + let covers_exact = |members: &[usize], index: usize| members.contains(&index); + let covers_shape = |members: &[usize], index: usize| { members.iter().any(|&member| equivalent(member, index)) }; // Whether a new overload could take a call that reached `expected`. let takes_calls = |members: &[usize], expected: &[usize]| { members.iter().any(|&member| { - !covers(expected, member) + !covers_shape(expected, member) && expected.iter().any(|&index| { overloads_may_overlap(entries[member].method, entries[index].method) }) @@ -562,7 +567,7 @@ fn plan_scopes<'a>( // An existing public method keeps every overload it reached // and gains none that could take its calls. for &index in &expected { - if covers(members, index) { + if covers_exact(members, index) || covers_shape(members, index) { continue; } if can_fall_back(members) { @@ -612,7 +617,36 @@ fn plan_scopes<'a>( fallback.extend(blamed); }; - let attributes = |name: &str, members: &[usize]| { + // An existing name can now be the documented name of a different, + // identically shaped interface method. Keep its old dispatcher exact by + // prepending the methods it previously dispatched to, while the same + // implementations remain available from their canonical CLR-name group. + let mut effective_groups = groups.clone(); + let mut compatibility_dispatchers = BTreeSet::new(); + for scope in 0..scopes.len() { + for (name, previous_key) in &existing[scope] { + let Some(members) = groups[scope].get(name) else { + continue; + }; + let expected = reachable(&previous_groups[scope][previous_key]); + let needs_compatibility_dispatcher = expected.iter().any(|&index| { + !members.contains(&index) && members.iter().any(|&member| equivalent(member, index)) + }); + if !needs_compatibility_dispatcher { + continue; + } + let mut combined = dispatch_order(&previous_groups[scope][previous_key]); + let extras = dispatch_order(members) + .into_iter() + .filter(|index| !combined.contains(index)) + .collect::>(); + combined.extend(extras); + effective_groups[scope].insert(name.clone(), combined); + compatibility_dispatchers.insert((scope, name.clone())); + } + } + + let previous_attributes = |name: &str, members: &[usize]| { let ordered = dispatch_order(members); let names = if ordered.len() == 1 { vec![name.to_string()] @@ -623,22 +657,44 @@ fn plan_scopes<'a>( }; (0..scopes.len()) .map(|scope| { - let mut ordered_groups = groups[scope].iter().collect::>(); - ordered_groups.sort_by_key(|(_, members)| members[0]); + let primary_group_of = groups[scope] + .iter() + .flat_map(|(name, members)| members.iter().map(move |&index| (index, name.clone()))) + .collect::>(); + let mut attribute_of = HashMap::new(); + for (name, members) in &groups[scope] { + let ordered = dispatch_order(members); + let names = if effective_groups[scope][name].len() == 1 { + vec![name.clone()] + } else { + private_overload_names(name, ordered.iter().map(|&index| entries[index].method)) + }; + attribute_of.extend(ordered.into_iter().zip(names)); + } + + let mut ordered_groups = effective_groups[scope].iter().collect::>(); + ordered_groups.sort_by_key(|(name, _)| groups[scope][*name][0]); let mut plan_groups = Vec::with_capacity(ordered_groups.len()); let mut group_of = HashMap::new(); - let mut attribute_of = HashMap::new(); for (position, (name, members)) in ordered_groups.into_iter().enumerate() { - let candidates = attributes(name, members) + let ordered = if compatibility_dispatchers.contains(&(scope, name.clone())) { + members.clone() + } else { + dispatch_order(members) + }; + let candidates = ordered .into_iter() - .map(|(index, attribute)| { + .map(|index| { let entry = &entries[index]; - group_of.insert(entry.method as *const MethodMeta, position); - attribute_of.insert(index, attribute.clone()); + let define = primary_group_of[&index] == *name; + if define { + group_of.insert(entry.method as *const MethodMeta, position); + } Candidate { interface: entry.interface, method: entry.method, - attribute, + attribute: attribute_of[&index].clone(), + define, } }) .collect(); @@ -649,7 +705,7 @@ fn plan_scopes<'a>( } let previous_attributes = previous_groups[scope] .iter() - .flat_map(|(name, members)| attributes(name, members)) + .flat_map(|(name, members)| previous_attributes(name, members)) .map(|(index, attribute)| (entries[index].method as *const MethodMeta, attribute)) .collect(); let aliases = existing[scope] @@ -1168,14 +1224,86 @@ mod tests { groups, [ ("format", vec!["INumberFormatter", "INumberFormatter"]), - ("format_int", vec!["INumberFormatter2"]), - ("format_u_int", vec!["INumberFormatter2"]), + ("format_int", vec!["INumberFormatter", "INumberFormatter2"]), + ( + "format_u_int", + vec!["INumberFormatter", "INumberFormatter2"] + ), ] ); + for (name, canonical_attribute, real_attribute) in [ + ("format_int", "_format_6", "_format_int_6"), + ("format_u_int", "_format_7", "_format_u_int_7"), + ] { + let group = plans[0] + .groups + .iter() + .find(|group| group.name == name) + .unwrap(); + assert_eq!( + group + .candidates + .iter() + .map(|candidate| (candidate.attribute.as_str(), candidate.define)) + .collect::>(), + [(canonical_attribute, false), (real_attribute, true)] + ); + } assert!(planned.aliases.is_empty(), "{planned:?}"); assert!(planned.fallbacks.is_empty(), "{planned:?}"); } + #[test] + fn compatibility_dispatcher_keeps_the_exact_previously_selected_interface() { + let first = interface( + "IFirst", + vec![overload( + "Pick", + "Choose", + 6, + &[("value", TypeMeta::String)], + )], + ); + let second = interface( + "ISecond", + vec![overload("Pick", "Pick", 6, &[("value", TypeMeta::String)])], + ); + let plans = plan_scopes(&[vec![&first, &second]], &HashSet::new()); + let choose = plans[0] + .groups + .iter() + .find(|group| group.name == "choose") + .unwrap(); + let pick = plans[0] + .groups + .iter() + .find(|group| group.name == "pick") + .unwrap(); + + assert_eq!( + choose + .candidates + .iter() + .map(|candidate| (candidate.interface.name.as_str(), candidate.define)) + .collect::>(), + [("IFirst", true)] + ); + assert_eq!( + pick.candidates + .iter() + .map(|candidate| { + ( + candidate.interface.name.as_str(), + candidate.attribute.as_str(), + candidate.define, + ) + }) + .collect::>(), + [("IFirst", "choose", false), ("ISecond", "_pick_6", true),], + "pick() must still call IFirst first, while ISecond.Pick remains projected" + ); + } + #[test] fn clr_name_does_not_split_distinct_overloads_of_an_existing_name() { // Same shape as INumberFormatter/INumberFormatter2, but the overloads diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index e3d43c4d..f7690c85 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -547,23 +547,33 @@ pub(crate) fn generate_instance_method_group<'a>( let overloads = group .candidates .iter() - .map(|candidate| (overload(candidate), candidate.attribute.as_str())) + .map(|candidate| { + ( + overload(candidate), + candidate.attribute.as_str(), + candidate.define, + ) + }) .collect::>(); let mut out = String::new(); - for (overload, attribute) in &overloads { - out.push_str(&generate_method_body( - &overload.iface_var, - &overload.obj_expr, - overload.method, - context, - Some(attribute), - overload.sibling_methods, - overload.property_has_getter, - )); + for (overload, attribute, define) in &overloads { + if *define { + out.push_str(&generate_method_body( + &overload.iface_var, + &overload.obj_expr, + overload.method, + context, + Some(attribute), + overload.sibling_methods, + overload.property_has_getter, + )); + } if overloads.len() == 1 { return out; } - out.push('\n'); + if *define { + out.push('\n'); + } } let public_name = &group.name; @@ -572,7 +582,7 @@ pub(crate) fn generate_instance_method_group<'a>( out.push_str(&method_pydoc(group.candidates[0].method, &public_params)); let candidates = overloads .iter() - .map(|(overload, attribute)| DispatchCandidate { + .map(|(overload, attribute, _)| DispatchCandidate { params: get_in_params(overload.method), body: vec![format!("return self.{attribute}(*_bound)")], }) @@ -626,30 +636,40 @@ pub(crate) fn generate_static_method_group<'a>( let overloads = group .candidates .iter() - .map(|candidate| (overload(candidate), candidate.attribute.as_str())) + .map(|candidate| { + ( + overload(candidate), + candidate.attribute.as_str(), + candidate.define, + ) + }) .collect::>(); let mut out = String::new(); - for (overload, attribute) in &overloads { - out.push_str(&match overload.kind { - StaticOverloadKind::Factory => generate_factory_method_invoke_named( - overload.class, - overload.iface, - overload.method, - context, - Some(attribute), - ), - StaticOverloadKind::Static => generate_static_method_invoke_named( - overload.class, - overload.iface, - overload.method, - context, - Some(attribute), - ), - }); + for (overload, attribute, define) in &overloads { + if *define { + out.push_str(&match overload.kind { + StaticOverloadKind::Factory => generate_factory_method_invoke_named( + overload.class, + overload.iface, + overload.method, + context, + Some(attribute), + ), + StaticOverloadKind::Static => generate_static_method_invoke_named( + overload.class, + overload.iface, + overload.method, + context, + Some(attribute), + ), + }); + } if overloads.len() == 1 { return out; } - out.push('\n'); + if *define { + out.push('\n'); + } } let public_name = &group.name; @@ -659,7 +679,7 @@ pub(crate) fn generate_static_method_group<'a>( out.push_str(&method_pydoc(group.candidates[0].method, &public_params)); let candidates = overloads .iter() - .map(|(overload, attribute)| DispatchCandidate { + .map(|(overload, attribute, _)| DispatchCandidate { params: get_in_params(overload.method), body: vec![format!( "return {}.{attribute}(*_bound)", diff --git a/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs b/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs index 5cf31383..2df801b8 100644 --- a/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs +++ b/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs @@ -481,6 +481,17 @@ fn real_classes_project_overloads_under_documented_clr_names() { for method in ["format_int", "format_u_int", "format_double"] { assert_eq!(members.get(method), Some(&single_definition()), "{runtime}"); } + let format_int = member_body(runtime, "format_int"); + assert!( + format_int.find("self._format_6(").unwrap() + < format_int.find("self._format_int_6(").unwrap(), + "the compatibility dispatcher must try the exact interface method that format_int used before CLR grouping:\n{runtime}" + ); + assert_eq!( + runtime.matches(" def _format_6(").count(), + 1, + "the canonical implementation should be defined once:\n{runtime}" + ); assert!( runtime.contains("return _INumberFormatter2.method(6)"), "{runtime}" From 38ae09a35b5ea6244ecb0b05235b1d6de7ee62b4 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 16:14:12 +0800 Subject: [PATCH 06/15] Pin compatibility dispatchers to stable private targets Determine compatibility from the actual reachable dispatch representative, not raw group membership, so a same-group newcomer cannot sort ahead of the exact interface method selected before CLR-name grouping. When an implementation is shared with a compatibility dispatcher, assign it a stable private attribute even if its canonical group has one candidate. The canonical public name aliases that private implementation, preventing a Python subclass override from changing the compatibility dispatcher's native target. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/codegen/winrt/python/member_plan.rs | 55 +++++++++++++++++-- .../src/codegen/winrt/python/method.rs | 41 +++++++++++++- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs index 3ff7461d..574cc45b 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs @@ -629,8 +629,9 @@ fn plan_scopes<'a>( continue; }; let expected = reachable(&previous_groups[scope][previous_key]); + let current = reachable(members); let needs_compatibility_dispatcher = expected.iter().any(|&index| { - !members.contains(&index) && members.iter().any(|&member| equivalent(member, index)) + !current.contains(&index) && members.iter().any(|&member| equivalent(member, index)) }); if !needs_compatibility_dispatcher { continue; @@ -661,10 +662,21 @@ fn plan_scopes<'a>( .iter() .flat_map(|(name, members)| members.iter().map(move |&index| (index, name.clone()))) .collect::>(); + let borrowed = effective_groups[scope] + .iter() + .flat_map(|(name, members)| { + members + .iter() + .copied() + .filter(|index| primary_group_of[index] != *name) + }) + .collect::>(); let mut attribute_of = HashMap::new(); for (name, members) in &groups[scope] { let ordered = dispatch_order(members); - let names = if effective_groups[scope][name].len() == 1 { + let names = if effective_groups[scope][name].len() == 1 + && members.iter().all(|index| !borrowed.contains(index)) + { vec![name.clone()] } else { private_overload_names(name, ordered.iter().map(|&index| entries[index].method)) @@ -1284,9 +1296,15 @@ mod tests { choose .candidates .iter() - .map(|candidate| (candidate.interface.name.as_str(), candidate.define)) + .map(|candidate| { + ( + candidate.interface.name.as_str(), + candidate.attribute.as_str(), + candidate.define, + ) + }) .collect::>(), - [("IFirst", true)] + [("IFirst", "_choose_6", true)] ); assert_eq!( pick.candidates @@ -1299,11 +1317,38 @@ mod tests { ) }) .collect::>(), - [("IFirst", "choose", false), ("ISecond", "_pick_6", true),], + [("IFirst", "_choose_6", false), ("ISecond", "_pick_6", true),], "pick() must still call IFirst first, while ISecond.Pick remains projected" ); } + #[test] + fn compatibility_dispatcher_pins_exact_target_within_a_canonical_group() { + let first = interface( + "IFirst", + vec![overload("Foo", "Foo", 6, &[("value", TypeMeta::String)])], + ); + let second = interface( + "ISecond", + vec![overload("Bar", "Foo", 6, &[("value", TypeMeta::String)])], + ); + let plans = plan_scopes(&[vec![&first, &second]], &HashSet::new()); + let foo = plans[0] + .groups + .iter() + .find(|group| group.name == "foo") + .unwrap(); + + assert_eq!( + foo.candidates + .iter() + .map(|candidate| candidate.interface.name.as_str()) + .collect::>(), + ["IFirst", "ISecond"], + "Foo must keep the exact IFirst target first even though Bar sorts before Foo" + ); + } + #[test] fn clr_name_does_not_split_distinct_overloads_of_an_existing_name() { // Same shape as INumberFormatter/INumberFormatter2, but the overloads diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index f7690c85..612178a0 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -556,6 +556,7 @@ pub(crate) fn generate_instance_method_group<'a>( }) .collect::>(); let mut out = String::new(); + let public_name = &group.name; for (overload, attribute, define) in &overloads { if *define { out.push_str(&generate_method_body( @@ -569,6 +570,9 @@ pub(crate) fn generate_instance_method_group<'a>( )); } if overloads.len() == 1 { + if *attribute != public_name { + out.push_str(&format!("\n {public_name} = {attribute}\n")); + } return out; } if *define { @@ -576,7 +580,6 @@ pub(crate) fn generate_instance_method_group<'a>( } } - let public_name = &group.name; out.push_str(&format!(" def {public_name}(self, *args, **kwargs):\n")); let public_params = get_in_params(group.candidates[0].method); out.push_str(&method_pydoc(group.candidates[0].method, &public_params)); @@ -645,6 +648,7 @@ pub(crate) fn generate_static_method_group<'a>( }) .collect::>(); let mut out = String::new(); + let public_name = &group.name; for (overload, attribute, define) in &overloads { if *define { out.push_str(&match overload.kind { @@ -665,6 +669,9 @@ pub(crate) fn generate_static_method_group<'a>( }); } if overloads.len() == 1 { + if *attribute != public_name { + out.push_str(&format!("\n {public_name} = {attribute}\n")); + } return out; } if *define { @@ -672,7 +679,6 @@ pub(crate) fn generate_static_method_group<'a>( } } - let public_name = &group.name; out.push_str(" @staticmethod\n"); out.push_str(&format!(" def {public_name}(*args, **kwargs):\n")); let public_params = get_in_params(group.candidates[0].method); @@ -1017,6 +1023,37 @@ mod tests { assert!(code.contains("self._register_6_1(*_bound)"), "{code}"); } + #[test] + fn shared_single_candidate_uses_a_private_implementation() { + let method = overloaded_method("Choose", 6, TypeMeta::String); + let interface = interface("IChooser", &[&method]); + let group = MethodGroup { + name: "choose".into(), + candidates: vec![Candidate { + interface: &interface, + method: &method, + attribute: "_choose_6".into(), + define: true, + }], + }; + + let code = generate_instance_method_group( + &group, + |candidate| InstanceOverload { + iface_var: "_IChooser".into(), + obj_expr: "self._obj".into(), + method: candidate.method, + sibling_methods: None, + property_has_getter: true, + }, + &PythonProjectionContext::default(), + ); + + assert!(code.contains("def _choose_6(self, value: str)"), "{code}"); + assert!(code.contains("\n choose = _choose_6\n"), "{code}"); + assert!(!code.contains("def choose(self, value: str)"), "{code}"); + } + fn enum_type(name: &str, is_flags: bool) -> TypeMeta { TypeMeta::Enum { namespace: "Contoso".into(), From d874ffd1b8fb3637f5a14895bd664c61c506f08b Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 19:41:17 +0800 Subject: [PATCH 07/15] Preserve guard-free overload conversion fallbacks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/e2e/e2e_specs.json | 6 +- tests/e2e/e2e_specs.schema.json | 2 + tests/e2e/runners/py_runner.py | 421 +++++++++++++++++- .../codegen/winrt/python/generator/class.rs | 12 +- .../src/codegen/winrt/python/generator/mod.rs | 41 +- .../codegen/winrt/python/generator/structs.rs | 2 +- .../codegen/winrt/python/generator/types.rs | 4 +- .../src/codegen/winrt/python/member_plan.rs | 129 +++++- .../src/codegen/winrt/python/method.rs | 85 +++- .../python_overload_normalization_test.rs | 126 ++++++ .../snapshots/data_writer_py/data_writer.py | 3 +- 11 files changed, 798 insertions(+), 33 deletions(-) diff --git a/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index 6fc1c96e..68934e43 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -143,7 +143,8 @@ "args": ["en-US"] }, "checks": [ - { "kind": "property_equals", "member": "language_tag", "expected": "en-US" } + { "kind": "property_equals", "member": "language_tag", "expected": "en-US" }, + { "kind": "language_comprehensive", "member": "self", "langs": ["py"] } ] }, { @@ -431,7 +432,8 @@ "checks": [ { "kind": "async_memory_roundtrip", "member": "roundtrip", "write_value": 42 }, { "kind": "data_stream_scalar_roundtrip", "member": "roundtrip", "langs": ["py"] }, - { "kind": "data_stream_constructor_roundtrip", "member": "__init__", "langs": ["py"] } + { "kind": "data_stream_constructor_roundtrip", "member": "__init__", "langs": ["py"] }, + { "kind": "can_cast_non_object", "member": "_dynwinrt_can_cast", "langs": ["py"] } ] }, { diff --git a/tests/e2e/e2e_specs.schema.json b/tests/e2e/e2e_specs.schema.json index c8881c73..f919c945 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -57,6 +57,7 @@ "kind": { "enum": [ "property_equals", + "language_comprehensive", "property_exists", "property_in_range", "method_equals", @@ -98,6 +99,7 @@ "value_set_mapping", "data_stream_scalar_roundtrip", "data_stream_constructor_roundtrip", + "can_cast_non_object", "calendar_comprehensive", "storage_query_temp_folder", "storage_file_copy_overloads", diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 434f7712..9b6f0485 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -196,6 +196,65 @@ async def run_check( else: cr['pass'] = True + elif kind == 'language_comprehensive': + properties = ( + obj.language_tag, + obj.display_name, + obj.native_name, + obj.script, + obj.layout_direction, + obj.abbreviated_name, + ) + extension_subtags = obj.get_extension_subtags('u') + mui_languages = cls.get_mui_compatible_language_list_from_language_tags( + ['en-US', 'fr-FR'] + ) + static_values = ( + cls.is_well_formed('en-US'), + cls.is_well_formed('not a language tag!'), + cls.get_current_input_method_language_tag(), + ) + language_module = importlib.import_module( + implementation_module_name( + pkg_name, 'Windows.Globalization', 'Language' + ) + ) + projections = [ + obj.as_interface(language_module.ILanguageExtensionSubtags), + obj.as_interface(language_module.ILanguage2), + obj.as_interface(language_module.ILanguage3), + ] + try: + projected_values = ( + projections[0].get_extension_subtags('u'), + projections[1].layout_direction, + projections[2].abbreviated_name, + ) + finally: + for projection in projections: + dw.release_projected(projection) + if mui_languages is not None: + mui_values = list(mui_languages) + dw.release_projected(mui_languages) + else: + mui_values = None + if ( + not all(value is not None for value in properties) + or extension_subtags is None + or static_values[0] is not True + or static_values[1] is not False + or not isinstance(static_values[2], str) + or mui_values is None + or projected_values[0] is None + ): + cr['error'] = ( + f'Language API coverage failed: properties={properties!r}, ' + f'subtags={extension_subtags!r}, static={static_values!r}, ' + f'mui={mui_values!r}, projected={projected_values!r}' + ) + else: + cr['pass'] = True + elif kind == 'ibuffer_copied_roundtrip': empty = cls.from_bytes(b'') if empty.capacity != 0 or empty.length != 0 or empty.to_bytes() != b'': @@ -901,6 +960,36 @@ def fail(*_args): return cr except IndexError: pass + view_mode = obj.view_mode + settings_identifier = obj.settings_identifier + suggested_start_location = obj.suggested_start_location + commit_button_text = obj.commit_button_text + obj.view_mode = view_mode + obj.settings_identifier = 'dynwinrt-e2e' + obj.settings_identifier = settings_identifier + obj.suggested_start_location = suggested_start_location + obj.commit_button_text = 'Open' + obj.commit_button_text = commit_button_text + picker_module = importlib.import_module( + implementation_module_name( + pkg_name, + 'Windows.Storage.Pickers', + 'FileOpenPicker', + ) + ) + picker_interfaces = [ + obj.as_interface(picker_module.IFileOpenPicker2), + obj.as_interface( + picker_module.IFileOpenPickerWithOperationId + ), + obj.as_interface(picker_module.IFileOpenPicker3), + ] + try: + _ = picker_interfaces[0].continuation_data + _ = picker_interfaces[2].user + finally: + for projected in picker_interfaces: + dw.release_projected(projected) cr['pass'] = True elif kind == 'datetime_roundtrip': @@ -1343,6 +1432,46 @@ def progress_without_loop(): else: cr['pass'] = True + elif kind == 'can_cast_non_object': + runtime = importlib.import_module(f'{pkg_name}._runtime') + iid = dw.WinGUID.parse('00000000-0000-0000-c000-000000000046') + values = [ + dw.DynWinRTValue.null_value(), + dw.DynWinRTValue.from_i32(1), + dw.DynWinRTValue.from_hstring('not an object'), + object(), + ] + results = [runtime._dynwinrt_can_cast(value, iid) for value in values] + legacy = runtime._dynwinrt_legacy_call( + lambda first, second: (first, second), + ('first', 'second'), + (1,), + {'second': 2}, + 'example', + ) + legacy_error = None + try: + runtime._dynwinrt_legacy_call( + lambda value: value, + ('value',), + (), + {}, + 'example', + ) + except TypeError as error: + legacy_error = str(error) + if ( + results != [False, False, False, False] + or legacy != (1, 2) + or legacy_error != 'No matching overload for example' + ): + cr['error'] = ( + 'runtime dispatch helpers failed: ' + f'casts={results!r}, legacy={legacy!r}, error={legacy_error!r}' + ) + else: + cr['pass'] = True + elif kind == 'calendar_comprehensive': obj.year = 2024 obj.month = 1 @@ -1431,26 +1560,59 @@ def progress_without_loop(): obj.change_clock(clock) obj.change_time_zone(time_zone) obj.numeral_system = numeral_system + constructed = [ + cls(), + cls(['en-US']), + cls(['en-US'], calendar_system, clock), + cls(['en-US'], calendar_system, clock, time_zone), + ] + try: + constructor_values = [ + ( + value.get_calendar_system(), + value.get_clock(), + value.get_time_zone(), + ) + for value in constructed + ] + finally: + for value in constructed: + dw.release_projected(value) + if any( + not all(isinstance(item, str) for item in values) + for values in constructor_values + ): + cr['error'] = ( + f'Calendar constructors returned invalid values: ' + f'{constructor_values!r}' + ) + return cr string_calls = [ ('era_as_full_string', ()), + ('era_as_string', ()), ('era_as_string', (3,)), ('year_as_string', ()), ('year_as_truncated_string', (2,)), ('year_as_padded_string', (4,)), ('month_as_full_string', ()), + ('month_as_string', ()), ('month_as_string', (3,)), ('month_as_full_solo_string', ()), + ('month_as_solo_string', ()), ('month_as_solo_string', (3,)), ('month_as_numeric_string', ()), ('month_as_padded_numeric_string', (2,)), ('day_as_string', ()), ('day_as_padded_string', (2,)), ('day_of_week_as_full_string', ()), + ('day_of_week_as_string', ()), ('day_of_week_as_string', (3,)), ('day_of_week_as_full_solo_string', ()), + ('day_of_week_as_solo_string', ()), ('day_of_week_as_solo_string', (3,)), ('period_as_full_string', ()), + ('period_as_string', ()), ('period_as_string', (2,)), ('hour_as_string', ()), ('hour_as_padded_string', (2,)), @@ -1461,6 +1623,7 @@ def progress_without_loop(): ('nanosecond_as_string', ()), ('nanosecond_as_padded_string', (3,)), ('time_zone_as_full_string', ()), + ('time_zone_as_string', ()), ('time_zone_as_string', (3,)), ] formatted = [ @@ -1504,13 +1667,38 @@ def progress_without_loop(): direct_files = await ( folder.get_files_async_overload_default_options_start_and_count() ) + common_file_query = generated_type(pkg_name, 'CommonFileQuery').DefaultQuery + common_folder_query = generated_type(pkg_name, 'CommonFolderQuery').DefaultQuery + canonical_files = [ + await folder.get_files_async(), + await folder.get_files_async(common_file_query), + await folder.get_files_async(common_file_query, 0, 10), + ] + canonical_folders = [ + await folder.get_folders_async(), + await folder.get_folders_async(common_folder_query), + await folder.get_folders_async(common_folder_query, 0, 10), + ] + canonical_items = [ + await folder.get_items_async(), + await folder.get_items_async(0, 10), + ] query = folder.create_file_query_overload_default() if query is None: cr['error'] = 'StorageFolder.create_file_query returned null' return cr count = await query.get_item_count_async() query_files = await query.get_files_async_default_start_and_count() + canonical_query_files = [ + await query.get_files_async(), + await query.get_files_async(0, 10), + ] options = query.get_current_query_options() + canonical_queries = [ + folder.create_file_query(), + folder.create_file_query(common_file_query), + folder.create_file_query(options), + ] query_folder = query.folder missing = await folder.try_get_item_async('missing.file') alpha = await folder.get_file_async('alpha.txt') @@ -1518,11 +1706,72 @@ def progress_without_loop(): if options is not None: query.apply_new_query_options(options) + folder_interface = folder.as_interface( + generated_type(pkg_name, 'IStorageFolder') + ) + item_interface = alpha.as_interface( + generated_type(pkg_name, 'IStorageItem') + ) + creation = generated_type( + pkg_name, 'CreationCollisionOption' + ).ReplaceExisting + created_file = await folder_interface.create_file_async( + 'interface-file.txt' + ) + replaced_file = await folder_interface.create_file_async( + 'interface-file.txt', creation + ) + created_folder = await folder_interface.create_folder_async( + 'interface-folder' + ) + replaced_folder = await folder_interface.create_folder_async( + 'interface-folder', creation + ) + interface_results = [ + await folder_interface.get_file_async('alpha.txt'), + await folder_interface.get_item_async('alpha.txt'), + await folder_interface.get_files_async(), + await folder_interface.get_folders_async(), + await folder_interface.get_items_async(), + item_interface.name, + item_interface.path, + item_interface.attributes, + item_interface.date_created, + item_interface.is_of_type( + generated_type(pkg_name, 'StorageItemTypes').File + ), + await item_interface.get_basic_properties_async(), + ] + await replaced_file.delete_async() + await replaced_folder.delete_async() + dw.release_projected(folder_interface) + dw.release_projected(item_interface) + direct_names = sorted(file.name for file in direct_files or []) query_names = sorted(file.name for file in query_files or []) + canonical_file_names = [ + sorted(file.name for file in files or []) + for files in canonical_files + ] + canonical_query_names = [ + sorted(file.name for file in files or []) + for files in canonical_query_files + ] if ( direct_names != ['alpha.txt', 'beta.txt'] or query_names != direct_names + or any(names != direct_names for names in canonical_file_names) + or any(names != direct_names for names in canonical_query_names) + or any(folders for folders in canonical_folders) + or any( + sorted(item.name for item in items or []) + != ['alpha.txt', 'beta.txt'] + for items in canonical_items + ) + or any(value is None for value in canonical_queries) + or any(value is None for value in interface_results) + or created_file is None + or created_folder is None or count != 2 or query_folder is None or not query_folder.is_equal(folder) @@ -1536,6 +1785,8 @@ def progress_without_loop(): cr['error'] = ( f'Storage query failed: direct={direct_names!r}, ' f'query={query_names!r}, count={count}, ' + f'canonical_files={canonical_file_names!r}, ' + f'canonical_query={canonical_query_names!r}, ' f'missing={missing!r}, alpha={alpha!r}' ) else: @@ -1550,9 +1801,13 @@ def progress_without_loop(): with TemporaryDirectory(prefix='dynwinrt-copy-') as temp_dir: root = Path(temp_dir) (root / 'source.txt').write_text('payload', encoding='utf-8') + for name in ('move1.txt', 'move2.txt', 'move3.txt'): + (root / name).write_text('move payload', encoding='utf-8') for name in ('documented', 'legacy'): (root / name).mkdir() source = await cls.get_file_from_path_async(str(root / 'source.txt')) + opened = await source.open_async(0) + legacy_opened = await source.open_with_options_async(0, 0) documented = await folder_cls.get_folder_from_path_async( str(root / 'documented') ) @@ -1579,6 +1834,14 @@ def progress_without_loop(): await source.copy_overload(legacy, 'named.txt', collision.ReplaceExisting), await source.copy_overload(legacy, 'named.txt', 1), ] + move1 = await cls.get_file_from_path_async(str(root / 'move1.txt')) + move2 = await cls.get_file_from_path_async(str(root / 'move2.txt')) + move3 = await cls.get_file_from_path_async(str(root / 'move3.txt')) + await move1.move_async(documented) + await move2.move_async(documented, 'moved2.txt') + await move3.move_async( + documented, 'moved3.txt', collision.ReplaceExisting + ) rejected = [] for arguments in (('not a folder',), (documented, 42)): try: @@ -1595,19 +1858,33 @@ def progress_without_loop(): for path in (root / 'documented').iterdir() if path.read_text(encoding='utf-8') == 'payload' ) + moved = sorted( + path.name + for path in (root / 'documented').iterdir() + if path.read_text(encoding='utf-8') == 'move payload' + ) if ( names != ['source.txt', 'named.txt', 'named.txt', 'keyword.txt'] or legacy_names != ['source.txt', 'named.txt', 'named.txt', 'named.txt'] or contents != ['keyword.txt', 'named.txt', 'source.txt'] + or moved != ['move1.txt', 'moved2.txt', 'moved3.txt'] + or opened is None + or legacy_opened is None or rejected ): cr['error'] = ( f'StorageFile copy overloads failed: names={names!r}, ' f'legacy={legacy_names!r}, contents={contents!r}, ' + f'moved={moved!r}, ' + f'opened={opened!r}, legacy_opened={legacy_opened!r}, ' f'rejected={rejected!r}' ) else: cr['pass'] = True + if opened is not None: + opened.close() + if legacy_opened is not None: + legacy_opened.close() elif kind == 'random_access_stream_copy_overloads': stream_cls = generated_type(pkg_name, 'InMemoryRandomAccessStream') @@ -1635,13 +1912,41 @@ async def read_all(stream): destination = stream_cls() copied = await copy(destination) results.append((copied, await read_all(destination))) + random_access = source.as_interface( + generated_type(pkg_name, 'IRandomAccessStream') + ) + stream_views = [ + random_access.get_input_stream_at(0), + random_access.get_output_stream_at(random_access.size), + random_access.clone_stream(), + ] + stream_properties = ( + random_access.can_read, + random_access.can_write, + random_access.position, + random_access.size, + ) + random_access.seek(0) + random_access.size = random_access.size + for view in stream_views: + dw.release_projected(view) + dw.release_projected(random_access) if results != [(10, '0123456789'), (4, '0123'), (4, '0123')]: cr['error'] = f'RandomAccessStream copy overloads failed: {results!r}' + elif ( + stream_properties[0] is not True + or stream_properties[1] is not True + or not all(isinstance(value, int) for value in stream_properties[2:]) + ): + cr['error'] = ( + f'IRandomAccessStream properties failed: {stream_properties!r}' + ) else: cr['pass'] = True elif kind == 'decimal_formatter_overloads': formatter = cls() + localized = cls(['en-US'], 'US') beyond_double = 2**53 + 1 beyond_int64 = 2**64 - 1 pairs = { @@ -1656,16 +1961,130 @@ async def read_all(stream): formatter.format_u_int(beyond_int64), ), } + property_values = { + 'languages': formatter.languages, + 'geographic_region': formatter.geographic_region, + 'integer_digits': formatter.integer_digits, + 'fraction_digits': formatter.fraction_digits, + 'is_grouped': formatter.is_grouped, + 'is_decimal_point_always_displayed': ( + formatter.is_decimal_point_always_displayed + ), + 'numeral_system': formatter.numeral_system, + 'resolved_language': formatter.resolved_language, + 'resolved_geographic_region': formatter.resolved_geographic_region, + 'significant_digits': formatter.significant_digits, + 'number_rounder': formatter.number_rounder, + 'is_zero_signed': formatter.is_zero_signed, + } + for name in ( + 'integer_digits', + 'fraction_digits', + 'is_grouped', + 'is_decimal_point_always_displayed', + 'numeral_system', + 'significant_digits', + 'is_zero_signed', + ): + setattr(formatter, name, property_values[name]) + + formatted_int = formatter.format_int(5) + formatted_uint = formatter.format_u_int(5) + formatted_double = formatter.format_double(2.5) + parsed = ( + formatter.parse_int(formatted_int), + formatter.parse_u_int(formatted_uint), + formatter.parse_double(formatted_double), + ) + + formatter_module = importlib.import_module( + implementation_module_name( + pkg_name, + 'Windows.Globalization.NumberFormatting', + 'DecimalFormatter', + ) + ) + interface_values = [] + options = formatter.as_interface( + formatter_module.INumberFormatterOptions + ) + formatter2 = formatter.as_interface( + formatter_module.INumberFormatter2 + ) + parser = formatter.as_interface(formatter_module.INumberParser) + significant = formatter.as_interface( + formatter_module.ISignificantDigitsOption + ) + rounder = formatter.as_interface( + formatter_module.INumberRounderOption + ) + signed = formatter.as_interface( + formatter_module.ISignedZeroOption + ) + projected_interfaces = [ + options, + formatter2, + parser, + significant, + rounder, + signed, + ] + try: + interface_values.extend( + [ + options.languages, + options.geographic_region, + options.integer_digits, + options.fraction_digits, + options.is_grouped, + options.is_decimal_point_always_displayed, + options.numeral_system, + options.resolved_language, + options.resolved_geographic_region, + formatter2.format_int(5), + formatter2.format_u_int(5), + formatter2.format_double(2.5), + parser.parse_int(formatted_int), + parser.parse_u_int(formatted_uint), + parser.parse_double(formatted_double), + significant.significant_digits, + rounder.number_rounder, + signed.is_zero_signed, + ] + ) + options.integer_digits = options.integer_digits + options.fraction_digits = options.fraction_digits + options.is_grouped = options.is_grouped + options.is_decimal_point_always_displayed = ( + options.is_decimal_point_always_displayed + ) + options.numeral_system = options.numeral_system + significant.significant_digits = significant.significant_digits + signed.is_zero_signed = signed.is_zero_signed + for projected in projected_interfaces: + same_interface = projected.as_interface(type(projected)) + dw.release_projected(same_interface) + finally: + for projected in projected_interfaces: + dw.release_projected(projected) + mismatches = { name: values for name, values in pairs.items() if values[0] != values[1] } if ( mismatches + or localized.resolved_language == '' + or parsed[0] != 5 + or parsed[1] != 5 + or parsed[2] != 2.5 + or any(value is None for value in interface_values[:9]) or formatter.format(beyond_double) == formatter.format_double(float(beyond_double)) ): cr['error'] = ( - f'DecimalFormatter.format overload dispatch failed: pairs={pairs!r}' + 'DecimalFormatter overloads/options failed: ' + f'pairs={pairs!r}, parsed={parsed!r}, ' + f'properties={property_values!r}, interfaces={interface_values!r}' ) else: cr['pass'] = True 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 d8981c97..e15e9aac 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -63,12 +63,18 @@ pub fn generate_class<'a>( let projectable = super::super::has_projectable_default_interface(class); let native_projectable = super::super::has_native_projector(class); let plan = ClassMemberPlan::new(class, context); + let needs_legacy_helper = plan.statics.has_legacy_fallback() + || plan.instance.has_legacy_fallback() + || class + .required_interfaces + .iter() + .any(|interface| interface_member_plan(interface).has_legacy_fallback()); let mut out = String::new(); // Header out.push_str(HEADER); out.push_str(FUTURE_ANNOTATIONS); - out.push_str(&import_line(context)); + out.push_str(&import_line(context, needs_legacy_helper)); if has_public_composition { out.push_str( "from dynwinrt import register_xaml_runtime_class as _dynwinrt_register_xaml_runtime_class\n", @@ -1280,7 +1286,7 @@ fn generate_python_constructor( )], }) .collect::>(); - emit_dispatch(&mut out, " ", &dispatch, context); + emit_dispatch(&mut out, " ", &dispatch, None, context); } out.push_str(" return super().__new__(cls)\n\n"); @@ -1488,7 +1494,7 @@ fn generate_python_constructor( } }) .collect::>(); - emit_dispatch(&mut out, " ", &dispatch, context); + emit_dispatch(&mut out, " ", &dispatch, None, context); if candidates.is_empty() { out.push_str(&format!( " raise TypeError(\"{} cannot be constructed directly\")\n\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 ecf26606..5f1be89e 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -41,8 +41,13 @@ use super::type_helpers::methods_have_async_output; const HEADER: &str = "# Generated by dynwinrt-codegen — do not edit\n"; const FUTURE_ANNOTATIONS: &str = "from __future__ import annotations\n"; -fn import_line(context: &PythonProjectionContext) -> String { +fn import_line(context: &PythonProjectionContext, needs_legacy_helper: bool) -> String { let object_input = context.support_symbol_import(PythonSupportSymbol::ObjectInput); + let legacy_helper = if needs_legacy_helper { + " _dynwinrt_legacy_call,\n" + } else { + "" + }; format!( "\ from ._runtime import ( @@ -53,7 +58,7 @@ from ._runtime import ( {object_input}, _property, _weakref_ref, _dynwinrt_array, _dynwinrt_bind_overload, _dynwinrt_can_cast, _dynwinrt_create_delegate, _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, - _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, +{legacy_helper} _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_symbol, _dynwinrt_track_projected, _dynwinrt_uuid, @@ -124,12 +129,27 @@ def _dynwinrt_can_cast(value, iid): raw = getattr(value, '_obj', value) if not isinstance(raw, DynWinRTValue): return False + if raw.is_null(): + return False + try: + raw.as_raw() + except RuntimeError as error: + if str(error) == 'Cannot get raw pointer from non-object': + return False + raise try: projected = raw.cast(iid) except OSError: return False projected.release() return True + + +def _dynwinrt_legacy_call(impl, parameter_names, args, kwargs, public_name): + bound = _dynwinrt_bind_overload(parameter_names, args, kwargs) + if bound is None: + raise TypeError(f'No matching overload for {public_name}') + return impl(*bound) \n"; pub fn generate_runtime_support_module() -> String { @@ -219,4 +239,21 @@ mod tests { assert!(runtime.contains("_dynwinrt_wrap_delegate_callback(callback),")); assert!(!runtime.contains("copy_context")); } + + #[test] + fn generated_cast_guard_rejects_null_and_non_object_values() { + let runtime = generate_runtime_support_module(); + + assert!(runtime.contains("if raw.is_null():\n return False")); + assert!(runtime.contains("raw.as_raw()")); + assert!(runtime.contains( + "if str(error) == 'Cannot get raw pointer from non-object':\n return False" + )); + assert!(runtime.contains("except OSError:\n return False")); + assert!(!runtime.contains("except RuntimeError:\n return False")); + assert!(runtime.contains( + "def _dynwinrt_legacy_call(impl, parameter_names, args, kwargs, public_name):" + )); + assert!(runtime.contains("raise TypeError(f'No matching overload for {public_name}')")); + } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/structs.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/structs.rs index 8fa74f45..88f39644 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/structs.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/structs.rs @@ -59,7 +59,7 @@ pub fn generate_struct(context: &PythonProjectionContext, s: &TypeMeta) -> Optio let mut out = String::new(); out.push_str(HEADER); out.push_str(FUTURE_ANNOTATIONS); - out.push_str(&import_line(context)); + out.push_str(&import_line(context, false)); out.push_str(&generate_struct_imports(context, &dependencies)); if has_ireference_struct_field(std::slice::from_ref(s)) { 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 629adcc9..789b34da 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs @@ -89,11 +89,12 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe return generate_delegate(iface); } let implementation = super::super::implementation::project(context, iface); + let plan = interface_member_plan(iface); let mut out = String::new(); out.push_str(HEADER); out.push_str(FUTURE_ANNOTATIONS); - out.push_str(&import_line(context)); + out.push_str(&import_line(context, plan.has_legacy_fallback())); if implementation.supported { out.push_str(super::super::implementation::IMPORTS); } @@ -577,7 +578,6 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe } else { "self._obj" }; - let plan = interface_member_plan(iface); let overload = |method| InstanceOverload { iface_var: iface_var.clone(), obj_expr: obj_expr.to_string(), diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs index 574cc45b..1ddde824 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs @@ -89,6 +89,14 @@ pub(crate) struct Candidate<'a> { pub(crate) struct MethodGroup<'a> { pub(crate) name: String, pub(crate) candidates: Vec>, + /// The exact formerly standalone method to call, without type guards, + /// when no typed overload candidate accepts the call. + pub(crate) legacy_fallback: Option>, +} + +pub(crate) struct LegacyFallback<'a> { + pub(crate) method: &'a MethodMeta, + pub(crate) attribute: String, } /// A previously emitted method name kept as a class attribute alias. @@ -170,6 +178,12 @@ impl<'a> ScopePlan<'a> { &self.aliases } + pub(crate) fn has_legacy_fallback(&self) -> bool { + self.groups + .iter() + .any(|group| group.legacy_fallback.is_some()) + } + /// CLR names that kept their previous Python names because of a collision. #[cfg(test)] pub(crate) fn fallbacks(&self) -> &[String] { @@ -709,10 +723,21 @@ fn plan_scopes<'a>( define, } }) - .collect(); + .collect::>(); + let legacy_fallback = existing[scope].get(name).and_then(|previous_key| { + let previous = &previous_groups[scope][previous_key]; + (previous.len() == 1 && candidates.len() > 1).then(|| { + let index = previous[0]; + LegacyFallback { + method: entries[index].method, + attribute: attribute_of[&index].clone(), + } + }) + }); plan_groups.push(MethodGroup { name: name.clone(), candidates, + legacy_fallback, }); } let previous_attributes = previous_groups[scope] @@ -971,6 +996,7 @@ mod tests { methods: BTreeMap<(String, usize), (String, String)>, aliases: Vec<(String, String)>, fallbacks: Vec, + legacy_fallbacks: BTreeMap, } impl Planned { @@ -1003,6 +1029,22 @@ mod tests { .map(|alias| (alias.name.clone(), alias.target.clone())) .collect(), fallbacks: plan.fallbacks().to_vec(), + legacy_fallbacks: plan + .groups + .iter() + .filter_map(|group| { + group.legacy_fallback.as_ref().map(|fallback| { + ( + group.name.clone(), + ( + fallback.method.name.clone(), + fallback.method.vtable_index, + fallback.attribute.clone(), + ), + ) + }) + }) + .collect(), } } @@ -1541,6 +1583,10 @@ mod tests { planned.aliases, aliases(&[("show_kind", "_show_9"), ("show_text", "_show_8")]) ); + assert_eq!( + planned.legacy_fallbacks["show"], + ("Show".to_string(), 6, "_show_6".to_string()) + ); } #[test] @@ -1604,4 +1650,85 @@ mod tests { ); assert_eq!(plan.previous_attribute(&widget.methods[1]), Some("create")); } + + #[test] + fn windows_corpus_marks_every_new_dispatcher_with_its_old_standalone_method() { + use crate::codegen::winrt::python::naming::PythonProjectionContext; + use crate::meta; + use std::path::Path; + + const WINMD: &str = + r"C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.26100.0\Windows.winmd"; + if !Path::new(WINMD).is_file() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + + fn assert_scope(interfaces: &[&InterfaceMeta], plan: &ScopePlan<'_>) -> usize { + let methods = interfaces + .iter() + .flat_map(|interface| interface.methods.iter()) + .filter(|method| !is_accessor(method)) + .collect::>(); + let names = methods.iter().map(|method| abi_name(method)).collect(); + let mut previous = BTreeMap::>::new(); + for method in methods { + previous + .entry(suffix_group_key(&abi_name(method), &names)) + .or_default() + .push(method); + } + + let mut count = 0; + for group in &plan.groups { + let Some(methods) = previous.get(&group.name) else { + continue; + }; + if methods.len() != 1 || group.candidates.len() <= 1 { + continue; + } + let fallback = group.legacy_fallback.as_ref().unwrap_or_else(|| { + panic!("{} became a dispatcher without a legacy tier", group.name) + }); + assert!( + std::ptr::eq(fallback.method, methods[0]), + "{} legacy tier changed its native method", + group.name + ); + count += 1; + } + count + } + + let context = PythonProjectionContext::default(); + let mut runtime_count = 0; + let mut all_plan_sites = 0; + for namespace in meta::list_namespaces(WINMD) { + for class in meta::parse_namespace(WINMD, &namespace) { + let statics = class + .factory_interfaces + .iter() + .chain(class.static_interfaces.iter()) + .collect::>(); + let instance = class_instance_interfaces(&class).collect::>(); + let plan = ClassMemberPlan::new(&class, &context); + let count = + assert_scope(&statics, &plan.statics) + assert_scope(&instance, &plan.instance); + runtime_count += count; + all_plan_sites += count; + for interface in &class.required_interfaces { + all_plan_sites += assert_scope(&[interface], &interface_member_plan(interface)); + } + } + for interface in meta::parse_interfaces(WINMD, &namespace) { + if !interface.is_delegate() { + all_plan_sites += + assert_scope(&[&interface], &interface_member_plan(&interface)); + } + } + } + + assert_eq!(runtime_count, 766); + assert_eq!(all_plan_sites, 897); + } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index 612178a0..f8dd66d7 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -198,16 +198,25 @@ pub(crate) struct DispatchCandidate<'a> { pub(crate) body: Vec, } +pub(crate) struct LegacyDispatch<'a> { + pub(crate) params: Vec<&'a crate::meta::ParamMeta>, + pub(crate) target: String, + pub(crate) public_name: String, +} + /// Emit argument binding and guards for an overload dispatcher. /// /// The first pass tries every candidate, in order, with its strict guards. /// Candidates with permissive guards are retried in a second pass that runs /// only after the first pass matched nothing, so a permissive guard can never -/// change which overload an already-matching call reaches. +/// change which overload an already-matching call reaches. A final legacy +/// candidate has no type guards and reproduces the conversions of the method +/// that occupied this public name before it became a dispatcher. pub(crate) fn emit_dispatch( out: &mut String, indent: &str, candidates: &[DispatchCandidate<'_>], + legacy: Option<&LegacyDispatch<'_>>, context: &PythonProjectionContext, ) { let guards = candidates @@ -233,25 +242,36 @@ pub(crate) fn emit_dispatch( emit_dispatch_candidate(out, indent, candidate, permissive); } } + if let Some(legacy) = legacy { + out.push_str(&format!( + "{indent}return _dynwinrt_legacy_call({}, {}, args, kwargs, '{}')\n", + legacy.target, + dispatch_parameter_names(&legacy.params), + legacy.public_name, + )); + } } -fn emit_dispatch_candidate<'g>( - out: &mut String, - indent: &str, - candidate: &DispatchCandidate<'_>, - guards: impl Iterator, -) { - let parameter_names = candidate - .params +fn dispatch_parameter_names(params: &[&crate::meta::ParamMeta]) -> String { + let names = params .iter() .map(|param| format!("'{}'", to_snake_case(¶m.name))) .collect::>() .join(", "); - let parameter_names = if parameter_names.is_empty() { + if names.is_empty() { "()".to_string() } else { - format!("({parameter_names},)") - }; + format!("({names},)") + } +} + +fn emit_dispatch_candidate<'g>( + out: &mut String, + indent: &str, + candidate: &DispatchCandidate<'_>, + guards: impl Iterator, +) { + let parameter_names = dispatch_parameter_names(&candidate.params); out.push_str(&format!( "{indent}_bound = _dynwinrt_bind_overload({parameter_names}, args, kwargs)\n" )); @@ -590,10 +610,20 @@ pub(crate) fn generate_instance_method_group<'a>( body: vec![format!("return self.{attribute}(*_bound)")], }) .collect::>(); - emit_dispatch(&mut out, " ", &candidates, context); - out.push_str(&format!( - " raise TypeError(\"No matching overload for {public_name}\")\n" - )); + let legacy = group + .legacy_fallback + .as_ref() + .map(|fallback| LegacyDispatch { + params: get_in_params(fallback.method), + target: format!("self.{}", fallback.attribute), + public_name: public_name.clone(), + }); + emit_dispatch(&mut out, " ", &candidates, legacy.as_ref(), context); + if legacy.is_none() { + out.push_str(&format!( + " raise TypeError(\"No matching overload for {public_name}\")\n" + )); + } out } @@ -693,10 +723,24 @@ pub(crate) fn generate_static_method_group<'a>( )], }) .collect::>(); - emit_dispatch(&mut out, " ", &candidates, context); - out.push_str(&format!( - " raise TypeError(\"No matching overload for {public_name}\")\n" - )); + let legacy = group + .legacy_fallback + .as_ref() + .map(|fallback| LegacyDispatch { + params: get_in_params(fallback.method), + target: format!( + "{}.{}", + context.class_name(overloads[0].0.class), + fallback.attribute + ), + public_name: public_name.clone(), + }); + emit_dispatch(&mut out, " ", &candidates, legacy.as_ref(), context); + if legacy.is_none() { + out.push_str(&format!( + " raise TypeError(\"No matching overload for {public_name}\")\n" + )); + } out } @@ -1035,6 +1079,7 @@ mod tests { attribute: "_choose_6".into(), define: true, }], + legacy_fallback: None, }; let code = generate_instance_method_group( diff --git a/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs b/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs index 2df801b8..59a96f76 100644 --- a/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs +++ b/tools/dynwinrt-codegen/tests/python_overload_normalization_test.rs @@ -105,6 +105,132 @@ fn default_option_method_is_one_python_overload_group_with_legacy_alias() { ); } +#[test] +fn newly_dispatched_methods_end_with_their_guard_free_legacy_tier() { + 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 iterable = TypeMeta::Parameterized { + namespace: "Windows.Foundation.Collections".into(), + name: "IIterable`1".into(), + piid: "faa585ea-6214-4217-afda-7f46de5869b3".into(), + args: vec![TypeMeta::String], + }; + let param = |name: &str, typ| ParamMeta { + name: name.into(), + typ, + direction: ParamDirection::In, + }; + let overload = |name: &str, raw_name: &str, index: usize, params: Vec| MethodMeta { + name: name.into(), + raw_name: raw_name.into(), + vtable_index: index, + params, + ..Default::default() + }; + let interface = InterfaceMeta { + name: "IWidget".into(), + namespace: "Contoso".into(), + iid: "11111111-1111-1111-1111-111111111111".into(), + methods: vec![ + overload( + "OpenAsync", + "OpenAsync", + 6, + vec![param("mode", mode.clone())], + ), + overload( + "OpenWithOptionsAsync", + "OpenAsync", + 7, + vec![param("mode", mode.clone()), param("options", mode.clone())], + ), + overload( + "FindAsync", + "FindAsync", + 8, + vec![param("id", TypeMeta::Guid)], + ), + overload( + "FindWithOptionsAsync", + "FindAsync", + 9, + vec![ + param("id", TypeMeta::Guid), + param("options", TypeMeta::String), + ], + ), + overload( + "CountAsync", + "CountAsync", + 10, + vec![param("count", TypeMeta::I32)], + ), + overload( + "CountWithOptionsAsync", + "CountAsync", + 11, + vec![ + param("count", TypeMeta::I32), + param("options", TypeMeta::Bool), + ], + ), + overload( + "LoadAsync", + "LoadAsync", + 12, + vec![param("items", iterable.clone())], + ), + overload( + "LoadWithOptionsAsync", + "LoadAsync", + 13, + vec![param("items", iterable), param("options", TypeMeta::Bool)], + ), + ], + ..Default::default() + }; + let known = HashSet::from(["IWidget".into(), "Mode".into()]); + let runtime = common::generate_interface(&interface, &known, &HashSet::new()); + let wrapper = &runtime[runtime.rfind("\nclass IWidget:").unwrap()..]; + + for (name, parameter, private, conversion) in [ + ("open_async", "mode", "_open_async_6", "int(mode)"), + ("find_async", "id", "_find_async_8", "_dynwinrt_guid(id)"), + ( + "count_async", + "count", + "_count_async_10", + "DynWinRTValue.from_i32(count)", + ), + ( + "load_async", + "items", + "_load_async_12", + "_dynwinrt_vector(items", + ), + ] { + let body = member_body(wrapper, name); + let tier = format!( + "return _dynwinrt_legacy_call(self.{private}, ('{parameter}',), args, kwargs, '{name}')" + ); + assert!( + body.contains(&tier), + "{name} lacks its final legacy tier:\n{body}" + ); + assert!( + member_body(wrapper, private).contains(conversion), + "{private} lost its permissive conversion:\n{runtime}" + ); + } +} + #[test] fn real_storage_folder_default_options_method_is_normalized() { if !Path::new(WINDOWS_WINMD).exists() { diff --git a/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py b/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py index 4f18e7b1..d0660108 100644 --- a/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py +++ b/tools/dynwinrt-codegen/tests/snapshots/data_writer_py/data_writer.py @@ -8,6 +8,7 @@ _DynWinRTObject, _property, _weakref_ref, _dynwinrt_array, _dynwinrt_bind_overload, _dynwinrt_can_cast, _dynwinrt_create_delegate, _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, + _dynwinrt_legacy_call, _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, _dynwinrt_ticks_to_timedelta, _dynwinrt_timedelta_to_ticks, _dynwinrt_cache_projected, _dynwinrt_projected_from_native, @@ -196,7 +197,7 @@ def write_buffer(self, *args, **kwargs): _bound = _dynwinrt_bind_overload(('buffer', 'start', 'count',), args, kwargs) if _bound is not None and (isinstance(_bound[0], _dynwinrt_symbol('windows__storage__streams__i_buffer', 'IBuffer')) or _dynwinrt_can_cast(_bound[0], IID_ARG_Windows_Storage_Streams_IBuffer)) and isinstance(_bound[1], int) and not isinstance(_bound[1], bool) and not isinstance(_bound[1], __import__('enum').Enum) and 0 <= _bound[1] <= 4294967295 and isinstance(_bound[2], int) and not isinstance(_bound[2], bool) and not isinstance(_bound[2], __import__('enum').Enum) and 0 <= _bound[2] <= 4294967295: return self._write_buffer_14(*_bound) - raise TypeError("No matching overload for write_buffer") + return _dynwinrt_legacy_call(self._write_buffer_13, ('buffer',), args, kwargs, 'write_buffer') def write_boolean(self, value: bool) -> None: _IDataWriter.method(15).invoke(self._obj, [DynWinRTValue.from_bool(value)]) From 12915fd4288cdd01dd91806b096ccc7dbb4f5770 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 28 Sep 2026 10:09:42 +0800 Subject: [PATCH 08/15] Preserve exact legacy overload routing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/e2e/runners/py_runner.py | 17 +- .../codegen/winrt/python/generator/class.rs | 32 +- .../src/codegen/winrt/python/generator/mod.rs | 66 ++- .../codegen/winrt/python/generator/structs.rs | 2 +- .../codegen/winrt/python/generator/types.rs | 12 +- .../src/codegen/winrt/python/member_plan.rs | 164 ++++++ .../src/codegen/winrt/python/method.rs | 73 ++- .../python_overload_legacy_compat_test.rs | 489 ++++++++++++++++++ 8 files changed, 822 insertions(+), 33 deletions(-) create mode 100644 tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 9b6f0485..4cdda746 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -1460,14 +1460,29 @@ def progress_without_loop(): ) except TypeError as error: legacy_error = str(error) + int_guards = [ + runtime._dynwinrt_legacy_int_guard('42'), + runtime._dynwinrt_legacy_int_guard('not numeric'), + ] + int_error = None + class UnexpectedIntError: + def __int__(self): + raise RuntimeError('unexpected-int-error') + try: + runtime._dynwinrt_legacy_int_guard(UnexpectedIntError()) + except RuntimeError as error: + int_error = str(error) if ( results != [False, False, False, False] or legacy != (1, 2) or legacy_error != 'No matching overload for example' + or int_guards != [True, False] + or int_error != 'unexpected-int-error' ): cr['error'] = ( 'runtime dispatch helpers failed: ' - f'casts={results!r}, legacy={legacy!r}, error={legacy_error!r}' + f'casts={results!r}, legacy={legacy!r}, error={legacy_error!r}, ' + f'int_guards={int_guards!r}, int_error={int_error!r}' ) else: cr['pass'] = True 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 e15e9aac..8a5e9217 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -69,12 +69,22 @@ pub fn generate_class<'a>( .required_interfaces .iter() .any(|interface| interface_member_plan(interface).has_legacy_fallback()); + let needs_legacy_int_guard = plan.statics.has_legacy_conversion_guard() + || plan.instance.has_legacy_conversion_guard() + || class + .required_interfaces + .iter() + .any(|interface| interface_member_plan(interface).has_legacy_conversion_guard()); let mut out = String::new(); // Header out.push_str(HEADER); out.push_str(FUTURE_ANNOTATIONS); - out.push_str(&import_line(context, needs_legacy_helper)); + out.push_str(&import_line( + context, + needs_legacy_helper, + needs_legacy_int_guard, + )); if has_public_composition { out.push_str( "from dynwinrt import register_xaml_runtime_class as _dynwinrt_register_xaml_runtime_class\n", @@ -447,7 +457,11 @@ pub fn generate_class<'a>( ), }); } - let static_aliases = generate_compatibility_aliases(&plan.statics); + let static_aliases = generate_static_compatibility_aliases( + &plan.statics, + |candidate| static_overload(candidate.interface, candidate.method), + context, + ); if !static_aliases.is_empty() { out.push('\n'); out.push_str(&static_aliases); @@ -656,7 +670,11 @@ pub fn generate_class<'a>( ), }); } - let instance_aliases = generate_compatibility_aliases(&plan.instance); + let instance_aliases = generate_instance_compatibility_aliases( + &plan.instance, + |candidate| instance_overload(candidate.interface, candidate.method), + context, + ); if !instance_aliases.is_empty() { out.push('\n'); out.push_str(&instance_aliases); @@ -891,7 +909,11 @@ pub fn generate_class<'a>( ), }); } - let aliases = generate_compatibility_aliases(&iface_plan); + let aliases = generate_instance_compatibility_aliases( + &iface_plan, + |candidate| overload(candidate.method), + context, + ); if !aliases.is_empty() { out.push('\n'); out.push_str(&aliases); @@ -1279,6 +1301,7 @@ fn generate_python_constructor( let dispatch = candidates .iter() .map(|candidate| DispatchCandidate { + method: None, params: candidate.public_params.clone(), body: vec![format!( "return {}", @@ -1489,6 +1512,7 @@ fn generate_python_constructor( body.push(format!("self._set_native({}._obj)", candidate.call_expr)); body.push("return".to_string()); DispatchCandidate { + method: None, params: candidate.public_params.clone(), body, } 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 5f1be89e..f15b28b3 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -22,7 +22,7 @@ use crate::codegen::winrt::shared::structs::{ collect_used_structs_from_struct, }; -use super::member_plan::ScopePlan; +use super::member_plan::{Candidate, ScopePlan}; use super::method::{ DispatchCandidate, InstanceOverload, StaticOverload, StaticOverloadKind, emit_dispatch, generate_instance_accessor, generate_instance_method_group, generate_static_accessor, @@ -41,13 +41,19 @@ use super::type_helpers::methods_have_async_output; const HEADER: &str = "# Generated by dynwinrt-codegen — do not edit\n"; const FUTURE_ANNOTATIONS: &str = "from __future__ import annotations\n"; -fn import_line(context: &PythonProjectionContext, needs_legacy_helper: bool) -> String { +fn import_line( + context: &PythonProjectionContext, + needs_legacy_helper: bool, + needs_legacy_int_guard: bool, +) -> String { let object_input = context.support_symbol_import(PythonSupportSymbol::ObjectInput); - let legacy_helper = if needs_legacy_helper { - " _dynwinrt_legacy_call,\n" - } else { - "" - }; + let mut legacy_helpers = String::new(); + if needs_legacy_helper { + legacy_helpers.push_str(" _dynwinrt_legacy_call,\n"); + } + if needs_legacy_int_guard { + legacy_helpers.push_str(" _dynwinrt_legacy_int_guard,\n"); + } format!( "\ from ._runtime import ( @@ -58,7 +64,7 @@ from ._runtime import ( {object_input}, _property, _weakref_ref, _dynwinrt_array, _dynwinrt_bind_overload, _dynwinrt_can_cast, _dynwinrt_create_delegate, _dynwinrt_datetime_to_ticks, _dynwinrt_delegate, _dynwinrt_enum, _dynwinrt_guid, -{legacy_helper} _dynwinrt_map, _dynwinrt_new_vector, _dynwinrt_ticks_to_datetime, +{legacy_helpers} _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_symbol, _dynwinrt_track_projected, _dynwinrt_uuid, @@ -150,6 +156,14 @@ def _dynwinrt_legacy_call(impl, parameter_names, args, kwargs, public_name): if bound is None: raise TypeError(f'No matching overload for {public_name}') return impl(*bound) + + +def _dynwinrt_legacy_int_guard(value): + try: + int(value) + except (TypeError, ValueError): + return False + return True \n"; pub fn generate_runtime_support_module() -> String { @@ -212,11 +226,38 @@ fn has_ireference_struct_field(structs: &[TypeMeta]) -> bool { structs.iter().any(contains) } -fn generate_compatibility_aliases(plan: &ScopePlan<'_>) -> String { +fn generate_instance_compatibility_aliases<'a>( + plan: &ScopePlan<'a>, + overload: impl Fn(&Candidate<'a>) -> InstanceOverload<'a>, + context: &PythonProjectionContext, +) -> String { plan.aliases() .iter() - .map(|alias| format!(" {} = {}\n", alias.name, alias.target)) - .collect() + .map(|alias| { + alias.dispatcher.as_ref().map_or_else( + || format!(" {} = {}\n", alias.name, alias.target), + |group| generate_instance_method_group(group, &overload, context), + ) + }) + .collect::>() + .join("\n") +} + +fn generate_static_compatibility_aliases<'a>( + plan: &ScopePlan<'a>, + overload: impl Fn(&Candidate<'a>) -> StaticOverload<'a>, + context: &PythonProjectionContext, +) -> String { + plan.aliases() + .iter() + .map(|alias| { + alias.dispatcher.as_ref().map_or_else( + || format!(" {} = {}\n", alias.name, alias.target), + |group| generate_static_method_group(group, &overload, context), + ) + }) + .collect::>() + .join("\n") } pub use class::generate_class; @@ -255,5 +296,8 @@ mod tests { "def _dynwinrt_legacy_call(impl, parameter_names, args, kwargs, public_name):" )); assert!(runtime.contains("raise TypeError(f'No matching overload for {public_name}')")); + assert!(runtime.contains("def _dynwinrt_legacy_int_guard(value):")); + assert!(runtime.contains("except (TypeError, ValueError):\n return False")); + assert!(!runtime.contains("except (TypeError, ValueError, RuntimeError)")); } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/structs.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/structs.rs index 88f39644..6ad8489d 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/structs.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/structs.rs @@ -59,7 +59,7 @@ pub fn generate_struct(context: &PythonProjectionContext, s: &TypeMeta) -> Optio let mut out = String::new(); out.push_str(HEADER); out.push_str(FUTURE_ANNOTATIONS); - out.push_str(&import_line(context, false)); + out.push_str(&import_line(context, false, false)); out.push_str(&generate_struct_imports(context, &dependencies)); if has_ireference_struct_field(std::slice::from_ref(s)) { 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 789b34da..adfdc614 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs @@ -94,7 +94,11 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe let mut out = String::new(); out.push_str(HEADER); out.push_str(FUTURE_ANNOTATIONS); - out.push_str(&import_line(context, plan.has_legacy_fallback())); + out.push_str(&import_line( + context, + plan.has_legacy_fallback(), + plan.has_legacy_conversion_guard(), + )); if implementation.supported { out.push_str(super::super::implementation::IMPORTS); } @@ -607,7 +611,11 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe ), }); } - let aliases = generate_compatibility_aliases(&plan); + let aliases = generate_instance_compatibility_aliases( + &plan, + |candidate| overload(candidate.method), + context, + ); if !aliases.is_empty() { out.push('\n'); out.push_str(&aliases); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs index 1ddde824..d9f7a482 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs @@ -99,11 +99,45 @@ pub(crate) struct LegacyFallback<'a> { pub(crate) attribute: String, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LegacyConversionGuard { + Int, +} + +/// Guard-free conversions accepted by an old standalone method that also fit +/// a newly added candidate's strict Python type domain. +pub(crate) fn legacy_conversion_shadow( + legacy: &MethodMeta, + candidate: &MethodMeta, +) -> Option> { + let legacy_params = get_in_params(legacy); + let candidate_params = get_in_params(candidate); + if legacy_params.len() != candidate_params.len() { + return None; + } + let mut guards = Vec::new(); + for (index, (legacy, candidate)) in legacy_params.iter().zip(candidate_params).enumerate() { + if legacy.typ == candidate.typ { + continue; + } + if matches!(legacy.typ, TypeMeta::Enum { .. }) && matches!(candidate.typ, TypeMeta::String) + { + guards.push((index, LegacyConversionGuard::Int)); + continue; + } + return None; + } + (!guards.is_empty()).then_some(guards) +} + /// A previously emitted method name kept as a class attribute alias. pub(crate) struct Alias<'a> { pub(crate) name: String, /// Attribute the alias is bound to. pub(crate) target: String, + /// Exact former dispatcher retained when the broader target could select a + /// different native method for a previously successful call. + pub(crate) dispatcher: Option>, /// Methods whose signatures the stub declares for this name. pub(crate) signatures: Vec<&'a MethodMeta>, } @@ -184,6 +218,23 @@ impl<'a> ScopePlan<'a> { .any(|group| group.legacy_fallback.is_some()) } + pub(crate) fn has_legacy_conversion_guard(&self) -> bool { + self.groups + .iter() + .chain( + self.aliases + .iter() + .filter_map(|alias| alias.dispatcher.as_ref()), + ) + .any(|group| { + group.legacy_fallback.as_ref().is_some_and(|legacy| { + group.candidates.iter().any(|candidate| { + legacy_conversion_shadow(legacy.method, candidate.method).is_some() + }) + }) + }) + } + /// CLR names that kept their previous Python names because of a collision. #[cfg(test)] pub(crate) fn fallbacks(&self) -> &[String] { @@ -761,6 +812,43 @@ fn plan_scopes<'a>( .name .clone() }; + let dispatcher = (previous_members.len() > 1) + .then(|| { + let target_group = + plan_groups.iter().find(|group| group.name == target)?; + let old_methods = previous_members + .iter() + .map(|&index| entries[index].method as *const MethodMeta) + .collect::>(); + let needs_exact_dispatcher = previous_members.iter().any(|&index| { + let method = entries[index].method; + let Some(position) = target_group + .candidates + .iter() + .position(|candidate| std::ptr::eq(candidate.method, method)) + else { + return false; + }; + target_group.candidates[..position].iter().any(|candidate| { + !old_methods.contains(&(candidate.method as *const MethodMeta)) + && equivalent_overloads(candidate.method, method) + }) + }); + needs_exact_dispatcher.then(|| MethodGroup { + name: name.clone(), + candidates: dispatch_order(previous_members) + .into_iter() + .map(|index| Candidate { + interface: entries[index].interface, + method: entries[index].method, + attribute: attribute_of[&index].clone(), + define: false, + }) + .collect(), + legacy_fallback: None, + }) + }) + .flatten(); // Stubs keep the signatures each name declared before. let signatures = if name == previous_key { dispatch_order(previous_members) @@ -774,6 +862,7 @@ fn plan_scopes<'a>( Alias { name: name.clone(), target, + dispatcher, signatures: signatures .into_iter() .map(|index| entries[index].method) @@ -1632,6 +1721,81 @@ mod tests { ); } + #[test] + fn former_dispatcher_keeps_exact_candidates_when_canonical_target_has_a_foreign_equivalent() { + let canonical = interface( + "ICanonical", + vec![overload("Foo", "Foo", 6, &[("value", TypeMeta::String)])], + ); + let legacy_text = interface( + "ILegacyText", + vec![overload( + "FooVersion", + "Foo", + 6, + &[("value", TypeMeta::String)], + )], + ); + let legacy_int = interface( + "ILegacyInt", + vec![overload( + "FooVersion", + "Foo", + 6, + &[("value", TypeMeta::I32)], + )], + ); + let plans = plan_scopes( + &[vec![&canonical, &legacy_text, &legacy_int]], + &HashSet::new(), + ); + let alias = plans[0] + .aliases() + .iter() + .find(|alias| alias.name == "foo_version") + .unwrap(); + assert_eq!(alias.target, "foo"); + assert_eq!( + alias + .dispatcher + .as_ref() + .unwrap() + .candidates + .iter() + .map(|candidate| candidate.interface.name.as_str()) + .collect::>(), + ["ILegacyText", "ILegacyInt"] + ); + } + + #[test] + fn enum_string_legacy_shadow_requires_the_complete_parameter_shape() { + let old = overload( + "Open", + "Open", + 6, + &[("mode", enumeration("Mode")), ("label", TypeMeta::String)], + ); + let overlapping = overload( + "OpenText", + "Open", + 7, + &[("mode", TypeMeta::String), ("label", TypeMeta::String)], + ); + let nonoverlapping = overload( + "OpenFlag", + "Open", + 8, + &[("mode", TypeMeta::String), ("enabled", TypeMeta::Bool)], + ); + + assert_eq!( + legacy_conversion_shadow(&old, &overlapping), + Some(vec![(0, LegacyConversionGuard::Int)]) + ); + assert_eq!(legacy_conversion_shadow(&old, &nonoverlapping), None); + } + #[test] fn previous_attributes_keep_pre_clr_names_for_ordering() { let widget = interface( diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index f8dd66d7..6e9b4771 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -9,7 +9,7 @@ use crate::codegen::winrt::shared::imports::{ fill_array_output_index, fill_array_uses_retval_count, get_in_params, }; -use super::member_plan::{Candidate, MethodGroup}; +use super::member_plan::{Candidate, LegacyConversionGuard, MethodGroup, legacy_conversion_shadow}; use super::naming::{PythonProjectionContext, PythonTypeIdentity, to_snake_case}; use super::signature::{ py_convert_return, py_interface_cast_guard, py_runtime_named_symbol, py_runtime_symbol, @@ -192,6 +192,7 @@ pub(crate) fn param_guard( /// One candidate of a generated `*args, **kwargs` overload dispatcher. pub(crate) struct DispatchCandidate<'a> { + pub(crate) method: Option<&'a MethodMeta>, /// Python-visible input parameters, in call order. pub(crate) params: Vec<&'a crate::meta::ParamMeta>, /// Statements run when the candidate matches, relative to its `if` block. @@ -199,6 +200,7 @@ pub(crate) struct DispatchCandidate<'a> { } pub(crate) struct LegacyDispatch<'a> { + pub(crate) method: &'a MethodMeta, pub(crate) params: Vec<&'a crate::meta::ParamMeta>, pub(crate) target: String, pub(crate) public_name: String, @@ -209,9 +211,9 @@ pub(crate) struct LegacyDispatch<'a> { /// The first pass tries every candidate, in order, with its strict guards. /// Candidates with permissive guards are retried in a second pass that runs /// only after the first pass matched nothing, so a permissive guard can never -/// change which overload an already-matching call reaches. A final legacy -/// candidate has no type guards and reproduces the conversions of the method -/// that occupied this public name before it became a dispatcher. +/// change which overload an already-matching call reaches. New candidates +/// exclude values known to remain accepted by the old guard-free conversion; +/// those reach the exact final legacy candidate instead. pub(crate) fn emit_dispatch( out: &mut String, indent: &str, @@ -230,16 +232,55 @@ pub(crate) fn emit_dispatch( .collect::>() }) .collect::>(); - for (candidate, guards) in candidates.iter().zip(&guards) { - let strict = guards.iter().map(|guard| guard.strict.as_str()); - emit_dispatch_candidate(out, indent, candidate, strict); + let legacy_shadows = legacy + .map(|legacy| { + candidates + .iter() + .map(|candidate| { + candidate + .method + .and_then(|method| legacy_conversion_shadow(legacy.method, method)) + }) + .collect::>() + }) + .unwrap_or_else(|| vec![None; candidates.len()]); + if legacy_shadows.iter().any(Option::is_some) { + let legacy = legacy.expect("legacy conversion shadows require a legacy candidate"); + out.push_str(&format!( + "{indent}_legacy_bound = _dynwinrt_bind_overload({}, args, kwargs)\n", + dispatch_parameter_names(&legacy.params), + )); } - for (candidate, guards) in candidates.iter().zip(&guards) { + let shadow_expression = |shadow: &[(usize, LegacyConversionGuard)]| { + std::iter::once("_legacy_bound is not None".to_string()) + .chain(shadow.iter().map(|(index, guard)| match guard { + LegacyConversionGuard::Int => { + format!("_dynwinrt_legacy_int_guard(_legacy_bound[{index}])") + } + })) + .collect::>() + .join(" and ") + }; + for ((candidate, guards), shadow) in candidates.iter().zip(&guards).zip(&legacy_shadows) { + let mut strict = guards + .iter() + .map(|guard| guard.strict.clone()) + .collect::>(); + if let Some(shadow) = shadow { + strict.push(format!("not ({})", shadow_expression(shadow))); + } + emit_dispatch_candidate(out, indent, candidate, &strict); + } + for ((candidate, guards), shadow) in candidates.iter().zip(&guards).zip(&legacy_shadows) { if guards.iter().any(|guard| guard.permissive.is_some()) { - let permissive = guards + let mut permissive = guards .iter() - .map(|guard| guard.permissive.as_deref().unwrap_or(&guard.strict)); - emit_dispatch_candidate(out, indent, candidate, permissive); + .map(|guard| guard.permissive.as_ref().unwrap_or(&guard.strict).clone()) + .collect::>(); + if let Some(shadow) = shadow { + permissive.push(format!("not ({})", shadow_expression(shadow))); + } + emit_dispatch_candidate(out, indent, candidate, &permissive); } } if let Some(legacy) = legacy { @@ -265,18 +306,18 @@ fn dispatch_parameter_names(params: &[&crate::meta::ParamMeta]) -> String { } } -fn emit_dispatch_candidate<'g>( +fn emit_dispatch_candidate( out: &mut String, indent: &str, candidate: &DispatchCandidate<'_>, - guards: impl Iterator, + guards: &[String], ) { let parameter_names = dispatch_parameter_names(&candidate.params); out.push_str(&format!( "{indent}_bound = _dynwinrt_bind_overload({parameter_names}, args, kwargs)\n" )); let condition = std::iter::once("_bound is not None") - .chain(guards) + .chain(guards.iter().map(String::as_str)) .collect::>() .join(" and "); out.push_str(&format!("{indent}if {condition}:\n")); @@ -606,6 +647,7 @@ pub(crate) fn generate_instance_method_group<'a>( let candidates = overloads .iter() .map(|(overload, attribute, _)| DispatchCandidate { + method: Some(overload.method), params: get_in_params(overload.method), body: vec![format!("return self.{attribute}(*_bound)")], }) @@ -614,6 +656,7 @@ pub(crate) fn generate_instance_method_group<'a>( .legacy_fallback .as_ref() .map(|fallback| LegacyDispatch { + method: fallback.method, params: get_in_params(fallback.method), target: format!("self.{}", fallback.attribute), public_name: public_name.clone(), @@ -716,6 +759,7 @@ pub(crate) fn generate_static_method_group<'a>( let candidates = overloads .iter() .map(|(overload, attribute, _)| DispatchCandidate { + method: Some(overload.method), params: get_in_params(overload.method), body: vec![format!( "return {}.{attribute}(*_bound)", @@ -727,6 +771,7 @@ pub(crate) fn generate_static_method_group<'a>( .legacy_fallback .as_ref() .map(|fallback| LegacyDispatch { + method: fallback.method, params: get_in_params(fallback.method), target: format!( "{}.{}", diff --git a/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs b/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs new file mode 100644 index 00000000..9170a504 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs @@ -0,0 +1,489 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use windows_metadata::{ + FieldAttributes, MethodAttributes, MethodCallAttributes, MethodImplAttributes, ParamAttributes, + Signature, Type, TypeAttributes, Value, writer, +}; + +const NAMESPACE: &str = "Tests.OverloadCompatibility"; +static NEXT: AtomicU64 = AtomicU64::new(0); + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let directory = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!( + "python-overload-legacy-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir_all(&directory).unwrap(); + Self(directory) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn root() -> PathBuf { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .unwrap(); + let text = path.to_string_lossy(); + PathBuf::from(text.strip_prefix(r"\\?\").unwrap_or(&text)) +} + +fn python() -> PathBuf { + std::env::var_os("DYNWINRT_TEST_PYTHON") + .map(PathBuf::from) + .unwrap_or_else(|| { + let venv = root() + .join("bindings") + .join("py") + .join(".venv") + .join("Scripts") + .join("python.exe"); + if venv.is_file() { + venv + } else { + PathBuf::from("python") + } + }) +} + +fn success(output: Output) { + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +fn guid(file: &mut writer::File, owner: writer::TypeDef, value: u32) { + let attribute = file.TypeRef("Windows.Foundation.Metadata", "GuidAttribute"); + let constructor = file.MemberRef( + ".ctor", + &Signature { + flags: MethodCallAttributes::HASTHIS, + return_type: Type::Void, + types: vec![ + Type::U32, + Type::U16, + Type::U16, + Type::U8, + Type::U8, + Type::U8, + Type::U8, + Type::U8, + Type::U8, + Type::U8, + Type::U8, + ], + }, + writer::MemberRefParent::TypeRef(attribute), + ); + let values = [ + Value::U32(value), + Value::U16(0x6281), + Value::U16(0x4900), + Value::U8(0xb7), + Value::U8(0x82), + Value::U8(4), + Value::U8(3), + Value::U8(2), + Value::U8(1), + Value::U8(9), + Value::U8(0x10), + ]; + file.Attribute( + writer::HasAttribute::TypeDef(owner), + writer::AttributeType::MemberRef(constructor), + &values + .into_iter() + .map(|value| (String::new(), value)) + .collect::>(), + ); +} + +fn overload(file: &mut writer::File, method: writer::MethodDef, name: &str) { + let attribute = file.TypeRef("Windows.Foundation.Metadata", "OverloadAttribute"); + let constructor = file.MemberRef( + ".ctor", + &Signature { + flags: MethodCallAttributes::HASTHIS, + return_type: Type::Void, + types: vec![Type::String], + }, + writer::MemberRefParent::TypeRef(attribute), + ); + file.Attribute( + writer::HasAttribute::MethodDef(method), + writer::AttributeType::MemberRef(constructor), + &[(String::new(), Value::Utf8(name.into()))], + ); +} + +fn interface( + file: &mut writer::File, + name: &str, + id: u32, + raw_name: &str, + abi_name: &str, + parameters: &[(&str, Type)], +) { + let definition = file.TypeDef( + NAMESPACE, + name, + writer::TypeDefOrRef::default(), + TypeAttributes::Public + | TypeAttributes::Interface + | TypeAttributes::Abstract + | TypeAttributes::WindowsRuntime, + ); + guid(file, definition, id); + let method = file.MethodDef( + raw_name, + &Signature { + flags: MethodCallAttributes::HASTHIS, + return_type: Type::I32, + types: parameters.iter().map(|(_, typ)| typ.clone()).collect(), + }, + MethodAttributes::Public + | MethodAttributes::Abstract + | MethodAttributes::Virtual + | MethodAttributes::NewSlot, + MethodImplAttributes::default(), + ); + for (index, (name, _)) in parameters.iter().enumerate() { + file.Param(name, index as u16 + 1, ParamAttributes::In); + } + if abi_name != raw_name { + overload(file, method, abi_name); + } +} + +fn runtime_class(file: &mut writer::File, name: &str, interfaces: &[&str]) { + let object = file.TypeRef("System", "Object"); + let definition = file.TypeDef( + NAMESPACE, + name, + writer::TypeDefOrRef::TypeRef(object), + TypeAttributes::Public | TypeAttributes::Sealed | TypeAttributes::WindowsRuntime, + ); + let default_attribute = file.TypeRef("Windows.Foundation.Metadata", "DefaultAttribute"); + let default_constructor = file.MemberRef( + ".ctor", + &Signature { + flags: MethodCallAttributes::HASTHIS, + return_type: Type::Void, + types: vec![], + }, + writer::MemberRefParent::TypeRef(default_attribute), + ); + for (index, interface) in interfaces.iter().enumerate() { + let implementation = file.InterfaceImpl(definition, &Type::named(NAMESPACE, *interface)); + if index == 0 { + file.Attribute( + writer::HasAttribute::InterfaceImpl(implementation), + writer::AttributeType::MemberRef(default_constructor), + &[], + ); + } + } +} + +fn metadata(path: &Path) { + let mut file = writer::File::new("PythonOverloadLegacyCompatibility"); + let enum_base = file.TypeRef("System", "Enum"); + let _mode = file.TypeDef( + NAMESPACE, + "Mode", + writer::TypeDefOrRef::TypeRef(enum_base), + TypeAttributes::Public | TypeAttributes::Sealed | TypeAttributes::WindowsRuntime, + ); + file.Field( + "value__", + &Type::I32, + FieldAttributes::Public | FieldAttributes::SpecialName | FieldAttributes::RTSpecialName, + ); + let one = file.Field( + "One", + &Type::named(NAMESPACE, "Mode"), + FieldAttributes::Public + | FieldAttributes::Static + | FieldAttributes::Literal + | FieldAttributes::HasDefault, + ); + file.Constant(writer::HasConstant::Field(one), &Value::I32(1)); + + interface( + &mut file, + "IAliasCanonical", + 0x51931901, + "Foo", + "Foo", + &[("value", Type::String)], + ); + interface( + &mut file, + "IAliasLegacyString", + 0x51931902, + "Foo", + "FooVersion", + &[("value", Type::String)], + ); + interface( + &mut file, + "IAliasLegacyInt", + 0x51931903, + "Foo", + "FooVersion", + &[("value", Type::I32)], + ); + runtime_class( + &mut file, + "AliasProbe", + &["IAliasCanonical", "IAliasLegacyString", "IAliasLegacyInt"], + ); + + interface( + &mut file, + "IEnumLegacy", + 0x51931904, + "Foo", + "Foo", + &[("value", Type::named(NAMESPACE, "Mode"))], + ); + interface( + &mut file, + "IStringCanonical", + 0x51931905, + "Foo", + "FooText", + &[("value", Type::String)], + ); + runtime_class(&mut file, "EnumProbe", &["IEnumLegacy", "IStringCanonical"]); + interface( + &mut file, + "IEnumPairLegacy", + 0x51931906, + "Bar", + "Bar", + &[ + ("mode", Type::named(NAMESPACE, "Mode")), + ("label", Type::String), + ], + ); + interface( + &mut file, + "IStringPairCanonical", + 0x51931907, + "Bar", + "BarText", + &[("mode", Type::String), ("enabled", Type::Bool)], + ); + runtime_class( + &mut file, + "PairProbe", + &["IEnumPairLegacy", "IStringPairCanonical"], + ); + + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, file.into_stream()).unwrap(); +} + +#[test] +fn old_dispatchers_and_guard_free_conversions_keep_their_exact_targets() { + let fixture = Fixture::new(); + let winmd = fixture.0.join("metadata").join("Input.winmd"); + metadata(&winmd); + let package = fixture.0.join("pyviews"); + success( + Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")) + .args(["generate", "--winmd"]) + .arg(&winmd) + .args(["--namespace", NAMESPACE, "--lang", "py", "--output"]) + .arg(&package) + .output() + .unwrap(), + ); + let alias_source = + fs::read_to_string(package.join("tests__overload_compatibility__alias_probe.py")).unwrap(); + let alias_class = alias_source + .split("\nclass IAliasLegacyString:") + .next() + .unwrap(); + assert!( + alias_class.contains(" def foo_version(self, *args, **kwargs):") + && alias_class.contains("return self._foo_6_1(*_bound)") + && !alias_class.contains(" foo_version = foo\n"), + "{alias_source}" + ); + let enum_source = + fs::read_to_string(package.join("tests__overload_compatibility__enum_probe.py")).unwrap(); + assert!( + enum_source.contains( + "not (_legacy_bound is not None and _dynwinrt_legacy_int_guard(_legacy_bound[0]))" + ) && enum_source.contains( + "return _dynwinrt_legacy_call(self._foo_6_1, ('value',), args, kwargs, 'foo')" + ), + "{enum_source}" + ); + let pair_source = + fs::read_to_string(package.join("tests__overload_compatibility__pair_probe.py")).unwrap(); + assert!( + !pair_source.contains("_dynwinrt_legacy_int_guard") + && pair_source.contains("return self._bar_6_0(*_bound)"), + "{pair_source}" + ); + + let available = Command::new(python()) + .args([ + "-c", + "import dynwinrt; assert hasattr(dynwinrt, 'DynWinRTImplementationHandle')", + ]) + .output() + .is_ok_and(|output| output.status.success()); + if !available { + eprintln!("Skipping live compatibility probe; prepared Python binding is unavailable"); + return; + } + + let probe = r#" +import json +import importlib +import dynwinrt as dw +from pyviews.tests__overload_compatibility__alias_probe import AliasProbe +from pyviews.tests__overload_compatibility__enum_probe import EnumProbe +from pyviews.tests__overload_compatibility__pair_probe import PairProbe +from pyviews.tests__overload_compatibility__i_alias_canonical import IAliasCanonical +from pyviews.tests__overload_compatibility__i_alias_legacy_int import IAliasLegacyInt +from pyviews.tests__overload_compatibility__i_alias_legacy_string import IAliasLegacyString +from pyviews.tests__overload_compatibility__i_enum_legacy import IEnumLegacy +from pyviews.tests__overload_compatibility__i_enum_pair_legacy import IEnumPairLegacy +from pyviews.tests__overload_compatibility__i_string_canonical import IStringCanonical +from pyviews.tests__overload_compatibility__i_string_pair_canonical import IStringPairCanonical + +calls = [] +runtime = importlib.import_module("pyviews._runtime") + +class AliasCanonical: + def foo(self, value): + calls.append(("alias-canonical", value)) + return 101 + +class AliasLegacyString: + def foo_version(self, value): + calls.append(("alias-legacy-string", value)) + return 201 + +class AliasLegacyInt: + def foo_version(self, value): + calls.append(("alias-legacy-int", value)) + return 202 + +class EnumLegacy: + def foo(self, value): + calls.append(("enum-legacy", int(value))) + return 301 + +class StringCanonical: + def foo_text(self, value): + calls.append(("string-canonical", value)) + return 401 + +class EnumPairLegacy: + def bar(self, mode, label): + calls.append(("enum-pair-legacy", int(mode), label)) + return 501 + +class StringPairCanonical: + def bar_text(self, mode, enabled): + calls.append(("string-pair-canonical", mode, enabled)) + return 601 + +results = {} +class UnexpectedIntError: + def __int__(self): + raise RuntimeError("unexpected-int-error") + +try: + runtime._dynwinrt_legacy_int_guard(UnexpectedIntError()) +except RuntimeError as error: + results["unexpected_int_error"] = str(error) + +with dw.RoApartment(1): + with IAliasCanonical.implement( + AliasCanonical(), + interfaces=[ + (IAliasLegacyString, AliasLegacyString()), + (IAliasLegacyInt, AliasLegacyInt()), + ], + ) as implementation: + value = AliasProbe._from_native(implementation.value._obj) + try: + results["alias_positional"] = value.foo_version("7") + results["alias_keyword"] = value.foo_version(value="8") + finally: + dw.release_projected(value) + + with IEnumLegacy.implement( + EnumLegacy(), + interfaces=[(IStringCanonical, StringCanonical())], + ) as implementation: + value = EnumProbe._from_native(implementation.value._obj) + try: + results["enum_positional"] = value.foo("1") + results["enum_keyword"] = value.foo(value="1") + results["text_positional"] = value.foo("not numeric") + finally: + dw.release_projected(value) + + with IEnumPairLegacy.implement( + EnumPairLegacy(), + interfaces=[(IStringPairCanonical, StringPairCanonical())], + ) as implementation: + value = PairProbe._from_native(implementation.value._obj) + try: + results["nonoverlap_positional"] = value.bar("1", True) + results["nonoverlap_keyword"] = value.bar(mode="1", enabled=False) + finally: + dw.release_projected(value) + +print(json.dumps({"results": results, "calls": calls})) +"#; + fs::write(fixture.0.join("probe.py"), probe).unwrap(); + let output = Command::new(python()) + .args(["-B", "probe.py"]) + .current_dir(&fixture.0) + .output() + .unwrap(); + success(output.clone()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains( + r#""results": {"unexpected_int_error": "unexpected-int-error", "alias_positional": 201, "alias_keyword": 201, "enum_positional": 301, "enum_keyword": 301, "text_positional": 401, "nonoverlap_positional": 601, "nonoverlap_keyword": 601}"# + ), + "{stdout}" + ); + assert!( + stdout.contains( + r#""calls": [["alias-legacy-string", "7"], ["alias-legacy-string", "8"], ["enum-legacy", 1], ["enum-legacy", 1], ["string-canonical", "not numeric"], ["string-pair-canonical", "1", true], ["string-pair-canonical", "1", false]]"# + ), + "{stdout}" + ); +} From 4d95df6dbe5fe9841cc3d02d4fb1872283c2ee47 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 28 Sep 2026 10:40:40 +0800 Subject: [PATCH 09/15] Require live overload compatibility regression Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 6 ++++-- .../tests/python_overload_legacy_compat_test.rs | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6ce079b2..0b70f85e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -543,12 +543,14 @@ jobs: .\eng\release\python\test_generated_python_long_paths.ps1 ` -Python .\bindings\py\.venv\Scripts\python.exe ` -Codegen $env:DYNWINRT_CODEGEN - - name: Test generated implementation symbol identities + - name: Test generated implementation and overload identities shell: pwsh run: | $env:DYNWINRT_TEST_PYTHON = (Resolve-Path .\bindings\py\.venv\Scripts\python.exe).Path $env:DYNWINRT_REQUIRE_IMPLEMENTATION_RUNTIME = '1' - cargo test -p dynwinrt-codegen --test implementation_naming_test + cargo test -p dynwinrt-codegen ` + --test implementation_naming_test ` + --test python_overload_legacy_compat_test - name: Run E2E tests run: .\tests\e2e\e2e_test.ps1 -SkipBuild -Codegen $env:DYNWINRT_CODEGEN # This optional-SDK behavioral smoke is separate from generated coverage diff --git a/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs b/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs index 9170a504..57d7d48e 100644 --- a/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs +++ b/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs @@ -236,7 +236,7 @@ fn metadata(path: &Path) { 0x51931901, "Foo", "Foo", - &[("value", Type::String)], + &[("text", Type::String)], ); interface( &mut file, @@ -329,7 +329,7 @@ fn old_dispatchers_and_guard_free_conversions_keep_their_exact_targets() { .unwrap(); assert!( alias_class.contains(" def foo_version(self, *args, **kwargs):") - && alias_class.contains("return self._foo_6_1(*_bound)") + && alias_class.contains("_IAliasLegacyString.method(6).invoke(") && !alias_class.contains(" foo_version = foo\n"), "{alias_source}" ); @@ -359,6 +359,11 @@ fn old_dispatchers_and_guard_free_conversions_keep_their_exact_targets() { .output() .is_ok_and(|output| output.status.success()); if !available { + assert_ne!( + std::env::var("DYNWINRT_REQUIRE_IMPLEMENTATION_RUNTIME").as_deref(), + Ok("1"), + "native Python implementation runtime is required" + ); eprintln!("Skipping live compatibility probe; prepared Python binding is unavailable"); return; } From 4ccc91f3a913211f5c60ecb6277df08dd7b09d37 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 28 Sep 2026 11:01:48 +0800 Subject: [PATCH 10/15] Update CI contract for overload regression Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- eng/ci/test_ci.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/eng/ci/test_ci.py b/eng/ci/test_ci.py index e0eeada5..29b1ca35 100644 --- a/eng/ci/test_ci.py +++ b/eng/ci/test_ci.py @@ -241,7 +241,9 @@ def test_current_run_artifacts_and_no_consumer_rebuilds(self): "npm run test:borrowed-copy"): self.assertNotIn(forbidden, commands) runtime = "\n".join(step.get("run", "") for step in JOBS["e2e-runtime"]["steps"]) - self.assertIn("cargo test -p dynwinrt-codegen --test implementation_naming_test", runtime) + self.assertIn("cargo test -p dynwinrt-codegen `", runtime) + self.assertIn("--test implementation_naming_test", runtime) + self.assertIn("--test python_overload_legacy_compat_test", runtime) def test_release_notes_validated_in_lightweight_lane(self): steps = [ From 3ce960ba86f293f699a77724094d61881da8a282 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 28 Sep 2026 12:56:40 +0800 Subject: [PATCH 11/15] Preserve bool calls to legacy numeric overloads Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../codegen/winrt/python/generator/class.rs | 6 +- .../codegen/winrt/python/generator/types.rs | 2 +- .../src/codegen/winrt/python/member_plan.rs | 135 +++++++++++++-- .../src/codegen/winrt/python/method.rs | 3 + .../python_overload_legacy_compat_test.rs | 159 +++++++++++++++++- 5 files changed, 286 insertions(+), 19 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 8a5e9217..dff69e41 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -69,12 +69,12 @@ pub fn generate_class<'a>( .required_interfaces .iter() .any(|interface| interface_member_plan(interface).has_legacy_fallback()); - let needs_legacy_int_guard = plan.statics.has_legacy_conversion_guard() - || plan.instance.has_legacy_conversion_guard() + let needs_legacy_int_guard = plan.statics.has_legacy_int_guard() + || plan.instance.has_legacy_int_guard() || class .required_interfaces .iter() - .any(|interface| interface_member_plan(interface).has_legacy_conversion_guard()); + .any(|interface| interface_member_plan(interface).has_legacy_int_guard()); let mut out = String::new(); // Header 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 adfdc614..52f57d0d 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs @@ -97,7 +97,7 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe out.push_str(&import_line( context, plan.has_legacy_fallback(), - plan.has_legacy_conversion_guard(), + plan.has_legacy_int_guard(), )); if implementation.supported { out.push_str(super::super::implementation::IMPORTS); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs index d9f7a482..4dd123b6 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs @@ -102,6 +102,24 @@ pub(crate) struct LegacyFallback<'a> { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum LegacyConversionGuard { Int, + Bool, +} + +fn legacy_conversion_accepts_bool(typ: &TypeMeta) -> bool { + matches!( + typ, + TypeMeta::I8 + | TypeMeta::U8 + | TypeMeta::I16 + | TypeMeta::U16 + | TypeMeta::I32 + | TypeMeta::U32 + | TypeMeta::I64 + | TypeMeta::U64 + | TypeMeta::F32 + | TypeMeta::F64 + | TypeMeta::Enum { .. } + ) || matches!(typ, TypeMeta::Struct { name, .. } if name == "HResult") } /// Guard-free conversions accepted by an old standalone method that also fit @@ -125,6 +143,10 @@ pub(crate) fn legacy_conversion_shadow( guards.push((index, LegacyConversionGuard::Int)); continue; } + if legacy_conversion_accepts_bool(&legacy.typ) && matches!(candidate.typ, TypeMeta::Bool) { + guards.push((index, LegacyConversionGuard::Bool)); + continue; + } return None; } (!guards.is_empty()).then_some(guards) @@ -218,7 +240,7 @@ impl<'a> ScopePlan<'a> { .any(|group| group.legacy_fallback.is_some()) } - pub(crate) fn has_legacy_conversion_guard(&self) -> bool { + pub(crate) fn has_legacy_int_guard(&self) -> bool { self.groups .iter() .chain( @@ -229,7 +251,13 @@ impl<'a> ScopePlan<'a> { .any(|group| { group.legacy_fallback.as_ref().is_some_and(|legacy| { group.candidates.iter().any(|candidate| { - legacy_conversion_shadow(legacy.method, candidate.method).is_some() + legacy_conversion_shadow(legacy.method, candidate.method).is_some_and( + |guards| { + guards + .iter() + .any(|(_, guard)| *guard == LegacyConversionGuard::Int) + }, + ) }) }) }) @@ -1769,8 +1797,8 @@ mod tests { } #[test] - fn enum_string_legacy_shadow_requires_the_complete_parameter_shape() { - let old = overload( + fn legacy_conversion_shadows_require_the_complete_parameter_shape() { + let old_enum = overload( "Open", "Open", 6, @@ -1790,10 +1818,70 @@ mod tests { ); assert_eq!( - legacy_conversion_shadow(&old, &overlapping), + legacy_conversion_shadow(&old_enum, &overlapping), Some(vec![(0, LegacyConversionGuard::Int)]) ); - assert_eq!(legacy_conversion_shadow(&old, &nonoverlapping), None); + assert_eq!(legacy_conversion_shadow(&old_enum, &nonoverlapping), None); + + for typ in [ + TypeMeta::I8, + TypeMeta::U8, + TypeMeta::I16, + TypeMeta::U16, + TypeMeta::I32, + TypeMeta::U32, + TypeMeta::I64, + TypeMeta::U64, + TypeMeta::F32, + TypeMeta::F64, + enumeration("Mode"), + TypeMeta::Struct { + namespace: "Windows.Foundation".into(), + name: "HResult".into(), + fields: Vec::new(), + }, + ] { + let old_numeric = overload("Pick", "Pick", 6, &[("value", typ)]); + let by_bool = overload("PickBool", "Pick", 7, &[("value", TypeMeta::Bool)]); + assert_eq!( + legacy_conversion_shadow(&old_numeric, &by_bool), + Some(vec![(0, LegacyConversionGuard::Bool)]) + ); + } + let old_char = overload("Pick", "Pick", 6, &[("value", TypeMeta::Char16)]); + let by_bool = overload("PickBool", "Pick", 7, &[("value", TypeMeta::Bool)]); + assert_eq!(legacy_conversion_shadow(&old_char, &by_bool), None); + + let old_numeric_pair = overload( + "Pick", + "Pick", + 6, + &[("value", TypeMeta::I32), ("label", TypeMeta::String)], + ); + let bool_other_parameter = overload( + "PickBool", + "Pick", + 7, + &[("value", TypeMeta::Bool), ("enabled", TypeMeta::Bool)], + ); + let bool_other_arity = overload( + "PickBool2", + "Pick", + 8, + &[ + ("value", TypeMeta::Bool), + ("label", TypeMeta::String), + ("enabled", TypeMeta::Bool), + ], + ); + assert_eq!( + legacy_conversion_shadow(&old_numeric_pair, &bool_other_parameter), + None + ); + assert_eq!( + legacy_conversion_shadow(&old_numeric_pair, &bool_other_arity), + None + ); } #[test] @@ -1828,7 +1916,7 @@ mod tests { return; } - fn assert_scope(interfaces: &[&InterfaceMeta], plan: &ScopePlan<'_>) -> usize { + fn assert_scope(interfaces: &[&InterfaceMeta], plan: &ScopePlan<'_>) -> (usize, usize) { let methods = interfaces .iter() .flat_map(|interface| interface.methods.iter()) @@ -1844,6 +1932,7 @@ mod tests { } let mut count = 0; + let mut bool_shadows = 0; for group in &plan.groups { let Some(methods) = previous.get(&group.name) else { continue; @@ -1859,14 +1948,28 @@ mod tests { "{} legacy tier changed its native method", group.name ); + bool_shadows += group + .candidates + .iter() + .filter(|candidate| { + legacy_conversion_shadow(fallback.method, candidate.method).is_some_and( + |guards| { + guards + .iter() + .any(|(_, guard)| *guard == LegacyConversionGuard::Bool) + }, + ) + }) + .count(); count += 1; } - count + (count, bool_shadows) } let context = PythonProjectionContext::default(); let mut runtime_count = 0; let mut all_plan_sites = 0; + let mut bool_shadows = 0; for namespace in meta::list_namespaces(WINMD) { for class in meta::parse_namespace(WINMD, &namespace) { let statics = class @@ -1876,23 +1979,29 @@ mod tests { .collect::>(); let instance = class_instance_interfaces(&class).collect::>(); let plan = ClassMemberPlan::new(&class, &context); - let count = - assert_scope(&statics, &plan.statics) + assert_scope(&instance, &plan.instance); + let static_count = assert_scope(&statics, &plan.statics); + let instance_count = assert_scope(&instance, &plan.instance); + let count = static_count.0 + instance_count.0; runtime_count += count; all_plan_sites += count; + bool_shadows += static_count.1 + instance_count.1; for interface in &class.required_interfaces { - all_plan_sites += assert_scope(&[interface], &interface_member_plan(interface)); + let count = assert_scope(&[interface], &interface_member_plan(interface)); + all_plan_sites += count.0; + bool_shadows += count.1; } } for interface in meta::parse_interfaces(WINMD, &namespace) { if !interface.is_delegate() { - all_plan_sites += - assert_scope(&[&interface], &interface_member_plan(&interface)); + let count = assert_scope(&[&interface], &interface_member_plan(&interface)); + all_plan_sites += count.0; + bool_shadows += count.1; } } } assert_eq!(runtime_count, 766); assert_eq!(all_plan_sites, 897); + assert_eq!(bool_shadows, 0); } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index 6e9b4771..b5ed928e 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -257,6 +257,9 @@ pub(crate) fn emit_dispatch( LegacyConversionGuard::Int => { format!("_dynwinrt_legacy_int_guard(_legacy_bound[{index}])") } + LegacyConversionGuard::Bool => { + format!("isinstance(_legacy_bound[{index}], bool)") + } })) .collect::>() .join(" and ") diff --git a/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs b/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs index 57d7d48e..2a97d863 100644 --- a/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs +++ b/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs @@ -301,6 +301,69 @@ fn metadata(path: &Path) { "PairProbe", &["IEnumPairLegacy", "IStringPairCanonical"], ); + interface( + &mut file, + "IIntBoolLegacy", + 0x51931908, + "Qux", + "Qux", + &[("value", Type::I32)], + ); + interface( + &mut file, + "IBoolCanonical", + 0x51931909, + "Qux", + "QuxBool", + &[("value", Type::Bool)], + ); + runtime_class( + &mut file, + "BoolProbe", + &["IIntBoolLegacy", "IBoolCanonical"], + ); + interface( + &mut file, + "IIntBoolPairLegacy", + 0x5193190a, + "Quux", + "Quux", + &[("value", Type::I32), ("label", Type::String)], + ); + interface( + &mut file, + "IBoolPairCanonical", + 0x5193190b, + "Quux", + "QuuxBool", + &[("value", Type::Bool), ("enabled", Type::Bool)], + ); + runtime_class( + &mut file, + "BoolPairProbe", + &["IIntBoolPairLegacy", "IBoolPairCanonical"], + ); + interface( + &mut file, + "IIntBoolArityLegacy", + 0x5193190c, + "Zap", + "Zap", + &[("value", Type::I32)], + ); + interface( + &mut file, + "IBoolArityCanonical", + 0x5193190d, + "Zap", + "ZapBool", + &[("value", Type::Bool), ("enabled", Type::Bool)], + ); + runtime_class( + &mut file, + "BoolArityProbe", + &["IIntBoolArityLegacy", "IBoolArityCanonical"], + ); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, file.into_stream()).unwrap(); @@ -350,6 +413,26 @@ fn old_dispatchers_and_guard_free_conversions_keep_their_exact_targets() { && pair_source.contains("return self._bar_6_0(*_bound)"), "{pair_source}" ); + let bool_source = + fs::read_to_string(package.join("tests__overload_compatibility__bool_probe.py")).unwrap(); + assert!( + bool_source.contains( + "isinstance(_bound[0], bool) and not (_legacy_bound is not None and isinstance(_legacy_bound[0], bool))" + ) && bool_source.contains( + "return _dynwinrt_legacy_call(self._qux_6_1, ('value',), args, kwargs, 'qux')" + ) && !bool_source.contains("_dynwinrt_legacy_int_guard"), + "{bool_source}" + ); + for name in ["bool_pair_probe", "bool_arity_probe"] { + let source = + fs::read_to_string(package.join(format!("tests__overload_compatibility__{name}.py"))) + .unwrap(); + assert!( + !source.contains("_legacy_bound") + && !source.contains("and not (_legacy_bound is not None"), + "{source}" + ); + } let available = Command::new(python()) .args([ @@ -373,13 +456,22 @@ import json import importlib import dynwinrt as dw from pyviews.tests__overload_compatibility__alias_probe import AliasProbe +from pyviews.tests__overload_compatibility__bool_arity_probe import BoolArityProbe +from pyviews.tests__overload_compatibility__bool_pair_probe import BoolPairProbe +from pyviews.tests__overload_compatibility__bool_probe import BoolProbe from pyviews.tests__overload_compatibility__enum_probe import EnumProbe from pyviews.tests__overload_compatibility__pair_probe import PairProbe from pyviews.tests__overload_compatibility__i_alias_canonical import IAliasCanonical from pyviews.tests__overload_compatibility__i_alias_legacy_int import IAliasLegacyInt from pyviews.tests__overload_compatibility__i_alias_legacy_string import IAliasLegacyString +from pyviews.tests__overload_compatibility__i_bool_arity_canonical import IBoolArityCanonical +from pyviews.tests__overload_compatibility__i_bool_canonical import IBoolCanonical +from pyviews.tests__overload_compatibility__i_bool_pair_canonical import IBoolPairCanonical from pyviews.tests__overload_compatibility__i_enum_legacy import IEnumLegacy from pyviews.tests__overload_compatibility__i_enum_pair_legacy import IEnumPairLegacy +from pyviews.tests__overload_compatibility__i_int_bool_arity_legacy import IIntBoolArityLegacy +from pyviews.tests__overload_compatibility__i_int_bool_legacy import IIntBoolLegacy +from pyviews.tests__overload_compatibility__i_int_bool_pair_legacy import IIntBoolPairLegacy from pyviews.tests__overload_compatibility__i_string_canonical import IStringCanonical from pyviews.tests__overload_compatibility__i_string_pair_canonical import IStringPairCanonical @@ -421,6 +513,36 @@ class StringPairCanonical: calls.append(("string-pair-canonical", mode, enabled)) return 601 +class IntBoolLegacy: + def qux(self, value): + calls.append(("int-bool-legacy", value)) + return 701 + +class BoolCanonical: + def qux_bool(self, value): + calls.append(("bool-canonical", value)) + return 702 + +class IntBoolPairLegacy: + def quux(self, value, label): + calls.append(("int-bool-pair-legacy", value, label)) + return 801 + +class BoolPairCanonical: + def quux_bool(self, value, enabled): + calls.append(("bool-pair-canonical", value, enabled)) + return 802 + +class IntBoolArityLegacy: + def zap(self, value): + calls.append(("int-bool-arity-legacy", value)) + return 901 + +class BoolArityCanonical: + def zap_bool(self, value, enabled): + calls.append(("bool-arity-canonical", value, enabled)) + return 902 + results = {} class UnexpectedIntError: def __int__(self): @@ -469,6 +591,39 @@ with dw.RoApartment(1): finally: dw.release_projected(value) + with IIntBoolLegacy.implement( + IntBoolLegacy(), + interfaces=[(IBoolCanonical, BoolCanonical())], + ) as implementation: + value = BoolProbe._from_native(implementation.value._obj) + try: + results["bool_positional"] = value.qux(True) + results["bool_keyword"] = value.qux(value=False) + finally: + dw.release_projected(value) + + with IIntBoolPairLegacy.implement( + IntBoolPairLegacy(), + interfaces=[(IBoolPairCanonical, BoolPairCanonical())], + ) as implementation: + value = BoolPairProbe._from_native(implementation.value._obj) + try: + results["bool_pair_positional"] = value.quux(True, False) + results["bool_pair_keyword"] = value.quux(value=False, enabled=True) + finally: + dw.release_projected(value) + + with IIntBoolArityLegacy.implement( + IntBoolArityLegacy(), + interfaces=[(IBoolArityCanonical, BoolArityCanonical())], + ) as implementation: + value = BoolArityProbe._from_native(implementation.value._obj) + try: + results["bool_arity_positional"] = value.zap(True, False) + results["bool_arity_keyword"] = value.zap(value=False, enabled=True) + finally: + dw.release_projected(value) + print(json.dumps({"results": results, "calls": calls})) "#; fs::write(fixture.0.join("probe.py"), probe).unwrap(); @@ -481,13 +636,13 @@ print(json.dumps({"results": results, "calls": calls})) let stdout = String::from_utf8(output.stdout).unwrap(); assert!( stdout.contains( - r#""results": {"unexpected_int_error": "unexpected-int-error", "alias_positional": 201, "alias_keyword": 201, "enum_positional": 301, "enum_keyword": 301, "text_positional": 401, "nonoverlap_positional": 601, "nonoverlap_keyword": 601}"# + r#""results": {"unexpected_int_error": "unexpected-int-error", "alias_positional": 201, "alias_keyword": 201, "enum_positional": 301, "enum_keyword": 301, "text_positional": 401, "nonoverlap_positional": 601, "nonoverlap_keyword": 601, "bool_positional": 701, "bool_keyword": 701, "bool_pair_positional": 802, "bool_pair_keyword": 802, "bool_arity_positional": 902, "bool_arity_keyword": 902}"# ), "{stdout}" ); assert!( stdout.contains( - r#""calls": [["alias-legacy-string", "7"], ["alias-legacy-string", "8"], ["enum-legacy", 1], ["enum-legacy", 1], ["string-canonical", "not numeric"], ["string-pair-canonical", "1", true], ["string-pair-canonical", "1", false]]"# + r#""calls": [["alias-legacy-string", "7"], ["alias-legacy-string", "8"], ["enum-legacy", 1], ["enum-legacy", 1], ["string-canonical", "not numeric"], ["string-pair-canonical", "1", true], ["string-pair-canonical", "1", false], ["int-bool-legacy", 1], ["int-bool-legacy", 0], ["bool-pair-canonical", true, false], ["bool-pair-canonical", false, true], ["bool-arity-canonical", true, false], ["bool-arity-canonical", false, true]]"# ), "{stdout}" ); From 094a1cdeef7b5a36f79ae956bf5437cfe01ce076 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 28 Sep 2026 16:13:39 +0800 Subject: [PATCH 12/15] Model legacy overload conversion domains Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/e2e/runners/py_runner.py | 5 +- .../codegen/winrt/python/generator/class.rs | 10 +- .../src/codegen/winrt/python/generator/mod.rs | 22 +- .../codegen/winrt/python/generator/types.rs | 2 +- .../src/codegen/winrt/python/member_plan.rs | 352 +++++++++---- .../src/codegen/winrt/python/method.rs | 107 ++-- .../src/codegen/winrt/python/signature.rs | 471 +++++++++++++++++- .../src/codegen/winrt/python/stubs.rs | 4 +- .../python_overload_legacy_compat_test.rs | 143 +++++- 9 files changed, 950 insertions(+), 166 deletions(-) diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 4cdda746..e78c461c 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -1463,6 +1463,9 @@ def progress_without_loop(): int_guards = [ runtime._dynwinrt_legacy_int_guard('42'), runtime._dynwinrt_legacy_int_guard('not numeric'), + runtime._dynwinrt_legacy_int_guard('42', 0, 100), + runtime._dynwinrt_legacy_int_guard('42', 43, 100), + runtime._dynwinrt_legacy_int_guard(float('inf')), ] int_error = None class UnexpectedIntError: @@ -1476,7 +1479,7 @@ def __int__(self): results != [False, False, False, False] or legacy != (1, 2) or legacy_error != 'No matching overload for example' - or int_guards != [True, False] + or int_guards != [True, False, True, False, False] or int_error != 'unexpected-int-error' ): cr['error'] = ( 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 dff69e41..dc4867a9 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -68,13 +68,13 @@ pub fn generate_class<'a>( || class .required_interfaces .iter() - .any(|interface| interface_member_plan(interface).has_legacy_fallback()); + .any(|interface| interface_member_plan(interface, context).has_legacy_fallback()); let needs_legacy_int_guard = plan.statics.has_legacy_int_guard() || plan.instance.has_legacy_int_guard() || class .required_interfaces .iter() - .any(|interface| interface_member_plan(interface).has_legacy_int_guard()); + .any(|interface| interface_member_plan(interface, context).has_legacy_int_guard()); let mut out = String::new(); // Header @@ -879,7 +879,7 @@ pub fn generate_class<'a>( out.push('\n'); out.push_str(" def as_interface(self, interface_class):\n"); out.push_str(" return interface_class.from_value(self._obj)\n"); - let iface_plan = interface_member_plan(req_iface); + let iface_plan = interface_member_plan(req_iface, context); let overload = |method: &'a MethodMeta| InstanceOverload { iface_var: reg_var.clone(), obj_expr: "self._obj".into(), @@ -1301,7 +1301,7 @@ fn generate_python_constructor( let dispatch = candidates .iter() .map(|candidate| DispatchCandidate { - method: None, + legacy_preservation: None, params: candidate.public_params.clone(), body: vec![format!( "return {}", @@ -1512,7 +1512,7 @@ fn generate_python_constructor( body.push(format!("self._set_native({}._obj)", candidate.call_expr)); body.push("return".to_string()); DispatchCandidate { - method: None, + legacy_preservation: None, params: candidate.public_params.clone(), body, } 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 f15b28b3..09c833fd 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -158,12 +158,15 @@ def _dynwinrt_legacy_call(impl, parameter_names, args, kwargs, public_name): return impl(*bound) -def _dynwinrt_legacy_int_guard(value): +def _dynwinrt_legacy_int_guard(value, minimum=None, maximum=None): try: - int(value) - except (TypeError, ValueError): + converted = int(value) + except (TypeError, ValueError, OverflowError): return False - return True + return ( + (minimum is None or minimum <= converted) + and (maximum is None or converted <= maximum) + ) \n"; pub fn generate_runtime_support_module() -> String { @@ -296,8 +299,13 @@ mod tests { "def _dynwinrt_legacy_call(impl, parameter_names, args, kwargs, public_name):" )); assert!(runtime.contains("raise TypeError(f'No matching overload for {public_name}')")); - assert!(runtime.contains("def _dynwinrt_legacy_int_guard(value):")); - assert!(runtime.contains("except (TypeError, ValueError):\n return False")); - assert!(!runtime.contains("except (TypeError, ValueError, RuntimeError)")); + assert!( + runtime.contains("def _dynwinrt_legacy_int_guard(value, minimum=None, maximum=None):") + ); + assert!( + runtime + .contains("except (TypeError, ValueError, OverflowError):\n return False") + ); + assert!(!runtime.contains("except (TypeError, ValueError, OverflowError, RuntimeError)")); } } 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 52f57d0d..14f23034 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/types.rs @@ -89,7 +89,7 @@ pub fn generate_interface(context: &PythonProjectionContext, iface: &InterfaceMe return generate_delegate(iface); } let implementation = super::super::implementation::project(context, iface); - let plan = interface_member_plan(iface); + let plan = interface_member_plan(iface, context); let mut out = String::new(); out.push_str(HEADER); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs index 4dd123b6..dce1be61 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs @@ -48,7 +48,10 @@ use super::collections::{ }; use super::naming::{PythonProjectionContext, to_snake_case}; use super::native_types::{FoundationType, foundation_type}; -use super::signature::py_dispatch_type_sort_key; +use super::signature::{ + LegacyGuardOverlap, LegacyPreservationPredicate, legacy_guard_overlap, + py_dispatch_type_sort_key, py_has_permissive_guard, +}; const ICLOSABLE_IID: &str = "30d5a829-7fa4-4026-83bb-d75bae4ea99e"; @@ -83,6 +86,15 @@ pub(crate) struct Candidate<'a> { /// dispatchers may also call an implementation defined by its canonical /// CLR-name group. pub(crate) define: bool, + /// Predicates that identify values accepted by the former guard-free + /// method and therefore reserve this call for the exact legacy tier. + pub(crate) legacy_preservation: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct LegacyPreservation { + pub(crate) strict: Option>, + pub(crate) permissive: Option>, } /// Methods projected as one Python method, in dispatch order. @@ -99,59 +111,6 @@ pub(crate) struct LegacyFallback<'a> { pub(crate) attribute: String, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum LegacyConversionGuard { - Int, - Bool, -} - -fn legacy_conversion_accepts_bool(typ: &TypeMeta) -> bool { - matches!( - typ, - TypeMeta::I8 - | TypeMeta::U8 - | TypeMeta::I16 - | TypeMeta::U16 - | TypeMeta::I32 - | TypeMeta::U32 - | TypeMeta::I64 - | TypeMeta::U64 - | TypeMeta::F32 - | TypeMeta::F64 - | TypeMeta::Enum { .. } - ) || matches!(typ, TypeMeta::Struct { name, .. } if name == "HResult") -} - -/// Guard-free conversions accepted by an old standalone method that also fit -/// a newly added candidate's strict Python type domain. -pub(crate) fn legacy_conversion_shadow( - legacy: &MethodMeta, - candidate: &MethodMeta, -) -> Option> { - let legacy_params = get_in_params(legacy); - let candidate_params = get_in_params(candidate); - if legacy_params.len() != candidate_params.len() { - return None; - } - let mut guards = Vec::new(); - for (index, (legacy, candidate)) in legacy_params.iter().zip(candidate_params).enumerate() { - if legacy.typ == candidate.typ { - continue; - } - if matches!(legacy.typ, TypeMeta::Enum { .. }) && matches!(candidate.typ, TypeMeta::String) - { - guards.push((index, LegacyConversionGuard::Int)); - continue; - } - if legacy_conversion_accepts_bool(&legacy.typ) && matches!(candidate.typ, TypeMeta::Bool) { - guards.push((index, LegacyConversionGuard::Bool)); - continue; - } - return None; - } - (!guards.is_empty()).then_some(guards) -} - /// A previously emitted method name kept as a class attribute alias. pub(crate) struct Alias<'a> { pub(crate) name: String, @@ -249,15 +208,20 @@ impl<'a> ScopePlan<'a> { .filter_map(|alias| alias.dispatcher.as_ref()), ) .any(|group| { - group.legacy_fallback.as_ref().is_some_and(|legacy| { - group.candidates.iter().any(|candidate| { - legacy_conversion_shadow(legacy.method, candidate.method).is_some_and( - |guards| { - guards - .iter() - .any(|(_, guard)| *guard == LegacyConversionGuard::Int) - }, - ) + group.candidates.iter().any(|candidate| { + candidate.legacy_preservation.as_ref().is_some_and(|plan| { + plan.strict + .iter() + .chain(plan.permissive.iter()) + .flatten() + .any(|predicate| { + matches!( + predicate, + LegacyPreservationPredicate::ExactIntConversionRangeOrSubclass { + .. + } + ) + }) }) }) }) @@ -286,7 +250,8 @@ impl<'a> ClassMemberPlan<'a> { .collect(); let instance = class_instance_interfaces(class).collect(); let reserved = class_reserved_names(class, context); - let mut scopes = plan_scopes(&[statics, instance], &reserved).into_iter(); + let mut scopes = + plan_scopes_with_context(&[statics, instance], &reserved, context).into_iter(); Self { statics: scopes.next().expect("static scope"), instance: scopes.next().expect("instance scope"), @@ -295,10 +260,17 @@ impl<'a> ClassMemberPlan<'a> { } /// Member plan for an interface wrapper class. -pub(crate) fn interface_member_plan(interface: &InterfaceMeta) -> ScopePlan<'_> { - plan_scopes(&[vec![interface]], &interface_reserved_names(interface)) - .pop() - .expect("interface scope") +pub(crate) fn interface_member_plan<'a>( + interface: &'a InterfaceMeta, + context: &PythonProjectionContext, +) -> ScopePlan<'a> { + plan_scopes_with_context( + &[vec![interface]], + &interface_reserved_names(interface), + context, + ) + .pop() + .expect("interface scope") } /// Interfaces whose methods are projected as instance members of a runtime class. @@ -528,11 +500,20 @@ struct Entry<'a> { type Groups = Vec>>; +#[cfg(test)] +fn plan_scopes<'a>( + scopes: &[Vec<&'a InterfaceMeta>], + reserved: &HashSet, +) -> Vec> { + plan_scopes_with_context(scopes, reserved, &PythonProjectionContext::default()) +} + /// Plan the scopes of one Python class namespace; `reserved` holds its /// non-method member names. -fn plan_scopes<'a>( +fn plan_scopes_with_context<'a>( scopes: &[Vec<&'a InterfaceMeta>], reserved: &HashSet, + context: &PythonProjectionContext, ) -> Vec> { let mut entries = Vec::new(); for (scope, interfaces) in scopes.iter().enumerate() { @@ -587,6 +568,46 @@ fn plan_scopes<'a>( } kept }; + let preservation = + |legacy: usize, candidate: usize| -> Result, ()> { + if legacy == candidate { + return Ok(None); + } + let strict = legacy_guard_overlap( + entries[legacy].method, + entries[candidate].method, + false, + context, + ); + let has_permissive = get_in_params(entries[candidate].method) + .iter() + .any(|param| py_has_permissive_guard(¶m.typ, context)); + let permissive = has_permissive.then(|| { + legacy_guard_overlap( + entries[legacy].method, + entries[candidate].method, + true, + context, + ) + }); + if strict == LegacyGuardOverlap::Unknown + || permissive.as_ref() == Some(&LegacyGuardOverlap::Unknown) + { + return Err(()); + } + let strict = match strict { + LegacyGuardOverlap::Preserve(predicates) => Some(predicates), + LegacyGuardOverlap::Disjoint => None, + LegacyGuardOverlap::Unknown => unreachable!("handled above"), + }; + let permissive = match permissive { + Some(LegacyGuardOverlap::Preserve(predicates)) => Some(predicates), + Some(LegacyGuardOverlap::Disjoint) | None => None, + Some(LegacyGuardOverlap::Unknown) => unreachable!("handled above"), + }; + Ok((strict.is_some() || permissive.is_some()) + .then_some(LegacyPreservation { strict, permissive })) + }; // Names emitted before CLR-name grouping, mapped to the group each reached. let mut previous_groups: Groups = vec![BTreeMap::new(); scopes.len()]; @@ -648,7 +669,11 @@ fn plan_scopes<'a>( members.iter().any(|&member| { !covers_shape(expected, member) && expected.iter().any(|&index| { - overloads_may_overlap(entries[member].method, entries[index].method) + if expected.len() == 1 { + preservation(index, member).is_err() + } else { + overloads_may_overlap(entries[member].method, entries[index].method) + } }) }) }; @@ -787,6 +812,10 @@ fn plan_scopes<'a>( } else { dispatch_order(members) }; + let legacy_index = existing[scope].get(name).and_then(|previous_key| { + let previous = &previous_groups[scope][previous_key]; + (previous.len() == 1 && ordered.len() > 1).then_some(previous[0]) + }); let candidates = ordered .into_iter() .map(|index| { @@ -800,18 +829,16 @@ fn plan_scopes<'a>( method: entry.method, attribute: attribute_of[&index].clone(), define, + legacy_preservation: legacy_index.and_then(|legacy| { + preservation(legacy, index) + .expect("unknown legacy overlap must retain ABI grouping") + }), } }) .collect::>(); - let legacy_fallback = existing[scope].get(name).and_then(|previous_key| { - let previous = &previous_groups[scope][previous_key]; - (previous.len() == 1 && candidates.len() > 1).then(|| { - let index = previous[0]; - LegacyFallback { - method: entries[index].method, - attribute: attribute_of[&index].clone(), - } - }) + let legacy_fallback = legacy_index.map(|index| LegacyFallback { + method: entries[index].method, + attribute: attribute_of[&index].clone(), }); plan_groups.push(MethodGroup { name: name.clone(), @@ -871,6 +898,7 @@ fn plan_scopes<'a>( method: entries[index].method, attribute: attribute_of[&index].clone(), define: false, + legacy_preservation: None, }) .collect(), legacy_fallback: None, @@ -1665,7 +1693,7 @@ mod tests { } #[test] - fn clr_name_falls_back_when_a_new_overload_could_take_existing_calls() { + fn clr_name_preserves_safe_overlaps_and_falls_back_for_unknown_ones() { let by_interface = overload("Show", "Show", 6, &[("target", interface_type("ITarget"))]); let by_text = overload( "ShowText", @@ -1682,9 +1710,13 @@ mod tests { &[], ); assert_eq!(planned.group("Show", 6), "show"); - assert_eq!(planned.group("ShowObject", 7), "show_object"); - assert_eq!(planned.group("ShowText", 8), "show_text"); - assert_eq!(planned.fallbacks, ["show"]); + assert_eq!(planned.group("ShowObject", 7), "show"); + assert_eq!(planned.group("ShowText", 8), "show"); + assert_eq!( + planned.aliases, + aliases(&[("show_object", "_show_7"), ("show_text", "_show_8")]) + ); + assert!(planned.fallbacks.is_empty()); let planned = plan_scope( vec![ @@ -1704,6 +1736,22 @@ mod tests { planned.legacy_fallbacks["show"], ("Show".to_string(), 6, "_show_6".to_string()) ); + + let planned = plan_scope( + vec![ + overload( + "Load", + "Load", + 6, + &[("values", TypeMeta::Array(Box::new(TypeMeta::I32)))], + ), + overload("LoadObject", "Load", 7, &[("values", TypeMeta::Object)]), + ], + &[], + ); + assert_eq!(planned.group("Load", 6), "load"); + assert_eq!(planned.group("LoadObject", 7), "load_object"); + assert_eq!(planned.fallbacks, ["load"]); } #[test] @@ -1798,6 +1846,7 @@ mod tests { #[test] fn legacy_conversion_shadows_require_the_complete_parameter_shape() { + let context = PythonProjectionContext::default(); let old_enum = overload( "Open", "Open", @@ -1818,10 +1867,20 @@ mod tests { ); assert_eq!( - legacy_conversion_shadow(&old_enum, &overlapping), - Some(vec![(0, LegacyConversionGuard::Int)]) + legacy_guard_overlap(&old_enum, &overlapping, false, &context), + LegacyGuardOverlap::Preserve(vec![ + LegacyPreservationPredicate::ExactIntConversionRangeOrSubclass { + index: 0, + exact_types: "(str,)", + minimum: i32::MIN as i128, + maximum: i32::MAX as i128, + } + ]) + ); + assert_eq!( + legacy_guard_overlap(&old_enum, &nonoverlapping, false, &context), + LegacyGuardOverlap::Disjoint ); - assert_eq!(legacy_conversion_shadow(&old_enum, &nonoverlapping), None); for typ in [ TypeMeta::I8, @@ -1844,13 +1903,16 @@ mod tests { let old_numeric = overload("Pick", "Pick", 6, &[("value", typ)]); let by_bool = overload("PickBool", "Pick", 7, &[("value", TypeMeta::Bool)]); assert_eq!( - legacy_conversion_shadow(&old_numeric, &by_bool), - Some(vec![(0, LegacyConversionGuard::Bool)]) + legacy_guard_overlap(&old_numeric, &by_bool, false, &context), + LegacyGuardOverlap::Preserve(Vec::new()) ); } let old_char = overload("Pick", "Pick", 6, &[("value", TypeMeta::Char16)]); let by_bool = overload("PickBool", "Pick", 7, &[("value", TypeMeta::Bool)]); - assert_eq!(legacy_conversion_shadow(&old_char, &by_bool), None); + assert_eq!( + legacy_guard_overlap(&old_char, &by_bool, false, &context), + LegacyGuardOverlap::Disjoint + ); let old_numeric_pair = overload( "Pick", @@ -1875,12 +1937,69 @@ mod tests { ], ); assert_eq!( - legacy_conversion_shadow(&old_numeric_pair, &bool_other_parameter), - None + legacy_guard_overlap(&old_numeric_pair, &bool_other_parameter, false, &context), + LegacyGuardOverlap::Disjoint + ); + assert_eq!( + legacy_guard_overlap(&old_numeric_pair, &bool_other_arity, false, &context), + LegacyGuardOverlap::Disjoint + ); + + let old_integer_pair = overload( + "Pick", + "Pick", + 6, + &[("first", TypeMeta::I32), ("second", TypeMeta::I32)], + ); + let bool_wide = overload( + "PickBool", + "Pick", + 7, + &[("first", TypeMeta::Bool), ("second", TypeMeta::I64)], + ); + assert_eq!( + legacy_guard_overlap(&old_integer_pair, &bool_wide, false, &context), + LegacyGuardOverlap::Preserve(vec![ + LegacyPreservationPredicate::ExactIntRangeOrSubclass { + index: 1, + minimum: i32::MIN as i128, + maximum: i32::MAX as i128, + } + ]) + ); + } + + #[test] + fn interface_conversion_overlap_is_planned_for_strict_and_permissive_tiers() { + let old_type = TypeMeta::Interface { + namespace: "Contoso".into(), + name: "IOld".into(), + iid: "11111111-1111-1111-1111-111111111111".into(), + }; + let new_type = TypeMeta::Interface { + namespace: "Contoso".into(), + name: "INew".into(), + iid: "22222222-2222-2222-2222-222222222222".into(), + }; + let context = PythonProjectionContext::standalone([ + old_type.type_identity(), + new_type.type_identity(), + ]) + .unwrap(); + let old = overload("Use", "Use", 6, &[("value", old_type)]); + let candidate = overload("UseNew", "Use", 7, &[("value", new_type)]); + let expected = LegacyGuardOverlap::Preserve(vec![LegacyPreservationPredicate::CanCast { + index: 0, + iid: "IID_ARG_Contoso_IOld".into(), + }]); + + assert_eq!( + legacy_guard_overlap(&old, &candidate, false, &context), + expected ); assert_eq!( - legacy_conversion_shadow(&old_numeric_pair, &bool_other_arity), - None + legacy_guard_overlap(&old, &candidate, true, &context), + expected ); } @@ -1952,13 +2071,10 @@ mod tests { .candidates .iter() .filter(|candidate| { - legacy_conversion_shadow(fallback.method, candidate.method).is_some_and( - |guards| { - guards - .iter() - .any(|(_, guard)| *guard == LegacyConversionGuard::Bool) - }, - ) + candidate.legacy_preservation.is_some() + && get_in_params(candidate.method) + .iter() + .any(|param| param.typ == TypeMeta::Bool) }) .count(); count += 1; @@ -1966,11 +2082,36 @@ mod tests { (count, bool_shadows) } - let context = PythonProjectionContext::default(); + let namespaces = meta::list_namespaces(WINMD); + let mut identities = BTreeSet::new(); + for namespace in &namespaces { + identities.extend( + meta::parse_enums(WINMD, namespace) + .into_iter() + .map(|typ| typ.type_identity()), + ); + identities.extend( + meta::parse_interfaces(WINMD, namespace) + .into_iter() + .map(|interface| interface.type_identity()), + ); + identities.extend( + meta::parse_namespace(WINMD, namespace) + .into_iter() + .map(|class| { + crate::types::TypeIdentity::named( + crate::types::TypeIdentityKind::Class, + class.namespace, + class.name, + ) + }), + ); + } + let context = PythonProjectionContext::standalone(identities).unwrap(); let mut runtime_count = 0; let mut all_plan_sites = 0; let mut bool_shadows = 0; - for namespace in meta::list_namespaces(WINMD) { + for namespace in namespaces { for class in meta::parse_namespace(WINMD, &namespace) { let statics = class .factory_interfaces @@ -1979,6 +2120,7 @@ mod tests { .collect::>(); let instance = class_instance_interfaces(&class).collect::>(); let plan = ClassMemberPlan::new(&class, &context); + let static_count = assert_scope(&statics, &plan.statics); let instance_count = assert_scope(&instance, &plan.instance); let count = static_count.0 + instance_count.0; @@ -1986,14 +2128,16 @@ mod tests { all_plan_sites += count; bool_shadows += static_count.1 + instance_count.1; for interface in &class.required_interfaces { - let count = assert_scope(&[interface], &interface_member_plan(interface)); + let count = + assert_scope(&[interface], &interface_member_plan(interface, &context)); all_plan_sites += count.0; bool_shadows += count.1; } } for interface in meta::parse_interfaces(WINMD, &namespace) { if !interface.is_delegate() { - let count = assert_scope(&[&interface], &interface_member_plan(&interface)); + let count = + assert_scope(&[&interface], &interface_member_plan(&interface, &context)); all_plan_sites += count.0; bool_shadows += count.1; } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index b5ed928e..ffa04a5f 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -9,11 +9,12 @@ use crate::codegen::winrt::shared::imports::{ fill_array_output_index, fill_array_uses_retval_count, get_in_params, }; -use super::member_plan::{Candidate, LegacyConversionGuard, MethodGroup, legacy_conversion_shadow}; +use super::member_plan::{Candidate, LegacyPreservation, MethodGroup}; use super::naming::{PythonProjectionContext, PythonTypeIdentity, to_snake_case}; use super::signature::{ - py_convert_return, py_interface_cast_guard, py_runtime_named_symbol, py_runtime_symbol, - py_type_guard, py_wrap_arg, py_wrap_async, py_wrap_async_with_converters, + LegacyPreservationPredicate, py_convert_return, py_has_permissive_guard, + py_interface_cast_guard, py_runtime_named_symbol, py_runtime_symbol, 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, @@ -184,7 +185,7 @@ pub(crate) fn param_guard( ) -> ParamGuard { ParamGuard { strict: py_method_type_guard(name, typ, context), - permissive: (!is_delegate_type(typ, context)) + permissive: py_has_permissive_guard(typ, context) .then(|| py_interface_cast_guard(name, typ, context)) .flatten(), } @@ -192,7 +193,7 @@ pub(crate) fn param_guard( /// One candidate of a generated `*args, **kwargs` overload dispatcher. pub(crate) struct DispatchCandidate<'a> { - pub(crate) method: Option<&'a MethodMeta>, + pub(crate) legacy_preservation: Option<&'a LegacyPreservation>, /// Python-visible input parameters, in call order. pub(crate) params: Vec<&'a crate::meta::ParamMeta>, /// Statements run when the candidate matches, relative to its `if` block. @@ -200,7 +201,6 @@ pub(crate) struct DispatchCandidate<'a> { } pub(crate) struct LegacyDispatch<'a> { - pub(crate) method: &'a MethodMeta, pub(crate) params: Vec<&'a crate::meta::ParamMeta>, pub(crate) target: String, pub(crate) public_name: String, @@ -232,56 +232,88 @@ pub(crate) fn emit_dispatch( .collect::>() }) .collect::>(); - let legacy_shadows = legacy - .map(|legacy| { - candidates - .iter() - .map(|candidate| { - candidate - .method - .and_then(|method| legacy_conversion_shadow(legacy.method, method)) - }) - .collect::>() - }) - .unwrap_or_else(|| vec![None; candidates.len()]); - if legacy_shadows.iter().any(Option::is_some) { + let needs_legacy_bound = candidates.iter().any(|candidate| { + candidate + .legacy_preservation + .as_ref() + .is_some_and(|plan| plan.strict.is_some() || plan.permissive.is_some()) + }); + if needs_legacy_bound { let legacy = legacy.expect("legacy conversion shadows require a legacy candidate"); out.push_str(&format!( "{indent}_legacy_bound = _dynwinrt_bind_overload({}, args, kwargs)\n", dispatch_parameter_names(&legacy.params), )); } - let shadow_expression = |shadow: &[(usize, LegacyConversionGuard)]| { + let preservation_expression = |predicates: &[LegacyPreservationPredicate]| { std::iter::once("_legacy_bound is not None".to_string()) - .chain(shadow.iter().map(|(index, guard)| match guard { - LegacyConversionGuard::Int => { - format!("_dynwinrt_legacy_int_guard(_legacy_bound[{index}])") + .chain(predicates.iter().map(|predicate| match predicate { + LegacyPreservationPredicate::ExactIntRangeOrSubclass { + index, + minimum, + maximum, + } => format!( + "(type(_legacy_bound[{index}]) is not int or {minimum} <= _legacy_bound[{index}] <= {maximum})" + ), + LegacyPreservationPredicate::ExactRealIntRangeOrSubclass { + index, + minimum, + maximum, + } => format!( + "(type(_legacy_bound[{index}]) not in (int, float) or (type(_legacy_bound[{index}]) is int and {minimum} <= _legacy_bound[{index}] <= {maximum}))" + ), + LegacyPreservationPredicate::ExactIntConversionRangeOrSubclass { + index, + exact_types, + minimum, + maximum, + } => format!( + "(type(_legacy_bound[{index}]) not in {exact_types} or _dynwinrt_legacy_int_guard(_legacy_bound[{index}], {minimum}, {maximum}))" + ), + LegacyPreservationPredicate::NumericRange { + index, + minimum, + maximum, + } => format!("{minimum} <= _legacy_bound[{index}] <= {maximum}"), + LegacyPreservationPredicate::Char16OrStringSubclass { index } => format!( + "(type(_legacy_bound[{index}]) is not str or (len(_legacy_bound[{index}]) == 1 and ord(_legacy_bound[{index}]) <= 65535))" + ), + LegacyPreservationPredicate::CanCast { index, iid } => { + format!("_dynwinrt_can_cast(_legacy_bound[{index}], {iid})") } - LegacyConversionGuard::Bool => { - format!("isinstance(_legacy_bound[{index}], bool)") + LegacyPreservationPredicate::DynWinRTValue { index } => { + format!( + "isinstance(getattr(_legacy_bound[{index}], '_obj', _legacy_bound[{index}]), DynWinRTValue)" + ) } })) .collect::>() .join(" and ") }; - for ((candidate, guards), shadow) in candidates.iter().zip(&guards).zip(&legacy_shadows) { + for (candidate, guards) in candidates.iter().zip(&guards) { let mut strict = guards .iter() .map(|guard| guard.strict.clone()) .collect::>(); - if let Some(shadow) = shadow { - strict.push(format!("not ({})", shadow_expression(shadow))); + if let Some(predicates) = candidate + .legacy_preservation + .and_then(|plan| plan.strict.as_deref()) + { + strict.insert(0, format!("not ({})", preservation_expression(predicates))); } emit_dispatch_candidate(out, indent, candidate, &strict); } - for ((candidate, guards), shadow) in candidates.iter().zip(&guards).zip(&legacy_shadows) { + for (candidate, guards) in candidates.iter().zip(&guards) { if guards.iter().any(|guard| guard.permissive.is_some()) { let mut permissive = guards .iter() .map(|guard| guard.permissive.as_ref().unwrap_or(&guard.strict).clone()) .collect::>(); - if let Some(shadow) = shadow { - permissive.push(format!("not ({})", shadow_expression(shadow))); + if let Some(predicates) = candidate + .legacy_preservation + .and_then(|plan| plan.permissive.as_deref()) + { + permissive.insert(0, format!("not ({})", preservation_expression(predicates))); } emit_dispatch_candidate(out, indent, candidate, &permissive); } @@ -649,8 +681,9 @@ pub(crate) fn generate_instance_method_group<'a>( out.push_str(&method_pydoc(group.candidates[0].method, &public_params)); let candidates = overloads .iter() - .map(|(overload, attribute, _)| DispatchCandidate { - method: Some(overload.method), + .zip(&group.candidates) + .map(|((overload, attribute, _), candidate)| DispatchCandidate { + legacy_preservation: candidate.legacy_preservation.as_ref(), params: get_in_params(overload.method), body: vec![format!("return self.{attribute}(*_bound)")], }) @@ -659,7 +692,6 @@ pub(crate) fn generate_instance_method_group<'a>( .legacy_fallback .as_ref() .map(|fallback| LegacyDispatch { - method: fallback.method, params: get_in_params(fallback.method), target: format!("self.{}", fallback.attribute), public_name: public_name.clone(), @@ -761,8 +793,9 @@ pub(crate) fn generate_static_method_group<'a>( out.push_str(&method_pydoc(group.candidates[0].method, &public_params)); let candidates = overloads .iter() - .map(|(overload, attribute, _)| DispatchCandidate { - method: Some(overload.method), + .zip(&group.candidates) + .map(|((overload, attribute, _), candidate)| DispatchCandidate { + legacy_preservation: candidate.legacy_preservation.as_ref(), params: get_in_params(overload.method), body: vec![format!( "return {}.{attribute}(*_bound)", @@ -774,7 +807,6 @@ pub(crate) fn generate_static_method_group<'a>( .legacy_fallback .as_ref() .map(|fallback| LegacyDispatch { - method: fallback.method, params: get_in_params(fallback.method), target: format!( "{}.{}", @@ -1126,6 +1158,7 @@ mod tests { method: &method, attribute: "_choose_6".into(), define: true, + legacy_preservation: None, }], legacy_fallback: None, }; diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs index 42a828b8..f5fd3388 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs @@ -9,7 +9,7 @@ use crate::types::{TypeIdentity, TypeIdentityKind, TypeMeta}; use super::naming::{PythonProjectionContext, PythonSymbol}; use crate::codegen::winrt::python::collections::{CollectionKind, is_mapping_input, type_kind}; use crate::codegen::winrt::python::native_types::{FoundationType, foundation_type}; -use crate::codegen::winrt::shared::imports::ireference_inner_type; +use crate::codegen::winrt::shared::imports::{get_in_params, ireference_inner_type}; pub(crate) fn py_runtime_symbol( context: &PythonProjectionContext, @@ -77,6 +77,470 @@ pub(crate) fn py_integer_bounds(typ: &TypeMeta) -> Option<(i128, i128)> { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum LegacyPreservationPredicate { + /// The candidate accepts builtin ints over a wider range. Exact ints must + /// fit the old converter; subclasses stay on the old path without probing + /// potentially visible conversion methods. + ExactIntRangeOrSubclass { + index: usize, + minimum: i128, + maximum: i128, + }, + /// The candidate accepts builtin ints and floats. Only exact builtin + /// values are preflighted; subclasses conservatively stay on the old path. + ExactRealIntRangeOrSubclass { + index: usize, + minimum: i128, + maximum: i128, + }, + /// The old enum converter calls `int()`. Parse only exact builtin strings + /// or numeric values; subclasses conservatively stay on the old path. + ExactIntConversionRangeOrSubclass { + index: usize, + exact_types: &'static str, + minimum: i128, + maximum: i128, + }, + NumericRange { + index: usize, + minimum: i128, + maximum: i128, + }, + Char16OrStringSubclass { + index: usize, + }, + CanCast { + index: usize, + iid: String, + }, + DynWinRTValue { + index: usize, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum LegacyGuardOverlap { + Disjoint, + Preserve(Vec), + /// The overlap cannot be checked without invoking user conversion code or + /// consuming an input. The planner must retain the old ABI grouping. + Unknown, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum LegacyConversionDomain { + Bool, + Integer { minimum: i128, maximum: i128 }, + Real, + EnumInteger { minimum: i128, maximum: i128 }, + Char16, + String, + Guid, + DateTime, + TimeSpan, + Struct(TypeIdentity), + QueryInterface(String), + DynWinRTValue, + Delegate, + Collection, + Unknown, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum CandidateGuardDomain { + Bool, + Integer { minimum: i128, maximum: i128 }, + Real, + KnownEnum { minimum: i128, maximum: i128 }, + Char16, + String, + Guid, + DateTime, + TimeSpan, + Struct(TypeIdentity), + QueryInterface(String), + DynWinRTValue, + Delegate, + Collection, + Unknown, +} + +fn enum_bounds(typ: &TypeMeta) -> Option<(i128, i128)> { + let TypeMeta::Enum { underlying, .. } = typ else { + return None; + }; + py_integer_bounds(underlying) +} + +fn hresult(typ: &TypeMeta) -> bool { + matches!(typ, TypeMeta::Struct { name, .. } if name == "HResult") +} + +fn legacy_conversion_domain( + typ: &TypeMeta, + context: &PythonProjectionContext, +) -> LegacyConversionDomain { + if ireference_inner_type(typ).is_some() { + return LegacyConversionDomain::Unknown; + } + if type_kind(typ).is_some() { + return LegacyConversionDomain::Collection; + } + if let Some((minimum, maximum)) = py_integer_bounds(typ) { + return LegacyConversionDomain::Integer { minimum, maximum }; + } + if hresult(typ) { + return LegacyConversionDomain::Integer { + minimum: i32::MIN as i128, + maximum: i32::MAX as i128, + }; + } + match typ { + TypeMeta::Bool => LegacyConversionDomain::Bool, + TypeMeta::F32 | TypeMeta::F64 => LegacyConversionDomain::Real, + TypeMeta::Enum { .. } => enum_bounds(typ) + .map_or(LegacyConversionDomain::Unknown, |(minimum, maximum)| { + LegacyConversionDomain::EnumInteger { minimum, maximum } + }), + TypeMeta::Char16 => LegacyConversionDomain::Char16, + TypeMeta::String => LegacyConversionDomain::String, + TypeMeta::Guid => LegacyConversionDomain::Guid, + typ if foundation_type(typ) == Some(FoundationType::DateTime) => { + LegacyConversionDomain::DateTime + } + typ if foundation_type(typ) == Some(FoundationType::TimeSpan) => { + LegacyConversionDomain::TimeSpan + } + TypeMeta::Struct { .. } => LegacyConversionDomain::Struct(typ.type_identity()), + typ @ TypeMeta::RuntimeClass { .. } => py_runtime_class_iid_const(typ) + .map(|(iid, _)| LegacyConversionDomain::QueryInterface(iid)) + .unwrap_or(LegacyConversionDomain::Unknown), + typ @ TypeMeta::Interface { .. } => py_interface_iid_const(typ) + .map(|(iid, _)| LegacyConversionDomain::QueryInterface(iid)) + .unwrap_or(LegacyConversionDomain::Unknown), + TypeMeta::Delegate { .. } if context.is_delegate_type(typ) => { + LegacyConversionDomain::Delegate + } + TypeMeta::Object | TypeMeta::Delegate { .. } | TypeMeta::Parameterized { .. } => { + LegacyConversionDomain::DynWinRTValue + } + _ => LegacyConversionDomain::Unknown, + } +} + +fn candidate_guard_domain( + typ: &TypeMeta, + permissive: bool, + context: &PythonProjectionContext, +) -> CandidateGuardDomain { + if ireference_inner_type(typ).is_some() { + return CandidateGuardDomain::Unknown; + } + if type_kind(typ).is_some() { + return CandidateGuardDomain::Collection; + } + if context.is_delegate_type(typ) { + return CandidateGuardDomain::Delegate; + } + if let Some((minimum, maximum)) = py_integer_bounds(typ) { + return CandidateGuardDomain::Integer { minimum, maximum }; + } + if hresult(typ) { + return CandidateGuardDomain::Integer { + minimum: i32::MIN as i128, + maximum: i32::MAX as i128, + }; + } + match typ { + TypeMeta::Bool => CandidateGuardDomain::Bool, + TypeMeta::F32 | TypeMeta::F64 => CandidateGuardDomain::Real, + TypeMeta::Enum { .. } if context.is_known_type(typ) => enum_bounds(typ) + .map_or(CandidateGuardDomain::Unknown, |(minimum, maximum)| { + CandidateGuardDomain::KnownEnum { minimum, maximum } + }), + TypeMeta::Enum { .. } => CandidateGuardDomain::Integer { + minimum: i128::MIN, + maximum: i128::MAX, + }, + TypeMeta::Char16 => CandidateGuardDomain::Char16, + TypeMeta::String => CandidateGuardDomain::String, + TypeMeta::Guid => CandidateGuardDomain::Guid, + typ if foundation_type(typ) == Some(FoundationType::DateTime) => { + CandidateGuardDomain::DateTime + } + typ if foundation_type(typ) == Some(FoundationType::TimeSpan) => { + CandidateGuardDomain::TimeSpan + } + TypeMeta::Struct { .. } => CandidateGuardDomain::Struct(typ.type_identity()), + typ @ TypeMeta::RuntimeClass { .. } => py_runtime_class_iid_const(typ) + .map(|(iid, _)| CandidateGuardDomain::QueryInterface(iid)) + .unwrap_or(CandidateGuardDomain::DynWinRTValue), + typ @ TypeMeta::Interface { .. } if permissive => py_interface_iid_const(typ) + .map(|(iid, _)| CandidateGuardDomain::QueryInterface(iid)) + .unwrap_or(CandidateGuardDomain::DynWinRTValue), + TypeMeta::Interface { .. } => CandidateGuardDomain::DynWinRTValue, + TypeMeta::Object | TypeMeta::Delegate { .. } | TypeMeta::Parameterized { .. } => { + CandidateGuardDomain::DynWinRTValue + } + _ => CandidateGuardDomain::Unknown, + } +} + +fn overlap_parameter( + legacy: &LegacyConversionDomain, + candidate: &CandidateGuardDomain, + index: usize, +) -> LegacyGuardOverlap { + use CandidateGuardDomain as Guard; + use LegacyConversionDomain as Legacy; + use LegacyGuardOverlap::{Disjoint, Preserve, Unknown}; + use LegacyPreservationPredicate as Predicate; + + match (legacy, candidate) { + (Legacy::Bool, Guard::Bool) => Preserve(Vec::new()), + (Legacy::Bool, Guard::Integer { .. } | Guard::Real | Guard::KnownEnum { .. }) => Disjoint, + (Legacy::Integer { .. }, Guard::Bool) => Preserve(Vec::new()), + ( + Legacy::Integer { minimum, maximum }, + Guard::Integer { + minimum: candidate_minimum, + maximum: candidate_maximum, + }, + ) if candidate_minimum >= minimum && candidate_maximum <= maximum => Preserve(Vec::new()), + (Legacy::Integer { minimum, maximum }, Guard::Integer { .. }) => { + Preserve(vec![Predicate::ExactIntRangeOrSubclass { + index, + minimum: *minimum, + maximum: *maximum, + }]) + } + (Legacy::Integer { minimum, maximum }, Guard::Real) => { + Preserve(vec![Predicate::ExactRealIntRangeOrSubclass { + index, + minimum: *minimum, + maximum: *maximum, + }]) + } + ( + Legacy::Integer { minimum, maximum }, + Guard::KnownEnum { + minimum: candidate_minimum, + maximum: candidate_maximum, + }, + ) if candidate_minimum >= minimum && candidate_maximum <= maximum => Preserve(Vec::new()), + (Legacy::Integer { minimum, maximum }, Guard::KnownEnum { .. }) => { + Preserve(vec![Predicate::NumericRange { + index, + minimum: *minimum, + maximum: *maximum, + }]) + } + ( + Legacy::Integer { .. }, + Guard::Char16 + | Guard::String + | Guard::Guid + | Guard::DateTime + | Guard::TimeSpan + | Guard::Struct(_), + ) => Disjoint, + (Legacy::Real, Guard::Bool | Guard::Integer { .. } | Guard::KnownEnum { .. }) => { + Preserve(Vec::new()) + } + (Legacy::Real, Guard::Real) => Unknown, + ( + Legacy::Real, + Guard::Char16 + | Guard::String + | Guard::Guid + | Guard::DateTime + | Guard::TimeSpan + | Guard::Struct(_), + ) => Disjoint, + (Legacy::EnumInteger { minimum, maximum }, Guard::Bool) => { + if *minimum <= 0 && *maximum >= 1 { + Preserve(Vec::new()) + } else { + Preserve(vec![Predicate::NumericRange { + index, + minimum: *minimum, + maximum: *maximum, + }]) + } + } + ( + Legacy::EnumInteger { minimum, maximum }, + Guard::Integer { + minimum: candidate_minimum, + maximum: candidate_maximum, + }, + ) if candidate_minimum >= minimum && candidate_maximum <= maximum => Preserve(Vec::new()), + (Legacy::EnumInteger { minimum, maximum }, Guard::Integer { .. }) => { + Preserve(vec![Predicate::ExactIntRangeOrSubclass { + index, + minimum: *minimum, + maximum: *maximum, + }]) + } + (Legacy::EnumInteger { minimum, maximum }, Guard::Real) => { + Preserve(vec![Predicate::ExactIntConversionRangeOrSubclass { + index, + exact_types: "(int, float)", + minimum: *minimum, + maximum: *maximum, + }]) + } + (Legacy::EnumInteger { minimum, maximum }, Guard::String | Guard::Char16) => { + Preserve(vec![Predicate::ExactIntConversionRangeOrSubclass { + index, + exact_types: "(str,)", + minimum: *minimum, + maximum: *maximum, + }]) + } + ( + Legacy::EnumInteger { minimum, maximum }, + Guard::KnownEnum { + minimum: candidate_minimum, + maximum: candidate_maximum, + }, + ) if candidate_minimum >= minimum && candidate_maximum <= maximum => Preserve(Vec::new()), + (Legacy::EnumInteger { minimum, maximum }, Guard::KnownEnum { .. }) => { + Preserve(vec![Predicate::NumericRange { + index, + minimum: *minimum, + maximum: *maximum, + }]) + } + ( + Legacy::EnumInteger { .. }, + Guard::Guid | Guard::DateTime | Guard::TimeSpan | Guard::Struct(_), + ) => Disjoint, + (Legacy::Char16, Guard::Char16) => Preserve(Vec::new()), + (Legacy::Char16, Guard::String) => { + Preserve(vec![Predicate::Char16OrStringSubclass { index }]) + } + ( + Legacy::Char16, + Guard::Bool + | Guard::Integer { .. } + | Guard::Real + | Guard::KnownEnum { .. } + | Guard::Guid + | Guard::DateTime + | Guard::TimeSpan + | Guard::Struct(_), + ) => Disjoint, + (Legacy::String, Guard::String | Guard::Char16) => Preserve(Vec::new()), + ( + Legacy::String, + Guard::Bool + | Guard::Integer { .. } + | Guard::Real + | Guard::KnownEnum { .. } + | Guard::Guid + | Guard::DateTime + | Guard::TimeSpan + | Guard::Struct(_), + ) => Disjoint, + (Legacy::Guid, Guard::Guid) => Preserve(Vec::new()), + (Legacy::DateTime, Guard::DateTime) => Preserve(Vec::new()), + (Legacy::TimeSpan, Guard::TimeSpan) => Preserve(Vec::new()), + (Legacy::Struct(left), Guard::Struct(right)) if left == right => Preserve(Vec::new()), + ( + Legacy::Guid | Legacy::DateTime | Legacy::TimeSpan | Legacy::Struct(_), + Guard::Bool + | Guard::Integer { .. } + | Guard::Real + | Guard::KnownEnum { .. } + | Guard::Char16 + | Guard::String + | Guard::Guid + | Guard::DateTime + | Guard::TimeSpan + | Guard::Struct(_), + ) => Disjoint, + (Legacy::QueryInterface(left), Guard::QueryInterface(right)) if left == right => { + Preserve(Vec::new()) + } + (Legacy::QueryInterface(iid), Guard::QueryInterface(_) | Guard::DynWinRTValue) => { + Preserve(vec![Predicate::CanCast { + index, + iid: iid.clone(), + }]) + } + (Legacy::DynWinRTValue, Guard::DynWinRTValue | Guard::QueryInterface(_)) => { + Preserve(vec![Predicate::DynWinRTValue { index }]) + } + (Legacy::Delegate, Guard::Delegate) => Unknown, + ( + Legacy::QueryInterface(_) + | Legacy::DynWinRTValue + | Legacy::Delegate + | Legacy::Collection, + Guard::Bool | Guard::KnownEnum { .. }, + ) => Disjoint, + ( + Legacy::Bool + | Legacy::Integer { .. } + | Legacy::Real + | Legacy::EnumInteger { .. } + | Legacy::Char16 + | Legacy::String + | Legacy::Guid + | Legacy::DateTime + | Legacy::TimeSpan + | Legacy::Struct(_), + Guard::QueryInterface(_) | Guard::DynWinRTValue | Guard::Delegate | Guard::Collection, + ) => Unknown, + ( + Legacy::QueryInterface(_) + | Legacy::DynWinRTValue + | Legacy::Delegate + | Legacy::Collection, + Guard::Integer { .. } + | Guard::Real + | Guard::Char16 + | Guard::String + | Guard::Guid + | Guard::DateTime + | Guard::TimeSpan + | Guard::Struct(_), + ) => Unknown, + _ => Unknown, + } +} + +pub(crate) fn legacy_guard_overlap( + legacy: &MethodMeta, + candidate: &MethodMeta, + permissive: bool, + context: &PythonProjectionContext, +) -> LegacyGuardOverlap { + let legacy_params = get_in_params(legacy); + let candidate_params = get_in_params(candidate); + if legacy_params.len() != candidate_params.len() { + return LegacyGuardOverlap::Disjoint; + } + let mut predicates = Vec::new(); + for (index, (legacy, candidate)) in legacy_params.iter().zip(candidate_params).enumerate() { + if legacy.typ == candidate.typ { + continue; + } + match overlap_parameter( + &legacy_conversion_domain(&legacy.typ, context), + &candidate_guard_domain(&candidate.typ, permissive, context), + index, + ) { + LegacyGuardOverlap::Disjoint => return LegacyGuardOverlap::Disjoint, + LegacyGuardOverlap::Unknown => return LegacyGuardOverlap::Unknown, + LegacyGuardOverlap::Preserve(mut parameter) => predicates.append(&mut parameter), + } + } + LegacyGuardOverlap::Preserve(predicates) +} + /// Return a stable overload-dispatch sort key for a projected Python argument type. /// /// Python overload dispatch is branch-ordered, so same-arity branches need a @@ -681,6 +1145,7 @@ pub(crate) fn py_interface_cast_guard( if !matches!(typ, TypeMeta::Interface { .. }) || !context.is_known_type(typ) { return None; } + let (iid, _) = py_interface_iid_const(typ)?; Some(format!( "({} or _dynwinrt_can_cast({name}, {iid}))", @@ -688,6 +1153,10 @@ pub(crate) fn py_interface_cast_guard( )) } +pub(crate) fn py_has_permissive_guard(typ: &TypeMeta, context: &PythonProjectionContext) -> bool { + !context.is_delegate_type(typ) && py_interface_cast_guard("value", typ, context).is_some() +} + /// Convert a Python return expression, given the raw `.call()` result expression. pub(crate) fn py_convert_return( expr: &str, diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs index 06bfeada..cba91836 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/stubs.rs @@ -582,7 +582,7 @@ pub fn generate_interface_stub(context: &PythonProjectionContext, iface: &Interf )); } - let plan = interface_member_plan(iface); + let plan = interface_member_plan(iface, context); let members = reorder_getters_before_setters(&iface.methods) .into_iter() .map(|method| (iface, method)); @@ -1111,7 +1111,7 @@ pub fn generate_class_stub<'a>( out.push_str( " def as_interface(self, interface_class: _DynWinRTProjector[_InterfaceT]) -> _InterfaceT: ...\n", ); - let iface_plan = interface_member_plan(req_iface); + let iface_plan = interface_member_plan(req_iface, context); let members = reorder_getters_before_setters(&req_iface.methods) .into_iter() .map(|method| (req_iface, method)); diff --git a/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs b/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs index 2a97d863..b57dc5ea 100644 --- a/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs +++ b/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs @@ -364,6 +364,44 @@ fn metadata(path: &Path) { "BoolArityProbe", &["IIntBoolArityLegacy", "IBoolArityCanonical"], ); + interface( + &mut file, + "IIntPairLegacy", + 0x5193190e, + "Pick", + "Pick", + &[("first", Type::I32), ("second", Type::I32)], + ); + interface( + &mut file, + "IBoolWideCanonical", + 0x5193190f, + "Pick", + "PickBool", + &[("first", Type::Bool), ("second", Type::I64)], + ); + runtime_class( + &mut file, + "NumericDomainProbe", + &["IIntPairLegacy", "IBoolWideCanonical"], + ); + interface( + &mut file, + "IQiLegacy", + 0x51931910, + "Use", + "Use", + &[("target", Type::named(NAMESPACE, "IAliasCanonical"))], + ); + interface( + &mut file, + "IQiCanonical", + 0x51931911, + "Use", + "Apply", + &[("target", Type::named(NAMESPACE, "IAliasLegacyString"))], + ); + runtime_class(&mut file, "QiDispatchProbe", &["IQiLegacy", "IQiCanonical"]); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, file.into_stream()).unwrap(); @@ -400,7 +438,7 @@ fn old_dispatchers_and_guard_free_conversions_keep_their_exact_targets() { fs::read_to_string(package.join("tests__overload_compatibility__enum_probe.py")).unwrap(); assert!( enum_source.contains( - "not (_legacy_bound is not None and _dynwinrt_legacy_int_guard(_legacy_bound[0]))" + "not (_legacy_bound is not None and (type(_legacy_bound[0]) not in (str,) or _dynwinrt_legacy_int_guard(_legacy_bound[0], -2147483648, 2147483647)))" ) && enum_source.contains( "return _dynwinrt_legacy_call(self._foo_6_1, ('value',), args, kwargs, 'foo')" ), @@ -416,11 +454,11 @@ fn old_dispatchers_and_guard_free_conversions_keep_their_exact_targets() { let bool_source = fs::read_to_string(package.join("tests__overload_compatibility__bool_probe.py")).unwrap(); assert!( - bool_source.contains( - "isinstance(_bound[0], bool) and not (_legacy_bound is not None and isinstance(_legacy_bound[0], bool))" - ) && bool_source.contains( - "return _dynwinrt_legacy_call(self._qux_6_1, ('value',), args, kwargs, 'qux')" - ) && !bool_source.contains("_dynwinrt_legacy_int_guard"), + bool_source.contains("not (_legacy_bound is not None) and isinstance(_bound[0], bool)") + && bool_source.contains( + "return _dynwinrt_legacy_call(self._qux_6_1, ('value',), args, kwargs, 'qux')" + ) + && !bool_source.contains("_dynwinrt_legacy_int_guard"), "{bool_source}" ); for name in ["bool_pair_probe", "bool_arity_probe"] { @@ -433,6 +471,27 @@ fn old_dispatchers_and_guard_free_conversions_keep_their_exact_targets() { "{source}" ); } + let numeric_domain_source = + fs::read_to_string(package.join("tests__overload_compatibility__numeric_domain_probe.py")) + .unwrap(); + assert!( + numeric_domain_source.contains( + "type(_legacy_bound[1]) is not int or -2147483648 <= _legacy_bound[1] <= 2147483647" + ) && numeric_domain_source.contains( + "return _dynwinrt_legacy_call(self._pick_6_1, ('first', 'second',), args, kwargs, 'pick')" + ), + "{numeric_domain_source}" + ); + let qi_source = + fs::read_to_string(package.join("tests__overload_compatibility__qi_dispatch_probe.py")) + .unwrap(); + assert_eq!( + qi_source + .matches("_dynwinrt_can_cast(_legacy_bound[0], IID_ARG_Tests_OverloadCompatibility_IAliasCanonical)") + .count(), + 2, + "{qi_source}" + ); let available = Command::new(python()) .args([ @@ -460,7 +519,9 @@ from pyviews.tests__overload_compatibility__bool_arity_probe import BoolArityPro from pyviews.tests__overload_compatibility__bool_pair_probe import BoolPairProbe from pyviews.tests__overload_compatibility__bool_probe import BoolProbe from pyviews.tests__overload_compatibility__enum_probe import EnumProbe +from pyviews.tests__overload_compatibility__numeric_domain_probe import NumericDomainProbe from pyviews.tests__overload_compatibility__pair_probe import PairProbe +from pyviews.tests__overload_compatibility__qi_dispatch_probe import QiDispatchProbe from pyviews.tests__overload_compatibility__i_alias_canonical import IAliasCanonical from pyviews.tests__overload_compatibility__i_alias_legacy_int import IAliasLegacyInt from pyviews.tests__overload_compatibility__i_alias_legacy_string import IAliasLegacyString @@ -472,10 +533,15 @@ from pyviews.tests__overload_compatibility__i_enum_pair_legacy import IEnumPairL from pyviews.tests__overload_compatibility__i_int_bool_arity_legacy import IIntBoolArityLegacy from pyviews.tests__overload_compatibility__i_int_bool_legacy import IIntBoolLegacy from pyviews.tests__overload_compatibility__i_int_bool_pair_legacy import IIntBoolPairLegacy +from pyviews.tests__overload_compatibility__i_int_pair_legacy import IIntPairLegacy +from pyviews.tests__overload_compatibility__i_qi_canonical import IQiCanonical +from pyviews.tests__overload_compatibility__i_qi_legacy import IQiLegacy from pyviews.tests__overload_compatibility__i_string_canonical import IStringCanonical from pyviews.tests__overload_compatibility__i_string_pair_canonical import IStringPairCanonical +from pyviews.tests__overload_compatibility__i_bool_wide_canonical import IBoolWideCanonical calls = [] +conversion_events = [] runtime = importlib.import_module("pyviews._runtime") class AliasCanonical: @@ -503,6 +569,11 @@ class StringCanonical: calls.append(("string-canonical", value)) return 401 +class NumericString(str): + def __int__(self): + conversion_events.append(("numeric-string-int", str(self))) + return int(str(self)) + class EnumPairLegacy: def bar(self, mode, label): calls.append(("enum-pair-legacy", int(mode), label)) @@ -543,6 +614,26 @@ class BoolArityCanonical: calls.append(("bool-arity-canonical", value, enabled)) return 902 +class IntPairLegacy: + def pick(self, first, second): + calls.append(("int-pair-legacy", first, second)) + return 1001 + +class BoolWideCanonical: + def pick_bool(self, first, second): + calls.append(("bool-wide-canonical", first, second)) + return 1002 + +class QiLegacy: + def use(self, target): + calls.append(("qi-legacy", target.__class__.__name__)) + return 1101 + +class QiCanonical: + def apply(self, target): + calls.append(("qi-canonical", target.__class__.__name__)) + return 1102 + results = {} class UnexpectedIntError: def __int__(self): @@ -577,6 +668,8 @@ with dw.RoApartment(1): results["enum_positional"] = value.foo("1") results["enum_keyword"] = value.foo(value="1") results["text_positional"] = value.foo("not numeric") + results["enum_string_subclass"] = value.foo(NumericString("9")) + results["enum_string_conversion_count"] = len(conversion_events) finally: dw.release_projected(value) @@ -624,6 +717,40 @@ with dw.RoApartment(1): finally: dw.release_projected(value) + with IIntPairLegacy.implement( + IntPairLegacy(), + interfaces=[(IBoolWideCanonical, BoolWideCanonical())], + ) as implementation: + value = NumericDomainProbe._from_native(implementation.value._obj) + try: + results["numeric_pair_positional"] = value.pick(True, 5) + results["numeric_pair_keyword"] = value.pick(first=False, second=5) + results["numeric_pair_i32_max"] = value.pick(True, 2**31 - 1) + results["numeric_pair_i32_min"] = value.pick(False, -(2**31)) + results["numeric_pair_wide_high"] = value.pick(True, 2**31) + results["numeric_pair_wide_low"] = value.pick(False, -(2**31) - 1) + finally: + dw.release_projected(value) + + with IAliasCanonical.implement( + AliasCanonical(), + interfaces=[(IAliasLegacyString, AliasLegacyString())], + ) as target_implementation: + target = AliasProbe._from_native(target_implementation.value._obj) + try: + with IQiLegacy.implement( + QiLegacy(), + interfaces=[(IQiCanonical, QiCanonical())], + ) as implementation: + value = QiDispatchProbe._from_native(implementation.value._obj) + try: + results["qi_positional"] = value.use(target) + results["qi_keyword"] = value.use(target=target) + finally: + dw.release_projected(value) + finally: + dw.release_projected(target) + print(json.dumps({"results": results, "calls": calls})) "#; fs::write(fixture.0.join("probe.py"), probe).unwrap(); @@ -636,13 +763,13 @@ print(json.dumps({"results": results, "calls": calls})) let stdout = String::from_utf8(output.stdout).unwrap(); assert!( stdout.contains( - r#""results": {"unexpected_int_error": "unexpected-int-error", "alias_positional": 201, "alias_keyword": 201, "enum_positional": 301, "enum_keyword": 301, "text_positional": 401, "nonoverlap_positional": 601, "nonoverlap_keyword": 601, "bool_positional": 701, "bool_keyword": 701, "bool_pair_positional": 802, "bool_pair_keyword": 802, "bool_arity_positional": 902, "bool_arity_keyword": 902}"# + r#""results": {"unexpected_int_error": "unexpected-int-error", "alias_positional": 201, "alias_keyword": 201, "enum_positional": 301, "enum_keyword": 301, "text_positional": 401, "enum_string_subclass": 301, "enum_string_conversion_count": 1, "nonoverlap_positional": 601, "nonoverlap_keyword": 601, "bool_positional": 701, "bool_keyword": 701, "bool_pair_positional": 802, "bool_pair_keyword": 802, "bool_arity_positional": 902, "bool_arity_keyword": 902, "numeric_pair_positional": 1001, "numeric_pair_keyword": 1001, "numeric_pair_i32_max": 1001, "numeric_pair_i32_min": 1001, "numeric_pair_wide_high": 1002, "numeric_pair_wide_low": 1002, "qi_positional": 1101, "qi_keyword": 1101}"# ), "{stdout}" ); assert!( stdout.contains( - r#""calls": [["alias-legacy-string", "7"], ["alias-legacy-string", "8"], ["enum-legacy", 1], ["enum-legacy", 1], ["string-canonical", "not numeric"], ["string-pair-canonical", "1", true], ["string-pair-canonical", "1", false], ["int-bool-legacy", 1], ["int-bool-legacy", 0], ["bool-pair-canonical", true, false], ["bool-pair-canonical", false, true], ["bool-arity-canonical", true, false], ["bool-arity-canonical", false, true]]"# + r#""calls": [["alias-legacy-string", "7"], ["alias-legacy-string", "8"], ["enum-legacy", 1], ["enum-legacy", 1], ["string-canonical", "not numeric"], ["enum-legacy", 9], ["string-pair-canonical", "1", true], ["string-pair-canonical", "1", false], ["int-bool-legacy", 1], ["int-bool-legacy", 0], ["bool-pair-canonical", true, false], ["bool-pair-canonical", false, true], ["bool-arity-canonical", true, false], ["bool-arity-canonical", false, true], ["int-pair-legacy", 1, 5], ["int-pair-legacy", 0, 5], ["int-pair-legacy", 1, 2147483647], ["int-pair-legacy", 0, -2147483648], ["bool-wide-canonical", true, 2147483648], ["bool-wide-canonical", false, -2147483649], ["qi-legacy", "IAliasCanonical"], ["qi-legacy", "IAliasCanonical"]]"# ), "{stdout}" ); From cfe8153f9749870a5fd80126d4d40044997676d9 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 28 Sep 2026 17:33:32 +0800 Subject: [PATCH 13/15] Plan overload conversion compatibility by domain Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/codegen/winrt/python/member_plan.rs | 75 +++++-- .../src/codegen/winrt/python/method.rs | 21 +- .../src/codegen/winrt/python/signature.rs | 185 ++++++++++++------ .../python_overload_legacy_compat_test.rs | 134 ++++++++++++- 4 files changed, 320 insertions(+), 95 deletions(-) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs index dce1be61..d5884323 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/member_plan.rs @@ -1726,16 +1726,11 @@ mod tests { ], &[], ); - assert_eq!(planned.group("ShowKind", 9), "show"); - assert_eq!(planned.group("ShowText", 8), "show"); - assert_eq!( - planned.aliases, - aliases(&[("show_kind", "_show_9"), ("show_text", "_show_8")]) - ); - assert_eq!( - planned.legacy_fallbacks["show"], - ("Show".to_string(), 6, "_show_6".to_string()) - ); + assert_eq!(planned.group("ShowKind", 9), "show_kind"); + assert_eq!(planned.group("ShowText", 8), "show_text"); + assert!(planned.aliases.is_empty()); + assert_eq!(planned.fallbacks, ["show"]); + assert!(planned.legacy_fallbacks.is_empty()); let planned = plan_scope( vec![ @@ -1959,13 +1954,11 @@ mod tests { ); assert_eq!( legacy_guard_overlap(&old_integer_pair, &bool_wide, false, &context), - LegacyGuardOverlap::Preserve(vec![ - LegacyPreservationPredicate::ExactIntRangeOrSubclass { - index: 1, - minimum: i32::MIN as i128, - maximum: i32::MAX as i128, - } - ]) + LegacyGuardOverlap::Preserve(vec![LegacyPreservationPredicate::IntBaseRange { + index: 1, + minimum: i32::MIN as i128, + maximum: i32::MAX as i128, + }]) ); } @@ -1984,6 +1977,7 @@ mod tests { let context = PythonProjectionContext::standalone([ old_type.type_identity(), new_type.type_identity(), + enumeration("Mode").type_identity(), ]) .unwrap(); let old = overload("Use", "Use", 6, &[("value", old_type)]); @@ -2001,6 +1995,11 @@ mod tests { legacy_guard_overlap(&old, &candidate, true, &context), expected ); + let enum_candidate = overload("UseKind", "Use", 8, &[("value", enumeration("Mode"))]); + assert_eq!( + legacy_guard_overlap(&old, &enum_candidate, false, &context), + expected + ); } #[test] @@ -2111,6 +2110,8 @@ mod tests { let mut runtime_count = 0; let mut all_plan_sites = 0; let mut bool_shadows = 0; + let mut compatibility_fallbacks = BTreeSet::new(); + let mut interface_fallbacks = BTreeSet::new(); for namespace in namespaces { for class in meta::parse_namespace(WINMD, &namespace) { let statics = class @@ -2120,6 +2121,13 @@ mod tests { .collect::>(); let instance = class_instance_interfaces(&class).collect::>(); let plan = ClassMemberPlan::new(&class, &context); + compatibility_fallbacks.extend( + plan.statics + .fallbacks() + .iter() + .chain(plan.instance.fallbacks()) + .map(|name| format!("{}.{}.{name}", class.namespace, class.name)), + ); let static_count = assert_scope(&statics, &plan.statics); let instance_count = assert_scope(&instance, &plan.instance); @@ -2128,16 +2136,27 @@ mod tests { all_plan_sites += count; bool_shadows += static_count.1 + instance_count.1; for interface in &class.required_interfaces { - let count = - assert_scope(&[interface], &interface_member_plan(interface, &context)); + let interface_plan = interface_member_plan(interface, &context); + interface_fallbacks.extend(interface_plan.fallbacks().iter().map(|name| { + format!( + "{}.{}[{}].{name}", + class.namespace, class.name, interface.name + ) + })); + let count = assert_scope(&[interface], &interface_plan); all_plan_sites += count.0; bool_shadows += count.1; } } for interface in meta::parse_interfaces(WINMD, &namespace) { if !interface.is_delegate() { - let count = - assert_scope(&[&interface], &interface_member_plan(&interface, &context)); + let interface_plan = interface_member_plan(&interface, &context); + interface_fallbacks.extend( + interface_plan.fallbacks().iter().map(|name| { + format!("{}.{}.{name}", interface.namespace, interface.name) + }), + ); + let count = assert_scope(&[&interface], &interface_plan); all_plan_sites += count.0; bool_shadows += count.1; } @@ -2147,5 +2166,19 @@ mod tests { assert_eq!(runtime_count, 766); assert_eq!(all_plan_sites, 897); assert_eq!(bool_shadows, 0); + assert!(interface_fallbacks.is_empty()); + assert_eq!( + compatibility_fallbacks, + [ + "Windows.Networking.Sockets.MessageWebSocket.close", + "Windows.Networking.Sockets.ServerMessageWebSocket.close", + "Windows.Networking.Sockets.ServerStreamWebSocket.close", + "Windows.Networking.Sockets.StreamWebSocket.close", + "Windows.UI.Notifications.TileUpdateManagerForUser.create_tile_updater_for_application", + ] + .into_iter() + .map(str::to_string) + .collect() + ); } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs index ffa04a5f..dfeac6e2 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/method.rs @@ -248,19 +248,19 @@ pub(crate) fn emit_dispatch( let preservation_expression = |predicates: &[LegacyPreservationPredicate]| { std::iter::once("_legacy_bound is not None".to_string()) .chain(predicates.iter().map(|predicate| match predicate { - LegacyPreservationPredicate::ExactIntRangeOrSubclass { + LegacyPreservationPredicate::IntBaseRange { index, minimum, maximum, } => format!( - "(type(_legacy_bound[{index}]) is not int or {minimum} <= _legacy_bound[{index}] <= {maximum})" + "{minimum} <= int.__index__(_legacy_bound[{index}]) <= {maximum}" ), - LegacyPreservationPredicate::ExactRealIntRangeOrSubclass { + LegacyPreservationPredicate::RealToIntRange { index, minimum, maximum, } => format!( - "(type(_legacy_bound[{index}]) not in (int, float) or (type(_legacy_bound[{index}]) is int and {minimum} <= _legacy_bound[{index}] <= {maximum}))" + "({minimum} <= int.__index__(_legacy_bound[{index}]) <= {maximum} if isinstance(_legacy_bound[{index}], int) else type(_legacy_bound[{index}]) is not float)" ), LegacyPreservationPredicate::ExactIntConversionRangeOrSubclass { index, @@ -270,11 +270,9 @@ pub(crate) fn emit_dispatch( } => format!( "(type(_legacy_bound[{index}]) not in {exact_types} or _dynwinrt_legacy_int_guard(_legacy_bound[{index}], {minimum}, {maximum}))" ), - LegacyPreservationPredicate::NumericRange { - index, - minimum, - maximum, - } => format!("{minimum} <= _legacy_bound[{index}] <= {maximum}"), + LegacyPreservationPredicate::BuiltinSubclass { index, exact_types } => { + format!("type(_legacy_bound[{index}]) not in {exact_types}") + } LegacyPreservationPredicate::Char16OrStringSubclass { index } => format!( "(type(_legacy_bound[{index}]) is not str or (len(_legacy_bound[{index}]) == 1 and ord(_legacy_bound[{index}]) <= 65535))" ), @@ -286,6 +284,11 @@ pub(crate) fn emit_dispatch( "isinstance(getattr(_legacy_bound[{index}], '_obj', _legacy_bound[{index}]), DynWinRTValue)" ) } + LegacyPreservationPredicate::CallableOrDynWinRTValue { index } => { + format!( + "(callable(_legacy_bound[{index}]) or isinstance(getattr(_legacy_bound[{index}], '_obj', _legacy_bound[{index}]), DynWinRTValue))" + ) + } })) .collect::>() .join(" and ") diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs b/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs index f5fd3388..d08a5937 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/signature.rs @@ -82,14 +82,14 @@ pub(crate) enum LegacyPreservationPredicate { /// The candidate accepts builtin ints over a wider range. Exact ints must /// fit the old converter; subclasses stay on the old path without probing /// potentially visible conversion methods. - ExactIntRangeOrSubclass { + IntBaseRange { index: usize, minimum: i128, maximum: i128, }, /// The candidate accepts builtin ints and floats. Only exact builtin /// values are preflighted; subclasses conservatively stay on the old path. - ExactRealIntRangeOrSubclass { + RealToIntRange { index: usize, minimum: i128, maximum: i128, @@ -102,10 +102,9 @@ pub(crate) enum LegacyPreservationPredicate { minimum: i128, maximum: i128, }, - NumericRange { + BuiltinSubclass { index: usize, - minimum: i128, - maximum: i128, + exact_types: &'static str, }, Char16OrStringSubclass { index: usize, @@ -117,6 +116,9 @@ pub(crate) enum LegacyPreservationPredicate { DynWinRTValue { index: usize, }, + CallableOrDynWinRTValue { + index: usize, + }, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -309,14 +311,14 @@ fn overlap_parameter( }, ) if candidate_minimum >= minimum && candidate_maximum <= maximum => Preserve(Vec::new()), (Legacy::Integer { minimum, maximum }, Guard::Integer { .. }) => { - Preserve(vec![Predicate::ExactIntRangeOrSubclass { + Preserve(vec![Predicate::IntBaseRange { index, minimum: *minimum, maximum: *maximum, }]) } (Legacy::Integer { minimum, maximum }, Guard::Real) => { - Preserve(vec![Predicate::ExactRealIntRangeOrSubclass { + Preserve(vec![Predicate::RealToIntRange { index, minimum: *minimum, maximum: *maximum, @@ -330,39 +332,59 @@ fn overlap_parameter( }, ) if candidate_minimum >= minimum && candidate_maximum <= maximum => Preserve(Vec::new()), (Legacy::Integer { minimum, maximum }, Guard::KnownEnum { .. }) => { - Preserve(vec![Predicate::NumericRange { + Preserve(vec![Predicate::IntBaseRange { index, minimum: *minimum, maximum: *maximum, }]) } - ( - Legacy::Integer { .. }, - Guard::Char16 - | Guard::String - | Guard::Guid - | Guard::DateTime - | Guard::TimeSpan - | Guard::Struct(_), - ) => Disjoint, + (Legacy::Integer { .. }, Guard::Char16 | Guard::String) => { + Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(str,)", + }]) + } + (Legacy::Integer { .. }, Guard::Guid) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(UUID,)", + }]), + (Legacy::Integer { .. }, Guard::DateTime) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(datetime,)", + }]), + (Legacy::Integer { .. }, Guard::TimeSpan) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(timedelta,)", + }]), + (Legacy::Integer { .. }, Guard::Struct(_)) => Unknown, (Legacy::Real, Guard::Bool | Guard::Integer { .. } | Guard::KnownEnum { .. }) => { Preserve(Vec::new()) } (Legacy::Real, Guard::Real) => Unknown, - ( - Legacy::Real, - Guard::Char16 - | Guard::String - | Guard::Guid - | Guard::DateTime - | Guard::TimeSpan - | Guard::Struct(_), - ) => Disjoint, + (Legacy::Real, Guard::Char16 | Guard::String) => { + Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(str,)", + }]) + } + (Legacy::Real, Guard::Guid) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(UUID,)", + }]), + (Legacy::Real, Guard::DateTime) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(datetime,)", + }]), + (Legacy::Real, Guard::TimeSpan) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(timedelta,)", + }]), + (Legacy::Real, Guard::Struct(_)) => Unknown, (Legacy::EnumInteger { minimum, maximum }, Guard::Bool) => { if *minimum <= 0 && *maximum >= 1 { Preserve(Vec::new()) } else { - Preserve(vec![Predicate::NumericRange { + Preserve(vec![Predicate::IntBaseRange { index, minimum: *minimum, maximum: *maximum, @@ -377,7 +399,7 @@ fn overlap_parameter( }, ) if candidate_minimum >= minimum && candidate_maximum <= maximum => Preserve(Vec::new()), (Legacy::EnumInteger { minimum, maximum }, Guard::Integer { .. }) => { - Preserve(vec![Predicate::ExactIntRangeOrSubclass { + Preserve(vec![Predicate::IntBaseRange { index, minimum: *minimum, maximum: *maximum, @@ -407,16 +429,29 @@ fn overlap_parameter( }, ) if candidate_minimum >= minimum && candidate_maximum <= maximum => Preserve(Vec::new()), (Legacy::EnumInteger { minimum, maximum }, Guard::KnownEnum { .. }) => { - Preserve(vec![Predicate::NumericRange { + Preserve(vec![Predicate::IntBaseRange { index, minimum: *minimum, maximum: *maximum, }]) } - ( - Legacy::EnumInteger { .. }, - Guard::Guid | Guard::DateTime | Guard::TimeSpan | Guard::Struct(_), - ) => Disjoint, + (Legacy::EnumInteger { .. }, Guard::Guid) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(UUID,)", + }]), + (Legacy::EnumInteger { .. }, Guard::DateTime) => { + Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(datetime,)", + }]) + } + (Legacy::EnumInteger { .. }, Guard::TimeSpan) => { + Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(timedelta,)", + }]) + } + (Legacy::EnumInteger { .. }, Guard::Struct(_)) => Unknown, (Legacy::Char16, Guard::Char16) => Preserve(Vec::new()), (Legacy::Char16, Guard::String) => { Preserve(vec![Predicate::Char16OrStringSubclass { index }]) @@ -429,9 +464,9 @@ fn overlap_parameter( | Guard::KnownEnum { .. } | Guard::Guid | Guard::DateTime - | Guard::TimeSpan - | Guard::Struct(_), + | Guard::TimeSpan, ) => Disjoint, + (Legacy::Char16, Guard::Struct(_)) => Unknown, (Legacy::String, Guard::String | Guard::Char16) => Preserve(Vec::new()), ( Legacy::String, @@ -441,15 +476,43 @@ fn overlap_parameter( | Guard::KnownEnum { .. } | Guard::Guid | Guard::DateTime - | Guard::TimeSpan - | Guard::Struct(_), + | Guard::TimeSpan, ) => Disjoint, + (Legacy::String, Guard::Struct(_)) => Unknown, (Legacy::Guid, Guard::Guid) => Preserve(Vec::new()), (Legacy::DateTime, Guard::DateTime) => Preserve(Vec::new()), (Legacy::TimeSpan, Guard::TimeSpan) => Preserve(Vec::new()), (Legacy::Struct(left), Guard::Struct(right)) if left == right => Preserve(Vec::new()), + (Legacy::Struct(_), Guard::Bool) => Disjoint, + (Legacy::Struct(_), Guard::Integer { .. }) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(int,)", + }]), + (Legacy::Struct(_), Guard::Real) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(int, float)", + }]), + (Legacy::Struct(_), Guard::Char16 | Guard::String) => { + Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(str,)", + }]) + } + (Legacy::Struct(_), Guard::Guid) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(UUID,)", + }]), + (Legacy::Struct(_), Guard::DateTime) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(datetime,)", + }]), + (Legacy::Struct(_), Guard::TimeSpan) => Preserve(vec![Predicate::BuiltinSubclass { + index, + exact_types: "(timedelta,)", + }]), + (Legacy::Struct(_), _) | (_, Guard::Struct(_)) => Unknown, ( - Legacy::Guid | Legacy::DateTime | Legacy::TimeSpan | Legacy::Struct(_), + Legacy::Guid | Legacy::DateTime | Legacy::TimeSpan, Guard::Bool | Guard::Integer { .. } | Guard::Real @@ -458,29 +521,31 @@ fn overlap_parameter( | Guard::String | Guard::Guid | Guard::DateTime - | Guard::TimeSpan - | Guard::Struct(_), + | Guard::TimeSpan, ) => Disjoint, (Legacy::QueryInterface(left), Guard::QueryInterface(right)) if left == right => { Preserve(Vec::new()) } - (Legacy::QueryInterface(iid), Guard::QueryInterface(_) | Guard::DynWinRTValue) => { - Preserve(vec![Predicate::CanCast { - index, - iid: iid.clone(), - }]) - } + ( + Legacy::QueryInterface(iid), + Guard::QueryInterface(_) | Guard::DynWinRTValue | Guard::KnownEnum { .. }, + ) => Preserve(vec![Predicate::CanCast { + index, + iid: iid.clone(), + }]), + (Legacy::QueryInterface(_), Guard::Bool) => Disjoint, + (Legacy::QueryInterface(_), _) => Unknown, (Legacy::DynWinRTValue, Guard::DynWinRTValue | Guard::QueryInterface(_)) => { Preserve(vec![Predicate::DynWinRTValue { index }]) } - (Legacy::Delegate, Guard::Delegate) => Unknown, - ( - Legacy::QueryInterface(_) - | Legacy::DynWinRTValue - | Legacy::Delegate - | Legacy::Collection, - Guard::Bool | Guard::KnownEnum { .. }, - ) => Disjoint, + (Legacy::DynWinRTValue, Guard::Bool) => Disjoint, + (Legacy::DynWinRTValue, _) => Unknown, + (Legacy::Delegate, Guard::Delegate) => Preserve(Vec::new()), + (Legacy::Delegate, Guard::DynWinRTValue | Guard::QueryInterface(_)) => { + Preserve(vec![Predicate::CallableOrDynWinRTValue { index }]) + } + (Legacy::Delegate | Legacy::Collection, Guard::Bool) => Disjoint, + (Legacy::Delegate, _) => Unknown, ( Legacy::Bool | Legacy::Integer { .. } @@ -490,23 +555,19 @@ fn overlap_parameter( | Legacy::String | Legacy::Guid | Legacy::DateTime - | Legacy::TimeSpan - | Legacy::Struct(_), + | Legacy::TimeSpan, Guard::QueryInterface(_) | Guard::DynWinRTValue | Guard::Delegate | Guard::Collection, ) => Unknown, ( - Legacy::QueryInterface(_) - | Legacy::DynWinRTValue - | Legacy::Delegate - | Legacy::Collection, + Legacy::Collection, Guard::Integer { .. } | Guard::Real + | Guard::KnownEnum { .. } | Guard::Char16 | Guard::String | Guard::Guid | Guard::DateTime - | Guard::TimeSpan - | Guard::Struct(_), + | Guard::TimeSpan, ) => Unknown, _ => Unknown, } diff --git a/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs b/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs index b57dc5ea..a51053ec 100644 --- a/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs +++ b/tools/dynwinrt-codegen/tests/python_overload_legacy_compat_test.rs @@ -402,6 +402,48 @@ fn metadata(path: &Path) { &[("target", Type::named(NAMESPACE, "IAliasLegacyString"))], ); runtime_class(&mut file, "QiDispatchProbe", &["IQiLegacy", "IQiCanonical"]); + interface( + &mut file, + "IIndexLegacy", + 0x51931912, + "Sift", + "Sift", + &[("value", Type::I32)], + ); + interface( + &mut file, + "IIndexStringCanonical", + 0x51931913, + "Sift", + "SiftText", + &[("value", Type::String)], + ); + runtime_class( + &mut file, + "IndexStringProbe", + &["IIndexLegacy", "IIndexStringCanonical"], + ); + interface( + &mut file, + "IByteLegacy", + 0x51931914, + "Rank", + "Rank", + &[("value", Type::I8)], + ); + interface( + &mut file, + "IModeCanonical", + 0x51931915, + "Rank", + "RankMode", + &[("value", Type::named(NAMESPACE, "Mode"))], + ); + runtime_class( + &mut file, + "EnumComparisonProbe", + &["IByteLegacy", "IModeCanonical"], + ); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, file.into_stream()).unwrap(); @@ -476,7 +518,7 @@ fn old_dispatchers_and_guard_free_conversions_keep_their_exact_targets() { .unwrap(); assert!( numeric_domain_source.contains( - "type(_legacy_bound[1]) is not int or -2147483648 <= _legacy_bound[1] <= 2147483647" + "-2147483648 <= int.__index__(_legacy_bound[1]) <= 2147483647" ) && numeric_domain_source.contains( "return _dynwinrt_legacy_call(self._pick_6_1, ('first', 'second',), args, kwargs, 'pick')" ), @@ -492,6 +534,25 @@ fn old_dispatchers_and_guard_free_conversions_keep_their_exact_targets() { 2, "{qi_source}" ); + let index_string_source = + fs::read_to_string(package.join("tests__overload_compatibility__index_string_probe.py")) + .unwrap(); + assert!( + index_string_source + .contains("type(_legacy_bound[0]) not in (str,)) and isinstance(_bound[0], str)") + && index_string_source.contains( + "return _dynwinrt_legacy_call(self._sift_6_1, ('value',), args, kwargs, 'sift')" + ), + "{index_string_source}" + ); + let enum_comparison_source = + fs::read_to_string(package.join("tests__overload_compatibility__enum_comparison_probe.py")) + .unwrap(); + assert!( + enum_comparison_source.contains("-128 <= int.__index__(_legacy_bound[0]) <= 127") + && !enum_comparison_source.contains("-128 <= _legacy_bound[0] <= 127"), + "{enum_comparison_source}" + ); let available = Command::new(python()) .args([ @@ -519,6 +580,8 @@ from pyviews.tests__overload_compatibility__bool_arity_probe import BoolArityPro from pyviews.tests__overload_compatibility__bool_pair_probe import BoolPairProbe from pyviews.tests__overload_compatibility__bool_probe import BoolProbe from pyviews.tests__overload_compatibility__enum_probe import EnumProbe +from pyviews.tests__overload_compatibility__enum_comparison_probe import EnumComparisonProbe +from pyviews.tests__overload_compatibility__index_string_probe import IndexStringProbe from pyviews.tests__overload_compatibility__numeric_domain_probe import NumericDomainProbe from pyviews.tests__overload_compatibility__pair_probe import PairProbe from pyviews.tests__overload_compatibility__qi_dispatch_probe import QiDispatchProbe @@ -530,18 +593,24 @@ from pyviews.tests__overload_compatibility__i_bool_canonical import IBoolCanonic from pyviews.tests__overload_compatibility__i_bool_pair_canonical import IBoolPairCanonical from pyviews.tests__overload_compatibility__i_enum_legacy import IEnumLegacy from pyviews.tests__overload_compatibility__i_enum_pair_legacy import IEnumPairLegacy +from pyviews.tests__overload_compatibility__i_byte_legacy import IByteLegacy from pyviews.tests__overload_compatibility__i_int_bool_arity_legacy import IIntBoolArityLegacy from pyviews.tests__overload_compatibility__i_int_bool_legacy import IIntBoolLegacy from pyviews.tests__overload_compatibility__i_int_bool_pair_legacy import IIntBoolPairLegacy from pyviews.tests__overload_compatibility__i_int_pair_legacy import IIntPairLegacy +from pyviews.tests__overload_compatibility__i_index_legacy import IIndexLegacy +from pyviews.tests__overload_compatibility__i_index_string_canonical import IIndexStringCanonical from pyviews.tests__overload_compatibility__i_qi_canonical import IQiCanonical from pyviews.tests__overload_compatibility__i_qi_legacy import IQiLegacy +from pyviews.tests__overload_compatibility__i_mode_canonical import IModeCanonical from pyviews.tests__overload_compatibility__i_string_canonical import IStringCanonical from pyviews.tests__overload_compatibility__i_string_pair_canonical import IStringPairCanonical from pyviews.tests__overload_compatibility__i_bool_wide_canonical import IBoolWideCanonical +from pyviews.tests__overload_compatibility__mode import Mode calls = [] conversion_events = [] +index_events = [] runtime = importlib.import_module("pyviews._runtime") class AliasCanonical: @@ -574,6 +643,11 @@ class NumericString(str): conversion_events.append(("numeric-string-int", str(self))) return int(str(self)) +class IndexString(str): + def __index__(self): + index_events.append(("index-string-index", str(self))) + return 7 + class EnumPairLegacy: def bar(self, mode, label): calls.append(("enum-pair-legacy", int(mode), label)) @@ -634,6 +708,26 @@ class QiCanonical: calls.append(("qi-canonical", target.__class__.__name__)) return 1102 +class IndexLegacy: + def sift(self, value): + calls.append(("index-legacy", value)) + return 1201 + +class IndexStringCanonical: + def sift_text(self, value): + calls.append(("index-string-canonical", value)) + return 1202 + +class ByteLegacy: + def rank(self, value): + calls.append(("byte-legacy", value)) + return 1301 + +class ModeCanonical: + def rank_mode(self, value): + calls.append(("mode-canonical", int(value))) + return 1302 + results = {} class UnexpectedIntError: def __int__(self): @@ -751,6 +845,40 @@ with dw.RoApartment(1): finally: dw.release_projected(target) + with IIndexLegacy.implement( + IndexLegacy(), + interfaces=[(IIndexStringCanonical, IndexStringCanonical())], + ) as implementation: + value = IndexStringProbe._from_native(implementation.value._obj) + try: + results["index_string_positional"] = value.sift(IndexString("child")) + results["index_string_keyword"] = value.sift(value=IndexString("child")) + results["index_string_builtin"] = value.sift("child") + results["index_string_conversion_count"] = len(index_events) + finally: + dw.release_projected(value) + + comparison_events = [] + original_le = Mode.__le__ + Mode.__le__ = lambda self, other: ( + comparison_events.append(("unexpected-le", int(self))), + (_ for _ in ()).throw(RuntimeError("unexpected comparison")), + )[1] + try: + with IByteLegacy.implement( + ByteLegacy(), + interfaces=[(IModeCanonical, ModeCanonical())], + ) as implementation: + value = EnumComparisonProbe._from_native(implementation.value._obj) + try: + results["enum_comparison_positional"] = value.rank(Mode.One) + results["enum_comparison_keyword"] = value.rank(value=Mode.One) + results["enum_comparison_side_effects"] = len(comparison_events) + finally: + dw.release_projected(value) + finally: + Mode.__le__ = original_le + print(json.dumps({"results": results, "calls": calls})) "#; fs::write(fixture.0.join("probe.py"), probe).unwrap(); @@ -763,13 +891,13 @@ print(json.dumps({"results": results, "calls": calls})) let stdout = String::from_utf8(output.stdout).unwrap(); assert!( stdout.contains( - r#""results": {"unexpected_int_error": "unexpected-int-error", "alias_positional": 201, "alias_keyword": 201, "enum_positional": 301, "enum_keyword": 301, "text_positional": 401, "enum_string_subclass": 301, "enum_string_conversion_count": 1, "nonoverlap_positional": 601, "nonoverlap_keyword": 601, "bool_positional": 701, "bool_keyword": 701, "bool_pair_positional": 802, "bool_pair_keyword": 802, "bool_arity_positional": 902, "bool_arity_keyword": 902, "numeric_pair_positional": 1001, "numeric_pair_keyword": 1001, "numeric_pair_i32_max": 1001, "numeric_pair_i32_min": 1001, "numeric_pair_wide_high": 1002, "numeric_pair_wide_low": 1002, "qi_positional": 1101, "qi_keyword": 1101}"# + r#""results": {"unexpected_int_error": "unexpected-int-error", "alias_positional": 201, "alias_keyword": 201, "enum_positional": 301, "enum_keyword": 301, "text_positional": 401, "enum_string_subclass": 301, "enum_string_conversion_count": 1, "nonoverlap_positional": 601, "nonoverlap_keyword": 601, "bool_positional": 701, "bool_keyword": 701, "bool_pair_positional": 802, "bool_pair_keyword": 802, "bool_arity_positional": 902, "bool_arity_keyword": 902, "numeric_pair_positional": 1001, "numeric_pair_keyword": 1001, "numeric_pair_i32_max": 1001, "numeric_pair_i32_min": 1001, "numeric_pair_wide_high": 1002, "numeric_pair_wide_low": 1002, "qi_positional": 1101, "qi_keyword": 1101, "index_string_positional": 1201, "index_string_keyword": 1201, "index_string_builtin": 1202, "index_string_conversion_count": 2, "enum_comparison_positional": 1301, "enum_comparison_keyword": 1301, "enum_comparison_side_effects": 0}"# ), "{stdout}" ); assert!( stdout.contains( - r#""calls": [["alias-legacy-string", "7"], ["alias-legacy-string", "8"], ["enum-legacy", 1], ["enum-legacy", 1], ["string-canonical", "not numeric"], ["enum-legacy", 9], ["string-pair-canonical", "1", true], ["string-pair-canonical", "1", false], ["int-bool-legacy", 1], ["int-bool-legacy", 0], ["bool-pair-canonical", true, false], ["bool-pair-canonical", false, true], ["bool-arity-canonical", true, false], ["bool-arity-canonical", false, true], ["int-pair-legacy", 1, 5], ["int-pair-legacy", 0, 5], ["int-pair-legacy", 1, 2147483647], ["int-pair-legacy", 0, -2147483648], ["bool-wide-canonical", true, 2147483648], ["bool-wide-canonical", false, -2147483649], ["qi-legacy", "IAliasCanonical"], ["qi-legacy", "IAliasCanonical"]]"# + r#""calls": [["alias-legacy-string", "7"], ["alias-legacy-string", "8"], ["enum-legacy", 1], ["enum-legacy", 1], ["string-canonical", "not numeric"], ["enum-legacy", 9], ["string-pair-canonical", "1", true], ["string-pair-canonical", "1", false], ["int-bool-legacy", 1], ["int-bool-legacy", 0], ["bool-pair-canonical", true, false], ["bool-pair-canonical", false, true], ["bool-arity-canonical", true, false], ["bool-arity-canonical", false, true], ["int-pair-legacy", 1, 5], ["int-pair-legacy", 0, 5], ["int-pair-legacy", 1, 2147483647], ["int-pair-legacy", 0, -2147483648], ["bool-wide-canonical", true, 2147483648], ["bool-wide-canonical", false, -2147483649], ["qi-legacy", "IAliasCanonical"], ["qi-legacy", "IAliasCanonical"], ["index-legacy", 7], ["index-legacy", 7], ["index-string-canonical", "child"], ["byte-legacy", 1], ["byte-legacy", 1]]"# ), "{stdout}" ); From 9eed36516f47ffff3f7bd92bde4d0dededf069a0 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 28 Sep 2026 18:10:16 +0800 Subject: [PATCH 14/15] Preserve cast diagnostics across runtime versions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/e2e/runners/py_runner.py | 12 +++++++++++- .../src/codegen/winrt/python/generator/mod.rs | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index bd3a586d..cb9d5cd5 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -1590,17 +1590,27 @@ def __int__(self): runtime._dynwinrt_legacy_int_guard(UnexpectedIntError()) except RuntimeError as error: int_error = str(error) + released = dw.DynWinRTValue.from_i32(1) + released.release() + released_error = None + try: + runtime._dynwinrt_can_cast(released, iid) + except RuntimeError as error: + released_error = str(error) if ( results != [False, False, False, False] or legacy != (1, 2) or legacy_error != 'No matching overload for example' or int_guards != [True, False, True, False, False] or int_error != 'unexpected-int-error' + or released_error is None + or 'released' not in released_error ): cr['error'] = ( 'runtime dispatch helpers failed: ' f'casts={results!r}, legacy={legacy!r}, error={legacy_error!r}, ' - f'int_guards={int_guards!r}, int_error={int_error!r}' + f'int_guards={int_guards!r}, int_error={int_error!r}, ' + f'released_error={released_error!r}' ) else: cr['pass'] = True 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 bf2a9a3d..7e894e0f 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -136,12 +136,18 @@ def _dynwinrt_can_cast(value, iid): raw = getattr(value, '_obj', value) if not isinstance(raw, DynWinRTValue): return False + is_released = getattr(raw, 'is_released', None) + if is_released is not None and is_released(): + raw.as_raw() if raw.is_null(): return False try: raw.as_raw() except RuntimeError as error: - if str(error) == 'Cannot get raw pointer from non-object': + if ( + is_released is not None + or str(error) == 'Cannot get raw pointer from non-object' + ): return False raise try: @@ -321,6 +327,9 @@ mod tests { let runtime = generate_runtime_support_module(); assert!(runtime.contains("if raw.is_null():\n return False")); + assert!( + runtime.contains("if is_released is not None and is_released():\n raw.as_raw()") + ); assert!(runtime.contains("raw.as_raw()")); assert!(runtime.contains( "if str(error) == 'Cannot get raw pointer from non-object':\n return False" From 1f6a5f9b44e11ab1dcfcf6ae1d8cea7facdf9006 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 28 Sep 2026 18:39:52 +0800 Subject: [PATCH 15/15] Propagate unexpected interface cast failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/e2e/runners/py_runner.py | 53 ++++++++++++++++++- .../src/codegen/winrt/python/generator/mod.rs | 21 ++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index cb9d5cd5..cc9e1771 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -1597,6 +1597,51 @@ def __int__(self): runtime._dynwinrt_can_cast(released, iid) except RuntimeError as error: released_error = str(error) + unsupported_iid = dw.WinGUID.parse( + '11111111-1111-1111-1111-111111111111' + ) + qi_subject = dw.DynWinRTValue.activation_factory( + 'Windows.Foundation.Uri' + ) + unsupported_error = None + try: + unexpected_projection = qi_subject.cast(unsupported_iid) + except OSError as error: + unsupported_error = error.winerror + else: + unexpected_projection.release() + unsupported = runtime._dynwinrt_can_cast(qi_subject, unsupported_iid) + qi_subject.release() + + controlled_error = OSError( + None, + 'controlled QI failure', + None, + -2147467259, + ) + + class FailingValue: + def is_released(self): + return False + + def is_null(self): + return False + + def as_raw(self): + return 1 + + def cast(self, _iid): + raise controlled_error + + original_value_type = runtime.DynWinRTValue + qi_error = None + runtime.DynWinRTValue = FailingValue + try: + runtime._dynwinrt_can_cast(FailingValue(), iid) + except OSError as error: + qi_error = error + finally: + runtime.DynWinRTValue = original_value_type if ( results != [False, False, False, False] or legacy != (1, 2) @@ -1605,12 +1650,18 @@ def __int__(self): or int_error != 'unexpected-int-error' or released_error is None or 'released' not in released_error + or unsupported_error != -2147467262 + or unsupported + or qi_error is not controlled_error ): cr['error'] = ( 'runtime dispatch helpers failed: ' f'casts={results!r}, legacy={legacy!r}, error={legacy_error!r}, ' f'int_guards={int_guards!r}, int_error={int_error!r}, ' - f'released_error={released_error!r}' + f'released_error={released_error!r}, ' + f'unsupported_error={unsupported_error!r}, ' + f'unsupported={unsupported!r}, qi_error=' + f'{getattr(qi_error, "winerror", None)!r}' ) else: cr['pass'] = True 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 7e894e0f..3aac09e7 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/mod.rs @@ -132,6 +132,11 @@ def _dynwinrt_delegate(value, iid, parameter_types): if not callable(value): raise TypeError('delegate value must be callable or a DynWinRTValue') return _dynwinrt_create_delegate(iid, parameter_types, value).to_value() + + +_E_NOINTERFACE = -2147467262 # 0x80004002 as a signed winerror + + def _dynwinrt_can_cast(value, iid): raw = getattr(value, '_obj', value) if not isinstance(raw, DynWinRTValue): @@ -152,8 +157,10 @@ def _dynwinrt_can_cast(value, iid): raise try: projected = raw.cast(iid) - except OSError: - return False + except OSError as error: + if error.winerror == _E_NOINTERFACE: + return False + raise projected.release() return True @@ -332,9 +339,15 @@ mod tests { ); assert!(runtime.contains("raw.as_raw()")); assert!(runtime.contains( - "if str(error) == 'Cannot get raw pointer from non-object':\n return False" + "or str(error) == 'Cannot get raw pointer from non-object'\n ):\n return False" + )); + assert!( + runtime + .contains("_E_NOINTERFACE = -2147467262 # 0x80004002 as a signed winerror\n\n\ndef _dynwinrt_can_cast(value, iid):") + ); + assert!(runtime.contains( + "except OSError as error:\n if error.winerror == _E_NOINTERFACE:\n return False\n raise" )); - assert!(runtime.contains("except OSError:\n return False")); assert!(!runtime.contains("except RuntimeError:\n return False")); assert!(runtime.contains( "def _dynwinrt_legacy_call(impl, parameter_names, args, kwargs, public_name):"