diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index b3f919e0..cdeca9d0 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -44,7 +44,7 @@ class JsonPayload: else: val = default - if convert: + if convert and (val is not None): val = convert(val) return val diff --git a/openflexure_microscope/api/v1/blueprints/plugins.py b/openflexure_microscope/api/v1/blueprints/plugins.py index 9aca0f80..9c32dacd 100644 --- a/openflexure_microscope/api/v1/blueprints/plugins.py +++ b/openflexure_microscope/api/v1/blueprints/plugins.py @@ -1,15 +1,33 @@ from openflexure_microscope.api.v1.views import MicroscopeViewPlugin -from flask import Blueprint +from flask import Blueprint, jsonify +from openflexure_microscope.api.v1.views import MicroscopeView import logging import warnings +class PluginSchemaAPI(MicroscopeView): + def get(self): + """ + Return the current plugin schemas + + .. :quickref: Plugin; Get schemas + + """ + out = self.microscope.plugin.schemas + return jsonify(out) + def construct_blueprint(microscope_obj): blueprint = Blueprint('plugin_blueprint', __name__) + # Create a base route to return plugin API schemas, if any exist + blueprint.add_url_rule( + '/', + view_func=PluginSchemaAPI.as_view('plugin_api_schema', microscope=microscope_obj) + ) + all_routes = [] # For each plugin attached to the microscope object @@ -18,27 +36,37 @@ def construct_blueprint(microscope_obj): # If plugin contains valid endpoints if hasattr(plugin_obj, 'api_views') and isinstance(plugin_obj.api_views, dict): + # We'll keep a record of how each route was expanded + expanded_routes = {} + # For each defined endpoint for view_route, view_class in plugin_obj.api_views.items(): # Remove all leading slashes from view route - while view_route[0] == '/': - view_route = view_route[1:] + cleaned_route = view_route + while cleaned_route[0] == '/': + cleaned_route = cleaned_route[1:] # Construct a full view route from the plugin name - full_view_route = "/{}/{}".format(plugin_name, view_route) + full_view_route = "/{}/{}".format(plugin_name, cleaned_route) logging.debug(full_view_route) + # Record how the view_route got expanded + expanded_routes[view_route] = full_view_route + # Check if endpoint name clashes if full_view_route not in all_routes and issubclass(view_class, MicroscopeViewPlugin): # Add route to main route dictionary all_routes.append(full_view_route) + # Create a Python-safe name for the route + plugin_route_id = 'plugin{}'.format(full_view_route).replace('/', '_') + # Add route to the plugins blueprint blueprint.add_url_rule( full_view_route, view_func=view_class.as_view( - 'plugin_{}'.format(full_view_route).replace('/', '_'), + plugin_route_id, microscope=microscope_obj, plugin=plugin_obj ) @@ -52,9 +80,23 @@ def construct_blueprint(microscope_obj): ) ) + # If plugin includes an API schema + if hasattr(plugin_obj, 'api_schema') and isinstance(plugin_obj.api_schema, dict): + schema = plugin_obj.api_schema + # TODO: Validate schema? We need to make sure no single plugin can break all plugins. + schema['id'] = plugin_name + if 'forms' in schema and isinstance(schema['forms'], list): + for form in schema['forms']: + if 'route' in form and form['route'] in expanded_routes.keys(): + form['route'] = expanded_routes[form['route']] + else: + logging.warn("No valid expandable route found for {}".format(form['route'])) + + # Store the complete schema in Microscope().plugin.schema + microscope_obj.plugin.schemas.append(schema) + else: warnings.warn( "No valid 'api_views' dictionary found in {}".format(plugin_obj) ) - return blueprint diff --git a/openflexure_microscope/plugins/default/autofocus/api.py b/openflexure_microscope/plugins/default/autofocus/api.py index 9670540f..a737e7fb 100644 --- a/openflexure_microscope/plugins/default/autofocus/api.py +++ b/openflexure_microscope/plugins/default/autofocus/api.py @@ -32,9 +32,9 @@ class FastAutofocusAPI(MicroscopeViewPlugin): # Figure out the parameters to use dz = payload.param("dz", default=2000, convert=int) - backlash = payload.param("backlash", default=None, convert=int) + backlash = payload.param("backlash", default=0, convert=int) if backlash < 0: - backlash = None + backlash = 0 logging.info("Running autofocus...") task = self.microscope.task.start(self.plugin.fast_autofocus, dz, backlash=backlash) diff --git a/openflexure_microscope/plugins/default/camera_calibration/plugin.py b/openflexure_microscope/plugins/default/camera_calibration/plugin.py index ccd0cd2c..9096a91e 100644 --- a/openflexure_microscope/plugins/default/camera_calibration/plugin.py +++ b/openflexure_microscope/plugins/default/camera_calibration/plugin.py @@ -7,27 +7,9 @@ import logging from .recalibrate_utils import recalibrate_camera, auto_expose_and_freeze_settings -API_SCHEMA = { - 'icon': 'lifesaver', - 'requireConnection': True, - 'forms': [ - { - 'route': '/recalibrate', - 'schema': [ - { - 'fieldType': "htmlBlock", - 'name': "heading", - 'content': "This is different form in a plugin!" - } - ] - } - ] -} class RecalibrateAPIView(MicroscopeViewPlugin): def post(self): - payload = JsonPayload(request) - logging.info("Starting microscope recalibration...") task = self.microscope.task.start(self.plugin.recalibrate) @@ -44,8 +26,6 @@ class Plugin(MicroscopePlugin): '/recalibrate': RecalibrateAPIView, } - api_schema = API_SCHEMA - def recalibrate(self): """Reset the camera's settings. diff --git a/openflexure_microscope/plugins/default/scan/plugin.py b/openflexure_microscope/plugins/default/scan/plugin.py index 06bb87a7..dbf37b0c 100644 --- a/openflexure_microscope/plugins/default/scan/plugin.py +++ b/openflexure_microscope/plugins/default/scan/plugin.py @@ -2,6 +2,7 @@ import time import numpy as np from typing import Tuple import uuid +import itertools import logging from openflexure_microscope.camera.base import generate_basename diff --git a/openflexure_microscope/plugins/loader.py b/openflexure_microscope/plugins/loader.py index 35bb00f9..74b40a70 100644 --- a/openflexure_microscope/plugins/loader.py +++ b/openflexure_microscope/plugins/loader.py @@ -126,7 +126,8 @@ class PluginMount(object): """ def __init__(self, parent): self.parent = parent - self.plugins = [] + self.plugins = [] # List of plugin objects + self.schemas = [] # List of plugin schemas logging.info("Creating plugin mount") @property diff --git a/openflexure_microscope/plugins/testing/__init__.py b/openflexure_microscope/plugins/testing/__init__.py index f42b093e..e69de29b 100644 --- a/openflexure_microscope/plugins/testing/__init__.py +++ b/openflexure_microscope/plugins/testing/__init__.py @@ -1 +0,0 @@ -from .plugin import Plugin \ No newline at end of file diff --git a/openflexure_microscope/plugins/testing/plugin.py b/openflexure_microscope/plugins/testing/plugin.py deleted file mode 100644 index 719eb62b..00000000 --- a/openflexure_microscope/plugins/testing/plugin.py +++ /dev/null @@ -1,13 +0,0 @@ -from openflexure_microscope.plugins import MicroscopePlugin - - -class Plugin(MicroscopePlugin): - """ - A set of default plugins - """ - - def identify(self): - """ - Tests for access to Microscope.camera, and Microscope.stage - """ - return self.microscope.camera, self.microscope.stage diff --git a/openflexure_microscope/plugins/testing/schema_example/__init__.py b/openflexure_microscope/plugins/testing/schema_example/__init__.py new file mode 100644 index 00000000..3e9a8c4d --- /dev/null +++ b/openflexure_microscope/plugins/testing/schema_example/__init__.py @@ -0,0 +1,2 @@ +from .plugin import ExamplePlugin +from . import api \ No newline at end of file diff --git a/openflexure_microscope/plugins/testing/schema_example/api.py b/openflexure_microscope/plugins/testing/schema_example/api.py new file mode 100644 index 00000000..67f92b62 --- /dev/null +++ b/openflexure_microscope/plugins/testing/schema_example/api.py @@ -0,0 +1,64 @@ +from openflexure_microscope.api.utilities import JsonPayload +from openflexure_microscope.api.v1.views import MicroscopeViewPlugin +from openflexure_microscope.exceptions import TaskDeniedException + +from flask import request, Response, escape, jsonify +import logging + +class DoAPI(MicroscopeViewPlugin): + """ + A simple example API plugin + """ + + def get(self): + return jsonify(self.plugin.get_values_dict()) + + def post(self): + # Get payload JSON + payload = JsonPayload(request) + + # Extract a values from the JSON payload. + val_int = payload.param('val_int', default=None, convert=int) + val_str = payload.param('val_str', default=None, convert=str) + val_radio = payload.param('val_radio', default=None) + val_check = payload.param('val_check', default=None) + val_select = payload.param('val_select', default=None) + val_disposable = payload.param('val_disposable', default=None) + + self.plugin.set_values( + val_int, + val_str, + val_radio, + val_check, + val_select, + val_disposable + ) + + print(self.plugin.get_values_dict()) + + return jsonify({ + 'response': 'completed' + }) + +class TaskAPI(MicroscopeViewPlugin): + """ + A task example API plugin + """ + + def get(self): + return jsonify({ + 'run_time': self.plugin.run_time + }) + + def post(self): + # Get payload JSON + payload = JsonPayload(request) + + # Extract a values from the JSON payload. + val_int = payload.param('run_time', default=5, convert=int) + + logging.info("Running task...") + task = self.microscope.task.start(self.plugin.generate_random_numbers_for_a_while, val_int) + + # return a handle on the autofocus task + return jsonify(task.state), 202 \ No newline at end of file diff --git a/openflexure_microscope/plugins/testing/schema_example/plugin.py b/openflexure_microscope/plugins/testing/schema_example/plugin.py new file mode 100644 index 00000000..da2264c9 --- /dev/null +++ b/openflexure_microscope/plugins/testing/schema_example/plugin.py @@ -0,0 +1,72 @@ +import random +import time +import os +import json +from openflexure_microscope.plugins import MicroscopePlugin + +from .api import DoAPI, TaskAPI + +HERE = os.path.dirname(os.path.realpath(__file__)) +SCHEMA_PATH = os.path.join(HERE, "schema.json") + +class ExamplePlugin(MicroscopePlugin): + """ + An example plugin using a comprehensive schema + """ + global SCHEMA_PATH + + with open(SCHEMA_PATH, 'r') as sc: + api_schema = json.load(sc) + + api_views = { + '/do': DoAPI, + '/task': TaskAPI + } + + def __init__(self): + self.val_int = 10 + self.val_str = "Hello" + self.val_radio = "First" + self.val_check = ["Foo", "Bar"] + self.val_select = "Most" + self.val_unused = "I'm an unused string, here to confuse the form parsing" + + self.run_time = 5 + + def set_values(self, val_int, val_str, val_radio, val_check, val_select, val_disposable): + """ + Demonstrate a plugin with schema + """ + if val_int: + self.val_int = int(val_int) + if val_str: + self.val_str = str(val_str) + if val_radio: + self.val_radio = val_radio + if val_check is not None: + print(val_check) + self.val_check = val_check if (type(val_check) is list) else [val_check] + if val_select: + self.val_select = val_select + + if val_disposable: + print("DISPOSABLE VALUE: {}".format(val_disposable)) + + def get_values_dict(self): + return { + 'val_int': self.val_int, + 'val_str': self.val_str, + 'val_radio': self.val_radio, + 'val_check': self.val_check, + 'val_select': self.val_select, + 'val_unused': self.val_unused + } + + def generate_random_numbers_for_a_while(self, run_time: int): + self.run_time = run_time + vals = [] + for _ in range(run_time): + vals.append(random.random()) + time.sleep(1) + + return vals \ No newline at end of file diff --git a/openflexure_microscope/plugins/testing/schema_example/schema.json b/openflexure_microscope/plugins/testing/schema_example/schema.json new file mode 100644 index 00000000..4aee4dcd --- /dev/null +++ b/openflexure_microscope/plugins/testing/schema_example/schema.json @@ -0,0 +1,80 @@ +{ + "id": "test-plugin", + "icon": "pets", + "forms": [ + { + "name": "Simple request", + "isCollapsible": false, + "isTask": false, + "selfUpdate": true, + "route": "/do", + "submitLabel": "Do things", + "schema": [ + [ + { + "fieldType": "numberInput", + "placeholder": "Some integer", + "name": "val_int", + "label": "Number value", + "minValue": 0 + }, + { + "fieldType": "textInput", + "placeholder": "Some string", + "label": "String value", + "name": "val_str" + } + ], + [ + { + "fieldType": "radioList", + "name": "val_radio", + "label": "Radio value", + "options": ["First", "Second", "Third"] + }, + { + "fieldType": "checkList", + "name": "val_check", + "label": "Checklist values", + "options": ["Foo", "Bar", "Baz"] + } + ], + { + "fieldType": "htmlBlock", + "name": "html_block", + "content": "This is a block of HTML in a plugin!
I can do paragraph breaks and stuff." + }, + { + "fieldType": "selectList", + "name": "val_select", + "multi": false, + "label": "Some selection", + "options": ["Most", "Average", "Least"] + }, + + { + "fieldType": "textInput", + "placeholder": "Some string", + "label": "Non-persistent string", + "name": "val_disposable" + } + ] + }, + { + "name": "Task form", + "isTask": true, + "selfUpdate": true, + "route": "/task", + "submitLabel": "Start task", + "schema": [ + { + "fieldType": "numberInput", + "placeholder": "", + "name": "run_time", + "label": "Run time (seconds)", + "minValue": 1 + } + ] + } + ] +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 44c01cfd..f861631b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.masonry.api" [tool.poetry] name = "openflexure_microscope" -version = "1.1.0-beta.1" +version = "1.1.0-beta.2" description = "Python module, and Flask-based web API, to run the OpenFlexure Microscope." authors = [