From 4555d579b5fe5474c6dd60adc84d269269231075 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 1 Oct 2026 22:25:43 +0800 Subject: [PATCH 1/3] Fix Python XML nullable result declarations Record exact reviewed Windows XML null results and declaring-interface aliases in the pinned documentation extraction flow. Cover SDK owner/signature matching, strict consumers, and scoped native XML states without changing runtime or JS output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 12 + bindings/py/README.md | 19 + eng/ci/test_extract_null_results.py | 100 +++++ .../windows-null-results.overrides.txt | 26 ++ .../api-docs/windows-null-results.txt | 16 + .../scripts/extract-null-results.py | 58 ++- .../dynwinrt-codegen/src/documented_nulls.rs | 139 ++++++- .../python_xml_nullability_invalid.py | 43 +++ .../fixtures/python_xml_nullability_native.py | 119 ++++++ .../fixtures/python_xml_nullability_valid.py | 104 +++++ .../tests/python_xml_nullability_test.rs | 357 ++++++++++++++++++ 11 files changed, 988 insertions(+), 5 deletions(-) create mode 100644 tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_invalid.py create mode 100644 tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_native.py create mode 100644 tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_valid.py create mode 100644 tools/dynwinrt-codegen/tests/python_xml_nullability_test.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 22507396..869eed99 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -543,6 +543,18 @@ jobs: .\eng\release\python\test_generated_python_long_paths.ps1 ` -Python .\bindings\py\.venv\Scripts\python.exe ` -Codegen $env:DYNWINRT_CODEGEN + - name: Test generated Python XML null results + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $true + npm install --prefix target\pyright --no-save --ignore-scripts --no-package-lock ` + --registry https://packagefeedproxy.microsoft.io/npm/ pyright@1.1.408 + $env:DYNWINRT_TEST_PYTHON = (Resolve-Path .\bindings\py\.venv\Scripts\python.exe).Path + $env:DYNWINRT_PYRIGHT = (Resolve-Path target\pyright\node_modules\.bin\pyright.cmd).Path + $env:DYNWINRT_REQUIRE_IMPLEMENTATION_RUNTIME = '1' + $env:DYNWINRT_REQUIRE_MYPY = '1' + $env:DYNWINRT_REQUIRE_XML_NULLABILITY = '1' + cargo test -p dynwinrt-codegen --test python_xml_nullability_test - name: Test generated implementation and overload identities shell: pwsh run: | diff --git a/bindings/py/README.md b/bindings/py/README.md index 01e4f74d..3baaddbe 100644 --- a/bindings/py/README.md +++ b/bindings/py/README.md @@ -44,6 +44,25 @@ These values keep `| None`: - `Object`/`IInspectable` values (`DynWinRTValue | None`) and delegate-typed values, which are often null. +`Windows.Data.Xml.Dom` also keeps `| None` for an absent document root or DTD, +a node's owner document, parent and previous/next sibling, missing attribute +lookups (including namespace-aware lookups), and the previous attribute +returned by `set_attribute_node()`/`set_attribute_node_ns()`. Cache the result +and guard it before reading a member: + +```python +root = document.document_element +if root is not None: + print(root.tag_name) +``` + +These are exact reviewed null-result facts, not a blanket rule for WinRT +references. They tighten the static declarations; native/runtime null +conversion and the broader inline `.py`/`--no-pyi` annotations are unchanged. +Class documentation facts also have exact declaring-interface aliases for +standalone rendering; this does not enable exclusive interfaces such as +`IXmlDocument` or `IXmlElement` as public CLI roots. + Reference-type elements read from WinRT collection interfaces are always typed `T | None`, including vectors, views, iterables, iterators, map keys and values, and key-value-pair keys and values. Here reference means the projection's diff --git a/eng/ci/test_extract_null_results.py b/eng/ci/test_extract_null_results.py index 08900752..f3ca4b84 100644 --- a/eng/ci/test_extract_null_results.py +++ b/eng/ci/test_extract_null_results.py @@ -2,8 +2,14 @@ # Licensed under the MIT License. import importlib.util +from collections import Counter +from contextlib import redirect_stdout +import io +import os from pathlib import Path +import tempfile import unittest +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[2] @@ -38,6 +44,100 @@ def method_doc( class NullResultExtractionTests(unittest.TestCase): + def apply_override_text(self, text, documented, nullable=()): + with tempfile.TemporaryDirectory() as directory: + overrides = Path(directory) / "overrides.txt" + overrides.write_text(text, encoding="utf-8") + with patch.object(extractor, "OVERRIDES", overrides): + return extractor.apply_overrides(set(documented), set(nullable), Counter()) + + def test_exact_declaration_alias_retains_documented_class_fact(self): + source = "M:Windows.Data.Xml.Dom.XmlElement.GetAttributeNode(System.String)" + target = "M:Windows.Data.Xml.Dom.IXmlElement.GetAttributeNode(System.String)" + self.assertEqual( + self.apply_override_text(f"+{source} => {target}", [source]), + {source, target}, + ) + source = "P:Windows.Data.Xml.Dom.XmlDocument.DocumentElement" + target = "P:Windows.Data.Xml.Dom.IXmlDocument.DocumentElement" + self.assertEqual( + self.apply_override_text(f"+{source} => {target}", [source]), + {source, target}, + ) + + def test_declaration_aliases_reject_malformed_or_drifted_signatures(self): + source = "M:Windows.Data.Xml.Dom.XmlElement.GetAttributeNode(System.String)" + target = "M:Windows.Data.Xml.Dom.IXmlElement.GetAttributeNode(System.String)" + invalid = ( + f"+{source} =>", + f"+ => {target}", + f"-{source} => {target}", + f"+{source} => {source}", + f"+{source} => {target} => {target}", + f"+{source} => M:Windows.Data.Xml.Dom.*.GetAttributeNode(System.String)", + f"+{source} => {target.replace('System.String', 'System.Object')}", + f"+{source} => {target.replace('GetAttributeNode', 'SetAttributeNode')}", + f"+{source} => {target.replace('M:', 'P:', 1)}", + f"+{source} => M:IXmlElement.GetAttributeNode(System.String)", + f"+{source} => M:Windows.Data.Xml.Dom.IXmlElement[].GetAttributeNode(System.String)", + f"+{source} => M:Windows.Data.Xml.Dom.IXmlElement.GetAttributeNode", + ) + for entry in invalid: + with self.subTest(entry=entry), self.assertRaisesRegex(SystemExit, "exact source => target"): + self.apply_override_text(entry, [source]) + + def test_declaration_alias_requires_exact_documented_source(self): + source = "M:Windows.Data.Xml.Dom.XmlElement.GetAttributeNode(System.String)" + target = "M:Windows.Data.Xml.Dom.IXmlElement.GetAttributeNode(System.String)" + with self.assertRaisesRegex(SystemExit, "matches no documented member"): + self.apply_override_text(f"+{source} => {target}", []) + with self.assertRaisesRegex(SystemExit, "matches no documented member"): + self.apply_override_text( + f"+{source} => {target}", + [source.replace("System.String", "System.Object")], + ) + + def test_duplicate_documented_api_id_cannot_supply_an_ambiguous_alias_source(self): + source = "M:Windows.Data.Xml.Dom.XmlElement.GetAttributeNode(System.String)" + with ( + patch.object(extractor, "documents", return_value=[method_doc(source)] * 2), + self.assertRaisesRegex(SystemExit, "duplicate documented api-id"), + ): + extractor.extract(Path("."), extractor.COMMIT) + + def test_declaration_aliases_reject_conflicts_and_removals(self): + source = "P:Windows.Data.Xml.Dom.XmlDocument.DocumentElement" + other = "P:Contoso.XmlDocument.DocumentElement" + target = "P:Windows.Data.Xml.Dom.IXmlDocument.DocumentElement" + for text in ( + f"+{source} => {target}\n+{other} => {target}", + f"+{source} => {target}\n+{source} => {target}", + f"-{source}\n+{source} => {target}", + f"+{source} => {target}\n-{source}", + f"+{source} => {target}\n-{target}", + ): + with self.subTest(text=text), self.assertRaisesRegex(SystemExit, "conflict"): + self.apply_override_text(text, [source, other]) + + def test_pinned_documentation_reproduces_checked_in_table(self): + cache = os.environ.get("DYNWINRT_WINRT_DOCS_REPO") + if not cache: + self.skipTest("set DYNWINRT_WINRT_DOCS_REPO to the local pinned winrt-api git-object cache") + repo = Path(cache) + self.assertEqual(extractor.COMMIT, "8448d5eecfbc2ed903f659f350841dcb4888bc8b") + self.assertEqual(extractor.git(repo, "cat-file", "-t", extractor.COMMIT).strip(), "commit") + expected = extractor.OUTPUT.read_bytes() + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "windows-null-results.txt" + for _ in range(2): + with ( + patch.object(extractor, "OUTPUT", output), + patch("sys.argv", [str(SCRIPT), "--repo", str(repo)]), + redirect_stdout(io.StringIO()), + ): + extractor.main() + self.assertEqual(output.read_bytes(), expected) + def test_required_return_null_check_is_nullable(self): wording = ( "Before using the return value from this method, the application " diff --git a/tools/dynwinrt-codegen/api-docs/windows-null-results.overrides.txt b/tools/dynwinrt-codegen/api-docs/windows-null-results.overrides.txt index 912ee303..853ae9cc 100644 --- a/tools/dynwinrt-codegen/api-docs/windows-null-results.overrides.txt +++ b/tools/dynwinrt-codegen/api-docs/windows-null-results.overrides.txt @@ -2,6 +2,32 @@ # scripts/extract-null-results.py. "+pattern" adds and "-pattern" removes # members. A pattern is an api-id that may use fnmatch wildcards; it must match # a member documented at the pinned winrt-api commit. +# Exact "+source => target" declaration aliases also retain the source fact. +# Both IDs use the same member and full normalized CLR parameter signature. +# The source must be documented at the pin; Windows.winmd tests validate the +# target's declaring interface, ExclusiveTo owner and native signature. + +# Stock Windows XML native evidence: an empty document has no root or DTD. +# The pinned reference documents these properties under the runtime class. ++P:Windows.Data.Xml.Dom.XmlDocument.DocumentElement => P:Windows.Data.Xml.Dom.IXmlDocument.DocumentElement ++P:Windows.Data.Xml.Dom.XmlDocument.Doctype => P:Windows.Data.Xml.Dom.IXmlDocument.Doctype ++M:Windows.Data.Xml.Dom.XmlDocument.GetElementById(System.String) => M:Windows.Data.Xml.Dom.IXmlDocument.GetElementById(System.String) + +# A document has no owner document, detached nodes have no parent or siblings, +# and the first/last child has no previous/next sibling. These are contracts +# of the declaring IXmlNode interface, inherited by its runtime classes. ++P:Windows.Data.Xml.Dom.IXmlNode.OwnerDocument ++P:Windows.Data.Xml.Dom.IXmlNode.ParentNode ++P:Windows.Data.Xml.Dom.IXmlNode.PreviousSibling ++P:Windows.Data.Xml.Dom.IXmlNode.NextSibling + +# Missing attributes return null; setting the first attribute returns no +# previous attribute, while replacement returns the prior object. The native +# regression also checks NS lookup/replacement with an explicitly boxed URI. ++M:Windows.Data.Xml.Dom.XmlElement.GetAttributeNode(System.String) => M:Windows.Data.Xml.Dom.IXmlElement.GetAttributeNode(System.String) ++M:Windows.Data.Xml.Dom.XmlElement.GetAttributeNodeNS(System.Object,System.String) => M:Windows.Data.Xml.Dom.IXmlElement.GetAttributeNodeNS(System.Object,System.String) ++M:Windows.Data.Xml.Dom.XmlElement.SetAttributeNode(Windows.Data.Xml.Dom.XmlAttribute) => M:Windows.Data.Xml.Dom.IXmlElement.SetAttributeNode(Windows.Data.Xml.Dom.XmlAttribute) ++M:Windows.Data.Xml.Dom.XmlElement.SetAttributeNodeNS(Windows.Data.Xml.Dom.XmlAttribute) => M:Windows.Data.Xml.Dom.IXmlElement.SetAttributeNodeNS(Windows.Data.Xml.Dom.XmlAttribute) # Sensors report a missing device through null across the whole family, as # most of their pages state ("or null if no integrated ... are found"). diff --git a/tools/dynwinrt-codegen/api-docs/windows-null-results.txt b/tools/dynwinrt-codegen/api-docs/windows-null-results.txt index 51c1b97b..7fe08e4d 100644 --- a/tools/dynwinrt-codegen/api-docs/windows-null-results.txt +++ b/tools/dynwinrt-codegen/api-docs/windows-null-results.txt @@ -30,6 +30,11 @@ M:Windows.Data.Xml.Dom.DtdNotation.RemoveChild(Windows.Data.Xml.Dom.IXmlNode) M:Windows.Data.Xml.Dom.DtdNotation.ReplaceChild(Windows.Data.Xml.Dom.IXmlNode,Windows.Data.Xml.Dom.IXmlNode) M:Windows.Data.Xml.Dom.DtdNotation.SelectSingleNode(System.String) M:Windows.Data.Xml.Dom.DtdNotation.SelectSingleNodeNS(System.String,System.Object) +M:Windows.Data.Xml.Dom.IXmlDocument.GetElementById(System.String) +M:Windows.Data.Xml.Dom.IXmlElement.GetAttributeNode(System.String) +M:Windows.Data.Xml.Dom.IXmlElement.GetAttributeNodeNS(System.Object,System.String) +M:Windows.Data.Xml.Dom.IXmlElement.SetAttributeNode(Windows.Data.Xml.Dom.XmlAttribute) +M:Windows.Data.Xml.Dom.IXmlElement.SetAttributeNodeNS(Windows.Data.Xml.Dom.XmlAttribute) M:Windows.Data.Xml.Dom.IXmlNode.AppendChild(Windows.Data.Xml.Dom.IXmlNode) M:Windows.Data.Xml.Dom.IXmlNode.InsertBefore(Windows.Data.Xml.Dom.IXmlNode,Windows.Data.Xml.Dom.IXmlNode) M:Windows.Data.Xml.Dom.IXmlNode.RemoveChild(Windows.Data.Xml.Dom.IXmlNode) @@ -74,11 +79,14 @@ M:Windows.Data.Xml.Dom.XmlDocumentType.ReplaceChild(Windows.Data.Xml.Dom.IXmlNod M:Windows.Data.Xml.Dom.XmlDocumentType.SelectSingleNode(System.String) M:Windows.Data.Xml.Dom.XmlDocumentType.SelectSingleNodeNS(System.String,System.Object) M:Windows.Data.Xml.Dom.XmlElement.AppendChild(Windows.Data.Xml.Dom.IXmlNode) +M:Windows.Data.Xml.Dom.XmlElement.GetAttributeNode(System.String) +M:Windows.Data.Xml.Dom.XmlElement.GetAttributeNodeNS(System.Object,System.String) M:Windows.Data.Xml.Dom.XmlElement.InsertBefore(Windows.Data.Xml.Dom.IXmlNode,Windows.Data.Xml.Dom.IXmlNode) M:Windows.Data.Xml.Dom.XmlElement.RemoveChild(Windows.Data.Xml.Dom.IXmlNode) M:Windows.Data.Xml.Dom.XmlElement.ReplaceChild(Windows.Data.Xml.Dom.IXmlNode,Windows.Data.Xml.Dom.IXmlNode) M:Windows.Data.Xml.Dom.XmlElement.SelectSingleNode(System.String) M:Windows.Data.Xml.Dom.XmlElement.SelectSingleNodeNS(System.String,System.Object) +M:Windows.Data.Xml.Dom.XmlElement.SetAttributeNode(Windows.Data.Xml.Dom.XmlAttribute) M:Windows.Data.Xml.Dom.XmlElement.SetAttributeNodeNS(Windows.Data.Xml.Dom.XmlAttribute) M:Windows.Data.Xml.Dom.XmlEntityReference.AppendChild(Windows.Data.Xml.Dom.IXmlNode) M:Windows.Data.Xml.Dom.XmlEntityReference.InsertBefore(Windows.Data.Xml.Dom.IXmlNode,Windows.Data.Xml.Dom.IXmlNode) @@ -473,9 +481,15 @@ P:Windows.Data.Xml.Dom.DtdNotation.FirstChild P:Windows.Data.Xml.Dom.DtdNotation.LastChild P:Windows.Data.Xml.Dom.DtdNotation.NextSibling P:Windows.Data.Xml.Dom.DtdNotation.NodeValue +P:Windows.Data.Xml.Dom.IXmlDocument.Doctype +P:Windows.Data.Xml.Dom.IXmlDocument.DocumentElement P:Windows.Data.Xml.Dom.IXmlNode.Attributes P:Windows.Data.Xml.Dom.IXmlNode.FirstChild P:Windows.Data.Xml.Dom.IXmlNode.LastChild +P:Windows.Data.Xml.Dom.IXmlNode.NextSibling +P:Windows.Data.Xml.Dom.IXmlNode.OwnerDocument +P:Windows.Data.Xml.Dom.IXmlNode.ParentNode +P:Windows.Data.Xml.Dom.IXmlNode.PreviousSibling P:Windows.Data.Xml.Dom.XmlAttribute.FirstChild P:Windows.Data.Xml.Dom.XmlAttribute.LastChild P:Windows.Data.Xml.Dom.XmlAttribute.NextSibling @@ -485,6 +499,8 @@ P:Windows.Data.Xml.Dom.XmlCDataSection.LastChild P:Windows.Data.Xml.Dom.XmlComment.ChildNodes P:Windows.Data.Xml.Dom.XmlComment.FirstChild P:Windows.Data.Xml.Dom.XmlComment.LastChild +P:Windows.Data.Xml.Dom.XmlDocument.Doctype +P:Windows.Data.Xml.Dom.XmlDocument.DocumentElement P:Windows.Data.Xml.Dom.XmlDocument.FirstChild P:Windows.Data.Xml.Dom.XmlDocument.LastChild P:Windows.Data.Xml.Dom.XmlDocument.ParentNode diff --git a/tools/dynwinrt-codegen/scripts/extract-null-results.py b/tools/dynwinrt-codegen/scripts/extract-null-results.py index 837e9546..e8b42160 100644 --- a/tools/dynwinrt-codegen/scripts/extract-null-results.py +++ b/tools/dynwinrt-codegen/scripts/extract-null-results.py @@ -20,7 +20,11 @@ describe an object holding a null value are ignored, and so are Boolean results, attached properties, constructors and members of generic types. Reviewed corrections from api-docs/windows-null-results.overrides.txt are -applied last. +applied last. Exact `+documented-api-id => declaring-interface-api-id` entries +also retain the class fact and add its reviewed metadata declaration alias. +Aliases must have identical member names and full normalized CLR parameter +signatures; the documented source must exist at the pinned commit. Real +Windows.winmd tests validate their declaring interfaces and native signatures. The documentation is read from git objects, without a working tree: @@ -79,6 +83,12 @@ r"[^.]{0,100}\b(?:the|that|return)\s+value\s+is\s+not\s+null\b", re.I, ) +CLR_NAME = r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+" +CLR_TYPE = rf"{CLR_NAME}(?:\[\])*" +DECLARATION_ID = re.compile( + rf"([MP]):({CLR_NAME})\.([A-Za-z_]\w*)(\((?:{CLR_TYPE}(?:,{CLR_TYPE})*)?\))?", + re.ASCII, +) def git(repo: Path, *args: str) -> str: @@ -231,6 +241,8 @@ def extract(repo: Path, commit: str) -> tuple[set[str], set[str], Counter[str]]: if classification is None: continue api_id, source = classification + if api_id in documented: + sys.exit(f"{commit}: duplicate documented api-id {api_id}") documented.add(api_id) if source is not None: sources[source] += 1 @@ -240,6 +252,8 @@ def extract(repo: Path, commit: str) -> tuple[set[str], set[str], Counter[str]]: def apply_overrides(documented: set[str], nullable: set[str], sources: Counter[str]) -> set[str]: result = set(nullable) + aliases: dict[str, str] = {} + removals: list[str] = [] for number, raw in enumerate(OVERRIDES.read_text(encoding="utf-8").splitlines(), 1): entry = raw.split("#", 1)[0].strip() if not entry: @@ -247,6 +261,48 @@ def apply_overrides(documented: set[str], nullable: set[str], sources: Counter[s sign, pattern = entry[0], entry[1:].strip() if sign not in "+-" or not pattern: sys.exit(f"{OVERRIDES.name}:{number}: expected '+api-id' or '-api-id'") + source, separator, target = pattern.partition("=>") + if separator: + source, target = source.strip(), target.strip() + source_id, target_id = DECLARATION_ID.fullmatch(source), DECLARATION_ID.fullmatch(target) + if ( + sign != "+" + or source_id is None + or target_id is None + or source == target + or source_id.group(1, 3, 4) != target_id.group(1, 3, 4) + or (source_id[1] == "M") != (source_id[4] is not None) + ): + sys.exit( + f"{OVERRIDES.name}:{number}: expected exact source => target " + "with the same CLR signature" + ) + if source not in documented: + sys.exit(f"{OVERRIDES.name}:{number}: {source} matches no documented member") + if target in aliases: + sys.exit( + f"{OVERRIDES.name}:{number}: duplicate or conflicting " + f"declaration alias for {target}" + ) + if any( + fnmatch.fnmatchcase(api_id, removal) + for removal in removals + for api_id in (source, target) + ): + sys.exit(f"{OVERRIDES.name}:{number}: removal conflicts with declaration alias {target}") + aliases[target] = source + sources["override additions"] += int(source not in result) + sources["declaration aliases"] += int(target not in result) + result.update((source, target)) + continue + if sign == "-": + if any( + fnmatch.fnmatchcase(api_id, pattern) + for target, source in aliases.items() + for api_id in (source, target) + ): + sys.exit(f"{OVERRIDES.name}:{number}: removal conflicts with a declaration alias") + removals.append(pattern) matches = {api_id for api_id in documented if fnmatch.fnmatchcase(api_id, pattern)} if not matches: sys.exit(f"{OVERRIDES.name}:{number}: {pattern} matches no documented member") diff --git a/tools/dynwinrt-codegen/src/documented_nulls.rs b/tools/dynwinrt-codegen/src/documented_nulls.rs index 83117a13..45b04d3d 100644 --- a/tools/dynwinrt-codegen/src/documented_nulls.rs +++ b/tools/dynwinrt-codegen/src/documented_nulls.rs @@ -7,8 +7,9 @@ //! lists the doc comment IDs (`M:`/`P:` api-ids) of the Windows SDK methods //! and properties whose documentation says the result can be null; //! `scripts/extract-null-results.py` derives it from MicrosoftDocs/winrt-api. -//! A member is looked up by the type its documentation lists it under, its -//! CLR name, and its parameter types. +//! Reviewed overrides retain native null-result corrections and exact aliases +//! from documented classes to their metadata-declaring interfaces. A member is +//! looked up by its exact owner, CLR name, and parameter types. use std::collections::HashSet; use std::sync::LazyLock; @@ -37,8 +38,8 @@ pub(crate) fn entries() -> impl Iterator { .filter(|line| !line.is_empty() && !line.starts_with('#')) } -/// Whether the documentation of `method`, listed under the type named -/// `owner` (`Namespace.Type`), says its result can be null. +/// Whether the documented or reviewed fact for `method` under the exact +/// `owner` (`Namespace.Type`) says its result can be null. pub(crate) fn documents_null_result(owner: &str, method: &MethodMeta) -> bool { DOCUMENTED.owners.contains(owner) && member_id(owner, method).is_some_and(|id| DOCUMENTED.members.contains(&id)) @@ -182,6 +183,8 @@ mod tests { use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; + use windows_metadata::HasAttributes; + use super::*; use crate::meta::{ParamDirection, ParamMeta}; @@ -265,6 +268,134 @@ mod tests { assert_eq!(member_id("N.T", &setter), None); } + #[test] + fn xml_declaration_aliases_resolve_to_exact_exclusive_interface_signatures() { + if !Path::new(WINDOWS_WINMD).is_file() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + let index = crate::meta::load_index(WINDOWS_WINMD).expect("Windows.winmd index"); + let mut count = 0; + for line in include_str!("../api-docs/windows-null-results.overrides.txt").lines() { + let Some((source, target)) = line + .strip_prefix('+') + .and_then(|line| line.split_once("=>")) + else { + continue; + }; + count += 1; + let (source, target) = (source.trim(), target.trim()); + assert_eq!(normalize_api_id(source), source); + assert_eq!(normalize_api_id(target), target); + let source_owner = owner_of(source).unwrap(); + let target_owner = owner_of(target).unwrap(); + let (namespace, name) = target_owner.rsplit_once('.').unwrap(); + let definitions = index.get(namespace, name).collect::>(); + assert_eq!(definitions.len(), 1, "{target}"); + let definition = &definitions[0]; + assert!(definition.extends().is_none(), "{target}"); + let exclusive_owners = definition + .attributes() + .filter(|attribute| { + attribute.ctor().parent().namespace() == "Windows.Foundation.Metadata" + && attribute.ctor().parent().name() == "ExclusiveToAttribute" + }) + .flat_map(|attribute| attribute.value()) + .filter_map(|(_, value)| match value { + windows_metadata::Value::Utf8(owner) => Some(owner), + _ => None, + }) + .collect::>(); + assert_eq!(exclusive_owners, vec![source_owner.clone()], "{target}"); + + let find = |owner: &str, id: &str| { + let (namespace, name) = owner.rsplit_once('.').unwrap(); + let methods = crate::meta::documented_owner_methods(&index, namespace, name); + let mut matches = methods + .into_iter() + .filter(|method| member_id(owner, method).as_deref() == Some(id)); + let method = matches.next().unwrap_or_else(|| panic!("missing {id}")); + assert!(matches.next().is_none(), "ambiguous {id}"); + assert!(method.documented_null_result, "unflagged {id}"); + method + }; + let source_method = find(&source_owner, source); + let target_method = find(&target_owner, target); + assert_eq!(source_method.raw_name, target_method.raw_name, "{target}"); + assert_eq!( + source_method.vtable_index, target_method.vtable_index, + "{target}" + ); + assert_eq!( + source_method.return_type, target_method.return_type, + "{target}" + ); + let parameters = |method: &MethodMeta| { + method + .params + .iter() + .map(|parameter| { + ( + parameter.name.clone(), + parameter.typ.clone(), + parameter.direction.clone(), + ) + }) + .collect::>() + }; + assert_eq!( + parameters(&source_method), + parameters(&target_method), + "{target}" + ); + assert_eq!( + source_method.raw_signature_key, target_method.raw_signature_key, + "{target}" + ); + } + assert_eq!(count, 7); + } + + #[test] + fn xml_null_facts_do_not_match_other_owners_or_parameter_types() { + let getter = MethodMeta { + name: "get_ParentNode".into(), + raw_name: "get_ParentNode".into(), + is_property_getter: true, + ..Default::default() + }; + assert!(documents_null_result( + "Windows.Data.Xml.Dom.IXmlNode", + &getter + )); + assert!(!documents_null_result("Contoso.IXmlNode", &getter)); + assert!(!documents_null_result( + "Windows.Data.Xml.Dom.IXmlElement", + &getter + )); + let mut method = MethodMeta { + name: "GetAttributeNode".into(), + raw_name: "GetAttributeNode".into(), + params: vec![ParamMeta { + name: "attributeName".into(), + typ: TypeMeta::String, + direction: ParamDirection::In, + }], + ..Default::default() + }; + for owner in [ + "Windows.Data.Xml.Dom.XmlElement", + "Windows.Data.Xml.Dom.IXmlElement", + ] { + assert!(documents_null_result(owner, &method)); + } + method.params[0].typ = TypeMeta::Object; + assert!(!documents_null_result( + "Windows.Data.Xml.Dom.IXmlElement", + &method + )); + } + /// Every entry names a member of the Windows SDK metadata whose parsed /// method carries the documented-null fact. This guards the table against /// typos and drift, and the key derivation against metadata changes. diff --git a/tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_invalid.py b/tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_invalid.py new file mode 100644 index 00000000..e4a30f08 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_invalid.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# pyright: strict + +from dynwinrt import to_winrt_object +from xml_generated import ( + IXmlNode, + XmlDocument, + XmlElement, + XmlText, +) +from xml_generated.windows__data__xml__dom__i_xml_document import IXmlDocument +from xml_generated.windows__data__xml__dom__i_xml_element import IXmlElement + + +def unsafe_reads( + document: XmlDocument, + element: XmlElement, + text: XmlText, + node: IXmlNode, + document_view: IXmlDocument, + element_view: IXmlElement, +) -> None: + print(document.document_element.tag_name) # unsafe + print(document.doctype.name) # unsafe + print(document.owner_document.node_name) # unsafe + print(element.parent_node.node_name) # unsafe + print(element.next_sibling.node_name) # unsafe + print(text.previous_sibling.node_name) # unsafe + print(node.parent_node.node_name) # unsafe + print(node.previous_sibling.node_name) # unsafe + print(node.next_sibling.node_name) # unsafe + print(node.owner_document.node_name) # unsafe + print(element.get_attribute_node("missing").name) # unsafe + print(element.set_attribute_node(document.create_attribute("a")).name) # unsafe + print(element.get_attribute_node_ns(to_winrt_object("urn:xml-test"), "missing").name) # unsafe + print(document_view.document_element.tag_name) # unsafe + print(document_view.doctype.name) # unsafe + print(document_view.get_element_by_id("missing").tag_name) # unsafe + print(element_view.get_attribute_node("missing").name) # unsafe + print(element_view.set_attribute_node(document.create_attribute("a")).name) # unsafe + print(element_view.get_attribute_node_ns(to_winrt_object("urn:xml-test"), "missing").name) # unsafe + print(element_view.set_attribute_node_ns(document.create_attribute("a")).name) # unsafe diff --git a/tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_native.py b/tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_native.py new file mode 100644 index 00000000..711dddfd --- /dev/null +++ b/tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_native.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from dynwinrt import RoApartment, projected_lifetime_scope, to_winrt_object +from xml_generated import ( + IXmlNode, + XmlDocument, + XmlLoadSettings, +) +from xml_generated.windows__data__xml__dom__i_xml_document import IXmlDocument +from xml_generated.windows__data__xml__dom__i_xml_element import IXmlElement + + +with RoApartment(), projected_lifetime_scope() as scope: + document = XmlDocument() + document_view = document.as_interface(IXmlDocument) + document_node = document.as_interface(IXmlNode) + for view in (document, document_view): + assert view.document_element is None + assert view.doctype is None + assert view.get_element_by_id("missing") is None + assert view.implementation is not None + for view in (document, document_node): + assert view.owner_document is None + assert view.parent_node is None + assert view.previous_sibling is None + assert view.next_sibling is None + assert view.first_child is None + assert view.last_child is None + print("empty document: class and declaring-interface null results") + + document.load_xml('') + root = document.document_element + assert root is not None and root.tag_name == "root" + assert document_view.document_element.tag_name == "root" + assert document.doctype is None and document_view.doctype is None + assert document.owner_document is None and document_node.owner_document is None + assert root.owner_document.get_xml() == document.get_xml() + assert root.parent_node.node_name == "#document" + first, last = root.first_child, root.last_child + assert first is not None and last is not None + assert first.previous_sibling is None and last.next_sibling is None + assert first.next_sibling.node_name == "last" + assert last.previous_sibling.node_name == "first" + print("loaded document without DTD: present root and sibling boundaries") + + detached = document.create_element("detached") + text = document.create_text_node("text") + for view in (detached, text, detached.as_interface(IXmlNode), text.as_interface(IXmlNode)): + assert view.parent_node is None + assert view.previous_sibling is None + assert view.next_sibling is None + assert view.owner_document.get_xml() == document.get_xml() + assert view.first_child is None and view.last_child is None + root.append_child(detached.as_interface(IXmlNode)) + root.append_child(text.as_interface(IXmlNode)) + assert detached.parent_node.node_name == "root" + assert text.parent_node.node_name == "root" + assert detached.next_sibling.node_name == "#text" + assert text.previous_sibling.node_name == "detached" + assert text.next_sibling is None + root.remove_child(detached.as_interface(IXmlNode)) + root.remove_child(text.as_interface(IXmlNode)) + for view in (detached, text): + assert view.parent_node is None + assert view.previous_sibling is None and view.next_sibling is None + print("element/text: detached, attached, and removed navigation") + + element_view = root.as_interface(IXmlElement) + for view in (root, element_view): + assert view.get_attribute_node("missing") is None + assert view.get_attribute_node("present").value == "old" + attribute = document.create_attribute("new") + attribute.value = "first" + assert root.set_attribute_node(attribute) is None + assert element_view.get_attribute_node("new").value == "first" + replacement = document.create_attribute("new") + replacement.value = "second" + previous = element_view.set_attribute_node(replacement) + assert previous is not None and previous.value == "first" + assert previous._obj.identity_raw() == attribute._obj.identity_raw() + assert root.get_attribute_node("new").value == "second" + print("attributes: missing/present lookup and added/replaced previous object") + + namespace = scope.track(to_winrt_object("urn:xml-test")) + for view in (root, element_view): + assert view.get_attribute_node_ns(namespace, "missing") is None + namespaced = document.create_attribute_ns(namespace, "test:flag") + namespaced.value = "first" + assert element_view.set_attribute_node_ns(namespaced) is None + assert root.get_attribute_node_ns(namespace, "flag").value == "first" + replacement_ns = document.create_attribute_ns(namespace, "test:flag") + replacement_ns.value = "second" + previous_ns = root.set_attribute_node_ns(replacement_ns) + assert previous_ns is not None and previous_ns.value == "first" + assert previous_ns._obj.identity_raw() == namespaced._obj.identity_raw() + assert element_view.get_attribute_node_ns(namespace, "flag").value == "second" + print("namespace attributes: explicitly boxed Object and both replacement states") + + settings = XmlLoadSettings() + settings.prohibit_dtd = False + settings.resolve_externals = False + with_dtd = XmlDocument() + with_dtd.load_xml( + ']>' + '', + settings, + ) + dtd_view = with_dtd.as_interface(IXmlDocument) + for view in (with_dtd, dtd_view): + assert view.doctype is not None and view.doctype.name == "root" + assert view.document_element.tag_name == "root" + assert view.get_element_by_id("missing") is None + assert view.get_element_by_id("known").tag_name == "root" + assert with_dtd.owner_document is None + assert with_dtd.as_interface(IXmlNode).owner_document is None + print("internal DTD: present doctype and missing/present ID lookup; no external resources") + +print("native XML nullable-result states passed; references released before apartment exit") diff --git a/tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_valid.py b/tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_valid.py new file mode 100644 index 00000000..d8c1dd6a --- /dev/null +++ b/tools/dynwinrt-codegen/tests/fixtures/python_xml_nullability_valid.py @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# pyright: strict + +from typing import assert_type + +from dynwinrt import to_winrt_object +from xml_generated import ( + IXmlNode, + Uri, + XmlAttribute, + XmlDocumentType, + XmlDomImplementation, + XmlElement, + XmlNodeList, + XmlText, + WwwFormUrlDecoder, +) +from xml_generated.windows.data.xml.dom import XmlDocument +from xml_generated.windows__data__xml__dom__i_xml_document import IXmlDocument +from xml_generated.windows__data__xml__dom__i_xml_element import IXmlElement + + +def guarded_reads( + document: XmlDocument, + element: XmlElement, + text: XmlText, + node: IXmlNode, + document_view: IXmlDocument, + element_view: IXmlElement, +) -> None: + root = document.document_element + assert_type(root, XmlElement | None) + if root is not None: + print(root.tag_name) + + doctype = document.doctype + assert_type(doctype, XmlDocumentType | None) + if doctype is not None: + print(doctype.name) + + for owner in (document.owner_document, node.owner_document): + assert_type(owner, XmlDocument | None) + if owner is not None: + print(owner.node_name) + + for relative in ( + element.parent_node, + element.previous_sibling, + element.next_sibling, + text.parent_node, + text.previous_sibling, + text.next_sibling, + node.parent_node, + node.previous_sibling, + node.next_sibling, + node.first_child, + node.last_child, + ): + assert_type(relative, IXmlNode | None) + if relative is not None: + print(relative.node_name) + + namespace = to_winrt_object("urn:xml-test") + for attribute in ( + element.get_attribute_node("missing"), + element.get_attribute_node_ns(namespace, "missing"), + element.set_attribute_node(document.create_attribute("a")), + element.set_attribute_node_ns(document.create_attribute("b")), + element_view.get_attribute_node("missing"), + element_view.get_attribute_node_ns(namespace, "missing"), + element_view.set_attribute_node(document.create_attribute("c")), + element_view.set_attribute_node_ns(document.create_attribute("d")), + ): + assert_type(attribute, XmlAttribute | None) + if attribute is not None: + print(attribute.name) + + interface_root = document_view.document_element + assert_type(interface_root, XmlElement | None) + if interface_root is not None: + print(interface_root.tag_name) + interface_doctype = document_view.doctype + assert_type(interface_doctype, XmlDocumentType | None) + if interface_doctype is not None: + print(interface_doctype.name) + identified = document_view.get_element_by_id("missing") + assert_type(identified, XmlElement | None) + if identified is not None: + print(identified.tag_name) + + # Ordinary factories, collection containers and successful removals stay non-null. + assert_type(document.create_element("root"), XmlElement) + assert_type(document.create_text_node("text"), XmlText) + assert_type(document.create_attribute("a"), XmlAttribute) + assert_type(document.implementation, XmlDomImplementation) + assert_type(document_view.implementation, XmlDomImplementation) + assert_type(document_view.create_element("root"), XmlElement) + assert_type(document.child_nodes, XmlNodeList) + assert_type(element.select_nodes("*"), XmlNodeList) + assert_type(element.get_attribute("missing"), str) + assert_type(element.remove_attribute_node(document.create_attribute("a")), XmlAttribute) + assert_type(node.clone_node(False), IXmlNode) + assert_type(Uri("https://example.com").query_parsed, WwwFormUrlDecoder) diff --git a/tools/dynwinrt-codegen/tests/python_xml_nullability_test.rs b/tools/dynwinrt-codegen/tests/python_xml_nullability_test.rs new file mode 100644 index 00000000..7add44b5 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/python_xml_nullability_test.rs @@ -0,0 +1,357 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use dynwinrt_codegen::codegen::{python, python_stub}; +use dynwinrt_codegen::meta::{self, InterfaceMeta, MethodMeta}; +use dynwinrt_codegen::types::{TypeIdentity, TypeIdentityKind, TypeMeta}; + +const WINDOWS_WINMD: &str = + r"C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.26100.0\Windows.winmd"; +const VALID: &str = include_str!("fixtures/python_xml_nullability_valid.py"); +const INVALID: &str = include_str!("fixtures/python_xml_nullability_invalid.py"); +const NATIVE: &str = include_str!("fixtures/python_xml_nullability_native.py"); +static NEXT: AtomicU64 = AtomicU64::new(0); + +struct Fixture(PathBuf); + +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} + +#[test] +fn class_and_standalone_interface_stubs_use_exact_xml_null_facts() { + let Some(fixture) = Fixture::new() else { + return; + }; + let stub = |name: &str| { + fs::read_to_string( + fixture + .0 + .join("xml_generated") + .join(format!("windows__data__xml__dom__{name}.pyi")), + ) + .unwrap() + }; + let contains = |text: &str, signature: &str| { + assert!(text.contains(signature), "missing {signature}\n{text}"); + }; + for module in ["xml_document", "i_xml_document"] { + let text = stub(module); + for signature in [ + "def document_element(self) -> XmlElement | None: ...", + "def doctype(self) -> XmlDocumentType | None: ...", + "def get_element_by_id(self, element_id: str) -> XmlElement | None: ...", + "def implementation(self) -> XmlDomImplementation: ...", + "def create_element(self, tag_name: str) -> XmlElement: ...", + ] { + contains(&text, signature); + } + } + for module in ["xml_document", "xml_element", "xml_text", "i_xml_node"] { + let text = stub(module); + for property in [ + "parent_node", + "previous_sibling", + "next_sibling", + "first_child", + "last_child", + ] { + contains( + &text, + &format!("def {property}(self) -> IXmlNode | None: ..."), + ); + } + contains(&text, "def owner_document(self) -> XmlDocument | None: ..."); + contains(&text, "def child_nodes(self) -> XmlNodeList: ..."); + contains(&text, "def clone_node(self, deep: bool) -> IXmlNode: ..."); + } + for module in ["xml_element", "i_xml_element"] { + let text = stub(module); + for signature in [ + "def get_attribute_node(self, attribute_name: str) -> XmlAttribute | None: ...", + "def get_attribute_node_ns(self, namespace_uri: 'DynWinRTValue | _DynWinRTObject', local_name: str) -> XmlAttribute | None: ...", + "def set_attribute_node(self, new_attribute: 'XmlAttributeLike') -> XmlAttribute | None: ...", + "def set_attribute_node_ns(self, new_attribute: 'XmlAttributeLike') -> XmlAttribute | None: ...", + "def remove_attribute_node(self, attribute_node: 'XmlAttributeLike') -> XmlAttribute: ...", + ] { + contains(&text, signature); + } + } + let runtime = fs::read_to_string( + fixture + .0 + .join("xml_generated") + .join("windows__data__xml__dom__xml_document.py"), + ) + .unwrap(); + contains( + &runtime, + "def implementation(self) -> XmlDomImplementation | None:", + ); +} + +fn diagnostics(output: &Output) -> String { + format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ) +} + +fn success(output: Output) { + assert!(output.status.success(), "{}", diagnostics(&output)); +} + +fn python() -> PathBuf { + std::env::var_os("DYNWINRT_TEST_PYTHON") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("python")) +} + +fn python_available(arguments: &[&str], required: &str) -> bool { + let output = Command::new(python()).args(arguments).output().unwrap(); + assert!( + output.status.success() || std::env::var(required).as_deref() != Ok("1"), + "{required}=1: {}", + diagnostics(&output), + ); + if !output.status.success() { + eprintln!("Skipping Python XML probe: {}", diagnostics(&output)); + } + output.status.success() +} + +impl Fixture { + fn new() -> Option { + let winmd = std::env::var_os("DYNWINRT_WINDOWS_WINMD") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(WINDOWS_WINMD)); + if !winmd.is_file() { + assert_ne!( + std::env::var("DYNWINRT_REQUIRE_XML_NULLABILITY").as_deref(), + Ok("1"), + "XML nullability tests require Windows.winmd", + ); + eprintln!("Skipping XML nullability tests: Windows.winmd not found"); + return None; + } + let fixture = Self( + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target") + .join(format!( + "px{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed), + )), + ); + fs::create_dir_all(&fixture.0).unwrap(); + let package = fixture.0.join("xml_generated"); + success( + Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")) + .args(["generate", "--winmd"]) + .arg(&winmd) + .args([ + "--class-name", + "Windows.Data.Xml.Dom.XmlDocument,Windows.Data.Xml.Dom.XmlElement,\ + Windows.Data.Xml.Dom.XmlText,Windows.Data.Xml.Dom.XmlLoadSettings,\ + Windows.Data.Xml.Dom.IXmlNode", + "--lang", + "py", + "--output", + ]) + .arg(&package) + .output() + .unwrap(), + ); + + // Exclusive interfaces are not public CLI roots. Resolve their actual + // declarations independently, then exercise the standalone renderer. + let winmd = winmd.to_str().unwrap(); + let declarations = ["XmlDocument", "XmlElement"].map(|name| { + meta::parse_class(winmd, "Windows.Data.Xml.Dom", name) + .unwrap() + .default_interface + .unwrap() + }); + let seed = InterfaceMeta { + namespace: "Test".into(), + name: "IXmlDeclarations".into(), + methods: declarations + .iter() + .map(|interface| MethodMeta { + name: format!("Get{}", interface.name), + return_type: Some(TypeMeta::Interface { + namespace: interface.namespace.clone(), + name: interface.name.clone(), + iid: interface.iid.clone(), + }), + ..Default::default() + }) + .collect(), + ..Default::default() + }; + let resolved = meta::resolve_python_dependencies(winmd, &[], &[seed], &[]); + let context = python::PythonProjectionContext::new( + resolved + .classes + .iter() + .map(|class| { + TypeIdentity::named(TypeIdentityKind::Class, &class.namespace, &class.name) + }) + .chain(resolved.interfaces.iter().map(InterfaceMeta::type_identity)) + .chain(resolved.enums.iter().map(TypeMeta::type_identity)), + true, + ) + .unwrap(); + for declaration in &declarations { + let interface = resolved + .interfaces + .iter() + .find(|interface| { + interface.namespace == declaration.namespace + && interface.name == declaration.name + }) + .unwrap(); + assert_eq!(interface.iid, declaration.iid); + let module = context.implementation_module(&interface.type_identity()); + fs::write( + package.join(format!("{module}.py")), + python::generate_interface(&context, interface), + ) + .unwrap(); + fs::write( + package.join(format!("{module}.pyi")), + python_stub::generate_interface_stub(&context, interface), + ) + .unwrap(); + } + Some(fixture) + } +} + +#[test] +fn guarded_xml_reads_pass_and_unguarded_reads_fail_strict_typechecking() { + if !python_available(&["-m", "mypy", "--version"], "DYNWINRT_REQUIRE_MYPY") { + return; + } + let Some(fixture) = Fixture::new() else { + return; + }; + let expected_lines = INVALID + .lines() + .enumerate() + .filter_map(|(index, line)| line.ends_with("# unsafe").then_some(index + 1)) + .collect::>(); + assert_eq!(expected_lines.len(), 20); + for (name, consumer) in [("valid.py", VALID), ("invalid.py", INVALID)] { + fs::write(fixture.0.join(name), consumer).unwrap(); + let output = Command::new(python()) + .args([ + "-B", + "-m", + "mypy", + "--strict", + "--no-incremental", + "--no-pretty", + "--show-error-codes", + ]) + .arg(name) + .current_dir(&fixture.0) + .env_remove("MYPYPATH") + .output() + .unwrap(); + let text = diagnostics(&output); + if name == "valid.py" { + success(output); + } else { + assert_eq!(output.status.code(), Some(1), "{text}"); + let errors = text + .lines() + .filter(|line| line.contains(": error:")) + .collect::>(); + assert_eq!(errors.len(), expected_lines.len(), "{text}"); + let lines = errors + .iter() + .map(|line| { + assert!(line.starts_with("invalid.py:"), "{text}"); + assert!(line.ends_with("[union-attr]"), "{text}"); + line.split(':').nth(1).unwrap().parse().unwrap() + }) + .collect::>(); + assert_eq!(lines, expected_lines, "{text}"); + } + + if let Some(pyright) = std::env::var_os("DYNWINRT_PYRIGHT") { + let output = Command::new(pyright) + .args(["--pythonpath"]) + .arg(python()) + .args(["--outputjson", name]) + .current_dir(&fixture.0) + .output() + .unwrap(); + let text = diagnostics(&output); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let errors = report["generalDiagnostics"] + .as_array() + .unwrap() + .iter() + .filter(|diagnostic| diagnostic["severity"] == "error") + .collect::>(); + if name == "valid.py" { + assert!(errors.is_empty(), "{text}"); + success(output); + } else { + assert_eq!(output.status.code(), Some(1), "{text}"); + assert_eq!(errors.len(), expected_lines.len(), "{text}"); + let lines = errors + .iter() + .map(|error| { + assert_eq!(error["rule"], "reportOptionalMemberAccess", "{text}"); + assert!( + Path::new(error["file"].as_str().unwrap()).ends_with(name), + "{text}", + ); + error["range"]["start"]["line"].as_u64().unwrap() as usize + 1 + }) + .collect::>(); + assert_eq!(lines, expected_lines, "{text}"); + } + } + } +} + +#[test] +fn generated_xml_results_match_native_null_and_non_null_states() { + if !python_available( + &[ + "-c", + "from dynwinrt import RoApartment, projected_lifetime_scope", + ], + "DYNWINRT_REQUIRE_IMPLEMENTATION_RUNTIME", + ) { + return; + } + let Some(fixture) = Fixture::new() else { + return; + }; + let output = Command::new(python()) + .args(["-B", "-c"]) + .arg(format!("import sys; sys.path.insert(0, '.')\n{NATIVE}")) + .current_dir(&fixture.0) + .output() + .unwrap(); + println!("{}", diagnostics(&output)); + success(output); +} From b87791142317fc091f6726c29f807c191820ca12 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 1 Oct 2026 22:32:35 +0800 Subject: [PATCH 2/3] Keep test-hook stubs compatible with current rustfmt CI-only compatibility: accept optional trailing commas and format the two existing audio stub macro calls required by hosted Rust 1.99. Apply only the explicitly reviewed fixture delta; native signatures, function bodies, and XML product changes are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bindings/js/src/com_completion_test_hooks.rs | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/bindings/js/src/com_completion_test_hooks.rs b/bindings/js/src/com_completion_test_hooks.rs index a1285d2b..732bb670 100644 --- a/bindings/js/src/com_completion_test_hooks.rs +++ b/bindings/js/src/com_completion_test_hooks.rs @@ -652,15 +652,30 @@ unsafe extern "system" fn result_u32(this: *mut c_void, output: *mut u32) -> HRE } macro_rules! stub { - ($name:ident($($arg:ident: $typ:ty),*)) => { + ($name:ident($($arg:ident: $typ:ty),* $(,)?)) => { unsafe extern "system" fn $name(_this: *mut c_void, $($arg: $typ),*) -> HRESULT { E_NOTIMPL } }; } -stub!(audio_initialize(_mode: AUDCLNT_SHAREMODE, _flags: u32, _duration: i64, _period: i64, _format: *const WAVEFORMATEX, _session: *const GUID)); +stub!( + audio_initialize( + _mode: AUDCLNT_SHAREMODE, + _flags: u32, + _duration: i64, + _period: i64, + _format: *const WAVEFORMATEX, + _session: *const GUID, + ) +); stub!(audio_i64(_value: *mut i64)); -stub!(audio_format_supported(_mode: AUDCLNT_SHAREMODE, _format: *const WAVEFORMATEX, _closest: *mut *mut WAVEFORMATEX)); +stub!( + audio_format_supported( + _mode: AUDCLNT_SHAREMODE, + _format: *const WAVEFORMATEX, + _closest: *mut *mut WAVEFORMATEX, + ) +); stub!(audio_mix_format(_format: *mut *mut WAVEFORMATEX)); stub!(audio_period(_default: *mut i64, _minimum: *mut i64)); stub!(audio_noargs()); From 8da77328c86a9725047cf95365e05c6932c656c2 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 1 Oct 2026 23:04:07 +0800 Subject: [PATCH 3/3] Isolate XML nullability harness metadata and static stubs Use the native SDK contract shared by null-fact tests instead of the unrelated CLR Facade override used by TypeScript jobs, and reuse existing sibling expansion. Exercise source stubs in pre-wheel static phases, preserve extra source paths, and additionally typecheck installed runtime stubs when present. Keep native XML and strict checker requirements enforced in the production E2E lane. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/python_xml_nullability_test.rs | 110 +++++++++++------- 1 file changed, 68 insertions(+), 42 deletions(-) diff --git a/tools/dynwinrt-codegen/tests/python_xml_nullability_test.rs b/tools/dynwinrt-codegen/tests/python_xml_nullability_test.rs index 7add44b5..2203ac6a 100644 --- a/tools/dynwinrt-codegen/tests/python_xml_nullability_test.rs +++ b/tools/dynwinrt-codegen/tests/python_xml_nullability_test.rs @@ -131,9 +131,9 @@ fn python_available(arguments: &[&str], required: &str) -> bool { impl Fixture { fn new() -> Option { - let winmd = std::env::var_os("DYNWINRT_WINDOWS_WINMD") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(WINDOWS_WINMD)); + // The TSC job's metadata override can name the CLR Facade, which has + // no native default-interface contract. Use the same SDK as the facts. + let winmd = PathBuf::from(WINDOWS_WINMD); if !winmd.is_file() { assert_ne!( std::env::var("DYNWINRT_REQUIRE_XML_NULLABILITY").as_deref(), @@ -143,6 +143,7 @@ impl Fixture { eprintln!("Skipping XML nullability tests: Windows.winmd not found"); return None; } + let winmd_paths = meta::expand_winmd_paths(winmd.to_str().unwrap()); let fixture = Self( Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -161,7 +162,7 @@ impl Fixture { success( Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")) .args(["generate", "--winmd"]) - .arg(&winmd) + .arg(&winmd_paths) .args([ "--class-name", "Windows.Data.Xml.Dom.XmlDocument,Windows.Data.Xml.Dom.XmlElement,\ @@ -178,12 +179,11 @@ impl Fixture { // Exclusive interfaces are not public CLI roots. Resolve their actual // declarations independently, then exercise the standalone renderer. - let winmd = winmd.to_str().unwrap(); let declarations = ["XmlDocument", "XmlElement"].map(|name| { - meta::parse_class(winmd, "Windows.Data.Xml.Dom", name) - .unwrap() + meta::parse_class(&winmd_paths, "Windows.Data.Xml.Dom", name) + .expect("XML class in native SDK metadata") .default_interface - .unwrap() + .expect("native XML default interface") }); let seed = InterfaceMeta { namespace: "Test".into(), @@ -202,7 +202,7 @@ impl Fixture { .collect(), ..Default::default() }; - let resolved = meta::resolve_python_dependencies(winmd, &[], &[seed], &[]); + let resolved = meta::resolve_python_dependencies(&winmd_paths, &[], &[seed], &[]); let context = python::PythonProjectionContext::new( resolved .classes @@ -249,6 +249,23 @@ fn guarded_xml_reads_pass_and_unguarded_reads_fail_strict_typechecking() { let Some(fixture) = Fixture::new() else { return; }; + let installed_runtime = python_available( + &["-c", "import dynwinrt"], + "DYNWINRT_REQUIRE_IMPLEMENTATION_RUNTIME", + ); + let source_stubs = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("bindings") + .join("py"); + assert!(source_stubs.join("dynwinrt.pyi").is_file()); + let mut stub_paths = vec![source_stubs]; + if let Some(paths) = std::env::var_os("MYPYPATH") { + stub_paths.extend(std::env::split_paths(&paths)); + } + let source_stub_path = std::env::join_paths(stub_paths).unwrap(); let expected_lines = INVALID .lines() .enumerate() @@ -257,40 +274,49 @@ fn guarded_xml_reads_pass_and_unguarded_reads_fail_strict_typechecking() { assert_eq!(expected_lines.len(), 20); for (name, consumer) in [("valid.py", VALID), ("invalid.py", INVALID)] { fs::write(fixture.0.join(name), consumer).unwrap(); - let output = Command::new(python()) - .args([ - "-B", - "-m", - "mypy", - "--strict", - "--no-incremental", - "--no-pretty", - "--show-error-codes", - ]) - .arg(name) - .current_dir(&fixture.0) - .env_remove("MYPYPATH") - .output() - .unwrap(); - let text = diagnostics(&output); - if name == "valid.py" { - success(output); + for use_source_stubs in if installed_runtime { + &[true, false][..] } else { - assert_eq!(output.status.code(), Some(1), "{text}"); - let errors = text - .lines() - .filter(|line| line.contains(": error:")) - .collect::>(); - assert_eq!(errors.len(), expected_lines.len(), "{text}"); - let lines = errors - .iter() - .map(|line| { - assert!(line.starts_with("invalid.py:"), "{text}"); - assert!(line.ends_with("[union-attr]"), "{text}"); - line.split(':').nth(1).unwrap().parse().unwrap() - }) - .collect::>(); - assert_eq!(lines, expected_lines, "{text}"); + &[true][..] + } { + let mut command = Command::new(python()); + command + .args([ + "-B", + "-m", + "mypy", + "--strict", + "--no-incremental", + "--no-pretty", + "--show-error-codes", + ]) + .arg(name) + .current_dir(&fixture.0) + .env_remove("MYPYPATH"); + if *use_source_stubs { + command.env("MYPYPATH", &source_stub_path); + } + let output = command.output().unwrap(); + let text = diagnostics(&output); + if name == "valid.py" { + success(output); + } else { + assert_eq!(output.status.code(), Some(1), "{text}"); + let errors = text + .lines() + .filter(|line| line.contains(": error:")) + .collect::>(); + assert_eq!(errors.len(), expected_lines.len(), "{text}"); + let lines = errors + .iter() + .map(|line| { + assert!(line.starts_with("invalid.py:"), "{text}"); + assert!(line.ends_with("[union-attr]"), "{text}"); + line.split(':').nth(1).unwrap().parse().unwrap() + }) + .collect::>(); + assert_eq!(lines, expected_lines, "{text}"); + } } if let Some(pyright) = std::env::var_os("DYNWINRT_PYRIGHT") {