Skip to content

Commit 3fe3b7b

Browse files
authored
Merge branch 'develop' into add-slide-data
2 parents 987861a + 59a22d8 commit 3fe3b7b

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

@@ -156,7 +166,7 @@ def run_app() -> None:
156166
layers={},
157167
)
158168
CORS(app, send_wildcard=True)
159-
app.run(host="127.0.0.1", threaded=True)
169+
app.run(host="127.0.0.1", port=int(main.port), threaded=True)
160170

161171

162172
@pytest.fixture(scope="module")
@@ -393,13 +403,17 @@ def test_type_cmap_select(doc: Document) -> None:
393403

394404
# remove the type cmap
395405
cmap_select.value = []
396-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/secondary_cmap")
406+
resp = main.UI["s"].get(
407+
f"http://{main.host2}:{main.port}/tileserver/secondary_cmap"
408+
)
397409
assert resp.json()["score_prop"] == "None"
398410

399411
# check callback works regardless of order
400412
cmap_select.value = ["0"]
401413
cmap_select.value = ["0", "prob"]
402-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/secondary_cmap")
414+
resp = main.UI["s"].get(
415+
f"http://{main.host2}:{main.port}/tileserver/secondary_cmap"
416+
)
403417
assert resp.json()["score_prop"] == "prob"
404418

405419

@@ -770,24 +784,24 @@ def test_cmap_select(doc: Document) -> None:
770784
main.UI["cprop_input"].value = ["prob"]
771785
# set to jet
772786
cmap_select.value = "jet"
773-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
787+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
774788
assert resp.json() == "jet"
775789
# set to dict
776790
cmap_select.value = "dict"
777-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
791+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
778792
assert isinstance(resp.json(), dict)
779793

780794
main.UI["cprop_input"].value = ["type"]
781795
# should now be the type mapping
782-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
796+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
783797
for key in main.UI["vstate"].mapper:
784798
assert str(key) in resp.json()
785799
assert np.all(
786800
np.array(resp.json()[str(key)]) == np.array(main.UI["vstate"].mapper[key]),
787801
)
788802
# set the cmap to "coolwarm"
789803
cmap_select.value = "coolwarm"
790-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
804+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
791805
# as cprop is type (categorical), it should have had no effect
792806
for key in main.UI["vstate"].mapper:
793807
assert str(key) in resp.json()
@@ -796,7 +810,7 @@ def test_cmap_select(doc: Document) -> None:
796810
)
797811

798812
main.UI["cprop_input"].value = ["prob"]
799-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
813+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
800814
# should be coolwarm as that is the last cmap we set, and prob is continuous
801815
assert resp.json() == "coolwarm"
802816

@@ -842,3 +856,51 @@ def test_clearing_doc(doc: Document) -> None:
842856
"""Test that the doc can be cleared."""
843857
doc.clear()
844858
assert len(doc.roots) == 0
859+
860+
861+
def test_app_hooks_session_destroyed(monkeypatch: pytest.MonkeyPatch) -> None:
862+
"""Hook should call reset endpoint and exit."""
863+
recorded: dict[str, object] = {}
864+
865+
def fake_get(url: str, *, timeout: int) -> None:
866+
"""Fake requests.get to record parameters."""
867+
recorded["url"] = url
868+
recorded["timeout"] = timeout
869+
870+
monkeypatch.setattr(app_hooks, "PORT", "6150")
871+
monkeypatch.setattr(app_hooks.requests, "get", fake_get)
872+
exited = False
873+
874+
def fake_exit() -> None:
875+
"""Fake sys.exit to record call."""
876+
nonlocal exited
877+
exited = True
878+
879+
monkeypatch.setattr(app_hooks, "sys", SimpleNamespace(exit=fake_exit))
880+
app_hooks.on_session_destroyed(_DummySessionContext("user-1"))
881+
assert recorded["url"] == "http://127.0.0.1:6150/tileserver/reset/user-1"
882+
assert recorded["timeout"] == 5
883+
assert exited
884+
885+
886+
def test_app_hooks_session_destroyed_suppresses_timeout(
887+
monkeypatch: pytest.MonkeyPatch,
888+
) -> None:
889+
"""ReadTimeout should be suppressed and exit still called."""
890+
891+
def fake_get(*_: object, **__: object) -> None:
892+
"""Fake requests.get to raise ReadTimeout."""
893+
raise app_hooks.requests.exceptions.ReadTimeout # type: ignore[attr-defined]
894+
895+
monkeypatch.setattr(app_hooks, "PORT", "6160")
896+
monkeypatch.setattr(app_hooks.requests, "get", fake_get)
897+
exited = False
898+
899+
def fake_exit() -> None:
900+
"""Fake sys.exit to record call."""
901+
nonlocal exited
902+
exited = True
903+
904+
monkeypatch.setattr(app_hooks, "sys", SimpleNamespace(exit=fake_exit))
905+
app_hooks.on_session_destroyed(_DummySessionContext("user-2"))
906+
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
@@ -15,6 +16,8 @@
1516
from tiatoolbox.cli.visualize import run_bokeh, run_tileserver
1617
from tiatoolbox.data import _fetch_remote_sample
1718

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

@@ -75,7 +78,10 @@ def bk_session(data_path: dict[str, Path]) -> ClientSession:
7578
yield session
7679
session.close()
7780
with suppress(requests.exceptions.ConnectionError):
78-
requests.post("http://localhost:5000/tileserver/shutdown", timeout=2)
81+
requests.post(
82+
f"http://localhost:{PORT}/tileserver/shutdown",
83+
timeout=2,
84+
)
7985

8086

8187
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)