Skip to content

Commit 0be5bfc

Browse files
authored
Merge branch 'dev-define-engines-abc' into dev-define-nucleus-detection-engine
2 parents 7db6457 + 39ae9cb commit 0be5bfc

File tree

8 files changed

+152
-39
lines changed

8 files changed

+152
-39
lines changed

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
import os
66
import shutil
7+
import socket
78
import time
9+
from contextlib import closing
810
from pathlib import Path
911
from typing import TYPE_CHECKING
1012

@@ -16,6 +18,13 @@
1618
from tiatoolbox.data import _fetch_remote_sample
1719
from tiatoolbox.utils.env_detection import has_gpu, running_on_ci
1820

21+
# Reserve a free port for tileserver tests
22+
_TILES_ENV = "TIATOOLBOX_TILESERVER_PORT"
23+
if _TILES_ENV not in os.environ:
24+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
25+
sock.bind(("127.0.0.1", 0))
26+
os.environ[_TILES_ENV] = str(sock.getsockname()[1])
27+
1928
if TYPE_CHECKING:
2029
from collections.abc import Callable
2130

tests/test_app_bokeh.py

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import re
1010
import time
1111
from pathlib import Path
12+
from types import SimpleNamespace
1213
from typing import TYPE_CHECKING
1314

1415
import bokeh.models as bkmodels
@@ -25,7 +26,7 @@
2526
from scipy.ndimage import label
2627

2728
from tiatoolbox.data import _fetch_remote_sample
28-
from tiatoolbox.visualization.bokeh_app import main
29+
from tiatoolbox.visualization.bokeh_app import app_hooks, main
2930
from tiatoolbox.visualization.tileserver import TileServer
3031
from tiatoolbox.visualization.ui_utils import get_level_by_extent
3132

@@ -41,6 +42,13 @@
4142
GRIDLINES = 2
4243

4344

45+
class _DummySessionContext:
46+
"""Simple shim matching the subset of Bokeh's SessionContext we use."""
47+
48+
def __init__(self: _DummySessionContext, user: str) -> None:
49+
self.request = SimpleNamespace(arguments={"user": user})
50+
51+
4452
# helper functions and fixtures
4553
def get_tile(layer: str, x: float, y: float, z: float, *, show: bool) -> np.ndarray:
4654
"""Get a tile from the server.
@@ -77,7 +85,9 @@ def get_renderer_prop(prop: str) -> json:
7785
The property to get.
7886
7987
"""
80-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/renderer/{prop}")
88+
resp = main.UI["s"].get(
89+
f"http://{main.host2}:{main.port}/tileserver/renderer/{prop}",
90+
)
8191
return resp.json()
8292

8393

@@ -144,7 +154,7 @@ def run_app() -> None:
144154
layers={},
145155
)
146156
CORS(app, send_wildcard=True)
147-
app.run(host="127.0.0.1", threaded=True)
157+
app.run(host="127.0.0.1", port=int(main.port), threaded=True)
148158

149159

150160
@pytest.fixture(scope="module")
@@ -376,13 +386,17 @@ def test_type_cmap_select(doc: Document) -> None:
376386

377387
# remove the type cmap
378388
cmap_select.value = []
379-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/secondary_cmap")
389+
resp = main.UI["s"].get(
390+
f"http://{main.host2}:{main.port}/tileserver/secondary_cmap"
391+
)
380392
assert resp.json()["score_prop"] == "None"
381393

382394
# check callback works regardless of order
383395
cmap_select.value = ["0"]
384396
cmap_select.value = ["0", "prob"]
385-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/secondary_cmap")
397+
resp = main.UI["s"].get(
398+
f"http://{main.host2}:{main.port}/tileserver/secondary_cmap"
399+
)
386400
assert resp.json()["score_prop"] == "prob"
387401

388402

@@ -706,24 +720,24 @@ def test_cmap_select(doc: Document) -> None:
706720
main.UI["cprop_input"].value = ["prob"]
707721
# set to jet
708722
cmap_select.value = "jet"
709-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
723+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
710724
assert resp.json() == "jet"
711725
# set to dict
712726
cmap_select.value = "dict"
713-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
727+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
714728
assert isinstance(resp.json(), dict)
715729

716730
main.UI["cprop_input"].value = ["type"]
717731
# should now be the type mapping
718-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
732+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
719733
for key in main.UI["vstate"].mapper:
720734
assert str(key) in resp.json()
721735
assert np.all(
722736
np.array(resp.json()[str(key)]) == np.array(main.UI["vstate"].mapper[key]),
723737
)
724738
# set the cmap to "coolwarm"
725739
cmap_select.value = "coolwarm"
726-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
740+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
727741
# as cprop is type (categorical), it should have had no effect
728742
for key in main.UI["vstate"].mapper:
729743
assert str(key) in resp.json()
@@ -732,7 +746,7 @@ def test_cmap_select(doc: Document) -> None:
732746
)
733747

734748
main.UI["cprop_input"].value = ["prob"]
735-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
749+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
736750
# should be coolwarm as that is the last cmap we set, and prob is continuous
737751
assert resp.json() == "coolwarm"
738752

@@ -778,3 +792,51 @@ def test_clearing_doc(doc: Document) -> None:
778792
"""Test that the doc can be cleared."""
779793
doc.clear()
780794
assert len(doc.roots) == 0
795+
796+
797+
def test_app_hooks_session_destroyed(monkeypatch: pytest.MonkeyPatch) -> None:
798+
"""Hook should call reset endpoint and exit."""
799+
recorded: dict[str, object] = {}
800+
801+
def fake_get(url: str, *, timeout: int) -> None:
802+
"""Fake requests.get to record parameters."""
803+
recorded["url"] = url
804+
recorded["timeout"] = timeout
805+
806+
monkeypatch.setattr(app_hooks, "PORT", "6150")
807+
monkeypatch.setattr(app_hooks.requests, "get", fake_get)
808+
exited = False
809+
810+
def fake_exit() -> None:
811+
"""Fake sys.exit to record call."""
812+
nonlocal exited
813+
exited = True
814+
815+
monkeypatch.setattr(app_hooks, "sys", SimpleNamespace(exit=fake_exit))
816+
app_hooks.on_session_destroyed(_DummySessionContext("user-1"))
817+
assert recorded["url"] == "http://127.0.0.1:6150/tileserver/reset/user-1"
818+
assert recorded["timeout"] == 5
819+
assert exited
820+
821+
822+
def test_app_hooks_session_destroyed_suppresses_timeout(
823+
monkeypatch: pytest.MonkeyPatch,
824+
) -> None:
825+
"""ReadTimeout should be suppressed and exit still called."""
826+
827+
def fake_get(*_: object, **__: object) -> None:
828+
"""Fake requests.get to raise ReadTimeout."""
829+
raise app_hooks.requests.exceptions.ReadTimeout # type: ignore[attr-defined]
830+
831+
monkeypatch.setattr(app_hooks, "PORT", "6160")
832+
monkeypatch.setattr(app_hooks.requests, "get", fake_get)
833+
exited = False
834+
835+
def fake_exit() -> None:
836+
"""Fake sys.exit to record call."""
837+
nonlocal exited
838+
exited = True
839+
840+
monkeypatch.setattr(app_hooks, "sys", SimpleNamespace(exit=fake_exit))
841+
app_hooks.on_session_destroyed(_DummySessionContext("user-2"))
842+
assert exited

tests/test_json_config_bokeh.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import time
67
from contextlib import suppress
78
from threading import Thread
@@ -14,6 +15,8 @@
1415
from tiatoolbox.cli.visualize import run_bokeh, run_tileserver
1516
from tiatoolbox.data import _fetch_remote_sample
1617

18+
PORT = os.environ.get("TIATOOLBOX_TILESERVER_PORT", "5000")
19+
1720
if TYPE_CHECKING:
1821
from pathlib import Path
1922

@@ -65,7 +68,10 @@ def bk_session(data_path: dict[str, Path]) -> ClientSession:
6568
yield session
6669
session.close()
6770
with suppress(requests.exceptions.ConnectionError):
68-
requests.post("http://localhost:5000/tileserver/shutdown", timeout=2)
71+
requests.post(
72+
f"http://localhost:{PORT}/tileserver/shutdown",
73+
timeout=2,
74+
)
6975

7076

7177
def test_slides_available(bk_session: ClientSession) -> None:

tests/test_server_bokeh.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import time
67
from contextlib import suppress
78
from threading import Thread
@@ -16,6 +17,8 @@
1617
from tiatoolbox.cli.visualize import run_bokeh, run_tileserver
1718
from tiatoolbox.data import _fetch_remote_sample
1819

20+
PORT = os.environ.get("TIATOOLBOX_TILESERVER_PORT", "5000")
21+
1922
if TYPE_CHECKING:
2023
from pathlib import Path
2124

@@ -72,7 +75,10 @@ def bk_session(data_path: dict[str, Path]) -> ClientSession:
7275
yield session
7376
session.close()
7477
with suppress(requests.exceptions.ConnectionError):
75-
requests.post("http://localhost:5000/tileserver/shutdown", timeout=2)
78+
requests.post(
79+
f"http://localhost:{PORT}/tileserver/shutdown",
80+
timeout=2,
81+
)
7682

7783

7884
def test_slides_available(bk_session: ClientSession) -> None:

tiatoolbox/annotation/storage.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2861,6 +2861,33 @@ def _initialize_query_string_parameters(
28612861

28622862
return query_string, query_parameters
28632863

2864+
@staticmethod
2865+
def _warn_if_query_not_using_index(
2866+
cur: sqlite3.Cursor,
2867+
query_string: str,
2868+
query_parameters: dict[str, object],
2869+
) -> None:
2870+
"""Log a warning when SQLite does not use an index."""
2871+
query_plan = cur.execute(
2872+
"EXPLAIN QUERY PLAN " + query_string,
2873+
query_parameters,
2874+
).fetchall()
2875+
uses_index = False
2876+
for plan in query_plan:
2877+
detail = str(plan[-1]).upper()
2878+
# macOS SQLite may report "USING COVERING INDEX".
2879+
if "AUTOMATIC" not in detail and (
2880+
"USING INDEX" in detail or "USING COVERING INDEX" in detail
2881+
):
2882+
uses_index = True
2883+
break
2884+
if not uses_index:
2885+
logger.warning(
2886+
"Query is not using an index. "
2887+
"Consider adding an index to improve performance.",
2888+
stacklevel=2,
2889+
)
2890+
28642891
def _query(
28652892
self: SQLiteStore,
28662893
columns: str,
@@ -2970,16 +2997,7 @@ def _query(
29702997

29712998
# Warn if the query is not using an index
29722999
if index_warning:
2973-
query_plan = cur.execute(
2974-
"EXPLAIN QUERY PLAN " + query_string,
2975-
query_parameters,
2976-
).fetchone()
2977-
if "USING INDEX" not in query_plan[-1]:
2978-
logger.warning(
2979-
"Query is not using an index. "
2980-
"Consider adding an index to improve performance.",
2981-
stacklevel=2,
2982-
)
3000+
self._warn_if_query_not_using_index(cur, query_string, query_parameters)
29833001
# if area column exists, sort annotations by area
29843002
if "area" in self.table_columns:
29853003
query_string += "\nORDER BY area DESC"

tiatoolbox/cli/visualize.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ def run_app() -> None:
2626
layers={},
2727
)
2828
CORS(app, send_wildcard=True)
29-
app.run(host="127.0.0.1", threaded=True)
29+
port = int(os.environ.get("TIATOOLBOX_TILESERVER_PORT", "5000"))
30+
app.run(host="127.0.0.1", port=port, threaded=True)
3031

3132
proc = Thread(target=run_app, daemon=True)
3233
proc.start()
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
"""Hooks to be executed upon specific events in bokeh app."""
22

3+
import os
34
import sys
45
from contextlib import suppress
56

67
import requests
78
from bokeh.application.application import SessionContext
89

10+
PORT = os.environ.get("TIATOOLBOX_TILESERVER_PORT", "5000")
11+
912

1013
def on_session_destroyed(session_context: SessionContext) -> None:
1114
"""Hook to be executed when a session is destroyed."""
1215
user = session_context.request.arguments["user"]
1316
with suppress(requests.exceptions.ReadTimeout):
14-
requests.get(f"http://127.0.0.1:5000/tileserver/reset/{user}", timeout=5)
17+
requests.get(
18+
f"http://127.0.0.1:{PORT}/tileserver/reset/{user}",
19+
timeout=5,
20+
)
1521
sys.exit()

0 commit comments

Comments
 (0)