From 94a5a416c217cc47760a7c4edf5c9cf01f3e811d Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 18:32:51 +0800 Subject: [PATCH 1/3] Add explicit WinRT Object boxing and type-preserving unboxing to Python Generated Object positions stay native (IInspectable); applications now convert boxed values explicitly at the boundaries that want value semantics. Core: PropertyValueData models every PropertyType that has a payload (37 of 41), unbox_property_value reports payload-less boxes as Unsupported, and box_property_value creates system PropertyValue boxes. JavaScript keeps its exact behavior and messages through a compile-only adapter. Python: - unbox_object additionally supports DateTime, TimeSpan, Point, Size, Rect, their arrays and InspectableArray (elements unboxed by the same rules); already-supported types return the same values, and payload-less boxes still raise OSError. - unbox_object(raw, preserve_type=True) returns dynwinrt.values tags and typed arrays, so to_winrt_object restores the exact PropertyType and value. - to_winrt_object(value, property_type=None) boxes only unambiguous values: a plain int is Int32 only, lists must be homogeneous, and enum members, empty or mixed lists and InspectableArray need an explicit type. - The tags, typed arrays, Point/Size/Rect and PropertyType live in the new dynwinrt.values submodule, outside the top-level namespace. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 1 + bindings/js/__test__/index.spec.ts | 5 +- bindings/js/src/property_value.rs | 25 + bindings/py/README.md | 78 +- bindings/py/dynwinrt.pyi | 46 +- bindings/py/python/dynwinrt/__init__.py | 1 + bindings/py/python/dynwinrt/values.py | 553 +++++++++ bindings/py/src/lib.rs | 5 +- bindings/py/src/object_value.rs | 1505 +++++++++++++++++++++++ bindings/py/src/runtime.rs | 95 +- bindings/py/tests/test_e2e_winrt.py | 8 +- bindings/py/tests/test_object_values.py | 756 ++++++++++++ crates/dynwinrt/src/lib.rs | 2 +- crates/dynwinrt/src/property_value.rs | 473 +++++-- docs/status/PYTHON_CHECKLIST.md | 4 + 15 files changed, 3349 insertions(+), 208 deletions(-) create mode 100644 bindings/py/python/dynwinrt/values.py create mode 100644 bindings/py/src/object_value.rs create mode 100644 bindings/py/tests/test_object_values.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7ad51e44..7201ced7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -125,6 +125,7 @@ output, runtime behavior, and the `@microsoft/dynwinrt` root API. - GUID: `WinGUID.parse('...')` - 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) ### Common Issues - `test_initialize` is `#[ignore]` — requires `WINAPPSDK_BOOTSTRAP_DLL_PATH` env var diff --git a/bindings/js/__test__/index.spec.ts b/bindings/js/__test__/index.spec.ts index f9e7b134..f6d97b02 100644 --- a/bindings/js/__test__/index.spec.ts +++ b/bindings/js/__test__/index.spec.ts @@ -1486,7 +1486,10 @@ test('explicitly unbox WinRT property values without changing raw objects', (t) const dateTime = DynWinRtStruct.create(DynWinRtType.structType('Windows.Foundation.DateTime', [DynWinRtType.i64()])) dateTime.setI64(0, 0n) const unsupported = staticsType.methodByName('CreateDateTime').invoke(factory, [dateTime.toValue()]) - t.throws(() => unboxObject(unsupported), { message: /Unsupported WinRT IPropertyValue type/ }) + // The core now models DateTime; JavaScript keeps its exact pre-existing error. + t.throws(() => unboxObject(unsupported), { + message: '0x80004001: Unsupported WinRT IPropertyValue type: 14 (0x80004001)', + }) const keyType = DynWinRtType.hstring() const propertyMap = DynWinRtValue.createMap( diff --git a/bindings/js/src/property_value.rs b/bindings/js/src/property_value.rs index 86457739..54b9a8b6 100644 --- a/bindings/js/src/property_value.rs +++ b/bindings/js/src/property_value.rs @@ -55,6 +55,17 @@ fn null_unknown<'env>(env: Env) -> napi::Result> { unsafe { Unknown::from_napi_value(env.raw(), value) } } +/// JavaScript does not project these PropertyTypes yet. Report them with the +/// exact error the core raised before it modeled every PropertyType, so the +/// JavaScript contract stays unchanged. +fn unsupported_property_type(property_type: windows::Foundation::PropertyType) -> napi::Error { + let error = dynwinrt::Error::WindowsError(windows::core::Error::new( + windows::core::HRESULT(0x80004001_u32 as i32), + format!("Unsupported WinRT IPropertyValue type: {}", property_type.0), + )); + napi::Error::from_reason(error.message()) +} + fn property_value_to_javascript<'env>( env: Env, value: dynwinrt::PropertyValueData, @@ -104,6 +115,17 @@ fn property_value_to_javascript<'env>( .map(property_value_guid) .collect::>(), ), + value @ (PropertyValueData::DateTime(_) + | PropertyValueData::TimeSpan(_) + | PropertyValueData::Point(_) + | PropertyValueData::Size(_) + | PropertyValueData::Rect(_) + | PropertyValueData::InspectableArray(_) + | PropertyValueData::DateTimeArray(_) + | PropertyValueData::TimeSpanArray(_) + | PropertyValueData::PointArray(_) + | PropertyValueData::SizeArray(_) + | PropertyValueData::RectArray(_)) => Err(unsupported_property_type(value.property_type())), } } @@ -129,6 +151,9 @@ pub fn unbox_object<'env>(env: Env, value: Unknown<'env>) -> napi::Result null_unknown(env), dynwinrt::PropertyValueUnboxResult::NotPropertyValue => Ok(value), + dynwinrt::PropertyValueUnboxResult::Unsupported(property_type) => { + Err(unsupported_property_type(property_type)) + } dynwinrt::PropertyValueUnboxResult::Value(value) => { property_value_to_javascript(env, value) } diff --git a/bindings/py/README.md b/bindings/py/README.md index 2a1cb72c..9d38d944 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -207,27 +207,79 @@ produce a wrapper unless the input actually implements that default interface. Incompatible types raise the ordinary WinRT `OSError`. Static-only metadata classes with no instance surface are not projection targets. -### Explicit boxed-value unboxing +### Explicit boxing and unboxing of `Object` values -Generic WinRT `Object`/`IInspectable` results remain raw `DynWinRTValue` -instances. Use `unbox_object()` only where the application expects a boxed -`Windows.Foundation.IPropertyValue`, such as values from -`DeviceInformation.properties`: +Generic WinRT `Object`/`IInspectable` results and parameters remain raw +`DynWinRTValue` instances: an `Object` may be any runtime object, not only a +boxed value. Convert explicitly where the application expects a boxed +`Windows.Foundation.IPropertyValue`, such as the values of +`DeviceInformation.properties` or a `PropertySet`: ```python -from dynwinrt import unbox_object +from datetime import datetime, timezone + +from dynwinrt import to_winrt_object, unbox_object +from dynwinrt.values import PropertyType, UInt32 raw = device_information.properties["System.Devices.DeviceInstanceId"] instance_id = unbox_object(raw) + +properties.insert("count", to_winrt_object(5)) # Int32 +properties.insert("port", to_winrt_object(UInt32(8080))) # UInt32 +properties.insert("size", to_winrt_object(2**40, PropertyType.UInt64)) +properties.insert("when", to_winrt_object(datetime.now(timezone.utc))) ``` -The helper borrows its argument. It maps supported numeric, Boolean, string, -character, GUID, and corresponding array property types to Python values; -64-bit integers use `int`, GUIDs use `uuid.UUID`, and `UInt8Array` uses `bytes`. -`None` stays `None`. If the object does not implement `IPropertyValue`, the -exact same Python object is returned, so identity and later projection remain -intact. Unsupported property types (including `DateTime`, `TimeSpan`, geometry, -inspectable, and other types) and native getter failures raise an exception. +`unbox_object(value, *, preserve_type=False)` borrows its argument. `None` and +WinRT null return `None`. If the object does not implement `IPropertyValue`, +the exact same Python object is returned, so identity and later projection +remain intact. Boxed values map to Python values: + +- numbers, Boolean, string and character values use `int`, `float`, `bool` + and `str`; GUIDs use `uuid.UUID`; +- `DateTime` uses a timezone-aware UTC `datetime` and `TimeSpan` a + `timedelta`, truncated to microseconds like other positions; a `DateTime` + beyond `datetime.max` raises `OverflowError`; +- `Point`, `Size` and `Rect` use the immutable `dynwinrt.values` types; +- `UInt8Array` uses `bytes`, other arrays use lists, and `InspectableArray` + elements are unboxed recursively by the same rules. + +`Empty`, `Inspectable`, `OtherType` and `OtherTypeArray` boxes, including +nested ones, and native getter failures raise `OSError`. + +With `preserve_type=True`, values whose plain Python form would box as a +different type come back as `dynwinrt.values` tags (`UInt8`, `Int16`, +`UInt16`, `UInt32`, `Int64`, `UInt64`, `Single`, `Char16`), and every array +except `bytes` comes back as a typed array such as `UInt32Array` or +`InspectableArray`. `to_winrt_object()` then restores the exact +`PropertyType` and value. It creates a new box, so the original box's COM +identity is not preserved. + +`to_winrt_object(value, property_type=None)` returns a `DynWinRTValue` and +never guesses a WinRT type: + +- `None` becomes WinRT null; a `DynWinRTValue` holding an object is returned + unchanged, and a projected wrapper returns its native `DynWinRTValue`; +- `bool`, `float`, `str`, a timezone-aware `datetime`, `timedelta`, + `uuid.UUID`/`WinGUID`, `bytes`/`bytearray`/`memoryview`, and + `dynwinrt.values` tags, typed arrays and `Point`/`Size`/`Rect` box as their + exact type; a naive `datetime` raises `ValueError`; +- a plain `int` boxes as `Int32` only; other values raise `OverflowError`; +- a list or tuple boxes only when all elements have the same unambiguous + type, for example all `str` (`StringArray`) or all in-range plain `int` + (`Int32Array`); empty and mixed lists raise `TypeError`; +- enum members raise `TypeError` unless an integer `property_type` is given. + Boxing a WinRT enum as `IReference` is not supported yet. + +`property_type` accepts a `dynwinrt.values.PropertyType` member or its +integer value and converts the value to exactly that type, validating its +range. `InspectableArray` elements, including `None`, follow the rules above. + +The `dynwinrt.values` types behave like the plain value: tags compare, hash, +format, pickle and serialize to JSON like `int`, `float` or `str`, and +arithmetic returns plain numbers. They live in `dynwinrt.values` rather than +`dynwinrt`, so they never shadow generated `Windows.Foundation` names such as +`Point` or `PropertyType`. Use `wrapper.as_interface(InterfaceClass)` when converting an existing wrapper to an interface view. Use `InterfaceClass.from_value(raw)` for a raw diff --git a/bindings/py/dynwinrt.pyi b/bindings/py/dynwinrt.pyi index 9292a1a5..e784b54c 100644 --- a/bindings/py/dynwinrt.pyi +++ b/bindings/py/dynwinrt.pyi @@ -1,4 +1,5 @@ from collections.abc import Coroutine +from datetime import datetime, timedelta from typing import Any, Awaitable, Callable, Generic, List, Literal, Mapping, Optional, Protocol, Sequence, TypeVar, Union, final, overload from uuid import UUID @@ -50,6 +51,7 @@ __all__ = [ "project_as", "release_projected", "unbox_object", + "to_winrt_object", "init_winappsdk", "ro_initialize", "ro_uninitialize", @@ -123,6 +125,30 @@ def project_as( def release_projected(value: object) -> None: ... +# Shapes of dynwinrt.values.Point, Size and Rect. This stub also serves as a +# single-file module stub, so it cannot import the dynwinrt.values submodule. +class _WinRTPointValue(Protocol): + @property + def x(self) -> float: ... + @property + def y(self) -> float: ... + +class _WinRTSizeValue(Protocol): + @property + def width(self) -> float: ... + @property + def height(self) -> float: ... + +class _WinRTRectValue(Protocol): + @property + def x(self) -> float: ... + @property + def y(self) -> float: ... + @property + def width(self) -> float: ... + @property + def height(self) -> float: ... + _UnboxedPropertyValue = Union[ bool, int, @@ -130,20 +156,36 @@ _UnboxedPropertyValue = Union[ str, UUID, bytes, + datetime, + timedelta, + _WinRTPointValue, + _WinRTSizeValue, + _WinRTRectValue, List[int], List[float], List[bool], List[str], List[UUID], + List[datetime], + List[timedelta], + List[_WinRTPointValue], + List[_WinRTSizeValue], + List[_WinRTRectValue], + # InspectableArray elements are unboxed recursively. + List[Any], ] @overload -def unbox_object(value: None) -> None: ... +def unbox_object(value: None, *, preserve_type: bool = ...) -> None: ... @overload def unbox_object( - value: "DynWinRTValue", + value: "DynWinRTValue", *, preserve_type: bool = ... ) -> Union[_UnboxedPropertyValue, "DynWinRTValue", None]: ... +def to_winrt_object( + value: object, property_type: Optional[int] = ... +) -> "DynWinRTValue": ... + @final class WinGUID: diff --git a/bindings/py/python/dynwinrt/__init__.py b/bindings/py/python/dynwinrt/__init__.py index e1a8be1d..e32dcc55 100644 --- a/bindings/py/python/dynwinrt/__init__.py +++ b/bindings/py/python/dynwinrt/__init__.py @@ -3,6 +3,7 @@ from . import dynwinrt as dynwinrt from .dynwinrt import * +from . import values as values __doc__ = dynwinrt.__doc__ __all__ = dynwinrt.__all__ diff --git a/bindings/py/python/dynwinrt/values.py b/bindings/py/python/dynwinrt/values.py new file mode 100644 index 00000000..bc599b17 --- /dev/null +++ b/bindings/py/python/dynwinrt/values.py @@ -0,0 +1,553 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Explicit value types for boxed WinRT ``Object`` (``IInspectable``) values. + +Generated bindings keep ``Object`` values native. Convert them explicitly: +``dynwinrt.to_winrt_object(value, property_type=None)`` boxes a Python value +as a system ``Windows.Foundation.PropertyValue``, and +``dynwinrt.unbox_object(raw, preserve_type=True)`` reads a box back with the +types below wherever a plain Python value would box as a different +``PropertyType``. + +- ``PropertyType`` mirrors ``Windows.Foundation.PropertyType``. +- Tags (``UInt8`` ... ``Char16``) are ``int``, ``float`` and ``str`` + subclasses that box as one exact scalar ``PropertyType``. They compare, + hash, format, pickle and serialize like the plain value; only ``repr`` shows + the tag, and arithmetic returns plain numbers. +- Typed arrays (``Int16Array`` ... ``RectArray``) are ``list`` subclasses that + box as one exact array ``PropertyType``. ``bytes`` is the ``UInt8Array`` + form. +- ``Point``, ``Size`` and ``Rect`` are immutable ``Windows.Foundation`` + geometry values whose fields are stored as float32, as WinRT stores them. +""" + +from __future__ import annotations + +import operator +import struct +from datetime import datetime, timedelta +from enum import IntEnum +from typing import Any, ClassVar, Self, SupportsFloat, SupportsIndex, TypeVar +from uuid import UUID + +__all__ = [ + "PropertyType", + "WinRTScalar", + "UInt8", + "Int16", + "UInt16", + "Int32", + "UInt32", + "Int64", + "UInt64", + "Single", + "Double", + "Char16", + "Point", + "Size", + "Rect", + "WinRTArray", + "Int16Array", + "UInt16Array", + "Int32Array", + "UInt32Array", + "Int64Array", + "UInt64Array", + "SingleArray", + "DoubleArray", + "Char16Array", + "BooleanArray", + "StringArray", + "InspectableArray", + "DateTimeArray", + "TimeSpanArray", + "GuidArray", + "PointArray", + "SizeArray", + "RectArray", +] + + +class PropertyType(IntEnum): + """``Windows.Foundation.PropertyType``: the kind of value a WinRT box holds.""" + + Empty = 0 + UInt8 = 1 + Int16 = 2 + UInt16 = 3 + Int32 = 4 + UInt32 = 5 + Int64 = 6 + UInt64 = 7 + Single = 8 + Double = 9 + Char16 = 10 + Boolean = 11 + String = 12 + Inspectable = 13 + DateTime = 14 + TimeSpan = 15 + Guid = 16 + Point = 17 + Size = 18 + Rect = 19 + OtherType = 20 + UInt8Array = 1025 + Int16Array = 1026 + UInt16Array = 1027 + Int32Array = 1028 + UInt32Array = 1029 + Int64Array = 1030 + UInt64Array = 1031 + SingleArray = 1032 + DoubleArray = 1033 + Char16Array = 1034 + BooleanArray = 1035 + StringArray = 1036 + InspectableArray = 1037 + DateTimeArray = 1038 + TimeSpanArray = 1039 + GuidArray = 1040 + PointArray = 1041 + SizeArray = 1042 + RectArray = 1043 + OtherTypeArray = 1044 + + +def _real(value: SupportsFloat | SupportsIndex, name: str) -> float: + if not isinstance(value, (str, bytes, bytearray)): + try: + return float(value) + except TypeError: + pass + raise TypeError(f"WinRT {name} requires a real number, not {type(value).__name__}") + + +def _single(value: SupportsFloat | SupportsIndex, name: str) -> float: + number = _real(value, name) + try: + rounded: float = struct.unpack(" Self: + try: + number = operator.index(value) + except TypeError: + raise TypeError( + f"WinRT {cls.__name__} requires an int, not {type(value).__name__}" + ) from None + low, high = cls._range + if not low <= number <= high: + raise OverflowError( + f"{number} is out of range for WinRT {cls.__name__} ({low}..{high})" + ) + return int.__new__(cls, number) + + def __repr__(self) -> str: + return f"dynwinrt.values.{type(self).__name__}({int.__repr__(self)})" + + def __str__(self) -> str: + return int.__repr__(self) + + +class UInt8(_WinRTInteger): + """An ``int`` that boxes as ``PropertyType.UInt8``.""" + + __slots__ = () + property_type = PropertyType.UInt8 + _range = (0, 0xFF) + + +class Int16(_WinRTInteger): + """An ``int`` that boxes as ``PropertyType.Int16``.""" + + __slots__ = () + property_type = PropertyType.Int16 + _range = (-0x8000, 0x7FFF) + + +class UInt16(_WinRTInteger): + """An ``int`` that boxes as ``PropertyType.UInt16``.""" + + __slots__ = () + property_type = PropertyType.UInt16 + _range = (0, 0xFFFF) + + +class Int32(_WinRTInteger): + """An ``int`` that boxes as ``PropertyType.Int32``, the plain ``int`` default.""" + + __slots__ = () + property_type = PropertyType.Int32 + _range = (-0x8000_0000, 0x7FFF_FFFF) + + +class UInt32(_WinRTInteger): + """An ``int`` that boxes as ``PropertyType.UInt32``.""" + + __slots__ = () + property_type = PropertyType.UInt32 + _range = (0, 0xFFFF_FFFF) + + +class Int64(_WinRTInteger): + """An ``int`` that boxes as ``PropertyType.Int64``.""" + + __slots__ = () + property_type = PropertyType.Int64 + _range = (-0x8000_0000_0000_0000, 0x7FFF_FFFF_FFFF_FFFF) + + +class UInt64(_WinRTInteger): + """An ``int`` that boxes as ``PropertyType.UInt64``.""" + + __slots__ = () + property_type = PropertyType.UInt64 + _range = (0, 0xFFFF_FFFF_FFFF_FFFF) + + +class Single(WinRTScalar, float): + """A ``float`` that boxes as ``PropertyType.Single``; rounded to float32 on construction.""" + + __slots__ = () + property_type = PropertyType.Single + + def __new__(cls, value: SupportsFloat | SupportsIndex = 0.0, /) -> Self: + return float.__new__(cls, _single(value, "Single")) + + def __repr__(self) -> str: + return f"dynwinrt.values.Single({float.__repr__(self)})" + + def __str__(self) -> str: + return float.__repr__(self) + + +class Double(WinRTScalar, float): + """A ``float`` that boxes as ``PropertyType.Double``, the plain ``float`` default.""" + + __slots__ = () + property_type = PropertyType.Double + + def __new__(cls, value: SupportsFloat | SupportsIndex = 0.0, /) -> Self: + return float.__new__(cls, _real(value, "Double")) + + def __repr__(self) -> str: + return f"dynwinrt.values.Double({float.__repr__(self)})" + + def __str__(self) -> str: + return float.__repr__(self) + + +class Char16(WinRTScalar, str): + """One UTF-16 code unit that boxes as ``PropertyType.Char16``.""" + + __slots__ = () + property_type = PropertyType.Char16 + + def __new__(cls, value: str, /) -> Self: + if not isinstance(value, str): + raise TypeError(f"WinRT Char16 requires a str, not {type(value).__name__}") + if len(value) != 1 or ord(value) > 0xFFFF: + raise ValueError( + f"WinRT Char16 requires exactly one UTF-16 code unit, not {value!r}" + ) + return str.__new__(cls, value) + + def __repr__(self) -> str: + return f"dynwinrt.values.Char16({str.__repr__(self)})" + + +# ---------------------------------------------------------------------- +# Geometry +# ---------------------------------------------------------------------- + + +class _WinRTGeometry: + __slots__ = () + property_type: ClassVar[PropertyType] + _fields: ClassVar[tuple[str, ...]] = () + + def _values(self) -> tuple[float, ...]: + return tuple(getattr(self, f"_{name}") for name in self._fields) + + def __setattr__(self, name: str, value: object) -> None: + raise AttributeError(f"dynwinrt.values.{type(self).__name__} is immutable") + + def __delattr__(self, name: str) -> None: + raise AttributeError(f"dynwinrt.values.{type(self).__name__} is immutable") + + def __eq__(self, other: object) -> bool: + if type(other) is not type(self) or not isinstance(other, _WinRTGeometry): + return NotImplemented + return self._values() == other._values() + + def __hash__(self) -> int: + return hash((type(self).__name__, self._values())) + + def __repr__(self) -> str: + fields = ", ".join( + f"{name}={value!r}" for name, value in zip(self._fields, self._values()) + ) + return f"dynwinrt.values.{type(self).__name__}({fields})" + + def __reduce__(self) -> tuple[type[Self], tuple[float, ...]]: + return (type(self), self._values()) + + +class Point(_WinRTGeometry): + """An immutable ``Windows.Foundation.Point`` that boxes as ``PropertyType.Point``.""" + + __slots__ = ("_x", "_y") + __match_args__ = ("x", "y") + property_type = PropertyType.Point + _fields = ("x", "y") + _x: float + _y: float + + def __init__(self, x: float = 0.0, y: float = 0.0) -> None: + object.__setattr__(self, "_x", _single(x, "Point.x")) + object.__setattr__(self, "_y", _single(y, "Point.y")) + + @property + def x(self) -> float: + return self._x + + @property + def y(self) -> float: + return self._y + + +class Size(_WinRTGeometry): + """An immutable ``Windows.Foundation.Size`` that boxes as ``PropertyType.Size``.""" + + __slots__ = ("_width", "_height") + __match_args__ = ("width", "height") + property_type = PropertyType.Size + _fields = ("width", "height") + _width: float + _height: float + + def __init__(self, width: float = 0.0, height: float = 0.0) -> None: + object.__setattr__(self, "_width", _single(width, "Size.width")) + object.__setattr__(self, "_height", _single(height, "Size.height")) + + @property + def width(self) -> float: + return self._width + + @property + def height(self) -> float: + return self._height + + +class Rect(_WinRTGeometry): + """An immutable ``Windows.Foundation.Rect`` that boxes as ``PropertyType.Rect``.""" + + __slots__ = ("_x", "_y", "_width", "_height") + __match_args__ = ("x", "y", "width", "height") + property_type = PropertyType.Rect + _fields = ("x", "y", "width", "height") + _x: float + _y: float + _width: float + _height: float + + def __init__( + self, x: float = 0.0, y: float = 0.0, width: float = 0.0, height: float = 0.0 + ) -> None: + object.__setattr__(self, "_x", _single(x, "Rect.x")) + object.__setattr__(self, "_y", _single(y, "Rect.y")) + object.__setattr__(self, "_width", _single(width, "Rect.width")) + object.__setattr__(self, "_height", _single(height, "Rect.height")) + + @property + def x(self) -> float: + return self._x + + @property + def y(self) -> float: + return self._y + + @property + def width(self) -> float: + return self._width + + @property + def height(self) -> float: + return self._height + + +# ---------------------------------------------------------------------- +# Typed arrays +# ---------------------------------------------------------------------- + +_T = TypeVar("_T") + + +class WinRTArray(list[_T]): + """Base class of the typed arrays: a list that boxes as one exact array ``PropertyType``. + + Elements are plain values and are validated when the array is boxed. + Operations that build a new list, such as slicing, ``+`` and + comprehensions, return plain lists. + """ + + __slots__ = () + property_type: ClassVar[PropertyType] + + def __repr__(self) -> str: + return f"dynwinrt.values.{type(self).__name__}({list.__repr__(self)})" + + def copy(self) -> Self: + return type(self)(self) + + +class Int16Array(WinRTArray[int]): + """A list that boxes as ``PropertyType.Int16Array``.""" + + __slots__ = () + property_type = PropertyType.Int16Array + + +class UInt16Array(WinRTArray[int]): + """A list that boxes as ``PropertyType.UInt16Array``.""" + + __slots__ = () + property_type = PropertyType.UInt16Array + + +class Int32Array(WinRTArray[int]): + """A list that boxes as ``PropertyType.Int32Array``.""" + + __slots__ = () + property_type = PropertyType.Int32Array + + +class UInt32Array(WinRTArray[int]): + """A list that boxes as ``PropertyType.UInt32Array``.""" + + __slots__ = () + property_type = PropertyType.UInt32Array + + +class Int64Array(WinRTArray[int]): + """A list that boxes as ``PropertyType.Int64Array``.""" + + __slots__ = () + property_type = PropertyType.Int64Array + + +class UInt64Array(WinRTArray[int]): + """A list that boxes as ``PropertyType.UInt64Array``.""" + + __slots__ = () + property_type = PropertyType.UInt64Array + + +class SingleArray(WinRTArray[float]): + """A list that boxes as ``PropertyType.SingleArray``.""" + + __slots__ = () + property_type = PropertyType.SingleArray + + +class DoubleArray(WinRTArray[float]): + """A list that boxes as ``PropertyType.DoubleArray``.""" + + __slots__ = () + property_type = PropertyType.DoubleArray + + +class Char16Array(WinRTArray[str]): + """A list of one-code-unit strings that boxes as ``PropertyType.Char16Array``.""" + + __slots__ = () + property_type = PropertyType.Char16Array + + +class BooleanArray(WinRTArray[bool]): + """A list that boxes as ``PropertyType.BooleanArray``.""" + + __slots__ = () + property_type = PropertyType.BooleanArray + + +class StringArray(WinRTArray[str]): + """A list that boxes as ``PropertyType.StringArray``.""" + + __slots__ = () + property_type = PropertyType.StringArray + + +class InspectableArray(WinRTArray[Any]): + """A list that boxes as ``PropertyType.InspectableArray``. + + Each element is converted like a ``to_winrt_object`` argument without + ``property_type``: ``None`` is a null element and WinRT objects are stored + as they are. + """ + + __slots__ = () + property_type = PropertyType.InspectableArray + + +class DateTimeArray(WinRTArray[datetime]): + """A list of timezone-aware datetimes that boxes as ``PropertyType.DateTimeArray``.""" + + __slots__ = () + property_type = PropertyType.DateTimeArray + + +class TimeSpanArray(WinRTArray[timedelta]): + """A list that boxes as ``PropertyType.TimeSpanArray``.""" + + __slots__ = () + property_type = PropertyType.TimeSpanArray + + +class GuidArray(WinRTArray[UUID]): + """A list that boxes as ``PropertyType.GuidArray``.""" + + __slots__ = () + property_type = PropertyType.GuidArray + + +class PointArray(WinRTArray[Point]): + """A list that boxes as ``PropertyType.PointArray``.""" + + __slots__ = () + property_type = PropertyType.PointArray + + +class SizeArray(WinRTArray[Size]): + """A list that boxes as ``PropertyType.SizeArray``.""" + + __slots__ = () + property_type = PropertyType.SizeArray + + +class RectArray(WinRTArray[Rect]): + """A list that boxes as ``PropertyType.RectArray``.""" + + __slots__ = () + property_type = PropertyType.RectArray diff --git a/bindings/py/src/lib.rs b/bindings/py/src/lib.rs index f5bf9fcf..f466e247 100644 --- a/bindings/py/src/lib.rs +++ b/bindings/py/src/lib.rs @@ -7,6 +7,7 @@ mod async_runtime; mod delegate_method; mod errors; mod implementation; +mod object_value; mod runtime; mod values; #[cfg(feature = "test-hooks")] @@ -585,7 +586,8 @@ _Coroutine.register(_DynWinRTAsyncWithProgress) m.add_function(wrap_pyfunction!(super::runtime::init_winappsdk, m)?)?; m.add_function(wrap_pyfunction!(super::runtime::ro_initialize, m)?)?; m.add_function(wrap_pyfunction!(super::runtime::ro_uninitialize, m)?)?; - m.add_function(wrap_pyfunction!(super::runtime::unbox_object, m)?)?; + m.add_function(wrap_pyfunction!(super::object_value::unbox_object, m)?)?; + m.add_function(wrap_pyfunction!(super::object_value::to_winrt_object, m)?)?; m.add_function(wrap_pyfunction!( super::runtime::register_xaml_runtime_class, m @@ -626,6 +628,7 @@ for _name in ( 'project_as', 'release_projected', 'unbox_object', + 'to_winrt_object', ): if _name not in __all__: __all__.append(_name) diff --git a/bindings/py/src/object_value.rs b/bindings/py/src/object_value.rs new file mode 100644 index 00000000..fa5d1977 --- /dev/null +++ b/bindings/py/src/object_value.rs @@ -0,0 +1,1505 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Explicit conversion between Python values and WinRT `Object` +//! (`IInspectable`) values. +//! +//! Generated code keeps `Object` values native. Applications convert only +//! where they expect value semantics: +//! - [`unbox_object`] reads a boxed `IPropertyValue` payload; +//! - [`to_winrt_object`] boxes a Python value as a system +//! `Windows.Foundation.PropertyValue`. +//! +//! No WinRT type is guessed. A plain `int` boxes only as Int32, a list boxes +//! only when its elements share one unambiguous type, and enum members, empty +//! or mixed lists and `InspectableArray` values need an explicit type. The +//! explicit value types live in `python/dynwinrt/values.py`; the +//! language-neutral payload is `dynwinrt::PropertyValueData`. + +use std::os::raw::c_int; + +use dynwinrt::{PropertyValueData as Data, PropertyValueUnboxResult, WinRTValue}; +use pyo3::IntoPyObjectExt; +use pyo3::exceptions::{PyOverflowError, PyRecursionError, PyTypeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::sync::PyOnceLock; +use pyo3::types::{ + PyBool, PyByteArray, PyBytes, PyDict, PyFloat, PyInt, PyList, PyMemoryView, PyString, PyTuple, + PyType, +}; +use pyo3::{ffi, intern}; +use windows::Foundation::{DateTime, Point, PropertyType, Rect, Size, TimeSpan}; +use windows::core::{GUID, HRESULT, IUnknown}; + +use crate::errors::{map_dynwinrt_error, map_windows_error}; +use crate::runtime::{DynWinRTValue, WinGUID}; + +const E_NOTIMPL: HRESULT = HRESULT(0x80004001_u32 as i32); + +/// Nested `InspectableArray` levels converted in either direction. +const MAX_NESTING: usize = 64; + +const SUPPORTED_INPUTS: &str = "None, a DynWinRTValue or projected WinRT object, bool, \ +an int in the Int32 range, float, str, a timezone-aware datetime.datetime, datetime.timedelta, \ +uuid.UUID, WinGUID, bytes, bytearray, memoryview, a dynwinrt.values tag, Point, Size, Rect or \ +typed array, or a non-empty list or tuple whose elements share one of those scalar types"; + +const EMPTY_SEQUENCE: &str = "cannot infer the WinRT array type of an empty list or tuple; pass \ +property_type= (for example dynwinrt.values.PropertyType.StringArray) or a typed array such as \ +dynwinrt.values.StringArray([])"; + +// ====================================================================== +// WinRT value kinds +// ====================================================================== + +/// A scalar `PropertyType`, or the element type of an array `PropertyType`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Element { + UInt8, + Int16, + UInt16, + Int32, + UInt32, + Int64, + UInt64, + Single, + Double, + Char16, + Boolean, + String, + Inspectable, + DateTime, + TimeSpan, + Guid, + Point, + Size, + Rect, +} + +impl Element { + const ALL: [Self; 19] = [ + Self::UInt8, + Self::Int16, + Self::UInt16, + Self::Int32, + Self::UInt32, + Self::Int64, + Self::UInt64, + Self::Single, + Self::Double, + Self::Char16, + Self::Boolean, + Self::String, + Self::Inspectable, + Self::DateTime, + Self::TimeSpan, + Self::Guid, + Self::Point, + Self::Size, + Self::Rect, + ]; + + /// Elements with a `dynwinrt.values` tag class. + const TAGS: [Self; 10] = [ + Self::UInt8, + Self::Int16, + Self::UInt16, + Self::Int32, + Self::UInt32, + Self::Int64, + Self::UInt64, + Self::Single, + Self::Double, + Self::Char16, + ]; + + const GEOMETRY: [Self; 3] = [Self::Point, Self::Size, Self::Rect]; + + fn name(self) -> &'static str { + match self { + Self::UInt8 => "UInt8", + Self::Int16 => "Int16", + Self::UInt16 => "UInt16", + Self::Int32 => "Int32", + Self::UInt32 => "UInt32", + Self::Int64 => "Int64", + Self::UInt64 => "UInt64", + Self::Single => "Single", + Self::Double => "Double", + Self::Char16 => "Char16", + Self::Boolean => "Boolean", + Self::String => "String", + Self::Inspectable => "Inspectable", + Self::DateTime => "DateTime", + Self::TimeSpan => "TimeSpan", + Self::Guid => "Guid", + Self::Point => "Point", + Self::Size => "Size", + Self::Rect => "Rect", + } + } + + fn scalar_type(self) -> PropertyType { + match self { + Self::UInt8 => PropertyType::UInt8, + Self::Int16 => PropertyType::Int16, + Self::UInt16 => PropertyType::UInt16, + Self::Int32 => PropertyType::Int32, + Self::UInt32 => PropertyType::UInt32, + Self::Int64 => PropertyType::Int64, + Self::UInt64 => PropertyType::UInt64, + Self::Single => PropertyType::Single, + Self::Double => PropertyType::Double, + Self::Char16 => PropertyType::Char16, + Self::Boolean => PropertyType::Boolean, + Self::String => PropertyType::String, + Self::Inspectable => PropertyType::Inspectable, + Self::DateTime => PropertyType::DateTime, + Self::TimeSpan => PropertyType::TimeSpan, + Self::Guid => PropertyType::Guid, + Self::Point => PropertyType::Point, + Self::Size => PropertyType::Size, + Self::Rect => PropertyType::Rect, + } + } + + fn array_type(self) -> PropertyType { + match self { + Self::UInt8 => PropertyType::UInt8Array, + Self::Int16 => PropertyType::Int16Array, + Self::UInt16 => PropertyType::UInt16Array, + Self::Int32 => PropertyType::Int32Array, + Self::UInt32 => PropertyType::UInt32Array, + Self::Int64 => PropertyType::Int64Array, + Self::UInt64 => PropertyType::UInt64Array, + Self::Single => PropertyType::SingleArray, + Self::Double => PropertyType::DoubleArray, + Self::Char16 => PropertyType::Char16Array, + Self::Boolean => PropertyType::BooleanArray, + Self::String => PropertyType::StringArray, + Self::Inspectable => PropertyType::InspectableArray, + Self::DateTime => PropertyType::DateTimeArray, + Self::TimeSpan => PropertyType::TimeSpanArray, + Self::Guid => PropertyType::GuidArray, + Self::Point => PropertyType::PointArray, + Self::Size => PropertyType::SizeArray, + Self::Rect => PropertyType::RectArray, + } + } + + fn integer_bounds(self) -> Option<(i128, i128)> { + match self { + Self::UInt8 => Some((0, u8::MAX.into())), + Self::Int16 => Some((i16::MIN.into(), i16::MAX.into())), + Self::UInt16 => Some((0, u16::MAX.into())), + Self::Int32 => Some((i32::MIN.into(), i32::MAX.into())), + Self::UInt32 => Some((0, u32::MAX.into())), + Self::Int64 => Some((i64::MIN.into(), i64::MAX.into())), + Self::UInt64 => Some((0, u64::MAX.into())), + _ => None, + } + } + + fn fields(self) -> &'static [&'static str] { + match self { + Self::Point => &["x", "y"], + Self::Size => &["width", "height"], + Self::Rect => &["x", "y", "width", "height"], + _ => &[], + } + } +} + +/// A `PropertyType` that `to_winrt_object` can create. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Target { + Scalar(Element), + Array(Element), +} + +impl Target { + fn from_property_type(property_type: PropertyType) -> Option { + Element::ALL.into_iter().find_map(|element| { + if element != Element::Inspectable && element.scalar_type() == property_type { + Some(Self::Scalar(element)) + } else if element.array_type() == property_type { + Some(Self::Array(element)) + } else { + None + } + }) + } +} + +fn payloadless_name(property_type: PropertyType) -> Option<&'static str> { + match property_type { + PropertyType::Empty => Some("Empty"), + PropertyType::Inspectable => Some("Inspectable"), + PropertyType::OtherType => Some("OtherType"), + PropertyType::OtherTypeArray => Some("OtherTypeArray"), + _ => None, + } +} + +// ====================================================================== +// The dynwinrt.values model +// ====================================================================== + +/// The `dynwinrt.values` types and the helpers conversions rely on. +struct ValueModel { + tags: Vec<(Py, Element)>, + scalar_base: Py, + arrays: Vec<(Py, Element)>, + array_base: Py, + geometry: Vec<(Py, Element)>, + enum_base: Py, + uuid: Py, + datetime: Py, + timedelta: Py, + int_new: Py, + float_new: Py, + str_new: Py, + ticks_to_datetime: Py, + datetime_to_ticks: Py, + ticks_to_timedelta: Py, + timedelta_to_ticks: Py, +} + +static MODEL: PyOnceLock = PyOnceLock::new(); + +/// The value model, loaded on first use so that importing the native module +/// never depends on `dynwinrt.values` being importable yet. +fn model(module: &Bound<'_, PyModule>) -> PyResult<&'static ValueModel> { + MODEL.get_or_try_init(module.py(), || ValueModel::load(module)) +} + +impl ValueModel { + fn load(module: &Bound<'_, PyModule>) -> PyResult { + let py = module.py(); + let values = py.import("dynwinrt.values")?; + let class = |name: &str| -> PyResult> { + Ok(values.getattr(name)?.cast_into::()?.unbind()) + }; + let imported = |module: &str, name: &str| -> PyResult> { + Ok(py + .import(module)? + .getattr(name)? + .cast_into::()? + .unbind()) + }; + let constructor = |typ: Bound<'_, PyType>| -> PyResult> { + Ok(typ.getattr(intern!(py, "__new__"))?.unbind()) + }; + let helper = |name: &str| -> PyResult> { Ok(module.getattr(name)?.unbind()) }; + Ok(Self { + tags: Element::TAGS + .into_iter() + .map(|element| Ok((class(element.name())?, element))) + .collect::>()?, + scalar_base: class("WinRTScalar")?, + arrays: Element::ALL + .into_iter() + .filter(|element| *element != Element::UInt8) + .map(|element| Ok((class(&format!("{}Array", element.name()))?, element))) + .collect::>()?, + array_base: class("WinRTArray")?, + geometry: Element::GEOMETRY + .into_iter() + .map(|element| Ok((class(element.name())?, element))) + .collect::>()?, + enum_base: imported("enum", "Enum")?, + uuid: imported("uuid", "UUID")?, + datetime: imported("datetime", "datetime")?, + timedelta: imported("datetime", "timedelta")?, + int_new: constructor(py.get_type::())?, + float_new: constructor(py.get_type::())?, + str_new: constructor(py.get_type::())?, + ticks_to_datetime: helper("_dynwinrt_ticks_to_datetime")?, + datetime_to_ticks: helper("_dynwinrt_datetime_to_ticks")?, + ticks_to_timedelta: helper("_dynwinrt_ticks_to_timedelta")?, + timedelta_to_ticks: helper("_dynwinrt_timedelta_to_ticks")?, + }) + } + + fn find(types: &[(Py, Element)], element: Element) -> &Py { + types + .iter() + .find(|(_, candidate)| *candidate == element) + .map(|(typ, _)| typ) + .expect("every modeled element has a dynwinrt.values class") + } + + /// The first class in `types` that `value` is an instance of. + fn instance_of( + types: &[(Py, Element)], + value: &Bound<'_, PyAny>, + ) -> PyResult> { + for (typ, element) in types { + if value.is_instance(typ.bind(value.py()))? { + return Ok(Some(*element)); + } + } + Ok(None) + } + + fn is_enum(&self, value: &Bound<'_, PyAny>) -> PyResult { + value.is_instance(self.enum_base.bind(value.py())) + } + + fn uuid(&self, py: Python<'_>, value: GUID) -> PyResult> { + let kwargs = PyDict::new(py); + kwargs.set_item(intern!(py, "int"), value.to_u128())?; + self.uuid.call(py, (), Some(&kwargs)) + } + + fn datetime(&self, py: Python<'_>, value: DateTime) -> PyResult> { + self.ticks_to_datetime + .call1(py, (value.UniversalTime,)) + .map_err(|error| { + if error.is_instance_of::(py) { + PyOverflowError::new_err(format!( + "WinRT DateTime value ({} ticks) is outside the range of datetime.datetime", + value.UniversalTime + )) + } else { + error + } + }) + } + + fn timedelta(&self, py: Python<'_>, value: TimeSpan) -> PyResult> { + self.ticks_to_timedelta.call1(py, (value.Duration,)) + } + + fn geometry(&self, py: Python<'_>, element: Element, fields: &[f32]) -> PyResult> { + let fields = PyTuple::new(py, fields.iter().map(|field| f64::from(*field)))?; + Self::find(&self.geometry, element).call1(py, fields) + } +} + +fn type_name(value: &Bound<'_, PyAny>) -> String { + if value.is_none() { + return "None".into(); + } + value + .get_type() + .qualname() + .map(|name| name.to_string()) + .unwrap_or_else(|_| "value".into()) +} + +fn repr(value: &Bound<'_, PyAny>) -> String { + value + .repr() + .map(|repr| repr.to_string()) + .unwrap_or_else(|_| type_name(value)) +} + +fn with_label(label: Option<&str>, message: String) -> String { + match label { + Some(label) => format!("{label}: {message}"), + None => message, + } +} + +/// Prefix `context` to a conversion error raised for a nested value. +fn with_context(py: Python<'_>, error: PyErr, context: &str) -> PyErr { + let message = format!("{context}: {}", error.value(py)); + let wrapped = if error.is_instance_of::(py) { + PyOverflowError::new_err(message) + } else if error.is_instance_of::(py) { + PyTypeError::new_err(message) + } else if error.is_instance_of::(py) { + PyValueError::new_err(message) + } else { + return error; + }; + wrapped.set_cause(py, Some(error)); + wrapped +} + +/// The one place that creates the Python values these conversions return. +fn native(value: WinRTValue) -> DynWinRTValue { + DynWinRTValue(value) +} + +/// Borrow the native value of a `DynWinRTValue`. +/// +/// Every native input, including nested elements, is read through here. +fn live<'py>(value: &Bound<'py, DynWinRTValue>) -> PyResult> { + Ok(value.try_borrow()?) +} + +fn is_object(value: &WinRTValue) -> bool { + matches!( + value, + WinRTValue::Object(_) | WinRTValue::Null | WinRTValue::Async(_) + ) +} + +fn value_kind(value: &WinRTValue) -> &'static str { + match value { + WinRTValue::Bool(_) => "Bool", + WinRTValue::I8(_) => "I8", + WinRTValue::U8(_) => "U8", + WinRTValue::I16(_) => "I16", + WinRTValue::U16(_) => "U16", + WinRTValue::I32(_) => "I32", + WinRTValue::U32(_) => "U32", + WinRTValue::I64(_) => "I64", + WinRTValue::U64(_) => "U64", + WinRTValue::F32(_) => "F32", + WinRTValue::F64(_) => "F64", + WinRTValue::HString(_) => "HString", + WinRTValue::HResult(_) => "HResult", + WinRTValue::Guid(_) => "Guid", + WinRTValue::Enum { .. } => "Enum", + WinRTValue::Struct(_) => "Struct", + WinRTValue::Array(_) | WinRTValue::ArrayOfIUnknown(_) => "Array", + WinRTValue::RawPtr(_) | WinRTValue::OutValue(..) => "native pointer", + WinRTValue::Object(_) | WinRTValue::Null | WinRTValue::Async(_) => "Object", + } +} + +/// The `DynWinRTValue` behind `value`: the value itself or a projected +/// wrapper's `_obj`. +fn native_input<'py>(value: &Bound<'py, PyAny>) -> PyResult>> { + if let Ok(raw) = value.cast::() { + return Ok(Some(raw.clone())); + } + if let Some(inner) = value.getattr_opt(intern!(value.py(), "_obj"))? + && let Ok(raw) = inner.cast_into::() + { + return Ok(Some(raw)); + } + Ok(None) +} + +fn unsupported_property_type(property_type: PropertyType) -> PyErr { + let name = payloadless_name(property_type) + .map(|name| format!(" ({name})")) + .unwrap_or_default(); + map_windows_error(windows::core::Error::new( + E_NOTIMPL, + format!( + "Unsupported WinRT IPropertyValue type: {}{name}", + property_type.0 + ), + )) +} + +// ====================================================================== +// unbox_object: DynWinRTValue -> Python value +// ====================================================================== + +/// Explicitly unbox a WinRT `IPropertyValue`. +/// +/// `None` and WinRT null return `None`. Any other value that is not an +/// `IPropertyValue` is returned unchanged, preserving Python and COM identity. +/// Boxed values become Python values, and `InspectableArray` elements are +/// unboxed recursively. With `preserve_type`, scalars whose plain Python value +/// would box as a different `PropertyType` become `dynwinrt.values` tags and +/// arrays become typed arrays, so `to_winrt_object` restores the exact +/// `PropertyType` and value; the new box does not keep the original box's COM +/// identity. `Empty`, `Inspectable`, `OtherType` and `OtherTypeArray` boxes +/// raise `OSError`. +#[pyfunction] +#[pyo3(pass_module, signature = (value, *, preserve_type = false))] +pub fn unbox_object( + module: &Bound<'_, PyModule>, + value: &Bound<'_, PyAny>, + preserve_type: bool, +) -> PyResult> { + let py = module.py(); + if value.is_none() { + return Ok(py.None()); + } + let Ok(raw) = value.cast::() else { + return Err(PyTypeError::new_err(format!( + "unbox_object() requires None or a DynWinRTValue, not {}", + type_name(value) + ))); + }; + Reader { + model: model(module)?, + preserve_type, + } + .unbox(raw, 0) +} + +struct Reader<'a> { + model: &'a ValueModel, + preserve_type: bool, +} + +impl Reader<'_> { + fn unbox(&self, raw: &Bound<'_, DynWinRTValue>, depth: usize) -> PyResult> { + let py = raw.py(); + let result = { + let native = live(raw)?; + dynwinrt::unbox_property_value(&native.0).map_err(map_dynwinrt_error)? + }; + match result { + PropertyValueUnboxResult::Null => Ok(py.None()), + PropertyValueUnboxResult::NotPropertyValue => Ok(raw.clone().into_any().unbind()), + PropertyValueUnboxResult::Unsupported(property_type) => { + Err(unsupported_property_type(property_type)) + } + PropertyValueUnboxResult::Value(data) => self.read(py, data, depth), + } + } + + fn read(&self, py: Python<'_>, data: Data, depth: usize) -> PyResult> { + let model = self.model; + let int = |value: i128| value.into_py_any(py); + match data { + Data::UInt8(value) => self.integer(py, Element::UInt8, value.into()), + Data::Int16(value) => self.integer(py, Element::Int16, value.into()), + Data::UInt16(value) => self.integer(py, Element::UInt16, value.into()), + Data::Int32(value) => int(value.into()), + Data::UInt32(value) => self.integer(py, Element::UInt32, value.into()), + Data::Int64(value) => self.integer(py, Element::Int64, value.into()), + Data::UInt64(value) => self.integer(py, Element::UInt64, value.into()), + Data::Single(value) => { + let value = f64::from(value); + if self.preserve_type { + model + .float_new + .call1(py, (ValueModel::find(&model.tags, Element::Single), value)) + } else { + value.into_py_any(py) + } + } + Data::Double(value) => value.into_py_any(py), + Data::Char16(value) => { + let character = char16(py, value)?; + if self.preserve_type { + model.str_new.call1( + py, + (ValueModel::find(&model.tags, Element::Char16), character), + ) + } else { + Ok(character.unbind()) + } + } + Data::Boolean(value) => Ok(PyBool::new(py, value).to_owned().into_any().unbind()), + Data::String(value) => value.into_py_any(py), + Data::Guid(value) => model.uuid(py, value), + Data::DateTime(value) => model.datetime(py, value), + Data::TimeSpan(value) => model.timedelta(py, value), + Data::Point(value) => model.geometry(py, Element::Point, &[value.X, value.Y]), + Data::Size(value) => model.geometry(py, Element::Size, &[value.Width, value.Height]), + Data::Rect(value) => model.geometry( + py, + Element::Rect, + &[value.X, value.Y, value.Width, value.Height], + ), + Data::UInt8Array(values) => Ok(PyBytes::new(py, &values).into_any().unbind()), + Data::Int16Array(values) => self.array( + py, + Element::Int16, + values.into_iter().map(|value| int(value.into())), + ), + Data::UInt16Array(values) => self.array( + py, + Element::UInt16, + values.into_iter().map(|value| int(value.into())), + ), + Data::Int32Array(values) => self.array( + py, + Element::Int32, + values.into_iter().map(|value| int(value.into())), + ), + Data::UInt32Array(values) => self.array( + py, + Element::UInt32, + values.into_iter().map(|value| int(value.into())), + ), + Data::Int64Array(values) => self.array( + py, + Element::Int64, + values.into_iter().map(|value| int(value.into())), + ), + Data::UInt64Array(values) => self.array( + py, + Element::UInt64, + values.into_iter().map(|value| int(value.into())), + ), + Data::SingleArray(values) => self.array( + py, + Element::Single, + values + .into_iter() + .map(|value| f64::from(value).into_py_any(py)), + ), + Data::DoubleArray(values) => self.array( + py, + Element::Double, + values.into_iter().map(|value| value.into_py_any(py)), + ), + Data::Char16Array(values) => self.array( + py, + Element::Char16, + values + .into_iter() + .map(|value| Ok(char16(py, value)?.unbind())), + ), + Data::BooleanArray(values) => self.array( + py, + Element::Boolean, + values + .into_iter() + .map(|value| Ok(PyBool::new(py, value).to_owned().into_any().unbind())), + ), + Data::StringArray(values) => self.array( + py, + Element::String, + values.into_iter().map(|value| value.into_py_any(py)), + ), + Data::InspectableArray(values) => { + if depth >= MAX_NESTING { + return Err(PyRecursionError::new_err(format!( + "WinRT InspectableArray values nest more than {MAX_NESTING} levels" + ))); + } + self.array( + py, + Element::Inspectable, + values.into_iter().map(|element| match element { + None => Ok(py.None()), + Some(object) => { + let element = Bound::new(py, native(WinRTValue::Object(object)))?; + self.unbox(&element, depth + 1) + } + }), + ) + } + Data::DateTimeArray(values) => self.array( + py, + Element::DateTime, + values.into_iter().map(|value| model.datetime(py, value)), + ), + Data::TimeSpanArray(values) => self.array( + py, + Element::TimeSpan, + values.into_iter().map(|value| model.timedelta(py, value)), + ), + Data::GuidArray(values) => self.array( + py, + Element::Guid, + values.into_iter().map(|value| model.uuid(py, value)), + ), + Data::PointArray(values) => self.array( + py, + Element::Point, + values + .into_iter() + .map(|value| model.geometry(py, Element::Point, &[value.X, value.Y])), + ), + Data::SizeArray(values) => self.array( + py, + Element::Size, + values + .into_iter() + .map(|value| model.geometry(py, Element::Size, &[value.Width, value.Height])), + ), + Data::RectArray(values) => self.array( + py, + Element::Rect, + values.into_iter().map(|value| { + model.geometry( + py, + Element::Rect, + &[value.X, value.Y, value.Width, value.Height], + ) + }), + ), + } + } + + /// A plain `int`, or the element's tag when preserving types. + fn integer(&self, py: Python<'_>, element: Element, value: i128) -> PyResult> { + if self.preserve_type && element != Element::Int32 { + // int.__new__(Tag, value) skips the tag's range check: WinRT + // values are in range by construction. + self.model + .int_new + .call1(py, (ValueModel::find(&self.model.tags, element), value)) + } else { + value.into_py_any(py) + } + } + + /// A plain list of array elements, or the typed array when preserving types. + fn array( + &self, + py: Python<'_>, + element: Element, + items: impl IntoIterator>>, + ) -> PyResult> { + let list = PyList::new(py, items.into_iter().collect::>>()?)?; + if self.preserve_type { + ValueModel::find(&self.model.arrays, element).call1(py, (list,)) + } else { + Ok(list.into_any().unbind()) + } + } +} + +/// One UTF-16 code unit as a Python `str`, keeping unpaired surrogates. +fn char16(py: Python<'_>, value: u16) -> PyResult> { + unsafe { Bound::from_owned_ptr_or_err(py, ffi::PyUnicode_FromOrdinal(c_int::from(value))) } +} + +// ====================================================================== +// to_winrt_object: Python value -> DynWinRTValue +// ====================================================================== + +/// Explicitly box a Python value as a WinRT `Object`. +/// +/// `None` becomes WinRT null, and a `DynWinRTValue` object or projected +/// wrapper is returned as its existing `DynWinRTValue`. Other values box as a +/// system `Windows.Foundation.PropertyValue` only when their WinRT type is +/// unambiguous: `bool`, `float`, `str`, timezone-aware `datetime`, +/// `timedelta`, `uuid.UUID`/`WinGUID`, bytes-like values, `dynwinrt.values` +/// tags, typed arrays and geometry, a plain `int` as Int32 only, and +/// homogeneous lists or tuples of those. Enum members, empty or mixed lists +/// and out-of-range ints raise. `property_type` converts a Python value to +/// exactly that `PropertyType`, validating its range. +#[pyfunction] +#[pyo3(pass_module, signature = (value, property_type = None))] +pub fn to_winrt_object( + module: &Bound<'_, PyModule>, + value: &Bound<'_, PyAny>, + property_type: Option<&Bound<'_, PyAny>>, +) -> PyResult> { + let py = module.py(); + let boxer = Boxer { + model: model(module)?, + }; + let boxed = match property_type { + None => boxer.box_value(value, 0)?, + Some(property_type) => boxer.box_as(value, parse_property_type(property_type)?)?, + }; + match boxed { + Boxed::Existing(object) => Ok(object.into_any().unbind()), + Boxed::New(value) => Ok(Bound::new(py, native(value))?.into_any().unbind()), + } +} + +fn parse_property_type(value: &Bound<'_, PyAny>) -> PyResult { + if value.cast::().is_ok() || !value.is_instance_of::() { + return Err(PyTypeError::new_err(format!( + "property_type must be a dynwinrt.values.PropertyType member or an int, not {}", + type_name(value) + ))); + } + let unknown = || { + PyValueError::new_err(format!( + "{} is not a Windows.Foundation.PropertyType value", + repr(value) + )) + }; + let property_type = PropertyType(value.extract::().map_err(|_| unknown())?); + if let Some(target) = Target::from_property_type(property_type) { + return Ok(target); + } + Err(match property_type { + PropertyType::Empty => PyValueError::new_err( + "PropertyType.Empty is WinRT null; pass None without property_type", + ), + PropertyType::Inspectable => PyValueError::new_err( + "PropertyType.Inspectable is not a boxed value; pass the WinRT object without \ + property_type", + ), + PropertyType::OtherType | PropertyType::OtherTypeArray => PyValueError::new_err(format!( + "PropertyType.{} has no language-neutral payload and cannot be created by \ + to_winrt_object()", + payloadless_name(property_type).unwrap_or_default() + )), + _ => unknown(), + }) +} + +enum Boxed<'py> { + /// An existing WinRT object, returned as the same Python object. + Existing(Bound<'py, DynWinRTValue>), + /// WinRT null or a new box. + New(WinRTValue), +} + +/// How the default rules read a Python value that is not `None`, a native +/// value or an exact `bool`, `int`, `float` or `str`. +enum Kind { + /// A value with exactly one WinRT scalar type. + Scalar(Element), + /// A plain `int` (or subclass), which boxes only as Int32. + Int, + /// `bytes`, `bytearray` or `memoryview`. + Bytes, + /// A `dynwinrt.values` typed array. + TypedArray(Element), + /// A `list` or `tuple`, boxed only when its elements are homogeneous. + Sequence, + /// An enum member, which needs an explicit integer `property_type`. + Enum, +} + +struct Boxer<'a> { + model: &'a ValueModel, +} + +impl Boxer<'_> { + fn new_box<'py>(&self, data: &Data) -> PyResult> { + dynwinrt::box_property_value(data) + .map(Boxed::New) + .map_err(map_dynwinrt_error) + } + + /// Box `value` by the default rules, in this order: None, native values + /// and wrappers, bool, tags, enums (rejected), int (Int32 only), float, + /// str, datetime, timedelta, UUID/WinGUID, bytes-like values, dynwinrt + /// geometry, typed arrays, then homogeneous lists and tuples. + fn box_value<'py>(&self, value: &Bound<'py, PyAny>, depth: usize) -> PyResult> { + // Exact built-in scalars cannot match an earlier rule. + if value.is_exact_instance_of::() { + return self.new_box(&plain_int(value)?); + } + if value.is_exact_instance_of::() { + return self.new_box(&Data::String(value.extract()?)); + } + if value.is_exact_instance_of::() { + return self.new_box(&Data::Double(value.extract()?)); + } + if let Ok(boolean) = value.cast::() { + return self.new_box(&Data::Boolean(boolean.is_true())); + } + if value.is_none() { + return Ok(Boxed::New(WinRTValue::Null)); + } + if let Some(raw) = native_input(value)? { + let kind = { + let native = live(&raw)?; + (!is_object(&native.0)).then(|| value_kind(&native.0)) + }; + return match kind { + None => Ok(Boxed::Existing(raw)), + Some(kind) => Err(PyTypeError::new_err(format!( + "to_winrt_object() received a DynWinRTValue holding a non-object {kind} \ + value; pass the Python value instead, with a dynwinrt.values tag or \ + property_type= to choose its WinRT type" + ))), + }; + } + match self.classify(value)? { + Some(Kind::Scalar(element)) => self.new_box(&self.scalar(value, element, None)?), + Some(Kind::Int) => self.new_box(&plain_int(value)?), + Some(Kind::Bytes) => self.new_box(&Data::UInt8Array(bytes_like(value)?)), + Some(Kind::TypedArray(element)) => { + self.new_box(&self.array(&items(value)?, element, depth)?) + } + Some(Kind::Sequence) => self.new_box(&self.infer_array(&items(value)?, depth)?), + Some(Kind::Enum) => Err(PyTypeError::new_err(format!( + "to_winrt_object() does not box the enum member {} without property_type: a WinRT \ + enum boxes as IReference, which is not supported yet. To box its integer \ + value, pass an integer property_type such as \ + dynwinrt.values.PropertyType.Int32 (UInt32 for flags), or int(value)", + repr(value) + ))), + None => Err(PyTypeError::new_err(format!( + "cannot convert {} to a WinRT Object; expected {SUPPORTED_INPUTS}. Pass \ + property_type= to convert other values, such as a generated \ + Windows.Foundation.Point, explicitly", + type_name(value) + ))), + } + } + + fn classify(&self, value: &Bound<'_, PyAny>) -> PyResult> { + let py = value.py(); + let model = self.model; + if value.is_instance(model.scalar_base.bind(py))? { + return match ValueModel::instance_of(&model.tags, value)? { + Some(element) => Ok(Some(Kind::Scalar(element))), + None => Err(PyTypeError::new_err(format!( + "{} is not a dynwinrt.values tag; use a tag such as dynwinrt.values.UInt32", + type_name(value) + ))), + }; + } + if model.is_enum(value)? { + return Ok(Some(Kind::Enum)); + } + if value.is_instance_of::() { + return Ok(Some(Kind::Int)); + } + if value.is_instance_of::() { + return Ok(Some(Kind::Scalar(Element::Double))); + } + if value.is_instance_of::() { + return Ok(Some(Kind::Scalar(Element::String))); + } + if value.is_instance(model.datetime.bind(py))? { + return Ok(Some(Kind::Scalar(Element::DateTime))); + } + if value.is_instance(model.timedelta.bind(py))? { + return Ok(Some(Kind::Scalar(Element::TimeSpan))); + } + if value.is_instance(model.uuid.bind(py))? || value.cast::().is_ok() { + return Ok(Some(Kind::Scalar(Element::Guid))); + } + if value.is_instance_of::() + || value.is_instance_of::() + || value.is_instance_of::() + { + return Ok(Some(Kind::Bytes)); + } + if let Some(element) = ValueModel::instance_of(&model.geometry, value)? { + return Ok(Some(Kind::Scalar(element))); + } + if value.is_instance(model.array_base.bind(py))? { + return match ValueModel::instance_of(&model.arrays, value)? { + Some(element) => Ok(Some(Kind::TypedArray(element))), + None => Err(PyTypeError::new_err(format!( + "{} is not a dynwinrt.values typed array; use one such as \ + dynwinrt.values.Int32Array", + type_name(value) + ))), + }; + } + if value.is_instance_of::() || value.is_instance_of::() { + return Ok(Some(Kind::Sequence)); + } + Ok(None) + } + + /// The array element type a list item implies, if it is unambiguous. + fn element_kind(&self, item: &Bound<'_, PyAny>) -> PyResult> { + if item.is_exact_instance_of::() { + return Ok(Some(Element::Int32)); + } + if item.is_exact_instance_of::() { + return Ok(Some(Element::String)); + } + if item.is_exact_instance_of::() { + return Ok(Some(Element::Double)); + } + if item.cast::().is_ok() { + return Ok(Some(Element::Boolean)); + } + if item.is_none() || native_input(item)?.is_some() { + return Ok(None); + } + Ok(match self.classify(item)? { + Some(Kind::Scalar(element)) => Some(element), + Some(Kind::Int) => Some(Element::Int32), + _ => None, + }) + } + + /// Box a homogeneous list or tuple as the matching array type. + fn infer_array(&self, items: &[Bound<'_, PyAny>], depth: usize) -> PyResult { + let Some(first) = items.first() else { + return Err(PyTypeError::new_err(EMPTY_SEQUENCE)); + }; + let mut inferred: Option = None; + for (index, item) in items.iter().enumerate() { + let Some(element) = self.element_kind(item)? else { + return Err(PyTypeError::new_err(format!( + "cannot infer a WinRT array type for a list containing {} (element {index}); \ + a list boxes only when all of its elements have the same unambiguous type. \ + Pass property_type= (for example dynwinrt.values.PropertyType.\ + InspectableArray) or a typed array such as \ + dynwinrt.values.InspectableArray([...])", + type_name(item) + ))); + }; + match inferred { + None => inferred = Some(element), + Some(existing) if existing == element => {} + Some(_) => { + return Err(PyTypeError::new_err(format!( + "cannot infer a WinRT array type for a list mixing {} (element 0) and {} \ + (element {index}); pass property_type= or a typed array such as \ + dynwinrt.values.InspectableArray([...])", + type_name(first), + type_name(item) + ))); + } + } + } + let element = inferred.expect("a non-empty list infers an element type"); + if element != Element::Int32 { + return self.array(items, element, depth); + } + // Plain ints box only as Int32Array; Int32 tags are exact. + items + .iter() + .enumerate() + .map(|(index, item)| { + let label = format!("element {index}"); + let data = if item.is_instance(self.model.scalar_base.bind(item.py()))? { + self.scalar(item, Element::Int32, Some(&label))? + } else { + plain_int_element(item, index)? + }; + match data { + Data::Int32(number) => Ok(number), + _ => unreachable!("an Int32 conversion returns Int32"), + } + }) + .collect::>() + .map(Data::Int32Array) + } + + /// Box a Python value as exactly `target`. + fn box_as<'py>(&self, value: &Bound<'py, PyAny>, target: Target) -> PyResult> { + if value.is_none() { + return Err(PyTypeError::new_err( + "property_type= converts a Python value; pass None without property_type for \ + WinRT null", + )); + } + if native_input(value)?.is_some() { + return Err(PyTypeError::new_err(format!( + "property_type= converts a Python value; {} is already a WinRT object, so pass \ + it without property_type", + type_name(value) + ))); + } + let data = match target { + Target::Scalar(element) => self.scalar(value, element, None)?, + Target::Array(element) => { + let bytes = element == Element::UInt8 + && (value.is_instance_of::() + || value.is_instance_of::() + || value.is_instance_of::()); + if bytes { + Data::UInt8Array(bytes_like(value)?) + } else if value.is_instance_of::() || value.is_instance_of::() { + self.array(&items(value)?, element, 0)? + } else { + return Err(PyTypeError::new_err(format!( + "PropertyType.{}Array requires a list or tuple{}, not {}", + element.name(), + if element == Element::UInt8 { + ", bytes, bytearray or memoryview" + } else { + "" + }, + type_name(value) + ))); + } + } + }; + self.new_box(&data) + } + + /// Convert one Python value to exactly `element`. `label` names the + /// enclosing array element in error messages. + fn scalar( + &self, + value: &Bound<'_, PyAny>, + element: Element, + label: Option<&str>, + ) -> PyResult { + let py = value.py(); + let model = self.model; + let type_error = |expected: &str| { + PyTypeError::new_err(with_label( + label, + format!( + "WinRT {} requires {expected}, not {}", + element.name(), + type_name(value) + ), + )) + }; + if let Some((low, high)) = element.integer_bounds() { + let number = self.integer(value, element, label)?; + if number < low || number > high { + return Err(PyOverflowError::new_err(with_label( + label, + format!( + "{number} is out of range for WinRT {} ({low}..{high})", + element.name() + ), + ))); + } + return Ok(match element { + Element::UInt8 => Data::UInt8(number as u8), + Element::Int16 => Data::Int16(number as i16), + Element::UInt16 => Data::UInt16(number as u16), + Element::Int32 => Data::Int32(number as i32), + Element::UInt32 => Data::UInt32(number as u32), + Element::Int64 => Data::Int64(number as i64), + _ => Data::UInt64(number as u64), + }); + } + if model.is_enum(value)? { + return Err(PyTypeError::new_err(with_label( + label, + format!( + "WinRT {} cannot box the enum member {}; enum members box only with an \ + integer PropertyType", + element.name(), + repr(value) + ), + ))); + } + Ok(match element { + Element::Single => Data::Single(to_f32( + value, + real(value, "WinRT Single", label)?, + "WinRT Single", + label, + )?), + Element::Double => Data::Double(real(value, "WinRT Double", label)?), + Element::Char16 => Data::Char16(char16_unit(value, label)?), + Element::Boolean => match value.cast::() { + Ok(boolean) => Data::Boolean(boolean.is_true()), + Err(_) => return Err(type_error("a bool")), + }, + Element::String => { + if !value.is_instance_of::() { + return Err(type_error("a str")); + } + Data::String(value.extract()?) + } + Element::DateTime => { + if !value.is_instance(model.datetime.bind(py))? { + return Err(type_error("a timezone-aware datetime.datetime")); + } + if value.call_method0(intern!(py, "utcoffset"))?.is_none() { + return Err(PyValueError::new_err(with_label( + label, + "WinRT DateTime requires a timezone-aware datetime.datetime, not a naive \ + one; attach tzinfo, for example datetime.now(timezone.utc)" + .into(), + ))); + } + Data::DateTime(DateTime { + UniversalTime: model.datetime_to_ticks.call1(py, (value,))?.extract(py)?, + }) + } + Element::TimeSpan => { + if !value.is_instance(model.timedelta.bind(py))? { + return Err(type_error("a datetime.timedelta")); + } + let ticks = model.timedelta_to_ticks.call1(py, (value,))?; + Data::TimeSpan(TimeSpan { + Duration: ticks.extract(py).map_err(|_| { + PyOverflowError::new_err(with_label( + label, + format!("{} is out of range for WinRT TimeSpan", repr(value)), + )) + })?, + }) + } + Element::Guid => { + if let Ok(guid) = value.cast::() { + Data::Guid(guid.borrow().0) + } else if value.is_instance(model.uuid.bind(py))? { + Data::Guid(GUID::from_u128( + value.getattr(intern!(py, "int"))?.extract()?, + )) + } else { + return Err(type_error("a uuid.UUID or WinGUID")); + } + } + Element::Point | Element::Size | Element::Rect => { + let fields = self.geometry_fields(value, element, label)?; + match element { + Element::Point => Data::Point(Point { + X: fields[0], + Y: fields[1], + }), + Element::Size => Data::Size(Size { + Width: fields[0], + Height: fields[1], + }), + _ => Data::Rect(Rect { + X: fields[0], + Y: fields[1], + Width: fields[2], + Height: fields[3], + }), + } + } + Element::Inspectable + | Element::UInt8 + | Element::Int16 + | Element::UInt16 + | Element::Int32 + | Element::UInt32 + | Element::Int64 + | Element::UInt64 => unreachable!("not a non-integer scalar element"), + }) + } + + /// The integer value of an int, or of an enum member boxed with an + /// explicit integer type. + fn integer( + &self, + value: &Bound<'_, PyAny>, + element: Element, + label: Option<&str>, + ) -> PyResult { + let py = value.py(); + let expected = |what: &str| { + PyTypeError::new_err(with_label( + label, + format!( + "WinRT {} requires {what}, not {}", + element.name(), + type_name(value) + ), + )) + }; + let number = if value.cast::().is_ok() { + return Err(expected("an int")); + } else if self.model.is_enum(value)? { + py.get_type::() + .call1((value,)) + .map_err(|_| expected("an int or an int-valued enum member"))? + } else if value.is_instance_of::() { + value.clone() + } else { + return Err(expected("an int")); + }; + number.extract::().map_err(|_| { + let (low, high) = element.integer_bounds().unwrap_or_default(); + PyOverflowError::new_err(with_label( + label, + format!( + "{} is out of range for WinRT {} ({low}..{high})", + repr(&number), + element.name() + ), + )) + }) + } + + fn geometry_fields( + &self, + value: &Bound<'_, PyAny>, + element: Element, + label: Option<&str>, + ) -> PyResult> { + let fields = element.fields(); + if let Some(found) = ValueModel::instance_of(&self.model.geometry, value)? + && found != element + { + return Err(PyTypeError::new_err(with_label( + label, + format!( + "WinRT {} requires a dynwinrt.values.{} or an object with numeric {} \ + attributes, not dynwinrt.values.{}", + element.name(), + element.name(), + fields.join("/"), + found.name() + ), + ))); + } + fields + .iter() + .map(|field| { + let attribute = value.getattr_opt(*field)?.ok_or_else(|| { + PyTypeError::new_err(with_label( + label, + format!( + "WinRT {} requires a dynwinrt.values.{} or an object with numeric {} \ + attributes, not {}", + element.name(), + element.name(), + fields.join("/"), + type_name(value) + ), + )) + })?; + let name = format!("WinRT {}.{field}", element.name()); + to_f32(&attribute, real(&attribute, &name, label)?, &name, label) + }) + .collect() + } + + /// Convert every item to exactly `element`, naming the failing element. + fn array(&self, items: &[Bound<'_, PyAny>], element: Element, depth: usize) -> PyResult { + let label = |index: usize| format!("{}Array element {index}", element.name()); + macro_rules! collect { + ($variant:ident, $scalar:ident) => { + Data::$variant( + items + .iter() + .enumerate() + .map(|(index, item)| { + match self.scalar(item, element, Some(&label(index)))? { + Data::$scalar(value) => Ok(value), + _ => unreachable!("scalar returns the requested element"), + } + }) + .collect::>()?, + ) + }; + } + Ok(match element { + Element::UInt8 => collect!(UInt8Array, UInt8), + Element::Int16 => collect!(Int16Array, Int16), + Element::UInt16 => collect!(UInt16Array, UInt16), + Element::Int32 => collect!(Int32Array, Int32), + Element::UInt32 => collect!(UInt32Array, UInt32), + Element::Int64 => collect!(Int64Array, Int64), + Element::UInt64 => collect!(UInt64Array, UInt64), + Element::Single => collect!(SingleArray, Single), + Element::Double => collect!(DoubleArray, Double), + Element::Char16 => collect!(Char16Array, Char16), + Element::Boolean => collect!(BooleanArray, Boolean), + Element::String => collect!(StringArray, String), + Element::DateTime => collect!(DateTimeArray, DateTime), + Element::TimeSpan => collect!(TimeSpanArray, TimeSpan), + Element::Guid => collect!(GuidArray, Guid), + Element::Point => collect!(PointArray, Point), + Element::Size => collect!(SizeArray, Size), + Element::Rect => collect!(RectArray, Rect), + Element::Inspectable => { + if depth >= MAX_NESTING { + return Err(PyRecursionError::new_err(format!( + "WinRT InspectableArray values cannot nest more than {MAX_NESTING} levels" + ))); + } + Data::InspectableArray( + items + .iter() + .enumerate() + .map(|(index, item)| { + self.inspectable_element(item, depth + 1) + .map_err(|error| with_context(item.py(), error, &label(index))) + }) + .collect::>()?, + ) + } + }) + } + + /// An `InspectableArray` element, converted by the default rules. + fn inspectable_element( + &self, + item: &Bound<'_, PyAny>, + depth: usize, + ) -> PyResult> { + Ok(match self.box_value(item, depth)? { + Boxed::Existing(object) => live(&object)?.0.as_object(), + Boxed::New(value) => value.as_object(), + }) + } +} + +/// A plain `int`, which boxes only as Int32. +fn plain_int(value: &Bound<'_, PyAny>) -> PyResult { + int32(value).map_err(|error| match error { + Some(error) => error, + None => PyOverflowError::new_err(format!( + "{} does not fit in Int32, the only WinRT type a plain int boxes as; pass \ + property_type= (for example dynwinrt.values.PropertyType.Int64 or \ + PropertyType.UInt64) or a tag such as dynwinrt.values.Int64(value)", + repr(value) + )), + }) +} + +/// An element of a list of plain ints, which boxes only as Int32Array. +fn plain_int_element(value: &Bound<'_, PyAny>, index: usize) -> PyResult { + int32(value).map_err(|error| match error { + Some(error) => error, + None => PyOverflowError::new_err(format!( + "element {index}: {} does not fit in Int32, and a list of plain ints boxes only as \ + Int32Array; pass property_type= (for example dynwinrt.values.PropertyType.\ + Int64Array or PropertyType.UInt64Array) or a typed array such as \ + dynwinrt.values.Int64Array([...])", + repr(value) + )), + }) +} + +/// `value` as Int32; `Err(None)` when it is an int outside the Int32 range. +fn int32(value: &Bound<'_, PyAny>) -> Result> { + match value.extract::() { + Ok(number) => Ok(Data::Int32(number)), + Err(error) if error.is_instance_of::(value.py()) => Err(None), + Err(error) => Err(Some(error)), + } +} + +fn real(value: &Bound<'_, PyAny>, name: &str, label: Option<&str>) -> PyResult { + if value.cast::().is_ok() + || !(value.is_instance_of::() || value.is_instance_of::()) + { + return Err(PyTypeError::new_err(with_label( + label, + format!("{name} requires a real number, not {}", type_name(value)), + ))); + } + value.extract::().map_err(|_| { + PyOverflowError::new_err(with_label( + label, + format!("{} is out of range for {name}", repr(value)), + )) + }) +} + +/// `value` (already read as `number`) rounded to float32, rejecting finite +/// values beyond the float32 range. +fn to_f32(value: &Bound<'_, PyAny>, number: f64, name: &str, label: Option<&str>) -> PyResult { + let single = number as f32; + if number.is_finite() && single.is_infinite() { + return Err(PyOverflowError::new_err(with_label( + label, + format!("{} is out of range for {name}", repr(value)), + ))); + } + Ok(single) +} + +fn char16_unit(value: &Bound<'_, PyAny>, label: Option<&str>) -> PyResult { + let Ok(text) = value.cast::() else { + return Err(PyTypeError::new_err(with_label( + label, + format!("WinRT Char16 requires a str, not {}", type_name(value)), + ))); + }; + // PyUnicode_ReadChar keeps unpaired surrogates, unlike Rust strings. + let unit = if unsafe { ffi::PyUnicode_GetLength(text.as_ptr()) } == 1 { + u16::try_from(unsafe { ffi::PyUnicode_ReadChar(text.as_ptr(), 0) }).ok() + } else { + None + }; + unit.ok_or_else(|| { + PyValueError::new_err(with_label( + label, + format!( + "WinRT Char16 requires exactly one UTF-16 code unit, not {}", + repr(value) + ), + )) + }) +} + +fn bytes_like(value: &Bound<'_, PyAny>) -> PyResult> { + if let Ok(bytes) = value.cast::() { + return Ok(bytes.as_bytes().to_vec()); + } + if let Ok(bytes) = value.cast::() { + return Ok(bytes.to_vec()); + } + // memoryview: Python's bytes() semantics, the raw bytes of the buffer. + Ok(value + .call_method0(intern!(value.py(), "tobytes"))? + .cast_into::()? + .as_bytes() + .to_vec()) +} + +fn items<'py>(value: &Bound<'py, PyAny>) -> PyResult>> { + value.try_iter()?.collect() +} diff --git a/bindings/py/src/runtime.rs b/bindings/py/src/runtime.rs index 3b717856..eafd1242 100644 --- a/bindings/py/src/runtime.rs +++ b/bindings/py/src/runtime.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use dynwinrt; use pyo3::exceptions::{PyIndexError, PyOverflowError, PyRuntimeError, PyTypeError}; use pyo3::prelude::*; -use pyo3::types::{PyBytes, PyDict, PyList}; +use pyo3::types::PyDict; use windows::core::{GUID, HSTRING, IUnknown, Interface}; use crate::errors::{map_dynwinrt_error, map_dynwinrt_error_with_context, map_windows_error}; @@ -224,99 +224,6 @@ pub fn ro_uninitialize() { unsafe { RoUninitialize() }; } -fn python_uuid(py: Python<'_>, value: GUID) -> PyResult> { - Ok(py - .import("uuid")? - .getattr("UUID")? - .call1((format!("{value:?}"),))? - .unbind()) -} - -fn python_char(py: Python<'_>, value: u16) -> PyResult> { - Ok(py - .import("builtins")? - .getattr("chr")? - .call1((u32::from(value),))? - .unbind()) -} - -fn property_value_to_python( - py: Python<'_>, - value: dynwinrt::PropertyValueData, -) -> PyResult> { - use dynwinrt::PropertyValueData; - - macro_rules! into_python { - ($value:expr) => { - Ok($value.into_pyobject(py)?.into_any().unbind()) - }; - } - - match value { - PropertyValueData::UInt8(value) => into_python!(value), - PropertyValueData::Int16(value) => into_python!(value), - PropertyValueData::UInt16(value) => into_python!(value), - PropertyValueData::Int32(value) => into_python!(value), - PropertyValueData::UInt32(value) => into_python!(value), - PropertyValueData::Int64(value) => into_python!(value), - PropertyValueData::UInt64(value) => into_python!(value), - PropertyValueData::Single(value) => into_python!(value), - PropertyValueData::Double(value) => into_python!(value), - PropertyValueData::Char16(value) => python_char(py, value), - PropertyValueData::Boolean(value) => { - Ok(value.into_pyobject(py)?.to_owned().into_any().unbind()) - } - PropertyValueData::String(value) => into_python!(value), - PropertyValueData::Guid(value) => python_uuid(py, value), - PropertyValueData::UInt8Array(value) => Ok(PyBytes::new(py, &value).into_any().unbind()), - PropertyValueData::Int16Array(value) => into_python!(value), - PropertyValueData::UInt16Array(value) => into_python!(value), - PropertyValueData::Int32Array(value) => into_python!(value), - PropertyValueData::UInt32Array(value) => into_python!(value), - PropertyValueData::Int64Array(value) => into_python!(value), - PropertyValueData::UInt64Array(value) => into_python!(value), - PropertyValueData::SingleArray(value) => into_python!(value), - PropertyValueData::DoubleArray(value) => into_python!(value), - PropertyValueData::Char16Array(value) => { - let values = value - .into_iter() - .map(|value| python_char(py, value)) - .collect::>>()?; - Ok(PyList::new(py, values)?.into_any().unbind()) - } - PropertyValueData::BooleanArray(value) => into_python!(value), - PropertyValueData::StringArray(value) => into_python!(value), - PropertyValueData::GuidArray(value) => { - let values = value - .into_iter() - .map(|value| python_uuid(py, value)) - .collect::>>()?; - Ok(PyList::new(py, values)?.into_any().unbind()) - } - } -} - -/// Explicitly unbox a supported WinRT `IPropertyValue`. -/// -/// Python `None` and WinRT null are returned as `None`. A non-`IPropertyValue` -/// `DynWinRTValue` is returned unchanged, preserving Python and COM identity. -#[pyfunction] -pub fn unbox_object(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult> { - if value.is_none() { - return Ok(py.None()); - } - - let result = { - let raw = value.extract::>()?; - dynwinrt::unbox_property_value(&raw.0).map_err(map_dynwinrt_error)? - }; - match result { - dynwinrt::PropertyValueUnboxResult::Null => Ok(py.None()), - dynwinrt::PropertyValueUnboxResult::NotPropertyValue => Ok(value.clone().unbind()), - dynwinrt::PropertyValueUnboxResult::Value(value) => property_value_to_python(py, value), - } -} - // ====================================================================== // Process-local named XAML runtime classes // ====================================================================== diff --git a/bindings/py/tests/test_e2e_winrt.py b/bindings/py/tests/test_e2e_winrt.py index b9df955b..edda63b0 100644 --- a/bindings/py/tests/test_e2e_winrt.py +++ b/bindings/py/tests/test_e2e_winrt.py @@ -453,13 +453,13 @@ def test_explicit_unbox_object_for_device_property_values(self): ) ) date_time.set_i64(0, 0) - unsupported = statics_h["CreateDateTime"].invoke( + boxed_date_time = statics_h["CreateDateTime"].invoke( factory, [date_time.to_value()] ) - import pytest + from datetime import datetime, timezone - with pytest.raises(OSError, match="Unsupported WinRT IPropertyValue type"): - unbox_object(unsupported) + # DateTime is supported now: tick 0 is 1601-01-01 UTC. + assert unbox_object(boxed_date_time) == datetime(1601, 1, 1, tzinfo=timezone.utc) key_type = DynWinRTType.hstring() object_type = DynWinRTType.object() diff --git a/bindings/py/tests/test_object_values.py b/bindings/py/tests/test_object_values.py new file mode 100644 index 00000000..7c0bbd07 --- /dev/null +++ b/bindings/py/tests/test_object_values.py @@ -0,0 +1,756 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Explicit WinRT ``Object`` conversion: ``unbox_object`` and ``to_winrt_object``. + +Generated code keeps ``Object`` values native; these helpers convert only when +the application asks. Boxes are created here through the real +``Windows.Foundation.PropertyValue`` factory (raw vtable calls, no interface +registrations) and through ``to_winrt_object`` itself. +""" + +import copy +import json +import math +import pickle +from datetime import date, datetime, timedelta, timezone +from enum import Enum, IntEnum, IntFlag, StrEnum +from uuid import UUID + +import pytest + +import dynwinrt +from dynwinrt import ( + DynWinRTArray, + DynWinRTImplementation, + DynWinRTImplementationMethod, + DynWinRTInterfacePlan, + DynWinRTMethodSig, + DynWinRTStruct, + DynWinRTType, + DynWinRTValue, + RoApartment, + WinGUID, + to_winrt_object, + unbox_object, +) +from dynwinrt import values +from dynwinrt.values import PropertyType + +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 +EPOCH = datetime(1601, 1, 1, tzinfo=UTC) + + +@pytest.fixture(scope="module", autouse=True) +def apartment(): + with RoApartment(1): + yield + + +def property_type(raw): + """IPropertyValue.Type through a raw vtable call.""" + view = raw.cast(IPROPERTY_VALUE) + try: + return PropertyType(view.call_0(6, DynWinRTType.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, DynWinRTType.object(), [DynWinRTType.hstring()], [DynWinRTValue.from_hstring(text)] + ) + + +# ---------------------------------------------------------------------- +# Boxes created by the real PropertyValue factory +# ---------------------------------------------------------------------- + +I8 = DynWinRTType.i64_type() +F4 = DynWinRTType.f32_type() +DATE_TIME = DynWinRTType.struct_type("Windows.Foundation.DateTime", [I8]) +TIME_SPAN = DynWinRTType.struct_type("Windows.Foundation.TimeSpan", [I8]) +POINT = DynWinRTType.struct_type("Windows.Foundation.Point", [F4, F4]) +SIZE = DynWinRTType.struct_type("Windows.Foundation.Size", [F4, F4]) +RECT = DynWinRTType.struct_type("Windows.Foundation.Rect", [F4, F4, F4, F4]) +GUID_TEXT = "01234567-89ab-cdef-0123-456789abcdef" +TICKS = 133_000_000_001_234_560 # 2022-06-18, a whole number of microseconds + + +def struct(typ, *fields, setter="set_f32"): + value = DynWinRTStruct.create(typ) + for index, field in enumerate(fields): + getattr(value, setter)(index, field) + return value.to_value() + + +def ticks(typ, value): + return struct(typ, value, setter="set_i64") + + +def created(slot, in_type, argument): + """Call IPropertyValueStatics.Create* (vtable ``slot``) directly.""" + factory = DynWinRTValue.activation_factory("Windows.Foundation.PropertyValue") + statics = factory.cast(IPROPERTY_VALUE_STATICS) + try: + return statics.call(slot, DynWinRTType.object(), [in_type], [argument]) + finally: + statics.release() + factory.release() + + +def array(values_, element_type): + return DynWinRTArray.from_values(values_, element_type).to_value() + + +def factory_cases(): + t = DynWinRTType + at = DynWinRTType.array_type + moment = EPOCH + timedelta(microseconds=TICKS // 10) + return [ + # (PropertyType, slot, input type, input or input factory, plain value, preserved type) + (PropertyType.UInt8, 7, t.u8_type(), DynWinRTValue.from_u8(255), 255, values.UInt8), + (PropertyType.Int16, 8, t.i16_type(), DynWinRTValue.from_i16(-32768), -32768, values.Int16), + (PropertyType.UInt16, 9, t.u16_type(), DynWinRTValue.from_u16(65535), 65535, values.UInt16), + (PropertyType.Int32, 10, t.i32_type(), DynWinRTValue.from_i32(-(2**31)), -(2**31), int), + (PropertyType.UInt32, 11, t.u32_type(), DynWinRTValue.from_u32(2**32 - 1), 2**32 - 1, values.UInt32), + (PropertyType.Int64, 12, t.i64_type(), DynWinRTValue.from_i64(-(2**63)), -(2**63), values.Int64), + (PropertyType.UInt64, 13, t.u64_type(), DynWinRTValue.from_u64(2**64 - 1), 2**64 - 1, values.UInt64), + (PropertyType.Single, 14, t.f32_type(), DynWinRTValue.from_f32(0.1), values.Single(0.1), values.Single), + (PropertyType.Double, 15, t.f64_type(), DynWinRTValue.from_f64(-0.1), -0.1, float), + (PropertyType.Char16, 16, t.char16(), DynWinRTValue.from_u16(0xD800), "\ud800", values.Char16), + (PropertyType.Boolean, 17, t.bool_type(), DynWinRTValue.from_bool(True), True, bool), + (PropertyType.String, 18, t.hstring(), DynWinRTValue.from_hstring("BLE Device"), "BLE Device", str), + (PropertyType.Guid, 20, t.guid_type(), DynWinRTValue.from_guid(WinGUID.parse(GUID_TEXT)), UUID(GUID_TEXT), UUID), + (PropertyType.DateTime, 21, DATE_TIME, ticks(DATE_TIME, TICKS), moment, datetime), + (PropertyType.TimeSpan, 22, TIME_SPAN, ticks(TIME_SPAN, -12_345_670), timedelta(microseconds=-1_234_567), timedelta), + (PropertyType.Point, 23, POINT, struct(POINT, 1.5, -2.25), values.Point(1.5, -2.25), values.Point), + (PropertyType.Size, 24, SIZE, struct(SIZE, 3.0, 4.5), values.Size(3.0, 4.5), values.Size), + (PropertyType.Rect, 25, RECT, struct(RECT, 1, 2, 3, 4), values.Rect(1, 2, 3, 4), values.Rect), + (PropertyType.UInt8Array, 26, at(t.u8_type()), DynWinRTArray.from_u8_values([0, 1, 255]).to_value(), b"\x00\x01\xff", bytes), + (PropertyType.Int16Array, 27, at(t.i16_type()), DynWinRTArray.from_i16_values([-32768, 7]).to_value(), [-32768, 7], values.Int16Array), + (PropertyType.UInt16Array, 28, at(t.u16_type()), DynWinRTArray.from_u16_values([0, 65535]).to_value(), [0, 65535], values.UInt16Array), + (PropertyType.Int32Array, 29, at(t.i32_type()), DynWinRTArray.from_i32_values([-1, 0, 42]).to_value(), [-1, 0, 42], values.Int32Array), + (PropertyType.UInt32Array, 30, at(t.u32_type()), DynWinRTArray.from_u32_values([0, 2**32 - 1]).to_value(), [0, 2**32 - 1], values.UInt32Array), + (PropertyType.Int64Array, 31, at(t.i64_type()), DynWinRTArray.from_i64_values([-(2**63), 2**63 - 1]).to_value(), [-(2**63), 2**63 - 1], values.Int64Array), + (PropertyType.UInt64Array, 32, at(t.u64_type()), DynWinRTArray.from_u64_values([0, 2**64 - 1]).to_value(), [0, 2**64 - 1], values.UInt64Array), + (PropertyType.SingleArray, 33, at(t.f32_type()), DynWinRTArray.from_f32_values([0.5, -1.25]).to_value(), [0.5, -1.25], values.SingleArray), + (PropertyType.DoubleArray, 34, at(t.f64_type()), DynWinRTArray.from_f64_values([0.1, 2.0]).to_value(), [0.1, 2.0], values.DoubleArray), + (PropertyType.Char16Array, 35, at(t.char16()), DynWinRTArray.from_u16_values([0xD800, 0x61]).to_value(), ["\ud800", "a"], values.Char16Array), + (PropertyType.BooleanArray, 36, at(t.bool_type()), array([DynWinRTValue.from_bool(True), DynWinRTValue.from_bool(False)], t.bool_type()), [True, False], values.BooleanArray), + (PropertyType.StringArray, 37, at(t.hstring()), DynWinRTArray.from_string_values(["one", ""]).to_value(), ["one", ""], values.StringArray), + (PropertyType.InspectableArray, 38, at(t.object()), lambda: array([DynWinRTValue.null_value(), created(10, t.i32_type(), DynWinRTValue.from_i32(7))], t.object()), [None, 7], values.InspectableArray), + (PropertyType.GuidArray, 39, at(t.guid_type()), array([DynWinRTValue.from_guid(WinGUID.parse(GUID_TEXT))], t.guid_type()), [UUID(GUID_TEXT)], values.GuidArray), + (PropertyType.DateTimeArray, 40, at(DATE_TIME), array([ticks(DATE_TIME, TICKS), ticks(DATE_TIME, 0)], DATE_TIME), [moment, EPOCH], values.DateTimeArray), + (PropertyType.TimeSpanArray, 41, at(TIME_SPAN), array([ticks(TIME_SPAN, 10)], TIME_SPAN), [timedelta(microseconds=1)], values.TimeSpanArray), + (PropertyType.PointArray, 42, at(POINT), array([struct(POINT, 1, 2)], POINT), [values.Point(1, 2)], values.PointArray), + (PropertyType.SizeArray, 43, at(SIZE), array([struct(SIZE, 1, 2), struct(SIZE, 0, 0)], SIZE), [values.Size(1, 2), values.Size()], values.SizeArray), + (PropertyType.RectArray, 44, at(RECT), array([struct(RECT, 1, 2, 3, 4)], RECT), [values.Rect(1, 2, 3, 4)], values.RectArray), + ] + + +FACTORY_CASES = factory_cases() + + +def test_the_factory_matrix_covers_every_boxable_property_type(): + boxable = set(PropertyType) - { + PropertyType.Empty, + PropertyType.Inspectable, + PropertyType.OtherType, + PropertyType.OtherTypeArray, + } + assert {case[0] for case in FACTORY_CASES} == boxable + assert len(boxable) == 37 + + +@pytest.mark.parametrize( + ("kind", "slot", "in_type", "argument", "plain", "preserved_type"), + FACTORY_CASES, + ids=[case[0].name for case in FACTORY_CASES], +) +def test_factory_boxes_round_trip_with_their_exact_property_type( + kind, slot, in_type, argument, plain, preserved_type +): + boxed = created(slot, in_type, argument() if callable(argument) else argument) + assert property_type(boxed) == kind + + exact = unbox_object(boxed, preserve_type=True) + assert type(exact) is preserved_type + assert exact == plain + + reboxed = to_winrt_object(exact) + assert property_type(reboxed) == kind + assert unbox_object(reboxed, preserve_type=True) == exact + assert type(unbox_object(reboxed, preserve_type=True)) is preserved_type + # Re-boxing keeps the PropertyType and value, not the box's COM identity. + assert reboxed.identity_raw() != boxed.identity_raw() + + +LEGACY_TYPES = { + PropertyType.UInt8: int, + PropertyType.Int16: int, + PropertyType.UInt16: int, + PropertyType.Int32: int, + PropertyType.UInt32: int, + PropertyType.Int64: int, + PropertyType.UInt64: int, + PropertyType.Single: float, + PropertyType.Double: float, + PropertyType.Char16: str, + PropertyType.Boolean: bool, + PropertyType.String: str, + PropertyType.Guid: UUID, + PropertyType.UInt8Array: bytes, +} + + +@pytest.mark.parametrize( + ("kind", "slot", "in_type", "argument", "plain", "preserved_type"), + FACTORY_CASES, + ids=[case[0].name for case in FACTORY_CASES], +) +def test_default_unboxing_returns_plain_python_values( + kind, slot, in_type, argument, plain, preserved_type +): + unboxed = unbox_object(created(slot, in_type, argument() if callable(argument) else argument)) + assert unboxed == plain + if kind in LEGACY_TYPES: + # Unchanged from the previous release: exact built-in types. + assert type(unboxed) is LEGACY_TYPES[kind] + elif kind.name.endswith("Array"): + assert type(unboxed) is list + assert not any(isinstance(item, values.WinRTScalar) for item in unboxed) + else: + assert type(unboxed) is preserved_type + assert not isinstance(unboxed, values.WinRTScalar) + + +def test_already_supported_arrays_keep_their_element_types(): + int64s = unbox_object(created(31, DynWinRTType.array_type(I8), DynWinRTArray.from_i64_values([1]).to_value())) + chars = unbox_object(created(35, DynWinRTType.array_type(DynWinRTType.char16()), DynWinRTArray.from_u16_values([0x61]).to_value())) + assert type(int64s) is list and type(int64s[0]) is int + assert type(chars) is list and type(chars[0]) is str + + +def test_datetime_and_timespan_truncate_to_microseconds(): + assert unbox_object(created(21, DATE_TIME, ticks(DATE_TIME, 7))) == EPOCH + assert unbox_object(created(21, DATE_TIME, ticks(DATE_TIME, -7))) == EPOCH + assert unbox_object(created(22, TIME_SPAN, ticks(TIME_SPAN, -7))) == timedelta(0) + moment = datetime(2024, 5, 6, 7, 8, 9, 123456, tzinfo=timezone(timedelta(hours=2))) + unboxed = unbox_object(to_winrt_object(moment)) + assert unboxed == moment and unboxed.tzinfo is UTC + + +def test_datetime_outside_the_python_range_raises(): + boxed = created(21, DATE_TIME, ticks(DATE_TIME, 2**62)) + with pytest.raises(OverflowError, match="outside the range of datetime.datetime"): + unbox_object(boxed) + + +def test_inspectable_arrays_unbox_their_elements_by_the_same_rules(): + raw = uri() + nested = to_winrt_object(values.InspectableArray([values.UInt16(4), "x"])) + boxed = to_winrt_object(values.InspectableArray([None, raw, 3, nested])) + plain = unbox_object(boxed) + assert type(plain) is list + assert plain[0] is None + assert isinstance(plain[1], DynWinRTValue) and plain[1].identity_raw() == raw.identity_raw() + assert plain[2] == 3 and plain[3] == [4, "x"] and type(plain[3][0]) is int + + exact = unbox_object(boxed, preserve_type=True) + assert type(exact) is values.InspectableArray + assert type(exact[3]) is values.InspectableArray and type(exact[3][0]) is values.UInt16 + reboxed = unbox_object(to_winrt_object(exact), preserve_type=True) + assert reboxed[1].identity_raw() == raw.identity_raw() + assert [reboxed[0], reboxed[2], reboxed[3]] == [exact[0], exact[2], exact[3]] + assert type(reboxed[3][0]) is values.UInt16 + + +def test_inspectable_array_nesting_is_bounded(): + deep = values.InspectableArray([1]) + for _ in range(63): + deep = values.InspectableArray([deep]) + boxed = to_winrt_object(deep) # 64 levels + read = unbox_object(boxed) + for _ in range(64): + read = read[0] + assert read == 1 + deeper = to_winrt_object(values.InspectableArray([boxed])) # 65 levels + with pytest.raises(RecursionError, match="64 levels"): + unbox_object(deeper) + with pytest.raises(RecursionError, match="64 levels"): + to_winrt_object(values.InspectableArray([deep])) + cyclic = values.InspectableArray([1]) + cyclic.append(cyclic) + with pytest.raises(RecursionError): + to_winrt_object(cyclic) + + +# ---------------------------------------------------------------------- +# Identity and unsupported boxes +# ---------------------------------------------------------------------- + + +def test_non_boxes_and_null_keep_their_identity(): + raw = uri() + identity = raw.identity_raw() + assert unbox_object(raw) is raw + assert unbox_object(raw, preserve_type=True) is raw + assert raw.identity_raw() == identity + assert to_winrt_object(raw) is raw + + class Wrapper: + def __init__(self, obj): + self._obj = obj + + assert to_winrt_object(Wrapper(raw)) is raw + null = DynWinRTValue.null_value() + assert unbox_object(null) is None and unbox_object(None) is None + assert to_winrt_object(null) is null + assert to_winrt_object(None).is_null() + number = DynWinRTValue.from_i32(5) + assert unbox_object(number) is number + with pytest.raises(TypeError, match="holding a non-object I32 value"): + to_winrt_object(number) + with pytest.raises(TypeError, match="requires None or a DynWinRTValue"): + unbox_object(5) + + +def _custom_property_value(type_value): + """A Python-implemented IPropertyValue whose Type() reports ``type_value``.""" + t = DynWinRTType + at = DynWinRTType.array_type + scalar_types = [ + t.bool_type(), t.u8_type(), t.i16_type(), t.u16_type(), t.i32_type(), t.u32_type(), + t.i64_type(), t.u64_type(), t.f32_type(), t.f64_type(), t.char16(), t.bool_type(), + t.hstring(), t.guid_type(), DATE_TIME, TIME_SPAN, POINT, SIZE, RECT, + ] + array_types = [ + t.u8_type(), t.i16_type(), t.u16_type(), t.i32_type(), t.u32_type(), t.i64_type(), + t.u64_type(), t.f32_type(), t.f64_type(), t.char16(), t.bool_type(), t.hstring(), + t.object(), t.guid_type(), DATE_TIME, TIME_SPAN, POINT, SIZE, RECT, + ] + signatures = [DynWinRTMethodSig().add_out(t.i32_type())] + signatures += [DynWinRTMethodSig().add_out(typ) for typ in scalar_types] + signatures += [DynWinRTMethodSig().add_out(at(typ)) for typ in array_types] + methods = [ + DynWinRTImplementationMethod(f"Method{slot}", slot, signature) + for slot, signature in enumerate(signatures, 6) + ] + plan = DynWinRTInterfacePlan.create( + "Windows.Foundation.IPropertyValue", DynWinRTType.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(type_value)] + + return DynWinRTImplementation.create([plan], dispatch) + + +@pytest.mark.parametrize( + ("type_value", "message"), + [ + (PropertyType.Empty, "Unsupported WinRT IPropertyValue type: 0 (Empty)"), + (PropertyType.Inspectable, "Unsupported WinRT IPropertyValue type: 13 (Inspectable)"), + (PropertyType.OtherType, "Unsupported WinRT IPropertyValue type: 20 (OtherType)"), + (PropertyType.OtherTypeArray, "Unsupported WinRT IPropertyValue type: 1044 (OtherTypeArray)"), + (0x7FFF, "Unsupported WinRT IPropertyValue type: 32767"), + ], +) +def test_payloadless_property_types_still_raise(type_value, message): + with _custom_property_value(int(type_value)) as owner: + raw = owner.to_value() + try: + for preserve_type in (False, True): + with pytest.raises(OSError) as caught: + unbox_object(raw, preserve_type=preserve_type) + assert caught.value.winerror == E_NOTIMPL + assert str(caught.value.strerror) == message + # 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]))) + finally: + raw.release() + + +# ---------------------------------------------------------------------- +# to_winrt_object without property_type +# ---------------------------------------------------------------------- + + +class Color(IntEnum): + Red = 1 + + +class Flags(IntFlag): + High = 0x8000_0000 + + +class Plain(Enum): + A = 1 + + +class Letter(StrEnum): + A = "a" + + +class GeneratedPoint: + """Shape of a generated windows.foundation.Point struct.""" + + __slots__ = ("x", "y") + + def __init__(self, x=0.0, y=0.0): + self.x = x + self.y = y + + +INFERRED = [ + # (value, PropertyType, unboxed with preserve_type=True) + (True, PropertyType.Boolean, True), + (5, PropertyType.Int32, 5), + (-(2**31), PropertyType.Int32, -(2**31)), + (1.5, PropertyType.Double, 1.5), + ("text", PropertyType.String, "text"), + (datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=UTC), PropertyType.DateTime, datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=UTC)), + (timedelta(seconds=-3.5), PropertyType.TimeSpan, timedelta(seconds=-3.5)), + (UUID(int=7), PropertyType.Guid, UUID(int=7)), + (WinGUID.parse(GUID_TEXT), PropertyType.Guid, UUID(GUID_TEXT)), + (b"\x00\xff", PropertyType.UInt8Array, b"\x00\xff"), + (bytearray(b"ab"), PropertyType.UInt8Array, b"ab"), + (memoryview(b"xyz"), PropertyType.UInt8Array, b"xyz"), + (values.UInt8(255), PropertyType.UInt8, values.UInt8(255)), + (values.Int16(-5), PropertyType.Int16, values.Int16(-5)), + (values.UInt16(9), PropertyType.UInt16, values.UInt16(9)), + (values.Int32(3), PropertyType.Int32, 3), + (values.UInt32(2**32 - 1), PropertyType.UInt32, values.UInt32(2**32 - 1)), + (values.Int64(-(2**63)), PropertyType.Int64, values.Int64(-(2**63))), + (values.UInt64(2**64 - 1), PropertyType.UInt64, values.UInt64(2**64 - 1)), + (values.Single(0.1), PropertyType.Single, values.Single(0.1)), + (values.Double(2), PropertyType.Double, 2.0), + (values.Char16("\ud800"), PropertyType.Char16, values.Char16("\ud800")), + (values.Point(1.5, 2.5), PropertyType.Point, values.Point(1.5, 2.5)), + (values.Size(3, 4), PropertyType.Size, values.Size(3, 4)), + (values.Rect(1, 2, 3, 4), PropertyType.Rect, values.Rect(1, 2, 3, 4)), + ([1, 2], PropertyType.Int32Array, [1, 2]), + ((1, 2), PropertyType.Int32Array, [1, 2]), + ([values.Int32(1), 2], PropertyType.Int32Array, [1, 2]), + (["a", ""], PropertyType.StringArray, ["a", ""]), + ([True, False], PropertyType.BooleanArray, [True, False]), + ([1.5, values.Double(2)], PropertyType.DoubleArray, [1.5, 2.0]), + ([values.UInt32(1), values.UInt32(2)], PropertyType.UInt32Array, [1, 2]), + ([values.UInt8(1), values.UInt8(2)], PropertyType.UInt8Array, b"\x01\x02"), + ([values.Char16("a")], PropertyType.Char16Array, ["a"]), + ([EPOCH], PropertyType.DateTimeArray, [EPOCH]), + ([timedelta(1)], PropertyType.TimeSpanArray, [timedelta(1)]), + ([UUID(int=1), WinGUID.parse(GUID_TEXT)], PropertyType.GuidArray, [UUID(int=1), UUID(GUID_TEXT)]), + ([values.Point(1, 2)], PropertyType.PointArray, [values.Point(1, 2)]), + ([values.Size(1, 2)], PropertyType.SizeArray, [values.Size(1, 2)]), + ([values.Rect(1, 2, 3, 4)], PropertyType.RectArray, [values.Rect(1, 2, 3, 4)]), + (values.StringArray([]), PropertyType.StringArray, []), + (values.UInt16Array([1, 2]), PropertyType.UInt16Array, [1, 2]), + (values.InspectableArray([]), PropertyType.InspectableArray, []), + (values.InspectableArray([None, 1, "a"]), PropertyType.InspectableArray, [None, 1, "a"]), +] + + +@pytest.mark.parametrize( + ("value", "kind", "expected"), + INFERRED, + ids=[f"{index}-{case[1].name}" for index, case in enumerate(INFERRED)], +) +def test_unambiguous_values_box_as_their_exact_property_type(value, kind, expected): + boxed = to_winrt_object(value) + assert property_type(boxed) == kind + read = unbox_object(boxed, preserve_type=True) + assert read == expected + assert type(read) is type(expected) or isinstance(read, values.WinRTArray) + assert property_type(to_winrt_object(read)) == kind + + +@pytest.mark.parametrize( + ("value", "error", "message"), + [ + (2**31, OverflowError, r"does not fit in Int32.*property_type=.*dynwinrt\.values\.Int64"), + (-(2**31) - 1, OverflowError, "does not fit in Int32"), + (2**100, OverflowError, "does not fit in Int32"), + ([1, 2**40], OverflowError, r"element 1: .*Int32Array.*Int64Array"), + ([], TypeError, "empty list or tuple"), + ((), TypeError, "empty list or tuple"), + ([1, "a"], TypeError, r"mixing int \(element 0\) and str \(element 1\)"), + ([1, 2.5], TypeError, "mixing int"), + ([True, 1], TypeError, "mixing bool"), + ([values.UInt32(1), 2], TypeError, "mixing UInt32"), + ([1, None], TypeError, r"containing None \(element 1\)"), + ([[1]], TypeError, "containing list"), + ([b"x"], TypeError, "containing bytes"), + ([Color.Red], TypeError, "containing Color"), + (datetime(2024, 1, 1), ValueError, "timezone-aware"), + ([datetime(2024, 1, 1)], ValueError, "DateTimeArray element 0: .*timezone-aware"), + (Color.Red, TypeError, r"enum member .*IReference.*property_type"), + (Flags.High, TypeError, "enum member"), + (Letter.A, TypeError, "enum member"), + (Plain.A, TypeError, "enum member"), + (date(2024, 1, 1), TypeError, "cannot convert date to a WinRT Object"), + (object(), TypeError, "cannot convert object"), + ({1: 2}, TypeError, "cannot convert dict"), + (1 + 2j, TypeError, "cannot convert complex"), + (GeneratedPoint(1, 2), TypeError, "cannot convert GeneratedPoint.*property_type="), + (timedelta.max, OverflowError, "out of range for WinRT TimeSpan"), + (values.UInt32Array([1, -1]), OverflowError, r"UInt32Array element 1: -1 is out of range"), + (values.StringArray([1]), TypeError, "StringArray element 0: WinRT String requires a str"), + (values.InspectableArray([2**40]), OverflowError, "InspectableArray element 0: .*does not fit in Int32"), + (values.InspectableArray([Color.Red]), TypeError, "InspectableArray element 0: .*enum member"), + (values.WinRTArray([1]), TypeError, "not a dynwinrt.values typed array"), + (DynWinRTValue.from_u32(5), TypeError, "holding a non-object U32 value"), + ], +) +def test_ambiguous_or_unsupported_values_raise(value, error, message): + with pytest.raises(error, match=message): + to_winrt_object(value) + + +# ---------------------------------------------------------------------- +# to_winrt_object with property_type +# ---------------------------------------------------------------------- + +EXPLICIT = [ + # (value, property_type, unboxed with preserve_type=True) + (5, PropertyType.UInt8, values.UInt8(5)), + (5, PropertyType.Int64, values.Int64(5)), + (2**63, PropertyType.UInt64, values.UInt64(2**63)), + (values.UInt8(7), PropertyType.Int16, values.Int16(7)), + (5, PropertyType.Double, 5.0), + (0.1, PropertyType.Single, values.Single(0.1)), + ("a", PropertyType.Char16, values.Char16("a")), + (values.Char16("b"), PropertyType.String, "b"), + (False, PropertyType.Boolean, False), + (UUID(int=9), PropertyType.Guid, UUID(int=9)), + (Color.Red, PropertyType.Int32, 1), + (Flags.High, PropertyType.UInt32, values.UInt32(0x8000_0000)), + (GeneratedPoint(1, 2), PropertyType.Point, values.Point(1, 2)), + (values.Point(1, 2), PropertyType.Point, values.Point(1, 2)), + ([1, 2], PropertyType.UInt8Array, b"\x01\x02"), + (b"\x01", PropertyType.UInt8Array, b"\x01"), + ([1, 2], PropertyType.Int64Array, [1, 2]), + ([], PropertyType.StringArray, []), + ((0.5,), PropertyType.SingleArray, [0.5]), + ([Color.Red, Flags.High], PropertyType.UInt32Array, [1, 0x8000_0000]), + (values.Int32Array([1]), PropertyType.Int64Array, [1]), + ([GeneratedPoint(1, 2)], PropertyType.PointArray, [values.Point(1, 2)]), + ([1, "a", None, [True]], PropertyType.InspectableArray, [1, "a", None, [True]]), + ([], PropertyType.InspectableArray, []), +] + + +@pytest.mark.parametrize( + ("value", "kind", "expected"), + EXPLICIT, + ids=[f"{index}-{case[1].name}" for index, case in enumerate(EXPLICIT)], +) +def test_property_type_boxes_exactly_that_type(value, kind, expected): + boxed = to_winrt_object(value, kind) + assert property_type(boxed) == kind + assert to_winrt_object(value, property_type=kind) is not boxed + read = unbox_object(boxed, preserve_type=True) + assert read == expected + assert type(read) is type(expected) or isinstance(read, values.WinRTArray) + + +def test_property_type_accepts_equal_ints_and_other_int_enums(): + class GeneratedPropertyType(IntEnum): + UInt16 = 3 + + assert property_type(to_winrt_object(5, 5)) == PropertyType.UInt32 + assert property_type(to_winrt_object(5, GeneratedPropertyType.UInt16)) == PropertyType.UInt16 + assert property_type(to_winrt_object(5, property_type=None)) == PropertyType.Int32 + + +@pytest.mark.parametrize( + ("value", "kind", "error", "message"), + [ + (300, PropertyType.UInt8, OverflowError, r"300 is out of range for WinRT UInt8 \(0..255\)"), + (-1, PropertyType.UInt64, OverflowError, "out of range for WinRT UInt64"), + (2**64, PropertyType.UInt64, OverflowError, "out of range for WinRT UInt64"), + (True, PropertyType.Int32, TypeError, "WinRT Int32 requires an int, not bool"), + (1.5, PropertyType.Int32, TypeError, "WinRT Int32 requires an int, not float"), + ("1", PropertyType.Int32, TypeError, "requires an int"), + (Flags.High, PropertyType.Int32, OverflowError, "out of range for WinRT Int32"), + (Plain.A, PropertyType.Int32, TypeError, "int-valued enum member"), + (Color.Red, PropertyType.Double, TypeError, "enum members box only with an integer"), + (Letter.A, PropertyType.String, TypeError, "enum members box only with an integer"), + (True, PropertyType.Double, TypeError, "requires a real number"), + (1e39, PropertyType.Single, OverflowError, r"1e\+39 is out of range for WinRT Single"), + ("ab", PropertyType.Char16, ValueError, "exactly one UTF-16 code unit"), + ("\U0001f600", PropertyType.Char16, ValueError, "exactly one UTF-16 code unit"), + (97, PropertyType.Char16, TypeError, "requires a str"), + (1, PropertyType.Boolean, TypeError, "requires a bool"), + (5, PropertyType.String, TypeError, "requires a str"), + (GUID_TEXT, PropertyType.Guid, TypeError, "requires a uuid.UUID or WinGUID"), + (datetime(2024, 1, 1), PropertyType.DateTime, ValueError, "timezone-aware"), + (5, PropertyType.TimeSpan, TypeError, "requires a datetime.timedelta"), + (values.Rect(), PropertyType.Point, TypeError, "not dynwinrt.values.Rect"), + (GeneratedPoint(), PropertyType.Size, TypeError, "numeric width/height attributes"), + (GeneratedPoint("x", 1), PropertyType.Point, TypeError, "WinRT Point.x requires a real number"), + (b"ab", PropertyType.Int32Array, TypeError, "Int32Array requires a list or tuple"), + ("abc", PropertyType.Char16Array, TypeError, "Char16Array requires a list or tuple"), + ({1}, PropertyType.UInt8Array, TypeError, "list or tuple, bytes, bytearray or memoryview"), + ([1, -1], PropertyType.UInt32Array, OverflowError, "UInt32Array element 1: -1 is out of range"), + ([1, None], PropertyType.Int32Array, TypeError, "Int32Array element 1: WinRT Int32 requires an int"), + ([[1, "a"]], PropertyType.InspectableArray, TypeError, "InspectableArray element 0: .*mixing"), + (None, PropertyType.Int32, TypeError, "pass None without property_type"), + (DynWinRTValue.null_value(), PropertyType.Int32, TypeError, "already a WinRT object"), + (1, PropertyType.Empty, ValueError, "PropertyType.Empty is WinRT null"), + (1, PropertyType.Inspectable, ValueError, "PropertyType.Inspectable is not a boxed value"), + (1, PropertyType.OtherType, ValueError, "OtherType has no language-neutral payload"), + (1, PropertyType.OtherTypeArray, ValueError, "OtherTypeArray has no language-neutral payload"), + (1, 99, ValueError, "99 is not a Windows.Foundation.PropertyType value"), + (1, 2**40, ValueError, "is not a Windows.Foundation.PropertyType value"), + (1, True, TypeError, "property_type must be a dynwinrt.values.PropertyType member or an int"), + (1, "UInt32", TypeError, "property_type must be"), + ], +) +def test_property_type_validates_range_and_convertibility(value, kind, error, message): + with pytest.raises(error, match=message): + to_winrt_object(value, kind) + + +def test_existing_objects_cannot_be_combined_with_property_type(): + raw = to_winrt_object(5) + with pytest.raises(TypeError, match="already a WinRT object"): + to_winrt_object(raw, PropertyType.Int32) + + +# ---------------------------------------------------------------------- +# The dynwinrt.values data model +# ---------------------------------------------------------------------- + + +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" + assert "to_winrt_object" in dynwinrt.__all__ and "unbox_object" in dynwinrt.__all__ + namespace = {} + exec("from dynwinrt import *", namespace) + assert not {"Point", "Size", "Rect", "PropertyType", "UInt32", "values"} & set(namespace) + + +def test_property_type_mirrors_windows_foundation_property_type(): + assert [(member.name, member.value) for member in PropertyType][:21] == [ + ("Empty", 0), ("UInt8", 1), ("Int16", 2), ("UInt16", 3), ("Int32", 4), + ("UInt32", 5), ("Int64", 6), ("UInt64", 7), ("Single", 8), ("Double", 9), + ("Char16", 10), ("Boolean", 11), ("String", 12), ("Inspectable", 13), + ("DateTime", 14), ("TimeSpan", 15), ("Guid", 16), ("Point", 17), ("Size", 18), + ("Rect", 19), ("OtherType", 20), + ] + for member in list(PropertyType)[1:21]: + assert PropertyType[f"{member.name}Array"] == member.value + 1024 + + +@pytest.mark.parametrize( + ("tag", "low", "high"), + [ + (values.UInt8, 0, 2**8 - 1), + (values.Int16, -(2**15), 2**15 - 1), + (values.UInt16, 0, 2**16 - 1), + (values.Int32, -(2**31), 2**31 - 1), + (values.UInt32, 0, 2**32 - 1), + (values.Int64, -(2**63), 2**63 - 1), + (values.UInt64, 0, 2**64 - 1), + ], +) +def test_integer_tags_validate_their_range_and_behave_like_int(tag, low, high): + assert tag(low) == low and tag(high) == high + for invalid in (low - 1, high + 1): + with pytest.raises(OverflowError, match=tag.__name__): + tag(invalid) + with pytest.raises(TypeError, match=f"WinRT {tag.__name__} requires an int"): + tag(1.5) + value = tag(5) + assert isinstance(value, int) and isinstance(value, values.WinRTScalar) + assert value.property_type == PropertyType[tag.__name__] + assert value == 5 and hash(value) == hash(5) and {value: "x"}[5] == "x" + assert repr(value) == f"dynwinrt.values.{tag.__name__}(5)" + assert str(value) == "5" and f"{value}" == "5" and f"{value:03}" == "005" + assert json.dumps({"n": value}) == '{"n": 5}' + assert type(value + 1) is int and value + 1 == 6 + assert type(-value) is int and type(value * 2) is int + for protocol in range(pickle.HIGHEST_PROTOCOL + 1): + restored = pickle.loads(pickle.dumps(value, protocol)) + assert type(restored) is tag and restored == 5 + assert type(copy.deepcopy(value)) is tag + assert tag(Color.Red) == 1 + + +def test_float_and_char16_tags(): + single = values.Single(0.1) + assert single != 0.1 and single == 0.10000000149011612 + assert repr(single) == "dynwinrt.values.Single(0.10000000149011612)" + assert str(single) == "0.10000000149011612" + assert json.dumps(single) == "0.10000000149011612" + assert type(single * 2) is float + assert math.isinf(values.Single(float("inf"))) and math.isnan(values.Single(float("nan"))) + with pytest.raises(OverflowError, match="Single"): + values.Single(1e39) + with pytest.raises(TypeError, match="real number"): + values.Single("1.5") + double = values.Double(3) + assert double == 3.0 and type(double + 1) is float + assert repr(double) == "dynwinrt.values.Double(3.0)" + assert type(pickle.loads(pickle.dumps(single))) is values.Single + + char = values.Char16("\ud800") + assert isinstance(char, str) and char == "\ud800" and len(char) == 1 + assert repr(char) == "dynwinrt.values.Char16('\\ud800')" + assert type(char + "x") is str and str(char) == "\ud800" + with pytest.raises(ValueError): + values.Char16("ab") + with pytest.raises(ValueError): + values.Char16("\U0001f600") + with pytest.raises(TypeError): + values.Char16(97) + assert type(pickle.loads(pickle.dumps(values.Char16("a")))) is values.Char16 + + +def test_typed_arrays_are_lists_that_remember_their_type(): + items = values.UInt32Array([1, 2]) + assert isinstance(items, list) and isinstance(items, values.WinRTArray) + assert items == [1, 2] and items.property_type == PropertyType.UInt32Array + assert repr(items) == "dynwinrt.values.UInt32Array([1, 2])" + assert type(items.copy()) is values.UInt32Array + assert type(copy.copy(items)) is values.UInt32Array + assert type(pickle.loads(pickle.dumps(items))) is values.UInt32Array + assert json.dumps(items) == "[1, 2]" + assert type(items[:1]) is list and type(items + [3]) is list + assert not hasattr(values, "UInt8Array") # bytes is the UInt8Array form + + +def test_geometry_values_are_immutable_float32_values(): + point = values.Point(0.1, 2) + assert point.x == values.Single(0.1) and point.y == 2.0 + assert repr(point) == "dynwinrt.values.Point(x=0.10000000149011612, y=2.0)" + with pytest.raises(AttributeError, match="immutable"): + point.x = 3 + assert point == values.Point(x=0.1, y=2.0) + assert hash(point) == hash(values.Point(0.1, 2.0)) + assert point != values.Size(0.1, 2.0) and point != (0.1, 2.0) + assert point != GeneratedPoint(0.1, 2.0) + assert point.property_type == PropertyType.Point + assert pickle.loads(pickle.dumps(point)) == point and copy.copy(point) == point + match values.Rect(1, 2, 3, 4): + case values.Rect(x, y, width, height): + assert (x, y, width, height) == (1.0, 2.0, 3.0, 4.0) + assert values.Size(width=3).width == 3.0 and values.Size().height == 0.0 + with pytest.raises(TypeError): + values.Point(1, 2, 3) + with pytest.raises(OverflowError): + values.Point(1e39, 0) + with pytest.raises(TypeError, match="real number"): + values.Point("1", 0) diff --git a/crates/dynwinrt/src/lib.rs b/crates/dynwinrt/src/lib.rs index 4465e6a9..82e4cd77 100644 --- a/crates/dynwinrt/src/lib.rs +++ b/crates/dynwinrt/src/lib.rs @@ -60,7 +60,7 @@ pub use crate::element_factory::{ pub use crate::ibuffer::{copy_from_ibuffer, copy_to_ibuffer}; pub use crate::metadata_table::{MetadataTable, MethodHandle, TypeHandle, TypeKind, ValueTypeData}; pub use crate::property_value::{ - PropertyValueData, PropertyValueUnboxResult, unbox_property_value, + PropertyValueData, PropertyValueUnboxResult, box_property_value, unbox_property_value, }; pub use crate::reference::box_ireference; pub use crate::result::{Error, Result}; diff --git a/crates/dynwinrt/src/property_value.rs b/crates/dynwinrt/src/property_value.rs index f318d8ad..159a570f 100644 --- a/crates/dynwinrt/src/property_value.rs +++ b/crates/dynwinrt/src/property_value.rs @@ -1,15 +1,29 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use windows::Foundation::{IPropertyValue, PropertyType}; -use windows_core::{Array, GUID, HRESULT, HSTRING, Interface}; +//! Language-neutral boxing and unboxing of WinRT `IPropertyValue` objects. +//! +//! This module is the single core definition of which +//! `Windows.Foundation.PropertyType` values a projection can convert to and from +//! plain data. Language bindings map [`PropertyValueData`] to their own value +//! model; they never call `IPropertyValue` getters or `PropertyValue` factories +//! themselves. + +use windows::Foundation::{ + DateTime, IPropertyValue, Point, PropertyType, PropertyValue, Rect, Size, TimeSpan, +}; +use windows_core::{Array, GUID, HRESULT, HSTRING, IInspectable, IUnknown, Interface}; use crate::{Error, Result, WinRTValue}; const E_NOINTERFACE: HRESULT = HRESULT(0x80004002_u32 as i32); -const E_NOTIMPL: HRESULT = HRESULT(0x80004001_u32 as i32); -/// A language-neutral value explicitly read from a WinRT `IPropertyValue`. +/// The payload of a WinRT `IPropertyValue`, for every `PropertyType` that has a +/// language-neutral representation. +/// +/// `Empty`, `Inspectable`, `OtherType` and `OtherTypeArray` carry no payload a +/// projection could reproduce, so reading them yields +/// [`PropertyValueUnboxResult::Unsupported`] instead of a value. #[derive(Debug, Clone, PartialEq)] pub enum PropertyValueData { UInt8(u8), @@ -25,6 +39,11 @@ pub enum PropertyValueData { Boolean(bool), String(String), Guid(GUID), + DateTime(DateTime), + TimeSpan(TimeSpan), + Point(Point), + Size(Size), + Rect(Rect), UInt8Array(Vec), Int16Array(Vec), UInt16Array(Vec), @@ -37,17 +56,75 @@ pub enum PropertyValueData { Char16Array(Vec), BooleanArray(Vec), StringArray(Vec), + /// Arbitrary WinRT objects; `None` is a null element. Bindings convert the + /// elements with their own `Object` conversion, recursively. + InspectableArray(Vec>), + DateTimeArray(Vec), + TimeSpanArray(Vec), GuidArray(Vec), + PointArray(Vec), + SizeArray(Vec), + RectArray(Vec), } -/// Result of explicitly attempting to unbox a raw WinRT object. +impl PropertyValueData { + /// The `Windows.Foundation.PropertyType` this payload boxes as. + pub fn property_type(&self) -> PropertyType { + match self { + Self::UInt8(_) => PropertyType::UInt8, + Self::Int16(_) => PropertyType::Int16, + Self::UInt16(_) => PropertyType::UInt16, + Self::Int32(_) => PropertyType::Int32, + Self::UInt32(_) => PropertyType::UInt32, + Self::Int64(_) => PropertyType::Int64, + Self::UInt64(_) => PropertyType::UInt64, + Self::Single(_) => PropertyType::Single, + Self::Double(_) => PropertyType::Double, + Self::Char16(_) => PropertyType::Char16, + Self::Boolean(_) => PropertyType::Boolean, + Self::String(_) => PropertyType::String, + Self::Guid(_) => PropertyType::Guid, + Self::DateTime(_) => PropertyType::DateTime, + Self::TimeSpan(_) => PropertyType::TimeSpan, + Self::Point(_) => PropertyType::Point, + Self::Size(_) => PropertyType::Size, + Self::Rect(_) => PropertyType::Rect, + Self::UInt8Array(_) => PropertyType::UInt8Array, + Self::Int16Array(_) => PropertyType::Int16Array, + Self::UInt16Array(_) => PropertyType::UInt16Array, + Self::Int32Array(_) => PropertyType::Int32Array, + Self::UInt32Array(_) => PropertyType::UInt32Array, + Self::Int64Array(_) => PropertyType::Int64Array, + Self::UInt64Array(_) => PropertyType::UInt64Array, + Self::SingleArray(_) => PropertyType::SingleArray, + Self::DoubleArray(_) => PropertyType::DoubleArray, + Self::Char16Array(_) => PropertyType::Char16Array, + Self::BooleanArray(_) => PropertyType::BooleanArray, + Self::StringArray(_) => PropertyType::StringArray, + Self::InspectableArray(_) => PropertyType::InspectableArray, + Self::DateTimeArray(_) => PropertyType::DateTimeArray, + Self::TimeSpanArray(_) => PropertyType::TimeSpanArray, + Self::GuidArray(_) => PropertyType::GuidArray, + Self::PointArray(_) => PropertyType::PointArray, + Self::SizeArray(_) => PropertyType::SizeArray, + Self::RectArray(_) => PropertyType::RectArray, + } + } +} + +/// Result of attempting to unbox a raw WinRT object. #[derive(Debug, Clone, PartialEq)] pub enum PropertyValueUnboxResult { /// The input was the WinRT null object. Null, /// The input does not implement `IPropertyValue` and must be preserved. NotPropertyValue, - /// The input was a supported boxed property value. + /// The input is an `IPropertyValue` whose `PropertyType` has no + /// language-neutral payload (`Empty`, `Inspectable`, `OtherType`, + /// `OtherTypeArray`, or a value unknown to this version). The explicit + /// unboxing helpers of the language bindings report it as an error. + Unsupported(PropertyType), + /// The input was a boxed value with a language-neutral payload. Value(PropertyValueData), } @@ -59,11 +136,13 @@ macro_rules! read_array { }}; } -/// Explicitly reads a supported `IPropertyValue` without consuming or mutating `value`. +/// Reads a boxed value without consuming or mutating `value`. /// -/// A QueryInterface failure with `E_NOINTERFACE` is reported as -/// [`PropertyValueUnboxResult::NotPropertyValue`]. Other QueryInterface failures, -/// unsupported `PropertyType` values, and getter failures are surfaced as errors. +/// Null and non-`IPropertyValue` objects are classified rather than rejected, +/// and so are `IPropertyValue` objects without a language-neutral payload. A +/// QueryInterface failure other than `E_NOINTERFACE` and a failing getter for a +/// supported `PropertyType` are surfaced as errors: those objects are broken, +/// not merely unsupported. pub fn unbox_property_value(value: &WinRTValue) -> Result { if value.is_null_object() { return Ok(PropertyValueUnboxResult::Null); @@ -80,129 +159,280 @@ pub fn unbox_property_value(value: &WinRTValue) -> Result return Err(Error::WindowsError(error)), }; - let property_type = property_value.Type()?; - let value = match property_type { - PropertyType::UInt8 => PropertyValueData::UInt8(property_value.GetUInt8()?), - PropertyType::Int16 => PropertyValueData::Int16(property_value.GetInt16()?), - PropertyType::UInt16 => PropertyValueData::UInt16(property_value.GetUInt16()?), - PropertyType::Int32 => PropertyValueData::Int32(property_value.GetInt32()?), - PropertyType::UInt32 => PropertyValueData::UInt32(property_value.GetUInt32()?), - PropertyType::Int64 => PropertyValueData::Int64(property_value.GetInt64()?), - PropertyType::UInt64 => PropertyValueData::UInt64(property_value.GetUInt64()?), - PropertyType::Single => PropertyValueData::Single(property_value.GetSingle()?), - PropertyType::Double => PropertyValueData::Double(property_value.GetDouble()?), - PropertyType::Char16 => PropertyValueData::Char16(property_value.GetChar16()?), - PropertyType::Boolean => PropertyValueData::Boolean(property_value.GetBoolean()?), - PropertyType::String => PropertyValueData::String(property_value.GetString()?.to_string()), - PropertyType::Guid => PropertyValueData::Guid(property_value.GetGuid()?), + let pv = &property_value; + let data = match pv.Type()? { + PropertyType::UInt8 => PropertyValueData::UInt8(pv.GetUInt8()?), + PropertyType::Int16 => PropertyValueData::Int16(pv.GetInt16()?), + PropertyType::UInt16 => PropertyValueData::UInt16(pv.GetUInt16()?), + PropertyType::Int32 => PropertyValueData::Int32(pv.GetInt32()?), + PropertyType::UInt32 => PropertyValueData::UInt32(pv.GetUInt32()?), + PropertyType::Int64 => PropertyValueData::Int64(pv.GetInt64()?), + PropertyType::UInt64 => PropertyValueData::UInt64(pv.GetUInt64()?), + PropertyType::Single => PropertyValueData::Single(pv.GetSingle()?), + PropertyType::Double => PropertyValueData::Double(pv.GetDouble()?), + PropertyType::Char16 => PropertyValueData::Char16(pv.GetChar16()?), + PropertyType::Boolean => PropertyValueData::Boolean(pv.GetBoolean()?), + PropertyType::String => PropertyValueData::String(pv.GetString()?.to_string()), + PropertyType::Guid => PropertyValueData::Guid(pv.GetGuid()?), + PropertyType::DateTime => PropertyValueData::DateTime(pv.GetDateTime()?), + PropertyType::TimeSpan => PropertyValueData::TimeSpan(pv.GetTimeSpan()?), + PropertyType::Point => PropertyValueData::Point(pv.GetPoint()?), + PropertyType::Size => PropertyValueData::Size(pv.GetSize()?), + PropertyType::Rect => PropertyValueData::Rect(pv.GetRect()?), PropertyType::UInt8Array => { - PropertyValueData::UInt8Array(read_array!(property_value, GetUInt8Array, u8)) + PropertyValueData::UInt8Array(read_array!(pv, GetUInt8Array, u8)) } PropertyType::Int16Array => { - PropertyValueData::Int16Array(read_array!(property_value, GetInt16Array, i16)) + PropertyValueData::Int16Array(read_array!(pv, GetInt16Array, i16)) } PropertyType::UInt16Array => { - PropertyValueData::UInt16Array(read_array!(property_value, GetUInt16Array, u16)) + PropertyValueData::UInt16Array(read_array!(pv, GetUInt16Array, u16)) } PropertyType::Int32Array => { - PropertyValueData::Int32Array(read_array!(property_value, GetInt32Array, i32)) + PropertyValueData::Int32Array(read_array!(pv, GetInt32Array, i32)) } PropertyType::UInt32Array => { - PropertyValueData::UInt32Array(read_array!(property_value, GetUInt32Array, u32)) + PropertyValueData::UInt32Array(read_array!(pv, GetUInt32Array, u32)) } PropertyType::Int64Array => { - PropertyValueData::Int64Array(read_array!(property_value, GetInt64Array, i64)) + PropertyValueData::Int64Array(read_array!(pv, GetInt64Array, i64)) } PropertyType::UInt64Array => { - PropertyValueData::UInt64Array(read_array!(property_value, GetUInt64Array, u64)) + PropertyValueData::UInt64Array(read_array!(pv, GetUInt64Array, u64)) } PropertyType::SingleArray => { - PropertyValueData::SingleArray(read_array!(property_value, GetSingleArray, f32)) + PropertyValueData::SingleArray(read_array!(pv, GetSingleArray, f32)) } PropertyType::DoubleArray => { - PropertyValueData::DoubleArray(read_array!(property_value, GetDoubleArray, f64)) + PropertyValueData::DoubleArray(read_array!(pv, GetDoubleArray, f64)) } PropertyType::Char16Array => { - PropertyValueData::Char16Array(read_array!(property_value, GetChar16Array, u16)) + PropertyValueData::Char16Array(read_array!(pv, GetChar16Array, u16)) } PropertyType::BooleanArray => { - PropertyValueData::BooleanArray(read_array!(property_value, GetBooleanArray, bool)) + PropertyValueData::BooleanArray(read_array!(pv, GetBooleanArray, bool)) + } + PropertyType::StringArray => PropertyValueData::StringArray( + read_array!(pv, GetStringArray, HSTRING) + .into_iter() + .map(|value| value.to_string()) + .collect(), + ), + PropertyType::InspectableArray => PropertyValueData::InspectableArray( + read_array!(pv, GetInspectableArray, IInspectable) + .into_iter() + .map(|value| value.map(IUnknown::from)) + .collect(), + ), + PropertyType::DateTimeArray => { + PropertyValueData::DateTimeArray(read_array!(pv, GetDateTimeArray, DateTime)) } - PropertyType::StringArray => { - let values = read_array!(property_value, GetStringArray, HSTRING); - PropertyValueData::StringArray( - values.into_iter().map(|value| value.to_string()).collect(), - ) + PropertyType::TimeSpanArray => { + PropertyValueData::TimeSpanArray(read_array!(pv, GetTimeSpanArray, TimeSpan)) } PropertyType::GuidArray => { - PropertyValueData::GuidArray(read_array!(property_value, GetGuidArray, GUID)) + PropertyValueData::GuidArray(read_array!(pv, GetGuidArray, GUID)) } - _ => { - return Err(Error::WindowsError(windows_core::Error::new( - E_NOTIMPL, - format!("Unsupported WinRT IPropertyValue type: {}", property_type.0), - ))); + PropertyType::PointArray => { + PropertyValueData::PointArray(read_array!(pv, GetPointArray, Point)) } + PropertyType::SizeArray => { + PropertyValueData::SizeArray(read_array!(pv, GetSizeArray, Size)) + } + PropertyType::RectArray => { + PropertyValueData::RectArray(read_array!(pv, GetRectArray, Rect)) + } + unsupported => return Ok(PropertyValueUnboxResult::Unsupported(unsupported)), }; - Ok(PropertyValueUnboxResult::Value(value)) + Ok(PropertyValueUnboxResult::Value(data)) +} + +/// Boxes `data` as a system `Windows.Foundation.PropertyValue`. +/// +/// The result implements `IPropertyValue` (reporting [`PropertyValueData::property_type`]) +/// and the matching `IReference` / `IReferenceArray`, exactly like values +/// boxed by C#, C++/WinRT or `PropertyValue.Create*`. +pub fn box_property_value(data: &PropertyValueData) -> Result { + let boxed = match data { + PropertyValueData::UInt8(value) => PropertyValue::CreateUInt8(*value), + PropertyValueData::Int16(value) => PropertyValue::CreateInt16(*value), + PropertyValueData::UInt16(value) => PropertyValue::CreateUInt16(*value), + PropertyValueData::Int32(value) => PropertyValue::CreateInt32(*value), + PropertyValueData::UInt32(value) => PropertyValue::CreateUInt32(*value), + PropertyValueData::Int64(value) => PropertyValue::CreateInt64(*value), + PropertyValueData::UInt64(value) => PropertyValue::CreateUInt64(*value), + PropertyValueData::Single(value) => PropertyValue::CreateSingle(*value), + PropertyValueData::Double(value) => PropertyValue::CreateDouble(*value), + PropertyValueData::Char16(value) => PropertyValue::CreateChar16(*value), + PropertyValueData::Boolean(value) => PropertyValue::CreateBoolean(*value), + PropertyValueData::String(value) => PropertyValue::CreateString(&HSTRING::from(value)), + PropertyValueData::Guid(value) => PropertyValue::CreateGuid(*value), + PropertyValueData::DateTime(value) => PropertyValue::CreateDateTime(*value), + PropertyValueData::TimeSpan(value) => PropertyValue::CreateTimeSpan(*value), + PropertyValueData::Point(value) => PropertyValue::CreatePoint(*value), + PropertyValueData::Size(value) => PropertyValue::CreateSize(*value), + PropertyValueData::Rect(value) => PropertyValue::CreateRect(*value), + PropertyValueData::UInt8Array(values) => PropertyValue::CreateUInt8Array(values), + PropertyValueData::Int16Array(values) => PropertyValue::CreateInt16Array(values), + PropertyValueData::UInt16Array(values) => PropertyValue::CreateUInt16Array(values), + PropertyValueData::Int32Array(values) => PropertyValue::CreateInt32Array(values), + PropertyValueData::UInt32Array(values) => PropertyValue::CreateUInt32Array(values), + PropertyValueData::Int64Array(values) => PropertyValue::CreateInt64Array(values), + PropertyValueData::UInt64Array(values) => PropertyValue::CreateUInt64Array(values), + PropertyValueData::SingleArray(values) => PropertyValue::CreateSingleArray(values), + PropertyValueData::DoubleArray(values) => PropertyValue::CreateDoubleArray(values), + PropertyValueData::Char16Array(values) => PropertyValue::CreateChar16Array(values), + PropertyValueData::BooleanArray(values) => PropertyValue::CreateBooleanArray(values), + PropertyValueData::StringArray(values) => { + PropertyValue::CreateStringArray(&values.iter().map(HSTRING::from).collect::>()) + } + PropertyValueData::InspectableArray(values) => { + let values = values + .iter() + .map(|value| value.as_ref().map(Interface::cast).transpose()) + .collect::>>>()?; + PropertyValue::CreateInspectableArray(&values) + } + PropertyValueData::DateTimeArray(values) => PropertyValue::CreateDateTimeArray(values), + PropertyValueData::TimeSpanArray(values) => PropertyValue::CreateTimeSpanArray(values), + PropertyValueData::GuidArray(values) => PropertyValue::CreateGuidArray(values), + PropertyValueData::PointArray(values) => PropertyValue::CreatePointArray(values), + PropertyValueData::SizeArray(values) => PropertyValue::CreateSizeArray(values), + PropertyValueData::RectArray(values) => PropertyValue::CreateRectArray(values), + }?; + Ok(WinRTValue::Object(boxed.into())) } #[cfg(test)] mod tests { - use windows::Foundation::{DateTime, PropertyValue, Uri}; - use windows::Win32::System::WinRT::{RO_INIT_MULTITHREADED, RoInitialize}; - use windows_core::{HSTRING, Interface}; + use windows::Foundation::{IPropertyValue_Impl, Uri}; + use windows_core::{HSTRING, implement}; use super::*; + use crate::test_apartment::initialize_mta; + + const E_NOTIMPL: HRESULT = HRESULT(0x80004001_u32 as i32); fn as_value(value: &T) -> WinRTValue { - WinRTValue::Object(value.cast().expect("IPropertyValue as IUnknown")) + WinRTValue::Object(value.cast().expect("WinRT object as IUnknown")) } - #[test] - fn unboxes_scalar_and_array_property_values() -> windows_core::Result<()> { - let _ = unsafe { RoInitialize(RO_INIT_MULTITHREADED) }; - - let string = PropertyValue::CreateString(&HSTRING::from("BLE Device"))?; - assert_eq!( - unbox_property_value(&as_value(&string)).unwrap(), - PropertyValueUnboxResult::Value(PropertyValueData::String("BLE Device".to_string())) - ); - - let int64 = PropertyValue::CreateInt64(i64::MIN)?; - assert_eq!( - unbox_property_value(&as_value(&int64)).unwrap(), - PropertyValueUnboxResult::Value(PropertyValueData::Int64(i64::MIN)) - ); + fn samples() -> Vec { + let uri: IUnknown = Uri::CreateUri(&HSTRING::from("https://example.com")) + .unwrap() + .cast() + .unwrap(); + let boxed = box_property_value(&PropertyValueData::UInt32(7)) + .unwrap() + .as_object() + .unwrap(); + let guid = GUID::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef); + let date = DateTime { + UniversalTime: 133_000_000_000_000_001, + }; + let span = TimeSpan { + Duration: -12_345_678, + }; + let point = Point { X: 1.5, Y: -2.25 }; + let size = Size { + Width: 3.0, + Height: 4.5, + }; + let rect = Rect { + X: 1.0, + Y: 2.0, + Width: 3.0, + Height: 4.0, + }; + vec![ + PropertyValueData::UInt8(u8::MAX), + PropertyValueData::Int16(i16::MIN), + PropertyValueData::UInt16(u16::MAX), + PropertyValueData::Int32(i32::MIN), + PropertyValueData::UInt32(u32::MAX), + PropertyValueData::Int64(i64::MIN), + PropertyValueData::UInt64(u64::MAX), + PropertyValueData::Single(0.1), + PropertyValueData::Double(-0.1), + PropertyValueData::Char16(0xD800), + PropertyValueData::Boolean(true), + PropertyValueData::String("BLE Device".into()), + PropertyValueData::Guid(guid), + PropertyValueData::DateTime(date), + PropertyValueData::TimeSpan(span), + PropertyValueData::Point(point), + PropertyValueData::Size(size), + PropertyValueData::Rect(rect), + PropertyValueData::UInt8Array(vec![0, 1, 127, 255]), + PropertyValueData::Int16Array(vec![i16::MIN, 0, i16::MAX]), + PropertyValueData::UInt16Array(vec![0, u16::MAX]), + PropertyValueData::Int32Array(vec![-1, 0, 42]), + PropertyValueData::UInt32Array(vec![0, u32::MAX]), + PropertyValueData::Int64Array(vec![i64::MIN, i64::MAX]), + PropertyValueData::UInt64Array(vec![0, u64::MAX]), + PropertyValueData::SingleArray(vec![0.5, -1.25]), + PropertyValueData::DoubleArray(vec![0.1, f64::MAX]), + PropertyValueData::Char16Array(vec![0xD800, 0x61]), + PropertyValueData::BooleanArray(vec![true, false]), + PropertyValueData::StringArray(vec!["one".into(), String::new()]), + PropertyValueData::InspectableArray(vec![Some(uri), None, Some(boxed)]), + PropertyValueData::DateTimeArray(vec![date, DateTime::default()]), + PropertyValueData::TimeSpanArray(vec![span]), + PropertyValueData::GuidArray(vec![guid, GUID::zeroed()]), + PropertyValueData::PointArray(vec![point]), + PropertyValueData::SizeArray(vec![size, Size::default()]), + PropertyValueData::RectArray(vec![rect]), + PropertyValueData::Int32Array(Vec::new()), + PropertyValueData::StringArray(Vec::new()), + ] + } - let bytes = PropertyValue::CreateUInt8Array(&[0, 1, 127, 255])?; - assert_eq!( - unbox_property_value(&as_value(&bytes)).unwrap(), - PropertyValueUnboxResult::Value(PropertyValueData::UInt8Array(vec![0, 1, 127, 255])) - ); + #[test] + fn round_trips_every_supported_property_type() { + initialize_mta(); - let strings = - PropertyValue::CreateStringArray(&[HSTRING::from("one"), HSTRING::from("two")])?; - assert_eq!( - unbox_property_value(&as_value(&strings)).unwrap(), - PropertyValueUnboxResult::Value(PropertyValueData::StringArray(vec![ - "one".to_string(), - "two".to_string() - ])) - ); + let samples = samples(); + let mut covered = samples + .iter() + .map(|sample| sample.property_type().0) + .collect::>(); + covered.sort_unstable(); + covered.dedup(); + // Every PropertyType except Empty, Inspectable, OtherType and OtherTypeArray. + assert_eq!(covered.len(), 37); - Ok(()) + for sample in samples { + let boxed = box_property_value(&sample).unwrap(); + let property_value: IPropertyValue = boxed.as_object().unwrap().cast().unwrap(); + assert_eq!(property_value.Type().unwrap(), sample.property_type()); + assert_eq!( + unbox_property_value(&boxed).unwrap(), + PropertyValueUnboxResult::Value(sample.clone()), + "{sample:?}" + ); + let rebox = box_property_value(&match unbox_property_value(&boxed).unwrap() { + PropertyValueUnboxResult::Value(data) => data, + other => panic!("unexpected {other:?}"), + }) + .unwrap(); + let property_value: IPropertyValue = rebox.as_object().unwrap().cast().unwrap(); + assert_eq!(property_value.Type().unwrap(), sample.property_type()); + } } #[test] fn preserves_null_and_non_property_objects() -> windows_core::Result<()> { - let _ = unsafe { RoInitialize(RO_INIT_MULTITHREADED) }; + initialize_mta(); assert_eq!( unbox_property_value(&WinRTValue::Null).unwrap(), PropertyValueUnboxResult::Null ); + assert_eq!( + unbox_property_value(&WinRTValue::I32(5)).unwrap(), + PropertyValueUnboxResult::NotPropertyValue + ); let uri = Uri::CreateUri(&HSTRING::from("https://example.com"))?; let raw = uri.as_raw(); @@ -217,17 +447,76 @@ mod tests { Ok(()) } + #[implement(IPropertyValue)] + struct CustomPropertyValue(PropertyType); + + macro_rules! not_implemented { + ($($getter:ident -> $typ:ty),* $(,)?) => { + $(fn $getter(&self) -> windows_core::Result<$typ> { + Err(E_NOTIMPL.into()) + })* + }; + } + + macro_rules! not_implemented_arrays { + ($($getter:ident: $typ:ty),* $(,)?) => { + $(fn $getter(&self, _value: &mut Array<$typ>) -> windows_core::Result<()> { + Err(E_NOTIMPL.into()) + })* + }; + } + + impl IPropertyValue_Impl for CustomPropertyValue_Impl { + fn Type(&self) -> windows_core::Result { + Ok(self.0) + } + + not_implemented!( + IsNumericScalar -> bool, GetUInt8 -> u8, GetInt16 -> i16, GetUInt16 -> u16, + GetInt32 -> i32, GetUInt32 -> u32, GetInt64 -> i64, GetUInt64 -> u64, + GetSingle -> f32, GetDouble -> f64, GetChar16 -> u16, GetBoolean -> bool, + GetString -> HSTRING, GetGuid -> GUID, GetDateTime -> DateTime, + GetTimeSpan -> TimeSpan, GetPoint -> Point, GetSize -> Size, GetRect -> Rect, + ); + + not_implemented_arrays!( + GetUInt8Array: u8, GetInt16Array: i16, GetUInt16Array: u16, GetInt32Array: i32, + GetUInt32Array: u32, GetInt64Array: i64, GetUInt64Array: u64, GetSingleArray: f32, + GetDoubleArray: f64, GetChar16Array: u16, GetBooleanArray: bool, + GetStringArray: HSTRING, GetInspectableArray: IInspectable, GetGuidArray: GUID, + GetDateTimeArray: DateTime, GetTimeSpanArray: TimeSpan, GetPointArray: Point, + GetSizeArray: Size, GetRectArray: Rect, + ); + } + + fn custom(property_type: PropertyType) -> WinRTValue { + let value: IPropertyValue = CustomPropertyValue(property_type).into(); + as_value(&value) + } + #[test] - fn rejects_unsupported_property_types() -> windows_core::Result<()> { - let _ = unsafe { RoInitialize(RO_INIT_MULTITHREADED) }; + fn classifies_payloadless_property_types_without_failing() { + initialize_mta(); + + for property_type in [ + PropertyType::Empty, + PropertyType::Inspectable, + PropertyType::OtherType, + PropertyType::OtherTypeArray, + PropertyType(0x7fff), + ] { + assert_eq!( + unbox_property_value(&custom(property_type)).unwrap(), + PropertyValueUnboxResult::Unsupported(property_type) + ); + } + } - let date_time = PropertyValue::CreateDateTime(DateTime { UniversalTime: 0 })?; - let error = unbox_property_value(&as_value(&date_time)).unwrap_err(); - assert!(error.message().contains(&format!( - "Unsupported WinRT IPropertyValue type: {}", - PropertyType::DateTime.0 - ))); + #[test] + fn surfaces_getter_failures_for_supported_property_types() { + initialize_mta(); - Ok(()) + let error = unbox_property_value(&custom(PropertyType::UInt32)).unwrap_err(); + assert!(matches!(error, Error::WindowsError(ref error) if error.code() == E_NOTIMPL)); } } diff --git a/docs/status/PYTHON_CHECKLIST.md b/docs/status/PYTHON_CHECKLIST.md index f2fc4ab1..09e10c92 100644 --- a/docs/status/PYTHON_CHECKLIST.md +++ b/docs/status/PYTHON_CHECKLIST.md @@ -140,6 +140,10 @@ of a dynamic projection. - [x] Expose `project_as(value, Type)` for typed, non-consuming projection of raw `Object`/`IInspectable` values to RuntimeClasses, matching JavaScript `projectAs`; use `as_interface()` / interface `from_value()` for QI views. +- [x] Convert boxed `Object` values explicitly with + `unbox_object(raw, preserve_type=...)` and + `to_winrt_object(value, property_type=...)`, covering every + `PropertyType` with a payload; generated `Object` positions stay native. - [x] Declare non-suppressing runtime and generated context managers as `Literal[False]`. - [x] Replace deprecated PyO3 automatic `FromPyObject` behavior explicitly. From bb346cca5e68f147cc63a6b68ed907c6effc6c72 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 24 Sep 2026 18:33:05 +0800 Subject: [PATCH 2/3] Cover explicit Object conversion with generated-binding E2E checks Python-only checks on generated bindings: - object_value_roundtrip: every PropertyValue.create_* factory (37 PropertyTypes) round-trips through unbox_object(preserve_type=True) and to_winrt_object with its exact PropertyType, and dynwinrt.values.PropertyType matches the metadata enum. - object_value_storage_properties: a temporary file's System.Size unboxes as UInt64 in preserve mode and System.DateModified as an aware datetime. - object_value_device_properties: DeviceInformation.properties values unbox as str, bool and UUID, skipping gracefully without devices. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/e2e/e2e_specs.json | 21 +++- tests/e2e/e2e_specs.schema.json | 5 +- tests/e2e/runners/py_runner.py | 163 ++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 2 deletions(-) diff --git a/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index 709bf04f..e98af6df 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -165,7 +165,8 @@ { "kind": "nullable_object_array_roundtrip", "member": "create_inspectable_array", "langs": ["py"] }, { "kind": "static_uuid_input", "member": "create_guid", "langs": ["py"] }, { "kind": "static_bytes_input", "member": "create_u_int8_array", "langs": ["py"] }, - { "kind": "static_sequence_input", "member": "create_int32_array", "langs": ["py"] } + { "kind": "static_sequence_input", "member": "create_int32_array", "langs": ["py"] }, + { "kind": "object_value_roundtrip", "member": "create_uint8", "langs": ["py"] } ] }, { @@ -452,6 +453,19 @@ { "kind": "storage_query_temp_folder", "member": "query" } ] }, + { + "id": "explicit_object_values_storage_properties", + "namespace": "Windows.Storage", + "class": "StorageFile", + "langs": ["py"], + "instantiate": { "kind": "none" }, + "checks": [ + { + "kind": "object_value_storage_properties", + "member": "get_file_from_path_async" + } + ] + }, { "id": "issue_69_device_information_async_collection", "namespace": "Windows.Devices.Enumeration", @@ -462,6 +476,11 @@ { "kind": "device_information_async_collection", "member": "find_all_async" + }, + { + "kind": "object_value_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 fc2ecf9e..f741cc8b 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -101,7 +101,10 @@ "storage_query_temp_folder", "value_set_event_lifecycle", "nested_struct_runtime", - "generated_helper_matrix" + "generated_helper_matrix", + "object_value_roundtrip", + "object_value_storage_properties", + "object_value_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 9bde333f..f9271461 100644 --- a/tests/e2e/runners/py_runner.py +++ b/tests/e2e/runners/py_runner.py @@ -1605,6 +1605,169 @@ def block_on_sta(): else: cr['pass'] = True + elif kind == 'object_value_roundtrip': + from datetime import datetime, timedelta, timezone + from uuid import UUID + + property_value_iid = dw.WinGUID.parse('4bd682dd-7554-40e9-9a9b-82654ede7e62') + generated_kinds = generated_type(pkg_name, 'PropertyType') + mirrored = {member.name: member.value for member in dw.values.PropertyType} + if mirrored != {member.name: member.value for member in generated_kinds}: + cr['error'] = 'dynwinrt.values.PropertyType differs from the metadata enum' + return cr + + 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() + + point = generated_type(pkg_name, 'Point') + size = generated_type(pkg_name, 'Size') + rect = generated_type(pkg_name, 'Rect') + uri = generated_type(pkg_name, 'Uri').create_uri('https://example.com/boxed') + moment = datetime(2024, 5, 6, 7, 8, 9, 123456, tzinfo=timezone.utc) + v = dw.values + cases = [ + # (factory, argument, PropertyType, unboxed with preserve_type=True) + ('create_uint8', 200, 'UInt8', v.UInt8(200)), + ('create_int16', -3, 'Int16', v.Int16(-3)), + ('create_uint16', 65535, 'UInt16', v.UInt16(65535)), + ('create_int32', -7, 'Int32', -7), + ('create_uint32', 2**32 - 1, 'UInt32', v.UInt32(2**32 - 1)), + ('create_int64', -(2**63), 'Int64', v.Int64(-(2**63))), + ('create_uint64', 2**64 - 1, 'UInt64', v.UInt64(2**64 - 1)), + ('create_single', 0.5, 'Single', v.Single(0.5)), + ('create_double', 0.1, 'Double', 0.1), + ('create_char16', 'x', 'Char16', v.Char16('x')), + ('create_boolean', True, 'Boolean', True), + ('create_string', 'text', 'String', 'text'), + ('create_guid', UUID(int=5), 'Guid', UUID(int=5)), + ('create_date_time', moment, 'DateTime', moment), + ('create_time_span', timedelta(seconds=-5), 'TimeSpan', timedelta(seconds=-5)), + ('create_point', point(1.5, 2.5), 'Point', v.Point(1.5, 2.5)), + ('create_size', size(3.0, 4.0), 'Size', v.Size(3.0, 4.0)), + ('create_rect', rect(1.0, 2.0, 3.0, 4.0), 'Rect', v.Rect(1.0, 2.0, 3.0, 4.0)), + ('create_uint8_array', b'\x01\x02', 'UInt8Array', b'\x01\x02'), + ('create_int16_array', [1, -2], 'Int16Array', v.Int16Array([1, -2])), + ('create_uint16_array', [1, 2], 'UInt16Array', v.UInt16Array([1, 2])), + ('create_int32_array', [1, -2], 'Int32Array', v.Int32Array([1, -2])), + ('create_uint32_array', [1, 2], 'UInt32Array', v.UInt32Array([1, 2])), + ('create_int64_array', [1, -2], 'Int64Array', v.Int64Array([1, -2])), + ('create_uint64_array', [1, 2], 'UInt64Array', v.UInt64Array([1, 2])), + ('create_single_array', [0.5, 1.5], 'SingleArray', v.SingleArray([0.5, 1.5])), + ('create_double_array', [0.1, 2.0], 'DoubleArray', v.DoubleArray([0.1, 2.0])), + ('create_char16_array', ['a', 'b'], 'Char16Array', v.Char16Array(['a', 'b'])), + ('create_boolean_array', [True, False], 'BooleanArray', v.BooleanArray([True, False])), + ('create_string_array', ['a', ''], 'StringArray', v.StringArray(['a', ''])), + ( + 'create_inspectable_array', + [dw.DynWinRTValue.null_value(), dw.to_winrt_object(v.UInt32(9))], + 'InspectableArray', + v.InspectableArray([None, v.UInt32(9)]), + ), + ('create_guid_array', [UUID(int=1)], 'GuidArray', v.GuidArray([UUID(int=1)])), + ('create_date_time_array', [moment], 'DateTimeArray', v.DateTimeArray([moment])), + ('create_time_span_array', [timedelta(1)], 'TimeSpanArray', v.TimeSpanArray([timedelta(1)])), + ('create_point_array', [point(1.0, 2.0)], 'PointArray', v.PointArray([v.Point(1.0, 2.0)])), + ('create_size_array', [size(1.0, 2.0)], 'SizeArray', v.SizeArray([v.Size(1.0, 2.0)])), + ('create_rect_array', [rect(1.0, 2.0, 3.0, 4.0)], 'RectArray', v.RectArray([v.Rect(1.0, 2.0, 3.0, 4.0)])), + ] + covered = {kind_name for _, _, kind_name, _ in cases} + if len(covered) != 37: + cr['error'] = f'the round-trip matrix covers {len(covered)} PropertyTypes, not 37' + return cr + for factory, argument, kind_name, expected in cases: + boxed = getattr(cls, factory)(argument) + expected_kind = generated_kinds[kind_name] + exact = dw.unbox_object(boxed, preserve_type=True) + reboxed = dw.to_winrt_object(exact) + again = dw.unbox_object(reboxed, preserve_type=True) + if ( + stored_type(boxed) != expected_kind + or stored_type(reboxed) != expected_kind + or type(exact) is not type(expected) + or exact != expected + or type(again) is not type(expected) + or again != expected + ): + cr['error'] = ( + f'{factory}: expected {expected!r} as {kind_name}, read {exact!r}, ' + f'then {again!r} as {stored_type(reboxed)}' + ) + return cr + if dw.unbox_object(uri._obj) is not uri._obj: + cr['error'] = 'a non-IPropertyValue object lost its identity' + else: + cr['pass'] = True + + elif kind == 'object_value_storage_properties': + from datetime import datetime, timezone + from pathlib import Path + from tempfile import TemporaryDirectory + + with TemporaryDirectory(prefix='dynwinrt-values-') as temp_dir: + path = Path(temp_dir) / 'sample.txt' + path.write_bytes(b'dynwinrt' * 3) + modified_at = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) + storage_file = await getattr(cls, member)(str(path)) + properties = await storage_file.properties.retrieve_properties_async( + ['System.Size', 'System.DateModified'] + ) + size = properties['System.Size'] + modified = dw.unbox_object(properties['System.DateModified']) + exact_size = dw.unbox_object(size, preserve_type=True) + reboxed = dw.to_winrt_object(exact_size) + if type(dw.unbox_object(size)) is not int or dw.unbox_object(size) != 24: + cr['error'] = f'System.Size unboxed as {dw.unbox_object(size)!r}' + elif type(exact_size) is not dw.values.UInt64 or exact_size != 24: + cr['error'] = f'System.Size preserved as {exact_size!r}' + elif type(dw.unbox_object(reboxed, preserve_type=True)) is not dw.values.UInt64: + cr['error'] = 'System.Size did not re-box as UInt64' + elif ( + not isinstance(modified, datetime) + or modified.tzinfo is not timezone.utc + or abs((modified - modified_at).total_seconds()) > 5 + ): + cr['error'] = ( + f'System.DateModified unboxed as {modified!r}, ' + f'expected about {modified_at!r}' + ) + else: + cr['pass'] = True + + elif kind == 'object_value_device_properties': + from uuid import UUID + + devices = await getattr(cls, member)() + checked = {} + for device in devices: + properties = device.properties + for key, expected_type in ( + ('System.ItemNameDisplay', str), + ('System.Devices.InterfaceEnabled', bool), + ('System.Devices.ContainerId', UUID), + ): + if key in checked or key not in properties: + continue + raw = properties[key] + value = dw.unbox_object(raw) + if value is None: + continue + if ( + type(value) is not expected_type + or dw.unbox_object(raw, preserve_type=True) != value + ): + cr['error'] = f'{key} unboxed as {value!r}' + return cr + checked[key] = value + if len(checked) == 3: + break + if 'System.ItemNameDisplay' not in checked: + print(' skipped DeviceInformation.properties string: no device exposes one') + cr['pass'] = True + elif kind == 'bitmap_encoder_async_create': stream_cls = generated_type(pkg_name, 'InMemoryRandomAccessStream') stream = ( From ae7fee8668e9e49452c2e23bd77bf0ce8fcea067 Mon Sep 17 00:00:00 2001 From: leileizhang Date: Mon, 28 Sep 2026 22:01:39 +0800 Subject: [PATCH 3/3] Add an opt-in value view for WinRT Object-valued maps in Python (#195) * 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> * 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> * 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> * 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> * Validate Object map dispatch metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Regress released Object map views with updated runtime Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 1 + bindings/py/README.md | 53 + bindings/py/python/dynwinrt/values.py | 383 ++++++- bindings/py/src/object_value.rs | 15 +- bindings/py/tests/test_object_value_view.py | 1020 +++++++++++++++++ bindings/py/tests/test_object_values.py | 5 +- docs/status/PYTHON_CHECKLIST.md | 4 + tests/e2e/e2e_specs.json | 30 + tests/e2e/e2e_specs.schema.json | 6 +- tests/e2e/runners/py_runner.py | 269 ++++- tests/e2e/typecheck/python_generated_api.py | 58 +- .../codegen/winrt/python/generator/class.rs | 81 ++ 12 files changed, 1906 insertions(+), 19 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 27353ea7..de5db552 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -287,6 +287,59 @@ wrapper to an interface view. Use `InterfaceClass.from_value(raw)` for a raw passing a runtime class raises `TypeError` that points to `project_as()`. 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`. 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 + 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, 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`. +- `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. +- 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. + ## 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..d71dc675 100644 --- a/bindings/py/python/dynwinrt/values.py +++ b/bindings/py/python/dynwinrt/values.py @@ -20,17 +20,39 @@ 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 Callable, Iterable, Iterator, Mapping, MutableMapping from datetime import datetime, timedelta from enum import IntEnum -from typing import Any, ClassVar, Self, SupportsFloat, SupportsIndex, TypeVar +from typing import ( + TYPE_CHECKING, + 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 + +if TYPE_CHECKING: + from _typeshed import SupportsKeysAndGetItem + __all__ = [ "PropertyType", "WinRTScalar", @@ -66,6 +88,10 @@ "PointArray", "SizeArray", "RectArray", + "WinRTObjectValue", + "ObjectValueView", + "MutableObjectValueView", + "object_value_view", ] @@ -551,3 +577,358 @@ 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", str, UUID) + +# Signed HRESULTs, as OSError.winerror reports them. +_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 _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) + except OSError as error: + if error.winerror == _E_NOINTERFACE: + continue + raise + interface.release() + return name + return None + + +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) + 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(declared_iid, WinGUID) or not isinstance(dispatch, DynWinRTValue): + raise TypeError( + f"{wrapper_type.__qualname__} has an invalid generated map projection" + ) + return dispatch, declared_iid + + +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: + return None + raise + try: + 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. + + 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 = _queried_object_map(native, "IMap") or _queried_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" + ) + generic = "IMap" if writable else "IMapView" + 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: + raise TypeError( + 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: + if raw is None: + return None + try: + return cast(WinRTObjectValue, unbox_object(raw, preserve_type=preserve_type)) + except OSError as error: + # 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. + 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() + + # 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 + 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) + + # 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( + 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/src/object_value.rs b/bindings/py/src/object_value.rs index 0f4821d7..fbcf11c3 100644 --- a/bindings/py/src/object_value.rs +++ b/bindings/py/src/object_value.rs @@ -481,17 +481,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 } // ====================================================================== @@ -554,7 +561,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 new file mode 100644 index 00000000..8e7aaea3 --- /dev/null +++ b/bindings/py/tests/test_object_value_view.py @@ -0,0 +1,1020 @@ +# 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 +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) +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()) +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): + """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 _map_obj(self): + return self._obj + + @property + def size(self): + return self._interface.method(7).invoke(self._map_obj, []).to_u32() + + def lookup(self, 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._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._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 + 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.""" + + _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): + 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._map_obj, []) + return self.VIEW_TYPE(native) + + def insert(self, key, value): + native = getattr(value, "_obj", value) + return self._interface.method(10).invoke( + self._map_obj, [self._keys.to_native(key), native] + ).to_bool() + + def remove(self, key): + self._interface.method(11).invoke(self._map_obj, [self._keys.to_native(key)]) + + def clear(self): + 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 + _dynwinrt_map_dispatch = (IMAP_STRING_OBJECT, "_collection_obj") + + 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 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).""" + + 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() + + +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 +# ---------------------------------------------------------------------- + + +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_multi_map_identity_uses_only_the_wrappers_projected_map(): + with object_and_string_maps() as (raw, stores, calls): + object_map = GeneratedStyleMap(raw) + string_map = GeneratedStyleStringMap(raw) + + 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)] + + 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() + == string_map._obj.identity_raw() + == runtime_map._obj.identity_raw() + == runtime_map._collection_obj.identity_raw() + ) + + +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: + 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 + 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") == guid + view.update() + + assert view.setdefault("a", 9) == 1 + 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 + 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_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 + 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 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"): + 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_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): + 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 = GeneratedStyleGuidObjectMap( + activate("Windows.Media.MediaProperties.MediaPropertySet") + ) + 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 = GeneratedStyleStringMap( + activate("Windows.Foundation.Collections.StringMap") + ) + 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_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(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"): + 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) + + +@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 = [ + ("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 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/bindings/py/tests/test_object_values.py b/bindings/py/tests/test_object_values.py index 7c0bbd07..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]))) @@ -635,7 +637,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 5aa1328d..09f8069d 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -474,9 +474,34 @@ { "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": "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", @@ -492,6 +517,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 29c1b06e..948376ce 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -106,7 +106,11 @@ "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_guid_map", + "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 79e1a6d3..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) @@ -1883,6 +1880,258 @@ 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' + 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 + 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_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 + 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..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 typing import Any, Awaitable, List, Tuple +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, @@ -16,13 +17,27 @@ 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.media.media_properties import MediaPropertySet from python_bindings.windows.storage.streams import ( Buffer as WinRTBuffer, DataWriter, @@ -152,3 +167,42 @@ 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, + media: MediaPropertySet, + integers: MutableMapping[int, DynWinRTValue | None], +) -> 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) + 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) + 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, guid_view, snapshot) 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 f109cd00..d1b3ff55 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, @@ -1713,6 +1728,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,