Skip to content

Commit e07461c

Browse files
committed
fix log assertion problems
1 parent 1ea757c commit e07461c

File tree

7 files changed

+118
-109
lines changed

7 files changed

+118
-109
lines changed

src/idom/core/hooks.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,7 @@ def create_context(
212212
class _Context(Context[_StateType]):
213213
_default_value = default_value
214214

215-
if name is not None:
216-
_Context.__name__ = name
215+
_Context.__name__ = name or "Context"
217216

218217
return _Context
219218

src/idom/testing.py

+81-81
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import re
77
import shutil
88
import time
9-
from contextlib import AsyncExitStack, contextmanager
9+
from contextlib import AsyncExitStack, ExitStack, contextmanager
1010
from functools import partial, wraps
1111
from inspect import isawaitable, iscoroutinefunction
1212
from traceback import format_exception
@@ -194,8 +194,9 @@ class ServerFixture:
194194
server.mount(MyComponent)
195195
"""
196196

197-
_log_handler: "_LogRecordCaptor"
197+
_records: list[logging.LogRecord]
198198
_server_future: asyncio.Task[Any]
199+
_exit_stack = ExitStack()
199200

200201
def __init__(
201202
self,
@@ -220,7 +221,7 @@ def __init__(
220221
@property
221222
def log_records(self) -> List[logging.LogRecord]:
222223
"""A list of captured log records"""
223-
return self._log_handler.records
224+
return self._records
224225

225226
def url(self, path: str = "", query: Optional[Any] = None) -> str:
226227
"""Return a URL string pointing to the host and point of the server
@@ -270,8 +271,9 @@ def list_logged_exceptions(
270271
return found
271272

272273
async def __aenter__(self: _Self) -> _Self:
273-
self._log_handler = _LogRecordCaptor()
274-
logging.getLogger().addHandler(self._log_handler)
274+
self._exit_stack = ExitStack()
275+
self._records = self._exit_stack.enter_context(capture_idom_logs())
276+
275277
app = self._app or self.server_implementation.create_development_app()
276278
self.server_implementation.configure(app, self._root_component)
277279

@@ -282,6 +284,8 @@ async def __aenter__(self: _Self) -> _Self:
282284
)
283285
)
284286

287+
self._exit_stack.callback(self._server_future.cancel)
288+
285289
try:
286290
await asyncio.wait_for(started.wait(), timeout=3)
287291
except Exception:
@@ -297,19 +301,18 @@ async def __aexit__(
297301
exc_value: Optional[BaseException],
298302
traceback: Optional[TracebackType],
299303
) -> None:
300-
self.mount(None) # reset the view
304+
self._exit_stack.close()
301305

302-
self._server_future.cancel()
306+
self.mount(None) # reset the view
303307

304308
try:
305309
await asyncio.wait_for(self._server_future, timeout=3)
306310
except asyncio.CancelledError:
307311
pass
308312

309-
logging.getLogger().removeHandler(self._log_handler)
310313
logged_errors = self.list_logged_exceptions(del_log_records=False)
311314
if logged_errors: # pragma: no cover
312-
raise logged_errors[0]
315+
raise LogAssertionError("Unexpected logged exception") from logged_errors[0]
313316

314317
return None
315318

@@ -323,75 +326,69 @@ def assert_idom_logged(
323326
match_message: str = "",
324327
error_type: type[Exception] | None = None,
325328
match_error: str = "",
326-
clear_matched_records: bool = False,
329+
clear_after: bool = True,
327330
) -> Iterator[None]:
328331
"""Assert that IDOM produced a log matching the described message or error.
329332
330333
Args:
331334
match_message: Must match a logged message.
332335
error_type: Checks the type of logged exceptions.
333336
match_error: Must match an error message.
334-
clear_matched_records: Whether to remove logged records that match.
337+
clear_after: Whether to remove logged records that match.
335338
"""
336339
message_pattern = re.compile(match_message)
337340
error_pattern = re.compile(match_error)
338341

339-
try:
340-
with capture_idom_logs(yield_existing=clear_matched_records) as log_records:
342+
with capture_idom_logs(clear_after=clear_after) as log_records:
343+
try:
341344
yield None
342-
except Exception:
343-
raise
344-
else:
345-
found = False
346-
for record in list(log_records):
347-
if (
348-
# record message matches
349-
message_pattern.findall(record.getMessage())
350-
# error type matches
351-
and (
352-
error_type is None
353-
or (
354-
record.exc_info is not None
355-
and record.exc_info[0] is not None
356-
and issubclass(record.exc_info[0], error_type)
345+
except Exception:
346+
raise
347+
else:
348+
for record in list(log_records):
349+
if (
350+
# record message matches
351+
message_pattern.findall(record.getMessage())
352+
# error type matches
353+
and (
354+
error_type is None
355+
or (
356+
record.exc_info is not None
357+
and record.exc_info[0] is not None
358+
and issubclass(record.exc_info[0], error_type)
359+
)
357360
)
358-
)
359-
# error message pattern matches
360-
and (
361-
not match_error
362-
or (
363-
record.exc_info is not None
364-
and error_pattern.findall(
365-
"".join(format_exception(*record.exc_info))
361+
# error message pattern matches
362+
and (
363+
not match_error
364+
or (
365+
record.exc_info is not None
366+
and error_pattern.findall(
367+
"".join(format_exception(*record.exc_info))
368+
)
366369
)
367370
)
371+
):
372+
break
373+
else: # pragma: no cover
374+
_raise_log_message_error(
375+
"Could not find a log record matching the given",
376+
match_message,
377+
error_type,
378+
match_error,
368379
)
369-
):
370-
found = True
371-
if clear_matched_records:
372-
log_records.remove(record)
373-
374-
if not found: # pragma: no cover
375-
_raise_log_message_error(
376-
"Could not find a log record matching the given",
377-
match_message,
378-
error_type,
379-
match_error,
380-
)
381380

382381

383382
@contextmanager
384383
def assert_idom_did_not_log(
385384
match_message: str = "",
386385
error_type: type[Exception] | None = None,
387386
match_error: str = "",
388-
clear_matched_records: bool = False,
387+
clear_after: bool = True,
389388
) -> Iterator[None]:
390389
"""Assert the inverse of :func:`assert_idom_logged`"""
391390
try:
392-
with assert_idom_logged(
393-
match_message, error_type, match_error, clear_matched_records
394-
):
391+
with assert_idom_logged(match_message, error_type, match_error, clear_after):
395392
yield None
396393
except LogAssertionError:
397394
pass
@@ -421,45 +418,35 @@ def _raise_log_message_error(
421418

422419

423420
@contextmanager
424-
def capture_idom_logs(
425-
yield_existing: bool = False,
426-
) -> Iterator[list[logging.LogRecord]]:
421+
def capture_idom_logs(clear_after: bool = True) -> Iterator[list[logging.LogRecord]]:
427422
"""Capture logs from IDOM
428423
429-
Parameters:
430-
yield_existing:
431-
If already inside an existing capture context yield the same list of logs.
432-
This is useful if you need to mutate the list of logs to affect behavior in
433-
the outer context.
424+
Args:
425+
clear_after:
426+
Clear any records which were produced in this context when exiting.
434427
"""
435-
if yield_existing:
436-
for handler in reversed(ROOT_LOGGER.handlers):
437-
if isinstance(handler, _LogRecordCaptor):
438-
yield handler.records
439-
return None
440-
441-
handler = _LogRecordCaptor()
442428
original_level = ROOT_LOGGER.level
443-
444429
ROOT_LOGGER.setLevel(logging.DEBUG)
445-
ROOT_LOGGER.addHandler(handler)
446430
try:
447-
yield handler.records
431+
if _LOG_RECORD_CAPTOR in ROOT_LOGGER.handlers:
432+
start_index = len(_LOG_RECORD_CAPTOR.records)
433+
try:
434+
yield _LOG_RECORD_CAPTOR.records
435+
finally:
436+
end_index = len(_LOG_RECORD_CAPTOR.records)
437+
_LOG_RECORD_CAPTOR.records[start_index:end_index] = []
438+
return None
439+
440+
ROOT_LOGGER.addHandler(_LOG_RECORD_CAPTOR)
441+
try:
442+
yield _LOG_RECORD_CAPTOR.records
443+
finally:
444+
ROOT_LOGGER.removeHandler(_LOG_RECORD_CAPTOR)
445+
_LOG_RECORD_CAPTOR.records.clear()
448446
finally:
449-
ROOT_LOGGER.removeHandler(handler)
450447
ROOT_LOGGER.setLevel(original_level)
451448

452449

453-
class _LogRecordCaptor(logging.NullHandler):
454-
def __init__(self) -> None:
455-
self.records: List[logging.LogRecord] = []
456-
super().__init__()
457-
458-
def handle(self, record: logging.LogRecord) -> bool:
459-
self.records.append(record)
460-
return True
461-
462-
463450
class HookCatcher:
464451
"""Utility for capturing a LifeCycleHook from a component
465452
@@ -575,3 +562,16 @@ def use(
575562
def clear_idom_web_modules_dir() -> None:
576563
for path in IDOM_WEB_MODULES_DIR.current.iterdir():
577564
shutil.rmtree(path) if path.is_dir() else path.unlink()
565+
566+
567+
class _LogRecordCaptor(logging.NullHandler):
568+
def __init__(self) -> None:
569+
self.records: List[logging.LogRecord] = []
570+
super().__init__()
571+
572+
def handle(self, record: logging.LogRecord) -> bool:
573+
self.records.append(record)
574+
return True
575+
576+
577+
_LOG_RECORD_CAPTOR = _LogRecordCaptor()

tests/conftest.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
from _pytest.config.argparsing import Parser
66
from playwright.async_api import async_playwright
77

8-
from idom.testing import DisplayFixture, ServerFixture, clear_idom_web_modules_dir
8+
from idom.testing import (
9+
DisplayFixture,
10+
ServerFixture,
11+
capture_idom_logs,
12+
clear_idom_web_modules_dir,
13+
)
914
from tests.tooling.loop import open_event_loop
1015

1116

@@ -55,3 +60,15 @@ def event_loop():
5560
@pytest.fixture(autouse=True)
5661
def clear_web_modules_dir_after_test():
5762
clear_idom_web_modules_dir()
63+
64+
65+
@pytest.fixture(autouse=True)
66+
def assert_no_logged_exceptions():
67+
with capture_idom_logs() as records:
68+
yield
69+
try:
70+
for r in records:
71+
if r.exc_info is not None:
72+
raise r.exc_info[1]
73+
finally:
74+
records.clear()

tests/test_core/test_hooks.py

+5-7
Original file line numberDiff line numberDiff line change
@@ -832,16 +832,14 @@ def ComponentWithRef():
832832
assert len(used_refs) == 2
833833

834834

835-
def test_bad_schedule_render_callback(caplog):
835+
def test_bad_schedule_render_callback():
836836
def bad_callback():
837837
raise ValueError("something went wrong")
838838

839-
hook = LifeCycleHook(bad_callback)
840-
841-
hook.schedule_render()
842-
843-
first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0]
844-
assert re.match(f"Failed to schedule render via {bad_callback}", first_log_line)
839+
with assert_idom_logged(
840+
match_message=f"Failed to schedule render via {bad_callback}"
841+
):
842+
LifeCycleHook(bad_callback).schedule_render()
845843

846844

847845
async def test_use_effect_automatically_infers_closure_values():

tests/test_core/test_layout.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def BadChild():
183183

184184
with assert_idom_logged(
185185
match_error="error from bad child",
186-
clear_matched_records=True,
186+
clear_after=True,
187187
):
188188

189189
with idom.Layout(Main()) as layout:
@@ -242,7 +242,7 @@ def BadChild():
242242

243243
with assert_idom_logged(
244244
match_error="error from bad child",
245-
clear_matched_records=True,
245+
clear_after=True,
246246
):
247247

248248
with idom.Layout(Main()) as layout:
@@ -743,7 +743,7 @@ def ComponentReturnsDuplicateKeys():
743743
with assert_idom_logged(
744744
error_type=ValueError,
745745
match_error=r"Duplicate keys \['duplicate'\] at '/children/0'",
746-
clear_matched_records=True,
746+
clear_after=True,
747747
):
748748
await layout.render()
749749

@@ -757,7 +757,7 @@ def ComponentReturnsDuplicateKeys():
757757
with assert_idom_logged(
758758
error_type=ValueError,
759759
match_error=r"Duplicate keys \['duplicate'\] at '/children/0'",
760-
clear_matched_records=True,
760+
clear_after=True,
761761
):
762762
await layout.render()
763763

@@ -798,7 +798,7 @@ def raise_error():
798798

799799
with assert_idom_logged(
800800
match_error="bad event handler",
801-
clear_matched_records=True,
801+
clear_after=True,
802802
):
803803

804804
with idom.Layout(ComponentWithBadEventHandler()) as layout:
@@ -807,7 +807,7 @@ def raise_error():
807807
await layout.deliver(event)
808808

809809

810-
async def test_schedule_render_from_unmounted_hook(caplog):
810+
async def test_schedule_render_from_unmounted_hook():
811811
parent_set_state = idom.Ref()
812812

813813
@idom.component
@@ -1233,7 +1233,7 @@ def bad_should_render(new):
12331233
match_message=r".* component failed to check if .* should be rendered",
12341234
error_type=ValueError,
12351235
match_error="The error message",
1236-
clear_matched_records=True,
1236+
clear_after=True,
12371237
):
12381238
with idom.Layout(Root()) as layout:
12391239
await layout.render()

0 commit comments

Comments
 (0)