cubevis 1.0.19__tar.gz → 1.0.21__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. {cubevis-1.0.19 → cubevis-1.0.21}/PKG-INFO +1 -1
  2. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/models/_bokeh_app_context.py +48 -1
  3. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/models/_showable.py +15 -48
  4. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/apps/_createmask.py +3 -9
  5. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/apps/_createregion.py +3 -9
  6. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/apps/_interactiveclean.mustache +2 -1
  7. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/apps/_interactiveclean.py +2 -1
  8. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/apps/_interactivecleannotebook.mustache +24 -1
  9. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/apps/_interactivecleannotebook.py +24 -1
  10. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/toolbox/__init__.py +0 -1
  11. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/toolbox/_cube.py +16 -10
  12. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/toolbox/_interactive_clean_ui.mustache +22 -7
  13. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/toolbox/_interactive_clean_ui.py +22 -7
  14. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/__init__.py +1 -0
  15. cubevis-1.0.21/cubevis/utils/_mutual_exclusion.py +117 -0
  16. {cubevis-1.0.19 → cubevis-1.0.21}/pyproject.toml +1 -1
  17. {cubevis-1.0.19 → cubevis-1.0.21}/LICENSE +0 -0
  18. {cubevis-1.0.19 → cubevis-1.0.21}/README.rst +0 -0
  19. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/LICENSE.rst +0 -0
  20. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/20px/fast-backward.svg +0 -0
  21. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/20px/fast-forward.svg +0 -0
  22. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/20px/step-backward.svg +0 -0
  23. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/20px/step-forward.svg +0 -0
  24. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/add-chan.png +0 -0
  25. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/add-chan.svg +0 -0
  26. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/add-cube.png +0 -0
  27. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/add-cube.svg +0 -0
  28. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/drag.png +0 -0
  29. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/drag.svg +0 -0
  30. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/mask-selected.png +0 -0
  31. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/mask.png +0 -0
  32. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/mask.svg +0 -0
  33. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/new-layer-sm-selected.png +0 -0
  34. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/new-layer-sm-selected.svg +0 -0
  35. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/new-layer-sm.png +0 -0
  36. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/new-layer-sm.svg +0 -0
  37. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/reset.png +0 -0
  38. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/reset.svg +0 -0
  39. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/sub-chan.png +0 -0
  40. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/sub-chan.svg +0 -0
  41. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/sub-cube.png +0 -0
  42. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/sub-cube.svg +0 -0
  43. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/trash.png +0 -0
  44. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/trash.svg +0 -0
  45. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/trash_full.png +0 -0
  46. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/trash_full.svg +0 -0
  47. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/trash_full_raw.png +0 -0
  48. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/zoom-to-fit.png +0 -0
  49. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__icons__/zoom-to-fit.svg +0 -0
  50. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__init__.py +0 -0
  51. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__js__/bokeh-3.6/cubevisjs.min.js +0 -0
  52. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__js__/bokeh-3.7/cubevisjs.min.js +0 -0
  53. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__js__/bokeh-3.8/cubevisjs.min.js +0 -0
  54. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/__js__/casalib.min.js +0 -0
  55. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/__init__.py +0 -0
  56. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/annotations/__init__.py +0 -0
  57. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/annotations/_ev_poly_annotation.py +0 -0
  58. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/components/__init__.py +0 -0
  59. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/format/__init__.py +0 -0
  60. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/format/_time_ticks.py +0 -0
  61. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/format/_wcs_ticks.py +0 -0
  62. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/models/__init__.py +0 -0
  63. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/models/_edit_span.py +0 -0
  64. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/models/_ev_text_input.py +0 -0
  65. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/models/_shared_dict.py +0 -0
  66. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/models/_tip.py +0 -0
  67. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/models/_tip_button.py +0 -0
  68. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/sources/__init__.py +0 -0
  69. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/sources/_data_pipe.py +0 -0
  70. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/sources/_image_data_source.py +0 -0
  71. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/sources/_image_pipe.py +0 -0
  72. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/sources/_spectra_data_source.py +0 -0
  73. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/sources/_updatable_data_source.py +0 -0
  74. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/__init__.py +0 -0
  75. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/_current.py +0 -0
  76. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/_initialize.py +0 -0
  77. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/_javascript.py +0 -0
  78. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/_palette.py +0 -0
  79. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/_session.py +0 -0
  80. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/js/bokeh-2.4.1.min.js +0 -0
  81. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/js/bokeh-gl-2.4.1.min.js +0 -0
  82. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/js/bokeh-tables-2.4.1.min.js +0 -0
  83. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/js/bokeh-widgets-2.4.1.min.js +0 -0
  84. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/js/casaguijs-v0.0.4.0-b2.4.min.js +0 -0
  85. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/js/casaguijs-v0.0.5.0-b2.4.min.js +0 -0
  86. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/js/casaguijs-v0.0.6.0-b2.4.min.js +0 -0
  87. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/state/js/casalib-v0.0.1.min.js +0 -0
  88. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/tools/__init__.py +0 -0
  89. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/tools/_cbreset_tool.py +0 -0
  90. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/tools/_drag_tool.py +0 -0
  91. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/utils/__init__.py +0 -0
  92. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/utils/_axes_labels.py +0 -0
  93. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/bokeh/utils/_svg_icon.py +0 -0
  94. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/data/__init__.py +0 -0
  95. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/data/casaimage/__init__.py +0 -0
  96. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/exe/__init__.py +0 -0
  97. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/exe/_context.py +0 -0
  98. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/exe/_mode.py +0 -0
  99. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/exe/_setting.py +0 -0
  100. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/exe/_task.py +0 -0
  101. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/_gclean.py +0 -0
  102. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/apps/__init__.py +0 -0
  103. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/apps/_plotants.py +0 -0
  104. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/apps/_plotbandpass.py +0 -0
  105. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/casashell/createmask.py +0 -0
  106. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/casashell/iclean.py +0 -0
  107. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/casatasks/__init__.py +0 -0
  108. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/casatasks/createmask.py +0 -0
  109. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/casatasks/createregion.py +0 -0
  110. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/casatasks/iclean.py +0 -0
  111. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/private/casatasks/iclean_notebook.py +0 -0
  112. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/readme.rst +0 -0
  113. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/remote/__init__.py +0 -0
  114. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/remote/_gclean.py +0 -0
  115. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/remote/_local.py +0 -0
  116. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/remote/_remote_kernel.py +0 -0
  117. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/toolbox/_app_context.py +0 -0
  118. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/toolbox/_interactiveclean_wrappers.py +0 -0
  119. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/toolbox/_region_list.py +0 -0
  120. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_ResourceManager.py +0 -0
  121. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_browser.py +0 -0
  122. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_contextmgrchain.py +0 -0
  123. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_conversion.py +0 -0
  124. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_copydoc.py +0 -0
  125. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_docenum.py +0 -0
  126. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_git.py +0 -0
  127. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_import_protected_module.py +0 -0
  128. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_jupyter.py +0 -0
  129. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_logging.py +0 -0
  130. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_pkgs.py +0 -0
  131. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_regions.py +0 -0
  132. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_static.py +0 -0
  133. {cubevis-1.0.19 → cubevis-1.0.21}/cubevis/utils/_tiles.py +0 -0
  134. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/alma-many-chan/alma-many-chan.py +0 -0
  135. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/basic-websockets-demo/client.html +0 -0
  136. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/basic-websockets-demo/client.py +0 -0
  137. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/basic-websockets-demo/server.py +0 -0
  138. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/createmask-demo/run-createmask.py +0 -0
  139. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/createregion-demo/run-createregion.py +0 -0
  140. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/cubemask-demo/image-slider-spectra-done-stats.py +0 -0
  141. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/cubemask-demo/image-slider-spectra-done.py +0 -0
  142. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/cubemask-demo/image-slider-spectra.py +0 -0
  143. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/cubemask-demo/image-slider.py +0 -0
  144. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/cubemask-demo/image.py +0 -0
  145. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-demo/iclean-demo.ipynb +0 -0
  146. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-demo/m100_interactive.py +0 -0
  147. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-demo/mask0-iclean.py +0 -0
  148. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-demo/run-gclean.py +0 -0
  149. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-demo/run-iclean-obj.py +0 -0
  150. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-demo/run-iclean.py +0 -0
  151. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-demo/vla-sim-jet-iclean.py +0 -0
  152. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-first-look/run-fl-cont.py +0 -0
  153. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-first-look/run-fl-line.py +0 -0
  154. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-outlier/run-iclean.py +0 -0
  155. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-outlier/test_outlier.txt +0 -0
  156. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/iclean-remote/iclean_remote_webserver.py +0 -0
  157. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/large-cube/run-largecube.py +0 -0
  158. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/svg-test.py +0 -0
  159. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/updatable-data-source/direct-plot.py +0 -0
  160. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/updatable-data-source/simple-update.py +0 -0
  161. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/updatable-data-source/updated-plot.py +0 -0
  162. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/uranus-demo/uranus-iclean.py +0 -0
  163. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/websocket-reconnect/client.html +0 -0
  164. {cubevis-1.0.19 → cubevis-1.0.21}/tests/manual/websocket-reconnect/server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cubevis
3
- Version: 1.0.19
3
+ Version: 1.0.21
4
4
  Summary: visualization toolkit and apps for casa
5
5
  License: LGPL
6
6
  Author-email: Darrell Schiebel <darrell@schiebel.us>,Pam Harris <pharris@nrao.edu>
@@ -2,7 +2,13 @@ import logging
2
2
  from bokeh.core.properties import String, Dict, Any, Nullable, Instance
3
3
  from bokeh.models.layouts import LayoutDOM
4
4
  from bokeh.models.ui import UIElement
5
+ from bokeh.resources import CDN
6
+ from tempfile import TemporaryDirectory
5
7
  from uuid import uuid4
8
+ import unicodedata
9
+ import webbrowser
10
+ import os
11
+ import re
6
12
 
7
13
  logger = logging.getLogger(__name__)
8
14
 
@@ -29,9 +35,34 @@ class BokehAppContext(LayoutDOM):
29
35
  cls._session_id = str(uuid4())
30
36
  return cls._session_id
31
37
 
32
- def __init__( self, ui=None, **kwargs ):
38
+ def _slugify(self, value, allow_unicode=False):
39
+ """
40
+ Taken from https://github.com/django/django/blob/master/django/utils/text.py
41
+ Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
42
+ dashes to single dashes. Remove characters that aren't alphanumerics,
43
+ underscores, or hyphens. Convert to lowercase. Also strip leading and
44
+ trailing whitespace, dashes, and underscores.
45
+ https://stackoverflow.com/a/295466/2903943
46
+ """
47
+ value = str(value)
48
+ if allow_unicode:
49
+ value = unicodedata.normalize('NFKC', value)
50
+ else:
51
+ value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
52
+ value = re.sub(r'[^\w\s-]', '', value.lower())
53
+ return re.sub(r'[-\s]+', '-', value).strip('-_')
54
+
55
+ def __init__( self, ui=None, title=str(uuid4( )), prefix=None, **kwargs ):
33
56
  logger.debug(f"\tBokehAppContext::__init__(ui={type(ui).__name__ if ui else None}, {kwargs}): {id(self)}")
34
57
 
58
+ if prefix is None:
59
+ ## create a prefix from the title
60
+ prefix = self._slugify(title)[:10]
61
+
62
+ self.__title = title
63
+ self.__workdir = TemporaryDirectory(prefix=prefix)
64
+ self.__htmlpath = os.path.join( self.__workdir.name, f'''{self._slugify(self.__title)}.html''' )
65
+
35
66
  if ui is not None and 'ui' in kwargs:
36
67
  raise RuntimeError( "'ui' supplied as both a positional parameter and a keyword parameter" )
37
68
 
@@ -61,3 +92,19 @@ class BokehAppContext(LayoutDOM):
61
92
  current_state = dict(self.app_state)
62
93
  current_state.update(state_updates)
63
94
  self.app_state = current_state
95
+
96
+ def show( self ):
97
+ """Always show plot in a new browser tab without changing output settings.
98
+ Jupyter display is handled by the Showable class. However, at some
99
+ point this function might need to support more than just independent
100
+ browser tab display.
101
+ """
102
+ logger.debug(f"\tBokehAppContext::show( ): {id(self)}")
103
+
104
+ from bokeh.plotting import save
105
+
106
+ # Save the plot
107
+ save( self, filename=self.__htmlpath, resources=CDN, title=self.__title)
108
+
109
+ # Open in browser
110
+ webbrowser.open('file://' + os.path.abspath(self.__htmlpath))
@@ -48,39 +48,11 @@ class Showable(LayoutDOM,BokehInit):
48
48
  # It might be a regular function or static method without explicit 'self'/'cls'
49
49
  return None
50
50
 
51
- # Detect Bokeh usage mode (i.e. self.__class__._usage_mode unset)
52
- calling_mode = None
53
- if self.__class__._usage_mode is None:
54
- self.__class__._usage_mode = "bokeh"
55
- calling_mode = "bokeh"
56
-
57
51
  # Allow None (detaching from document) without any further checking
58
52
  if doc is None:
59
53
  self._document = None
60
54
  return
61
55
 
62
- if calling_mode is None:
63
- import inspect
64
- stack_frames = inspect.stack( )
65
- try:
66
- for frame in stack_frames[1:]:
67
- if frame.function == '_repr_mimebundle_' or frame.function == 'show':
68
- if get_caller_class_name(frame.frame) == self.__class__.__name__:
69
- calling_mode = "custom"
70
- break
71
- finally:
72
- # Essential to delete stack frames to avoid reference cycles
73
- del stack_frames
74
-
75
- if calling_mode != self.__class__._usage_mode:
76
- ### THIS CATCHES: using Bokeh show after Showable display methods
77
- ### using Showable.show after Bokeh show
78
- raise RuntimeError(
79
- f"\n{'='*70}\n" +
80
- ( (self._usage_error['custom'] % self.__class__.__name__) if calling_mode == 'custom' else
81
- (self._usage_error['bokeh'] % 'bokeh.plotting.show') ) +
82
- f"\n{'='*70}\n" )
83
-
84
56
  from bokeh.io.state import curstate
85
57
  state = curstate( )
86
58
 
@@ -118,12 +90,23 @@ class Showable(LayoutDOM,BokehInit):
118
90
  self._start_backend()
119
91
  self._backend_started = True
120
92
 
93
+ def to_serializable(self, *args, **kwargs):
94
+ if self._display_context:
95
+ self._display_context.on_to_serializable( )
96
+
97
+ # Call parent's to_serializable
98
+ return super().to_serializable(*args, **kwargs)
99
+
121
100
  def __init__( self, ui_element=None, backend_func=None,
122
101
  result_retrieval=None,
123
102
  notebook_width=1200, notebook_height=800,
124
- notebook_sizing='fixed', **kwargs):
103
+ notebook_sizing='fixed',
104
+ display_context=None,
105
+ **kwargs ):
125
106
  logger.debug(f"\tShowable::__init__(ui_element={type(ui_element).__name__ if ui_element else None}, {kwargs}): {id(self)}")
126
107
 
108
+ self._display_context = display_context
109
+
127
110
  # Set default sizing if not provided
128
111
  sizing_params = {'sizing_mode', 'width', 'height'}
129
112
  provided_sizing_params = set(kwargs.keys()) & sizing_params
@@ -147,18 +130,6 @@ class Showable(LayoutDOM,BokehInit):
147
130
  'browser': { 'mode': self.sizing_mode, 'width': self.width, 'height': self.height }
148
131
  }
149
132
 
150
- # Error messages included in RuntimeErrors
151
- self._usage_error = {
152
- 'custom': "❌ Cannot use %s display methods:\n\n" \
153
- "Reason: bokeh.plotting.show() has already been used for display\n" \
154
- " of this class. Mixing display methods within a single notebook\n" \
155
- " corrupts Bokeh display within the notebook\n",
156
- 'bokeh': "❌ Cannot use %s display method:\n\n" \
157
- "Reason: Showable display methods have already been used for display\n" \
158
- " of this class. Mixing display methods within a single notebook\n" \
159
- " corrupts Bokeh display within the notebook\n" }
160
-
161
-
162
133
  # Set the function to be called upon display
163
134
  if backend_func is not None:
164
135
  self._backend_startup_callback = backend_func
@@ -304,13 +275,6 @@ class Showable(LayoutDOM,BokehInit):
304
275
  from bokeh.embed import components
305
276
  from bokeh.io.state import curstate
306
277
 
307
- if self.__class__._usage_mode != "custom":
308
- ### THIS CATCHES: Showable display via evaluation ( "ic" ) after Bokeh show
309
- raise RuntimeError(
310
- f"\n{'='*70}\n" +
311
- (self._usage_error['custom'] % self.__class__.__name__) +
312
- f"\n{'='*70}\n" )
313
-
314
278
  state = curstate()
315
279
 
316
280
  if not state.notebook:
@@ -319,6 +283,9 @@ class Showable(LayoutDOM,BokehInit):
319
283
  if self.ui is None:
320
284
  return '<div style="color: red; padding: 10px; border: 1px solid red;">Showable object with no UI set</div>'
321
285
 
286
+ if self._display_context:
287
+ self._display_context.on_show( )
288
+
322
289
  if self._notebook_rendering:
323
290
  # Return a lightweight reference instead of re-rendering the full GUI
324
291
  return f'''
@@ -35,7 +35,7 @@ from contextlib import asynccontextmanager
35
35
  from bokeh.layouts import row, column
36
36
  from bokeh.plotting import show
37
37
  from bokeh.models import Button, CustomJS, TabPanel, Tabs, Spacer, Div
38
- from cubevis.toolbox import CubeMask, AppContext
38
+ from cubevis.toolbox import CubeMask
39
39
  from cubevis.bokeh.utils import svg_icon
40
40
  from bokeh.io import reset_output as reset_bokeh_output
41
41
  from bokeh.io import output_notebook
@@ -149,12 +149,6 @@ class CreateMask:
149
149
  if False a mask path which does not exist results in an exception
150
150
  '''
151
151
 
152
- ###
153
- ### Create application context (which includes a temporary directory).
154
- ### This sets the title of the plot.
155
- ###
156
- self._app_state = AppContext( 'Create Mask' )
157
-
158
152
  ###
159
153
  ### widgets shared across image tabs (masking multiple images)
160
154
  ###
@@ -382,7 +376,7 @@ class CreateMask:
382
376
  app_state={ ### while the state dictionary itself
383
377
  'name': 'create mask', ### is used, these particular element
384
378
  'initialized': True ### are not currently used for anything
385
- } )
379
+ }, title='Create Mask' )
386
380
 
387
381
  ###
388
382
  ### Keep track of which image is currently active in appstate.image_name (which is
@@ -411,7 +405,7 @@ class CreateMask:
411
405
  ### output_file(self._imagename+'_webpage/index.html')
412
406
  pass
413
407
 
414
- show(self._fig['layout'])
408
+ self._fig['layout'].show( )
415
409
 
416
410
  def _asyncio_loop( self ):
417
411
  '''return the event loop which can be mixed in with an existing event loop
@@ -35,7 +35,7 @@ from contextlib import asynccontextmanager
35
35
  from bokeh.layouts import row, column, grid
36
36
  from bokeh.plotting import show
37
37
  from bokeh.models import Button, CustomJS, TabPanel, Tabs, Spacer, Div, Dropdown
38
- from cubevis.toolbox import CubeMask, AppContext, RegionList
38
+ from cubevis.toolbox import CubeMask, RegionList
39
39
  from cubevis.bokeh.utils import svg_icon
40
40
  from bokeh.io import curdoc
41
41
  from bokeh.io import reset_output as reset_bokeh_output
@@ -141,12 +141,6 @@ class CreateRegion:
141
141
  path(s) to CASA image for which interactive regions will be drawn
142
142
  '''
143
143
 
144
- ###
145
- ### Create application context (which includes a temporary directory).
146
- ### This sets the title of the plot.
147
- ###
148
- self._app_state = AppContext( 'Create Region' )
149
-
150
144
  ###
151
145
  ### widgets shared across image tabs (masking multiple images)
152
146
  ###
@@ -435,7 +429,7 @@ class CreateRegion:
435
429
  app_state={ ### while the state dictionary itself
436
430
  'name': 'create region', ### is used, these particular element
437
431
  'initialized': True ### are not currently used for anything
438
- } )
432
+ }, title='Create Region' )
439
433
 
440
434
  ###
441
435
  ### Keep track of which image is currently active in appstate.image_name (which is
@@ -464,7 +458,7 @@ class CreateRegion:
464
458
  ### output_file(self._imagename+'_webpage/index.html')
465
459
  pass
466
460
 
467
- show(self._fig['layout'])
461
+ self._fig['layout'].show( )
468
462
 
469
463
  def _asyncio_loop( self ):
470
464
  '''return the event loop which can be mixed in with an existing event loop
@@ -87,7 +87,8 @@ class InteractiveClean:
87
87
  interpolation='nearest', ... )( ) )
88
88
  '''
89
89
  self._id = uuid4( )
90
+ self._ui.exclusion_mgr.set_mode("tab")
90
91
  context = exe.Context( exe.Mode.SYNC )
91
92
  bokeh_ui, exec_task = self._ui( context, self._id )
92
- show(bokeh_ui)
93
+ bokeh_ui.show( )
93
94
  return context.execute( exec_task, self._id )
@@ -1849,7 +1849,8 @@ class InteractiveClean:
1849
1849
  interpolation='nearest', ... )( ) )
1850
1850
  '''
1851
1851
  self._id = uuid4( )
1852
+ self._ui.exclusion_mgr.set_mode("tab")
1852
1853
  context = exe.Context( exe.Mode.SYNC )
1853
1854
  bokeh_ui, exec_task = self._ui( context, self._id )
1854
- show(bokeh_ui)
1855
+ bokeh_ui.show( )
1855
1856
  return context.execute( exec_task, self._id )
@@ -43,6 +43,22 @@ from cubevis.utils import find_pkg, load_pkg
43
43
  from cubevis.toolbox import InteractiveCleanUI
44
44
  from cubevis import exe
45
45
 
46
+ class DisplayContext:
47
+ def __init__(self, exclusion_manager):
48
+ self.exclusion_manager = exclusion_manager
49
+ self._custom_show_called = False
50
+
51
+ def on_show(self):
52
+ self._custom_show_called = True
53
+ self.exclusion_manager.set_mode('cell-custom-show')
54
+
55
+ def on_to_serializable(self):
56
+ # Only set mode if custom_show hasn't been called
57
+ if not self._custom_show_called:
58
+ self.exclusion_manager.set_mode('cell-bokeh-show')
59
+ # If custom_show was called, we're already in 'cell-custom-show' mode
60
+ # and don't need to do anything
61
+
46
62
  class InteractiveCleanNotebook:
47
63
  r'''InteractiveCleanNotebook(...) implements interactive clean using Bokeh
48
64
  {{docstring}}
@@ -107,6 +123,13 @@ class InteractiveCleanNotebook:
107
123
  def startup( ):
108
124
  self._future = context.execute( exec_task, self._id )
109
125
 
126
+ display_ctx = DisplayContext(self._ui.exclusion_mgr)
110
127
  ### name is used in summary output of the Showable
111
- showed = Showable(bokeh_ui, startup, self.get_future, name="iclean-jpy")
128
+ showed = Showable(
129
+ bokeh_ui,
130
+ startup,
131
+ self.get_future,
132
+ name="iclean-jpy",
133
+ display_context=display_ctx
134
+ )
112
135
  return showed
@@ -42,6 +42,22 @@ from cubevis.utils import find_pkg, load_pkg
42
42
  from cubevis.toolbox import InteractiveCleanUI
43
43
  from cubevis import exe
44
44
 
45
+ class DisplayContext:
46
+ def __init__(self, exclusion_manager):
47
+ self.exclusion_manager = exclusion_manager
48
+ self._custom_show_called = False
49
+
50
+ def on_show(self):
51
+ self._custom_show_called = True
52
+ self.exclusion_manager.set_mode('cell-custom-show')
53
+
54
+ def on_to_serializable(self):
55
+ # Only set mode if custom_show hasn't been called
56
+ if not self._custom_show_called:
57
+ self.exclusion_manager.set_mode('cell-bokeh-show')
58
+ # If custom_show was called, we're already in 'cell-custom-show' mode
59
+ # and don't need to do anything
60
+
45
61
  class InteractiveCleanNotebook:
46
62
  r'''InteractiveCleanNotebook(...) implements interactive clean using Bokeh
47
63
  tclean ---- Radio Interferometric Image Reconstruction
@@ -1869,6 +1885,13 @@ class InteractiveCleanNotebook:
1869
1885
  def startup( ):
1870
1886
  self._future = context.execute( exec_task, self._id )
1871
1887
 
1888
+ display_ctx = DisplayContext(self._ui.exclusion_mgr)
1872
1889
  ### name is used in summary output of the Showable
1873
- showed = Showable(bokeh_ui, startup, self.get_future, name="iclean-jpy")
1890
+ showed = Showable(
1891
+ bokeh_ui,
1892
+ startup,
1893
+ self.get_future,
1894
+ name="iclean-jpy",
1895
+ display_context=display_ctx
1896
+ )
1874
1897
  return showed
@@ -28,6 +28,5 @@
28
28
  '''Common tools used in creating applications.'''
29
29
 
30
30
  from ._cube import CubeMask
31
- from ._app_context import AppContext
32
31
  from ._region_list import RegionList
33
32
  from ._interactive_clean_ui import InteractiveCleanUI
@@ -1513,18 +1513,24 @@ class CubeMask:
1513
1513
  ## """console.log("Running from script/terminal. Closing window.")
1514
1514
  ## window.close()"""
1515
1515
  ##) +
1516
- ( """if ( showable ) {
1517
- console.log("Running in jupyter notebook.\\nDisabling GUI via root Showable.")
1518
- showable.disabled = true
1519
- const data_pipes = Bokeh.activeDataPipes.getInstances( )
1520
- console.log("Data pipes to be closed:", data_pipes)
1516
+ ###
1517
+ ### Previously the Python is_interactive_jupyter( ) function was used
1518
+ ### to decide whether the window should be closed or not. However, to
1519
+ ### support the use of the regular iclean (i.e. display in a separate
1520
+ ### browser tab) the check is now whether a showable is available or
1521
+ ### not since Showable is the coupling between GUIs and Jupyter
1522
+ ### notebooks.
1523
+ ###
1524
+ """if ( showable ) {
1525
+ console.log("Running in jupyter notebook.\\nDisabling GUI via root Showable.")
1526
+ showable.disabled = true
1527
+ const data_pipes = Bokeh.activeDataPipes.getInstances( )
1528
+ console.log("Data pipes to be closed:", data_pipes)
1521
1529
  } else {
1522
- console.log("Running in jupyter notebook.\\nCannot find root Showable so cannot disable GUI.")
1530
+ console.log("Running from script/terminal. Closing window.")
1531
+ window.close()
1523
1532
  }
1524
- """ if is_interactive_jupyter( ) else
1525
- """console.log("Running from script/terminal. Closing window.")
1526
- window.close()"""
1527
- ) +
1533
+ """ +
1528
1534
  """
1529
1535
  }
1530
1536
  }
@@ -53,6 +53,7 @@ from cubevis.bokeh.models import TipButton, Tip, EvTextInput, BokehAppContext
53
53
 
54
54
  from cubevis.utils import resource_manager, reset_resource_manager, is_interactive_jupyter, find_pkg, load_pkg
55
55
  from cubevis.utils import ContextMgrChain as CMC
56
+ from cubevis.utils import MutualExclusionManager
56
57
 
57
58
  # pylint: disable=no-name-in-module
58
59
  from casatasks.private.imagerhelpers.imager_return_dict import ImagingDict
@@ -61,7 +62,7 @@ from casatasks.private.imagerhelpers.input_parameters import ImagerParameters
61
62
  # pylint: enable=no-name-in-module
62
63
 
63
64
  from cubevis.utils import find_ws_address, convert_masks
64
- from cubevis.toolbox import CubeMask, AppContext
65
+ from cubevis.toolbox import CubeMask
65
66
  from cubevis.bokeh.utils import svg_icon
66
67
  from cubevis.bokeh.sources import DataPipe
67
68
  from cubevis.utils import DocEnum
@@ -74,6 +75,25 @@ USE_MULTIPLE_GCLEAN_HACK=False
74
75
  class InteractiveCleanUI:
75
76
  '''InteractiveCleanUI(...) implements interactive clean using Bokeh
76
77
  '''
78
+
79
+ exclusion_mgr = MutualExclusionManager(
80
+ name="interactive clean",
81
+ valid_modes={
82
+ "tab": "❌ Cannot use iclean task display:\n\n" \
83
+ "Reason: bokeh.plotting.show() or iclean show method has already been\n" \
84
+ " used for display of this class. Mixing display methods within\n" \
85
+ " a single notebook corrupts Bokeh display within the notebook\n",
86
+ "cell-bokeh-show": "❌ Cannot use bokeh.plotting.show() display method:\n\n" \
87
+ "Reason: iclean show or task method has already been used for display\n" \
88
+ " of this class. Mixing display methods within a single notebook\n" \
89
+ " corrupts Bokeh display within the notebook\n",
90
+ "cell-custom-show": "❌ Cannot use iclean show method:\n\n" \
91
+ "Reason: bokeh.plotting.show() or task has already been used for display\n" \
92
+ " of this class. Mixing display methods within a single notebook\n" \
93
+ " corrupts Bokeh display within the notebook\n",
94
+ }
95
+ )
96
+
77
97
  def __stop( self, _=None ):
78
98
  self.__result_future.set_result(self.__retrieve_result( ))
79
99
 
@@ -164,11 +184,6 @@ class InteractiveCleanUI:
164
184
  ### the image display.
165
185
  ###
166
186
  self._conv_spect_plot_width = 450
167
- ###
168
- ### Create application context (which includes a temporary directory).
169
- ### This sets the title of the plot.
170
- ###
171
- self._app_state = AppContext( 'Interactive Clean' )
172
187
 
173
188
  ###
174
189
  ### Whether or not the Interactive Clean session is running remotely
@@ -948,7 +963,7 @@ class InteractiveCleanUI:
948
963
  app_state={ ### while the state dictionary itself
949
964
  'name': 'interactive clean', ### is used, these particular element
950
965
  'initialized': True ### are not currently used for anything
951
- } )
966
+ }, title='Interactive Clean' )
952
967
 
953
968
  ###
954
969
  ### Keep track of which image is currently active in appstate.image_name (which is
@@ -52,6 +52,7 @@ from cubevis.bokeh.models import TipButton, Tip, EvTextInput, BokehAppContext
52
52
 
53
53
  from cubevis.utils import resource_manager, reset_resource_manager, is_interactive_jupyter, find_pkg, load_pkg
54
54
  from cubevis.utils import ContextMgrChain as CMC
55
+ from cubevis.utils import MutualExclusionManager
55
56
 
56
57
  # pylint: disable=no-name-in-module
57
58
  from casatasks.private.imagerhelpers.imager_return_dict import ImagingDict
@@ -60,7 +61,7 @@ from casatasks.private.imagerhelpers.input_parameters import ImagerParameters
60
61
  # pylint: enable=no-name-in-module
61
62
 
62
63
  from cubevis.utils import find_ws_address, convert_masks
63
- from cubevis.toolbox import CubeMask, AppContext
64
+ from cubevis.toolbox import CubeMask
64
65
  from cubevis.bokeh.utils import svg_icon
65
66
  from cubevis.bokeh.sources import DataPipe
66
67
  from cubevis.utils import DocEnum
@@ -73,6 +74,25 @@ USE_MULTIPLE_GCLEAN_HACK=False
73
74
  class InteractiveCleanUI:
74
75
  '''InteractiveCleanUI(...) implements interactive clean using Bokeh
75
76
  '''
77
+
78
+ exclusion_mgr = MutualExclusionManager(
79
+ name="interactive clean",
80
+ valid_modes={
81
+ "tab": "❌ Cannot use iclean task display:\n\n" \
82
+ "Reason: bokeh.plotting.show() or iclean show method has already been\n" \
83
+ " used for display of this class. Mixing display methods within\n" \
84
+ " a single notebook corrupts Bokeh display within the notebook\n",
85
+ "cell-bokeh-show": "❌ Cannot use bokeh.plotting.show() display method:\n\n" \
86
+ "Reason: iclean show or task method has already been used for display\n" \
87
+ " of this class. Mixing display methods within a single notebook\n" \
88
+ " corrupts Bokeh display within the notebook\n",
89
+ "cell-custom-show": "❌ Cannot use iclean show method:\n\n" \
90
+ "Reason: bokeh.plotting.show() or task has already been used for display\n" \
91
+ " of this class. Mixing display methods within a single notebook\n" \
92
+ " corrupts Bokeh display within the notebook\n",
93
+ }
94
+ )
95
+
76
96
  def __stop( self, _=None ):
77
97
  self.__result_future.set_result(self.__retrieve_result( ))
78
98
 
@@ -163,11 +183,6 @@ class InteractiveCleanUI:
163
183
  ### the image display.
164
184
  ###
165
185
  self._conv_spect_plot_width = 450
166
- ###
167
- ### Create application context (which includes a temporary directory).
168
- ### This sets the title of the plot.
169
- ###
170
- self._app_state = AppContext( 'Interactive Clean' )
171
186
 
172
187
  ###
173
188
  ### Whether or not the Interactive Clean session is running remotely
@@ -947,7 +962,7 @@ class InteractiveCleanUI:
947
962
  app_state={ ### while the state dictionary itself
948
963
  'name': 'interactive clean', ### is used, these particular element
949
964
  'initialized': True ### are not currently used for anything
950
- } )
965
+ }, title='Interactive Clean' )
951
966
 
952
967
  ###
953
968
  ### Keep track of which image is currently active in appstate.image_name (which is
@@ -67,6 +67,7 @@ from ._static import static_vars, static_dir
67
67
  from ._tiles import TMSTiles
68
68
  from ._contextmgrchain import ContextMgrChain
69
69
  from ._import_protected_module import ImportProtectedModule
70
+ from ._mutual_exclusion import MutualExclusionManager
70
71
 
71
72
  @static_vars(mgr=None)
72
73
  def resource_manager( ):
@@ -0,0 +1,117 @@
1
+ import threading
2
+ from typing import Dict
3
+
4
+
5
+ class MutualExclusionManager:
6
+ """
7
+ Generic manager for enforcing mutual exclusion between different execution paths.
8
+
9
+ Use this when you want to ensure only one of several mutually exclusive paths
10
+ can be taken within a given context (thread).
11
+
12
+ Example:
13
+ mode_manager = MutualExclusionManager(
14
+ name="display_mode",
15
+ valid_modes={
16
+ 'notebook': "Cannot use Showable after displaying with bokeh.plotting.show().",
17
+ 'separate_tab': "Cannot use bokeh.plotting.show() after displaying with Showable."
18
+ }
19
+ )
20
+
21
+ # In path A:
22
+ mode_manager.set_mode('notebook')
23
+
24
+ # In path B (will raise error if path A was already taken):
25
+ mode_manager.set_mode('separate_tab')
26
+ """
27
+
28
+ def __init__(self, name: str, valid_modes: Dict[str, str]):
29
+ """
30
+ Args:
31
+ name: Descriptive name for this manager (used in error messages)
32
+ valid_modes: Dictionary mapping mode names to error messages to display
33
+ when that mode conflicts with an already-set mode.
34
+ """
35
+ self.name = name
36
+ self.valid_modes = valid_modes
37
+ self._local = threading.local()
38
+
39
+ def set_mode(self, mode: str):
40
+ """
41
+ Set the execution mode. Raises RuntimeError if a different mode was already set.
42
+
43
+ Args:
44
+ mode: The mode identifier to set
45
+
46
+ Raises:
47
+ ValueError: If mode is not in valid_modes
48
+ RuntimeError: If a different mode was already set in this context
49
+ """
50
+ if mode not in self.valid_modes:
51
+ raise ValueError(
52
+ f"Invalid mode '{mode}' for {self.name}. "
53
+ f"Valid modes: {set(self.valid_modes.keys())}"
54
+ )
55
+
56
+ current_mode = self.get_mode()
57
+ if current_mode is not None and current_mode != mode:
58
+ # Use the error message associated with the mode being set
59
+ error_message = self.valid_modes[mode]
60
+ raise RuntimeError(error_message)
61
+
62
+ self._local.mode = mode
63
+
64
+ def get_mode(self) -> str | None:
65
+ """
66
+ Get the current mode, or None if no mode has been set.
67
+
68
+ Returns:
69
+ The current mode string, or None
70
+ """
71
+ return getattr(self._local, 'mode', None)
72
+
73
+ def require_mode(self, mode: str):
74
+ """
75
+ Check that we're in the specified mode, set it if unset, error if different.
76
+
77
+ This is a convenience method that combines get_mode checking with set_mode.
78
+
79
+ Args:
80
+ mode: The required mode
81
+
82
+ Raises:
83
+ RuntimeError: If a different mode was already set
84
+ """
85
+ self.set_mode(mode)
86
+
87
+ def forbid_mode(self, mode: str, message: str | None = None):
88
+ """
89
+ Raise an error if the current mode matches the specified mode.
90
+
91
+ Args:
92
+ mode: The mode to forbid
93
+ message: Optional custom error message. If None, uses the message from valid_modes.
94
+
95
+ Raises:
96
+ RuntimeError: If current mode matches the forbidden mode
97
+ """
98
+ current_mode = self.get_mode()
99
+ if current_mode == mode:
100
+ if message is None:
101
+ message = self.valid_modes.get(
102
+ mode,
103
+ f"Cannot proceed: {self.name} is set to '{mode}', "
104
+ f"which is not allowed in this context."
105
+ )
106
+ raise RuntimeError(message)
107
+
108
+ def reset(self):
109
+ """
110
+ Reset the mode for this context. Useful for testing or manual mode switching.
111
+ """
112
+ if hasattr(self._local, 'mode'):
113
+ del self._local.mode
114
+
115
+ def __repr__(self):
116
+ mode = self.get_mode()
117
+ return f"MutualExclusionManager(name='{self.name}', current_mode={mode!r})"