diff --git a/docs/source/plugins/developing_plugins.rst b/docs/source/plugins/developing_plugins.rst index ddc693778e..b7e27478f1 100644 --- a/docs/source/plugins/developing_plugins.rst +++ b/docs/source/plugins/developing_plugins.rst @@ -85,9 +85,9 @@ used to add functionality to custom panels. FiftyOne comes with a number of builtin :mod:`Python ` and -`TypeScript `_ -operators for common tasks that are intended for either user-facing or -internal plugin use. +`JavaScript `_ +operators for common tasks that are intended for either user-facing or internal +plugin use. .. image:: /images/plugins/operator-browser.gif :align: center @@ -726,16 +726,20 @@ subsequent sections. view = ctx.view n = len(view) - # Use ctx.trigger() to call other operators as necessary - ctx.trigger("operator_name", params={"key": value}) + # Use ctx.ops to trigger builtin operations + ctx.ops.clear_selected_samples() + ctx.ops.set_view(view=view) + + # Use ctx.trigger to call other operators as necessary + ctx.trigger("operator_uri", params={"key": value}) # If `execute_as_generator=True`, this method may yield multiple # messages for i, sample in enumerate(current_view, 1): # do some computation - yield ctx.trigger("set_progress", {"progress": i / n}) + yield ctx.ops.set_progress(progress=i/n) - yield ctx.trigger("reload_dataset") + yield ctx.ops.reload_dataset() return {"value": value, ...} @@ -845,6 +849,11 @@ contains the following properties: requested for the operation - `ctx.delegation_target` - the orchestrator to which the operation should be delegated, if applicable +- `ctx.ops` - an + :class:`Operations ` instance + that you can use to trigger builtin operations on the current context +- `ctx.trigger` - a method that you can use to trigger arbitrary operations + on the current context - `ctx.secrets` - a dict of :ref:`secrets ` for the plugin, if any - `ctx.results` - a dict containing the outputs of the `execute()` method, if @@ -1288,18 +1297,49 @@ Operator composition ~~~~~~~~~~~~~~~~~~~~ Many operators are designed to be composed with other operators to build up -more complex behaviors. This can be achieved by simply calling -:meth:`ctx.trigger() ` -from within the operator's -:meth:`execute() ` method to -invoke another operator with the appropriate parameters, if any. - -For example, many operations involve updating the current state of the App. -FiftyOne contains a number of -`builtin operators `_ -that you can trigger from within -:meth:`execute() ` to achieve -this with ease! +more complex behaviors. You can trigger other operations from within an +operator's :meth:`execute() ` +method via :meth:`ctx.ops ` and +:meth:`ctx.trigger `. + +The :meth:`ctx.ops ` property of an +execution context exposes all builtin +:mod:`Python ` and +`JavaScript `_ +in a conveniently documented functional interface. For example, many operations +involve updating the current state of the App: + +.. code-block:: python + :linenos: + + def execute(self, ctx): + # Dataset + ctx.ops.open_dataset("...") + ctx.ops.reload_dataset() + + # View/sidebar + ctx.ops.set_view(name="...") # saved view by name + ctx.ops.set_view(view=view) # arbitrary view + ctx.ops.clear_view() + ctx.ops.clear_sidebar_filters() + + # Selected samples + ctx.ops.set_selected_samples([...])) + ctx.ops.clear_selected_samples() + + # Selected labels + ctx.ops.set_selected_labels([...]) + ctx.ops.clear_selected_labels() + + # Panels + ctx.ops.open_panel("Embeddings") + ctx.ops.close_panel("Embeddings") + +The :meth:`ctx.trigger ` +property is a lower-level funtion that allows you to invoke arbitrary +operations by providing their URI and parameters, including all builtin +operations as well as any operations installed via custom plugins. For example, +here's how to trigger the same App-related operations from above: .. code-block:: python :linenos: @@ -1310,17 +1350,18 @@ this with ease! ctx.trigger("reload_dataset") # refreshes the App # View/sidebar + ctx.trigger("set_view", params=dict(name="...")) # saved view by name + ctx.trigger("set_view", params=dict(view=view._serialize())) # arbitrary view ctx.trigger("clear_view") ctx.trigger("clear_sidebar_filters") - ctx.trigger("set_view", params=dict(view=view._serialize())) # Selected samples - ctx.trigger("clear_selected_samples") ctx.trigger("set_selected_samples", params=dict(samples=[...])) + ctx.trigger("clear_selected_samples") # Selected labels - ctx.trigger("clear_selected_labels") ctx.trigger("set_selected_labels", params=dict(labels=[...])) + ctx.trigger("clear_selected_labels") # Panels ctx.trigger("open_panel", params=dict(name="Embeddings")) @@ -1333,15 +1374,14 @@ If your :ref:`operator's config ` declares that it is a generator via `execute_as_generator=True`, then its :meth:`execute() ` method should `yield` calls to +:meth:`ctx.ops ` methods or :meth:`ctx.trigger() `, -which triggers another operator and returns a +both of which trigger another operation and return a :class:`GeneratedMessage ` -containing the result of the invocation. +instance containing the result of the invocation. -For example, a common generator pattern is to use the -`builtin `_ -`set_progress` operator to render a progress bar tracking the progress of an -operation: +For example, a common generator pattern is to use the builtin `set_progress` +operator to render a progress bar tracking the progress of an operation: .. code-block:: python :linenos: @@ -1350,9 +1390,14 @@ operation: # render a progress bar tracking the execution for i in range(n): # [process a chunk here] + + # Option 1: ctx.ops + yield ctx.ops.set_progress(progress=i/n, label=f"Processed {i}/{n}") + + # Option 2: ctx.trigger yield ctx.trigger( "set_progress", - dict(progress=i / n, label=f"Processed {i}/{n}"), + dict(progress=i/n, label=f"Processed {i}/{n}"), ) .. note:: @@ -1520,10 +1565,7 @@ method as demonstrated below: ) def execute(self, ctx): - return ctx.trigger( - "open_panel", - params=dict(name="Histograms", isActive=True, layout="horizontal"), - ) + return ctx.ops.open_panel("Histograms", layout="horizontal", is_active=True) def register(p): p.register(OpenHistogramsPanel) diff --git a/fiftyone/operators/executor.py b/fiftyone/operators/executor.py index 1014ed88ae..44d14f18fa 100644 --- a/fiftyone/operators/executor.py +++ b/fiftyone/operators/executor.py @@ -23,6 +23,7 @@ from .decorators import coroutine_timeout from .message import GeneratedMessage, MessageType from .registry import OperatorRegistry +from .operations import Operations logger = logging.getLogger(__name__) @@ -442,6 +443,7 @@ def __init__( self._dataset = None self._view = None + self._ops = Operations(self) self._set_progress = set_progress self._delegated_operation_id = delegated_operation_id @@ -599,6 +601,13 @@ def secrets(self): required_keys=self._required_secret_keys, ) + @property + def ops(self): + """A :class:`fiftyone.operators.operations.Operations` instance that + you can use to trigger builtin operations on the current context. + """ + return self._ops + def secret(self, key): """Retrieves the secret with the given key. diff --git a/fiftyone/operators/operations.py b/fiftyone/operators/operations.py new file mode 100644 index 0000000000..6ba974b706 --- /dev/null +++ b/fiftyone/operators/operations.py @@ -0,0 +1,321 @@ +""" +FiftyOne operator execution. + +| Copyright 2017-2024, Voxel51, Inc. +| `voxel51.com `_ +| +""" +import json + +from bson import json_util + + +class Operations(object): + """Interface to trigger builtin operations on an execution context. + + Args: + ctx: an :class:`fiftyone.operators.executor.ExecutionContext` + """ + + def __init__(self, ctx): + self._ctx = ctx + + ########################################################################### + # Builtin Python operators + ########################################################################### + + def clone_selected_samples(self): + """Clone the selected samples in the App.""" + return self._ctx.trigger("clone_selected_samples") + + def clone_sample_field(self, field_name, new_field_name): + """Clone a sample field to a new field name. + + Args: + field_name: the name of the field to clone + new_field_name: the name for the new field + """ + return self._ctx.trigger( + "clone_sample_field", + params={ + "field_name": field_name, + "new_field_name": new_field_name, + }, + ) + + def rename_sample_field(self, field_name, new_field_name): + """Rename a sample field to a new field name. + + Args: + field_name: the name of the field to rename + new_field_name: the new name for the field + """ + return self._ctx.trigger( + "rename_sample_field", + params={ + "field_name": field_name, + "new_field_name": new_field_name, + }, + ) + + def clear_sample_field(self, field_name): + """Clear the contents of a sample field. + + Args: + field_name: the name of the field to clear + """ + return self._ctx.trigger( + "clear_sample_field", + params={"field_name": field_name}, + ) + + def delete_selected_samples(self): + """Delete the selected samples in the App.""" + return self._ctx.trigger("delete_selected_samples") + + def delete_selected_labels(self): + """Delete the selected labels in the App.""" + return self._ctx.trigger("delete_selected_labels") + + def delete_sample_field(self, field_name): + """Delete a sample field. + + Args: + field_name: the name of the field to delete + """ + return self._ctx.trigger( + "delete_sample_field", + params={"field_name": field_name}, + ) + + def print_stdout(self, message): + """Print a message to the standard output. + + Args: + message: the message to print + """ + return self._ctx.trigger("print_stdout", params={"msg": message}) + + def list_files(self, path=None, list_filesystems=False): + """List files in a directory or list filesystems. + + Args: + path (None): the path to list files from, or None to list + filesystems + list_filesystems (False): whether to list filesystems instead of + files + """ + return self._ctx.trigger( + "list_files", + params={"path": path, "list_filesystems": list_filesystems}, + ) + + ########################################################################### + # Builtin JS operators + ########################################################################### + + def reload_samples(self): + """Reload the sample grid in the App.""" + return self._ctx.trigger("reload_samples") + + def reload_dataset(self): + """Reload the dataset in the App.""" + return self._ctx.trigger("reload_dataset") + + def clear_selected_samples(self): + """Clear selected samples in the App.""" + return self._ctx.trigger("clear_selected_samples") + + def copy_view_as_json(self): + """Copy the current view in the App as JSON.""" + return self._ctx.trigger("copy_view_as_json") + + def view_from_json(self): + """Set the view in the App from JSON present in clipboard.""" + return self._ctx.trigger("view_from_clipboard") + + def open_panel(self, name, is_active=True, layout=None): + """Open a panel with the given name and layout options in the App. + + Args: + name: the name of the panel to open + is_active (True): whether to activate the panel immediately + layout (None): the layout orientation + ``("horizontal", "vertical")``, if applicable + """ + params = {"name": name, "isActive": is_active} + if layout is not None: + params["layout"] = layout + + return self._ctx.trigger("open_panel", params=params) + + def open_all_panels(self): + """Open all available panels in the App.""" + return self._ctx.trigger("open_all_panel") + + def close_panel(self, name): + """Close the panel with the given name in the App. + + Args: + name: the name of the panel to close + """ + return self._ctx.trigger("close_panel", params={"name": name}) + + def close_all_panels(self): + """Close all open panels in the App.""" + return self._ctx.trigger("close_all_panel") + + def split_panel(self, name, layout): + """Split the panel with the given layout in the App. + + Args: + name: the name of the panel to split + layout: the layout orientation ``("horizontal", "vertical")`` + """ + return self._ctx.trigger( + "split_panel", params={"name": name, "layout": layout} + ) + + def open_dataset(self, dataset_name): + """Open the specified dataset in the App. + + Args: + dataset_name: the name of the dataset to open + """ + return self._ctx.trigger( + "open_dataset", params={"dataset": dataset_name} + ) + + def clear_view(self): + """Clear the view bar in the App.""" + return self._ctx.trigger("clear_view") + + def clear_sidebar_filters(self): + """Clear all filters in the App's sidebar.""" + return self._ctx.trigger("clear_sidebar_filters") + + def clear_all_stages(self): + """Clear all selections, filters, and view stages from the App.""" + return self._ctx.trigger("clear_all_stages") + + def refresh_colors(self): + """Refresh the colors used in the App's UI.""" + return self._ctx.trigger("refresh_colors") + + def show_selected_samples(self): + """Show the samples that are currently selected in the App.""" + return self._ctx.trigger("show_selected_samples") + + def convert_extended_selection_to_selected_samples(self): + """Convert the extended selection to selected samples in the App.""" + return self._ctx.trigger( + "convert_extended_selection_to_selected_samples" + ) + + def set_selected_samples(self, samples): + """Select the specified samples in the App. + + Args: + samples: a list of sample IDs to select + """ + return self._ctx.trigger( + "set_selected_samples", params={"samples": samples} + ) + + def set_view(self, view=None, name=None): + """Set the current view in the App. + + Args: + view (None): a :class:`fiftyone.core.view.DatasetView` to load + name (None): the name of a saved view to load + """ + params = {} + if view is not None: + params["view"] = _serialize_view(view) + + if name is not None: + params["name"] = name + + return self._ctx.trigger("set_view", params=params) + + def show_samples(self, samples, use_extended_selection=False): + """Show specific samples, optionally using extended selection in the + App. + + Args: + samples: a list of sample IDs to show + use_extended_selection (False): whether to use the extended + selection feature + """ + params = { + "samples": samples, + "use_extended_selection": use_extended_selection, + } + return self._ctx.trigger("show_samples", params=params) + + def console_log(self, message): + """Log a message to the console. + + Args: + message: the message to log + """ + return self._ctx.trigger("console_log", params={"message": message}) + + def show_output(self, outputs, results): + """Show output in the App's UI. + + Args: + outputs: outputs to show + results: results to display + """ + return self._ctx.trigger( + "show_output", params={"outputs": outputs, "results": results} + ) + + def set_progress(self, label=None, progress=None, variant=None): + """Set the progress indicator in the App's UI. + + Args: + label (None): a label for the progress indicator + progress (None): a progress value to set + variant (None): the type of indicator ``("linear", "circular")`` + """ + params = {} + if label is not None: + params["label"] = label + if progress is not None: + params["progress"] = progress + if variant is not None: + params["variant"] = variant + + return self._ctx.trigger("set_progress", params=params) + + def test_operator(self, operator, raw_params): + """Test the operator with given parameters. + + Args: + operator: the operator to test + raw_params: raw parameters for the operator + """ + return self._ctx.trigger( + "test_operator", + params={"operator": operator, "raw_params": raw_params}, + ) + + def set_selected_labels(self, labels): + """Set the selected labels in the App. + + Args: + labels: the labels to select + """ + return self._ctx.trigger( + "set_selected_labels", params={"labels": labels} + ) + + def clear_selected_labels(self): + """Clear the selected labels in the App.""" + return self._ctx.trigger("clear_selected_labels") + + +def _serialize_view(view): + return json.loads(json_util.dumps(view._serialize()))