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): async def _recompute_argument(self, name):
try: try:
expdesc = await self.manager.compute_expdesc(self.expurl) expdesc, _ = await self.manager.compute_expdesc(self.expurl)
except: except:
logger.error("Could not recompute argument '%s' of '%s'", logger.error("Could not recompute argument '%s' of '%s'",
name, self.expurl, exc_info=True) name, self.expurl, exc_info=True)
@ -250,7 +250,8 @@ class _ExperimentDock(QtWidgets.QMdiSubWindow):
self.manager = manager self.manager = manager
self.expurl = expurl 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.addWidget(self.argeditor, 0, 0, 1, 5)
self.layout.setRowStretch(0, 1) self.layout.setRowStretch(0, 1)
@ -401,7 +402,7 @@ class _ExperimentDock(QtWidgets.QMdiSubWindow):
async def _recompute_arguments_task(self, overrides=dict()): async def _recompute_arguments_task(self, overrides=dict()):
try: try:
expdesc = await self.manager.compute_expdesc(self.expurl) expdesc, ui_name = await self.manager.compute_expdesc(self.expurl)
except: except:
logger.error("Could not recompute experiment description of '%s'", logger.error("Could not recompute experiment description of '%s'",
self.expurl, exc_info=True) self.expurl, exc_info=True)
@ -414,12 +415,13 @@ class _ExperimentDock(QtWidgets.QMdiSubWindow):
arginfo[k][0]["default"].insert(0, v) arginfo[k][0]["default"].insert(0, v)
else: else:
arginfo[k][0]["default"] = v 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() argeditor_state = self.argeditor.save_state()
self.argeditor.deleteLater() 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.argeditor.restore_state(argeditor_state)
self.layout.addWidget(self.argeditor, 0, 0, 1, 5) self.layout.addWidget(self.argeditor, 0, 0, 1, 5)
@ -432,7 +434,7 @@ class _ExperimentDock(QtWidgets.QMdiSubWindow):
async def _recompute_sched_options_task(self): async def _recompute_sched_options_task(self):
try: try:
expdesc = await self.manager.compute_expdesc(self.expurl) expdesc, _ = await self.manager.compute_expdesc(self.expurl)
except: except:
logger.error("Could not recompute experiment description of '%s'", logger.error("Could not recompute experiment description of '%s'",
self.expurl, exc_info=True) self.expurl, exc_info=True)
@ -555,6 +557,12 @@ class _QuickOpenDialog(QtWidgets.QDialog):
class ExperimentManager: 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, def __init__(self, main_window, dataset_sub,
explist_sub, schedule_sub, explist_sub, schedule_sub,
schedule_ctl, experiment_db_ctl): schedule_ctl, experiment_db_ctl):
@ -566,6 +574,7 @@ class ExperimentManager:
self.submission_scheduling = dict() self.submission_scheduling = dict()
self.submission_options = dict() self.submission_options = dict()
self.submission_arguments = dict() self.submission_arguments = dict()
self.argument_ui_names = dict()
self.datasets = dict() self.datasets = dict()
dataset_sub.add_setmodel_callback(self.set_dataset_model) dataset_sub.add_setmodel_callback(self.set_dataset_model)
@ -602,6 +611,17 @@ class ExperimentManager:
else: else:
raise ValueError("Malformed experiment URL") 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): def get_submission_scheduling(self, expurl):
if expurl in self.submission_scheduling: if expurl in self.submission_scheduling:
return self.submission_scheduling[expurl] return self.submission_scheduling[expurl]
@ -631,7 +651,7 @@ class ExperimentManager:
self.submission_options[expurl] = options self.submission_options[expurl] = options
return options return options
def initialize_submission_arguments(self, expurl, arginfo): def initialize_submission_arguments(self, expurl, arginfo, ui_name):
arguments = OrderedDict() arguments = OrderedDict()
for name, (procdesc, group, tooltip) in arginfo.items(): for name, (procdesc, group, tooltip) in arginfo.items():
state = procdesc_to_entry(procdesc).default_state(procdesc) state = procdesc_to_entry(procdesc).default_state(procdesc)
@ -642,6 +662,7 @@ class ExperimentManager:
"state": state, # mutated by entries "state": state, # mutated by entries
} }
self.submission_arguments[expurl] = arguments self.submission_arguments[expurl] = arguments
self.argument_ui_names[expurl] = ui_name
return arguments return arguments
def get_submission_arguments(self, expurl): def get_submission_arguments(self, expurl):
@ -651,9 +672,9 @@ class ExperimentManager:
if expurl[:5] != "repo:": if expurl[:5] != "repo:":
raise ValueError("Submission arguments must be preinitialized " raise ValueError("Submission arguments must be preinitialized "
"when not using repository") "when not using repository")
arginfo = self.explist[expurl[5:]]["arginfo"] class_desc = self.explist[expurl[5:]]
arguments = self.initialize_submission_arguments(expurl, arginfo) return self.initialize_submission_arguments(expurl,
return arguments class_desc["arginfo"], class_desc.get("argument_ui", None))
def open_experiment(self, expurl): def open_experiment(self, expurl):
if expurl in self.open_experiments: if expurl in self.open_experiments:
@ -755,13 +776,15 @@ class ExperimentManager:
revision = None revision = None
description = await self.experiment_db_ctl.examine( description = await self.experiment_db_ctl.examine(
file, use_repository, revision) 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): async def open_file(self, file):
description = await self.experiment_db_ctl.examine(file, False) description = await self.experiment_db_ctl.examine(file, False)
for class_name, class_desc in description.items(): for class_name, class_desc in description.items():
expurl = "file:{}@{}".format(class_name, file) 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: if expurl in self.open_experiments:
self.open_experiments[expurl].close() self.open_experiments[expurl].close()
self.open_experiment(expurl) self.open_experiment(expurl)
@ -774,6 +797,7 @@ class ExperimentManager:
"options": self.submission_options, "options": self.submission_options,
"arguments": self.submission_arguments, "arguments": self.submission_arguments,
"docks": self.dock_states, "docks": self.dock_states,
"argument_uis": self.argument_ui_names,
"open_docks": set(self.open_experiments.keys()) "open_docks": set(self.open_experiments.keys())
} }
@ -784,6 +808,7 @@ class ExperimentManager:
self.submission_scheduling = state["scheduling"] self.submission_scheduling = state["scheduling"]
self.submission_options = state["options"] self.submission_options = state["options"]
self.submission_arguments = state["arguments"] self.submission_arguments = state["arguments"]
self.argument_ui_names = state.get("argument_uis", {})
for expurl in state["open_docks"]: for expurl in state["open_docks"]:
self.open_experiment(expurl) self.open_experiment(expurl)

View File

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

View File

@ -205,7 +205,10 @@ def examine(device_mgr, dataset_mgr, file):
(k, (proc.describe(), group, tooltip)) (k, (proc.describe(), group, tooltip))
for k, (proc, group, tooltip) in argument_mgr.requested_args.items() 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: finally:
new_keys = set(sys.modules.keys()) new_keys = set(sys.modules.keys())
for key in new_keys - previous_keys: for key in new_keys - previous_keys: