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