Allow experiments to specify a custom argument editor UI (#1916)

On the master/EnvExperiment side, the only addition is an optional
property `argument_ui` that is made accessible to the dashboard, e.g.

    class Example(EnvExperiment):
        argument_ui = "ndscan"
        def build(self):
           …

Clients – primarily artiq_dashboard, but in principle e.g. a
command-line UI could do the same – can then compare the value to a
list of well-known names and prefer any matching custom UI handlers.

On the dashboard side, this commit adds the mechanism to register
a custom argument editor for a given argument_ui string, i.e. the
widget that displays the parameter values within the wider
experiment UI shell with the submit button, pipeline parameters, and
so on. The registry remains empty by default and would be filled by
out-of-tree plugins such as ndscan.

The UI state readback is implemented somewhat defensively to avoid
needless disruptions to users when upgrading.
This commit is contained in:
David Nadlinger 2022-06-18 08:55:13 +01:00 committed by GitHub
parent dbc87f08ff
commit 32db6ff978
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 43 additions and 15 deletions

View File

@ -164,7 +164,7 @@ class _ArgumentEditor(QtWidgets.QTreeWidget):
async def _recompute_argument(self, name):
try:
expdesc = await self.manager.compute_expdesc(self.expurl)
expdesc, _ = await self.manager.compute_expdesc(self.expurl)
except:
logger.error("Could not recompute argument '%s' of '%s'",
name, self.expurl, exc_info=True)
@ -250,7 +250,8 @@ class _ExperimentDock(QtWidgets.QMdiSubWindow):
self.manager = manager
self.expurl = expurl
self.argeditor = _ArgumentEditor(self.manager, self, self.expurl)
editor_class = self.manager.get_argument_editor_class(expurl)
self.argeditor = editor_class(self.manager, self, self.expurl)
self.layout.addWidget(self.argeditor, 0, 0, 1, 5)
self.layout.setRowStretch(0, 1)
@ -401,7 +402,7 @@ class _ExperimentDock(QtWidgets.QMdiSubWindow):
async def _recompute_arguments_task(self, overrides=dict()):
try:
expdesc = await self.manager.compute_expdesc(self.expurl)
expdesc, ui_name = await self.manager.compute_expdesc(self.expurl)
except:
logger.error("Could not recompute experiment description of '%s'",
self.expurl, exc_info=True)
@ -414,12 +415,13 @@ class _ExperimentDock(QtWidgets.QMdiSubWindow):
arginfo[k][0]["default"].insert(0, v)
else:
arginfo[k][0]["default"] = v
self.manager.initialize_submission_arguments(self.expurl, arginfo)
self.manager.initialize_submission_arguments(self.expurl, arginfo, ui_name)
argeditor_state = self.argeditor.save_state()
self.argeditor.deleteLater()
self.argeditor = _ArgumentEditor(self.manager, self, self.expurl)
editor_class = self.manager.get_argument_editor_class(self.expurl)
self.argeditor = editor_class(self.manager, self, self.expurl)
self.argeditor.restore_state(argeditor_state)
self.layout.addWidget(self.argeditor, 0, 0, 1, 5)
@ -432,7 +434,7 @@ class _ExperimentDock(QtWidgets.QMdiSubWindow):
async def _recompute_sched_options_task(self):
try:
expdesc = await self.manager.compute_expdesc(self.expurl)
expdesc, _ = await self.manager.compute_expdesc(self.expurl)
except:
logger.error("Could not recompute experiment description of '%s'",
self.expurl, exc_info=True)
@ -555,6 +557,12 @@ class _QuickOpenDialog(QtWidgets.QDialog):
class ExperimentManager:
#: Global registry for custom argument editor classes, indexed by the experiment
#: `argument_ui` string; can be populated by dashboard plugins such as ndscan.
#: If no handler for a requested UI name is found, the default built-in argument
#: editor will be used.
argument_ui_classes = dict()
def __init__(self, main_window, dataset_sub,
explist_sub, schedule_sub,
schedule_ctl, experiment_db_ctl):
@ -566,6 +574,7 @@ class ExperimentManager:
self.submission_scheduling = dict()
self.submission_options = dict()
self.submission_arguments = dict()
self.argument_ui_names = dict()
self.datasets = dict()
dataset_sub.add_setmodel_callback(self.set_dataset_model)
@ -602,6 +611,17 @@ class ExperimentManager:
else:
raise ValueError("Malformed experiment URL")
def get_argument_editor_class(self, expurl):
ui_name = self.argument_ui_names.get(expurl, None)
if not ui_name and expurl[:5] == "repo:":
ui_name = self.explist.get(expurl[5:], {}).get("argument_ui", None)
if ui_name:
result = self.argument_ui_classes.get(ui_name, None)
if result:
return result
logger.warning("Ignoring unknown argument UI '%s'", ui_name)
return _ArgumentEditor
def get_submission_scheduling(self, expurl):
if expurl in self.submission_scheduling:
return self.submission_scheduling[expurl]
@ -631,7 +651,7 @@ class ExperimentManager:
self.submission_options[expurl] = options
return options
def initialize_submission_arguments(self, expurl, arginfo):
def initialize_submission_arguments(self, expurl, arginfo, ui_name):
arguments = OrderedDict()
for name, (procdesc, group, tooltip) in arginfo.items():
state = procdesc_to_entry(procdesc).default_state(procdesc)
@ -642,6 +662,7 @@ class ExperimentManager:
"state": state, # mutated by entries
}
self.submission_arguments[expurl] = arguments
self.argument_ui_names[expurl] = ui_name
return arguments
def get_submission_arguments(self, expurl):
@ -651,9 +672,9 @@ class ExperimentManager:
if expurl[:5] != "repo:":
raise ValueError("Submission arguments must be preinitialized "
"when not using repository")
arginfo = self.explist[expurl[5:]]["arginfo"]
arguments = self.initialize_submission_arguments(expurl, arginfo)
return arguments
class_desc = self.explist[expurl[5:]]
return self.initialize_submission_arguments(expurl,
class_desc["arginfo"], class_desc.get("argument_ui", None))
def open_experiment(self, expurl):
if expurl in self.open_experiments:
@ -755,13 +776,15 @@ class ExperimentManager:
revision = None
description = await self.experiment_db_ctl.examine(
file, use_repository, revision)
return description[class_name]
class_desc = description[class_name]
return class_desc, class_desc.get("argument_ui", None)
async def open_file(self, file):
description = await self.experiment_db_ctl.examine(file, False)
for class_name, class_desc in description.items():
expurl = "file:{}@{}".format(class_name, file)
self.initialize_submission_arguments(expurl, class_desc["arginfo"])
self.initialize_submission_arguments(expurl, class_desc["arginfo"],
class_desc.get("argument_ui", None))
if expurl in self.open_experiments:
self.open_experiments[expurl].close()
self.open_experiment(expurl)
@ -774,6 +797,7 @@ class ExperimentManager:
"options": self.submission_options,
"arguments": self.submission_arguments,
"docks": self.dock_states,
"argument_uis": self.argument_ui_names,
"open_docks": set(self.open_experiments.keys())
}
@ -784,6 +808,7 @@ class ExperimentManager:
self.submission_scheduling = state["scheduling"]
self.submission_options = state["options"]
self.submission_arguments = state["arguments"]
self.argument_ui_names = state.get("argument_uis", {})
for expurl in state["open_docks"]:
self.open_experiment(expurl)

View File

@ -30,7 +30,6 @@ class _RepoScanner:
raise
for class_name, class_desc in description.items():
name = class_desc["name"]
arginfo = class_desc["arginfo"]
if "/" in name:
logger.warning("Character '/' is not allowed in experiment "
"name (%s)", name)
@ -47,7 +46,8 @@ class _RepoScanner:
entry = {
"file": filename,
"class_name": class_name,
"arginfo": arginfo,
"arginfo": class_desc["arginfo"],
"argument_ui": class_desc["argument_ui"],
"scheduler_defaults": class_desc["scheduler_defaults"]
}
entry_dict[name] = entry

View File

@ -205,7 +205,10 @@ def examine(device_mgr, dataset_mgr, file):
(k, (proc.describe(), group, tooltip))
for k, (proc, group, tooltip) in argument_mgr.requested_args.items()
)
register_experiment(class_name, name, arginfo, scheduler_defaults)
argument_ui = None
if hasattr(exp_class, "argument_ui"):
argument_ui = exp_class.argument_ui
register_experiment(class_name, name, arginfo, argument_ui, scheduler_defaults)
finally:
new_keys = set(sys.modules.keys())
for key in new_keys - previous_keys: