cubevis 1.0.5__py3-none-any.whl → 1.0.21__py3-none-any.whl
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.
- cubevis/__js__/bokeh-3.6/cubevisjs.min.js +13 -10
- cubevis/__js__/bokeh-3.7/cubevisjs.min.js +12 -8
- cubevis/__js__/bokeh-3.8/cubevisjs.min.js +12 -9
- cubevis/__version__.py +1 -1
- cubevis/bokeh/models/__init__.py +1 -0
- cubevis/bokeh/models/_bokeh_app_context.py +110 -0
- cubevis/bokeh/models/_showable.py +108 -82
- cubevis/bokeh/sources/_data_pipe.py +14 -2
- cubevis/exe/_task.py +36 -16
- cubevis/private/apps/_createmask.py +43 -41
- cubevis/private/apps/_createregion.py +31 -29
- cubevis/private/apps/_interactiveclean.mustache +2 -1
- cubevis/private/apps/_interactiveclean.py +2 -1
- cubevis/private/apps/_interactivecleannotebook.mustache +24 -1
- cubevis/private/apps/_interactivecleannotebook.py +24 -1
- cubevis/toolbox/__init__.py +0 -1
- cubevis/toolbox/_cube.py +40 -23
- cubevis/toolbox/_interactive_clean_ui.mustache +82 -50
- cubevis/toolbox/_interactive_clean_ui.py +82 -50
- cubevis/utils/__init__.py +1 -0
- cubevis/utils/_mutual_exclusion.py +117 -0
- {cubevis-1.0.5.dist-info → cubevis-1.0.21.dist-info}/METADATA +2 -2
- {cubevis-1.0.5.dist-info → cubevis-1.0.21.dist-info}/RECORD +25 -23
- {cubevis-1.0.5.dist-info → cubevis-1.0.21.dist-info}/WHEEL +0 -0
- {cubevis-1.0.5.dist-info → cubevis-1.0.21.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from bokeh.core.properties import String, Dict, Any, Nullable, Instance
|
|
3
|
+
from bokeh.models.layouts import LayoutDOM
|
|
4
|
+
from bokeh.models.ui import UIElement
|
|
5
|
+
from bokeh.resources import CDN
|
|
6
|
+
from tempfile import TemporaryDirectory
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
import unicodedata
|
|
9
|
+
import webbrowser
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
class BokehAppContext(LayoutDOM):
|
|
16
|
+
"""
|
|
17
|
+
Custom Bokeh model that bridges Python AppContext with JavaScript.
|
|
18
|
+
Initializes session-level data structure and app-specific state.
|
|
19
|
+
"""
|
|
20
|
+
ui = Nullable(Instance(UIElement), help="""
|
|
21
|
+
A UI element, which can be plots, layouts, widgets, or any other UIElement.
|
|
22
|
+
""")
|
|
23
|
+
|
|
24
|
+
app_id = String(default="")
|
|
25
|
+
session_id = String(default="")
|
|
26
|
+
app_state = Dict(String, Any, default={})
|
|
27
|
+
|
|
28
|
+
# Class-level session ID shared across all apps in the same Python session
|
|
29
|
+
_session_id = None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def get_session_id(cls):
|
|
33
|
+
"""Get or create a session ID for this Python session"""
|
|
34
|
+
if cls._session_id is None:
|
|
35
|
+
cls._session_id = str(uuid4())
|
|
36
|
+
return cls._session_id
|
|
37
|
+
|
|
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 ):
|
|
56
|
+
logger.debug(f"\tBokehAppContext::__init__(ui={type(ui).__name__ if ui else None}, {kwargs}): {id(self)}")
|
|
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
|
+
|
|
66
|
+
if ui is not None and 'ui' in kwargs:
|
|
67
|
+
raise RuntimeError( "'ui' supplied as both a positional parameter and a keyword parameter" )
|
|
68
|
+
|
|
69
|
+
kwargs['session_id'] = self.get_session_id( )
|
|
70
|
+
|
|
71
|
+
if 'ui' not in kwargs:
|
|
72
|
+
kwargs['ui'] = ui
|
|
73
|
+
if 'app_id' not in kwargs:
|
|
74
|
+
kwargs['app_id'] = str(uuid4())
|
|
75
|
+
|
|
76
|
+
super().__init__(**kwargs)
|
|
77
|
+
|
|
78
|
+
def _sphinx_height_hint(self):
|
|
79
|
+
"""Delegate height hint to the wrapped UI element"""
|
|
80
|
+
logger.debug(f"\tShowable::_sphinx_height_hint(): {id(self)}")
|
|
81
|
+
if self.ui and hasattr(self.ui, '_sphinx_height_hint'):
|
|
82
|
+
return self.ui._sphinx_height_hint()
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def update_app_state(self, state_updates):
|
|
86
|
+
"""
|
|
87
|
+
Update the application state (will be in the generated HTML/JS)
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
state_updates: dict of state key-value pairs to update
|
|
91
|
+
"""
|
|
92
|
+
current_state = dict(self.app_state)
|
|
93
|
+
current_state.update(state_updates)
|
|
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))
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from bokeh.models.layouts import LayoutDOM
|
|
3
3
|
from bokeh.models.ui import UIElement
|
|
4
|
-
from bokeh.core.properties import Instance
|
|
4
|
+
from bokeh.core.properties import Instance, String
|
|
5
|
+
|
|
5
6
|
from bokeh.io import curdoc
|
|
6
7
|
from .. import BokehInit
|
|
7
8
|
|
|
@@ -9,12 +10,20 @@ logger = logging.getLogger(__name__)
|
|
|
9
10
|
|
|
10
11
|
class Showable(LayoutDOM,BokehInit):
|
|
11
12
|
"""Wrap a UIElement to make any Bokeh UI component showable with show()
|
|
12
|
-
|
|
13
|
+
|
|
13
14
|
This class works by acting as a simple container that delegates to its UI element.
|
|
14
15
|
For Jupyter notebook display, use show(showable) - automatic display via _repr_mimebundle_
|
|
15
16
|
is not reliably supported by Bokeh's architecture.
|
|
16
17
|
"""
|
|
17
18
|
|
|
19
|
+
### _usage_mode is needed to prevent mixing "bokeh.plotting.show(showable)" with
|
|
20
|
+
### "showable.show( )" or just evaluating "showable". This is required because the
|
|
21
|
+
### latter use "bokeh.embed.components" to create the HTML that is rendered while
|
|
22
|
+
### "bokeh.plotting.show(showable)" uses internal Bokeh rendering that is
|
|
23
|
+
### incompatable with "bokeh.embed.components" usage. For this reason, the user
|
|
24
|
+
### can use either one, but not both.
|
|
25
|
+
_usage_mode = None
|
|
26
|
+
|
|
18
27
|
@property
|
|
19
28
|
def document(self):
|
|
20
29
|
"""Get the document this model is attached to."""
|
|
@@ -26,58 +35,52 @@ class Showable(LayoutDOM,BokehInit):
|
|
|
26
35
|
Intercept when Bokeh tries to attach us to a document.
|
|
27
36
|
This is called by bokeh.plotting.show() when it adds us to a document.
|
|
28
37
|
"""
|
|
29
|
-
from bokeh.io.state import curstate
|
|
30
|
-
import traceback
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
def get_caller_class_name(frame):
|
|
40
|
+
"""Attempt to find the name of the class the calling method belongs to."""
|
|
41
|
+
# Check if 'self' is in the caller's local variables (conventional for instance methods)
|
|
42
|
+
if 'self' in frame.f_locals:
|
|
43
|
+
return frame.f_locals['self'].__class__.__name__
|
|
44
|
+
# Check for 'cls' (conventional for class methods)
|
|
45
|
+
elif 'cls' in frame.f_locals:
|
|
46
|
+
return frame.f_locals['cls'].__name__
|
|
47
|
+
else:
|
|
48
|
+
# It might be a regular function or static method without explicit 'self'/'cls'
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
# Allow None (detaching from document) without any further checking
|
|
33
52
|
if doc is None:
|
|
34
53
|
self._document = None
|
|
35
54
|
return
|
|
36
55
|
|
|
37
|
-
state
|
|
38
|
-
|
|
39
|
-
# Check calling context
|
|
40
|
-
stack = traceback.extract_stack()
|
|
41
|
-
|
|
42
|
-
# Detect if called from bokeh.plotting.show or bokeh.io.show
|
|
43
|
-
called_from_bokeh_show = any(
|
|
44
|
-
('bokeh/io/' in frame.filename or 'bokeh\\io\\' in frame.filename or
|
|
45
|
-
'bokeh/plotting/' in frame.filename or 'bokeh\\plotting\\' in frame.filename)
|
|
46
|
-
for frame in stack[:-2] # Exclude the last 2 frames (this setter and __setattr__)
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
# Check if called from our own methods
|
|
50
|
-
called_from_our_methods = any(
|
|
51
|
-
'Showable' in str(frame.line) or frame.filename.endswith('showable.py')
|
|
52
|
-
for frame in stack[-5:-2] # Check recent frames
|
|
53
|
-
)
|
|
56
|
+
from bokeh.io.state import curstate
|
|
57
|
+
state = curstate( )
|
|
54
58
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
f"Please use one of these methods instead:\n"
|
|
60
|
-
f" • my_showable.show() # Custom show method\n"
|
|
61
|
-
f" • my_showable # Automatic display (evaluate in cell)\n\n"
|
|
62
|
-
f"Reason: bokeh.plotting.show() doesn't properly handle the custom\n"
|
|
63
|
-
f"sizing and backend requirements of Showable objects.\n"
|
|
64
|
-
f"{'='*70}\n"
|
|
65
|
-
)
|
|
59
|
+
# Validate environment (only one OUTPUT mode)
|
|
60
|
+
active_modes = []
|
|
61
|
+
if state.file: active_modes.append('file')
|
|
62
|
+
if state.notebook: active_modes.append('notebook')
|
|
66
63
|
|
|
67
|
-
#
|
|
68
|
-
if
|
|
64
|
+
# only allow a single GUI to be displayed since there is a backend
|
|
65
|
+
# this could be relaxed if the backend can manage events from two
|
|
66
|
+
# different GUIs
|
|
67
|
+
if len(active_modes) > 1:
|
|
69
68
|
raise RuntimeError(
|
|
70
|
-
f"{self.__class__.__name__} can only be displayed in
|
|
71
|
-
f"
|
|
72
|
-
f" from bokeh.io import output_notebook\n"
|
|
73
|
-
f" output_notebook()"
|
|
69
|
+
f"{self.__class__.__name__} can only be displayed in a single Bokeh\n"
|
|
70
|
+
f"display mode. Either file or notebook, but not both."
|
|
74
71
|
)
|
|
75
72
|
|
|
76
|
-
#
|
|
77
|
-
|
|
73
|
+
# For notebook display, fixed sizing is required. This selects between the
|
|
74
|
+
# fixed, notebook dimensions and the default browser dimensions based on
|
|
75
|
+
# the Bokeh output that has been selected.
|
|
76
|
+
if 'notebook' in active_modes and self._display_config['notebook']['mode'] == 'fixed':
|
|
78
77
|
self.sizing_mode = None
|
|
79
|
-
self.width = self.
|
|
80
|
-
self.height = self.
|
|
78
|
+
self.width = self._display_config['notebook']['width']
|
|
79
|
+
self.height = self._display_config['notebook']['height']
|
|
80
|
+
else:
|
|
81
|
+
self.sizing_mode = self._display_config['browser']['mode']
|
|
82
|
+
self.width = self._display_config['browser']['width']
|
|
83
|
+
self.height = self._display_config['browser']['height']
|
|
81
84
|
|
|
82
85
|
# Now set the document
|
|
83
86
|
self._document = doc
|
|
@@ -87,12 +90,23 @@ class Showable(LayoutDOM,BokehInit):
|
|
|
87
90
|
self._start_backend()
|
|
88
91
|
self._backend_started = True
|
|
89
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
|
+
|
|
90
100
|
def __init__( self, ui_element=None, backend_func=None,
|
|
91
101
|
result_retrieval=None,
|
|
92
102
|
notebook_width=1200, notebook_height=800,
|
|
93
|
-
notebook_sizing='fixed',
|
|
103
|
+
notebook_sizing='fixed',
|
|
104
|
+
display_context=None,
|
|
105
|
+
**kwargs ):
|
|
94
106
|
logger.debug(f"\tShowable::__init__(ui_element={type(ui_element).__name__ if ui_element else None}, {kwargs}): {id(self)}")
|
|
95
|
-
|
|
107
|
+
|
|
108
|
+
self._display_context = display_context
|
|
109
|
+
|
|
96
110
|
# Set default sizing if not provided
|
|
97
111
|
sizing_params = {'sizing_mode', 'width', 'height'}
|
|
98
112
|
provided_sizing_params = set(kwargs.keys()) & sizing_params
|
|
@@ -102,11 +116,20 @@ class Showable(LayoutDOM,BokehInit):
|
|
|
102
116
|
# CRITICAL FIX: Don't call _ensure_in_document during __init__
|
|
103
117
|
# Let Bokeh handle document management through the normal flow
|
|
104
118
|
super().__init__(**kwargs)
|
|
105
|
-
|
|
119
|
+
|
|
106
120
|
# Set the UI element
|
|
107
121
|
if ui_element is not None:
|
|
108
122
|
self.ui = ui_element
|
|
109
123
|
|
|
124
|
+
# Keep track of defaults based on display mode
|
|
125
|
+
### self._notebook_width = notebook_width
|
|
126
|
+
### self._notebook_height = notebook_height
|
|
127
|
+
### self._notebook_sizing = notebook_sizing # 'fixed' or 'stretch'
|
|
128
|
+
self._display_config = {
|
|
129
|
+
'notebook': { 'mode': notebook_sizing, 'width': notebook_width, 'height': notebook_height },
|
|
130
|
+
'browser': { 'mode': self.sizing_mode, 'width': self.width, 'height': self.height }
|
|
131
|
+
}
|
|
132
|
+
|
|
110
133
|
# Set the function to be called upon display
|
|
111
134
|
if backend_func is not None:
|
|
112
135
|
self._backend_startup_callback = backend_func
|
|
@@ -114,14 +137,35 @@ class Showable(LayoutDOM,BokehInit):
|
|
|
114
137
|
# result (if one is/will be available)...
|
|
115
138
|
self._result_retrieval = result_retrieval
|
|
116
139
|
|
|
117
|
-
self._notebook_width = notebook_width
|
|
118
|
-
self._notebook_height = notebook_height
|
|
119
|
-
self._notebook_sizing = notebook_sizing # 'fixed' or 'stretch'
|
|
120
140
|
self._notebook_rendering = None
|
|
121
141
|
|
|
122
142
|
ui = Instance(UIElement, help="""
|
|
123
143
|
A UI element, which can be plots, layouts, widgets, or any other UIElement.
|
|
124
144
|
""")
|
|
145
|
+
###
|
|
146
|
+
### when 'disabled' is set to true, this message should be displayed over
|
|
147
|
+
### a grey-obscured GUI...
|
|
148
|
+
###
|
|
149
|
+
### May need to adjust message formatting to allow for more flexiblity,
|
|
150
|
+
### i.e. all of the message does not need to be in big green print. May
|
|
151
|
+
### need to allow something like:
|
|
152
|
+
###
|
|
153
|
+
### <div style="font-size: 24px; font-weight: bold; color: #4CAF50; margin-bottom: 10px;">
|
|
154
|
+
### Interaction Complete ✓
|
|
155
|
+
### </div>
|
|
156
|
+
### <div style="font-size: 14px; color: #666;">
|
|
157
|
+
### You can now close this GUI or continue working in your notebook
|
|
158
|
+
### </div>
|
|
159
|
+
###
|
|
160
|
+
### currently the message is displayed in showable.ts like:
|
|
161
|
+
###
|
|
162
|
+
### <div style="font-size: 24px; font-weight: bold; color: #4CAF50; margin-bottom: 10px;">
|
|
163
|
+
### ${this.model.disabled_message}
|
|
164
|
+
### </div>
|
|
165
|
+
###
|
|
166
|
+
disabled_message = String(default="Interaction Complete ✓", help="""
|
|
167
|
+
Message to show when disabled
|
|
168
|
+
""")
|
|
125
169
|
|
|
126
170
|
# FIXED: Remove the children property override
|
|
127
171
|
# Let LayoutDOM handle its own children management
|
|
@@ -138,7 +182,7 @@ class Showable(LayoutDOM,BokehInit):
|
|
|
138
182
|
"""Ensure this Showable is in the current document"""
|
|
139
183
|
from bokeh.io import curdoc
|
|
140
184
|
current_doc = curdoc()
|
|
141
|
-
|
|
185
|
+
|
|
142
186
|
# FIXED: More careful document management
|
|
143
187
|
# Only add to document if we're not already in the right one
|
|
144
188
|
if self.document is None:
|
|
@@ -239,6 +283,9 @@ class Showable(LayoutDOM,BokehInit):
|
|
|
239
283
|
if self.ui is None:
|
|
240
284
|
return '<div style="color: red; padding: 10px; border: 1px solid red;">Showable object with no UI set</div>'
|
|
241
285
|
|
|
286
|
+
if self._display_context:
|
|
287
|
+
self._display_context.on_show( )
|
|
288
|
+
|
|
242
289
|
if self._notebook_rendering:
|
|
243
290
|
# Return a lightweight reference instead of re-rendering the full GUI
|
|
244
291
|
return f'''
|
|
@@ -251,10 +298,10 @@ class Showable(LayoutDOM,BokehInit):
|
|
|
251
298
|
'''
|
|
252
299
|
|
|
253
300
|
# Apply notebook sizing for Jupyter context
|
|
254
|
-
if self._notebook_sizing == 'fixed':
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
301
|
+
###if self._notebook_sizing == 'fixed':
|
|
302
|
+
### self.sizing_mode = None
|
|
303
|
+
### self.width = self._notebook_width
|
|
304
|
+
### self.height = self._notebook_height
|
|
258
305
|
|
|
259
306
|
script, div = components(self)
|
|
260
307
|
if start_backend:
|
|
@@ -263,32 +310,6 @@ class Showable(LayoutDOM,BokehInit):
|
|
|
263
310
|
self._notebook_rendering = f'{script}\n{div}'
|
|
264
311
|
return self._notebook_rendering
|
|
265
312
|
|
|
266
|
-
def _repr_html_(self, start_backend=True):
|
|
267
|
-
"""
|
|
268
|
-
HTML representation for Jupyter display.
|
|
269
|
-
|
|
270
|
-
Note: Bokeh doesn't reliably support automatic display via _repr_mimebundle_.
|
|
271
|
-
This provides a helpful message directing users to use show().
|
|
272
|
-
"""
|
|
273
|
-
html = self._get_notebook_html(start_backend)
|
|
274
|
-
|
|
275
|
-
if html is not None:
|
|
276
|
-
return html
|
|
277
|
-
|
|
278
|
-
# Not in notebook environment
|
|
279
|
-
return f"<!-- error: non-notebook environment{' in ' + self.name if self.name else ''} -->" + '''
|
|
280
|
-
<div style="padding: 15px; border: 2px solid #4CAF50; border-radius: 5px; background: #f9fff9; margin: 10px 0;">
|
|
281
|
-
<strong>📊 Showable Widget Ready</strong><br>
|
|
282
|
-
<em>Notebook display is not enabled, run:</em>
|
|
283
|
-
<p><pre>
|
|
284
|
-
from bokeh.io import output_notebook
|
|
285
|
-
output_notebook()</pre>
|
|
286
|
-
<p><em>and try again.</em>
|
|
287
|
-
<hr>
|
|
288
|
-
<small>Contains: {}</small>
|
|
289
|
-
</div>
|
|
290
|
-
'''.format(type(self.ui).__name__ if self.ui else "None")
|
|
291
|
-
|
|
292
313
|
def _repr_mimebundle_(self, include=None, exclude=None):
|
|
293
314
|
"""
|
|
294
315
|
MIME bundle representation for Jupyter display.
|
|
@@ -301,6 +322,8 @@ output_notebook()</pre>
|
|
|
301
322
|
- Bokeh show(showable)
|
|
302
323
|
- showable.show()
|
|
303
324
|
"""
|
|
325
|
+
if self.__class__._usage_mode is None:
|
|
326
|
+
self.__class__._usage_mode = "custom"
|
|
304
327
|
from bokeh.io.state import curstate
|
|
305
328
|
|
|
306
329
|
state = curstate()
|
|
@@ -311,13 +334,16 @@ output_notebook()</pre>
|
|
|
311
334
|
return {
|
|
312
335
|
'text/html': html
|
|
313
336
|
}
|
|
314
|
-
|
|
337
|
+
|
|
315
338
|
# Fall back to default Bokeh behavior for non-notebook environments
|
|
316
339
|
# Return None to let Bokeh handle it
|
|
317
340
|
return None
|
|
318
341
|
|
|
319
342
|
def show(self, start_backend=True):
|
|
320
343
|
"""Explicitly show this Showable using inline display in Jupyter"""
|
|
344
|
+
if self.__class__._usage_mode is None:
|
|
345
|
+
self.__class__._usage_mode = "custom"
|
|
346
|
+
|
|
321
347
|
from bokeh.io.state import curstate
|
|
322
348
|
|
|
323
349
|
self._ensure_in_document()
|
|
@@ -327,7 +353,7 @@ output_notebook()</pre>
|
|
|
327
353
|
if state.notebook:
|
|
328
354
|
# In Jupyter, display directly using IPython.display
|
|
329
355
|
from IPython.display import display, HTML
|
|
330
|
-
|
|
356
|
+
|
|
331
357
|
html = self._get_notebook_html(start_backend)
|
|
332
358
|
if html:
|
|
333
359
|
display(HTML(html))
|
|
@@ -36,13 +36,14 @@ import asyncio
|
|
|
36
36
|
import traceback
|
|
37
37
|
import time
|
|
38
38
|
import json
|
|
39
|
+
from uuid import uuid4
|
|
39
40
|
|
|
40
41
|
from bokeh.models.sources import DataSource
|
|
41
42
|
from bokeh.util.compiler import TypeScript
|
|
42
|
-
from bokeh.core.properties import Tuple, String, Int, Instance, Nullable
|
|
43
|
+
from bokeh.core.properties import Tuple, String, Int, Instance, Nullable, Bool
|
|
43
44
|
from bokeh.models.callbacks import Callback
|
|
44
45
|
|
|
45
|
-
from ...utils import serialize, deserialize
|
|
46
|
+
from ...utils import serialize, deserialize, is_interactive_jupyter
|
|
46
47
|
from ..state import casalib_url, cubevisjs_url
|
|
47
48
|
from .. import BokehInit
|
|
48
49
|
|
|
@@ -72,6 +73,10 @@ class DataPipe(DataSource,BokehInit):
|
|
|
72
73
|
|
|
73
74
|
address = Tuple( String, Int, help="two integer sequence representing the address and port to use for the websocket" )
|
|
74
75
|
|
|
76
|
+
instance_id = String( help="Unique ID for each DataPipe object" )
|
|
77
|
+
|
|
78
|
+
conflict_check = Bool( default=True, help="Perform check to avoid reuse of URL for GUI. Not needed in the Jupyter context" )
|
|
79
|
+
|
|
75
80
|
# Class-level session tracking to prevent multiple connections
|
|
76
81
|
_active_sessions = {} # session_id -> {'websocket': ws, 'timestamp': time, 'datapipe': instance}
|
|
77
82
|
_session_lock = threading.Lock()
|
|
@@ -82,7 +87,14 @@ class DataPipe(DataSource,BokehInit):
|
|
|
82
87
|
#__javascript__ = [ casalib_url( ), cubevisjs_url( ) ]
|
|
83
88
|
|
|
84
89
|
def __init__( self, *args, abort=None, **kwargs ):
|
|
90
|
+
|
|
91
|
+
if 'conflict_check' not in kwargs:
|
|
92
|
+
kwargs['conflict_check'] = not is_interactive_jupyter( )
|
|
93
|
+
if 'instance_id' not in kwargs:
|
|
94
|
+
kwargs['instance_id'] = str(uuid4( ))
|
|
95
|
+
|
|
85
96
|
super( ).__init__( *args, **kwargs )
|
|
97
|
+
|
|
86
98
|
self.__send_queue = { }
|
|
87
99
|
self.__pending = { }
|
|
88
100
|
self.__incoming_callbacks = { }
|
cubevis/exe/_task.py
CHANGED
|
@@ -67,31 +67,53 @@ class Task:
|
|
|
67
67
|
|
|
68
68
|
return threading_event
|
|
69
69
|
|
|
70
|
+
def _run_coroutine_sync(self, coro):
|
|
71
|
+
"""
|
|
72
|
+
Helper to run a coroutine synchronously, handling both CLI and Jupyter contexts.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
# Check if there's already a running event loop (Jupyter/IPython)
|
|
76
|
+
loop = asyncio.get_running_loop()
|
|
77
|
+
except RuntimeError:
|
|
78
|
+
# No running loop - safe to use asyncio.run() (CLI context)
|
|
79
|
+
return asyncio.run(coro)
|
|
80
|
+
else:
|
|
81
|
+
# Running loop exists (Jupyter context)
|
|
82
|
+
try:
|
|
83
|
+
import nest_asyncio
|
|
84
|
+
nest_asyncio.apply()
|
|
85
|
+
return asyncio.run(coro)
|
|
86
|
+
except ImportError:
|
|
87
|
+
# Fallback: run in a new thread with its own event loop
|
|
88
|
+
import concurrent.futures
|
|
89
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
90
|
+
return pool.submit(asyncio.run, coro).result()
|
|
91
|
+
|
|
70
92
|
def run_sync(self) -> Any:
|
|
71
93
|
"""Run synchronously until completion or stop signal."""
|
|
72
94
|
if self.stop_condition is None:
|
|
73
95
|
# Handle async functions in sync context
|
|
74
96
|
if asyncio.iscoroutinefunction(self.server_func):
|
|
75
|
-
logger.debug(
|
|
76
|
-
return
|
|
97
|
+
logger.debug(f"\tTask::run_sync( ): {id(self)} - asyncio no stop condition {self.server_func}")
|
|
98
|
+
return self._run_coroutine_sync(self.server_func(*self.args, **self.kwargs))
|
|
77
99
|
else:
|
|
78
|
-
logger.debug(
|
|
100
|
+
logger.debug(f"\tTask::run_sync( ): {id(self)} - no stop condition {self.server_func}")
|
|
79
101
|
return self.server_func(*self.args, **self.kwargs)
|
|
80
102
|
|
|
81
103
|
# Handle asyncio.Event in sync context by converting to threading.Event
|
|
82
104
|
stop_condition = self.stop_condition
|
|
83
105
|
if isinstance(self.stop_condition, asyncio.Event):
|
|
84
|
-
logger.debug(
|
|
106
|
+
logger.debug(f"\tTask::run_sync( ): {id(self)} - asyncio bridge {self.server_func}")
|
|
85
107
|
# Convert asyncio.Event to threading.Event for sync execution
|
|
86
108
|
stop_condition = self._convert_asyncio_event_to_threading(self.stop_condition)
|
|
87
|
-
|
|
109
|
+
|
|
88
110
|
if callable(stop_condition):
|
|
89
111
|
# Poll-based stopping
|
|
90
|
-
logger.debug(
|
|
112
|
+
logger.debug(f"\tTask::run_sync( ): {id(self)} - polling {self.server_func}")
|
|
91
113
|
while not stop_condition():
|
|
92
114
|
try:
|
|
93
115
|
if asyncio.iscoroutinefunction(self.server_func):
|
|
94
|
-
result =
|
|
116
|
+
result = self._run_coroutine_sync(self.server_func(*self.args, **self.kwargs))
|
|
95
117
|
else:
|
|
96
118
|
result = self.server_func(*self.args, **self.kwargs)
|
|
97
119
|
if result is not None: # Server function completed
|
|
@@ -102,12 +124,12 @@ class Task:
|
|
|
102
124
|
|
|
103
125
|
elif isinstance(stop_condition, threading.Event):
|
|
104
126
|
# Event-based stopping for threads
|
|
105
|
-
logger.debug(
|
|
127
|
+
logger.debug(f"\tTask::run_sync( ): {id(self)} - threading event {self.server_func}")
|
|
106
128
|
def run_with_check():
|
|
107
129
|
while not stop_condition.is_set():
|
|
108
130
|
try:
|
|
109
131
|
if asyncio.iscoroutinefunction(self.server_func):
|
|
110
|
-
result =
|
|
132
|
+
result = self._run_coroutine_sync(self.server_func(*self.args, **self.kwargs))
|
|
111
133
|
else:
|
|
112
134
|
result = self.server_func(*self.args, **self.kwargs)
|
|
113
135
|
if result is not None:
|
|
@@ -118,26 +140,25 @@ class Task:
|
|
|
118
140
|
return run_with_check()
|
|
119
141
|
|
|
120
142
|
elif isinstance(stop_condition, threading.Condition):
|
|
121
|
-
logger.debug(
|
|
143
|
+
logger.debug(f"\tTask::run_sync( ): {id(self)} - threading condition {self.server_func}")
|
|
122
144
|
# Condition-based stopping - most flexible and efficient
|
|
123
145
|
def run_with_condition():
|
|
124
146
|
with stop_condition:
|
|
125
147
|
while True:
|
|
126
|
-
# Check if we should stop (condition should have a flag or predicate)
|
|
127
|
-
# The condition object should be used by external code to signal stopping
|
|
128
|
-
# We'll pass the condition to the server function so it can wait efficiently
|
|
129
148
|
try:
|
|
130
149
|
# Pass the condition to the server function if it accepts it
|
|
131
150
|
import inspect
|
|
132
151
|
sig = inspect.signature(self.server_func)
|
|
133
152
|
if 'stop_condition' in sig.parameters:
|
|
134
153
|
if asyncio.iscoroutinefunction(self.server_func):
|
|
135
|
-
result =
|
|
154
|
+
result = self._run_coroutine_sync(
|
|
155
|
+
self.server_func(*self.args, stop_condition=stop_condition, **self.kwargs)
|
|
156
|
+
)
|
|
136
157
|
else:
|
|
137
158
|
result = self.server_func(*self.args, stop_condition=stop_condition, **self.kwargs)
|
|
138
159
|
else:
|
|
139
160
|
if asyncio.iscoroutinefunction(self.server_func):
|
|
140
|
-
result =
|
|
161
|
+
result = self._run_coroutine_sync(self.server_func(*self.args, **self.kwargs))
|
|
141
162
|
else:
|
|
142
163
|
result = self.server_func(*self.args, **self.kwargs)
|
|
143
164
|
|
|
@@ -227,4 +248,3 @@ class Task:
|
|
|
227
248
|
# Sync function in async context - run in thread pool
|
|
228
249
|
loop = asyncio.get_event_loop()
|
|
229
250
|
return await loop.run_in_executor(None, self.run_sync)
|
|
230
|
-
|