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
@@ -145,7 +155,7 @@ def run_app() -> None:
145155 )
146156 app .json .sort_keys = False
147157 CORS (app , send_wildcard = True )
148- app .run (host = "127.0.0.1" , threaded = True )
158+ app .run (host = "127.0.0.1" , port = int ( main . port ), threaded = True )
149159
150160
151161@pytest .fixture (scope = "module" )
@@ -377,13 +387,17 @@ def test_type_cmap_select(doc: Document) -> None:
377387
378388 # remove the type cmap
379389 cmap_select .value = []
380- resp = main .UI ["s" ].get (f"http://{ main .host2 } :5000/tileserver/secondary_cmap" )
390+ resp = main .UI ["s" ].get (
391+ f"http://{ main .host2 } :{ main .port } /tileserver/secondary_cmap"
392+ )
381393 assert resp .json ()["score_prop" ] == "None"
382394
383395 # check callback works regardless of order
384396 cmap_select .value = ["0" ]
385397 cmap_select .value = ["0" , "prob" ]
386- resp = main .UI ["s" ].get (f"http://{ main .host2 } :5000/tileserver/secondary_cmap" )
398+ resp = main .UI ["s" ].get (
399+ f"http://{ main .host2 } :{ main .port } /tileserver/secondary_cmap"
400+ )
387401 assert resp .json ()["score_prop" ] == "prob"
388402
389403
@@ -754,24 +768,24 @@ def test_cmap_select(doc: Document) -> None:
754768 main .UI ["cprop_input" ].value = ["prob" ]
755769 # set to jet
756770 cmap_select .value = "jet"
757- resp = main .UI ["s" ].get (f"http://{ main .host2 } :5000 /tileserver/cmap" )
771+ resp = main .UI ["s" ].get (f"http://{ main .host2 } :{ main . port } /tileserver/cmap" )
758772 assert resp .json () == "jet"
759773 # set to dict
760774 cmap_select .value = "dict"
761- resp = main .UI ["s" ].get (f"http://{ main .host2 } :5000 /tileserver/cmap" )
775+ resp = main .UI ["s" ].get (f"http://{ main .host2 } :{ main . port } /tileserver/cmap" )
762776 assert isinstance (resp .json (), dict )
763777
764778 main .UI ["cprop_input" ].value = ["type" ]
765779 # should now be the type mapping
766- resp = main .UI ["s" ].get (f"http://{ main .host2 } :5000 /tileserver/cmap" )
780+ resp = main .UI ["s" ].get (f"http://{ main .host2 } :{ main . port } /tileserver/cmap" )
767781 for key in main .UI ["vstate" ].mapper :
768782 assert str (key ) in resp .json ()
769783 assert np .all (
770784 np .array (resp .json ()[str (key )]) == np .array (main .UI ["vstate" ].mapper [key ]),
771785 )
772786 # set the cmap to "coolwarm"
773787 cmap_select .value = "coolwarm"
774- resp = main .UI ["s" ].get (f"http://{ main .host2 } :5000 /tileserver/cmap" )
788+ resp = main .UI ["s" ].get (f"http://{ main .host2 } :{ main . port } /tileserver/cmap" )
775789 # as cprop is type (categorical), it should have had no effect
776790 for key in main .UI ["vstate" ].mapper :
777791 assert str (key ) in resp .json ()
@@ -780,7 +794,7 @@ def test_cmap_select(doc: Document) -> None:
780794 )
781795
782796 main .UI ["cprop_input" ].value = ["prob" ]
783- resp = main .UI ["s" ].get (f"http://{ main .host2 } :5000 /tileserver/cmap" )
797+ resp = main .UI ["s" ].get (f"http://{ main .host2 } :{ main . port } /tileserver/cmap" )
784798 # should be coolwarm as that is the last cmap we set, and prob is continuous
785799 assert resp .json () == "coolwarm"
786800
@@ -826,3 +840,51 @@ def test_clearing_doc(doc: Document) -> None:
826840 """Test that the doc can be cleared."""
827841 doc .clear ()
828842 assert len (doc .roots ) == 0
843+
844+
845+ def test_app_hooks_session_destroyed (monkeypatch : pytest .MonkeyPatch ) -> None :
846+ """Hook should call reset endpoint and exit."""
847+ recorded : dict [str , object ] = {}
848+
849+ def fake_get (url : str , * , timeout : int ) -> None :
850+ """Fake requests.get to record parameters."""
851+ recorded ["url" ] = url
852+ recorded ["timeout" ] = timeout
853+
854+ monkeypatch .setattr (app_hooks , "PORT" , "6150" )
855+ monkeypatch .setattr (app_hooks .requests , "get" , fake_get )
856+ exited = False
857+
858+ def fake_exit () -> None :
859+ """Fake sys.exit to record call."""
860+ nonlocal exited
861+ exited = True
862+
863+ monkeypatch .setattr (app_hooks , "sys" , SimpleNamespace (exit = fake_exit ))
864+ app_hooks .on_session_destroyed (_DummySessionContext ("user-1" ))
865+ assert recorded ["url" ] == "http://127.0.0.1:6150/tileserver/reset/user-1"
866+ assert recorded ["timeout" ] == 5
867+ assert exited
868+
869+
870+ def test_app_hooks_session_destroyed_suppresses_timeout (
871+ monkeypatch : pytest .MonkeyPatch ,
872+ ) -> None :
873+ """ReadTimeout should be suppressed and exit still called."""
874+
875+ def fake_get (* _ : object , ** __ : object ) -> None :
876+ """Fake requests.get to raise ReadTimeout."""
877+ raise app_hooks .requests .exceptions .ReadTimeout # type: ignore[attr-defined]
878+
879+ monkeypatch .setattr (app_hooks , "PORT" , "6160" )
880+ monkeypatch .setattr (app_hooks .requests , "get" , fake_get )
881+ exited = False
882+
883+ def fake_exit () -> None :
884+ """Fake sys.exit to record call."""
885+ nonlocal exited
886+ exited = True
887+
888+ monkeypatch .setattr (app_hooks , "sys" , SimpleNamespace (exit = fake_exit ))
889+ app_hooks .on_session_destroyed (_DummySessionContext ("user-2" ))
890+ assert exited
0 commit comments