Code source de datalab.gui.plugins.recipe_runner

# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""Transactional Desktop runner for plugin-provided scientific recipes."""

from __future__ import annotations

import dataclasses
from collections.abc import Mapping, Sequence
from contextlib import ExitStack
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from uuid import uuid4

import guidata.dataset as gds
import sigima
from sigima.objects import GeometryResult, ImageObj, SignalObj, TableResult

from datalab import __version__ as datalab_version
from datalab.adapters_metadata import GeometryAdapter, TableAdapter
from datalab.objectmodel import ObjectGroup, get_uuid
from datalab.plugins.recipe_binding import check_recipe_inputs
from datalab.plugins.recipes import (
    RECIPE_RUN_RECORD_OPTION,
    RecipeCardinality,
    RecipeDescriptor,
    RecipeDiagnosticLevel,
    RecipeExecutionContext,
    RecipeInputs,
    RecipeObjectType,
    RecipeOutcome,
    RecipeResultOutput,
    RecipeRunRecord,
    RecipeRunStatus,
    RecipeValidationError,
)

if TYPE_CHECKING:
    from datalab.gui.main import DLMainWindow
    from datalab.gui.panel.base import BaseDataPanel

__all__ = ["RecipeCommitError", "RecipeRunner"]


[docs] class RecipeCommitError(RuntimeError): """Raised after a failed recipe workspace commit has been rolled back. Attributes: rollback_error: Exception raised by the rollback itself, or None when the workspace was successfully restored. """ def __init__(self, message: str, rollback_error: Exception | None = None) -> None: super().__init__(message) self.rollback_error = rollback_error
[docs] class RecipeRunner: """Validate, execute, and transactionally commit a recipe on Desktop.""" def __init__(self, mainwindow: DLMainWindow) -> None: self.mainwindow = mainwindow @staticmethod def _validate_parameters( descriptor: RecipeDescriptor, parameters: gds.DataSet | None ) -> None: """Validate parameters against the class declared by the recipe.""" if descriptor.parameter_class is None: if parameters is not None: raise RecipeValidationError( f"Recipe {descriptor.recipe_id!r} does not accept parameters" ) elif not isinstance(parameters, descriptor.parameter_class): raise RecipeValidationError( f"Recipe {descriptor.recipe_id!r} requires parameters of type " f"{descriptor.parameter_class.__name__}" ) @staticmethod def _normalize_inputs( descriptor: RecipeDescriptor, inputs: Mapping[str, Sequence[SignalObj | ImageObj]], ) -> RecipeInputs: """Validate and normalize recipe slot assignments to object tuples.""" if not isinstance(inputs, Mapping): raise RecipeValidationError("Recipe inputs must be a mapping") declared_ids = {slot.id for slot in descriptor.inputs} unknown_ids = set(inputs) - declared_ids if unknown_ids: unknown = ", ".join(sorted(repr(slot_id) for slot_id in unknown_ids)) raise RecipeValidationError(f"Unknown recipe input slot(s): {unknown}") normalized: dict[str, tuple[SignalObj | ImageObj, ...]] = {} for slot in descriptor.inputs: values = inputs.get(slot.id, ()) if not isinstance(values, Sequence): raise RecipeValidationError( f"Recipe input slot {slot.id!r} must contain a sequence" ) objects = tuple(values) if slot.cardinality is RecipeCardinality.ONE and len(objects) > 1: raise RecipeValidationError( f"Recipe input slot {slot.id!r} accepts exactly one object" ) if slot.required and not objects: raise RecipeValidationError( f"Required recipe input slot {slot.id!r} is empty" ) expected_type = ( SignalObj if slot.object_type is RecipeObjectType.SIGNAL else ImageObj ) if any(not isinstance(obj, expected_type) for obj in objects): raise RecipeValidationError( f"Recipe input slot {slot.id!r} accepts only " f"{slot.object_type.value} objects" ) if objects and len(objects) < slot.min_count: raise RecipeValidationError( f"Recipe input slot {slot.id!r} requires at least " f"{slot.min_count} objects" ) for requirement in slot.metadata: if requirement.required and any( obj.metadata.get(requirement.key) is None for obj in objects ): raise RecipeValidationError( f"Recipe input slot {slot.id!r} requires metadata " f"{requirement.key!r} on every object" ) normalized[slot.id] = objects return normalized def _panel_for_object(self, obj: SignalObj | ImageObj) -> BaseDataPanel: """Return the Desktop panel owning an output object's type.""" return ( self.mainwindow.signalpanel if isinstance(obj, SignalObj) else self.mainwindow.imagepanel ) def _prepare_outcome( self, descriptor: RecipeDescriptor, outcome: RecipeOutcome, parameters: gds.DataSet | None, inputs: RecipeInputs, started_at: str, ) -> RecipeOutcome: """Validate outputs and attach scalar results before workspace mutation.""" anchors = {output.id: output.value for output in outcome.objects} seen_uuids: set[str] = set() for output in outcome.objects: obj = output.value obj_uuid = get_uuid(obj) if obj_uuid in seen_uuids: raise RecipeValidationError( f"Recipe output objects share UUID {obj_uuid!r}" ) seen_uuids.add(obj_uuid) if any( panel.objmodel.has_uuid(obj_uuid) for panel in ( self.mainwindow.signalpanel, self.mainwindow.imagepanel, ) ): raise RecipeValidationError( f"Recipe output {output.id!r} already belongs to the workspace" ) prepared_results: list[RecipeResultOutput] = [] for output in outcome.results: func_name = f"{descriptor.recipe_id}:{output.id}" result = dataclasses.replace( output.value, func_name=func_name, attrs=dict(output.value.attrs), ) anchor = anchors[output.anchor_id] if isinstance(result, TableResult): TableAdapter(result).add_to(anchor, parameters) elif isinstance(result, GeometryResult): GeometryAdapter(result).add_to(anchor, parameters) prepared_results.append(dataclasses.replace(output, value=result)) record = RecipeRunRecord( run_id=str(uuid4()), plugin_id=descriptor.plugin_id, plugin_version=descriptor.plugin_version, recipe_id=descriptor.recipe_id, recipe_version=descriptor.version, parameters_json=( gds.dataset_to_json(parameters) if parameters is not None else "null" ), input_uuids={ slot_id: tuple(get_uuid(obj) for obj in objects) for slot_id, objects in inputs.items() }, output_uuids={ output.id: get_uuid(output.value) for output in outcome.objects }, datalab_version=datalab_version, sigima_version=sigima.__version__, status=RecipeRunStatus.COMPLETED, started_at=started_at, finished_at=self._utc_now(), ) for output in outcome.objects: output.value.set_metadata_option( RECIPE_RUN_RECORD_OPTION, record.to_dict(), ) return dataclasses.replace(outcome, results=tuple(prepared_results)) @staticmethod def _utc_now() -> str: """Return the current UTC time in an ISO 8601 JSON-friendly form.""" return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def _rollback( self, added_objects: list[tuple[BaseDataPanel, SignalObj | ImageObj]], added_groups: list[tuple[BaseDataPanel, ObjectGroup]], ) -> None: """Remove every model, tree, and plot artifact created by a commit.""" touched_panels = list( dict.fromkeys(panel for panel, _item in (*added_groups, *added_objects)) ) with ExitStack() as cleanup: for panel in touched_panels: cleanup.callback(panel.SIG_OBJECT_REMOVED.emit) cleanup.callback(panel.selection_changed, update_items=True) cleanup.callback(panel.objview.update_tree) for panel, group in added_groups: cleanup.callback(self._remove_added_group, panel, group) for panel, obj in added_objects: cleanup.callback( panel._remove_added_object, # pylint: disable=protected-access obj, ) @staticmethod def _remove_added_group(panel: BaseDataPanel, group: ObjectGroup) -> None: """Remove a group created by the current commit.""" panel.objview.remove_item(get_uuid(group), refresh=False) if group in panel.objmodel.get_groups(): panel.objmodel.remove_group(group) def _commit(self, outcome: RecipeOutcome, group_title: str) -> None: """Commit recipe outputs to both panels, compensating any failure.""" if not self.mainwindow.confirm_memory_state(): raise RecipeCommitError("Recipe commit cancelled due to low memory") modified_before = self.mainwindow.is_modified() current_panel_before = self.mainwindow.get_current_panel() added_groups: list[tuple[BaseDataPanel, ObjectGroup]] = [] added_objects: list[tuple[BaseDataPanel, SignalObj | ImageObj]] = [] groups_by_panel: dict[BaseDataPanel, ObjectGroup] = {} objects_by_panel: dict[BaseDataPanel, list[SignalObj | ImageObj]] = {} try: for output in outcome.objects: panel = self._panel_for_object(output.value) objects_by_panel.setdefault(panel, []).append(output.value) for panel in objects_by_panel: group = panel.add_group(group_title) groups_by_panel[panel] = group added_groups.append((panel, group)) for panel, objects in objects_by_panel.items(): group = groups_by_panel[panel] panel._add_objects( # pylint: disable=protected-access objects, group_id=get_uuid(group), set_current=False, ) added_objects.extend((panel, obj) for obj in objects) if outcome.objects: last_object = outcome.objects[-1].value last_panel = self._panel_for_object(last_object) self.mainwindow.set_current_panel(last_panel) last_panel.objview.select_objects([last_object]) except Exception as exc: rollback_error: Exception | None = None try: self._rollback(added_objects, added_groups) except Exception as error: # pylint: disable=broad-except rollback_error = error finally: try: self.mainwindow.set_modified(modified_before) finally: self.mainwindow.set_current_panel(current_panel_before) if rollback_error is not None: raise RecipeCommitError( f"{exc} (rollback also failed: {rollback_error})", rollback_error=rollback_error, ) from exc raise RecipeCommitError(str(exc)) from exc
[docs] def run( self, descriptor: RecipeDescriptor, inputs: Mapping[str, Sequence[SignalObj | ImageObj]], parameters: gds.DataSet | None = None, context: RecipeExecutionContext | None = None, group_title: str | None = None, ) -> RecipeOutcome: """Validate, execute, and atomically commit one recipe invocation.""" if not isinstance(descriptor, RecipeDescriptor): raise TypeError("Recipe runner requires a RecipeDescriptor") self._validate_parameters(descriptor, parameters) normalized_inputs = self._normalize_inputs(descriptor, inputs) errors = [ diagnostic.message for diagnostic in check_recipe_inputs( descriptor, normalized_inputs, parameters ) if diagnostic.level is RecipeDiagnosticLevel.ERROR ] if errors: raise RecipeValidationError("\n".join(errors)) title = descriptor.title if group_title is None else group_title if not isinstance(title, str) or not title.strip(): raise RecipeValidationError("Recipe output group title must be non-empty") if context is None: context = RecipeExecutionContext() elif not isinstance(context, RecipeExecutionContext): raise TypeError("Recipe context must be a RecipeExecutionContext") started_at = self._utc_now() context.raise_if_cancelled() outcome = descriptor.run(normalized_inputs, parameters, context) if not isinstance(outcome, RecipeOutcome): raise TypeError("Recipe callable must return a RecipeOutcome") context.raise_if_cancelled() prepared_outcome = self._prepare_outcome( descriptor, outcome, parameters, normalized_inputs, started_at, ) context.raise_if_cancelled() self._commit(prepared_outcome, title) return prepared_outcome