Merge branch 'api-schema' into 'master'
Support for plugin schemas to render GUI elements See merge request openflexure/openflexure-microscope-server!25
This commit is contained in:
commit
5d5ae7594e
13 changed files with 273 additions and 45 deletions
|
|
@ -44,7 +44,7 @@ class JsonPayload:
|
||||||
else:
|
else:
|
||||||
val = default
|
val = default
|
||||||
|
|
||||||
if convert:
|
if convert and (val is not None):
|
||||||
val = convert(val)
|
val = convert(val)
|
||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,33 @@
|
||||||
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
|
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 logging
|
||||||
import warnings
|
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):
|
def construct_blueprint(microscope_obj):
|
||||||
|
|
||||||
blueprint = Blueprint('plugin_blueprint', __name__)
|
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 = []
|
all_routes = []
|
||||||
|
|
||||||
# For each plugin attached to the microscope object
|
# For each plugin attached to the microscope object
|
||||||
|
|
@ -18,27 +36,37 @@ def construct_blueprint(microscope_obj):
|
||||||
# If plugin contains valid endpoints
|
# If plugin contains valid endpoints
|
||||||
if hasattr(plugin_obj, 'api_views') and isinstance(plugin_obj.api_views, dict):
|
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 each defined endpoint
|
||||||
for view_route, view_class in plugin_obj.api_views.items():
|
for view_route, view_class in plugin_obj.api_views.items():
|
||||||
|
|
||||||
# Remove all leading slashes from view route
|
# Remove all leading slashes from view route
|
||||||
while view_route[0] == '/':
|
cleaned_route = view_route
|
||||||
view_route = view_route[1:]
|
while cleaned_route[0] == '/':
|
||||||
|
cleaned_route = cleaned_route[1:]
|
||||||
|
|
||||||
# Construct a full view route from the plugin name
|
# 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)
|
logging.debug(full_view_route)
|
||||||
|
|
||||||
|
# Record how the view_route got expanded
|
||||||
|
expanded_routes[view_route] = full_view_route
|
||||||
|
|
||||||
# Check if endpoint name clashes
|
# Check if endpoint name clashes
|
||||||
if full_view_route not in all_routes and issubclass(view_class, MicroscopeViewPlugin):
|
if full_view_route not in all_routes and issubclass(view_class, MicroscopeViewPlugin):
|
||||||
# Add route to main route dictionary
|
# Add route to main route dictionary
|
||||||
all_routes.append(full_view_route)
|
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
|
# Add route to the plugins blueprint
|
||||||
blueprint.add_url_rule(
|
blueprint.add_url_rule(
|
||||||
full_view_route,
|
full_view_route,
|
||||||
view_func=view_class.as_view(
|
view_func=view_class.as_view(
|
||||||
'plugin_{}'.format(full_view_route).replace('/', '_'),
|
plugin_route_id,
|
||||||
microscope=microscope_obj,
|
microscope=microscope_obj,
|
||||||
plugin=plugin_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:
|
else:
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
"No valid 'api_views' dictionary found in {}".format(plugin_obj)
|
"No valid 'api_views' dictionary found in {}".format(plugin_obj)
|
||||||
)
|
)
|
||||||
|
|
||||||
return blueprint
|
return blueprint
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,9 @@ class FastAutofocusAPI(MicroscopeViewPlugin):
|
||||||
|
|
||||||
# Figure out the parameters to use
|
# Figure out the parameters to use
|
||||||
dz = payload.param("dz", default=2000, convert=int)
|
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:
|
if backlash < 0:
|
||||||
backlash = None
|
backlash = 0
|
||||||
|
|
||||||
logging.info("Running autofocus...")
|
logging.info("Running autofocus...")
|
||||||
task = self.microscope.task.start(self.plugin.fast_autofocus, dz, backlash=backlash)
|
task = self.microscope.task.start(self.plugin.fast_autofocus, dz, backlash=backlash)
|
||||||
|
|
|
||||||
|
|
@ -7,27 +7,9 @@ import logging
|
||||||
|
|
||||||
from .recalibrate_utils import recalibrate_camera, auto_expose_and_freeze_settings
|
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': "<b>This is different form in a plugin!</b>"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
class RecalibrateAPIView(MicroscopeViewPlugin):
|
class RecalibrateAPIView(MicroscopeViewPlugin):
|
||||||
def post(self):
|
def post(self):
|
||||||
payload = JsonPayload(request)
|
|
||||||
|
|
||||||
logging.info("Starting microscope recalibration...")
|
logging.info("Starting microscope recalibration...")
|
||||||
task = self.microscope.task.start(self.plugin.recalibrate)
|
task = self.microscope.task.start(self.plugin.recalibrate)
|
||||||
|
|
||||||
|
|
@ -44,8 +26,6 @@ class Plugin(MicroscopePlugin):
|
||||||
'/recalibrate': RecalibrateAPIView,
|
'/recalibrate': RecalibrateAPIView,
|
||||||
}
|
}
|
||||||
|
|
||||||
api_schema = API_SCHEMA
|
|
||||||
|
|
||||||
def recalibrate(self):
|
def recalibrate(self):
|
||||||
"""Reset the camera's settings.
|
"""Reset the camera's settings.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import time
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
import uuid
|
import uuid
|
||||||
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from openflexure_microscope.camera.base import generate_basename
|
from openflexure_microscope.camera.base import generate_basename
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,8 @@ class PluginMount(object):
|
||||||
"""
|
"""
|
||||||
def __init__(self, parent):
|
def __init__(self, parent):
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
self.plugins = []
|
self.plugins = [] # List of plugin objects
|
||||||
|
self.schemas = [] # List of plugin schemas
|
||||||
logging.info("Creating plugin mount")
|
logging.info("Creating plugin mount")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from .plugin import Plugin
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
from .plugin import ExamplePlugin
|
||||||
|
from . import api
|
||||||
64
openflexure_microscope/plugins/testing/schema_example/api.py
Normal file
64
openflexure_microscope/plugins/testing/schema_example/api.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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": "<i>This is a block of HTML in a plugin!</i><br>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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "poetry.masonry.api"
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "openflexure_microscope"
|
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."
|
description = "Python module, and Flask-based web API, to run the OpenFlexure Microscope."
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue