11import collections
22from functools import wraps
33
4+ import flask
5+
46from .dependencies import (
57 handle_callback_args ,
68 handle_grouped_callback_args ,
79 Output ,
810)
9- from .exceptions import PreventUpdate
11+ from .exceptions import PreventUpdate , WildcardInLongCallback , DuplicateCallback
1012
1113from ._grouping import (
1214 flatten_grouping ,
1719 create_callback_id ,
1820 stringify_id ,
1921 to_json ,
22+ coerce_to_list ,
2023)
2124
2225from . import _validate
26+ from .long_callback .managers import BaseLongCallbackManager
2327
2428
2529class NoUpdate :
@@ -30,15 +34,28 @@ def to_plotly_json(self): # pylint: disable=no-self-use
3034
3135 @staticmethod
3236 def is_no_update (obj ):
33- return obj == {"_dash_no_update" : "_dash_no_update" }
37+ return isinstance (obj , NoUpdate ) or obj == {
38+ "_dash_no_update" : "_dash_no_update"
39+ }
3440
3541
3642GLOBAL_CALLBACK_LIST = []
3743GLOBAL_CALLBACK_MAP = {}
3844GLOBAL_INLINE_SCRIPTS = []
3945
4046
41- def callback (* _args , ** _kwargs ):
47+ def callback (
48+ * _args ,
49+ long = False ,
50+ long_interval = 1000 ,
51+ long_progress = None ,
52+ long_progress_default = None ,
53+ long_running = None ,
54+ long_cancel = None ,
55+ long_manager = None ,
56+ long_cache_args_to_ignore = None ,
57+ ** _kwargs ,
58+ ):
4259 """
4360 Normally used as a decorator, `@dash.callback` provides a server-side
4461 callback relating the values of one or more `Output` items to one or
@@ -56,15 +73,79 @@ def callback(*_args, **_kwargs):
5673 not to fire when its outputs are first added to the page. Defaults to
5774 `False` and unlike `app.callback` is not configurable at the app level.
5875 """
76+
77+ long_spec = None
78+
79+ if long :
80+ long_spec = {
81+ "interval" : long_interval ,
82+ }
83+
84+ if long_manager :
85+ long_spec ["manager" ] = long_manager
86+
87+ if long_progress :
88+ long_spec ["progress" ] = coerce_to_list (long_progress )
89+ validate_long_inputs (long_spec ["progress" ])
90+
91+ if long_progress_default :
92+ long_spec ["progressDefault" ] = coerce_to_list (long_progress_default )
93+
94+ if not len (long_spec ["progress" ]) == len (long_spec ["progressDefault" ]):
95+ raise Exception (
96+ "Progress and progress default needs to be of same length"
97+ )
98+
99+ if long_running :
100+ long_spec ["running" ] = coerce_to_list (long_running )
101+ validate_long_inputs (x [0 ] for x in long_spec ["running" ])
102+
103+ if long_cancel :
104+ cancel_inputs = coerce_to_list (long_cancel )
105+ validate_long_inputs (cancel_inputs )
106+
107+ cancels_output = [Output (c .component_id , "id" ) for c in cancel_inputs ]
108+
109+ try :
110+
111+ @callback (cancels_output , cancel_inputs , prevent_initial_call = True )
112+ def cancel_call (* _ ):
113+ job_ids = flask .request .args .getlist ("cancelJob" )
114+ manager = long_manager or flask .g .long_callback_manager
115+ if job_ids :
116+ for job_id in job_ids :
117+ manager .terminate_job (int (job_id ))
118+ return NoUpdate ()
119+
120+ except DuplicateCallback :
121+ pass # Already a callback to cancel, will get the proper jobs from the store.
122+
123+ long_spec ["cancel" ] = [c .to_dict () for c in cancel_inputs ]
124+
125+ if long_cache_args_to_ignore :
126+ long_spec ["cache_args_to_ignore" ] = long_cache_args_to_ignore
127+
59128 return register_callback (
60129 GLOBAL_CALLBACK_LIST ,
61130 GLOBAL_CALLBACK_MAP ,
62131 False ,
63132 * _args ,
64133 ** _kwargs ,
134+ long = long_spec ,
65135 )
66136
67137
138+ def validate_long_inputs (deps ):
139+ for dep in deps :
140+ if dep .has_wildcard ():
141+ raise WildcardInLongCallback (
142+ f"""
143+ long callbacks does not support dependencies with
144+ pattern-matching ids
145+ Received: { repr (dep )} \n """
146+ )
147+
148+
68149def clientside_callback (clientside_function , * args , ** kwargs ):
69150 return register_clientside_callback (
70151 GLOBAL_CALLBACK_LIST ,
@@ -87,6 +168,7 @@ def insert_callback(
87168 state ,
88169 inputs_state_indices ,
89170 prevent_initial_call ,
171+ long = None ,
90172):
91173 if prevent_initial_call is None :
92174 prevent_initial_call = config_prevent_initial_callbacks
@@ -98,19 +180,26 @@ def insert_callback(
98180 "state" : [c .to_dict () for c in state ],
99181 "clientside_function" : None ,
100182 "prevent_initial_call" : prevent_initial_call ,
183+ "long" : long
184+ and {
185+ "interval" : long ["interval" ],
186+ },
101187 }
188+
102189 callback_map [callback_id ] = {
103190 "inputs" : callback_spec ["inputs" ],
104191 "state" : callback_spec ["state" ],
105192 "outputs_indices" : outputs_indices ,
106193 "inputs_state_indices" : inputs_state_indices ,
194+ "long" : long ,
107195 }
108196 callback_list .append (callback_spec )
109197
110198 return callback_id
111199
112200
113- def register_callback (
201+ # pylint: disable=R0912
202+ def register_callback ( # pylint: disable=R0914
114203 callback_list , callback_map , config_prevent_initial_callbacks , * _args , ** _kwargs
115204):
116205 (
@@ -129,6 +218,8 @@ def register_callback(
129218 insert_output = flatten_grouping (output )
130219 multi = True
131220
221+ long = _kwargs .get ("long" )
222+
132223 output_indices = make_grouping_by_index (output , list (range (grouping_len (output ))))
133224 callback_id = insert_callback (
134225 callback_list ,
@@ -140,23 +231,118 @@ def register_callback(
140231 flat_state ,
141232 inputs_state_indices ,
142233 prevent_initial_call ,
234+ long = long ,
143235 )
144236
145237 # pylint: disable=too-many-locals
146238 def wrap_func (func ):
239+
240+ if long is not None :
241+ long_key = BaseLongCallbackManager .register_func (
242+ func , long .get ("progress" ) is not None
243+ )
244+
147245 @wraps (func )
148246 def add_context (* args , ** kwargs ):
149247 output_spec = kwargs .pop ("outputs_list" )
248+ callback_manager = long .get (
249+ "manager" , kwargs .pop ("long_callback_manager" , None )
250+ )
150251 _validate .validate_output_spec (insert_output , output_spec , Output )
151252
152253 func_args , func_kwargs = _validate .validate_and_group_input_args (
153254 args , inputs_state_indices
154255 )
155256
156- # don't touch the comment on the next line - used by debugger
157- output_value = func (* func_args , ** func_kwargs ) # %% callback invoked %%
257+ response = {"multi" : True }
258+
259+ if long is not None :
260+ progress_outputs = long .get ("progress" )
261+ cache_key = flask .request .args .get ("cacheKey" )
262+ job_id = flask .request .args .get ("job" )
263+
264+ current_key = callback_manager .build_cache_key (
265+ func ,
266+ # Inputs provided as dict is kwargs.
267+ func_args if func_args else func_kwargs ,
268+ long .get ("cache_args_to_ignore" , []),
269+ )
270+
271+ if not cache_key :
272+ cache_key = current_key
273+
274+ job_fn = callback_manager .func_registry .get (long_key )
275+
276+ job = callback_manager .call_job_fn (
277+ cache_key ,
278+ job_fn ,
279+ args ,
280+ )
281+
282+ data = {
283+ "cacheKey" : cache_key ,
284+ "job" : job ,
285+ }
286+
287+ running = long .get ("running" )
288+
289+ if running :
290+ data ["running" ] = {str (r [0 ]): r [1 ] for r in running }
291+ data ["runningOff" ] = {str (r [0 ]): r [2 ] for r in running }
292+ cancel = long .get ("cancel" )
293+ if cancel :
294+ data ["cancel" ] = cancel
295+
296+ progress_default = long .get ("progressDefault" )
297+ if progress_default :
298+ data ["progressDefault" ] = {
299+ str (o ): x
300+ for o , x in zip (progress_outputs , progress_default )
301+ }
302+ return to_json (data )
303+ else :
304+ if progress_outputs :
305+ # Get the progress before the result as it would be erased after the results.
306+ progress = callback_manager .get_progress (cache_key )
307+ if progress :
308+ response ["progress" ] = {
309+ str (x ): progress [i ]
310+ for i , x in enumerate (progress_outputs )
311+ }
312+
313+ output_value = callback_manager .get_result (cache_key , job_id )
314+ # Must get job_running after get_result since get_results terminates it.
315+ job_running = callback_manager .job_running (job_id )
316+ if not job_running and output_value is callback_manager .UNDEFINED :
317+ # Job canceled -> no output to close the loop.
318+ output_value = NoUpdate ()
319+
320+ elif (
321+ isinstance (output_value , dict )
322+ and "long_callback_error" in output_value
323+ ):
324+ error = output_value .get ("long_callback_error" )
325+ raise Exception (
326+ f"An error occurred inside a long callback: { error ['msg' ]} \n { error ['tb' ]} "
327+ )
328+
329+ if job_running and output_value is not callback_manager .UNDEFINED :
330+ # cached results.
331+ callback_manager .terminate_job (job_id )
332+
333+ if multi and isinstance (output_value , (list , tuple )):
334+ output_value = [
335+ NoUpdate () if NoUpdate .is_no_update (r ) else r
336+ for r in output_value
337+ ]
338+
339+ if output_value is callback_manager .UNDEFINED :
340+ return to_json (response )
341+ else :
342+ # don't touch the comment on the next line - used by debugger
343+ output_value = func (* func_args , ** func_kwargs ) # %% callback invoked %%
158344
159- if isinstance (output_value , NoUpdate ):
345+ if NoUpdate . is_no_update (output_value ):
160346 raise PreventUpdate
161347
162348 if not multi :
@@ -191,7 +377,7 @@ def add_context(*args, **kwargs):
191377 if not has_update :
192378 raise PreventUpdate
193379
194- response = { "response" : component_ids , "multi" : True }
380+ response [ "response" ] = component_ids
195381
196382 try :
197383 jsonResponse = to_json (response )
0 commit comments