Skip to content

Commit 8a47302

Browse files
authored
Merge branch 'develop' into sam-viz-fix
2 parents 3a58797 + 59a22d8 commit 8a47302

File tree

11 files changed

+158
-43
lines changed

11 files changed

+158
-43
lines changed

.readthedocs.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ build:
1111
tools:
1212
python: "3.12"
1313
apt_packages:
14-
- openslide-tools
1514
- libopenjp2-7-dev
1615
- libopenjp2-tools
1716

@@ -22,5 +21,7 @@ sphinx:
2221
# Optionally set the version of Python and requirements required to build your docs
2322
python:
2423
install:
25-
- requirements: requirements/requirements.txt
24+
# Install PyTorch CPU version first
25+
- requirements: requirements/requirements_cpu.txt
2626
- requirements: docs/requirements.txt
27+
- requirements: requirements/requirements.txt

requirements/requirements.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# torch installation
2-
--extra-index-url https://download.pytorch.org/whl/cu126; sys_platform != "darwin"
31
aiohttp>=3.8.1
42
albumentations>=1.3.0
53
bokeh>=3.1.1, <3.6.0

requirements/requirements_cpu.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
--index-url https://download.pytorch.org/whl/cpu
2+
torch
3+
torchvision

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

@@ -797,24 +811,24 @@ def test_cmap_select(doc: Document) -> None:
797811
main.UI["cprop_input"].value = ["prob"]
798812
# set to jet
799813
cmap_select.value = "jet"
800-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
814+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
801815
assert resp.json() == "jet"
802816
# set to dict
803817
cmap_select.value = "dict"
804-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
818+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
805819
assert isinstance(resp.json(), dict)
806820

807821
main.UI["cprop_input"].value = ["type"]
808822
# should now be the type mapping
809-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
823+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
810824
for key in main.UI["vstate"].mapper:
811825
assert str(key) in resp.json()
812826
assert np.all(
813827
np.array(resp.json()[str(key)]) == np.array(main.UI["vstate"].mapper[key]),
814828
)
815829
# set the cmap to "coolwarm"
816830
cmap_select.value = "coolwarm"
817-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
831+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
818832
# as cprop is type (categorical), it should have had no effect
819833
for key in main.UI["vstate"].mapper:
820834
assert str(key) in resp.json()
@@ -823,7 +837,7 @@ def test_cmap_select(doc: Document) -> None:
823837
)
824838

825839
main.UI["cprop_input"].value = ["prob"]
826-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
840+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
827841
# should be coolwarm as that is the last cmap we set, and prob is continuous
828842
assert resp.json() == "coolwarm"
829843

@@ -869,3 +883,51 @@ def test_clearing_doc(doc: Document) -> None:
869883
"""Test that the doc can be cleared."""
870884
doc.clear()
871885
assert len(doc.roots) == 0
886+
887+
888+
def test_app_hooks_session_destroyed(monkeypatch: pytest.MonkeyPatch) -> None:
889+
"""Hook should call reset endpoint and exit."""
890+
recorded: dict[str, object] = {}
891+
892+
def fake_get(url: str, *, timeout: int) -> None:
893+
"""Fake requests.get to record parameters."""
894+
recorded["url"] = url
895+
recorded["timeout"] = timeout
896+
897+
monkeypatch.setattr(app_hooks, "PORT", "6150")
898+
monkeypatch.setattr(app_hooks.requests, "get", fake_get)
899+
exited = False
900+
901+
def fake_exit() -> None:
902+
"""Fake sys.exit to record call."""
903+
nonlocal exited
904+
exited = True
905+
906+
monkeypatch.setattr(app_hooks, "sys", SimpleNamespace(exit=fake_exit))
907+
app_hooks.on_session_destroyed(_DummySessionContext("user-1"))
908+
assert recorded["url"] == "http://127.0.0.1:6150/tileserver/reset/user-1"
909+
assert recorded["timeout"] == 5
910+
assert exited
911+
912+
913+
def test_app_hooks_session_destroyed_suppresses_timeout(
914+
monkeypatch: pytest.MonkeyPatch,
915+
) -> None:
916+
"""ReadTimeout should be suppressed and exit still called."""
917+
918+
def fake_get(*_: object, **__: object) -> None:
919+
"""Fake requests.get to raise ReadTimeout."""
920+
raise app_hooks.requests.exceptions.ReadTimeout # type: ignore[attr-defined]
921+
922+
monkeypatch.setattr(app_hooks, "PORT", "6160")
923+
monkeypatch.setattr(app_hooks.requests, "get", fake_get)
924+
exited = False
925+
926+
def fake_exit() -> None:
927+
"""Fake sys.exit to record call."""
928+
nonlocal exited
929+
exited = True
930+
931+
monkeypatch.setattr(app_hooks, "sys", SimpleNamespace(exit=fake_exit))
932+
app_hooks.on_session_destroyed(_DummySessionContext("user-2"))
933+
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)