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