99import re
1010import time
1111from pathlib import Path
12+ from types import SimpleNamespace
1213from typing import TYPE_CHECKING
1314
1415import bokeh .models as bkmodels
2526from scipy .ndimage import label
2627
2728from tiatoolbox .data import _fetch_remote_sample
28- from tiatoolbox .visualization .bokeh_app import main
29+ from tiatoolbox .visualization .bokeh_app import app_hooks , main
2930from tiatoolbox .visualization .tileserver import TileServer
3031from tiatoolbox .visualization .ui_utils import get_level_by_extent
3132
4142GRIDLINES = 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
4553def 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
0 commit comments