diff --git a/crates/pyetwkit-core/src/event.rs b/crates/pyetwkit-core/src/event.rs index fb06ff3..aca9189 100644 --- a/crates/pyetwkit-core/src/event.rs +++ b/crates/pyetwkit-core/src/event.rs @@ -42,6 +42,8 @@ pub struct EtwEvent { pub properties: HashMap, /// Raw event data (if schema parsing failed) pub raw_data: Option>, + /// TDH's own display strings, populated only when property formatting is on + pub formatted_properties: HashMap, /// Stack trace (if enabled) pub stack_trace: Option>, } @@ -93,6 +95,7 @@ impl EtwEvent { channel: 0, properties: HashMap::new(), raw_data: None, + formatted_properties: HashMap::new(), stack_trace: None, } } @@ -290,6 +293,39 @@ impl PyEtwEvent { self.inner.channel } + /// Undecoded payload, present only when TDH could not describe the event. + /// + /// WPP events with no matching TMF, and manifest providers whose manifest is + /// not installed on this machine, have no property names to report a value + /// under. `properties` is empty for those, and this carries the bytes so the + /// payload is still recoverable. `None` whenever the event decoded normally. + #[getter] + fn raw_data(&self, py: Python<'_>) -> PyResult>> { + use pyo3::IntoPyObject; + match &self.inner.raw_data { + Some(bytes) => Ok(Some( + bytes.as_slice().into_pyobject(py)?.into_any().unbind(), + )), + None => Ok(None), + } + } + + /// TDH's own display strings for each property, name to text. + /// + /// Empty unless property formatting has been switched on, and empty for + /// events TDH has no schema for. These differ from `properties` in that TDH + /// resolves the manifest's value maps, so a bare number becomes the name the + /// manifest gives it. Struct-typed properties are absent: their members are + /// values in their own right and `properties` already reports them. + #[getter] + fn formatted_properties(&self, py: Python<'_>) -> PyResult> { + let dict = PyDict::new(py); + for (key, value) in &self.inner.formatted_properties { + dict.set_item(key, value)?; + } + Ok(dict.into()) + } + /// Get event properties as a dictionary #[getter] fn properties(&self, py: Python<'_>) -> PyResult> { diff --git a/crates/pyetwkit-core/src/lib.rs b/crates/pyetwkit-core/src/lib.rs index cfce7a3..2c167de 100644 --- a/crates/pyetwkit-core/src/lib.rs +++ b/crates/pyetwkit-core/src/lib.rs @@ -71,6 +71,26 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { wrap_pyfunction!(discovery::py_get_provider_info, m)?, )?; + // Property formatting toggle + m.add( + "set_property_formatting", + wrap_pyfunction!(tdh::py_set_property_formatting, m)?, + )?; + m.add( + "property_formatting", + wrap_pyfunction!(tdh::py_property_formatting, m)?, + )?; + + // WPP decoding needs a directory of .tmf files to consult + m.add( + "set_wpp_tmf_search_path", + wrap_pyfunction!(tdh::py_set_wpp_tmf_search_path, m)?, + )?; + m.add( + "wpp_tmf_search_path", + wrap_pyfunction!(tdh::py_wpp_tmf_search_path, m)?, + )?; + // Register ETL reader m.add_class::()?; diff --git a/crates/pyetwkit-core/src/session.rs b/crates/pyetwkit-core/src/session.rs index ded6b3d..6294335 100644 --- a/crates/pyetwkit-core/src/session.rs +++ b/crates/pyetwkit-core/src/session.rs @@ -364,7 +364,17 @@ pub fn parse_event_record(record: &EventRecord, schema: Option<&Schema>) -> EtwE if let Some(schema) = schema { event.provider_name = Some(schema.provider_name().to_string()); } - // Note: raw_data extraction removed as user_buffer is private in ferrisetw 1.2 + + // When TDH has no schema -- WPP with no matching TMF, or a manifest that is + // not installed here -- there are no names to report a payload under, and + // the payload used to be dropped entirely. Keep the bytes instead, so the + // event is at least recoverable. Only in that case: carrying a second copy + // of every decoded event's data would be a memory cost for nothing. + event.formatted_properties = crate::tdh::format_properties(record); + + if event.properties.is_empty() && !crate::tdh::has_schema(record) { + event.raw_data = crate::tdh::raw_user_data(record); + } event } diff --git a/crates/pyetwkit-core/src/tdh.rs b/crates/pyetwkit-core/src/tdh.rs index 3b23bf0..7b71f74 100644 --- a/crates/pyetwkit-core/src/tdh.rs +++ b/crates/pyetwkit-core/src/tdh.rs @@ -26,12 +26,16 @@ use crate::event::EventValue; use chrono::{TimeZone, Utc}; use ferrisetw::EventRecord; use parking_lot::Mutex; +use pyo3::prelude::*; use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; use uuid::Uuid; +use windows::core::{PCWSTR, PWSTR}; use windows::Win32::System::Diagnostics::Etw::{ - TdhGetEventInformation, TdhGetProperty, TdhGetPropertySize, EVENT_PROPERTY_INFO, EVENT_RECORD, - PROPERTY_DATA_DESCRIPTOR, TRACE_EVENT_INFO, + TdhFormatProperty, TdhGetEventInformation, TdhGetEventMapInformation, TdhGetProperty, + TdhGetPropertySize, EVENT_MAP_INFO, EVENT_PROPERTY_INFO, EVENT_RECORD, + PROPERTY_DATA_DESCRIPTOR, TDH_CONTEXT, TDH_CONTEXT_WPP_TMFSEARCHPATH, TRACE_EVENT_INFO, }; /// `EVENT_HEADER_FLAG_32_BIT_HEADER`, used to pick the pointer width of the @@ -48,6 +52,9 @@ const PROPERTY_STRUCT: i32 = 1; /// rather than in this one's `count` field. const PROPERTY_PARAM_COUNT: i32 = 4; +/// `PROPERTY_FLAGS` bit meaning the byte length lives in another property. +const PROPERTY_PARAM_LENGTH: i32 = 2; + /// Ceiling on how many elements we will materialise for one array property. /// /// A fixed `count` cannot exceed `u16::MAX` anyway; the cap only matters for a @@ -117,6 +124,11 @@ struct PropertyDesc { struct_start: u16, /// For a struct, how many members follow `struct_start`. struct_members: u16, + /// Declared byte length, which `TdhFormatProperty` wants for fixed types. + length: u16, + /// Name of the value map for this property, if the schema declares one, + /// NUL-terminated and ready for `TdhGetEventMapInformation`. + map_name: Option>, } impl PropertyDesc { @@ -135,6 +147,8 @@ impl PropertyDesc { count: PropertyCount::Fixed(1), struct_start: 0, struct_members: 0, + length: 0, + map_name: None, } } @@ -155,6 +169,54 @@ struct EventLayout { type LayoutKey = (u128, u16, u8); +/// Directory TDH should search for WPP `.tmf` files, NUL-terminated UTF-16. +/// +/// WPP events carry no schema of their own: the format strings live in a `.tmf` +/// generated from the emitting binary's PDB. Without one TDH can say nothing +/// about the event, which is why such events come back with no properties and +/// only `raw_data`. Given a search path it can decode them. +fn tmf_search_path() -> &'static Mutex>> { + static PATH: OnceLock>>> = OnceLock::new(); + PATH.get_or_init(|| Mutex::new(None)) +} + +/// Point TDH at a directory of WPP `.tmf` files, or pass `None` to stop. +/// +/// Clears the layout cache, since whether an event has a schema is exactly what +/// this changes. +pub fn set_wpp_tmf_search_path(path: Option<&str>) { + let encoded = path.map(|p| { + let mut utf16: Vec = p.encode_utf16().collect(); + utf16.push(0); + utf16 + }); + *tmf_search_path().lock() = encoded; + clear_layout_cache(); +} + +/// The directory currently searched for `.tmf` files, if any. +pub fn wpp_tmf_search_path() -> Option { + tmf_search_path().lock().as_ref().map(|utf16| { + let end = utf16.iter().position(|&u| u == 0).unwrap_or(utf16.len()); + String::from_utf16_lossy(&utf16[..end]) + }) +} + +/// Build the `TDH_CONTEXT` array for a decoding call. +/// +/// Empty unless a TMF search path has been set, so the common path passes TDH +/// no context at all, exactly as before. +fn tdh_contexts(path: &Option>) -> Vec { + match path { + Some(utf16) => vec![TDH_CONTEXT { + ParameterValue: utf16.as_ptr() as u64, + ParameterType: TDH_CONTEXT_WPP_TMFSEARCHPATH, + ParameterSize: 0, + }], + None => Vec::new(), + } +} + /// Cache of event layouts keyed by (provider GUID, event id, version). /// /// A `None` entry records that TDH has no schema for this event, so we do not @@ -213,10 +275,15 @@ fn wide_string_at(buf: &[u8], offset: u32) -> Option { /// /// `record` must point to a valid `EVENT_RECORD` that stays alive for the call. unsafe fn read_layout(record: *const EVENT_RECORD) -> Option { + // Held across both calls so the path it points at stays alive for TDH. + let path = tmf_search_path().lock(); + let contexts = tdh_contexts(&path); + let contexts = (!contexts.is_empty()).then_some(contexts.as_slice()); + let mut size: u32 = 0; - let status = unsafe { TdhGetEventInformation(record, None, None, &mut size) }; + let status = unsafe { TdhGetEventInformation(record, contexts, None, &mut size) }; if status != ERROR_INSUFFICIENT_BUFFER || size == 0 { - // Anything else means TDH has no manifest/MOF schema for this event. + // Anything else means TDH has no manifest/MOF/WPP schema for this event. return None; } @@ -226,7 +293,7 @@ unsafe fn read_layout(record: *const EVENT_RECORD) -> Option { let mut buf: Vec = vec![0u64; size.div_ceil(8) as usize]; let info_ptr = buf.as_mut_ptr() as *mut TRACE_EVENT_INFO; - let status = unsafe { TdhGetEventInformation(record, None, Some(info_ptr), &mut size) }; + let status = unsafe { TdhGetEventInformation(record, contexts, Some(info_ptr), &mut size) }; if status != ERROR_SUCCESS { return None; } @@ -256,12 +323,27 @@ unsafe fn read_layout(record: *const EVENT_RECORD) -> Option { let is_struct = prop.Flags.0 & PROPERTY_STRUCT != 0; // `nonStructType` and `structType` overlay each other; only one of them // is meaningful, and reading the wrong one would be nonsense. - let (in_type, out_type, struct_start, struct_members) = if is_struct { + let (in_type, out_type, struct_start, struct_members, map_name) = if is_struct { let s = unsafe { prop.Anonymous1.structType }; - (IN_NULL, 0, s.StructStartIndex, s.NumOfStructMembers) + (IN_NULL, 0, s.StructStartIndex, s.NumOfStructMembers, None) } else { let non_struct = unsafe { prop.Anonymous1.nonStructType }; - (non_struct.InType, non_struct.OutType, 0, 0) + // A map turns a bare number into the name the manifest gives it. + let map_name = wide_string_at(bytes, non_struct.MapNameOffset).map(|name| { + let mut utf16: Vec = name.encode_utf16().collect(); + utf16.push(0); + utf16 + }); + (non_struct.InType, non_struct.OutType, 0, 0, map_name) + }; + + // `length` and `lengthPropertyIndex` overlay each other. Only the plain + // length is of use here, and only as a hint to `TdhFormatProperty`; the + // value bytes themselves always come from TDH. + let length = if prop.Flags.0 & PROPERTY_PARAM_LENGTH != 0 { + 0 + } else { + unsafe { prop.Anonymous3.length } }; // `count` and `countPropertyIndex` overlay each other; the flag says @@ -284,6 +366,8 @@ unsafe fn read_layout(record: *const EVENT_RECORD) -> Option { count, struct_start, struct_members, + length, + map_name, }); } @@ -819,6 +903,240 @@ fn to_event_array( Some(EventValue::Array(items)) } +/// Whether events should also carry TDH's own display strings. +/// +/// Off by default: typed values are what the exporters want, and rendering every +/// property twice costs a `TdhGetEventInformation` plus a `TdhFormatProperty` +/// per property on top of the parse. +static FORMAT_PROPERTIES: AtomicBool = AtomicBool::new(false); + +/// Turn TDH's display strings on or off for events parsed from now on. +pub fn set_property_formatting(enabled: bool) { + FORMAT_PROPERTIES.store(enabled, Ordering::Relaxed); +} + +/// Whether TDH display strings are currently being produced. +pub fn property_formatting() -> bool { + FORMAT_PROPERTIES.load(Ordering::Relaxed) +} + +/// Look up the value map named by a property, if it has one. +/// +/// A map turns a bare number into the name the manifest gives it, which is the +/// part of `TdhFormatProperty`'s output that typed values cannot express. +/// +/// # Safety +/// +/// `record` must point to a valid `EVENT_RECORD` that stays alive for the call. +unsafe fn map_info(record: *const EVENT_RECORD, map_name: &[u16]) -> Option> { + let name = PCWSTR(map_name.as_ptr()); + let mut size: u32 = 0; + let status = unsafe { TdhGetEventMapInformation(record, name, None, &mut size) }; + if status != ERROR_INSUFFICIENT_BUFFER || size == 0 { + return None; + } + // Over-aligned for the same reason as TRACE_EVENT_INFO. + let mut buf: Vec = vec![0u64; size.div_ceil(8) as usize]; + let ptr = buf.as_mut_ptr() as *mut EVENT_MAP_INFO; + let status = unsafe { TdhGetEventMapInformation(record, name, Some(ptr), &mut size) }; + if status != ERROR_SUCCESS { + return None; + } + Some(buf) +} + +/// Render one already-isolated property value the way TDH would display it. +/// +/// The bytes come from `TdhGetProperty`, so this hands `TdhFormatProperty` a +/// single value rather than walking the event payload with running offsets. That +/// walk is the part the rest of this module avoids on purpose, and skipping it +/// costs nothing here because TDH has already found the value for us. +/// +/// # Safety +/// +/// `info` must point to the `TRACE_EVENT_INFO` describing this event. +unsafe fn format_one( + info: *const TRACE_EVENT_INFO, + map: Option<*const EVENT_MAP_INFO>, + pointer_size: usize, + prop: &PropertyDesc, + bytes: &[u8], +) -> Option { + if bytes.is_empty() { + return None; + } + let length = if prop.length != 0 { + prop.length + } else { + u16::try_from(bytes.len()).ok()? + }; + + let mut size: u32 = 0; + let mut consumed: u16 = 0; + let status = unsafe { + TdhFormatProperty( + info, + map, + pointer_size as u32, + prop.in_type, + prop.out_type, + length, + bytes, + &mut size, + None, + &mut consumed, + ) + }; + if status != ERROR_INSUFFICIENT_BUFFER || size == 0 { + return None; + } + + let mut out: Vec = vec![0u16; (size as usize).div_ceil(2)]; + let status = unsafe { + TdhFormatProperty( + info, + map, + pointer_size as u32, + prop.in_type, + prop.out_type, + length, + bytes, + &mut size, + Some(PWSTR(out.as_mut_ptr())), + &mut consumed, + ) + }; + if status != ERROR_SUCCESS { + return None; + } + + let end = out.iter().position(|&u| u == 0).unwrap_or(out.len()); + Some(String::from_utf16_lossy(&out[..end])) +} + +/// The event's raw user data, copied out of the `EVENT_RECORD`. +/// +/// ferrisetw keeps its own `user_buffer` crate-private, which is why +/// `EtwEvent.raw_data` went unpopulated; the record TDH is handed carries the +/// same bytes, so read them from there. +pub fn raw_user_data(record: &EventRecord) -> Option> { + let raw = as_raw_record(record); + // Safety: `record` is a live `EVENT_RECORD` for the duration of the call, + // and `UserDataLength` is the length ETW itself recorded for `UserData`. + unsafe { + let len = (*raw).UserDataLength as usize; + let ptr = (*raw).UserData as *const u8; + if len == 0 || ptr.is_null() { + return None; + } + Some(std::slice::from_raw_parts(ptr, len).to_vec()) + } +} + +/// Render each top-level property the way TDH itself would display it. +/// +/// This is the string view #72 asks about, offered alongside the typed values +/// rather than instead of them: typed values are what the Parquet/Arrow +/// exporters need, while these are what a human reading a trace wants, because +/// TDH resolves value maps here -- a bare `1` becomes the name the manifest +/// gives it. +/// +/// Empty unless [`set_property_formatting`] has been called, and empty for +/// events TDH has no schema for. Struct-typed properties are skipped: their +/// members are values in their own right, and a single string for the whole +/// struct would be less useful than the typed map already returned. +pub fn format_properties(record: &EventRecord) -> HashMap { + if !property_formatting() { + return HashMap::new(); + } + let raw = as_raw_record(record); + let Some(layout) = layout_for(record, raw) else { + return HashMap::new(); + }; + + // `TdhFormatProperty` wants the TRACE_EVENT_INFO itself, which the cache + // deliberately does not retain -- keeping every event's raw schema buffer + // alive would cost far more than re-reading it on the opt-in path. + let Some(mut info_buf) = (unsafe { read_event_information(raw) }) else { + return HashMap::new(); + }; + let info = info_buf.as_mut_ptr() as *const TRACE_EVENT_INFO; + + let pointer_size = pointer_size_of(record); + + let mut out = HashMap::with_capacity(layout.top_level_count); + for prop in layout.properties.iter().take(layout.top_level_count) { + if prop.is_placeholder() || prop.is_struct { + continue; + } + // `TdhFormatProperty` renders one value, so handing it a whole array + // would silently describe only the first element. Arrays and structs are + // both left to `properties`, which reports every element. + let count = element_count(raw, &layout, prop, &[]); + if count > 1 && !spans_whole_blob(prop.in_type) { + continue; + } + let Some(bytes) = (unsafe { property_bytes(raw, &prop.name_utf16, WHOLE_PROPERTY) }) else { + continue; + }; + let map_buf = prop + .map_name + .as_ref() + .and_then(|name| unsafe { map_info(raw, name) }); + let map = map_buf + .as_ref() + .map(|b| b.as_ptr() as *const EVENT_MAP_INFO); + + if let Some(text) = unsafe { format_one(info, map, pointer_size, prop, &bytes) } { + out.insert(prop.name.clone(), text); + } + } + out +} + +/// Pointer width of the process that emitted the event, not of this one. +fn pointer_size_of(record: &EventRecord) -> usize { + if record.event_flags() & EVENT_HEADER_FLAG_32_BIT_HEADER != 0 { + 4 + } else { + 8 + } +} + +/// Fetch the raw `TRACE_EVENT_INFO` for an event, over-aligned. +/// +/// # Safety +/// +/// `record` must point to a valid `EVENT_RECORD` that stays alive for the call. +unsafe fn read_event_information(record: *const EVENT_RECORD) -> Option> { + let path = tmf_search_path().lock(); + let contexts = tdh_contexts(&path); + let contexts = (!contexts.is_empty()).then_some(contexts.as_slice()); + + let mut size: u32 = 0; + let status = unsafe { TdhGetEventInformation(record, contexts, None, &mut size) }; + if status != ERROR_INSUFFICIENT_BUFFER || size == 0 { + return None; + } + let mut buf: Vec = vec![0u64; size.div_ceil(8) as usize]; + let ptr = buf.as_mut_ptr() as *mut TRACE_EVENT_INFO; + let status = unsafe { TdhGetEventInformation(record, contexts, Some(ptr), &mut size) }; + if status != ERROR_SUCCESS { + return None; + } + Some(buf) +} + +/// Whether TDH can describe this event at all. +/// +/// False for WPP without a matching TMF, and for manifest providers whose +/// manifest is not installed on this machine. Those events have no property +/// names to report, so their payload is only reachable as raw bytes. +pub fn has_schema(record: &EventRecord) -> bool { + let raw = as_raw_record(record); + layout_for(record, raw).is_some() +} + /// Parse every top-level property of an event. /// /// Returns an empty map when TDH has no schema for the event, which is normal @@ -851,6 +1169,37 @@ pub fn parse_properties(record: &EventRecord) -> HashMap { out } +/// Turn TDH's display strings on or off for events parsed from now on. +/// +/// Off by default. Typed values stay in `properties` either way; this only adds +/// `formatted_properties`, and costs an extra TDH round trip per property. +#[pyfunction] +pub fn py_set_property_formatting(enabled: bool) { + set_property_formatting(enabled); +} + +/// Whether TDH display strings are currently being produced. +#[pyfunction] +pub fn py_property_formatting() -> bool { + property_formatting() +} + +/// Point TDH at a directory of WPP `.tmf` files, or pass `None` to stop. +/// +/// WPP events carry no schema of their own, so without a matching `.tmf` they +/// arrive with no properties and only `raw_data`. Clears the schema cache. +#[pyfunction] +#[pyo3(signature = (path=None))] +pub fn py_set_wpp_tmf_search_path(path: Option<&str>) { + set_wpp_tmf_search_path(path); +} + +/// The directory currently searched for `.tmf` files, if any. +#[pyfunction] +pub fn py_wpp_tmf_search_path() -> Option { + wpp_tmf_search_path() +} + #[cfg(test)] mod tests { use super::*; @@ -884,6 +1233,35 @@ mod tests { assert_eq!(strip_counted_prefix(&bytes), bytes.as_slice()); } + #[test] + fn test_property_formatting_is_off_by_default_and_togglable() { + assert!(!property_formatting()); + set_property_formatting(true); + assert!(property_formatting()); + set_property_formatting(false); + assert!(!property_formatting()); + } + + #[test] + fn test_tmf_search_path_round_trips() { + assert_eq!(wpp_tmf_search_path(), None); + set_wpp_tmf_search_path(Some(r"C:\tmf")); + assert_eq!(wpp_tmf_search_path().as_deref(), Some(r"C:\tmf")); + set_wpp_tmf_search_path(None); + assert_eq!(wpp_tmf_search_path(), None); + } + + #[test] + fn test_no_tmf_path_means_no_tdh_context() { + // The common path must hand TDH nothing, exactly as before the WPP + // search path existed. + assert!(tdh_contexts(&None).is_empty()); + let path: Vec = "x\0".encode_utf16().collect(); + let contexts = tdh_contexts(&Some(path)); + assert_eq!(contexts.len(), 1); + assert_eq!(contexts[0].ParameterType, TDH_CONTEXT_WPP_TMFSEARCHPATH); + } + #[test] fn test_decode_utf16_trims_trailing_nul() { let bytes: Vec = "abc\0".encode_utf16().flat_map(u16::to_le_bytes).collect(); @@ -989,6 +1367,8 @@ mod tests { count: PropertyCount::Fixed(count), struct_start: 0, struct_members: 0, + length: 0, + map_name: None, } } diff --git a/tests/fixtures/no_schema.etl b/tests/fixtures/no_schema.etl new file mode 100644 index 0000000..e980af4 Binary files /dev/null and b/tests/fixtures/no_schema.etl differ diff --git a/tests/test_etl_reader.py b/tests/test_etl_reader.py index aaeec97..8531482 100644 --- a/tests/test_etl_reader.py +++ b/tests/test_etl_reader.py @@ -350,3 +350,121 @@ def test_nested_values_are_python_native_types(self) -> None: assert isinstance( inner, allowed ), f"{name}.{member} produced {type(inner)!r}" + + +NO_SCHEMA_ETL_PATH = Path(__file__).parent / "fixtures" / "no_schema.etl" + +requires_no_schema_etl = pytest.mark.skipif( + not NO_SCHEMA_ETL_PATH.exists(), reason="Requires schema-less ETL file" +) + + +class TestEventsWithoutASchema: + """Events TDH cannot describe must not lose their payload. + + WPP without a matching TMF, and manifest providers whose manifest is not + installed here, have no property names to report a value under. Before the + fallback the payload was simply dropped. ``no_schema.etl`` is a capture where + roughly half the events are of that kind. + """ + + @requires_no_schema_etl + def test_undecodable_events_keep_their_payload(self) -> None: + from pyetwkit import _core as pyetwkit_core + + events = pyetwkit_core.EtlReader(str(NO_SCHEMA_ETL_PATH)).read_all() + assert events, "fixture produced no events" + + undecoded = [e for e in events if not e.properties] + assert undecoded, "fixture no longer contains schema-less events" + + with_raw = [e for e in undecoded if e.raw_data is not None] + assert with_raw, "schema-less events came back with no payload at all" + assert all(isinstance(e.raw_data, bytes) for e in with_raw) + assert all(len(e.raw_data) > 0 for e in with_raw) + + @requires_no_schema_etl + def test_decoded_events_do_not_also_carry_raw_data(self) -> None: + """The fallback is a fallback, not a second copy of every event.""" + from pyetwkit import _core as pyetwkit_core + + events = pyetwkit_core.EtlReader(str(NO_SCHEMA_ETL_PATH)).read_all() + decoded = [e for e in events if e.properties] + assert decoded, "fixture no longer contains decodable events" + assert all(e.raw_data is None for e in decoded) + + +class TestPropertyFormatting: + """TDH's own display strings, offered alongside the typed values.""" + + def test_formatting_is_off_by_default(self) -> None: + from pyetwkit import _core as pyetwkit_core + + assert pyetwkit_core.property_formatting() is False + + @pytest.mark.skipif( + not NESTED_STRUCT_ETL_PATH.exists(), reason="Requires nested-struct ETL file" + ) + def test_formatted_properties_appear_only_when_enabled(self) -> None: + from pyetwkit import _core as pyetwkit_core + + path = str(NESTED_STRUCT_ETL_PATH) + + off = pyetwkit_core.EtlReader(path).read_all() + assert off, "fixture produced no events" + assert all(not e.formatted_properties for e in off) + + pyetwkit_core.set_property_formatting(True) + try: + on = pyetwkit_core.EtlReader(path).read_all() + assert any(e.formatted_properties for e in on) + for event in on: + for name, text in event.formatted_properties.items(): + assert isinstance(name, str) + assert isinstance(text, str) + # Typed values must be unaffected by the string view. + assert [e.properties for e in on] == [e.properties for e in off] + finally: + pyetwkit_core.set_property_formatting(False) + + assert pyetwkit_core.property_formatting() is False + + +class TestWppTmfSearchPath: + """WPP needs a .tmf to decode; this is how one is offered to TDH.""" + + def test_search_path_is_unset_by_default(self) -> None: + from pyetwkit import _core as pyetwkit_core + + assert pyetwkit_core.wpp_tmf_search_path() is None + + def test_search_path_round_trips(self) -> None: + from pyetwkit import _core as pyetwkit_core + + try: + pyetwkit_core.set_wpp_tmf_search_path(r"C:\tmf") + assert pyetwkit_core.wpp_tmf_search_path() == r"C:\tmf" + finally: + pyetwkit_core.set_wpp_tmf_search_path(None) + assert pyetwkit_core.wpp_tmf_search_path() is None + + @requires_no_schema_etl + def test_setting_a_search_path_does_not_disturb_other_decoding(self) -> None: + """A path that matches nothing must leave every other event alone.""" + from pyetwkit import _core as pyetwkit_core + + path = str(NO_SCHEMA_ETL_PATH) + + def snapshot() -> list[tuple[int, tuple[str, ...]]]: + return [ + (e.event_id, tuple(sorted(e.properties))) + for e in pyetwkit_core.EtlReader(path).read_all() + ] + + before = snapshot() + try: + pyetwkit_core.set_wpp_tmf_search_path(r"Z:\no\such\directory") + assert snapshot() == before + finally: + pyetwkit_core.set_wpp_tmf_search_path(None) + assert snapshot() == before