diff --git a/app.py b/app.py index 74ed3df..3f4d2a3 100755 --- a/app.py +++ b/app.py @@ -20,7 +20,8 @@ app_image = html.Div(html.Img(src=logo, style={'width': '5%', 'height': '5%'}), style={'textAlign': 'center'}) app_title = html.Div([html.H1(children='obsidian'), html.H5(children='Algorithmic Process Optimization and Experiment Design', - style={'font-style': 'italic', 'color': '#AAAAAA'})], + className="fst-italic", + style={'color': '#AAAAAA'})], style={'textAlign': 'center'}) app_infobar = html.Div(id='root-infobar') app_tabs = dbc.Tabs(children=[], id="root-tabs") diff --git a/assets/css/styles.css b/assets/css/styles.css new file mode 100644 index 0000000..c54ca57 --- /dev/null +++ b/assets/css/styles.css @@ -0,0 +1,58 @@ +:root { + --obsd-label-color: var(--bs-primary); + --obsd-text-color: var(--bs-body-color); + --obsd-form-font-size: 0.7em; + --obsd-form-lg-font-size: 0.8em; + --obsd-form-xl-font-size: 1em; + --obsd-info-color: var(--bs-info, #0dcaf0); +} + +.obsd-dashed-box { + height: 60px; + line-height: 60px; + border: 1px dashed; + border-radius: 5px; + box-sizing: border-box; +} + +/* Base Class */ +.obsd-form-text { + font-size: var(--obsd-form-font-size); + color: var(--obsd-text-color); +} + +/* Utility Classes */ +.obsd-text-lg { + font-size: var(--obsd-form-lg-font-size); +} + +.obsd-text-xl { + font-size: var(--obsd-form-xl-font-size); +} + +.obsd-text-normal-style { + font-style: var(--obsd-help-font-style); +} + +.obsd-input-row { + margin-bottom: 1rem; +} + +.obsd-form-label { + font-weight: bold; + color: var(--obsd-label-color); + font-size: var(--obsd-form-lg-font-size); +} + +.text-transform-none { + text-transform: none !important; +} + +.d-inline-block { + display: inline-block; +} + +.overflow-x-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} \ No newline at end of file diff --git a/obsidian/dash/infobar.py b/obsidian/dash/infobar.py index b43f9fd..697e11f 100644 --- a/obsidian/dash/infobar.py +++ b/obsidian/dash/infobar.py @@ -16,12 +16,9 @@ def setup_infobar(app, app_infobar): app_infobar.children = dbc.Container([ html.Br(), dbc.Row([ - dbc.Col(dbc.Button('Help', outline='True', color='warning', className='me-1', id='button-help'), - style={'textAlign': 'left'}, width={'size': 2}), - dbc.Col(html.Div(dbc.Badge(f'v{obsidian.__version__}', color='primary', className='me-1'), style={'textAlign': 'center'}), - width={'size': 2}), - dbc.Col(dbc.Button('Contact', outline='True', color='secondary', className='me-1', id='button-contact'), - style={'textAlign': 'right'}, width={'size': 2}), + dbc.Col(dbc.Button('Help', outline='True', color='warning', className='me-1', id='button-help'), className="text-start", width={'size': 2}), + dbc.Col(html.Div(dbc.Badge(f'v{obsidian.__version__}', color='primary', className='me-1 text-center')), className="text-center", width={'size': 2}), + dbc.Col(dbc.Button('Contact', outline='True', color='secondary', className='me-1', id='button-contact'), className="text-end", width={'size': 2}), ], justify='center'), # Pop-up for Contact Us dbc.Modal([ @@ -31,7 +28,7 @@ def setup_infobar(app, app_infobar): color='primary', className='me-1'), dbc.Button('Visit Our Site', href='https://msdllcpapers.github.io/obsidian/', target='_blank', external_link=True, color='primary', className='me-1') - ], style={'textAlign': 'center'}), + ], className="text-center"), dbc.ModalFooter([]) ], id='modal-contact', is_open=False, size='xl', centered=True), # Pop-up for Help diff --git a/obsidian/dash/inputs_config.py b/obsidian/dash/inputs_config.py index 86d499a..01eec4a 100644 --- a/obsidian/dash/inputs_config.py +++ b/obsidian/dash/inputs_config.py @@ -25,7 +25,8 @@ def make_acquisition(index, delete=True): className='me-2', color='danger', n_clicks=0)) columns.append(dbc.Col([dbc.InputGroup(hyper_input), - dbc.FormText('Input a hyperparameter (optional)', style={'font-size': '0.7em'})])) + dbc.FormText('Input a hyperparameter (optional)', + className="obsd-form-text")])) return dbc.Row(id={'type': 'div-acquisition', 'index': index}, children=columns, align='center') @@ -62,19 +63,19 @@ def setup_config(app, app_tabs): acquisitions = dbc.Container(dbc.Card(id='aq_inputs', children=[ dbc.CardHeader(['Acquisition Functions', html.Div(dbc.FormText('Objective function of the experiment\ search/selection', - style={'font-size': '0.7em'}))]), + className="obsd-form-text"))]), dbc.CardBody(html.Div(id='div-acquisition_all', children=[make_acquisition(index=0, delete=False)])), dbc.CardFooter(dbc.Button('Add', id='button-acquisition_add', className='me-2', color='secondary', n_clicks=0)) - ], style={'margin-top': '15px'})) + ], className="mt-3")) general_options = dbc.Container(dbc.Card(dbc.CardBody( children=[input_surrogate, input_m_batch, input_optim_sequential])), - style={'margin-top': '15px'}) + className="mt-3") advanced_options = dbc.Container(make_collapse('adv_options', [input_optimizer_seed, input_f_transform, input_optim_restarts], 'Advanced Options'), - style={'margin-top': '15px'}) + className="mt-3") # Config store config = dcc.Store(id='store-config') diff --git a/obsidian/dash/inputs_data.py b/obsidian/dash/inputs_data.py index bdfa024..6664c72 100644 --- a/obsidian/dash/inputs_data.py +++ b/obsidian/dash/inputs_data.py @@ -1,7 +1,7 @@ from .utils import add_tab, make_input, make_dropdown, make_switch, make_slider, make_knob, make_table, make_collapse -from .utils import center +from .utils import center, error_message_handling, is_input_empty import dash_bootstrap_components as dbc -from dash import dcc, html, Dash, dash_table, callback, Output, Input, State, ALL, MATCH +from dash import dcc, html, Dash, dash_table, callback, Output, Input, State, ALL, MATCH, no_update import pandas as pd import base64 import io @@ -16,12 +16,7 @@ def setup_data(app, app_tabs, default_data, default_Xspace): uploader = dcc.Upload(id='uploader-X0', children=html.Div(['Upload Data: Drag and Drop or ', html.A('Select Files')]), - style={ - 'width': '100%', 'height': '60px', - 'lineHeight': '60px', 'borderWidth': '1px', - 'borderStyle': 'dashed', 'borderRadius': '5px', - 'textAlign': 'center', 'margin': '10px' - }, + className="w-100 m-2 text-center obsd-dashed-box", multiple=False, filename='Example Data' ) @@ -30,7 +25,7 @@ def setup_data(app, app_tabs, default_data, default_Xspace): template_downloader = html.Div(children=[dbc.Button('Download Template Data', id='button-download_data', className='me-2', color='primary'), dcc.Download(id='downloader-X0_template')], - style={'textAlign': 'center'}) + className="text-center") # Data preview preview = html.Div(id='table-X0', children=dbc.Card( @@ -41,16 +36,18 @@ def setup_data(app, app_tabs, default_data, default_Xspace): dbc.Tooltip('Input data must be a CSV file and must include column headers for the input\ parameters and response variable(s), with one row per observation. Download\ template data (left) for example.', - target='info-data', placement='top', style={'text-transform': 'none'}), - dbc.FormText('Example Data', color='info', style={'font-size': '1em', 'font-style': 'italic'}, + target='info-data', placement='top', className="text-transform-none"), + dbc.FormText('Example Data', + color="info", + className="obsd-form-text obsd-text-xl fst-italic", id='table-X0-footer') ], - style={'textAlign': 'center'})) + className="text-center")) ])) preview_uploader = dbc.Row([dbc.Col([uploader, template_downloader], width=4), dbc.Col(preview, width=8)], - style={'margin-top': '15px'}) + className="mt-3") # Data store storage_X0 = dcc.Store(id='store-X0', data=default_data.to_dict()) @@ -68,7 +65,7 @@ def setup_data(app, app_tabs, default_data, default_Xspace): it is the target of the objective function before\ including optional transformations.', target='info-response_col', placement='top', - style={'text-transform': 'none'}), + className="text-transform-none"), 'Response Selection']), dbc.CardBody(html.Div(id='div-response_name', children=[make_dropdown('Data Column', @@ -78,18 +75,43 @@ def setup_data(app, app_tabs, default_data, default_Xspace): kwargs={'value': default_data.columns[-1]})])) ]), width=4), justify='center') + input_container = dbc.Row( + dbc.Col( + dbc.Card( + [ + dbc.CardHeader( + [ + html.I( + id="search-space-block", + className="bi bi-info-circle-fill me-2", + ), + dbc.Tooltip( + "The search space defines the range and types of values for each parameter that the optimizer will explore. These parameter types and ranges are initially inferred from the uploaded data. Please verify they are appropriate for your use case. While you can update the parameter space during optimization, do so only if you understand the implications.", + target="search-space-block", + placement="top", + className="text-transform-none", + ), + "Search Space Configuration", + ] + ), + dbc.CardBody(xspace), + ] + ), + ), + justify="center", + className="m-3", + ) # Extra div for printing outputs, for troubleshooting troubleshoot = html.Div(id='debug-print-data') # Add all of these elements to the app - elements = [html.Br(), preview_uploader, html.Hr(), ycol, xspace, storage_X0, + elements = [html.Br(), preview_uploader, html.Hr(), ycol, input_container, storage_X0, storage_X0_template, storage_Xspace, html.Hr(), troubleshoot] add_tab(app_tabs, elements, 'tab-data', 'Data') setup_data_callbacks(app) return - def setup_data_callbacks(app): # Save the uploaded data into the data-store @@ -180,7 +202,7 @@ def update_xspace_types(data, ycol, Xspace_save): ], color='primary', outline=True), width=2)) - return dbc.Container(dbc.Row(cols, style={'margin-top': '15px'}, + return dbc.Container(dbc.Row(cols, className="mt-3", justify='center'), fluid=True) @@ -218,7 +240,7 @@ def update_xspace_vals(param_type, param_id, data, ycol): className='me-2', color='danger')]), html.Hr(), html.Div(id={'type': 'div-param_categories', 'index': x}, children=None, - style={'textAlign': 'center'})])), + className="text-center")])), dcc.Store(id={'type': 'store-param_categories', 'index': x}, data=', '.join(list(ser_x.sort_values().astype('str').unique()))), html.Div(id={'type': 'input-param_min', 'index': x}), @@ -233,25 +255,55 @@ def update_xspace_vals(param_type, param_id, data, ycol): Input({'type': 'input-param_min', 'index': MATCH}, 'value'), Input({'type': 'input-param_max', 'index': MATCH}, 'value'), Input({'type': 'store-param_categories', 'index': MATCH}, 'data'), + State('store-config', 'data'), prevent_initial_call=True # It takes a second for these input matches to show up ) - def update_param_save(param_id, param_type, param_min, param_max, param_cat): - - name = param_id['index'] - + def update_param_save( + param_id, param_type, param_min, param_max, param_cat, config + ): + """ + Build and serialize a single-parameter ParamSpace for this control. + On exception, do not update the store (return no_update) to avoid triggering downstream aggregation. + """ + + name = param_id["index"] + # Placeholder for verbosity setting from config + # It should be configurable in the future + verbosity = (config or {}).get('verbose', 1) try: - if param_type == 'Numeric': + # Explicit conversion/validation + if param_type == "Numeric": + # Allow empty/incomplete during edit: keep previous store value + if not (param_min and param_max): + return no_update + param_min = float(param_min) + param_max = float(param_max) param = Param_Continuous(name, param_min, param_max) - elif param_type == 'Categorical': - param = Param_Categorical(name, param_cat) - elif param_type == 'Ordinal': - param = Param_Ordinal(name, param_cat) - + + elif param_type in ("Categorical", "Ordinal"): + # Expect a comma-separated string or list; accept both + if is_input_empty(param_cat): + return no_update + if param_type == "Categorical": + param = Param_Categorical(name, param_cat) + else: + param = Param_Ordinal(name, param_cat) + + else: + error_message_handling(name, f'unknown param_type "{param_type}"', verbosity) + return no_update + single_param = ParamSpace([param]) return single_param.save_state() - - except TypeError: - return None + + except Exception as e: + error_message_handling( + name, + f"please double check the inputs. Error: {e}", + verbosity, + tb="traceback", + ) + return no_update # Category management for categorical variables @app.callback( @@ -298,7 +350,7 @@ def preview_cats(current_cats): preview = [] for cat in cat_list: - preview += [html.Div(cat, style={'font-size': '0.8em'})] + preview += [html.Div(cat, className="obsd-form-text obsd-text-lg")] return preview @app.callback( diff --git a/obsidian/dash/optimize.py b/obsidian/dash/optimize.py index 23836de..bb1bb5b 100644 --- a/obsidian/dash/optimize.py +++ b/obsidian/dash/optimize.py @@ -29,7 +29,7 @@ def setup_optimize(app, app_tabs): ]), html.Div(id='graph-parity', children=[]) ], - style={'textAlign': 'center'} + className="text-center" ) predict_div = dbc.Container([ @@ -41,11 +41,11 @@ def setup_optimize(app, app_tabs): dbc.Card([ dbc.CardHeader('Optimal Experiments'), dbc.CardBody([ - html.Div(id='div-predict', children=[], style={}) + html.Div(id="div-predict", children=[], className="overflow-x-scroll") ]), ]), ], - style={'textAlign': 'center'} + className="text-center" ) storage_fit = dcc.Store(id='store-fit', data=None) @@ -57,8 +57,7 @@ def setup_optimize(app, app_tabs): candidates_downloader = html.Div(children=[ dbc.Button('Download Suggested Candidates', id='button-download_candidates', className='me-2', color='primary'), - dcc.Download(id='downloader-candidates')], - style={'textAlign': 'center', 'margin-top': '15px'}) + dcc.Download(id='downloader-candidates')], className="text-center mt-3") # Add all of these elements to the app columns = dbc.Row([dbc.Col(fit_div, width=6), dbc.Col([predict_div, candidates_downloader], width=6)]) @@ -142,7 +141,7 @@ def graph_parity_plot(opt_save, config): @app.callback( Output('div-predict', 'children'), - Output('div-predict', 'style'), + Output('div-predict', 'className'), Output('button-predict', 'n_clicks'), Output('store-candidates', 'data'), Input('button-predict', 'n_clicks'), @@ -157,7 +156,7 @@ def predict_optimizer(predict_clicked, config, opt_save): alert_color = 'danger' else: alert_color = 'info' - return dbc.Alert('Model must be fit first', color=alert_color), {}, predict_clicked, {} + return dbc.Alert('Model must be fit first', color=alert_color), "", predict_clicked, {} optimizer = load_optimizer(config, opt_save) X_suggest, eval_suggest = optimizer.suggest(**config['aq_params']) @@ -165,7 +164,7 @@ def predict_optimizer(predict_clicked, config, opt_save): df_suggest.insert(loc=0, column='CandidatesID', value=df_suggest.index) tables = [center(make_table(df_suggest))] - return tables, {'overflow-x': 'scroll'}, 0, df_suggest.to_dict() + return tables, "overflow-x-scroll", 0, df_suggest.to_dict() # Download Suggested Candidates @app.callback( diff --git a/obsidian/dash/predict.py b/obsidian/dash/predict.py index 9d55492..c7ad1f9 100644 --- a/obsidian/dash/predict.py +++ b/obsidian/dash/predict.py @@ -20,11 +20,11 @@ def setup_predict(app, app_tabs): dbc.Card([ dbc.CardHeader('Parameter Space'), dbc.CardBody([ - html.Div(id='div-xspace_df', children=[], style={'overflow-x': 'scroll'}) + html.Div(id='div-xspace_df', children=[], className="overflow-x-scroll") ]), ]), ], - style={'textAlign': 'center'} + className="text-center" ) template_div = dbc.Container([ @@ -34,11 +34,11 @@ def setup_predict(app, app_tabs): dbc.Card([ dbc.CardHeader('Example Prediction Input'), dbc.CardBody([ - html.Div(id='div-template', children=[], style={'overflow-x': 'scroll'}) + html.Div(id='div-template', children=[], className="overflow-x-scroll") ]), ]), ], - style={'textAlign': 'center'} + className="text-center" ) # candidates store store_template = dcc.Store(id='store-template', data={}) @@ -47,7 +47,7 @@ def setup_predict(app, app_tabs): template_downloader = html.Div(children=[dbc.Button('Download Template Candidates', id='button-download_template', className='me-2', color='primary'), dcc.Download(id='downloader-template')], - style={'textAlign': 'center', 'margin-top': '15px'}) + className="text-center mt-3") default_data = pd.DataFrame() @@ -55,12 +55,7 @@ def setup_predict(app, app_tabs): uploader_1 = dcc.Upload(id='uploader-X1', children=html.Div(['Upload Data: Drag and Drop or ', html.A('Select Files')]), - style={ - 'width': '100%', 'height': '60px', - 'lineHeight': '60px', 'borderWidth': '1px', - 'borderStyle': 'dashed', 'borderRadius': '5px', - 'textAlign': 'center', 'margin': '10px' - }, + className="m-2 text-center obsd-dashed-box", multiple=False, filename='Example Data' ) @@ -77,16 +72,18 @@ def setup_predict(app, app_tabs): dbc.Tooltip('Input data must be a CSV file and must include column headers for the input\ parameters and response variable(s), with one row per observation. Download\ template data (left) for example.', - target='info-data_1', placement='top', style={'text-transform': 'none'}), - dbc.FormText('Example Data', color='info', style={'font-size': '1em', 'font-style': 'italic'}, + target='info-data_1', placement='top', className="text-transform-none"), + dbc.FormText('Example Data', + color="info", + className="obsd-form-text obsd-text-xl fst-italic", id='table-X1-footer') ], - style={'textAlign': 'center'})) - ])) + className="text-center")) + ], className="m-2")) row1 = dbc.Row([dbc.Col([xspace_df_div], width=4), - dbc.Col([template_div, template_downloader], width=8)], style={'margin-top': '15px'}) - row2 = dbc.Row([uploader_1, preview_1], style={'margin-top': '15px'}) + dbc.Col([template_div, template_downloader], width=8)], className="mt-3") + row2 = dbc.Row([uploader_1, preview_1], className="mt-3") # Add all of these elements to the app elements = [html.Br(), store_template, row1, row2, storage_X1] @@ -101,14 +98,14 @@ def setup_predict_callbacks(app): @app.callback( Output('button-config', 'n_clicks'), Output('div-xspace_df', 'children'), - Output('div-xspace_df', 'style'), + Output('div-xspace_df', 'className'), Input('button-config', 'n_clicks'), State('store-Xspace', 'data'), prevent_initial_call=True ) def config_tableView(clicked, Xspace_save): if not Xspace_save: - return 0, None, {'overflow-x': 'scroll'} + return 0, None, "overflow-x-scroll" Xspace_list = [] for param in Xspace_save.keys(): @@ -121,7 +118,7 @@ def config_tableView(clicked, Xspace_save): df_xspace = pd.DataFrame(Xspace_list) tables = [center(make_table(df_xspace))] - return 0, tables, {'overflow-x': 'scroll'} + return 0, tables, "overflow-x-scroll" # Generate New Data template according to x_space @app.callback( diff --git a/obsidian/dash/utils.py b/obsidian/dash/utils.py index 9bf06be..3f24a21 100644 --- a/obsidian/dash/utils.py +++ b/obsidian/dash/utils.py @@ -1,3 +1,5 @@ +import traceback + import dash_bootstrap_components as dbc from dash import dcc, html, Dash, dash_table, callback, Output, Input, State, ALL, MATCH from dash.dash_table.Format import Format, Scheme @@ -9,7 +11,7 @@ def center(element): - return html.Div(html.Div(element, style={'display': 'inline-block'}), style={'textAlign': 'center'}) + return html.Div(html.Div(element, className="d-inline-block"), className="text-center") def load_optimizer(config, opt_save): @@ -28,57 +30,75 @@ def add_tab(target, elements, id, label): target.children.append(tab) return target.children - -def make_input(property_name, help_text, default_value=None, id=None, kwargs={}, required=True): +def make_input( + property_name, help_text, default_value=None, id=None, kwargs={}, required=True +): components = [ - dbc.Label(property_name, style={'font-weight': 'bold', 'font-size': '0.8em'}), - dbc.Input(value=default_value, id=f'input-[{property_name}]' if id is None else id, - debounce=True, required=required, **kwargs), - html.Div(f'{help_text}', style={'font-size': '0.7em'}), + dbc.Label(property_name, className="obsd-form-label"), + dbc.Input( + value=default_value, + id=f"input-[{property_name}]" if id is None else id, + debounce=True, + required=required, + **kwargs, + ), + html.Div(help_text, className="obsd-form-text"), ] - - return html.Div(components, className='mb-4') + return html.Div(components, className="mb-4 obsd-input-row") def make_dropdown(property_name, help_text, options=[], id=None, kwargs={}): components = [ - dbc.Label(property_name, style={'font-weight': 'bold', 'font-size': '0.8em'}), - dcc.Dropdown(options, id=f'input-[{property_name}]' if id is None else id, clearable=False, **kwargs), - dbc.FormText(f'{help_text}', style={'font-size': '0.7em'}), - ] - - return html.Div(components, className='mb-4') + dbc.Label(property_name, className="obsd-form-label"), + dcc.Dropdown( + options, + id=f"input-[{property_name}]" if id is None else id, + clearable=False, + **kwargs, + ), + dbc.FormText(help_text, className="obsd-form-text"), + ] + return html.Div(components, className="mb-4 obsd-input-row") def make_switch(property_name, help_text, id=None, kwargs={}): components = [ - dbc.Label(property_name, style={'font-weight': 'bold', 'font-size': '0.8em'}), - html.Div(dbc.Switch(id=f'toggle-[{property_name}]' if id is None else id, value=True, **kwargs)), - dbc.FormText(f'{help_text}', style={'font-size': '0.7em'}), + dbc.Label(property_name, className="obsd-form-label"), + html.Div( + dbc.Switch( + id=f"toggle-[{property_name}]" if id is None else id, + value=True, + **kwargs, + ) + ), + dbc.FormText(help_text, className="obsd-form-text"), ] - - return html.Div(components) + return html.Div(components, className="obsd-input-row") def make_slider(property_name, help_text, min, max, id=None, kwargs={}): components = [ - dbc.Label(property_name, style={'font-weight': 'bold', 'font-size': '0.8em'}), - dcc.Slider(min, max, id=f'input-[{property_name}]' if id is None else id, **kwargs), - dbc.FormText(f'{help_text}', style={'font-size': '0.7em'}), + dbc.Label(property_name, className="obsd-form-label"), + dcc.Slider( + min, max, id=f"input-[{property_name}]" if id is None else id, **kwargs + ), + dbc.FormText(help_text, className="obsd-form-text"), ] - - return html.Div(components) + return html.Div(components, className="obsd-input-row") def make_knob(property_name, help_text, min, max, id=None, kwargs={}): components = [ - dbc.Label(property_name, style={'font-weight': 'bold', 'font-size': '0.8em'}), - daq.Knob(min=min, max=max, id=f'input-[{property_name}]' if id is None else id, **kwargs), - dbc.FormText(f'{help_text}', style={'font-size': '0.7em'}), + dbc.Label(property_name, className="obsd-form-label"), + daq.Knob( + min=min, + max=max, + id=f"input-[{property_name}]" if id is None else id, + **kwargs, + ), + dbc.FormText(help_text, className="obsd-form-text"), ] - - return html.Div(components) - + return html.Div(components, className="obsd-input-row") def make_table(df, fill_width=False): table = html.Div([dash_table.DataTable(data=df.to_dict('records'), @@ -97,7 +117,22 @@ def make_table(df, fill_width=False): def make_collapse(id, contents, label): components = [ html.Div(dbc.Button(label, id=f'button-collapse-{id}', className='mb-3', color='primary', n_clicks=0), - style={'textAlign': 'center'}), + className="text-center"), dbc.Collapse(contents, id=f'collapse-{id}', is_open=False) ] return dbc.Card(dbc.CardBody(components)) + +def is_input_empty(value): + if isinstance(value, (list, tuple)): + return len(value) == 0 + if isinstance(value, str): + return value.strip() == '' + +def error_message_handling(name, message, verbosity=1, tb=None): + if verbosity >= 1: + print(f'Updating "{name}" failed: {message}') + if tb is not None and verbosity > 1: + if isinstance(tb, str): + print(tb) + else: + traceback.print_exc() \ No newline at end of file