From 16b051beef6ef41efadc96750ff98999fd81034f Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 21:17:10 +0800 Subject: [PATCH 1/6] Add an opt-in value view for WinRT Object-valued maps in Python dynwinrt.values.object_value_view(mapping, *, preserve_type=False) wraps a generated IMap or IMapView wrapper whose values are Object, such as PropertySet, ValueSet or DeviceInformation.properties. It returns a live MutableObjectValueView or read-only ObjectValueView that holds no WinRT reference of its own: reads unbox with unbox_object(), writes box with to_winrt_object()'s default rules, and view.raw is the generated map. QueryInterface for IMap/IMapView confirms the value type, so other maps such as StringMap raise TypeError, and the overloads reject them statically. A box without a Python form (an unsupported PropertyType or a DateTime outside datetime's range) reads back raw so that dict(view) does not fail on one odd entry. Generated code, unbox_object() and to_winrt_object() are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 1 + bindings/py/README.md | 46 ++ bindings/py/python/dynwinrt/values.py | 276 ++++++++- bindings/py/tests/test_object_value_view.py | 630 ++++++++++++++++++++ bindings/py/tests/test_object_values.py | 3 +- docs/status/PYTHON_CHECKLIST.md | 4 + tests/e2e/e2e_specs.json | 20 + tests/e2e/e2e_specs.schema.json | 5 +- tests/e2e/runners/py_runner.py | 173 ++++++ tests/e2e/typecheck/python_generated_api.py | 43 +- 10 files changed, 1197 insertions(+), 4 deletions(-) create mode 100644 bindings/py/tests/test_object_value_view.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7201ced7..2b696026 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -126,6 +126,7 @@ output, runtime behavior, and the `@microsoft/dynwinrt` root API. - Method call: `method_handle.invoke(obj, [args])` → returns single `DynWinRTValue` - `IReference` values project as native values plus `None`; generated `IReference_*` wrappers remain accepted for input compatibility - Generated `Object` positions stay raw `DynWinRTValue`s; `unbox_object(raw, preserve_type=False)` and `to_winrt_object(value, property_type=None)` convert boxed values explicitly, with tags, typed arrays, `Point`/`Size`/`Rect` and `PropertyType` in `dynwinrt.values` (never guess a WinRT type) +- `dynwinrt.values.object_value_view(map, preserve_type=False)` is the opt-in, live converting view of a generated map that QueryInterface confirms as `IMap`/`IMapView`; reads unbox, writes use `to_winrt_object`, and `view.raw` stays native ### Common Issues - `test_initialize` is `#[ignore]` — requires `WINAPPSDK_BOOTSTRAP_DLL_PATH` env var diff --git a/bindings/py/README.md b/bindings/py/README.md index 9d38d944..35de1698 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -286,6 +286,52 @@ wrapper to an interface view. Use `InterfaceClass.from_value(raw)` for a raw `DynWinRTValue`. Do not call the internal `_from_native()` method from application code. +### Views of `Object`-valued maps + +`dynwinrt.values.object_value_view(mapping, *, preserve_type=False)` is an +opt-in view that applies these conversions to the values of a generated map +whose values are `Object`: `PropertySet`, `ValueSet`, +`DeviceInformation.properties`, the map returned by +`StorageItemContentProperties.retrieve_properties_async()`, and other +`IMap` or `IMapView` maps with `str` or `uuid.UUID` +keys: + +```python +from dynwinrt.values import UInt32, object_value_view + +view = object_value_view(properties) +view["count"] = 5 # to_winrt_object(5): Int32 +view["port"] = UInt32(8080) # UInt32 +count = view["count"] # 5 +box = view.raw["count"] # the native DynWinRTValue box +``` + +- The view is live: every operation goes to the map, and the view holds no + WinRT reference of its own. An `IMap` gets a `MutableObjectValueView`, a + `collections.abc.MutableMapping`; an `IMapView` gets a read-only + `ObjectValueView`, a `Mapping`. +- Reads return `None` for WinRT null, a runtime object that is not a box as + its `DynWinRTValue`, and `unbox_object(value, preserve_type=preserve_type)` + for a box. Where `unbox_object` raises for a box without a Python form, the + view returns the box's `DynWinRTValue`, so `dict(view)` does not fail on one + odd entry: an unsupported `PropertyType` such as `OtherType` anywhere inside + the box, or a `DateTime` outside the range of `datetime`. Other errors + propagate. Each read returns a new `DynWinRTValue` for a runtime object, so + compare those with `identity_raw()`. +- Writes store `to_winrt_object(value)` and raise its errors unchanged: a + plain `int` boxes as `Int32` only, and an empty or mixed list needs a typed + array or an explicit `property_type`. +- `view.raw` is the generated map, which keeps returning native + `DynWinRTValue` objects. Use it to keep a box's COM identity: with + `preserve_type=True`, a value that is read and written back keeps its + `PropertyType`, but it is a new box. +- `QueryInterface` confirms the map's value type, so other maps, such as + `StringMap` or `JsonObject`, raise `TypeError`; the type stubs reject them + too. +- Generated `IPropertySet` wrappers, such as `ApplicationDataContainer.values`, + are not Python mappings. Pass + `container.values.as_interface(IMap_String_Object)` instead. + ## COM apartments and cleanup Use `RoApartment` to initialize COM for a thread and balance every successful diff --git a/bindings/py/python/dynwinrt/values.py b/bindings/py/python/dynwinrt/values.py index bc599b17..5267e7c6 100644 --- a/bindings/py/python/dynwinrt/values.py +++ b/bindings/py/python/dynwinrt/values.py @@ -20,17 +20,35 @@ form. - ``Point``, ``Size`` and ``Rect`` are immutable ``Windows.Foundation`` geometry values whose fields are stored as float32, as WinRT stores them. +- ``object_value_view(mapping)`` is an opt-in, live view of a generated map + whose values are ``Object``, such as ``PropertySet``: it unboxes values on + read and boxes them with ``to_winrt_object`` on write. ``view.raw`` is the + generated map, which keeps returning native ``DynWinRTValue`` objects. """ from __future__ import annotations import operator import struct +from collections.abc import Iterator, Mapping, MutableMapping from datetime import datetime, timedelta from enum import IntEnum -from typing import Any, ClassVar, Self, SupportsFloat, SupportsIndex, TypeVar +from typing import ( + Any, + ClassVar, + Self, + SupportsFloat, + SupportsIndex, + TypeAlias, + TypeVar, + Union, + cast, + overload, +) from uuid import UUID +from dynwinrt import DynWinRTType, DynWinRTValue, WinGUID, to_winrt_object, unbox_object + __all__ = [ "PropertyType", "WinRTScalar", @@ -66,6 +84,10 @@ "PointArray", "SizeArray", "RectArray", + "WinRTObjectValue", + "ObjectValueView", + "MutableObjectValueView", + "object_value_view", ] @@ -551,3 +573,255 @@ class RectArray(WinRTArray[Rect]): __slots__ = () property_type = PropertyType.RectArray + + +# ---------------------------------------------------------------------- +# Object-valued map views +# ---------------------------------------------------------------------- + +# A value read through an Object map view: None, an unboxed Python value (a +# tag or typed array with preserve_type=True), or a native DynWinRTValue for a +# runtime object or a box without a Python form. +WinRTObjectValue: TypeAlias = Union[ + None, + bool, + int, + float, + str, + UUID, + datetime, + timedelta, + bytes, + Point, + Size, + Rect, + list[Any], + DynWinRTValue, +] + +_K = TypeVar("_K") + +# Signed HRESULTs, as OSError.winerror reports them. +_E_NOTIMPL = 0x80004001 - 2**32 +_E_NOINTERFACE = 0x80004002 - 2**32 + +_GENERIC_MAPS = { + "IMap": "3c2925fe-8519-45c1-aa79-197b6718c1c1", + "IMapView": "e480ce40-a338-4ada-adcf-272272e48cb9", +} +_OBJECT_MAPS = ( + "IMap or IMapView with String or Guid keys, such as " + "PropertySet, ValueSet or DeviceInformation.properties" +) +_object_map_iids: dict[str, tuple[tuple[str, WinGUID], ...]] = {} + + +def _object_maps(generic: str) -> tuple[tuple[str, WinGUID], ...]: + """The generated names and IIDs of ``generic`` for String and Guid keys.""" + maps = _object_map_iids.get(generic) + if maps is None: + piid = WinGUID.parse(_GENERIC_MAPS[generic]) + maps = tuple( + ( + f"{generic}_{name}_Object", + DynWinRTType.parameterized(piid, [key, DynWinRTType.object()]).iid(), + ) + for name, key in ( + ("String", DynWinRTType.hstring()), + ("Guid", DynWinRTType.guid_type()), + ) + ) + _object_map_iids[generic] = maps + return maps + + +def _implemented_object_map(native: DynWinRTValue, generic: str) -> str | None: + """The generated name of the ``generic`` that ``native`` implements.""" + for name, iid in _object_maps(generic): + try: + interface = native.cast(iid) + except OSError as error: + if error.winerror == _E_NOINTERFACE: + continue + raise + interface.release() + return name + return None + + +def _check_object_map(mapping: object, *, mutable: bool) -> None: + """Raise ``TypeError`` unless ``mapping`` wraps an Object-valued WinRT map. + + The wrapper's own protocol says which interface it projects: a + ``MutableMapping`` wraps ``IMap`` and a ``Mapping`` wraps ``IMapView``. + ``QueryInterface`` for the ``Object``-valued instantiation confirms the + value type. + """ + name = type(mapping).__qualname__ + native = getattr(mapping, "_obj", None) + if not isinstance(native, DynWinRTValue): + hint = "" + if isinstance(mapping, DynWinRTValue): + hint = "; project a raw value first, for example with IMap_String_Object.from_value()" + raise TypeError( + f"object_value_view() requires a generated WinRT map wrapper ({_OBJECT_MAPS}), " + f"not {name}{hint}" + ) + if not isinstance(mapping, Mapping): + implemented = _implemented_object_map(native, "IMap") or _implemented_object_map( + native, "IMapView" + ) + if implemented is not None: + raise TypeError( + f"{name} is not a Python mapping in generated bindings; pass " + f"value.as_interface({implemented}) to object_value_view()" + ) + raise TypeError( + f"object_value_view() requires a generated WinRT map wrapper ({_OBJECT_MAPS}), " + f"not {name}" + ) + writable = isinstance(mapping, MutableMapping) + if mutable and not writable: + raise TypeError( + f"MutableObjectValueView requires a mutable map; {name} is read-only, so use " + "ObjectValueView" + ) + if _implemented_object_map(native, "IMap" if writable else "IMapView") is None: + raise TypeError( + f"{name} is not a WinRT map with Object values; object_value_view() accepts " + f"{_OBJECT_MAPS}" + ) + + +def _read(raw: DynWinRTValue | None, preserve_type: bool) -> WinRTObjectValue: + if raw is None: + return None + try: + return cast(WinRTObjectValue, unbox_object(raw, preserve_type=preserve_type)) + except OSError as error: + # An unsupported PropertyType, anywhere inside the box. + if error.winerror != _E_NOTIMPL: + raise + except OverflowError: + # A DateTime outside the range of datetime.datetime. + pass + return raw + + +class ObjectValueView(Mapping[_K, WinRTObjectValue]): + """A live, read-only view of a generated WinRT map whose values are ``Object``. + + Keys go to the map unchanged. Reading a value returns ``None`` for WinRT + null, a runtime object that is not a box as its ``DynWinRTValue``, and + ``unbox_object(value, preserve_type=preserve_type)`` for a box. Where + ``unbox_object`` raises for a box without a Python form, the view returns + the box's ``DynWinRTValue``: an unsupported ``PropertyType`` anywhere + inside the box, or a ``DateTime`` outside the range of + ``datetime.datetime``. Other errors propagate. + + Every operation goes to the map; the view holds no WinRT reference of its + own. ``raw`` is the generated map, whose values stay native. + """ + + __slots__ = ("_map", "_preserve_type") + _map: Mapping[_K, DynWinRTValue | None] + _preserve_type: bool + + def __init__( + self, mapping: Mapping[_K, DynWinRTValue | None], *, preserve_type: bool = False + ) -> None: + self._bind(mapping, preserve_type, mutable=False) + + def _bind( + self, mapping: Mapping[_K, DynWinRTValue | None], preserve_type: bool, *, mutable: bool + ) -> None: + if not isinstance(preserve_type, bool): + raise TypeError(f"preserve_type must be a bool, not {type(preserve_type).__name__}") + _check_object_map(mapping, mutable=mutable) + self._map = mapping + self._preserve_type = preserve_type + + @property + def raw(self) -> Mapping[_K, DynWinRTValue | None]: + """The generated map, whose values are native ``DynWinRTValue`` objects.""" + return self._map + + @property + def preserve_type(self) -> bool: + """Whether reads return tags and typed arrays that box as the same ``PropertyType``.""" + return self._preserve_type + + def __getitem__(self, key: _K) -> WinRTObjectValue: + return _read(self._map[key], self._preserve_type) + + def __iter__(self) -> Iterator[_K]: + return iter(self._map) + + def __len__(self) -> int: + return len(self._map) + + def __contains__(self, key: object) -> bool: + return key in self._map + + def __repr__(self) -> str: + options = ", preserve_type=True" if self._preserve_type else "" + return f"dynwinrt.values.{type(self).__name__}({self._map!r}{options})" + + +class MutableObjectValueView(ObjectValueView[_K], MutableMapping[_K, WinRTObjectValue]): + """A live view of a generated ``IMap`` that converts values both ways. + + Reads follow ``ObjectValueView``. A write stores ``to_winrt_object(value)``, + whose default rules pick the WinRT type and whose errors propagate: a plain + ``int`` boxes as Int32 only, and an empty or mixed list needs a typed array + or a value boxed with an explicit ``property_type``. With + ``preserve_type=True``, a value read and written back keeps its + ``PropertyType``, but it is a new box; ``raw`` keeps the original box's COM + identity. + """ + + __slots__ = () + + def __init__( + self, mapping: MutableMapping[_K, DynWinRTValue | None], *, preserve_type: bool = False + ) -> None: + self._bind(mapping, preserve_type, mutable=True) + + @property + def raw(self) -> MutableMapping[_K, DynWinRTValue | None]: + """The generated map, whose values are native ``DynWinRTValue`` objects.""" + return cast("MutableMapping[_K, DynWinRTValue | None]", self._map) + + def __setitem__(self, key: _K, value: object) -> None: + self.raw[key] = to_winrt_object(value) + + def __delitem__(self, key: _K) -> None: + del self.raw[key] + + def clear(self) -> None: + self.raw.clear() + + +@overload +def object_value_view( + mapping: MutableMapping[_K, DynWinRTValue | None], *, preserve_type: bool = False +) -> MutableObjectValueView[_K]: ... +@overload +def object_value_view( + mapping: Mapping[_K, DynWinRTValue | None], *, preserve_type: bool = False +) -> ObjectValueView[_K]: ... +def object_value_view( + mapping: Mapping[_K, DynWinRTValue | None], *, preserve_type: bool = False +) -> ObjectValueView[_K]: + """A live view of a generated WinRT map that converts its ``Object`` values. + + ``mapping`` is a generated wrapper of ``IMap`` or + ``IMapView`` with String or Guid keys, such as ``PropertySet``, + ``ValueSet`` or ``DeviceInformation.properties``. ``QueryInterface`` + confirms the value type, so other maps, such as ``StringMap``, raise + ``TypeError``. A mutable map returns a ``MutableObjectValueView`` and a + read-only map an ``ObjectValueView``; see them for the conversion rules. + """ + if isinstance(mapping, MutableMapping): + return MutableObjectValueView(mapping, preserve_type=preserve_type) + return ObjectValueView(mapping, preserve_type=preserve_type) diff --git a/bindings/py/tests/test_object_value_view.py b/bindings/py/tests/test_object_value_view.py new file mode 100644 index 00000000..9071dbe4 --- /dev/null +++ b/bindings/py/tests/test_object_value_view.py @@ -0,0 +1,630 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Opt-in value views of ``Object``-valued WinRT maps: ``object_value_view``. + +The maps are real WinRT collections (PropertySet, ValueSet, StringMap, +MediaPropertySet and ``create_map`` maps) behind wrappers built like the +generated ones: the runtime mapping mixins over the collection interface, +registered with the method names and vtable order codegen uses. +""" + +import gc +from collections.abc import Mapping, MutableMapping +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from enum import IntEnum +from types import SimpleNamespace +from uuid import UUID + +import pytest + +import dynwinrt +from dynwinrt import ( + DynWinRTImplementation, + DynWinRTImplementationMethod, + DynWinRTInterfacePlan, + DynWinRTMethodSig, + DynWinRTStruct, + DynWinRTType, + DynWinRTValue, + RoApartment, + WinGUID, + release_projected, + to_winrt_object, + unbox_object, +) +from dynwinrt import values +from dynwinrt.dynwinrt import _WinRTMappingMixin, _WinRTMutableMappingMixin +from dynwinrt.values import ( + MutableObjectValueView, + ObjectValueView, + PropertyType, + object_value_view, +) + +T = DynWinRTType +OBJECT = T.object() +IMAP = WinGUID.parse("3c2925fe-8519-45c1-aa79-197b6718c1c1") +IMAP_VIEW = WinGUID.parse("e480ce40-a338-4ada-adcf-272272e48cb9") +IITERABLE = WinGUID.parse("faa585ea-6214-4217-afda-7f46de5869b3") +IKEY_VALUE_PAIR = WinGUID.parse("02b51929-c1c4-4a7e-8940-0312b5c18500") +IPROPERTY_VALUE = WinGUID.parse("4bd682dd-7554-40e9-9a9b-82654ede7e62") +IPROPERTY_VALUE_STATICS = WinGUID.parse("629bdbc8-d932-4ff4-96b9-8d96c5c1e858") +IURI_FACTORY = WinGUID.parse("44a9796f-723e-4fdf-a218-033e75b0c084") +E_NOTIMPL = -2147467263 +UTC = timezone.utc + + +@pytest.fixture(scope="module", autouse=True) +def apartment(): + with RoApartment(1): + yield + # Release the WinRT objects that exception tracebacks keep in + # reference cycles while the apartment is still initialized. + gc.collect() + + +# ---------------------------------------------------------------------- +# Generated-style map wrappers +# ---------------------------------------------------------------------- + + +class Keys: + """How a generated wrapper marshals one WinRT key type.""" + + def __init__(self, typ, to_native, from_native): + self.type = typ + self.to_native = to_native + self.from_native = from_native + + +STRING_KEYS = Keys(T.hstring(), DynWinRTValue.from_hstring, lambda value: value.to_string()) +GUID_KEYS = Keys( + T.guid_type(), + lambda key: DynWinRTValue.from_guid(WinGUID.parse(str(key))), + lambda value: UUID(value.to_guid().to_string()), +) +INT32_KEYS = Keys(T.i32_type(), DynWinRTValue.from_i32, lambda value: value.to_number()) + + +def register(generic, keys, value_type, methods): + """Register ``generic`` with codegen's method names, in vtable order.""" + iid = T.parameterized(generic, [keys.type, value_type]).iid() + interface = T.register_interface(f"GeneratedStyle{iid.to_string()}", iid) + for name, inputs, output in methods: + signature = DynWinRTMethodSig() + for typ in inputs: + signature = signature.add_in(typ) + if output is not None: + signature = signature.add_out(output) + interface = interface.add_method(name, signature) + return iid, interface + + +class GeneratedStyle: + GENERIC = IMAP_VIEW + + def __init__(self, native, keys=STRING_KEYS, value_type=OBJECT): + self._keys = keys + self._value_type = value_type + iid, self._interface = register( + self.GENERIC, keys, value_type, self.methods(keys.type, value_type) + ) + self._obj = native.cast(iid) + + @staticmethod + def methods(key, value): + return [ + ("Lookup", [key], value), + ("get_Size", [], T.u32_type()), + ("HasKey", [key], T.bool_type()), + ] + + @property + def size(self): + return self._interface.method(7).invoke(self._obj, []).to_u32() + + def lookup(self, key): + value = self._interface.method(6).invoke(self._obj, [self._keys.to_native(key)]) + return None if value.is_null() else value + + def has_key(self, key): + return self._interface.method(8).invoke(self._obj, [self._keys.to_native(key)]).to_bool() + + def _iter_pairs(self): + pair = T.parameterized(IKEY_VALUE_PAIR, [self._keys.type, self._value_type]) + iterable = self._obj.cast(T.parameterized(IITERABLE, [pair]).iid()) + iterator = iterable.call_0(6, OBJECT) # First + while iterator.call_0(7, T.bool_type()).to_bool(): # get_HasCurrent + current = iterator.call_0(6, OBJECT) # get_Current + key = current.call_0(6, self._keys.type) # get_Key + yield SimpleNamespace(key=self._keys.from_native(key)) + iterator.call_0(8, T.bool_type()) # MoveNext + + +class GeneratedStyleMapView(GeneratedStyle, _WinRTMappingMixin): + """Built like a generated ``IMapView`` wrapper.""" + + +class GeneratedStyleMap(GeneratedStyle, _WinRTMutableMappingMixin): + """Built like a generated ``IMap`` wrapper.""" + + GENERIC = IMAP + + @staticmethod + def methods(key, value): + return GeneratedStyle.methods(key, value) + [ + ("GetView", [], T.parameterized(IMAP_VIEW, [key, value])), + ("Insert", [key, value], T.bool_type()), + ("Remove", [key], None), + ("Clear", [], None), + ] + + def get_view(self): + native = self._interface.method(9).invoke(self._obj, []) + return GeneratedStyleMapView(native, self._keys, self._value_type) + + def insert(self, key, value): + native = getattr(value, "_obj", value) + return self._interface.method(10).invoke( + self._obj, [self._keys.to_native(key), native] + ).to_bool() + + def remove(self, key): + self._interface.method(11).invoke(self._obj, [self._keys.to_native(key)]) + + def clear(self): + self._interface.method(12).invoke(self._obj, []) + + +class GeneratedStyleInterface: + """Built like a generated interface wrapper without a mapping protocol (IPropertySet).""" + + def __init__(self, native): + self._obj = native + + +def activate(class_name): + return DynWinRTValue.activation_factory(class_name).activate() + + +def property_set(): + return GeneratedStyleMap(activate("Windows.Foundation.Collections.PropertySet")) + + +def property_type(raw): + """IPropertyValue.Type through a raw vtable call.""" + view = raw.cast(IPROPERTY_VALUE) + try: + return PropertyType(view.call_0(6, T.i32_type()).to_number()) + finally: + view.release() + + +def uri(text="https://example.com/"): + factory = DynWinRTValue.activation_factory("Windows.Foundation.Uri").cast(IURI_FACTORY) + return factory.call(6, OBJECT, [T.hstring()], [DynWinRTValue.from_hstring(text)]) + + +def date_time_box(ticks): + """A DateTime box created by the real PropertyValue factory.""" + date_time = T.struct_type("Windows.Foundation.DateTime", [T.i64_type()]) + value = DynWinRTStruct.create(date_time) + value.set_i64(0, ticks) + factory = DynWinRTValue.activation_factory("Windows.Foundation.PropertyValue") + statics = factory.cast(IPROPERTY_VALUE_STATICS) + try: + return statics.call(21, OBJECT, [date_time], [value.to_value()]) + finally: + statics.release() + factory.release() + + +@contextmanager +def payloadless_box(type_value=PropertyType.OtherType): + """A Python-implemented IPropertyValue whose Type() reports ``type_value``.""" + methods = [ + DynWinRTImplementationMethod("get_Type", 6, DynWinRTMethodSig().add_out(T.i32_type())) + ] + plan = DynWinRTInterfacePlan.create( + "Windows.Foundation.IPropertyValue", T.interface(IPROPERTY_VALUE), methods + ) + + def dispatch(_interface, slot, _args): + assert slot == 6, "only Type() may be called for a payload-less box" + return [DynWinRTValue.from_i32(int(type_value))] + + owner = DynWinRTImplementation.create([plan], dispatch) + raw = owner.to_value() + try: + yield owner, raw + finally: + raw.release() + owner.release() + + +# ---------------------------------------------------------------------- +# Views and their protocol +# ---------------------------------------------------------------------- + + +def test_object_value_view_picks_the_view_for_the_map_protocol(): + properties = property_set() + view = object_value_view(properties) + assert type(view) is MutableObjectValueView + assert isinstance(view, MutableMapping) and isinstance(view, ObjectValueView) + assert view.raw is properties and view.preserve_type is False + assert repr(view) == f"dynwinrt.values.MutableObjectValueView({properties!r})" + + read_only = object_value_view(properties.get_view(), preserve_type=True) + assert type(read_only) is ObjectValueView + assert isinstance(read_only, Mapping) and not isinstance(read_only, MutableMapping) + assert read_only.preserve_type is True + assert repr(read_only).endswith(", preserve_type=True)") + with pytest.raises(TypeError, match="does not support item assignment"): + read_only["count"] = 5 + assert not hasattr(read_only, "pop") and not hasattr(read_only, "update") + + # A read-only view of a mutable map; a mutable view needs a mutable map. + assert type(ObjectValueView(properties)) is ObjectValueView + with pytest.raises(TypeError, match="requires a mutable map; GeneratedStyleMapView is"): + MutableObjectValueView(properties.get_view()) + with pytest.raises(AttributeError): + view.raw = properties + + +def test_the_view_types_are_public_and_generic(): + names = ("WinRTObjectValue", "ObjectValueView", "MutableObjectValueView", "object_value_view") + for name in names: + assert name in values.__all__ and name not in dynwinrt.__all__ + assert ObjectValueView[str] is not None and MutableObjectValueView[UUID] is not None + assert issubclass(MutableObjectValueView, ObjectValueView) + + +@pytest.mark.parametrize("class_name", ["PropertySet", "ValueSet"]) +def test_writes_box_by_the_default_rules_and_reads_unbox(class_name): + properties = GeneratedStyleMap(activate(f"Windows.Foundation.Collections.{class_name}")) + view = object_value_view(properties) + moment = datetime(2024, 5, 6, 7, 8, 9, 123456, tzinfo=timezone(timedelta(hours=2))) + cases = [ + # (value, PropertyType, value read back) + (5, PropertyType.Int32, 5), + ("text", PropertyType.String, "text"), + (moment, PropertyType.DateTime, moment), + (values.UInt32(8080), PropertyType.UInt32, 8080), + (values.UInt16Array([1, 2]), PropertyType.UInt16Array, [1, 2]), + (b"\x00\xff", PropertyType.UInt8Array, b"\x00\xff"), + (values.Point(1.5, 2.5), PropertyType.Point, values.Point(1.5, 2.5)), + ] + for index, (value, kind, expected) in enumerate(cases): + view[f"key{index}"] = value + assert property_type(properties[f"key{index}"]) == kind + read = view[f"key{index}"] + assert read == expected + assert not isinstance(read, (values.WinRTScalar, values.WinRTArray)) + assert view["key2"].tzinfo is UTC + + view["null"] = None + assert properties["null"] is None and view["null"] is None and "null" in view + assert len(view) == len(cases) + 1 + + +def test_the_view_is_live_in_both_directions(): + properties = property_set() + view = object_value_view(properties) + other = object_value_view(properties) + properties["native"] = to_winrt_object(values.Int64(7)) + assert view["native"] == 7 and len(view) == 1 + view["converted"] = 1.5 + assert unbox_object(properties["converted"]) == 1.5 + assert other["converted"] == 1.5 and set(other) == {"native", "converted"} + del properties["native"] + with pytest.raises(KeyError): + view["native"] + assert list(view) == ["converted"] + + +def test_dict_and_equality_use_converted_values(): + properties = property_set() + view = object_value_view(properties) + view.update({"count": 5, "name": "text", "sizes": values.UInt32Array([1, 2])}) + expected = {"count": 5, "name": "text", "sizes": [1, 2]} + assert dict(view) == expected + assert view == expected and not view != expected + assert view != {"count": 5} + assert object_value_view(properties.get_view()) == expected + assert view != [("count", 5)] + + +def test_mutable_mapping_methods(): + view = object_value_view(property_set()) + view.update({"a": 1}, b="x") + view.update([("c", values.Int16(3))]) + assert dict(view) == {"a": 1, "b": "x", "c": 3} + assert property_type(view.raw["c"]) == PropertyType.Int16 + + assert view.setdefault("a", 9) == 1 + default = values.UInt8(4) + assert view.setdefault("d", default) is default + assert property_type(view.raw["d"]) == PropertyType.UInt8 + + assert view.get("a") == 1 and view.get("missing") is None and view.get("missing", 0) == 0 + assert view.pop("a") == 1 and "a" not in view + assert view.pop("missing", None) is None + with pytest.raises(KeyError): + view.pop("missing") + key, value = view.popitem() + assert key not in view and value in ("x", 3, 4) + + keys, items = view.keys(), view.items() + view["e"] = [True, False] + assert "e" in keys and ("e", [True, False]) in items + assert sorted(view.values(), key=repr) == sorted( + (unbox_object(view.raw[name]) for name in view), key=repr + ) + + del view["e"] + with pytest.raises(KeyError): + del view["e"] + view.clear() + assert len(view) == 0 and not view and list(view.raw) == [] + + +def test_contains_len_and_iteration_do_not_unbox(monkeypatch): + view = object_value_view(property_set()) + view["count"] = 5 + calls = [] + + def counting(value, *, preserve_type=False): + calls.append(value) + return unbox_object(value, preserve_type=preserve_type) + + monkeypatch.setattr(values, "unbox_object", counting) + assert "count" in view and "missing" not in view + assert len(view) == 1 and list(view) == ["count"] and "count" in view.keys() + assert calls == [] + assert view["count"] == 5 and len(calls) == 1 + + +def test_raw_is_the_wrapper_and_keeps_the_box_identity(): + properties = property_set() + box = to_winrt_object(values.UInt32(7)) + properties["port"] = box + view = object_value_view(properties) + exact = object_value_view(properties, preserve_type=True) + assert view.raw is properties and exact.raw is properties + assert view.raw["port"].identity_raw() == box.identity_raw() + assert view["port"] == 7 and type(view["port"]) is int + + # Reading and writing back keeps the PropertyType, not the box's identity. + assert type(exact["port"]) is values.UInt32 + exact["port"] = exact["port"] + assert property_type(view.raw["port"]) == PropertyType.UInt32 + assert view.raw["port"].identity_raw() != box.identity_raw() + # Native access through raw keeps it. + view.raw["copy"] = view.raw["port"] + assert view.raw["copy"].identity_raw() == view.raw["port"].identity_raw() + + +def test_objects_that_are_not_boxes_keep_their_identity(): + properties = property_set() + view = object_value_view(properties) + raw = uri() + view["uri"] = raw + read = view["uri"] + assert isinstance(read, DynWinRTValue) and read.identity_raw() == raw.identity_raw() + assert unbox_object(read) is read + + class Projected: + def __init__(self, obj): + self._obj = obj + + view["projected"] = Projected(raw) + assert view["projected"].identity_raw() == raw.identity_raw() + + # A nested map is an object too, and gets a view of its own. + nested = property_set() + view["nested"] = nested + inner = object_value_view(GeneratedStyleMap(view["nested"])) + inner["count"] = 5 + assert object_value_view(nested)["count"] == 5 + + +def test_boxes_without_a_python_form_come_back_raw(): + properties = property_set() + view = object_value_view(properties) + view["count"] = 5 + with payloadless_box() as (_owner, other): + properties["other"] = other + properties["nested"] = to_winrt_object(values.InspectableArray([1, other])) + properties["far"] = date_time_box(2**62) + for key in ("other", "nested", "far"): + read = view[key] + assert isinstance(read, DynWinRTValue) + assert read.identity_raw() == properties[key].identity_raw() + # Explicit unboxing still raises for the same values. + with pytest.raises(OSError) as caught: + unbox_object(properties["other"]) + assert caught.value.winerror == E_NOTIMPL + with pytest.raises(OSError, match="Unsupported WinRT IPropertyValue type"): + unbox_object(properties["nested"]) + with pytest.raises(OverflowError, match="outside the range of datetime.datetime"): + unbox_object(properties["far"]) + # One odd entry does not break iteration. + converted = dict(object_value_view(properties, preserve_type=True)) + assert converted["count"] == 5 and set(converted) == {"count", "other", "nested", "far"} + + +def test_other_read_errors_propagate(): + properties = property_set() + with payloadless_box() as (owner, box): + properties["box"] = box + owner.disconnect() + with pytest.raises(OSError, match="disconnected"): + object_value_view(properties)["box"] + owner.take_error() + + +class Color(IntEnum): + Red = 1 + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(2**31, id="int-above-Int32"), + pytest.param(-(2**31) - 1, id="int-below-Int32"), + pytest.param([], id="empty-list"), + pytest.param((), id="empty-tuple"), + pytest.param([1, "a"], id="mixed-list"), + pytest.param([1, None], id="list-with-None"), + pytest.param(Color.Red, id="enum-member"), + pytest.param(datetime(2024, 1, 1), id="naive-datetime"), + pytest.param(object(), id="object"), + pytest.param({"a": 1}, id="dict"), + pytest.param(DynWinRTValue.from_i32(5), id="non-object-DynWinRTValue"), + pytest.param(values.StringArray([1]), id="invalid-typed-array"), + ], +) +def test_write_errors_propagate_from_to_winrt_object(value): + with pytest.raises((TypeError, ValueError, OverflowError)) as expected: + to_winrt_object(value) + properties = property_set() + view = object_value_view(properties) + view["key"] = "kept" + with pytest.raises(type(expected.value)) as caught: + view["key"] = value + assert str(caught.value) == str(expected.value) + with pytest.raises(type(expected.value)): + view.update(other=value) + assert dict(view) == {"key": "kept"} + + +@pytest.mark.parametrize( + "value", + [ + values.UInt8(255), + values.Int16(-5), + values.UInt16(9), + values.UInt32(2**32 - 1), + values.Int64(-(2**63)), + values.UInt64(2**64 - 1), + values.Single(0.1), + values.Char16("x"), + values.UInt16Array([1, 2]), + values.DoubleArray([]), + values.InspectableArray([None, values.UInt32(1), "a"]), + ], + ids=repr, +) +def test_preserve_type_reads_tags_and_writes_back_the_same_type(value): + properties = property_set() + view = object_value_view(properties, preserve_type=True) + view["value"] = value + kind = property_type(properties["value"]) + read = view["value"] + assert type(read) is type(value) and read == value + view["again"] = read + assert property_type(properties["again"]) == kind + assert view["again"] == value and type(view["again"]) is type(value) + assert type(object_value_view(properties)["value"]) is not type(value) + + +def test_read_only_views_of_map_views(): + properties = property_set() + object_value_view(properties).update(count=5, name="text") + view = object_value_view(properties.get_view()) + assert view["count"] == 5 and view.get("name") == "text" and view.get("missing") is None + assert len(view) == 2 and "count" in view and set(view) == {"count", "name"} + assert dict(view.items()) == {"count": 5, "name": "text"} + with pytest.raises(KeyError): + view["missing"] + + +def test_guid_keyed_maps(): + media = GeneratedStyleMap( + activate("Windows.Media.MediaProperties.MediaPropertySet"), GUID_KEYS + ) + view = object_value_view(media) + key = UUID(int=7) + view[key] = values.UInt32(3) + assert type(view) is MutableObjectValueView + assert dict(view) == {key: 3} and property_type(media[key]) == PropertyType.UInt32 + + +def test_create_map_maps(): + native = DynWinRTValue.create_map( + [DynWinRTValue.from_hstring("count")], [to_winrt_object(5)], T.hstring(), OBJECT + ) + view = object_value_view(GeneratedStyleMap(native)) + view["name"] = "text" + assert dict(view) == {"count": 5, "name": "text"} + + +# ---------------------------------------------------------------------- +# Rejected maps and values +# ---------------------------------------------------------------------- + + +def test_maps_without_object_values_are_rejected(): + message = "is not a WinRT map with Object values" + string_map = GeneratedStyleMap( + activate("Windows.Foundation.Collections.StringMap"), value_type=T.hstring() + ) + with pytest.raises(TypeError, match=f"GeneratedStyleMap {message}.*String or Guid keys"): + object_value_view(string_map) + with pytest.raises(TypeError, match=message): + ObjectValueView(string_map.get_view()) + json_value = T.interface(WinGUID.parse("a3219ecb-f0b3-4dcd-beee-19d48cd3ed1e")) + json_object = GeneratedStyleMap( + activate("Windows.Data.Json.JsonObject"), value_type=json_value + ) + with pytest.raises(TypeError, match=message): + object_value_view(json_object) + int32_keys = DynWinRTValue.create_map( + [DynWinRTValue.from_i32(1)], [to_winrt_object(5)], T.i32_type(), OBJECT + ) + with pytest.raises(TypeError, match=message): + object_value_view(GeneratedStyleMap(int32_keys, INT32_KEYS)) + + +def test_values_that_are_not_generated_map_wrappers_are_rejected(): + properties = property_set() + requires = "requires a generated WinRT map wrapper" + with pytest.raises(TypeError, match=f"{requires}.*not dict$"): + object_value_view({"count": to_winrt_object(5)}) + with pytest.raises(TypeError, match=r"not DynWinRTValue; .*IMap_String_Object\.from_value"): + object_value_view(properties._obj) + with pytest.raises(TypeError, match=f"{requires}.*not NoneType"): + object_value_view(None) + with pytest.raises(TypeError, match=f"{requires}.*not GeneratedStyleInterface"): + object_value_view(GeneratedStyleInterface(uri())) + # IPropertySet wrappers are not Python mappings in generated bindings. + with pytest.raises( + TypeError, + match=r"GeneratedStyleInterface is not a Python mapping in generated bindings; " + r"pass value\.as_interface\(IMap_String_Object\) to object_value_view\(\)", + ): + object_value_view(GeneratedStyleInterface(properties._obj)) + with pytest.raises(TypeError, match="preserve_type must be a bool, not int"): + object_value_view(properties, preserve_type=1) + + +def test_released_maps_raise_on_reads_and_writes(): + properties = property_set() + properties["count"] = to_winrt_object(5) + view = object_value_view(properties) + release_projected(properties) + operations = [ + lambda: view["count"], + lambda: view.__setitem__("count", 6), + lambda: view.__delitem__("count"), + lambda: len(view), + lambda: list(view), + lambda: "count" in view, + lambda: object_value_view(properties), + ] + for operation in operations: + with pytest.raises(RuntimeError): + operation() diff --git a/bindings/py/tests/test_object_values.py b/bindings/py/tests/test_object_values.py index 7c0bbd07..c43edd41 100644 --- a/bindings/py/tests/test_object_values.py +++ b/bindings/py/tests/test_object_values.py @@ -635,7 +635,8 @@ def test_the_data_model_lives_in_its_own_namespace(): assert dynwinrt.values is values for name in values.__all__: assert name not in dynwinrt.__all__ - assert getattr(values, name).__module__ == "dynwinrt.values" + if name != "WinRTObjectValue": # a typing.Union alias + assert getattr(values, name).__module__ == "dynwinrt.values" assert "to_winrt_object" in dynwinrt.__all__ and "unbox_object" in dynwinrt.__all__ namespace = {} exec("from dynwinrt import *", namespace) diff --git a/docs/status/PYTHON_CHECKLIST.md b/docs/status/PYTHON_CHECKLIST.md index 09e10c92..b6c41050 100644 --- a/docs/status/PYTHON_CHECKLIST.md +++ b/docs/status/PYTHON_CHECKLIST.md @@ -144,6 +144,10 @@ of a dynamic projection. `unbox_object(raw, preserve_type=...)` and `to_winrt_object(value, property_type=...)`, covering every `PropertyType` with a payload; generated `Object` positions stay native. +- [x] Offer `dynwinrt.values.object_value_view(map)`, an opt-in live view that + converts the values of maps that QueryInterface confirms as + `IMap`/`IMapView`; `view.raw` stays native. + JavaScript parity is still open. - [x] Declare non-suppressing runtime and generated context managers as `Literal[False]`. - [x] Replace deprecated PyO3 automatic `FromPyObject` behavior explicitly. diff --git a/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index e98af6df..b6725def 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -463,9 +463,24 @@ { "kind": "object_value_storage_properties", "member": "get_file_from_path_async" + }, + { + "kind": "object_value_view_storage_properties", + "member": "get_file_from_path_async" } ] }, + { + "id": "object_value_view_maps", + "namespace": "Windows.Foundation.Collections", + "class": "PropertySet", + "extra_classes": ["ValueSet", "StringMap"], + "langs": ["py"], + "instantiate": { "kind": "activate" }, + "checks": [ + { "kind": "object_value_view_maps", "member": "insert" } + ] + }, { "id": "issue_69_device_information_async_collection", "namespace": "Windows.Devices.Enumeration", @@ -481,6 +496,11 @@ "kind": "object_value_device_properties", "member": "find_all_async", "langs": ["py"] + }, + { + "kind": "object_value_view_device_properties", + "member": "find_all_async", + "langs": ["py"] } ] }, diff --git a/tests/e2e/e2e_specs.schema.json b/tests/e2e/e2e_specs.schema.json index f741cc8b..b3038f34 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -104,7 +104,10 @@ "generated_helper_matrix", "object_value_roundtrip", "object_value_storage_properties", - "object_value_device_properties" + "object_value_device_properties", + "object_value_view_maps", + "object_value_view_storage_properties", + "object_value_view_device_properties" ] }, "member": { "type": "string", "description": "Property or method name (snake_case)" }, diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index f9271461..ddb39532 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -1768,6 +1768,179 @@ def stored_type(raw): print(' skipped DeviceInformation.properties string: no device exposes one') cr['pass'] = True + elif kind == 'object_value_view_maps': + from datetime import datetime, timedelta, timezone + + v = dw.values + kinds = v.PropertyType + property_value_iid = dw.WinGUID.parse('4bd682dd-7554-40e9-9a9b-82654ede7e62') + + def stored_type(raw): + view = raw.cast(property_value_iid) + try: + return view.call_0(6, dw.DynWinRTType.i32_type()).to_number() + finally: + view.release() + + moment = datetime(2024, 5, 6, 7, 8, 9, 123456, tzinfo=timezone(timedelta(hours=2))) + writes = [ + # (key, value, PropertyType, value read back) + ('count', 5, kinds.Int32, 5), + ('name', 'text', kinds.String, 'text'), + ('when', moment, kinds.DateTime, moment), + ('port', v.UInt32(8080), kinds.UInt32, 8080), + ('sizes', v.UInt16Array([1, 2]), kinds.UInt16Array, [1, 2]), + ] + expected = {key: read for key, _, _, read in writes} + expected['empty'] = None + for mapping in (obj, generated_type(pkg_name, 'ValueSet')()): + label = type(mapping).__name__ + view = v.object_value_view(mapping) + if type(view) is not v.MutableObjectValueView or view.raw is not mapping: + cr['error'] = f'{label}: object_value_view returned {view!r}' + return cr + for key, value, kind_, read in writes: + view[key] = value + stored = stored_type(mapping[key]) + if stored != kind_ or view[key] != read: + cr['error'] = f'{label}: {value!r} was stored as {stored}' + return cr + view['empty'] = None + if mapping['empty'] is not None or dict(view) != expected or view != expected: + cr['error'] = f'{label}: the view reads {dict(view)!r}' + return cr + view.update({'extra': 1.5}, other=True) + if ( + view.setdefault('count', 9) != 5 + or view.setdefault('small', v.Int16(3)) != 3 + or stored_type(mapping['small']) != kinds.Int16 + or view.pop('extra') != 1.5 + or view.pop('extra', None) is not None + ): + cr['error'] = f'{label}: update, setdefault or pop failed' + return cr + del view['other'] + if 'other' in view or 'extra' in mapping or len(view) != len(expected) + 1: + cr['error'] = f'{label}: deletion did not reach the map' + return cr + + exact = v.object_value_view(mapping, preserve_type=True) + box = mapping['port'] + if type(exact['port']) is not v.UInt32: + cr['error'] = f'{label}: preserve_type read {exact["port"]!r}' + return cr + exact['port'] = exact['port'] + if ( + stored_type(mapping['port']) != kinds.UInt32 + or mapping['port'].identity_raw() == box.identity_raw() + ): + cr['error'] = f'{label}: writing back did not create a UInt32 box' + return cr + invalid = ((2**31, OverflowError), ([], TypeError), ([1, 'a'], TypeError)) + for bad, error_type in invalid: + try: + view['bad'] = bad + except error_type: + continue + cr['error'] = f'{label}: writing {bad!r} did not raise {error_type.__name__}' + return cr + if 'bad' in view: + cr['error'] = f'{label}: a failed write changed the map' + return cr + + view = v.object_value_view(obj) + uri = generated_type(pkg_name, 'Uri')('https://example.com/view') + view['uri'] = uri + if view['uri'].identity_raw() != uri._obj.identity_raw(): + cr['error'] = 'a runtime object lost its COM identity' + return cr + read_only = v.object_value_view(obj.get_view()) + if type(read_only) is not v.ObjectValueView or read_only['count'] != 5: + cr['error'] = f'the IMapView view read {dict(read_only)!r}' + return cr + + try: + v.object_value_view(generated_type(pkg_name, 'StringMap')()) + cr['error'] = 'a StringMap was accepted' + return cr + except TypeError as error: + if 'not a WinRT map with Object values' not in str(error): + raise + interface = obj.as_interface(generated_type(pkg_name, 'IPropertySet')) + try: + v.object_value_view(interface) + cr['error'] = 'an IPropertySet wrapper was accepted' + return cr + except TypeError as error: + if 'as_interface(IMap_String_Object)' not in str(error): + raise + through = v.object_value_view( + interface.as_interface(generated_type(pkg_name, 'IMap_String_Object')) + ) + through['through'] = 7 + if view['through'] != 7: + cr['error'] = 'the IPropertySet workaround did not reach the map' + else: + cr['pass'] = True + + elif kind == 'object_value_view_storage_properties': + from datetime import datetime + from pathlib import Path + from tempfile import TemporaryDirectory + + with TemporaryDirectory(prefix='dynwinrt-view-') as temp_dir: + path = Path(temp_dir) / 'sample.txt' + path.write_bytes(b'dynwinrt' * 3) + storage_file = await getattr(cls, member)(str(path)) + properties = await storage_file.properties.retrieve_properties_async( + ['System.Size', 'System.DateModified'] + ) + view = dw.values.object_value_view(properties) + exact = dw.values.object_value_view(properties, preserve_type=True) + converted = dict(view) + if ( + type(view) is not dw.values.MutableObjectValueView + or view.raw is not properties + or set(converted) != {'System.Size', 'System.DateModified'} + or type(converted['System.Size']) is not int + or converted['System.Size'] != 24 + or not isinstance(converted['System.DateModified'], datetime) + ): + cr['error'] = f'the properties view read {converted!r}' + elif type(exact['System.Size']) is not dw.values.UInt64: + cr['error'] = f'System.Size preserved as {exact["System.Size"]!r}' + else: + exact['System.Size'] = exact['System.Size'] + written = dw.unbox_object(properties['System.Size'], preserve_type=True) + if type(written) is not dw.values.UInt64 or written != 24: + cr['error'] = f'System.Size was written back as {written!r}' + else: + cr['pass'] = True + + elif kind == 'object_value_view_device_properties': + devices = await getattr(cls, member)() + checked = 0 + for device in devices: + properties = device.properties + view = dw.values.object_value_view(properties) + if type(view) is not dw.values.ObjectValueView or view.raw is not properties: + cr['error'] = f'DeviceInformation.properties view is {view!r}' + return cr + converted = dict(view) + if set(converted) != set(properties) or len(view) != len(properties): + cr['error'] = 'the device properties view lost keys' + return cr + name = converted.get('System.ItemNameDisplay') + if name is not None and type(name) is not str: + cr['error'] = f'System.ItemNameDisplay read as {name!r}' + return cr + checked += 1 + if checked == 20: + break + if not checked: + print(' skipped DeviceInformation.properties view: no devices') + cr['pass'] = True + elif kind == 'bitmap_encoder_async_create': stream_cls = generated_type(pkg_name, 'InMemoryRandomAccessStream') stream = ( diff --git a/tests/e2e/typecheck/python_generated_api.py b/tests/e2e/typecheck/python_generated_api.py index 3902d0c9..5a39c46f 100644 --- a/tests/e2e/typecheck/python_generated_api.py +++ b/tests/e2e/typecheck/python_generated_api.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Coroutine, Generator, Sequence -from typing import Any, Awaitable, List, Tuple +from typing import Any, Awaitable, Dict, List, Tuple from dynwinrt import ( DynWinRTArray, @@ -16,12 +16,25 @@ DynWinRTValue, WinGUID, ) +from dynwinrt.values import ( + MutableObjectValueView, + ObjectValueView, + UInt32, + WinRTObjectValue, + object_value_view, +) from python_bindings.windows.application_model.contacts import ContactDate +from python_bindings.windows.devices.enumeration import DeviceInformation from python_bindings.windows.foundation import ( IReference_UInt32, IWwwFormUrlDecoderEntry, Uri, ) +from python_bindings.windows.foundation.collections import ( + PropertySet, + StringMap, + ValueSet, +) from python_bindings.windows.globalization import Calendar from python_bindings.windows.storage.streams import ( Buffer as WinRTBuffer, @@ -152,3 +165,31 @@ 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_object_value_views( + properties: PropertySet, + value_set: ValueSet, + device: DeviceInformation, + strings: StringMap, +) -> None: + view: MutableObjectValueView[str] = object_value_view(properties) + view["count"] = 5 + view["port"] = UInt32(8080) + view["uri"] = Uri("https://example.com") + view.update({"name": "text"}, empty=None) + count: WinRTObjectValue = view["count"] + native: DynWinRTValue | None = view.raw["count"] + exact: MutableObjectValueView[str] = object_value_view(value_set, preserve_type=True) + device_properties = device.properties + assert device_properties is not None + read_only: ObjectValueView[str] = object_value_view(device_properties) + snapshot: Dict[str, WinRTObjectValue] = dict(read_only) + read_only["count"] = 5 # type: ignore[index] + object_value_view(strings) # type: ignore[arg-type] + _: Tuple[ + WinRTObjectValue, + DynWinRTValue | None, + MutableObjectValueView[str], + Dict[str, WinRTObjectValue], + ] = (count, native, exact, snapshot) From d854fe275beecdd94931d3c7169c2b9119e53c58 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 21:37:51 +0800 Subject: [PATCH 2/6] Type MutableObjectValueView.update() like item assignment update() writes through __setitem__ and to_winrt_object(), so its overloads now accept any value, as item assignment already does. They mirror MutableMapping.update(), which allows keyword arguments only for str keys. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/python/dynwinrt/values.py | 29 ++++++++++++++++++++- bindings/py/tests/test_object_value_view.py | 4 +++ tests/e2e/typecheck/python_generated_api.py | 1 + 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/bindings/py/python/dynwinrt/values.py b/bindings/py/python/dynwinrt/values.py index 5267e7c6..263fdff7 100644 --- a/bindings/py/python/dynwinrt/values.py +++ b/bindings/py/python/dynwinrt/values.py @@ -30,10 +30,11 @@ import operator import struct -from collections.abc import Iterator, Mapping, MutableMapping +from collections.abc import Callable, Iterable, Iterator, Mapping, MutableMapping from datetime import datetime, timedelta from enum import IntEnum from typing import ( + TYPE_CHECKING, Any, ClassVar, Self, @@ -49,6 +50,9 @@ from dynwinrt import DynWinRTType, DynWinRTValue, WinGUID, to_winrt_object, unbox_object +if TYPE_CHECKING: + from _typeshed import SupportsKeysAndGetItem + __all__ = [ "PropertyType", "WinRTScalar", @@ -801,6 +805,29 @@ def __delitem__(self, key: _K) -> None: def clear(self) -> None: self.raw.clear() + # update() writes through __setitem__, so like item assignment it accepts + # any value that to_winrt_object accepts. + @overload + def update(self, other: SupportsKeysAndGetItem[_K, object], /) -> None: ... + @overload + def update( + self: MutableObjectValueView[str], + other: SupportsKeysAndGetItem[str, object], + /, + **kwargs: object, + ) -> None: ... + @overload + def update(self, other: Iterable[tuple[_K, object]], /) -> None: ... + @overload + def update( + self: MutableObjectValueView[str], other: Iterable[tuple[str, object]], /, **kwargs: object + ) -> None: ... + @overload + def update(self: MutableObjectValueView[str], /, **kwargs: object) -> None: ... + def update(self, other: Any = (), /, **kwargs: object) -> None: + mapping_update: Callable[..., None] = MutableMapping.update + mapping_update(self, other, **kwargs) + @overload def object_value_view( diff --git a/bindings/py/tests/test_object_value_view.py b/bindings/py/tests/test_object_value_view.py index 9071dbe4..3562732e 100644 --- a/bindings/py/tests/test_object_value_view.py +++ b/bindings/py/tests/test_object_value_view.py @@ -343,6 +343,10 @@ def test_mutable_mapping_methods(): view.update([("c", values.Int16(3))]) assert dict(view) == {"a": 1, "b": "x", "c": 3} assert property_type(view.raw["c"]) == PropertyType.Int16 + view.update(pair=(1, 2), guid=WinGUID.parse("01234567-89ab-cdef-0123-456789abcdef")) + assert property_type(view.raw["pair"]) == PropertyType.Int32Array + assert view.pop("pair") == [1, 2] and view.pop("guid") == UUID("01234567-89ab-cdef-0123-456789abcdef") + view.update() assert view.setdefault("a", 9) == 1 default = values.UInt8(4) diff --git a/tests/e2e/typecheck/python_generated_api.py b/tests/e2e/typecheck/python_generated_api.py index 5a39c46f..8ef8e00e 100644 --- a/tests/e2e/typecheck/python_generated_api.py +++ b/tests/e2e/typecheck/python_generated_api.py @@ -178,6 +178,7 @@ def check_object_value_views( view["port"] = UInt32(8080) view["uri"] = Uri("https://example.com") view.update({"name": "text"}, empty=None) + view.update([("sizes", (1, 2))], uri=Uri("https://example.com")) count: WinRTObjectValue = view["count"] native: DynWinRTValue | None = view.raw["count"] exact: MutableObjectValueView[str] = object_value_view(value_set, preserve_type=True) From 37b76bdc427fe572737e70ff483fea1a19e64195 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 22:37:58 +0800 Subject: [PATCH 3/6] Type MutableObjectValueView.setdefault() like item assignment setdefault() now accepts any default that to_winrt_object() accepts, like __setitem__ and update(), and returns the read type: after storing a missing key it reads the value back, so (1, 2) returns [1, 2] and a runtime object its DynWinRTValue. MutableMapping.setdefault() would return the default itself. A targeted type: ignore[override] covers typeshed's self-typed overload, which infers "-> None" for a value type that includes None. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/python/dynwinrt/values.py | 20 ++++++++++-- bindings/py/tests/test_object_value_view.py | 36 ++++++++++++++++++--- tests/e2e/typecheck/python_generated_api.py | 5 ++- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/bindings/py/python/dynwinrt/values.py b/bindings/py/python/dynwinrt/values.py index 263fdff7..dd914ba8 100644 --- a/bindings/py/python/dynwinrt/values.py +++ b/bindings/py/python/dynwinrt/values.py @@ -805,8 +805,8 @@ def __delitem__(self, key: _K) -> None: def clear(self) -> None: self.raw.clear() - # update() writes through __setitem__, so like item assignment it accepts - # any value that to_winrt_object accepts. + # update() and setdefault() write through __setitem__, so like item + # assignment they accept any value that to_winrt_object accepts. @overload def update(self, other: SupportsKeysAndGetItem[_K, object], /) -> None: ... @overload @@ -828,6 +828,22 @@ def update(self, other: Any = (), /, **kwargs: object) -> None: mapping_update: Callable[..., None] = MutableMapping.update mapping_update(self, other, **kwargs) + # Typeshed's self-typed overload infers "-> None" for a value type that includes None. + def setdefault( # type: ignore[override] + self, key: _K, default: object = None, / + ) -> WinRTObjectValue: + """Return ``self[key]``, first storing ``default`` if ``key`` is missing. + + The result is the value as a read returns it, so a default stored as + a new box comes back converted: ``(1, 2)`` reads as ``[1, 2]`` and a + runtime object as its ``DynWinRTValue``. + """ + try: + return self[key] + except KeyError: + self[key] = default + return self[key] + @overload def object_value_view( diff --git a/bindings/py/tests/test_object_value_view.py b/bindings/py/tests/test_object_value_view.py index 3562732e..609d06e0 100644 --- a/bindings/py/tests/test_object_value_view.py +++ b/bindings/py/tests/test_object_value_view.py @@ -343,14 +343,15 @@ def test_mutable_mapping_methods(): view.update([("c", values.Int16(3))]) assert dict(view) == {"a": 1, "b": "x", "c": 3} assert property_type(view.raw["c"]) == PropertyType.Int16 - view.update(pair=(1, 2), guid=WinGUID.parse("01234567-89ab-cdef-0123-456789abcdef")) + guid = UUID("01234567-89ab-cdef-0123-456789abcdef") + view.update(pair=(1, 2), guid=WinGUID.parse(str(guid))) assert property_type(view.raw["pair"]) == PropertyType.Int32Array - assert view.pop("pair") == [1, 2] and view.pop("guid") == UUID("01234567-89ab-cdef-0123-456789abcdef") + assert view.pop("pair") == [1, 2] and view.pop("guid") == guid view.update() assert view.setdefault("a", 9) == 1 - default = values.UInt8(4) - assert view.setdefault("d", default) is default + stored = view.setdefault("d", values.UInt8(4)) + assert stored == 4 and type(stored) is int assert property_type(view.raw["d"]) == PropertyType.UInt8 assert view.get("a") == 1 and view.get("missing") is None and view.get("missing", 0) == 0 @@ -375,6 +376,33 @@ def test_mutable_mapping_methods(): assert len(view) == 0 and not view and list(view.raw) == [] +def test_setdefault_returns_the_value_as_a_read_returns_it(): + properties = property_set() + view = object_value_view(properties) + raw = uri() + + class Projected: + def __init__(self, obj): + self._obj = obj + + # A missing key stores to_winrt_object(default) and reads it back. + assert view.setdefault("pair", (1, 2)) == [1, 2] + assert property_type(properties["pair"]) == PropertyType.Int32Array + stored = view.setdefault("uri", Projected(raw)) + assert isinstance(stored, DynWinRTValue) and stored.identity_raw() == raw.identity_raw() + assert view.setdefault("nothing") is None and "nothing" in view + assert properties["nothing"] is None + # A present key keeps its value. + assert view.setdefault("pair", "ignored") == [1, 2] + assert view.setdefault("nothing", 5) is None + exact = object_value_view(properties, preserve_type=True) + assert type(exact.setdefault("port", values.UInt32(80))) is values.UInt32 + assert type(exact.setdefault("pair")) is values.Int32Array + with pytest.raises(TypeError, match="empty list or tuple"): + view.setdefault("empty", []) + assert "empty" not in view + + def test_contains_len_and_iteration_do_not_unbox(monkeypatch): view = object_value_view(property_set()) view["count"] = 5 diff --git a/tests/e2e/typecheck/python_generated_api.py b/tests/e2e/typecheck/python_generated_api.py index 8ef8e00e..0aed5fb8 100644 --- a/tests/e2e/typecheck/python_generated_api.py +++ b/tests/e2e/typecheck/python_generated_api.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Coroutine, Generator, Sequence -from typing import Any, Awaitable, Dict, List, Tuple +from typing import Any, Awaitable, Dict, List, Tuple, assert_type from dynwinrt import ( DynWinRTArray, @@ -179,6 +179,9 @@ def check_object_value_views( view["uri"] = Uri("https://example.com") view.update({"name": "text"}, empty=None) view.update([("sizes", (1, 2))], uri=Uri("https://example.com")) + assert_type(view.setdefault("uri", Uri("https://example.com")), WinRTObjectValue) + assert_type(view.setdefault("pair", (1, 2)), WinRTObjectValue) + assert_type(view.setdefault("missing"), WinRTObjectValue) count: WinRTObjectValue = view["count"] native: DynWinRTValue | None = view.raw["count"] exact: MutableObjectValueView[str] = object_value_view(value_set, preserve_type=True) From 5215bdab37f495b7e6ef31467da61c8c76ddba10 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 28 Sep 2026 09:40:58 +0800 Subject: [PATCH 4/6] Address Object map view review findings Mark only binding-classified unsupported PropertyTypes so a supported IPropertyValue getter that returns the same HRESULT still propagates. Verify that a generated wrapper's actual map dispatch pointer is the reflexive IMap/IMapView pointer, rejecting another map wrapper on the same COM identity while preserving .raw. Constrain the public view key type to str or UUID. Add a synthetic dual-map COM object, getter-error identity checks, strict mypy/Pyright key controls, and generated Guid-map E2E coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 13 +- bindings/py/python/dynwinrt/values.py | 72 ++++++-- bindings/py/src/object_value.rs | 15 +- bindings/py/tests/test_object_value_view.py | 183 +++++++++++++++++++- bindings/py/tests/test_object_values.py | 2 + tests/e2e/e2e_specs.json | 10 ++ tests/e2e/e2e_specs.schema.json | 1 + tests/e2e/runners/py_runner.py | 16 ++ tests/e2e/typecheck/python_generated_api.py | 13 +- 9 files changed, 296 insertions(+), 29 deletions(-) diff --git a/bindings/py/README.md b/bindings/py/README.md index 35de1698..029e9d1f 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -316,8 +316,9 @@ box = view.raw["count"] # the native DynWinRTValue box view returns the box's `DynWinRTValue`, so `dict(view)` does not fail on one odd entry: an unsupported `PropertyType` such as `OtherType` anywhere inside the box, or a `DateTime` outside the range of `datetime`. Other errors - propagate. Each read returns a new `DynWinRTValue` for a runtime object, so - compare those with `identity_raw()`. + propagate, including the same HRESULT from a failing getter for an otherwise + supported `PropertyType`. Each read returns a new `DynWinRTValue` for a + runtime object, so compare those with `identity_raw()`. - Writes store `to_winrt_object(value)` and raise its errors unchanged: a plain `int` boxes as `Int32` only, and an empty or mixed list needs a typed array or an explicit `property_type`. @@ -325,9 +326,11 @@ box = view.raw["count"] # the native DynWinRTValue box `DynWinRTValue` objects. Use it to keep a box's COM identity: with `preserve_type=True`, a value that is read and written back keeps its `PropertyType`, but it is a new box. -- `QueryInterface` confirms the map's value type, so other maps, such as - `StringMap` or `JsonObject`, raise `TypeError`; the type stubs reject them - too. +- `QueryInterface` confirms both the map's value type and that the wrapper + actually dispatches through that `IMap`/`IMapView` interface. A wrapper for + another map on the same COM identity is rejected rather than dispatching + through the wrong vtable. Other maps, such as `StringMap` or `JsonObject`, + raise `TypeError`; the type stubs reject them too. - Generated `IPropertySet` wrappers, such as `ApplicationDataContainer.values`, are not Python mappings. Pass `container.values.as_interface(IMap_String_Object)` instead. diff --git a/bindings/py/python/dynwinrt/values.py b/bindings/py/python/dynwinrt/values.py index dd914ba8..b3ec306c 100644 --- a/bindings/py/python/dynwinrt/values.py +++ b/bindings/py/python/dynwinrt/values.py @@ -603,10 +603,9 @@ class RectArray(WinRTArray[Rect]): DynWinRTValue, ] -_K = TypeVar("_K") +_K = TypeVar("_K", str, UUID) # Signed HRESULTs, as OSError.winerror reports them. -_E_NOTIMPL = 0x80004001 - 2**32 _E_NOINTERFACE = 0x80004002 - 2**32 _GENERIC_MAPS = { @@ -639,8 +638,8 @@ def _object_maps(generic: str) -> tuple[tuple[str, WinGUID], ...]: return maps -def _implemented_object_map(native: DynWinRTValue, generic: str) -> str | None: - """The generated name of the ``generic`` that ``native`` implements.""" +def _queried_object_map(native: DynWinRTValue, generic: str) -> str | None: + """The generated name of a ``generic`` supported by ``native``.""" for name, iid in _object_maps(generic): try: interface = native.cast(iid) @@ -653,6 +652,49 @@ def _implemented_object_map(native: DynWinRTValue, generic: str) -> str | None: return None +def _generated_map_dispatch(mapping: object, native: DynWinRTValue) -> DynWinRTValue: + """The interface pointer codegen uses for this wrapper's map operations.""" + wrapper_type = type(mapping) + if getattr(wrapper_type, "_dynwinrt_runtime_class_type", False): + dispatch = getattr(mapping, "_collection_obj", native) + elif getattr(wrapper_type, "_dynwinrt_interface_type", False): + dispatch = native + else: + raise TypeError( + "object_value_view() requires a generated WinRT map wrapper, " + f"not {wrapper_type.__qualname__}" + ) + if not isinstance(dispatch, DynWinRTValue): + raise TypeError( + f"{wrapper_type.__qualname__} has an invalid generated map projection" + ) + return dispatch + + +def _projected_object_map(dispatch: DynWinRTValue, generic: str) -> str | None: + """The Object map IID already projected by ``dispatch``, if any. + + QueryInterface is reflexive: asking an interface pointer for its own IID + returns the same physical pointer. Comparing ``as_raw`` therefore proves + that the generated wrapper dispatches through the Object-valued map, not + merely that the same COM identity implements one. + """ + dispatch_raw = dispatch.as_raw() + for name, iid in _object_maps(generic): + try: + interface = dispatch.cast(iid) + except OSError as error: + if error.winerror == _E_NOINTERFACE: + continue + raise + try: + if interface.as_raw() == dispatch_raw: + return name + finally: + interface.release() + return None + + def _check_object_map(mapping: object, *, mutable: bool) -> None: """Raise ``TypeError`` unless ``mapping`` wraps an Object-valued WinRT map. @@ -672,7 +714,7 @@ def _check_object_map(mapping: object, *, mutable: bool) -> None: f"not {name}{hint}" ) if not isinstance(mapping, Mapping): - implemented = _implemented_object_map(native, "IMap") or _implemented_object_map( + implemented = _queried_object_map(native, "IMap") or _queried_object_map( native, "IMapView" ) if implemented is not None: @@ -690,11 +732,20 @@ def _check_object_map(mapping: object, *, mutable: bool) -> None: f"MutableObjectValueView requires a mutable map; {name} is read-only, so use " "ObjectValueView" ) - if _implemented_object_map(native, "IMap" if writable else "IMapView") is None: + generic = "IMap" if writable else "IMapView" + dispatch = _generated_map_dispatch(mapping, native) + if _projected_object_map(dispatch, generic) is not None: + return + implemented = _queried_object_map(native, generic) + if implemented is not None: raise TypeError( - f"{name} is not a WinRT map with Object values; object_value_view() accepts " - f"{_OBJECT_MAPS}" + f"{name} projects a different WinRT map interface; pass " + f"value.as_interface({implemented}) to object_value_view()" ) + raise TypeError( + f"{name} is not a WinRT map with Object values; object_value_view() accepts " + f"{_OBJECT_MAPS}" + ) def _read(raw: DynWinRTValue | None, preserve_type: bool) -> WinRTObjectValue: @@ -703,8 +754,9 @@ def _read(raw: DynWinRTValue | None, preserve_type: bool) -> WinRTObjectValue: try: return cast(WinRTObjectValue, unbox_object(raw, preserve_type=preserve_type)) except OSError as error: - # An unsupported PropertyType, anywhere inside the box. - if error.winerror != _E_NOTIMPL: + # Only PropertyValueUnboxResult::Unsupported carries this marker. + # A supported getter may fail with the same HRESULT and must propagate. + if getattr(error, "_dynwinrt_unsupported_property_type", None) is None: raise except OverflowError: # A DateTime outside the range of datetime.datetime. diff --git a/bindings/py/src/object_value.rs b/bindings/py/src/object_value.rs index fa5d1977..cf9bf9c3 100644 --- a/bindings/py/src/object_value.rs +++ b/bindings/py/src/object_value.rs @@ -475,17 +475,24 @@ fn native_input<'py>(value: &Bound<'py, PyAny>) -> PyResult PyErr { +fn unsupported_property_type(py: Python<'_>, property_type: PropertyType) -> PyErr { let name = payloadless_name(property_type) .map(|name| format!(" ({name})")) .unwrap_or_default(); - map_windows_error(windows::core::Error::new( + let error = map_windows_error(windows::core::Error::new( E_NOTIMPL, format!( "Unsupported WinRT IPropertyValue type: {}{name}", property_type.0 ), - )) + )); + if let Err(marker_error) = error + .value(py) + .setattr("_dynwinrt_unsupported_property_type", property_type.0) + { + return marker_error; + } + error } // ====================================================================== @@ -543,7 +550,7 @@ impl Reader<'_> { PropertyValueUnboxResult::Null => Ok(py.None()), PropertyValueUnboxResult::NotPropertyValue => Ok(raw.clone().into_any().unbind()), PropertyValueUnboxResult::Unsupported(property_type) => { - Err(unsupported_property_type(property_type)) + Err(unsupported_property_type(py, property_type)) } PropertyValueUnboxResult::Value(data) => self.read(py, data, depth), } diff --git a/bindings/py/tests/test_object_value_view.py b/bindings/py/tests/test_object_value_view.py index 609d06e0..74281d04 100644 --- a/bindings/py/tests/test_object_value_view.py +++ b/bindings/py/tests/test_object_value_view.py @@ -103,6 +103,7 @@ def register(generic, keys, value_type, methods): class GeneratedStyle: + _dynwinrt_interface_type = True GENERIC = IMAP_VIEW def __init__(self, native, keys=STRING_KEYS, value_type=OBJECT): @@ -121,20 +122,28 @@ def methods(key, value): ("HasKey", [key], T.bool_type()), ] + @property + def _map_obj(self): + return self._obj + @property def size(self): - return self._interface.method(7).invoke(self._obj, []).to_u32() + return self._interface.method(7).invoke(self._map_obj, []).to_u32() def lookup(self, key): - value = self._interface.method(6).invoke(self._obj, [self._keys.to_native(key)]) + value = self._interface.method(6).invoke( + self._map_obj, [self._keys.to_native(key)] + ) return None if value.is_null() else value def has_key(self, key): - return self._interface.method(8).invoke(self._obj, [self._keys.to_native(key)]).to_bool() + return self._interface.method(8).invoke( + self._map_obj, [self._keys.to_native(key)] + ).to_bool() def _iter_pairs(self): pair = T.parameterized(IKEY_VALUE_PAIR, [self._keys.type, self._value_type]) - iterable = self._obj.cast(T.parameterized(IITERABLE, [pair]).iid()) + iterable = self._map_obj.cast(T.parameterized(IITERABLE, [pair]).iid()) iterator = iterable.call_0(6, OBJECT) # First while iterator.call_0(7, T.bool_type()).to_bool(): # get_HasCurrent current = iterator.call_0(6, OBJECT) # get_Current @@ -162,20 +171,39 @@ def methods(key, value): ] def get_view(self): - native = self._interface.method(9).invoke(self._obj, []) + native = self._interface.method(9).invoke(self._map_obj, []) return GeneratedStyleMapView(native, self._keys, self._value_type) def insert(self, key, value): native = getattr(value, "_obj", value) return self._interface.method(10).invoke( - self._obj, [self._keys.to_native(key), native] + self._map_obj, [self._keys.to_native(key), native] ).to_bool() def remove(self, key): - self._interface.method(11).invoke(self._obj, [self._keys.to_native(key)]) + self._interface.method(11).invoke(self._map_obj, [self._keys.to_native(key)]) def clear(self): - self._interface.method(12).invoke(self._obj, []) + self._interface.method(12).invoke(self._map_obj, []) + + +class GeneratedStyleRuntimeMap(GeneratedStyleMap): + """A runtime-class wrapper whose collection interface is non-default.""" + + _dynwinrt_interface_type = False + _dynwinrt_runtime_class_type = True + + def __init__(self, native, keys=STRING_KEYS, value_type=OBJECT): + super().__init__(native, keys, value_type) + self._collection_obj = self._obj + # Stand in for a different default interface on the same identity. + self._obj = native.cast( + T.parameterized(IMAP, [keys.type, T.hstring()]).iid() + ) + + @property + def _map_obj(self): + return self._collection_obj class GeneratedStyleInterface: @@ -244,6 +272,75 @@ def dispatch(_interface, slot, _args): owner.release() +def map_implementation_plan(name, value_type): + """A complete ``IMap`` implementation plan.""" + methods = GeneratedStyleMap.methods(T.hstring(), value_type) + iid = T.parameterized(IMAP, [T.hstring(), value_type]).iid() + interface = T.register_interface(name, iid) + definitions = [] + for slot, (method_name, inputs, output) in enumerate(methods, 6): + signature = DynWinRTMethodSig() + for typ in inputs: + signature = signature.add_in(typ) + if output is not None: + signature = signature.add_out(output) + interface = interface.add_method(method_name, signature) + definitions.append( + DynWinRTImplementationMethod(method_name, slot, signature) + ) + return DynWinRTInterfacePlan.create(name, interface, definitions) + + +@contextmanager +def object_and_string_maps(): + """One COM identity with distinct ``IMap`` stores.""" + plans = [ + map_implementation_plan("Tests.IMap_String_Object", OBJECT), + map_implementation_plan("Tests.IMap_String_String", T.hstring()), + ] + stores = [ + {"shared": to_winrt_object(7)}, + {"shared": "wrong map"}, + ] + calls = [] + + def dispatch(interface, slot, args): + calls.append((interface, slot)) + store = stores[interface] + key = args[0].to_string() if args else None + if slot == 6: # Lookup + value = store[key] + return [ + value + if interface == 0 + else DynWinRTValue.from_hstring(value) + ] + if slot == 7: # get_Size + return [DynWinRTValue.from_u32(len(store))] + if slot == 8: # HasKey + return [DynWinRTValue.from_bool(key in store)] + if slot == 9: # GetView (unused by this regression) + return [DynWinRTValue.null_value()] + if slot == 10: # Insert + replaced = key in store + store[key] = args[1] if interface == 0 else args[1].to_string() + return [DynWinRTValue.from_bool(replaced)] + if slot == 11: # Remove + del store[key] + return [] + assert slot == 12 # Clear + store.clear() + return [] + + owner = DynWinRTImplementation.create(plans, dispatch) + raw = owner.to_value() + try: + yield raw, stores, calls + finally: + raw.release() + owner.release() + + # ---------------------------------------------------------------------- # Views and their protocol # ---------------------------------------------------------------------- @@ -274,6 +371,48 @@ def test_object_value_view_picks_the_view_for_the_map_protocol(): view.raw = properties +def test_multi_map_identity_uses_only_the_wrappers_projected_map(): + with object_and_string_maps() as (raw, stores, calls): + object_map = GeneratedStyleMap(raw) + string_map = GeneratedStyleMap(raw, value_type=T.hstring()) + + view = object_value_view(object_map) + assert view.raw is object_map + assert view["shared"] == 7 + view["added"] = values.UInt32(9) + assert unbox_object(stores[0]["added"]) == 9 + assert stores[1] == {"shared": "wrong map"} + assert calls == [(0, 8), (0, 6), (0, 10)] + + before = list(calls) + with pytest.raises( + TypeError, + match=r"projects a different WinRT map interface; pass " + r"value\.as_interface\(IMap_String_Object\)", + ): + object_value_view(string_map) + # QI does not enter either map implementation, and rejection happens + # before any mapping operation can dispatch through the wrong vtable. + assert calls == before + + runtime_map = GeneratedStyleRuntimeMap(raw) + runtime_view = object_value_view(runtime_map) + assert runtime_view.raw is runtime_map + assert runtime_view["shared"] == 7 + runtime_view["runtime"] = "collection pointer" + assert unbox_object(stores[0]["runtime"]) == "collection pointer" + assert stores[1] == {"shared": "wrong map"} + assert calls[-3:] == [(0, 8), (0, 6), (0, 10)] + + assert ( + raw.identity_raw() + == object_map._obj.identity_raw() + == string_map._obj.identity_raw() + == runtime_map._obj.identity_raw() + == runtime_map._collection_obj.identity_raw() + ) + + def test_the_view_types_are_public_and_generic(): names = ("WinRTObjectValue", "ObjectValueView", "MutableObjectValueView", "object_value_view") for name in names: @@ -478,7 +617,12 @@ def test_boxes_without_a_python_form_come_back_raw(): # Explicit unboxing still raises for the same values. with pytest.raises(OSError) as caught: unbox_object(properties["other"]) + assert type(caught.value) is OSError assert caught.value.winerror == E_NOTIMPL + assert ( + caught.value._dynwinrt_unsupported_property_type + == PropertyType.OtherType + ) with pytest.raises(OSError, match="Unsupported WinRT IPropertyValue type"): unbox_object(properties["nested"]) with pytest.raises(OverflowError, match="outside the range of datetime.datetime"): @@ -488,6 +632,29 @@ def test_boxes_without_a_python_form_come_back_raw(): assert converted["count"] == 5 and set(converted) == {"count", "other", "nested", "far"} +def test_supported_getter_e_notimpl_propagates_the_same_error(monkeypatch): + properties = property_set() + properties["count"] = to_winrt_object(5) + failure = OSError( + 0, + "supported IPropertyValue getter failed", + None, + E_NOTIMPL, + ) + assert failure.winerror == E_NOTIMPL + assert not hasattr(failure, "_dynwinrt_unsupported_property_type") + + def failing_getter(_raw, *, preserve_type=False): + assert preserve_type is False + raise failure + + monkeypatch.setattr(values, "unbox_object", failing_getter) + with pytest.raises(OSError) as caught: + object_value_view(properties)["count"] + # The view must not classify by HRESULT, exception text or public type. + assert caught.value is failure + + def test_other_read_errors_propagate(): properties = property_set() with payloadless_box() as (owner, box): diff --git a/bindings/py/tests/test_object_values.py b/bindings/py/tests/test_object_values.py index c43edd41..e9044d42 100644 --- a/bindings/py/tests/test_object_values.py +++ b/bindings/py/tests/test_object_values.py @@ -370,8 +370,10 @@ def test_payloadless_property_types_still_raise(type_value, message): for preserve_type in (False, True): with pytest.raises(OSError) as caught: unbox_object(raw, preserve_type=preserve_type) + assert type(caught.value) is OSError assert caught.value.winerror == E_NOTIMPL assert str(caught.value.strerror) == message + assert caught.value._dynwinrt_unsupported_property_type == int(type_value) # The same rules apply to InspectableArray elements. with pytest.raises(OSError, match="Unsupported WinRT IPropertyValue type"): unbox_object(to_winrt_object(values.InspectableArray([1, raw]))) diff --git a/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index b6725def..bf3e6235 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -481,6 +481,16 @@ { "kind": "object_value_view_maps", "member": "insert" } ] }, + { + "id": "object_value_view_guid_map", + "namespace": "Windows.Media.MediaProperties", + "class": "MediaPropertySet", + "langs": ["py"], + "instantiate": { "kind": "activate" }, + "checks": [ + { "kind": "object_value_view_guid_map", "member": "insert" } + ] + }, { "id": "issue_69_device_information_async_collection", "namespace": "Windows.Devices.Enumeration", diff --git a/tests/e2e/e2e_specs.schema.json b/tests/e2e/e2e_specs.schema.json index b3038f34..da3715d0 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -106,6 +106,7 @@ "object_value_storage_properties", "object_value_device_properties", "object_value_view_maps", + "object_value_view_guid_map", "object_value_view_storage_properties", "object_value_view_device_properties" ] diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index ddb39532..697cd987 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -1917,6 +1917,22 @@ def stored_type(raw): else: cr['pass'] = True + elif kind == 'object_value_view_guid_map': + from uuid import UUID + + view = dw.values.object_value_view(obj) + key = UUID(int=7) + view[key] = dw.values.UInt32(9) + converted = dict(view) + if ( + type(view) is not dw.values.MutableObjectValueView + or view.raw is not obj + or converted != {key: 9} + ): + cr['error'] = f'the Guid-keyed Object map view read {converted!r}' + else: + cr['pass'] = True + elif kind == 'object_value_view_device_properties': devices = await getattr(cls, member)() checked = 0 diff --git a/tests/e2e/typecheck/python_generated_api.py b/tests/e2e/typecheck/python_generated_api.py index 0aed5fb8..d4200a10 100644 --- a/tests/e2e/typecheck/python_generated_api.py +++ b/tests/e2e/typecheck/python_generated_api.py @@ -2,8 +2,9 @@ # Licensed under the MIT License. import asyncio -from collections.abc import Coroutine, Generator, Sequence +from collections.abc import Coroutine, Generator, MutableMapping, Sequence from typing import Any, Awaitable, Dict, List, Tuple, assert_type +from uuid import UUID from dynwinrt import ( DynWinRTArray, @@ -36,6 +37,7 @@ ValueSet, ) from python_bindings.windows.globalization import Calendar +from python_bindings.windows.media.media_properties import MediaPropertySet from python_bindings.windows.storage.streams import ( Buffer as WinRTBuffer, DataWriter, @@ -172,6 +174,8 @@ def check_object_value_views( value_set: ValueSet, device: DeviceInformation, strings: StringMap, + media: MediaPropertySet, + integers: MutableMapping[int, DynWinRTValue | None], ) -> None: view: MutableObjectValueView[str] = object_value_view(properties) view["count"] = 5 @@ -188,12 +192,17 @@ def check_object_value_views( device_properties = device.properties assert device_properties is not None read_only: ObjectValueView[str] = object_value_view(device_properties) + guid_view: MutableObjectValueView[UUID] = object_value_view(media) + guid_view[UUID(int=1)] = 5 snapshot: Dict[str, WinRTObjectValue] = dict(read_only) read_only["count"] = 5 # type: ignore[index] object_value_view(strings) # type: ignore[arg-type] + object_value_view(integers) # type: ignore[type-var] + invalid_int_view: ObjectValueView[int] # type: ignore[type-var] _: Tuple[ WinRTObjectValue, DynWinRTValue | None, MutableObjectValueView[str], + MutableObjectValueView[UUID], Dict[str, WinRTObjectValue], - ] = (count, native, exact, snapshot) + ] = (count, native, exact, guid_view, snapshot) From dd3f463d1572909395983de127c45f05aca380a6 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 28 Sep 2026 12:59:43 +0800 Subject: [PATCH 5/6] Validate Object map dispatch metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 12 +- bindings/py/python/dynwinrt/values.py | 54 ++++--- bindings/py/tests/test_object_value_view.py | 145 ++++++++++++++++-- .../codegen/winrt/python/generator/class.rs | 81 ++++++++++ 4 files changed, 253 insertions(+), 39 deletions(-) diff --git a/bindings/py/README.md b/bindings/py/README.md index 029e9d1f..17694d6d 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -326,11 +326,13 @@ box = view.raw["count"] # the native DynWinRTValue box `DynWinRTValue` objects. Use it to keep a box's COM identity: with `preserve_type=True`, a value that is read and written back keeps its `PropertyType`, but it is a new box. -- `QueryInterface` confirms both the map's value type and that the wrapper - actually dispatches through that `IMap`/`IMapView` interface. A wrapper for - another map on the same COM identity is rejected rather than dispatching - through the wrong vtable. Other maps, such as `StringMap` or `JsonObject`, - raise `TypeError`; the type stubs reject them too. +- The generated wrapper declares the exact `IMap`/`IMapView` interface used by + its mapping methods, and `QueryInterface` confirms support for that IID. A + wrapper for another map on the same COM identity is rejected rather than + dispatching through the wrong vtable. Other maps, such as `StringMap` or + `JsonObject`, raise `TypeError`; the type stubs reject them too. Runtime-class + wrappers generated without this declaration fail closed; reproject them with + `value.as_interface(IMap_String_Object)` (or the Guid/read-only equivalent). - Generated `IPropertySet` wrappers, such as `ApplicationDataContainer.values`, are not Python mappings. Pass `container.values.as_interface(IMap_String_Object)` instead. diff --git a/bindings/py/python/dynwinrt/values.py b/bindings/py/python/dynwinrt/values.py index b3ec306c..d71dc675 100644 --- a/bindings/py/python/dynwinrt/values.py +++ b/bindings/py/python/dynwinrt/values.py @@ -652,44 +652,56 @@ def _queried_object_map(native: DynWinRTValue, generic: str) -> str | None: return None -def _generated_map_dispatch(mapping: object, native: DynWinRTValue) -> DynWinRTValue: - """The interface pointer codegen uses for this wrapper's map operations.""" +def _generated_map_dispatch( + mapping: object, native: DynWinRTValue +) -> tuple[DynWinRTValue, WinGUID]: + """The interface pointer and IID codegen uses for this wrapper's map operations.""" wrapper_type = type(mapping) - if getattr(wrapper_type, "_dynwinrt_runtime_class_type", False): - dispatch = getattr(mapping, "_collection_obj", native) - elif getattr(wrapper_type, "_dynwinrt_interface_type", False): + declarations = wrapper_type.__dict__ + if declarations.get("_dynwinrt_runtime_class_type") is True: + declaration = declarations.get("_dynwinrt_map_dispatch") + if not ( + isinstance(declaration, tuple) + and len(declaration) == 2 + and isinstance(declaration[0], WinGUID) + and declaration[1] in ("_obj", "_collection_obj") + ): + raise TypeError( + f"{wrapper_type.__qualname__} does not declare a valid generated map projection" + ) + declared_iid, dispatch_name = declaration + dispatch = native if dispatch_name == "_obj" else getattr(mapping, dispatch_name, None) + elif declarations.get("_dynwinrt_interface_type") is True: + declared_iid = declarations.get("_dynwinrt_interface_iid") dispatch = native else: raise TypeError( "object_value_view() requires a generated WinRT map wrapper, " f"not {wrapper_type.__qualname__}" ) - if not isinstance(dispatch, DynWinRTValue): + if not isinstance(declared_iid, WinGUID) or not isinstance(dispatch, DynWinRTValue): raise TypeError( f"{wrapper_type.__qualname__} has an invalid generated map projection" ) - return dispatch - + return dispatch, declared_iid -def _projected_object_map(dispatch: DynWinRTValue, generic: str) -> str | None: - """The Object map IID already projected by ``dispatch``, if any. - QueryInterface is reflexive: asking an interface pointer for its own IID - returns the same physical pointer. Comparing ``as_raw`` therefore proves - that the generated wrapper dispatches through the Object-valued map, not - merely that the same COM identity implements one. - """ - dispatch_raw = dispatch.as_raw() +def _projected_object_map( + dispatch: DynWinRTValue, declared_iid: WinGUID, generic: str +) -> str | None: + """The declared Object map interface supported by ``dispatch``, if any.""" + declared = declared_iid.to_string().lower() for name, iid in _object_maps(generic): + if iid.to_string().lower() != declared: + continue try: interface = dispatch.cast(iid) except OSError as error: if error.winerror == _E_NOINTERFACE: - continue + return None raise try: - if interface.as_raw() == dispatch_raw: - return name + return name finally: interface.release() return None @@ -733,8 +745,8 @@ def _check_object_map(mapping: object, *, mutable: bool) -> None: "ObjectValueView" ) generic = "IMap" if writable else "IMapView" - dispatch = _generated_map_dispatch(mapping, native) - if _projected_object_map(dispatch, generic) is not None: + dispatch, declared_iid = _generated_map_dispatch(mapping, native) + if _projected_object_map(dispatch, declared_iid, generic) is not None: return implemented = _queried_object_map(native, generic) if implemented is not None: diff --git a/bindings/py/tests/test_object_value_view.py b/bindings/py/tests/test_object_value_view.py index 74281d04..a926c6bd 100644 --- a/bindings/py/tests/test_object_value_view.py +++ b/bindings/py/tests/test_object_value_view.py @@ -86,6 +86,15 @@ def __init__(self, typ, to_native, from_native): lambda value: UUID(value.to_guid().to_string()), ) INT32_KEYS = Keys(T.i32_type(), DynWinRTValue.from_i32, lambda value: value.to_number()) +IMAP_STRING_OBJECT = T.parameterized(IMAP, [STRING_KEYS.type, OBJECT]).iid() +IMAP_VIEW_STRING_OBJECT = T.parameterized(IMAP_VIEW, [STRING_KEYS.type, OBJECT]).iid() +IMAP_GUID_OBJECT = T.parameterized(IMAP, [GUID_KEYS.type, OBJECT]).iid() +IMAP_VIEW_GUID_OBJECT = T.parameterized(IMAP_VIEW, [GUID_KEYS.type, OBJECT]).iid() +IMAP_STRING_STRING = T.parameterized(IMAP, [STRING_KEYS.type, T.hstring()]).iid() +IMAP_VIEW_STRING_STRING = T.parameterized(IMAP_VIEW, [STRING_KEYS.type, T.hstring()]).iid() +IMAP_INT32_OBJECT = T.parameterized(IMAP, [INT32_KEYS.type, OBJECT]).iid() +JSON_VALUE = T.interface(WinGUID.parse("a3219ecb-f0b3-4dcd-beee-19d48cd3ed1e")) +IMAP_STRING_JSON = T.parameterized(IMAP, [STRING_KEYS.type, JSON_VALUE]).iid() def register(generic, keys, value_type, methods): @@ -103,7 +112,6 @@ def register(generic, keys, value_type, methods): class GeneratedStyle: - _dynwinrt_interface_type = True GENERIC = IMAP_VIEW def __init__(self, native, keys=STRING_KEYS, value_type=OBJECT): @@ -155,11 +163,17 @@ def _iter_pairs(self): class GeneratedStyleMapView(GeneratedStyle, _WinRTMappingMixin): """Built like a generated ``IMapView`` wrapper.""" + _dynwinrt_interface_type = True + _dynwinrt_interface_iid = IMAP_VIEW_STRING_OBJECT + class GeneratedStyleMap(GeneratedStyle, _WinRTMutableMappingMixin): """Built like a generated ``IMap`` wrapper.""" GENERIC = IMAP + VIEW_TYPE = GeneratedStyleMapView + _dynwinrt_interface_type = True + _dynwinrt_interface_iid = IMAP_STRING_OBJECT @staticmethod def methods(key, value): @@ -172,7 +186,7 @@ def methods(key, value): def get_view(self): native = self._interface.method(9).invoke(self._map_obj, []) - return GeneratedStyleMapView(native, self._keys, self._value_type) + return self.VIEW_TYPE(native) def insert(self, key, value): native = getattr(value, "_obj", value) @@ -192,6 +206,7 @@ class GeneratedStyleRuntimeMap(GeneratedStyleMap): _dynwinrt_interface_type = False _dynwinrt_runtime_class_type = True + _dynwinrt_map_dispatch = (IMAP_STRING_OBJECT, "_collection_obj") def __init__(self, native, keys=STRING_KEYS, value_type=OBJECT): super().__init__(native, keys, value_type) @@ -206,6 +221,64 @@ def _map_obj(self): return self._collection_obj +class GeneratedStyleDefaultRuntimeMap(GeneratedStyleMap): + """A runtime-class wrapper whose collection interface is its default.""" + + _dynwinrt_interface_type = False + _dynwinrt_runtime_class_type = True + _dynwinrt_map_dispatch = (IMAP_STRING_OBJECT, "_obj") + + +class GeneratedStyleStringMapView(GeneratedStyleMapView): + _dynwinrt_interface_type = True + _dynwinrt_interface_iid = IMAP_VIEW_STRING_STRING + + def __init__(self, native): + super().__init__(native, value_type=T.hstring()) + + +class GeneratedStyleStringMap(GeneratedStyleMap): + VIEW_TYPE = GeneratedStyleStringMapView + _dynwinrt_interface_type = True + _dynwinrt_interface_iid = IMAP_STRING_STRING + + def __init__(self, native): + super().__init__(native, value_type=T.hstring()) + + +class GeneratedStyleGuidObjectMapView(GeneratedStyleMapView): + _dynwinrt_interface_type = True + _dynwinrt_interface_iid = IMAP_VIEW_GUID_OBJECT + + def __init__(self, native): + super().__init__(native, keys=GUID_KEYS) + + +class GeneratedStyleGuidObjectMap(GeneratedStyleMap): + VIEW_TYPE = GeneratedStyleGuidObjectMapView + _dynwinrt_interface_type = True + _dynwinrt_interface_iid = IMAP_GUID_OBJECT + + def __init__(self, native): + super().__init__(native, keys=GUID_KEYS) + + +class GeneratedStyleInt32ObjectMap(GeneratedStyleMap): + _dynwinrt_interface_type = True + _dynwinrt_interface_iid = IMAP_INT32_OBJECT + + def __init__(self, native): + super().__init__(native, keys=INT32_KEYS) + + +class GeneratedStyleJsonMap(GeneratedStyleMap): + _dynwinrt_interface_type = True + _dynwinrt_interface_iid = IMAP_STRING_JSON + + def __init__(self, native): + super().__init__(native, value_type=JSON_VALUE) + + class GeneratedStyleInterface: """Built like a generated interface wrapper without a mapping protocol (IPropertySet).""" @@ -374,7 +447,7 @@ def test_object_value_view_picks_the_view_for_the_map_protocol(): def test_multi_map_identity_uses_only_the_wrappers_projected_map(): with object_and_string_maps() as (raw, stores, calls): object_map = GeneratedStyleMap(raw) - string_map = GeneratedStyleMap(raw, value_type=T.hstring()) + string_map = GeneratedStyleStringMap(raw) view = object_value_view(object_map) assert view.raw is object_map @@ -404,6 +477,15 @@ def test_multi_map_identity_uses_only_the_wrappers_projected_map(): assert stores[1] == {"shared": "wrong map"} assert calls[-3:] == [(0, 8), (0, 6), (0, 10)] + default_runtime_map = GeneratedStyleDefaultRuntimeMap(raw) + default_runtime_view = object_value_view(default_runtime_map) + assert default_runtime_view.raw is default_runtime_map + assert default_runtime_view["shared"] == 7 + default_runtime_view["default"] = "default pointer" + assert unbox_object(stores[0]["default"]) == "default pointer" + assert stores[1] == {"shared": "wrong map"} + assert calls[-3:] == [(0, 8), (0, 6), (0, 10)] + assert ( raw.identity_raw() == object_map._obj.identity_raw() @@ -413,6 +495,35 @@ def test_multi_map_identity_uses_only_the_wrappers_projected_map(): ) +def test_same_iid_query_does_not_require_physical_pointer_identity(): + class TearOff: + released = False + + def as_raw(self): + return 2 + + def release(self): + self.released = True + + class Dispatch: + def __init__(self): + self.queried = TearOff() + + def as_raw(self): + return 1 + + def cast(self, iid): + assert iid.to_string().lower() == IMAP_STRING_OBJECT.to_string().lower() + return self.queried + + dispatch = Dispatch() + assert ( + values._projected_object_map(dispatch, IMAP_STRING_OBJECT, "IMap") + == "IMap_String_Object" + ) + assert dispatch.queried.released + + def test_the_view_types_are_public_and_generic(): names = ("WinRTObjectValue", "ObjectValueView", "MutableObjectValueView", "object_value_view") for name in names: @@ -742,8 +853,8 @@ def test_read_only_views_of_map_views(): def test_guid_keyed_maps(): - media = GeneratedStyleMap( - activate("Windows.Media.MediaProperties.MediaPropertySet"), GUID_KEYS + media = GeneratedStyleGuidObjectMap( + activate("Windows.Media.MediaProperties.MediaPropertySet") ) view = object_value_view(media) key = UUID(int=7) @@ -768,29 +879,37 @@ def test_create_map_maps(): def test_maps_without_object_values_are_rejected(): message = "is not a WinRT map with Object values" - string_map = GeneratedStyleMap( - activate("Windows.Foundation.Collections.StringMap"), value_type=T.hstring() + string_map = GeneratedStyleStringMap( + activate("Windows.Foundation.Collections.StringMap") ) - with pytest.raises(TypeError, match=f"GeneratedStyleMap {message}.*String or Guid keys"): + with pytest.raises(TypeError, match=f"GeneratedStyleStringMap {message}.*String or Guid keys"): object_value_view(string_map) with pytest.raises(TypeError, match=message): ObjectValueView(string_map.get_view()) - json_value = T.interface(WinGUID.parse("a3219ecb-f0b3-4dcd-beee-19d48cd3ed1e")) - json_object = GeneratedStyleMap( - activate("Windows.Data.Json.JsonObject"), value_type=json_value - ) + json_object = GeneratedStyleJsonMap(activate("Windows.Data.Json.JsonObject")) with pytest.raises(TypeError, match=message): object_value_view(json_object) int32_keys = DynWinRTValue.create_map( [DynWinRTValue.from_i32(1)], [to_winrt_object(5)], T.i32_type(), OBJECT ) with pytest.raises(TypeError, match=message): - object_value_view(GeneratedStyleMap(int32_keys, INT32_KEYS)) + object_value_view(GeneratedStyleInt32ObjectMap(int32_keys)) def test_values_that_are_not_generated_map_wrappers_are_rejected(): properties = property_set() requires = "requires a generated WinRT map wrapper" + + class CustomMap(GeneratedStyleMap): + pass + + class UndeclaredRuntimeMap(GeneratedStyleMap): + _dynwinrt_runtime_class_type = True + + with pytest.raises(TypeError, match=f"{requires}.*not .*CustomMap"): + object_value_view(CustomMap(properties._obj)) + with pytest.raises(TypeError, match="does not declare a valid generated map projection"): + object_value_view(UndeclaredRuntimeMap(properties._obj)) with pytest.raises(TypeError, match=f"{requires}.*not dict$"): object_value_view({"count": to_winrt_object(5)}) with pytest.raises(TypeError, match=r"not DynWinRTValue; .*IMap_String_Object\.from_value"): 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..03630cc3 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/python/generator/class.rs @@ -331,6 +331,21 @@ pub fn generate_class( } else if native_projectable { out.push_str(" _dynwinrt_projectable_class_type = True\n"); } + if matches!( + collection_kind, + Some(CollectionKind::Mapping | CollectionKind::MutableMapping) + ) && let Some(collection_iface) = collection_iface + { + let symbol = interface_symbol(context, collection_iface); + let dispatch = if collection_uses_default { + "_obj" + } else { + "_collection_obj" + }; + out.push_str(&format!( + " _dynwinrt_map_dispatch = (IID_{symbol}, '{dispatch}')\n" + )); + } out.push_str(&generate_python_constructor( context, @@ -1715,6 +1730,72 @@ mod tests { assert!(!code.contains("type(self)._create_6(_bound[0])"), "{code}"); } + fn map_interface(name: &str, value: TypeMeta) -> InterfaceMeta { + InterfaceMeta { + name: name.into(), + namespace: "Windows.Foundation.Collections".into(), + iid: "11111111-1111-1111-1111-111111111111".into(), + generic_piid: Some(crate::codegen::winrt::python::collections::IMAP_PIID.into()), + generic_name: Some("IMap`2".into()), + generic_args: vec![TypeMeta::String, value], + ..Default::default() + } + } + + #[test] + fn map_runtime_class_declares_the_codegen_dispatch_interface() { + let object_map = map_interface("IMap_String_Object", TypeMeta::Object); + let default_map = ClassMeta { + name: "DefaultMap".into(), + namespace: "Contoso".into(), + full_name: "Contoso.DefaultMap".into(), + default_interface: Some(object_map.clone()), + is_referenced_as_value: true, + ..Default::default() + }; + let code = generate_class( + &PythonProjectionContext::default(), + &default_map, + &HashSet::new(), + ); + assert!( + code.contains("_dynwinrt_map_dispatch = (IID_IMap_String_Object, '_obj')"), + "{code}" + ); + assert!( + !code.contains("self._collection_obj ="), + "default collection interface must dispatch through _obj:\n{code}" + ); + + let required_map = ClassMeta { + name: "RequiredMap".into(), + namespace: "Contoso".into(), + full_name: "Contoso.RequiredMap".into(), + default_interface: Some(InterfaceMeta { + name: "IRequiredMap".into(), + namespace: "Contoso".into(), + iid: "22222222-2222-2222-2222-222222222222".into(), + ..Default::default() + }), + required_interfaces: vec![object_map], + is_referenced_as_value: true, + ..Default::default() + }; + let code = generate_class( + &PythonProjectionContext::default(), + &required_map, + &HashSet::new(), + ); + assert!( + code.contains("_dynwinrt_map_dispatch = (IID_IMap_String_Object, '_collection_obj')"), + "{code}" + ); + assert!( + code.contains("self._collection_obj = obj.cast(IID_IMap_String_Object)"), + "{code}" + ); + } + fn run_python(script: &str) -> String { fn invoke( program: &str, From a1b492dd3ef81b7512e223c72e40463b6fabc491 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 28 Sep 2026 18:38:27 +0800 Subject: [PATCH 6/6] Regress released Object map views with updated runtime Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/py/README.md | 4 +- bindings/py/tests/test_object_value_view.py | 96 ++++++++++++++++++--- tests/e2e/runners/py_runner.py | 84 +++++++++++++++--- 3 files changed, 159 insertions(+), 25 deletions(-) diff --git a/bindings/py/README.md b/bindings/py/README.md index f9adbbda..de5db552 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -310,7 +310,9 @@ box = view.raw["count"] # the native DynWinRTValue box - The view is live: every operation goes to the map, and the view holds no WinRT reference of its own. An `IMap` gets a `MutableObjectValueView`, a `collections.abc.MutableMapping`; an `IMapView` gets a read-only - `ObjectValueView`, a `Mapping`. + `ObjectValueView`, a `Mapping`. Releasing the underlying generated wrapper + makes native reads and writes through the view raise the usual released-object + error; a live WinRT null entry still reads as `None`. - Reads return `None` for WinRT null, a runtime object that is not a box as its `DynWinRTValue`, and `unbox_object(value, preserve_type=preserve_type)` for a box. Where `unbox_object` raises for a box without a Python form, the diff --git a/bindings/py/tests/test_object_value_view.py b/bindings/py/tests/test_object_value_view.py index a926c6bd..8e7aaea3 100644 --- a/bindings/py/tests/test_object_value_view.py +++ b/bindings/py/tests/test_object_value_view.py @@ -54,6 +54,11 @@ IURI_FACTORY = WinGUID.parse("44a9796f-723e-4fdf-a218-033e75b0c084") E_NOTIMPL = -2147467263 UTC = timezone.utc +RELEASED_REASON = ( + "has been released (its projected_lifetime_scope() exited, or " + "release_projected() / DynWinRTValue.release() was called) and can no longer " + "be used." +) @pytest.fixture(scope="module", autouse=True) @@ -929,20 +934,87 @@ class UndeclaredRuntimeMap(GeneratedStyleMap): object_value_view(properties, preserve_type=1) -def test_released_maps_raise_on_reads_and_writes(): - properties = property_set() - properties["count"] = to_winrt_object(5) +@pytest.mark.parametrize("class_name", ["PropertySet", "ValueSet"]) +def test_released_maps_fail_through_native_and_mapping_operations(class_name): + properties = GeneratedStyleMap(activate(f"Windows.Foundation.Collections.{class_name}")) view = object_value_view(properties) + view["count"] = 5 + view["empty"] = None + read_only = object_value_view(properties.get_view()) + assert view.raw is properties + assert view["empty"] is None and properties["empty"] is None + assert read_only["empty"] is None and dict(read_only) == {"count": 5, "empty": None} + assert view.setdefault("empty", 6) is None + assert view.pop("absent", 7) == 7 + release_projected(properties) + assert view.raw is properties and read_only["count"] == 5 + operations = [ + ("view creation", lambda: object_value_view(properties)), + ("raw read", lambda: view.raw["count"]), + ("raw null read", lambda: view.raw["empty"]), + ("raw write", lambda: view.raw.__setitem__("added", to_winrt_object(6))), + ("read", lambda: view["count"]), + ("null read", lambda: view["empty"]), + ("write", lambda: view.__setitem__("added", 6)), + ("delete", lambda: view.__delitem__("count")), + ("contains", lambda: "count" in view), + ("length", lambda: len(view)), + ("iteration", lambda: next(iter(view))), + ("get", lambda: view.get("absent", 7)), + ("keys", lambda: list(view.keys())), + ("items", lambda: list(view.items())), + ("values", lambda: list(view.values())), + ("dict", lambda: dict(view)), + ("equality", lambda: view == {"count": 5, "empty": None}), + ("update", lambda: view.update(added=7)), + ("setdefault", lambda: view.setdefault("added", 7)), + ("pop", lambda: view.pop("absent", 7)), + ("popitem", lambda: view.popitem()), + ("clear", lambda: view.clear()), + ] + for label, operation in operations: + with pytest.raises(RuntimeError) as caught: + operation() + assert str(caught.value) == f"This WinRT object {RELEASED_REASON}", label + + native_read_only = read_only.raw + release_projected(native_read_only) + assert read_only.raw is native_read_only + for label, operation in [ + ("read-only creation", lambda: object_value_view(native_read_only)), + ("read-only raw", lambda: read_only.raw["count"]), + ("read-only null", lambda: read_only["empty"]), + ("read-only get", lambda: read_only.get("absent", 7)), + ("read-only items", lambda: list(read_only.items())), + ]: + with pytest.raises(RuntimeError) as caught: + operation() + assert str(caught.value) == f"This WinRT object {RELEASED_REASON}", label + + +def test_view_writes_reject_released_values_but_keep_live_null(): + view = object_value_view(property_set()) + view["empty"] = None + released = to_winrt_object(5) + released.release() + assert view.setdefault("empty", released) is None + operations = [ - lambda: view["count"], - lambda: view.__setitem__("count", 6), - lambda: view.__delitem__("count"), - lambda: len(view), - lambda: list(view), - lambda: "count" in view, - lambda: object_value_view(properties), + ("item assignment", lambda: view.__setitem__("bad", released), "argument 0"), + ("update", lambda: view.update(bad=released), "argument 0"), + ("setdefault", lambda: view.setdefault("bad", released), "argument 0"), + ( + "typed InspectableArray", + lambda: view.__setitem__("bad", values.InspectableArray([None, released])), + "element 1", + ), ] - for operation in operations: - with pytest.raises(RuntimeError): + for label, operation, slot in operations: + with pytest.raises(RuntimeError) as caught: operation() + assert ( + str(caught.value) + == f"This WinRT object ({slot} of to_winrt_object()) {RELEASED_REASON}" + ), label + assert dict(view) == {"empty": None} and view.raw["empty"] is None diff --git a/tests/e2e/runners/py_runner.py b/tests/e2e/runners/py_runner.py index 3cb28407..7a003bbe 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -23,6 +23,11 @@ _WINRT_UINT_SUFFIXES = {'int8', 'int16', 'int32', 'int64'} +_RELEASED_REASON = ( + 'has been released (its projected_lifetime_scope() exited, or ' + 'release_projected() / DynWinRTValue.release() was called) and ' + 'can no longer be used.' +) def collapse_winrt_uint_tokens(name: str) -> str: @@ -369,11 +374,7 @@ async def run_check( cr['pass'] = True elif kind == 'released_projection_error': - reason = ( - 'has been released (its projected_lifetime_scope() exited, or ' - 'release_projected() / DynWinRTValue.release() was called) and ' - 'can no longer be used.' - ) + reason = _RELEASED_REASON receiver = f'This WinRT object {reason}' args = [literal_arg(a) for a in check.get('args', [])] with dw.projected_lifetime_scope(): @@ -585,11 +586,7 @@ async def run_check( # A released wrapper is neither passed nor unboxed as a null # reference. Generated struct IReference field setters share the # module's unbox helper. - reason = ( - 'has been released (its projected_lifetime_scope() exited, or ' - 'release_projected() / DynWinRTValue.release() was called) and ' - 'can no longer be used.' - ) + reason = _RELEASED_REASON released_box = factory(check['compatibility_value']) released = reference_cls.from_value(getattr(released_box, '_obj', released_box)) dw.release_projected(released) @@ -1995,8 +1992,71 @@ def stored_type(raw): through['through'] = 7 if view['through'] != 7: cr['error'] = 'the IPropertySet workaround did not reach the map' - else: - cr['pass'] = True + return cr + + expected_release = f'This WinRT object {_RELEASED_REASON}' + for map_class in (cls, generated_type(pkg_name, 'ValueSet')): + label = map_class.__name__ + released_map = map_class() + released_view = v.object_value_view(released_map) + released_view['count'] = 5 + released_view['empty'] = None + native_read_only = released_map.get_view() + read_only_view = v.object_value_view(native_read_only) + if ( + released_view['empty'] is not None + or released_map['empty'] is not None + or read_only_view['empty'] is not None + ): + cr['error'] = f'{label}: a live WinRT null was not preserved' + return cr + + dw.release_projected(released_map) + if read_only_view['count'] != 5: + cr['error'] = f'{label}: releasing the map invalidated its separate IMapView' + return cr + operations = ( + ('view creation', lambda: v.object_value_view(released_map)), + ('raw read', lambda: released_view.raw['empty']), + ( + 'raw write', + lambda: released_view.raw.__setitem__('raw', dw.to_winrt_object(6)), + ), + ('read', lambda: released_view['empty']), + ('write', lambda: released_view.__setitem__('new', 7)), + ('get', lambda: released_view.get('missing', 9)), + ('items', lambda: list(released_view.items())), + ('update', lambda: released_view.update(new=7)), + ('setdefault', lambda: released_view.setdefault('new', 7)), + ('pop', lambda: released_view.pop('missing', 9)), + ) + for operation, action in operations: + try: + action() + except RuntimeError as error: + if str(error) == expected_release: + continue + cr['error'] = f'{label} {operation}: unexpected error {error!s}' + return cr + cr['error'] = f'{label} {operation}: a released map was accepted' + return cr + + dw.release_projected(native_read_only) + for operation, action in ( + ('IMapView creation', lambda: v.object_value_view(native_read_only)), + ('IMapView read', lambda: read_only_view['empty']), + ('IMapView values', lambda: list(read_only_view.values())), + ): + try: + action() + except RuntimeError as error: + if str(error) == expected_release: + continue + cr['error'] = f'{label} {operation}: unexpected error {error!s}' + return cr + cr['error'] = f'{label} {operation}: a released map was accepted' + return cr + cr['pass'] = True elif kind == 'object_value_view_storage_properties': from datetime import datetime