Skip to content

Commit 928faa9

Browse files
Unify example stdout checking with CheckOutput
Move the hand-rolled StdOutCapture filtering in add_example_test into CheckOutput, which now accepts optional expected_patterns for its allow-list. This gives example tests and unit tests a single mechanism for catching unexpected stdout. Add an allowed_output parameter to add_example_test for patterns that should not trigger failures but are not required to appear (e.g. solver stats printed only when matplotlib is missing).
1 parent 7ea6360 commit 928faa9

File tree

2 files changed

+62
-60
lines changed

2 files changed

+62
-60
lines changed

newton/tests/test_examples.py

Lines changed: 25 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,10 @@
2525
import newton.viewer
2626
from newton.tests.unittest_utils import (
2727
USD_AVAILABLE,
28-
StdOutCapture,
28+
CheckOutput,
2929
add_function_test,
3030
get_selected_cuda_test_devices,
3131
get_test_devices,
32-
is_noise_line,
3332
)
3433

3534

@@ -69,6 +68,7 @@ def add_example_test(
6968
test_suffix: str | None = None,
7069
expected_output: list[str] | None = None,
7170
expected_output_cpu: list[str] | None = None,
71+
allowed_output: list[str] | None = None,
7272
):
7373
"""Registers a Newton example to run on ``devices`` as a TestCase.
7474
@@ -79,6 +79,9 @@ def add_example_test(
7979
matched by at least one pattern fails the test.
8080
expected_output_cpu: Like *expected_output* but only asserted on
8181
CPU devices.
82+
allowed_output: Regex patterns for stdout lines that are allowed
83+
but not required. These prevent unexpected-output failures
84+
without requiring a match.
8285
"""
8386

8487
# verify the module exists (use package-relative path so this works from any CWD)
@@ -152,40 +155,33 @@ def run(test, device):
152155
else:
153156
i += 1
154157

155-
# Create viewer and run example in-process, capturing warnings + stdout
156-
viewer = newton.viewer.ViewerNull(num_frames=args.num_frames)
157-
factory = getattr(mod, "create_example", None) or mod.Example
158-
capture = StdOutCapture()
159-
capture.begin()
160-
try:
161-
with wp.ScopedDevice(device), warnings.catch_warnings(record=True) as caught:
162-
warnings.simplefilter("always")
163-
example = factory(viewer, args)
164-
newton.examples.run(example, args)
165-
wp.synchronize()
166-
167-
stdout = capture.end()
168-
except BaseException:
169-
error_stdout = capture.end()
170-
if error_stdout.strip():
171-
print(f"Captured stdout before crash:\n{error_stdout.rstrip()}")
172-
raise
173-
174-
# Build expected pattern list
158+
# Build expected pattern list (used for both warning and stdout checks)
175159
is_cpu = wp.get_device(device).is_cpu
176160
expected_patterns = list(expected_output or [])
177161
if is_cpu:
178162
expected_patterns.extend(expected_output_cpu or [])
179163

164+
# CheckOutput uses both expected and allowed patterns for filtering;
165+
# only expected_patterns are asserted to appear at least once.
166+
all_patterns = expected_patterns + list(allowed_output or [])
180167
compiled_patterns = [re.compile(p) for p in expected_patterns] if expected_patterns else []
181168

182-
# Combine warnings and stdout for expected-pattern matching
169+
# Run example in-process.
170+
# CheckOutput captures stdout and fails on unexpected lines.
171+
# warnings.catch_warnings captures Python warnings for separate validation.
172+
viewer = newton.viewer.ViewerNull(num_frames=args.num_frames)
173+
factory = getattr(mod, "create_example", None) or mod.Example
174+
with CheckOutput(test, expected_patterns=all_patterns) as check:
175+
with wp.ScopedDevice(device), warnings.catch_warnings(record=True) as caught:
176+
warnings.simplefilter("always")
177+
example = factory(viewer, args)
178+
newton.examples.run(example, args)
179+
180+
# Assert each expected pattern appears in warnings or stdout
183181
warning_messages = [str(w.message) for w in caught]
184182
all_text = "\n".join(warning_messages)
185-
if stdout.strip():
186-
all_text += "\n" + stdout
187-
188-
# Assert each expected pattern appears at least once
183+
if check.output.strip():
184+
all_text += "\n" + check.output
189185
for pattern in compiled_patterns:
190186
test.assertRegex(all_text, pattern, f"Expected output pattern not found: {pattern.pattern}")
191187

@@ -198,16 +194,6 @@ def run(test, device):
198194
if not any(p.search(msg) for p in compiled_patterns):
199195
test.fail(f"Unexpected warning: {msg}")
200196

201-
# Fail on unexpected stdout (same strictness as warnings above)
202-
if stdout.strip():
203-
filtered = [
204-
line
205-
for line in stdout.splitlines()
206-
if not is_noise_line(line) and not any(p.search(line) for p in compiled_patterns)
207-
]
208-
if filtered:
209-
test.fail("Unexpected stdout:\n" + "\n".join(filtered))
210-
211197
test_name = f"test_{name}_{test_suffix}" if test_suffix else f"test_{name}"
212198
add_function_test(cls, test_name, run, devices=devices, check_output=False)
213199

@@ -713,10 +699,8 @@ class TestMPMExamples(unittest.TestCase):
713699
name="basic.example_basic_plotting",
714700
devices=test_devices,
715701
test_options={"num-frames": 200},
716-
expected_output=[
717-
"Diagnostics plot saved to|Simulation diagnostics summary",
718-
r"^\s+(Iterations|Kinetic E|Potential E|Constraints):",
719-
],
702+
expected_output=["Diagnostics plot saved to|Simulation diagnostics summary"],
703+
allowed_output=[r"^\s+(Iterations|Kinetic E|Potential E|Constraints):"],
720704
)
721705

722706

newton/tests/unittest_utils.py

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -207,30 +207,50 @@ def is_noise_line(line: str) -> bool:
207207

208208

209209
class CheckOutput:
210-
def __init__(self, test):
210+
"""Context manager that captures stdout and fails on unexpected output.
211+
212+
Args:
213+
test: The ``unittest.TestCase`` instance used for assertions.
214+
expected_patterns: Optional regex strings. Lines matching any pattern
215+
are considered expected and will not trigger an unexpected-output
216+
failure. The captured text is available as ``self.output`` after
217+
the context exits.
218+
"""
219+
220+
def __init__(self, test, expected_patterns: list[str] | None = None):
211221
self.test = test
222+
self.output = ""
223+
self._compiled = [re.compile(p) for p in expected_patterns] if expected_patterns else []
212224

213225
def __enter__(self):
214-
# wp.force_load()
215-
216226
self.capture = StdOutCapture()
217227
self.capture.begin()
228+
return self
218229

219230
def __exit__(self, exc_type, exc_value, traceback):
220231
# ensure any stdout output is flushed
221232
wp.synchronize()
222233

223-
s = self.capture.end()
224-
if s != "":
225-
print(s.rstrip())
234+
self.output = self.capture.end()
235+
if exc_type is not None:
236+
# An exception is propagating — print captured output for
237+
# debugging but skip the unexpected-output assertion.
238+
if self.output.strip():
239+
print(f"Captured stdout before crash:\n{self.output.rstrip()}")
240+
return
241+
242+
if self.output != "":
243+
print(self.output.rstrip())
226244

227-
# fail if test produces unexpected output (e.g.: from wp.expect_eq() builtins)
228-
# we allow strings starting of the form "Module xxx load on device xxx"
229-
# for lazy loaded modules
230-
filtered_s = "\n".join(line for line in s.splitlines() if not is_noise_line(line))
245+
# Fail on unexpected output — filter noise and expected patterns
246+
filtered_s = "\n".join(
247+
line
248+
for line in self.output.splitlines()
249+
if not is_noise_line(line) and not any(p.search(line) for p in self._compiled)
250+
)
231251

232252
if filtered_s.strip():
233-
self.test.fail(f"Unexpected output:\n'{s.rstrip()}'")
253+
self.test.fail(f"Unexpected output:\n'{self.output.rstrip()}'")
234254

235255

236256
def assert_array_equal(result: wp.array, expect: wp.array):
@@ -285,12 +305,10 @@ def find_nonfinite_members(obj: Any | None) -> list[str]:
285305
return nonfinite_members
286306

287307

288-
# if check_output is True any output to stdout will be treated as an error
289-
def create_test_func(func, device, check_output, **kwargs):
290-
# pass args to func
308+
def create_test_func(func, device, check_output, expected_patterns=None, **kwargs):
291309
def test_func(self):
292310
if check_output:
293-
with CheckOutput(self):
311+
with CheckOutput(self, expected_patterns=expected_patterns):
294312
func(self, device, **kwargs)
295313
else:
296314
func(self, device, **kwargs)
@@ -317,9 +335,9 @@ def sanitize_identifier(s):
317335
return re.sub(r"\W|^(?=\d)", "_", s)
318336

319337

320-
def add_function_test(cls, name, func, devices=None, check_output=True, **kwargs):
338+
def add_function_test(cls, name, func, devices=None, check_output=True, expected_patterns=None, **kwargs):
321339
if devices is None:
322-
setattr(cls, name, create_test_func(func, None, check_output, **kwargs))
340+
setattr(cls, name, create_test_func(func, None, check_output, expected_patterns=expected_patterns, **kwargs))
323341
elif isinstance(devices, list):
324342
if not devices:
325343
# No devices to run this test
@@ -329,13 +347,13 @@ def add_function_test(cls, name, func, devices=None, check_output=True, **kwargs
329347
setattr(
330348
cls,
331349
name + "_" + sanitize_identifier(device),
332-
create_test_func(func, device, check_output, **kwargs),
350+
create_test_func(func, device, check_output, expected_patterns=expected_patterns, **kwargs),
333351
)
334352
else:
335353
setattr(
336354
cls,
337355
name + "_" + sanitize_identifier(devices),
338-
create_test_func(func, devices, check_output, **kwargs),
356+
create_test_func(func, devices, check_output, expected_patterns=expected_patterns, **kwargs),
339357
)
340358

341359

0 commit comments

Comments
 (0)