I have a long_callback
that uses the progress
named argument to send updates as the function runs. My progress-updating function is called set_progress
. However, the final call to set_progress
is not realized in the client/browser when the callback finishes.
The final Output
of the callback is shown, as well as a print
statement being visible on stdout even though the statement is after the last call to set_progress
.
Interestingly enough, if I comment out the callback’s Output
and the final return statement, then the effects of the final set_progress
can be seen, but the page’s title keeps flashing that it’s loading/updating, which to me means the callback hasn’t registered as “complete” yet.
This leads me to suspect that the “completion” of the callback is happening too fast, or too soon after the last set_progress
call, which is somehow nullifying its effects. If I have the Output
added back in and add a time.sleep(1)
just before the return statement, then the effects of the final progress update can be seen. However, this is a hacky solution and should not be needed in the first place.
Environment
OS
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="20.04.3 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.3 LTS"
VERSION_ID="20.04"
...
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal
Python version
$ python --version
Python 3.9.6
Dash packages
$ python -m pip list | grep dash
dash 2.0.0
dash-bootstrap-components 0.13.1
dash-core-components 2.0.0
dash-html-components 2.0.0
dash-table 5.0.0
Relevant .py files
app.py
import dash
import dash_bootstrap_components as dbc
from website.layout_main import define_callbacks, layout
from website.long_callback_manager import LONG_CALLBACK_MANAGER
app = dash.Dash(
__name__,
update_title="Loading...",
external_stylesheets=[
dbc.themes.BOOTSTRAP,
"https://codepen.io/chriddyp/pen/bWLwgP.css"
],
long_callback_manager=LONG_CALLBACK_MANAGER
)
app.title = "CS 236 | Project Submissions"
app.layout = layout
define_callbacks(app)
server = app.server # expose for gunicorn
if __name__ == "__main__":
app.run_server(debug=True, host="0.0.0.0")
website/long_callback_manager.py
import os
import shutil
import diskcache
from dash.long_callback import DiskcacheLongCallbackManager
from util import RUN_DIR
cache_dir = os.path.join(RUN_DIR, "callback_cache")
shutil.rmtree(cache_dir, ignore_errors=True) # ok if it didn't exist
cache = diskcache.Cache(cache_dir)
LONG_CALLBACK_MANAGER = DiskcacheLongCallbackManager(cache)
website/layout_main.py
from typing import Union
import dash
import dash_bootstrap_components as dbc
from dash import dcc, html
from dash.dependencies import Input, Output, State
from util.authenticator import authenticate
from website import ID_LOGIN_STORE, NET_ID, PASSWORD
from website.tabs.config import define_config_callbacks, layout as config_layout
from website.tabs.log import define_log_callbacks, layout as log_layout
from website.tabs.submit import define_submit_callbacks, layout as submit_layout
from website.util import AUTH_FAILED_MESSAGE, STYLE_RED
# cache
LOGIN_INFO_EMPTY = {NET_ID: None, PASSWORD: None}
# button display modes
VISIBLE = "inline-block"
HIDDEN = "none"
# header
ID_LOGIN_BUTTON = "login-button"
ID_LOGGED_IN_AS = "logged-in-as"
ID_LOGOUT_BUTTON = "logout-button"
# tabs
ID_TAB_SELECTOR = "tab-selector"
ID_SUBMIT_TAB = "submit-tab"
ID_LOG_TAB = "log-tab"
ID_CONFIG_TAB = "config-tab"
# login modal
ID_LOGIN_MODAL = "login-modal"
ID_LOGIN_MODAL_NET_ID = "login-modal-net-id"
ID_LOGIN_MODAL_PASSWORD = "login-modal-password"
ID_LOGIN_MODAL_MESSAGE = "login-modal-message"
ID_LOGIN_MODAL_CANCEL = "login-modal-cancel"
ID_LOGIN_MODAL_ACCEPT = "login-modal-accept"
# logout modal
ID_LOGOUT_MODAL = "logout-modal"
ID_LOGOUT_MODAL_CANCEL = "logout-modal-cancel"
ID_LOGOUT_MODAL_ACCEPT = "logout-modal-accept"
layout = html.Div([
dcc.Store(id=ID_LOGIN_STORE, storage_type="session", data=LOGIN_INFO_EMPTY),
html.Div(
[
html.H2("BYU CS 236 - Project Submission Website", style={"marginLeft": "10px"}),
html.Div(
[
html.Div(id=ID_LOGGED_IN_AS, style={"display": HIDDEN, "marginRight": "10px"}),
html.Button("Log in", id=ID_LOGIN_BUTTON, style={"display": VISIBLE}),
html.Button("Log out", id=ID_LOGOUT_BUTTON, style={"display": HIDDEN})
],
style={
"marginRight": "25px",
"display": "flex",
"alignItems": "center"
}
)
],
style={
"height": "100px",
"marginLeft": "10px",
"marginRight": "10px",
"display": "flex",
"alignItems": "center",
"justifyContent": "space-between"
}
),
dcc.Tabs(id=ID_TAB_SELECTOR, value=ID_SUBMIT_TAB, children=[
dcc.Tab(submit_layout, label="New Submission", value=ID_SUBMIT_TAB),
dcc.Tab(log_layout, label="Submission Logs", value=ID_LOG_TAB),
dcc.Tab(config_layout, label="View Configuration", value=ID_CONFIG_TAB)
]),
dbc.Modal(
[
dbc.ModalHeader("Log In"),
dbc.ModalBody([
html.Div(
[
html.Label("BYU Net ID:", style={"marginRight": "10px"}),
dcc.Input(
id=ID_LOGIN_MODAL_NET_ID,
type="text",
autoComplete="username",
value="",
style={"marginRight": "30px"}
)
],
style={
"marginBottom": "5px",
"display": "flex",
"alignItems": "center",
"justifyContent": "flex-end"
}
),
html.Div(
[
html.Label("Submission Password:", style={"marginRight": "10px"}),
dcc.Input(
id=ID_LOGIN_MODAL_PASSWORD,
type="password",
autoComplete="current-password",
value="",
style={"marginRight": "30px"}
)
],
style={
"display": "flex",
"alignItems": "center",
"justifyContent": "flex-end"
}
),
html.Div(id=ID_LOGIN_MODAL_MESSAGE, style={"textAlign": "center", "marginTop": "10px"})
]),
dbc.ModalFooter([
html.Button("Cancel", id=ID_LOGIN_MODAL_CANCEL),
html.Button("Log In", id=ID_LOGIN_MODAL_ACCEPT)
])
],
id=ID_LOGIN_MODAL,
is_open=False
),
dbc.Modal(
[
dbc.ModalHeader("Log Out"),
dbc.ModalBody("Are you sure you want to log out?"),
dbc.ModalFooter([
html.Button("Stay Logged In", id=ID_LOGOUT_MODAL_CANCEL),
html.Button("Log Out", id=ID_LOGOUT_MODAL_ACCEPT)
])
],
id=ID_LOGOUT_MODAL,
is_open=False
)
])
def on_click_login_modal_accept(net_id: Union[str, None], password: Union[str, None]) -> Union[str, None]:
# validate
if net_id is None or net_id == "":
return "BYU Net ID is required."
if password is None or password == "":
return "Submission Password is required."
# authenticate
auth_success = authenticate(net_id, password)
if auth_success:
return None
else:
return AUTH_FAILED_MESSAGE
def define_callbacks(app: dash.Dash):
@app.callback(Output(ID_LOGIN_MODAL, "is_open"),
Output(ID_LOGIN_MODAL_MESSAGE, "children"),
Output(ID_LOGOUT_MODAL, "is_open"),
Output(ID_LOGIN_STORE, "data"),
Input(ID_LOGIN_BUTTON, "n_clicks"),
Input(ID_LOGIN_MODAL_CANCEL, "n_clicks"),
Input(ID_LOGIN_MODAL_ACCEPT, "n_clicks"),
Input(ID_LOGOUT_BUTTON, "n_clicks"),
Input(ID_LOGOUT_MODAL_CANCEL, "n_clicks"),
Input(ID_LOGOUT_MODAL_ACCEPT, "n_clicks"),
State(ID_LOGIN_MODAL_NET_ID, "value"),
State(ID_LOGIN_MODAL_PASSWORD, "value"),
prevent_initial_call=True)
def on_login_logout_clicked(
n_login_clicks: int,
n_login_cancel_clicks: int,
n_login_accept_clicks: int,
n_logout_clicks: int,
n_logout_cancel_clicks: int,
n_logout_accept_clicks: int,
net_id: str,
password: str):
ctx = dash.callback_context
btn_id = ctx.triggered[0]["prop_id"].split(".")[0]
if btn_id == ID_LOGIN_BUTTON:
# show the login modal (with no message)
return True, None, dash.no_update, dash.no_update
elif btn_id == ID_LOGIN_MODAL_CANCEL:
# hide the login modal
return False, dash.no_update, dash.no_update, dash.no_update
elif btn_id == ID_LOGIN_MODAL_ACCEPT:
# try to actually log in
error_message = on_click_login_modal_accept(net_id, password)
if error_message is None: # login success!
# hide the modal, update the login store
return False, dash.no_update, dash.no_update, {NET_ID: net_id, PASSWORD: password}
else: # login failed
# show the message and keep the modal open
return dash.no_update, html.Span(error_message, style=STYLE_RED), dash.no_update, dash.no_update
elif btn_id == ID_LOGOUT_BUTTON:
# show the logout modal
return dash.no_update, dash.no_update, True, dash.no_update
elif btn_id == ID_LOGOUT_MODAL_CANCEL:
# hide the logout modal
return dash.no_update, dash.no_update, False, dash.no_update
elif btn_id == ID_LOGOUT_MODAL_ACCEPT:
# hide the logout modal and clear the login store
return dash.no_update, dash.no_update, False, LOGIN_INFO_EMPTY
else: # error
print(f"unknown button id: {btn_id}") # TODO: better logging
return [dash.no_update] * 4 # one for each Output
@app.callback(Output(ID_LOGIN_BUTTON, "style"),
Output(ID_LOGGED_IN_AS, "children"),
Output(ID_LOGGED_IN_AS, "style"),
Output(ID_LOGOUT_BUTTON, "style"),
Input(ID_LOGIN_STORE, "data"),
State(ID_LOGIN_BUTTON, "style"),
State(ID_LOGGED_IN_AS, "style"),
State(ID_LOGOUT_BUTTON, "style"))
def on_login_data_changed(login_store, login_style, logged_in_as_style, logout_style):
# just in case no style is provided
if login_style is None:
login_style = dict()
if logged_in_as_style is None:
logged_in_as_style = dict()
if logout_style is None:
logout_style = dict()
# are they logged in or not?
if login_store[NET_ID] is None or login_store[PASSWORD] is None:
# not logged in
login_style["display"] = VISIBLE
logged_in_as_style["display"] = HIDDEN
logout_style["display"] = HIDDEN
return login_style, None, logged_in_as_style, logout_style
else: # yes logged in
login_style["display"] = HIDDEN
logged_in_as_style["display"] = VISIBLE
logout_style["display"] = VISIBLE
return login_style, f"Logged in as '{login_store[NET_ID]}'", logged_in_as_style, logout_style
# define callbacks for all of the tabs
define_submit_callbacks(app)
define_log_callbacks(app)
define_config_callbacks(app)
website/tabs/submit.py
import time
from io import StringIO
from typing import Callable, Dict, Union
import dash
import dash_bootstrap_components as dbc
from dash import dcc, html
from dash.dependencies import Input, Output, State
from config.loaded_config import CONFIG
from driver.passoff_driver import PassoffDriver
from util.authenticator import authenticate
from website import ID_LOGIN_STORE, NET_ID, PASSWORD
from website.util import AUTH_FAILED_MESSAGE, save_to_submit, text_html_colorizer
# submit tab IDs
ID_SUBMIT_PROJECT_NUMBER_RADIO = "submit-project-number-radio"
ID_UPLOAD_BUTTON = "upload-button"
ID_UPLOAD_CONTENTS = "upload-contents"
ID_FILE_NAME_DISPLAY = "file-name-display"
ID_SUBMISSION_SUBMIT_BUTTON = "submission-submit-button"
ID_SUBMISSION_OUTPUT = "submission-output"
ID_DUMMY_DIV = "dummy-div"
# info modal
ID_SUBMISSION_INFO_MODAL = "submission-info-modal"
ID_SUBMISSION_INFO_MODAL_MESSAGE = "submission-info-modal-message"
ID_SUBMISSION_INFO_MODAL_ACCEPT = "submission-info-modal-accept"
# submission confirmation modal
ID_SUBMISSION_CONFIRMATION_MODAL = "submission-confirmation-modal"
ID_SUBMISSION_CONFIRMATION_MODAL_CANCEL = "submission-confirmation-modal-cancel"
ID_SUBMISSION_CONFIRMATION_MODAL_ACCEPT = "submission-confirmation-modal-accept"
# store to trigger submission
ID_SUBMISSION_TRIGGER_STORE = "submission-trigger-store"
layout = html.Div(
[
html.H3("Upload New Submission"),
html.P("Which project are you submitting?"),
dcc.RadioItems(
id=ID_SUBMIT_PROJECT_NUMBER_RADIO,
options=[{
"label": f" Project {proj_num}",
"value": proj_num
} for proj_num in range(1, CONFIG.n_projects + 1)]
),
html.Br(),
html.P("Upload your .zip file here:"),
html.Div(
[
dcc.Upload(
html.Button("Select File", id=ID_UPLOAD_BUTTON),
id=ID_UPLOAD_CONTENTS,
multiple=False
),
html.Pre("No File Selected", id=ID_FILE_NAME_DISPLAY, style={"marginLeft": "10px"})
],
style={
"display": "flex",
"justifyContent": "flex-start",
"alignItems": "center"
}
),
html.Br(),
html.Button("Submit", id=ID_SUBMISSION_SUBMIT_BUTTON),
html.Br(),
html.Div(id=ID_SUBMISSION_OUTPUT, style={"marginTop": "20px"}),
html.Br(),
html.Div(id=ID_DUMMY_DIV),
dbc.Modal(
[
dbc.ModalHeader("Try Again"),
dbc.ModalBody(id=ID_SUBMISSION_INFO_MODAL_MESSAGE),
dbc.ModalFooter([
html.Button("OK", id=ID_SUBMISSION_INFO_MODAL_ACCEPT)
])
],
id=ID_SUBMISSION_INFO_MODAL,
is_open=False
),
dbc.Modal(
[
dbc.ModalHeader("Confirm Submission"),
dbc.ModalBody("Are you sure you want to officially submit?"),
dbc.ModalFooter([
html.Button("Cancel", id=ID_SUBMISSION_CONFIRMATION_MODAL_CANCEL),
html.Button("Submit", id=ID_SUBMISSION_CONFIRMATION_MODAL_ACCEPT)
])
],
id=ID_SUBMISSION_CONFIRMATION_MODAL,
is_open=False
),
dcc.Store(id=ID_SUBMISSION_TRIGGER_STORE, storage_type="memory", data=False) # data value just flips to trigger the long callback
],
style={
"margin": "10px",
"padding": "10px",
"borderStyle": "double"
}
)
def on_submit_button_clicked(
proj_number: Union[int, None],
file_name: Union[str, None],
file_contents: Union[str, None],
login_store: Union[Dict[str, str], None]) -> Union[str, None]:
# validate
if login_store is None or NET_ID not in login_store or PASSWORD not in login_store:
return "There was a problem with the login store!"
net_id = login_store[NET_ID]
password = login_store[PASSWORD]
if net_id is None or net_id == "" or password is None or password == "":
return "You must log in before submitting."
if proj_number is None:
return "The project number must be selected."
if not (1 <= proj_number <= CONFIG.n_projects):
return "Invalid project selected."
if file_name is None or file_name == "" or file_contents is None or file_contents == "":
return "A zip file must be uploaded to submit."
if not file_name.endswith(".zip"):
return "The uploaded file must be a .zip file."
# all good, it seems; return no error message
return None
def run_submission(proj_number: int, file_contents: str, login_store: Dict[str, str]): # -> html element(s)
# authenticate
net_id = login_store[NET_ID]
password = login_store[PASSWORD]
auth_success = authenticate(net_id, password)
if not auth_success:
return AUTH_FAILED_MESSAGE
# write their zip file to the submit directory
save_to_submit(proj_number, net_id, file_contents)
# actually submit
this_stdout = StringIO() # TODO: use a long callback "progress" feature to write continuous output?
this_stderr = StringIO()
driver = PassoffDriver(stdout=this_stdout, stderr=this_stderr)
submission_result = driver.run(net_id, proj_number, use_user_input=False)
# TODO: change returned result^ from submission_result to diff/error info?
# show results to the user
output = list()
output.append(html.P(f"submission for '{net_id}', project {proj_number}"))
stdout_val = this_stdout.getvalue()
output.append(html.P("stdout:"))
output.append(html.Br())
output.append(html.Pre(text_html_colorizer(stdout_val)))
output.append(html.Br())
stderr_val = this_stderr.getvalue()
if stderr_val != "":
output.append(html.P("stderr:"))
output.append(html.Br())
output.append(html.Pre(text_html_colorizer(stderr_val)))
return output
def define_submit_callbacks(app: dash.Dash):
@app.callback(Output(ID_FILE_NAME_DISPLAY, "children"),
Input(ID_UPLOAD_CONTENTS, "filename"),
prevent_initial_call=True)
def on_select_file(filename: str):
if filename is None or filename == "":
return "No File Selected"
return filename
@app.callback(Output(ID_SUBMISSION_CONFIRMATION_MODAL, "is_open"),
Output(ID_SUBMISSION_INFO_MODAL, "is_open"),
Output(ID_SUBMISSION_INFO_MODAL_MESSAGE, "children"),
Output(ID_SUBMISSION_TRIGGER_STORE, "data"),
Input(ID_SUBMISSION_SUBMIT_BUTTON, "n_clicks"),
Input(ID_SUBMISSION_CONFIRMATION_MODAL_CANCEL, "n_clicks"),
Input(ID_SUBMISSION_CONFIRMATION_MODAL_ACCEPT, "n_clicks"),
Input(ID_SUBMISSION_INFO_MODAL_ACCEPT, "n_clicks"),
State(ID_SUBMIT_PROJECT_NUMBER_RADIO, "value"),
State(ID_UPLOAD_CONTENTS, "filename"),
State(ID_UPLOAD_CONTENTS, "contents"),
State(ID_LOGIN_STORE, "data"),
State(ID_SUBMISSION_TRIGGER_STORE, "data"),
prevent_initial_call=True)
def on_submission_submit_clicked(
n_submit_clicks: int,
n_confirmation_cancel_clicks: int,
n_confirmation_accept_clicks: int,
n_info_accept_clicks: int,
proj_number: int,
file_name: str,
file_contents: str,
login_store: Dict[str, str],
submission_trigger_store: bool):
ctx = dash.callback_context
trigger_id = ctx.triggered[0]["prop_id"].split(".")[0]
print("trigger:", trigger_id)
if trigger_id == ID_SUBMISSION_SUBMIT_BUTTON:
# validate
error_message = on_submit_button_clicked(proj_number, file_name, file_contents, login_store)
if error_message is None: # good to go
# show the confirmation modal
return True, dash.no_update, dash.no_update, dash.no_update
else:
# show the error message in the info modal
return dash.no_update, True, error_message, dash.no_update
elif trigger_id == ID_SUBMISSION_CONFIRMATION_MODAL_CANCEL:
# hide the confirmation modal
return False, dash.no_update, dash.no_update, dash.no_update
elif trigger_id == ID_SUBMISSION_CONFIRMATION_MODAL_ACCEPT:
# hide the confirmation modal and trigger on_submit_confirmed
return False, dash.no_update, dash.no_update, not submission_trigger_store # just flip the value, whatever it is
elif trigger_id == ID_SUBMISSION_INFO_MODAL_ACCEPT:
# hide the info modal
return dash.no_update, False, dash.no_update, dash.no_update
else: # error
print(f"unknown button id: {trigger_id}") # TODO: better logging
return [dash.no_update] * 4 # one for each Output
@app.long_callback(
progress=[Output(ID_SUBMISSION_OUTPUT, "children")],
progress_default=[dash.no_update], # I'll set stuff manually, thank you very much
output=Output(ID_DUMMY_DIV, "children"),
inputs=[
Input(ID_SUBMISSION_TRIGGER_STORE, "data"),
State(ID_SUBMIT_PROJECT_NUMBER_RADIO, "value"),
State(ID_UPLOAD_CONTENTS, "filename"),
State(ID_UPLOAD_CONTENTS, "contents"),
State(ID_LOGIN_STORE, "data")
],
running=[(Output(ID_SUBMISSION_SUBMIT_BUTTON, "disabled"), True, False)], # disable the submit button while running
prevent_initial_call=True)
def on_submit_confirmed(
set_progress: Callable,
submission_trigger_store: bool,
proj_number: int,
file_name: str,
file_contents,
login_store):
# TODO
print("long callback start")
set_progress([None])
# actually run the submission
# submission_result = run_submission(proj_number, file_contents, login_store)
output_children = list()
for i in range(5):
output_children.append(html.P(f"i = {i}"))
set_progress([output_children])
time.sleep(1)
output_children.append(html.P("done submitting"))
set_progress([output_children])
timestamp = time.time()
print(timestamp)
# time.sleep(1) # THIS FIXES IT - but that's dumb.
return [f"Current timestamp: {timestamp}"]
Outputs
stdout and stderr
/home/echols14/miniconda3/envs/passoff/lib/python3.9/site-packages/dash_bootstrap_components/_table.py:5: UserWarning:
The dash_html_components package is deprecated. Please replace
`import dash_html_components as html` with `from dash import html`
import dash_html_components as html
Dash is running on http://0.0.0.0:8050/
* Serving Flask app "app" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: on
/home/echols14/miniconda3/envs/passoff/lib/python3.9/site-packages/dash_bootstrap_components/_table.py:5: UserWarning:
The dash_html_components package is deprecated. Please replace
`import dash_html_components as html` with `from dash import html`
import dash_html_components as html
trigger: submission-submit-button
trigger: submission-confirmation-modal-accept
long callback start
1632470283.8649454