Skip to content

Commit b2fee43

Browse files
committed
test for logged errors in layout
as a result of this additional check, we found what seemed to be an unnecessary assertion about old component state havint the same key and index. we change the assertion to only check for the same key since only the key is meaningful for identity
1 parent 4e2cab6 commit b2fee43

File tree

5 files changed

+231
-93
lines changed

5 files changed

+231
-93
lines changed

src/idom/core/_fixed_jsonpatch.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,28 @@
99
from jsonpatch import _ST_REMOVE
1010
from jsonpatch import DiffBuilder as _DiffBuilder
1111
from jsonpatch import JsonPatch as _JsonPatch
12-
from jsonpatch import JsonPointer, RemoveOperation, _path_join
12+
from jsonpatch import RemoveOperation, _path_join
1313

1414

15-
def apply_patch(doc, patch, in_place=False, pointer_cls=JsonPointer):
15+
def apply_patch(doc, patch, in_place=False):
1616
if isinstance(patch, (str, bytes)):
17-
patch = JsonPatch.from_string(patch, pointer_cls=pointer_cls)
17+
patch = JsonPatch.from_string(patch)
1818
else:
19-
patch = JsonPatch(patch, pointer_cls=pointer_cls)
19+
patch = JsonPatch(patch)
2020
return patch.apply(doc, in_place)
2121

2222

23-
def make_patch(src, dst, pointer_cls=JsonPointer):
24-
return JsonPatch.from_diff(src, dst, pointer_cls=pointer_cls)
23+
def make_patch(src, dst):
24+
return JsonPatch.from_diff(src, dst)
2525

2626

2727
class JsonPatch(_JsonPatch):
2828
@classmethod
29-
def from_diff(
30-
cls,
31-
src,
32-
dst,
33-
optimization=True,
34-
dumps=None,
35-
pointer_cls=JsonPointer,
36-
):
37-
json_dumper = dumps or cls.json_dumper
38-
builder = DiffBuilder(src, dst, json_dumper, pointer_cls=pointer_cls)
29+
def from_diff(cls, src, dst, optimization=True):
30+
builder = DiffBuilder()
3931
builder._compare_values("", None, src, dst)
4032
ops = list(builder.execute())
41-
return cls(ops, pointer_cls=pointer_cls)
33+
return cls(ops)
4234

4335

4436
class DiffBuilder(_DiffBuilder):
@@ -47,8 +39,7 @@ def _item_removed(self, path, key, item):
4739
{
4840
"op": "remove",
4941
"path": _path_join(path, key),
50-
},
51-
pointer_cls=self.pointer_cls,
42+
}
5243
)
5344
new_index = self.insert(new_op)
5445
self.store_index(item, new_index, _ST_REMOVE)

src/idom/core/layout.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,9 @@ def _render_component(
217217
else:
218218
key, index = new_state.key, new_state.index
219219
if old_state is not None:
220-
assert (key, index) == (old_state.key, old_state.index,), (
220+
assert key == old_state.key, (
221221
"state mismatch during component update - "
222-
f"key {key!r}!={old_state.key} "
223-
f"or index {index}!={old_state.index}"
222+
f"key {key!r}!={old_state.key!r} "
224223
)
225224
parent.children_by_key[key] = new_state
226225
# need to do insertion in case where old_state is None and we're appending

src/idom/log.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
import logging
22
import sys
33
from logging.config import dictConfig
4-
from typing import Any
54

65
from .config import IDOM_DEBUG_MODE
76

87

9-
root_logger = logging.getLogger("idom")
10-
11-
12-
def logging_config_defaults() -> Any:
13-
"""Get default logging configuration"""
14-
return {
8+
dictConfig(
9+
{
1510
"version": 1,
1611
"disable_existing_loggers": False,
1712
"loggers": {
@@ -35,10 +30,12 @@ def logging_config_defaults() -> Any:
3530
}
3631
},
3732
}
33+
)
3834

3935

40-
dictConfig(logging_config_defaults())
36+
ROOT_LOGGER = logging.getLogger("idom")
37+
"""IDOM's root logger instance"""
4138

4239

4340
if IDOM_DEBUG_MODE.current:
44-
root_logger.debug("IDOM is in debug mode")
41+
ROOT_LOGGER.debug("IDOM is in debug mode")

src/idom/testing.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
from __future__ import annotations
2+
13
import logging
24
import re
35
import shutil
6+
from contextlib import contextmanager
47
from functools import wraps
8+
from traceback import format_exception
59
from types import TracebackType
610
from typing import (
711
Any,
812
Callable,
913
Dict,
1014
Generic,
15+
Iterator,
1116
List,
1217
Optional,
1318
Tuple,
@@ -29,6 +34,8 @@
2934
from idom.server.proto import ServerFactory, ServerType
3035
from idom.server.utils import find_available_port
3136

37+
from .log import ROOT_LOGGER
38+
3239

3340
__all__ = [
3441
"find_available_port",
@@ -166,6 +173,86 @@ def __exit__(
166173
return None
167174

168175

176+
@contextmanager
177+
def assert_idom_logged(
178+
match_message: str = "",
179+
error_type: type[Exception] | None = None,
180+
match_error: str = "",
181+
clear_matched_records: bool = False,
182+
) -> Iterator[None]:
183+
"""Assert that IDOM produced a log matching the described message or error.
184+
185+
Args:
186+
match_message: Must match a logged message.
187+
error_type: Checks the type of logged exceptions.
188+
match_error: Must match an error message.
189+
clear_matched_records: Whether to remove logged records that match.
190+
"""
191+
message_pattern = re.compile(match_message)
192+
error_pattern = re.compile(match_error)
193+
194+
try:
195+
with capture_idom_logs() as handler:
196+
yield None
197+
finally:
198+
found = False
199+
for record in list(handler.records):
200+
if (
201+
# record message matches
202+
message_pattern.findall(record.getMessage())
203+
# error type matches
204+
and (
205+
not error_type
206+
or (
207+
record.exc_info is not None
208+
and issubclass(record.exc_info[0], error_type)
209+
)
210+
)
211+
# error message pattern matches
212+
and (
213+
not match_error
214+
or (
215+
record.exc_info is not None
216+
and error_pattern.findall(
217+
"".join(format_exception(*record.exc_info))
218+
)
219+
)
220+
)
221+
):
222+
found = True
223+
if clear_matched_records:
224+
handler.records.remove(record)
225+
226+
if not found:
227+
conditions = []
228+
if match_message:
229+
conditions.append(f"log message pattern {match_message!r}")
230+
if error_type:
231+
conditions.append(f"exception type {error_type}")
232+
if match_error:
233+
conditions.append(f"error message pattern {match_error!r}")
234+
raise AssertionError(
235+
"Could not find a log record matching the given "
236+
+ " and ".join(conditions)
237+
)
238+
239+
240+
@contextmanager
241+
def capture_idom_logs() -> Iterator[_LogRecordCaptor]:
242+
"""Capture logs from IDOM"""
243+
if _LOG_RECORD_CAPTOR_SINGLTON in ROOT_LOGGER.handlers:
244+
# this is being handled by an outer capture context
245+
yield _LOG_RECORD_CAPTOR_SINGLTON
246+
return None
247+
248+
ROOT_LOGGER.addHandler(_LOG_RECORD_CAPTOR_SINGLTON)
249+
try:
250+
yield _LOG_RECORD_CAPTOR_SINGLTON
251+
finally:
252+
ROOT_LOGGER.removeHandler(_LOG_RECORD_CAPTOR_SINGLTON)
253+
_LOG_RECORD_CAPTOR_SINGLTON.records = []
254+
255+
169256
class _LogRecordCaptor(logging.NullHandler):
170257
def __init__(self) -> None:
171258
self.records: List[logging.LogRecord] = []
@@ -176,6 +263,9 @@ def handle(self, record: logging.LogRecord) -> bool:
176263
return True
177264

178265

266+
_LOG_RECORD_CAPTOR_SINGLTON = _LogRecordCaptor()
267+
268+
179269
class HookCatcher:
180270
"""Utility for capturing a LifeCycleHook from a component
181271

0 commit comments

Comments
 (0)