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
@@ -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
0 commit comments