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()); diff --git a/tools/dynwinrt-codegen/README.md b/tools/dynwinrt-codegen/README.md index e436347b..b432d08e 100644 --- a/tools/dynwinrt-codegen/README.md +++ b/tools/dynwinrt-codegen/README.md @@ -155,6 +155,12 @@ complete output. Python module components longer than 120 characters are shortened with a stable readable prefix and hash suffix while public type names remain unchanged. +That component budget does not constrain an arbitrary output root. On Windows, +Python generation warns once about final `.py`/`.pyi` paths of 260 or more +UTF-16 code units, without rejecting generation or changing generated bytes. +See [Python short-path guidance](python/README.md#windows-path-length) for +import, type-checking, build, and installation considerations. + Implementation helper names are allocated alongside metadata types and follow their owning interfaces through incremental renames. Python's package-level root exports drop an interface and its helpers when its short name becomes diff --git a/tools/dynwinrt-codegen/python/README.md b/tools/dynwinrt-codegen/python/README.md index f9dda50b..5eb46bac 100644 --- a/tools/dynwinrt-codegen/python/README.md +++ b/tools/dynwinrt-codegen/python/README.md @@ -195,6 +195,76 @@ closed identity, even when their local projection names differ. Python module components longer than 120 characters are shortened with a stable readable prefix and hash suffix while public type names remain unchanged. +### Windows path length + +A short module name does not guarantee a short **absolute** path. In deep +checkouts, codegen can successfully write files that ordinary Python imports, +mypy, or pip cannot open. CPython supports long paths on appropriately +configured Windows; see the [Python Windows guide](https://docs.python.org/3.11/using/windows.html#removing-the-max-path-limitation) +and [Windows long-path requirements](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation). +Extended `\\?\` paths used by the generator do not automatically make downstream +tools long-path compatible; the Windows `LongPathsEnabled` setting alone does +not prove every consumer supports them. + +On Windows, the generator emits one stderr warning before publishing output +when its longest final `.py`/`.pyi` path reaches 260 UTF-16 code units (the +legacy limit allows 259, excluding the terminating NUL). The warning names +that file, measures its path without the extended prefix, and states how much +shorter the output root needs to be. Relative `--output` paths are resolved +from the current directory; temporary transactional staging paths are not +counted. `--dry-run` checks the requested projection's planned source paths, +aggregating all selected namespaces into one warning; `--no-pyi` excludes +stubs. This is a **compatibility risk diagnostic**: +generation still succeeds, and module names, imports, layout, and bytes are +unchanged. + +Prefer a shorter checkout such as `C:\src\dynwinrt`, or generate directly into +a short root and run consumers there: + +```powershell +dynwinrt-codegen generate --namespace Windows.Foundation --class-name Uri ` + --lang py --output C:\g\generated_uri +Set-Location C:\g +python -c "from generated_uri.windows.foundation import Uri; print(Uri)" +python -m mypy --strict .\consumer.py +python -m pip install .\generated_uri +``` + +Here `consumer.py` is your application or typing fixture beside the generated +package. Leave additional headroom for pip's build staging and the destination +venv/site-packages path. Those paths cannot all be predicted from `--output`; +no warning is not a guarantee that a later build or deep-venv install will work. +Keep the venv short as well, for example `C:\g\venv`. + +For an existing deep source tree, an optional, user-owned short junction can +expose the **same generated bytes** without renaming modules or copying code: + +```powershell +New-Item -ItemType Directory -Path C:\g -Force | Out-Null +New-Item -ItemType Junction -Path C:\g\project -Target C:\src\deep\project +Set-Location C:\g\project +dynwinrt-codegen generate --namespace Windows.Foundation --class-name Uri ` + --lang py --output .\generated +python -c "from generated.windows.foundation import Uri; print(Uri)" +python -m mypy --strict .\consumer.py +python -m pip install .\generated +Set-Location C:\g +Remove-Item -LiteralPath C:\g\project +``` + +Link the parent checkout, not the generated directory itself: transactional +generation rejects a linked output directory. Use the **same shortened path** +for generation, imports, mypy, and package builds; returning to the deep path +reintroduces its limits. Remove only the junction you created, without +recursively deleting its target. + +Alternatively, map an unused drive for the session with +`subst.exe R: C:\src\deep\project`, use `R:\` consistently for these commands, +and remove only that mapping with `subst.exe R: /D` after leaving the drive. +These short-path remedies have been validated with Python imports, strict +mypy, and generated-package installation on Windows with long paths disabled; +they do not automatically fix arbitrary deep build or install destinations. + ## Links - [`dynwinrt` runtime on PyPI](https://pypi.org/project/dynwinrt/) diff --git a/tools/dynwinrt-codegen/src/main.rs b/tools/dynwinrt-codegen/src/main.rs index 9bbdd142..c0c2171c 100644 --- a/tools/dynwinrt-codegen/src/main.rs +++ b/tools/dynwinrt-codegen/src/main.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod python_output_paths; mod win32_census; mod win32_output; @@ -535,6 +536,7 @@ fn run() -> Result<(), String> { remove_all_generated_python_stubs(output_dir)?; } + let mut python_namespace_summary = None; if let Some(ref cls_arg) = class_name { let class_requests = parse_class_requests(cls_arg, namespace.as_deref())?; @@ -962,6 +964,7 @@ fn run() -> Result<(), String> { let (_, _, _, shared_interfaces) = generate_for_types( &winmd, output_dir, + final_output_dir, classes.clone(), implicit_interfaces.clone(), Vec::new(), @@ -1116,6 +1119,7 @@ fn run() -> Result<(), String> { generate_for_types( &winmd, output_dir, + final_output_dir, selected_classes, selected_interfaces, selected_enums, @@ -1210,6 +1214,8 @@ fn run() -> Result<(), String> { "Done. {} class(es) + {} interface(s) + {} enum(s) validated (dry run)", total_classes, total_interfaces, total_enums, ); + } else if lang == "py" { + python_namespace_summary = Some((total_classes, total_interfaces, total_enums)); } else { println!( "Done. {} class(es) + {} interface(s) + {} enum(s) generated in {}", @@ -1223,11 +1229,18 @@ fn run() -> Result<(), String> { if lang == "py" && !dry_run { write_python_package_manifest(output_dir, final_output_dir)?; - write_python_generated_inventory(output_dir, pyi)?; + let files = write_python_generated_inventory(output_dir, pyi)?; + python_output_paths::warn(final_output_dir, &files)?; } if let Some(transaction) = output_transaction.take() { transaction.commit()?; } + if let Some((classes, interfaces, enums)) = python_namespace_summary { + println!( + "Done. {classes} class(es) + {interfaces} interface(s) + {enums} enum(s) generated in {}", + final_output_dir.display() + ); + } } } Ok(()) @@ -1238,6 +1251,7 @@ fn run() -> Result<(), String> { fn generate_for_types( winmd: &str, output_dir: &Path, + final_output_dir: &Path, classes: Vec, interfaces: Vec, enums: Vec, @@ -1502,6 +1516,27 @@ fn generate_for_types( } else { None }; + if dry_run && let Some(context) = python_context.as_ref() { + let mut struct_interfaces = emittable_interfaces.clone(); + struct_interfaces.extend_from_slice(&shared_interfaces); + let mut implementations = + python_type_identities(&all_classes, &struct_interfaces, &all_enums); + let mut public_types = + python_type_identities(&all_classes, &emittable_interfaces, &all_enums); + implementations + .retain(|identity| !python_type_shadowed_by_class(identity, &class_identities_all)); + public_types + .retain(|identity| !python_type_shadowed_by_class(identity, &class_identities_all)); + python_output_paths::warn( + final_output_dir, + &python_output_paths::dry_run_source_files( + context, + &implementations, + &public_types, + pyi, + ), + )?; + } let (delegate_signatures, delegate_sig_refs, delegate_param_wraps) = if let Some(context) = javascript_context.as_ref() { project::build_delegate_signatures( @@ -6895,7 +6930,7 @@ fn clean_python_generated_output(output_dir: &Path) -> Result<(), String> { Ok(()) } -fn write_python_generated_inventory(output_dir: &Path, pyi: bool) -> Result<(), String> { +fn write_python_generated_inventory(output_dir: &Path, pyi: bool) -> Result, String> { let files = collect_generated_python_files(output_dir, true, pyi)?; let content = files .iter() @@ -6905,7 +6940,8 @@ fn write_python_generated_inventory(output_dir: &Path, pyi: bool) -> Result<(), write_file( &output_dir.join(PYTHON_GENERATED_INVENTORY), &format!("{content}\n"), - ) + )?; + Ok(files) } fn collect_generated_python_files( @@ -10603,9 +10639,11 @@ mod tests { ..Default::default() }; + let output_dir = test_directory("python-struct-symbol-collision"); let error = generate_for_types( "", - &test_directory("python-struct-symbol-collision"), + &output_dir, + &output_dir, vec![class], Vec::new(), Vec::new(), diff --git a/tools/dynwinrt-codegen/src/python_output_paths.rs b/tools/dynwinrt-codegen/src/python_output_paths.rs new file mode 100644 index 00000000..2afa8872 --- /dev/null +++ b/tools/dynwinrt-codegen/src/python_output_paths.rs @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use dynwinrt_codegen::codegen::python::{PythonProjectionContext, PythonTypeIdentity}; +use dynwinrt_codegen::types::TypeIdentityKind; + +const LEGACY_MAX_PATH: usize = 260; // Includes the terminating NUL. + +pub(crate) fn dry_run_source_files( + context: &PythonProjectionContext, + implementations: &[PythonTypeIdentity], + public_types: &[PythonTypeIdentity], + pyi: bool, +) -> Vec { + fn add_module(files: &mut BTreeSet, module: &Path, pyi: bool) { + files.insert(module.with_extension("py")); + if pyi { + files.insert(module.with_extension("pyi")); + } + } + + let mut files = BTreeSet::new(); + for module in ["__init__", "_runtime"] { + add_module(&mut files, Path::new(module), pyi); + } + if pyi { + files.extend(["_typing.pyi", "_implementation_types.pyi"].map(PathBuf::from)); + } + let is_emitted = |identity: &&PythonTypeIdentity| { + !(identity.kind() == Some(TypeIdentityKind::Struct) + && identity.definition_name() == Some("HResult")) + }; + for identity in implementations.iter().filter(is_emitted) { + add_module( + &mut files, + Path::new(&context.implementation_module(identity)), + pyi, + ); + } + for identity in public_types.iter().filter(is_emitted) { + if identity.namespace().is_none_or(str::is_empty) { + continue; + } + let facade = context + .public_qualified_module(identity) + .split('.') + .collect::(); + add_module(&mut files, &facade, pyi); + let mut parent = facade.parent(); + while let Some(directory) = parent.filter(|path| !path.as_os_str().is_empty()) { + add_module(&mut files, &directory.join("__init__"), pyi); + parent = directory.parent(); + } + } + files.into_iter().collect() +} + +pub(crate) fn warn(output_dir: &Path, source_files: &[PathBuf]) -> Result<(), String> { + if let Some(message) = warning(output_dir, source_files)? { + eprintln!("{message}"); + } + Ok(()) +} + +fn warning(output_dir: &Path, source_files: &[PathBuf]) -> Result, String> { + if !cfg!(windows) { + return Ok(None); + } + // Do not canonicalize: a junction/subst path is the consumer's useful short path. + let output_dir = absolute_consumer_path(output_dir)?; + let longest = source_files + .iter() + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "py" || extension == "pyi") + }) + .map(|relative| { + let path = output_dir.join(relative); + let length = utf16_length(&path); + (path, length) + }) + .max_by(|left, right| (left.1, &left.0).cmp(&(right.1, &right.0))); + Ok(longest.and_then(|(path, length)| warning_for_longest(&path, length, true))) +} + +fn warning_for_longest(path: &Path, length: usize, windows: bool) -> Option { + if !windows || length < LEGACY_MAX_PATH { + return None; + } + let excess = length - (LEGACY_MAX_PATH - 1); + Some(format!( + "warning: longest Python output path is {length} UTF-16 code units \ + (legacy Windows maximum: 259, excluding NUL): '{}'.\n \ + Python import, mypy or pip may fail even if generation succeeds. \ + Shorten --output or the checkout by at least {excess} code units \ + (e.g. C:\\g\\bindings); run generation and consumers through the same short path. \ + Build/install staging and venv paths may need additional headroom.", + path.display(), + )) +} + +fn absolute_consumer_path(path: &Path) -> Result { + #[cfg(windows)] + let normalized = { + use std::ffi::OsString; + use std::path::{Component, Prefix}; + + let mut components = path.components(); + let prefix = match components.next() { + Some(Component::Prefix(prefix)) => match prefix.kind() { + Prefix::VerbatimDisk(drive) => { + Some(OsString::from(format!("{}:", char::from(drive)))) + } + Prefix::VerbatimUNC(server, share) => { + let mut prefix = OsString::from(r"\\"); + prefix.push(server); + prefix.push(r"\"); + prefix.push(share); + Some(prefix) + } + _ => None, + }, + _ => None, + }; + if let Some(prefix) = prefix { + let mut normalized = PathBuf::from(prefix); + normalized.extend(components.map(|component| component.as_os_str())); + normalized + } else { + path.to_path_buf() + } + }; + #[cfg(not(windows))] + let normalized = path.to_path_buf(); + + std::path::absolute(normalized).map_err(|error| { + format!( + "Failed to resolve Python output path '{}': {error}", + path.display() + ) + }) +} + +fn utf16_length(path: &Path) -> usize { + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use std::path::{Component, Prefix}; + + let prefix_length = match path.components().next() { + Some(Component::Prefix(prefix)) if matches!(prefix.kind(), Prefix::Verbatim(_)) => 4, + _ => 0, + }; + path.as_os_str().encode_wide().count() - prefix_length + } + #[cfg(not(windows))] + { + path.to_string_lossy().encode_utf16().count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dynwinrt_codegen::types::TypeIdentity; + + #[test] + fn legacy_budget_boundaries_and_non_windows_policy() { + for (length, risky) in [(259, false), (260, true), (261, true)] { + let path = PathBuf::from(format!(r"C:\g\{}.py", "x".repeat(length - 8))); + assert_eq!(utf16_length(&path), length); + let message = warning_for_longest(&path, length, true); + assert_eq!(message.is_some(), risky); + if let Some(message) = message { + assert!(message.contains(&format!("{length} UTF-16 code units"))); + assert!(message.contains(&format!("at least {} code units", length - 259))); + assert!(message.contains("--output")); + } + assert!(warning_for_longest(&path, length, false).is_none()); + } + } + + #[test] + fn dry_run_paths_include_facades_indexes_and_only_requested_stubs() { + let identity = TypeIdentity::named(TypeIdentityKind::Class, "Windows.Foundation", "Uri"); + let context = PythonProjectionContext::packaged([identity.clone()]).unwrap(); + let identities = [identity]; + let runtime = dry_run_source_files(&context, &identities, &identities, false); + assert!(runtime.contains(&PathBuf::from("windows__foundation__uri.py"))); + assert!(runtime.contains(&Path::new("windows").join("foundation").join("uri.py"))); + assert!(runtime.contains(&Path::new("windows").join("foundation").join("__init__.py"))); + assert!(runtime.iter().all(|path| path.extension().unwrap() == "py")); + let typed = dry_run_source_files(&context, &identities, &identities, true); + assert!(typed.contains(&PathBuf::from("_implementation_types.pyi"))); + for path in runtime { + assert!(typed.contains(&path)); + assert!(typed.contains(&path.with_extension("pyi"))); + } + } + + #[cfg(windows)] + #[test] + fn normalized_drive_unc_and_extended_paths_have_the_same_budget() { + for (input, expected) in [ + (r"C:\g\.\deep\..\bindings", r"C:\g\bindings"), + (r"\\?\C:\g\.\deep\..\bindings", r"C:\g\bindings"), + ( + r"\\server\share\deep\..\bindings", + r"\\server\share\bindings", + ), + ( + r"\\?\UNC\server\share\deep\..\bindings", + r"\\server\share\bindings", + ), + ] { + let path = absolute_consumer_path(Path::new(input)).unwrap(); + assert_eq!(path, Path::new(expected)); + assert_eq!(utf16_length(&path), expected.encode_utf16().count()); + } + } + + #[cfg(windows)] + #[test] + fn stubs_can_cross_the_budget_when_runtime_sources_do_not() { + let runtime = PathBuf::from(format!("{}.py", "x".repeat(251))); + assert!( + warning(Path::new(r"C:\g"), &[runtime.clone()]) + .unwrap() + .is_none() + ); + let stub = runtime.with_extension("pyi"); + let message = warning(Path::new(r"\\?\C:\g"), &[runtime, stub]) + .unwrap() + .unwrap(); + assert!(message.contains("260 UTF-16 code units")); + assert!(message.contains(".pyi'")); + assert!(!message.contains(r"\\?\")); + } + + #[cfg(windows)] + #[test] + fn public_facades_can_be_longer_than_implementation_modules() { + let namespace = format!( + "Contoso.{}.{}.{}", + "a".repeat(90), + "b".repeat(90), + "c".repeat(90), + ); + let identity = TypeIdentity::named(TypeIdentityKind::Class, namespace, "Widget"); + let context = PythonProjectionContext::packaged([identity.clone()]).unwrap(); + let implementation = + PathBuf::from(format!("{}.pyi", context.implementation_module(&identity))); + assert!( + warning(Path::new(r"C:\g"), &[implementation]) + .unwrap() + .is_none() + ); + let identities = [identity]; + let files = dry_run_source_files(&context, &identities, &identities, true); + let message = warning(Path::new(r"C:\g"), &files).unwrap().unwrap(); + assert!(message.contains(r"C:\g\contoso\")); + assert!(message.contains(".pyi'")); + } + + #[cfg(windows)] + #[test] + fn relative_paths_and_non_ascii_are_measured_in_utf16() { + let relative = Path::new(r"parent\..\generated"); + let absolute = std::env::current_dir().unwrap().join("generated"); + assert_eq!(absolute_consumer_path(relative).unwrap(), absolute); + let module = PathBuf::from(format!("{}.py", "\u{1f642}".repeat(126))); + let path = absolute.join(&module); + let message = warning(relative, &[module]).unwrap().unwrap(); + assert!(message.contains(&path.display().to_string())); + assert!(message.contains(&format!("{} UTF-16 code units", utf16_length(&path)))); + assert_ne!(utf16_length(&path), path.to_string_lossy().chars().count()); + } + + #[test] + fn ordinary_outputs_and_non_python_files_do_not_warn() { + assert!( + warning(Path::new(r"C:\g"), &[PathBuf::from("uri.py")]) + .unwrap() + .is_none() + ); + assert!( + warning( + Path::new("generated"), + &[PathBuf::from(format!("{}.js", "x".repeat(300)))] + ) + .unwrap() + .is_none() + ); + if !cfg!(windows) { + assert!( + warning( + Path::new("generated"), + &[PathBuf::from(format!("{}.py", "x".repeat(300)))] + ) + .unwrap() + .is_none() + ); + } + } +} diff --git a/tools/dynwinrt-codegen/tests/python_output_path_test.rs b/tools/dynwinrt-codegen/tests/python_output_path_test.rs new file mode 100644 index 00000000..d62eff3c --- /dev/null +++ b/tools/dynwinrt-codegen/tests/python_output_path_test.rs @@ -0,0 +1,315 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg(windows)] + +use std::collections::BTreeMap; +use std::fs; +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use windows_metadata::{FieldAttributes, Type, TypeAttributes, Value, writer}; + +const WINDOWS_WINMD: &str = + r"C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.26100.0\Windows.winmd"; +const WARNING: &str = "warning: longest Python output path"; +static NEXT: AtomicU64 = AtomicU64::new(0); + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "dpp{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn output(&self, name: &str, length: usize) -> PathBuf { + let parent = self.0.join(name); + let leaf = "generated_bindings"; + let padding = length + .checked_sub(path_length(&parent) + leaf.len() + 2) + .unwrap(); + assert!((1..=255).contains(&padding)); + let output = parent.join("x".repeat(padding)).join(leaf); + fs::create_dir_all(output.parent().unwrap()).unwrap(); + assert_eq!(path_length(&output), length); + output + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} + +fn path_length(path: &Path) -> usize { + path.as_os_str().encode_wide().count() +} + +fn command(output: &Path) -> Command { + command_with_metadata(output, Path::new(WINDOWS_WINMD)) +} + +fn command_with_metadata(output: &Path, metadata: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")); + command + .args(["generate", "--winmd"]) + .arg(metadata) + .args(["--lang", "py", "--output"]) + .arg(output) + .env_remove("DYNWINRT_CODEGEN_TEST_FAIL_OUTPUT_COMMIT"); + command +} + +fn device_command(output: &Path) -> Command { + let mut command = command(output); + command.args([ + "--class-name", + "Windows.Devices.Enumeration.DeviceInformationCustomPairing", + ]); + command +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn successful(output: &Output) { + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + stderr(output), + ); +} + +fn files(root: &Path) -> BTreeMap> { + fn visit(root: &Path, current: &Path, result: &mut BTreeMap>) { + for entry in fs::read_dir(current).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + visit(root, &path, result); + } else { + result.insert( + path.strip_prefix(root).unwrap().to_path_buf(), + fs::read(path).unwrap(), + ); + } + } + } + let mut result = BTreeMap::new(); + visit(root, root, &mut result); + result +} + +#[test] +fn python_dry_run_aggregates_multiple_namespaces_into_one_warning_without_writes() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + let fixture = Fixture::new(); + let metadata = fixture.0.join("MultipleNamespaces.winmd"); + let mut file = writer::File::new("PythonOutputPaths"); + let base = file.TypeRef("System", "Enum"); + for namespace in ["Contoso.OutputPaths.First", "Contoso.OutputPaths.Second"] { + for index in 0..4 { + let name = format!("Options{index}"); + file.TypeDef( + namespace, + &name, + writer::TypeDefOrRef::TypeRef(base), + TypeAttributes::Public | TypeAttributes::Sealed | TypeAttributes::WindowsRuntime, + ); + file.Field( + "value__", + &Type::I32, + FieldAttributes::Public + | FieldAttributes::SpecialName + | FieldAttributes::RTSpecialName, + ); + let member = file.Field( + "One", + &Type::named(namespace, &name), + FieldAttributes::Public + | FieldAttributes::Static + | FieldAttributes::Literal + | FieldAttributes::HasDefault, + ); + file.Constant(writer::HasConstant::Field(member), &Value::I32(1)); + } + } + fs::write(&metadata, file.into_stream()).unwrap(); + let output = fixture.output("multiple-namespaces", 220); + let before = files(&fixture.0); + let dry_run = command_with_metadata(&output, &metadata) + .args(["--ref", WINDOWS_WINMD, "--dry-run"]) + .output() + .unwrap(); + successful(&dry_run); + let message = stderr(&dry_run); + assert!( + message.contains("Discovered 2 namespace(s) to generate:"), + "{message}" + ); + assert!(message.contains("Contoso.OutputPaths.First"), "{message}"); + assert!(message.contains("Contoso.OutputPaths.Second"), "{message}"); + assert_eq!(message.matches(WARNING).count(), 1, "{message}"); + assert!( + String::from_utf8_lossy(&dry_run.stdout).contains("8 enum(s) validated (dry run)"), + "{}", + String::from_utf8_lossy(&dry_run.stdout), + ); + assert!(!output.exists()); + assert_eq!(files(&fixture.0), before); +} + +#[test] +fn python_path_diagnostic_uses_actual_final_files_not_transactional_paths() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + let fixture = Fixture::new(); + let deep = fixture.output("deep", 137); + let shallow = fixture.output("short", 130); + let output = device_command(&deep).output().unwrap(); + successful(&output); + let generated = files(&deep); + let (longest, length) = generated + .keys() + .filter(|path| { + path.extension() + .is_some_and(|ext| ext == "py" || ext == "pyi") + }) + .map(|relative| { + let absolute = deep.join(relative); + let length = path_length(&absolute); + (absolute, length) + }) + .max_by(|left, right| (left.1, &left.0).cmp(&(right.1, &right.0))) + .unwrap(); + assert_eq!(length, 262); + let message = stderr(&output); + assert_eq!(message.matches(WARNING).count(), 1, "{message}"); + assert!(message.contains(&format!("{length} UTF-16 code units"))); + assert!( + message.contains(&longest.display().to_string()), + "{message}" + ); + assert!(message.contains("at least 3 code units")); + assert!(!message.contains(".dynwinrt-stage-")); + + let ordinary = device_command(&shallow).output().unwrap(); + successful(&ordinary); + assert!(!stderr(&ordinary).contains(WARNING)); + assert_eq!(generated, files(&shallow)); + assert!( + String::from_utf8_lossy(&ordinary.stdout) + .lines() + .filter_map(|line| line.strip_prefix("Generated ")) + .any(|path| path.encode_utf16().count() >= 260), + "the no-warning control must exercise over-budget staging paths" + ); + + let unpublished = fixture.output("failure", 137); + let failure = device_command(&unpublished) + .env("DYNWINRT_CODEGEN_TEST_FAIL_OUTPUT_COMMIT", "before_publish") + .output() + .unwrap(); + assert!(!failure.status.success()); + assert!(!unpublished.exists()); + let message = stderr(&failure); + assert!( + message.find(WARNING).unwrap() + < message.find("Injected output transaction failure").unwrap(), + "{message}", + ); +} + +#[test] +fn python_path_diagnostic_respects_relative_dry_run_and_no_pyi_boundary() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + let fixture = Fixture::new(); + let output = fixture.output("boundary-\u{1f642}", 135); + let relative = output.strip_prefix(&fixture.0).unwrap(); + let dry_run = device_command(relative) + .current_dir(&fixture.0) + .arg("--dry-run") + .output() + .unwrap(); + successful(&dry_run); + assert!(!output.exists()); + let message = stderr(&dry_run); + assert_eq!(message.matches(WARNING).count(), 1, "{message}"); + assert!(message.contains("260 UTF-16 code units")); + assert!(message.contains(&output.display().to_string())); + let runtime_dry_run = device_command(relative) + .current_dir(&fixture.0) + .args(["--dry-run", "--no-pyi"]) + .output() + .unwrap(); + successful(&runtime_dry_run); + assert!(!stderr(&runtime_dry_run).contains(WARNING)); + assert!(!output.exists()); + + let extended = PathBuf::from(format!(r"\\?\{}", output.display())); + let typed = device_command(&extended).output().unwrap(); + successful(&typed); + assert!(stderr(&typed).contains("260 UTF-16 code units")); + assert!(!stderr(&typed).contains(r"\\?\")); + let typed_files = files(&output); + let runtime = device_command(&output).arg("--no-pyi").output().unwrap(); + successful(&runtime); + assert!(!stderr(&runtime).contains(WARNING)); + let runtime_files = files(&output); + assert!( + runtime_files + .keys() + .all(|path| path.extension().is_none_or(|ext| ext != "pyi")) + ); + assert!(!output.join("py.typed").exists()); + for (path, content) in typed_files + .iter() + .filter(|(path, _)| path.extension().is_some_and(|ext| ext == "py")) + { + assert_eq!(runtime_files.get(path), Some(content)); + } +} + +#[test] +fn python_path_diagnostic_precedes_namespace_completion() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping: Windows.winmd not found"); + return; + } + let fixture = Fixture::new(); + let output = fixture.output("namespace", 220); + let log_path = fixture.0.join("combined.log"); + let log = fs::File::create(&log_path).unwrap(); + let status = command(&output) + .args(["--namespace", "Windows.Security.Cryptography"]) + .stdout(Stdio::from(log.try_clone().unwrap())) + .stderr(Stdio::from(log)) + .status() + .unwrap(); + let log = fs::read_to_string(log_path).unwrap(); + assert!(status.success(), "{log}"); + assert_eq!(log.matches(WARNING).count(), 1, "{log}"); + assert!( + log.find(WARNING).unwrap() < log.find("Done.").unwrap(), + "{log}" + ); + assert!(!files(&output).is_empty()); +}