Skip to content

Commit 503beb7

Browse files
committed
better server error capture in test
1 parent 296e4d6 commit 503beb7

File tree

6 files changed

+116
-31
lines changed

6 files changed

+116
-31
lines changed

idom/testing.py

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import re
2+
import logging
13
from urllib.parse import urlunparse, urlencode
24
from typing import (
35
Callable,
@@ -8,9 +10,12 @@
810
Dict,
911
Generic,
1012
TypeVar,
13+
List,
14+
Union,
1115
)
12-
from weakref import finalize
16+
from types import TracebackType
1317

18+
from loguru import logger
1419
from selenium.webdriver.remote.webdriver import WebDriver
1520
from selenium.webdriver import Chrome
1621

@@ -46,13 +51,19 @@ def create_simple_selenium_web_driver(
4651
return driver
4752

4853

54+
_Self = TypeVar("_Self", bound="ServerMountPoint[Any, Any]")
4955
_Mount = TypeVar("_Mount")
5056
_Server = TypeVar("_Server", bound=AnyRenderServer)
5157

5258

5359
class ServerMountPoint(Generic[_Mount, _Server]):
60+
"""A context manager for imperatively mounting views to a render server when testing"""
5461

55-
__slots__ = "server", "host", "port", "mount", "__weakref__"
62+
mount: _Mount
63+
server: _Server
64+
65+
_log_handler: "_LogRecordCaptor"
66+
_loguru_handler_id: int
5667

5768
def __init__(
5869
self,
@@ -64,20 +75,60 @@ def __init__(
6475
mount_and_server_constructor: "Callable[..., Tuple[_Mount, _Server]]" = hotswap_server, # type: ignore
6576
app: Optional[Any] = None,
6677
**other_options: Any,
67-
):
78+
) -> None:
6879
self.host = host
6980
self.port = port or find_available_port(host)
70-
self.mount, self.server = mount_and_server_constructor(
71-
server_type,
72-
self.host,
73-
self.port,
74-
server_config,
75-
run_kwargs,
76-
app,
77-
**other_options,
81+
self._mount_and_server_constructor: "Callable[[], Tuple[_Mount, _Server]]" = (
82+
lambda: mount_and_server_constructor(
83+
server_type,
84+
self.host,
85+
self.port,
86+
server_config,
87+
run_kwargs,
88+
app,
89+
**other_options,
90+
)
7891
)
79-
# stop server once mount is done being used
80-
finalize(self, self.server.stop)
92+
93+
@property
94+
def log_records(self) -> List[logging.LogRecord]:
95+
"""A list of captured log records"""
96+
return self._log_handler.records
97+
98+
def assert_logged_exception(
99+
self,
100+
error_type: Type[Exception],
101+
error_pattern: str,
102+
clear_after: bool = True,
103+
) -> None:
104+
"""Assert that a given error type and message were logged"""
105+
try:
106+
re_pattern = re.compile(error_pattern)
107+
for record in self.log_records:
108+
if record.exc_info is not None:
109+
error = record.exc_info[1]
110+
if isinstance(error, error_type) and re_pattern.search(str(error)):
111+
break
112+
else: # pragma: no cover
113+
assert False, f"did not raise {error_type} matching {error_pattern!r}"
114+
finally:
115+
if clear_after:
116+
self.log_records.clear()
117+
118+
def raise_first_logged_exception(
119+
self,
120+
exclude_exc_types: Union[Type[Exception], Tuple[Type[Exception], ...]] = (),
121+
) -> None:
122+
"""Raise the first logged exception (if any)
123+
124+
Args:
125+
exclude_exc_types: Any exception types to ignore
126+
"""
127+
for record in self._log_handler.records:
128+
if record.exc_info is not None:
129+
error = record.exc_info[1]
130+
if error is not None and not isinstance(error, exclude_exc_types):
131+
raise error
81132

82133
def url(self, path: str = "", query: Optional[Any] = None) -> str:
83134
return urlunparse(
@@ -90,3 +141,34 @@ def url(self, path: str = "", query: Optional[Any] = None) -> str:
90141
"",
91142
]
92143
)
144+
145+
def __enter__(self: _Self) -> _Self:
146+
self._log_handler = _LogRecordCaptor()
147+
logging.getLogger().addHandler(self._log_handler)
148+
self._loguru_handler_id = logger.add(self._log_handler, format="{message}")
149+
self.mount, self.server = self._mount_and_server_constructor()
150+
return self
151+
152+
def __exit__(
153+
self,
154+
exc_type: Optional[Type[BaseException]],
155+
exc_value: Optional[BaseException],
156+
traceback: Optional[TracebackType],
157+
) -> None:
158+
self.server.stop()
159+
160+
logging.getLogger().removeHandler(self._log_handler)
161+
logger.remove(self._loguru_handler_id)
162+
163+
self.raise_first_logged_exception()
164+
165+
return None
166+
167+
168+
class _LogRecordCaptor(logging.NullHandler):
169+
def __init__(self) -> None:
170+
self.records: List[logging.LogRecord] = []
171+
super().__init__()
172+
173+
def handle(self, record: logging.LogRecord) -> None:
174+
self.records.append(record)

noxfile.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111
@nox.session
12-
def test_suite(session: Session) -> None:
12+
def test_python(session: Session) -> None:
1313
session.env.update(os.environ)
1414
session.install("-r", "requirements/test-env.txt")
1515
session.install(".[all]")
@@ -22,7 +22,8 @@ def test_suite(session: Session) -> None:
2222
@nox.session
2323
def check_types(session: Session) -> None:
2424
session.install("-r", "requirements/check-types.txt")
25-
session.install(".[all]")
25+
session.install("-r", "requirements/pkg-deps.txt")
26+
session.install("-r", "requirements/pkg-extras.txt")
2627
session.run("mypy", "--strict", "idom")
2728

2829

tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ def server_mount_point():
6767
6868
The ``mount`` and ``server`` fixtures use this.
6969
"""
70-
return ServerMountPoint(server_config={"cors": True})
70+
with ServerMountPoint(server_config={"cors": True}) as mount_point:
71+
yield mount_point
7172

7273

7374
@pytest.fixture(scope="module")

tests/test_server/test_common/test_per_client_state.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
ids=lambda cls: f"{cls.__module__}.{cls.__name__}",
2121
)
2222
def server_mount_point(request):
23-
return ServerMountPoint(request.param)
23+
with ServerMountPoint(request.param) as mount_point:
24+
yield mount_point
2425

2526

2627
def test_display_simple_hello_world(driver, display):

tests/test_server/test_common/test_shared_state_client.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
ids=lambda cls: f"{cls.__module__}.{cls.__name__}",
1919
)
2020
def server_mount_point(request):
21-
return ServerMountPoint(request.param, sync_views=True)
21+
with ServerMountPoint(request.param, sync_views=True) as mount_point:
22+
yield mount_point
2223

2324

2425
def test_shared_client_state(create_driver, server_mount_point):
@@ -71,17 +72,13 @@ def Counter(count):
7172

7273

7374
def test_shared_client_state_server_does_not_support_per_client_parameters(
74-
driver_get, caplog
75+
driver_get,
76+
server_mount_point,
7577
):
7678
driver_get({"per_client_param": 1})
7779

78-
for record in caplog.records:
79-
if record.exc_info and isinstance(record.exc_info[1], ValueError):
80-
assert "does not support per-client view parameters" in str(
81-
record.exc_info[1]
82-
)
83-
break
84-
else:
85-
assert False, "did not log error"
86-
87-
caplog.clear()
80+
server_mount_point.assert_logged_exception(
81+
ValueError,
82+
"does not support per-client view parameters",
83+
clear_after=True,
84+
)

tests/test_server/test_prefab.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010

1111
@pytest.fixture
1212
def server_mount_point():
13-
return ServerMountPoint(
13+
with ServerMountPoint(
1414
find_builtin_server_type("PerClientStateServer"),
1515
mount_and_server_constructor=multiview_server,
16-
)
16+
) as mount_point:
17+
yield mount_point
1718

1819

1920
def test_multiview_server(driver_get, driver, server_mount_point):
@@ -35,3 +36,5 @@ def test_multiview_server(driver_get, driver, server_mount_point):
3536

3637
assert no_such_element(driver, "id", "e1")
3738
assert no_such_element(driver, "id", "e2")
39+
40+
server_mount_point.log_records.clear()

0 commit comments

Comments
 (0)