Restructured labthings
This commit is contained in:
parent
206dd521ec
commit
a8a3cafa97
39 changed files with 275 additions and 269 deletions
|
|
@ -0,0 +1 @@
|
|||
EXTENSION_NAME = "flask-lab"
|
||||
37
openflexure_microscope/common/flask_labthings/exceptions.py
Normal file
37
openflexure_microscope/common/flask_labthings/exceptions.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from flask import jsonify, escape
|
||||
from werkzeug.exceptions import default_exceptions
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
|
||||
class JSONExceptionHandler(object):
|
||||
|
||||
"""
|
||||
A class to be registered as a Flask error handler,
|
||||
converts error codes into a JSON response
|
||||
"""
|
||||
|
||||
def __init__(self, app=None):
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def std_handler(self, error):
|
||||
if isinstance(error, HTTPException):
|
||||
message = error.description
|
||||
elif hasattr(error, "message"):
|
||||
message = error.message
|
||||
else:
|
||||
message = str(error)
|
||||
|
||||
status_code = error.code if isinstance(error, HTTPException) else 500
|
||||
|
||||
response = {"code": status_code, "message": escape(message)}
|
||||
return jsonify(response), status_code
|
||||
|
||||
def init_app(self, app):
|
||||
self.app = app
|
||||
self.register(HTTPException)
|
||||
for code, v in default_exceptions.items():
|
||||
self.register(code)
|
||||
|
||||
def register(self, exception_or_code, handler=None):
|
||||
self.app.errorhandler(exception_or_code)(handler or self.std_handler)
|
||||
171
openflexure_microscope/common/flask_labthings/fields.py
Normal file
171
openflexure_microscope/common/flask_labthings/fields.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
from marshmallow.fields import *
|
||||
from marshmallow import missing
|
||||
import re
|
||||
from flask import url_for
|
||||
from flask.views import View
|
||||
|
||||
_tpl_pattern = re.compile(r"\s*<\s*(\S*)\s*>\s*")
|
||||
|
||||
|
||||
def _tpl(val):
|
||||
"""Return value within ``< >`` if possible, else return ``None``."""
|
||||
match = _tpl_pattern.match(val)
|
||||
if match:
|
||||
return match.groups()[0]
|
||||
return None
|
||||
|
||||
|
||||
def _get_value(obj, key, default=missing):
|
||||
"""Slightly-modified version of marshmallow.utils.get_value.
|
||||
If a dot-delimited ``key`` is passed and any attribute in the
|
||||
path is `None`, return `None`.
|
||||
"""
|
||||
if "." in key:
|
||||
return _get_value_for_keys(obj, key.split("."), default)
|
||||
else:
|
||||
return _get_value_for_key(obj, key, default)
|
||||
|
||||
|
||||
def _get_value_for_keys(obj, keys, default):
|
||||
if len(keys) == 1:
|
||||
return _get_value_for_key(obj, keys[0], default)
|
||||
else:
|
||||
value = _get_value_for_key(obj, keys[0], default)
|
||||
# XXX This differs from the marshmallow implementation
|
||||
if value is None:
|
||||
return None
|
||||
return _get_value_for_keys(value, keys[1:], default)
|
||||
|
||||
|
||||
def _get_value_for_key(obj, key, default):
|
||||
if not hasattr(obj, "__getitem__"):
|
||||
return getattr(obj, key, default)
|
||||
|
||||
try:
|
||||
return obj[key]
|
||||
except (KeyError, IndexError, TypeError, AttributeError):
|
||||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
class URLFor(Field):
|
||||
"""Field that outputs the URL for an endpoint. Acts identically to
|
||||
Flask's ``url_for`` function, except that arguments can be pulled from the
|
||||
object to be serialized.
|
||||
Usage: ::
|
||||
url = URLFor('author_get', id='<id>')
|
||||
https_url = URLFor('author_get', id='<id>', _scheme='https', _external=True)
|
||||
:param str endpoint: Flask endpoint name.
|
||||
:param kwargs: Same keyword arguments as Flask's url_for, except string
|
||||
arguments enclosed in `< >` will be interpreted as attributes to pull
|
||||
from the object.
|
||||
"""
|
||||
|
||||
_CHECK_ATTRIBUTE = False
|
||||
|
||||
def __init__(self, endpoint, **kwargs):
|
||||
# Handle the case where endpoint is an attached flask View of any kind
|
||||
if isinstance(endpoint, type) and issubclass(endpoint, View):
|
||||
self.view_class = endpoint
|
||||
self.endpoint = None
|
||||
# Handle cases where endpoint is passed directly as a string
|
||||
elif type(endpoint) == str:
|
||||
self.view_class = None
|
||||
self.endpoint = endpoint
|
||||
else:
|
||||
raise RuntimeError(f"Endpoint {endpoint} is not a valid Flask view or endpoint string.")
|
||||
self.params = kwargs
|
||||
Field.__init__(self, **kwargs)
|
||||
|
||||
def _serialize(self, value, key, obj):
|
||||
"""Output the URL for the endpoint, given the kwargs passed to
|
||||
``__init__``.
|
||||
"""
|
||||
# Get endpoint from view_class, if needed
|
||||
if self.view_class and not self.endpoint:
|
||||
if hasattr(self.view_class, "endpoint"):
|
||||
self.endpoint = self.view_class.endpoint
|
||||
else:
|
||||
raise RuntimeError(f"Resource {self.endpoint} has not been added to a LabThing application. Unable to generate URL.")
|
||||
|
||||
# Generate URL for
|
||||
param_values = {}
|
||||
for name, attr_tpl in self.params.items():
|
||||
attr_name = _tpl(str(attr_tpl))
|
||||
if attr_name:
|
||||
attribute_value = _get_value(obj, attr_name, default=missing)
|
||||
if attribute_value is None:
|
||||
return None
|
||||
if attribute_value is not missing:
|
||||
param_values[name] = attribute_value
|
||||
else:
|
||||
raise AttributeError(
|
||||
"{attr_name!r} is not a valid "
|
||||
"attribute of {obj!r}".format(attr_name=attr_name, obj=obj)
|
||||
)
|
||||
else:
|
||||
param_values[name] = attr_tpl
|
||||
return url_for(self.endpoint, **param_values)
|
||||
|
||||
|
||||
UrlFor = URLFor
|
||||
|
||||
|
||||
class AbsoluteURLFor(URLFor):
|
||||
"""Field that outputs the absolute URL for an endpoint."""
|
||||
|
||||
def __init__(self, endpoint, **kwargs):
|
||||
kwargs["_external"] = True
|
||||
URLFor.__init__(self, endpoint=endpoint, **kwargs)
|
||||
|
||||
|
||||
AbsoluteUrlFor = AbsoluteURLFor
|
||||
|
||||
|
||||
def _rapply(d, func, *args, **kwargs):
|
||||
"""Apply a function to all values in a dictionary or list of dictionaries, recursively."""
|
||||
if isinstance(d, (tuple, list)):
|
||||
return [_rapply(each, func, *args, **kwargs) for each in d]
|
||||
if isinstance(d, dict):
|
||||
return {key: _rapply(value, func, *args, **kwargs) for key, value in d.items()}
|
||||
else:
|
||||
return func(d, *args, **kwargs)
|
||||
|
||||
|
||||
def _url_val(val, key, obj, **kwargs):
|
||||
"""Function applied by `HyperlinksField` to get the correct value in the
|
||||
schema.
|
||||
"""
|
||||
if isinstance(val, URLFor):
|
||||
return val.serialize(key, obj, **kwargs)
|
||||
else:
|
||||
return val
|
||||
|
||||
|
||||
class Hyperlinks(Field):
|
||||
"""Field that outputs a dictionary of hyperlinks,
|
||||
given a dictionary schema with :class:`~flask_marshmallow.fields.URLFor`
|
||||
objects as values.
|
||||
Example: ::
|
||||
_links = Hyperlinks({
|
||||
'self': URLFor('author', id='<id>'),
|
||||
'collection': URLFor('author_list'),
|
||||
})
|
||||
`URLFor` objects can be nested within the dictionary. ::
|
||||
_links = Hyperlinks({
|
||||
'self': {
|
||||
'href': URLFor('book', id='<id>'),
|
||||
'title': 'book detail'
|
||||
}
|
||||
})
|
||||
:param dict schema: A dict that maps names to
|
||||
:class:`~fields.URLFor` fields.
|
||||
"""
|
||||
|
||||
_CHECK_ATTRIBUTE = False
|
||||
|
||||
def __init__(self, schema, **kwargs):
|
||||
self.schema = schema
|
||||
Field.__init__(self, **kwargs)
|
||||
|
||||
def _serialize(self, value, attr, obj):
|
||||
return _rapply(self.schema, _url_val, key=attr, obj=obj)
|
||||
50
openflexure_microscope/common/flask_labthings/find.py
Normal file
50
openflexure_microscope/common/flask_labthings/find.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import logging
|
||||
from flask import current_app
|
||||
|
||||
from . import EXTENSION_NAME
|
||||
|
||||
|
||||
def current_labthing():
|
||||
app = current_app._get_current_object()
|
||||
if not app:
|
||||
return None
|
||||
logging.debug("Active app extensions:")
|
||||
logging.debug(app.extensions)
|
||||
logging.debug("Active labthing:")
|
||||
logging.debug(app.extensions[EXTENSION_NAME])
|
||||
return app.extensions[EXTENSION_NAME]
|
||||
|
||||
|
||||
def registered_plugins(labthing_instance=None):
|
||||
if not labthing_instance:
|
||||
labthing_instance = current_labthing()
|
||||
return labthing_instance.plugins
|
||||
|
||||
|
||||
def registered_devices(labthing_instance=None):
|
||||
if not labthing_instance:
|
||||
labthing_instance = current_labthing()
|
||||
return labthing_instance.devices
|
||||
|
||||
|
||||
def find_device(device_name, labthing_instance=None):
|
||||
if not labthing_instance:
|
||||
labthing_instance = current_labthing()
|
||||
|
||||
if device_name in labthing_instance.devices:
|
||||
return labthing_instance.devices[device_name]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def find_plugin(plugin_name, labthing_instance=None):
|
||||
if not labthing_instance:
|
||||
labthing_instance = current_labthing()
|
||||
|
||||
logging.debug("Current labthing:")
|
||||
logging.debug(current_labthing())
|
||||
|
||||
if plugin_name in labthing_instance.plugins:
|
||||
return labthing_instance.plugins[plugin_name]
|
||||
else:
|
||||
return None
|
||||
256
openflexure_microscope/common/flask_labthings/labthing.py
Normal file
256
openflexure_microscope/common/flask_labthings/labthing.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
from flask import url_for, jsonify
|
||||
|
||||
from .plugins import BasePlugin
|
||||
from .views.plugins import PluginListResource
|
||||
from .views.tasks import TaskList, TaskResource
|
||||
|
||||
from openflexure_microscope.common.labthings_core.utilities import get_docstring
|
||||
from .exceptions import JSONExceptionHandler
|
||||
|
||||
from . import EXTENSION_NAME
|
||||
|
||||
|
||||
class LabThing(object):
|
||||
def __init__(self, app=None, prefix: str = "", title: str = "", description: str = "", handle_errors: bool = True):
|
||||
self.app = app
|
||||
|
||||
self.devices = {}
|
||||
|
||||
self.plugins = {}
|
||||
|
||||
self.resources = []
|
||||
self.properties = {}
|
||||
self.actions = {}
|
||||
|
||||
self.endpoints = set()
|
||||
|
||||
self.url_prefix = prefix
|
||||
self.description = description
|
||||
self.title = title
|
||||
|
||||
if handle_errors:
|
||||
self.error_handler = JSONExceptionHandler()
|
||||
else:
|
||||
self.error_handler = None
|
||||
|
||||
if app is not None:
|
||||
self.init_app(app)
|
||||
|
||||
### Flask stuff
|
||||
|
||||
def init_app(self, app):
|
||||
app.teardown_appcontext(self.teardown)
|
||||
|
||||
# Register Flask extension
|
||||
app.extensions = getattr(app, "extensions", {})
|
||||
app.extensions[EXTENSION_NAME] = self
|
||||
|
||||
# Register error handler if one exists
|
||||
if self.error_handler:
|
||||
self.error_handler.init_app(self.app)
|
||||
|
||||
# Create base routes
|
||||
self._create_base_routes()
|
||||
|
||||
# Add resources, if registered before tying to a Flask app
|
||||
if len(self.resources) > 0:
|
||||
for resource, urls, endpoint, kwargs in self.resources:
|
||||
self._register_view(app, resource, *urls, endpoint=endpoint, **kwargs)
|
||||
|
||||
def teardown(self, exception):
|
||||
pass
|
||||
|
||||
def _create_base_routes(self):
|
||||
# Add thing description to root
|
||||
self.app.add_url_rule(self._complete_url("/td", ""), "td", self.td)
|
||||
# Add plugin overview
|
||||
self.add_resource(PluginListResource, "/plugins")
|
||||
self.register_property(PluginListResource)
|
||||
# Add task routes
|
||||
self.add_resource(TaskList, "/tasks", endpoint="TasksProperty")
|
||||
self.register_property(TaskList)
|
||||
self.add_resource(TaskResource, "/tasks/<id>", endpoint="TaskResource")
|
||||
|
||||
### Device stuff
|
||||
|
||||
def register_device(self, device_object, device_name: str):
|
||||
self.devices[device_name] = device_object
|
||||
|
||||
### Plugin stuff
|
||||
|
||||
def register_plugin(self, plugin_object):
|
||||
if isinstance(plugin_object, BasePlugin):
|
||||
self.plugins[plugin_object.name] = plugin_object
|
||||
else:
|
||||
raise TypeError("Plugin object must be an instance of BasePlugin")
|
||||
|
||||
for plugin_view_id, plugin_view in plugin_object.views.items():
|
||||
# Add route to the plugins blueprint
|
||||
self.add_resource(
|
||||
plugin_view["view"],
|
||||
"/plugins" + plugin_view["rule"],
|
||||
**plugin_view["kwargs"],
|
||||
)
|
||||
|
||||
for prop in plugin_object.properties:
|
||||
self.register_property(prop)
|
||||
|
||||
for action in plugin_object.actions:
|
||||
self.register_action(action)
|
||||
|
||||
### Resource stuff
|
||||
|
||||
def _complete_url(self, url_part, registration_prefix):
|
||||
"""This method is used to defer the construction of the final url in
|
||||
the case that the Api is created with a Blueprint.
|
||||
:param url_part: The part of the url the endpoint is registered with
|
||||
:param registration_prefix: The part of the url contributed by the
|
||||
blueprint. Generally speaking, BlueprintSetupState.url_prefix
|
||||
"""
|
||||
parts = [registration_prefix, self.url_prefix, url_part]
|
||||
return "".join([part for part in parts if part])
|
||||
|
||||
def register_property(self, resource):
|
||||
if hasattr(resource, "endpoint"):
|
||||
self.properties[resource.endpoint] = resource
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Resource {resource} has not yet been added. Cannot set as a property."
|
||||
)
|
||||
|
||||
def register_action(self, resource):
|
||||
if hasattr(resource, "endpoint"):
|
||||
self.actions[resource.endpoint] = resource
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Resource {resource} has not yet been added. Cannot set as an action."
|
||||
)
|
||||
|
||||
def add_resource(self, resource, *urls, endpoint=None, **kwargs):
|
||||
"""Adds a resource to the api.
|
||||
:param resource: the class name of your resource
|
||||
:type resource: :class:`Type[Resource]`
|
||||
:param urls: one or more url routes to match for the resource, standard
|
||||
flask routing rules apply. Any url variables will be
|
||||
passed to the resource method as args.
|
||||
:type urls: str
|
||||
:param endpoint: endpoint name (defaults to :meth:`Resource.__name__`
|
||||
Can be used to reference this route in :class:`fields.Url` fields
|
||||
:type endpoint: str
|
||||
:param resource_class_args: args to be forwarded to the constructor of
|
||||
the resource.
|
||||
:type resource_class_args: tuple
|
||||
:param resource_class_kwargs: kwargs to be forwarded to the constructor
|
||||
of the resource.
|
||||
:type resource_class_kwargs: dict
|
||||
Additional keyword arguments not specified above will be passed as-is
|
||||
to :meth:`flask.Flask.add_url_rule`.
|
||||
Examples::
|
||||
api.add_resource(HelloWorld, '/', '/hello')
|
||||
api.add_resource(Foo, '/foo', endpoint="foo")
|
||||
api.add_resource(FooSpecial, '/special/foo', endpoint="foo")
|
||||
"""
|
||||
endpoint = endpoint or resource.__name__
|
||||
if self.app is not None:
|
||||
self._register_view(self.app, resource, *urls, endpoint=endpoint, **kwargs)
|
||||
else:
|
||||
self.resources.append((resource, urls, endpoint, kwargs))
|
||||
|
||||
def resource(self, *urls, **kwargs):
|
||||
"""Wraps a :class:`~flask_restful.Resource` class, adding it to the
|
||||
api. Parameters are the same as :meth:`~flask_restful.Api.add_resource`.
|
||||
Example::
|
||||
app = Flask(__name__)
|
||||
api = restful.Api(app)
|
||||
@api.resource('/foo')
|
||||
class Foo(Resource):
|
||||
def get(self):
|
||||
return 'Hello, World!'
|
||||
"""
|
||||
|
||||
def decorator(cls):
|
||||
self.add_resource(cls, *urls, **kwargs)
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
def _register_view(self, app, resource, *urls, endpoint=None, **kwargs):
|
||||
endpoint = endpoint or resource.__name__
|
||||
self.endpoints.add(endpoint)
|
||||
resource_class_args = kwargs.pop("resource_class_args", ())
|
||||
resource_class_kwargs = kwargs.pop("resource_class_kwargs", {})
|
||||
|
||||
# NOTE: 'view_functions' is cleaned up from Blueprint class in Flask 1.0
|
||||
if endpoint in getattr(app, "view_functions", {}):
|
||||
previous_view_class = app.view_functions[endpoint].__dict__["view_class"]
|
||||
|
||||
# if you override the endpoint with a different class, avoid the collision by raising an exception
|
||||
if previous_view_class != resource:
|
||||
raise ValueError(
|
||||
"This endpoint (%s) is already set to the class %s."
|
||||
% (endpoint, previous_view_class.__name__)
|
||||
)
|
||||
|
||||
resource.endpoint = endpoint
|
||||
resource_func = resource.as_view(
|
||||
endpoint, *resource_class_args, **resource_class_kwargs
|
||||
)
|
||||
|
||||
for url in urls:
|
||||
# If we've got no Blueprint, just build a url with no prefix
|
||||
rule = self._complete_url(url, "")
|
||||
# Add the url to the application or blueprint
|
||||
app.add_url_rule(rule, view_func=resource_func, **kwargs)
|
||||
|
||||
### Utilities
|
||||
|
||||
def url_for(self, resource, **values):
|
||||
"""Generates a URL to the given resource.
|
||||
Works like :func:`flask.url_for`."""
|
||||
endpoint = resource.endpoint
|
||||
return url_for(endpoint, **values)
|
||||
|
||||
def owns_endpoint(self, endpoint):
|
||||
"""Tests if an endpoint name (not path) belongs to this Api. Takes
|
||||
in to account the Blueprint name part of the endpoint name.
|
||||
:param endpoint: The name of the endpoint being checked
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
if self.blueprint:
|
||||
if endpoint.startswith(self.blueprint.name):
|
||||
endpoint = endpoint.split(self.blueprint.name + '.', 1)[-1]
|
||||
else:
|
||||
return False
|
||||
return endpoint in self.endpoints
|
||||
|
||||
### Description
|
||||
def td(self):
|
||||
"""
|
||||
W3C-style Thing Description
|
||||
"""
|
||||
props = {}
|
||||
for key, prop in self.properties.items():
|
||||
props[key] = {}
|
||||
props[key]["title"] = prop.__name__
|
||||
props[key]["description"] = get_docstring(prop)
|
||||
props[key]["links"] = [{"href": self.url_for(prop, _external=True)}]
|
||||
|
||||
actions = {}
|
||||
for key, prop in self.actions.items():
|
||||
actions[key] = {}
|
||||
actions[key]["title"] = prop.__name__
|
||||
actions[key]["description"] = get_docstring(prop)
|
||||
actions[key]["links"] = [{"href": self.url_for(prop, _external=True)}]
|
||||
|
||||
td = {
|
||||
"id": url_for("td", _external=True),
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"properties": props,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
return jsonify(td)
|
||||
|
||||
# TODO: Add a nicer root resource like the old self-documenting system
|
||||
137
openflexure_microscope/common/flask_labthings/plugins.py
Normal file
137
openflexure_microscope/common/flask_labthings/plugins.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import logging
|
||||
import collections
|
||||
import copy
|
||||
|
||||
from importlib import util
|
||||
import sys
|
||||
|
||||
from openflexure_microscope.utilities import (
|
||||
camel_to_snake,
|
||||
snake_to_spine,
|
||||
)
|
||||
|
||||
|
||||
class BasePlugin:
|
||||
"""
|
||||
Parent class for all plugins.
|
||||
|
||||
Handles binding route views and forms.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, description=""):
|
||||
self._views = (
|
||||
{}
|
||||
) # Key: Full, Python-safe ID. Val: Original rule, and view class
|
||||
self._rules = {} # Key: Original rule. Val: View class
|
||||
self._gui = None
|
||||
|
||||
self.actions = []
|
||||
self.properties = []
|
||||
|
||||
self.name = name
|
||||
|
||||
self.methods = {}
|
||||
|
||||
@property
|
||||
def views(self):
|
||||
return self._views
|
||||
|
||||
def add_view(self, view_class, rule, **kwargs):
|
||||
# Remove all leading slashes from view route
|
||||
cleaned_rule = rule
|
||||
while cleaned_rule[0] == "/":
|
||||
cleaned_rule = cleaned_rule[1:]
|
||||
|
||||
# Expand the rule to include plugin name
|
||||
full_rule = "/{}/{}".format(self._name_uri_safe, cleaned_rule)
|
||||
|
||||
view_id = cleaned_rule.replace("/", "_").replace("<", "").replace(">", "")
|
||||
|
||||
# Create a Python-safe route ID
|
||||
logging.debug(view_id)
|
||||
|
||||
# Store route information in a dictionary
|
||||
d = {"rule": full_rule, "view": view_class, "kwargs": kwargs}
|
||||
|
||||
# Add view to private views dictionary
|
||||
self._views[view_id] = d
|
||||
# Store the rule expansion information
|
||||
self._rules[rule] = self._views[view_id]
|
||||
|
||||
def register_action(self, view_class):
|
||||
self.actions.append(view_class)
|
||||
|
||||
def register_property(self, view_class):
|
||||
self.properties.append(view_class)
|
||||
|
||||
@property
|
||||
def gui(self):
|
||||
print(self._gui)
|
||||
# Handle missing/no GUI
|
||||
if not self._gui:
|
||||
return None
|
||||
# Handle GUI as a dictionary-returning callable function
|
||||
elif isinstance(self._gui, collections.Callable):
|
||||
api_gui = self._gui()
|
||||
# Handle GUI as a static dictionary
|
||||
elif isinstance(self._gui, dict):
|
||||
api_gui = copy.deepcopy(self._gui)
|
||||
# Handle borked GUI
|
||||
else:
|
||||
raise ValueError(
|
||||
"GUI must be a dictionary, or a dictionary-returning function with no arguments."
|
||||
)
|
||||
|
||||
api_gui["id"] = self._name
|
||||
|
||||
if "forms" in api_gui and isinstance(api_gui["forms"], list):
|
||||
for form in api_gui["forms"]:
|
||||
if "route" in form and form["route"] in self._rules.keys():
|
||||
form["route"] = self._rules[form["route"]]["rule"]
|
||||
else:
|
||||
logging.warn(
|
||||
"No valid expandable route found for {}".format(form["route"])
|
||||
)
|
||||
return api_gui
|
||||
|
||||
def set_gui(self, form_dictionary: dict):
|
||||
self._gui = form_dictionary
|
||||
|
||||
@property
|
||||
def _name(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def _name_python_safe(self):
|
||||
name = camel_to_snake(self._name) # Camel to snake
|
||||
name = name.replace(" ", "_") # Spaces to snake
|
||||
return name
|
||||
|
||||
@property
|
||||
def _name_uri_safe(self):
|
||||
return snake_to_spine(self._name_python_safe)
|
||||
|
||||
def add_method(self, method, method_name):
|
||||
self.methods[method_name] = method
|
||||
|
||||
if not hasattr(self, method_name):
|
||||
setattr(self, method_name, method)
|
||||
else:
|
||||
logging.warning(
|
||||
"Unable to bind method to plugin. Method name already exists."
|
||||
)
|
||||
|
||||
|
||||
def find_plugins(plugin_path, module_name="plugins"):
|
||||
logging.debug(f"Loading plugins from {plugin_path}")
|
||||
|
||||
spec = util.spec_from_file_location(module_name, plugin_path)
|
||||
mod = util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
if hasattr(mod, "__plugins__"):
|
||||
return mod.__plugins__
|
||||
else:
|
||||
return None
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
from flask.views import MethodView
|
||||
|
||||
|
||||
class Resource(MethodView):
|
||||
"""Currently identical to MethodView
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
MethodView.__init__(self, *args, **kwargs)
|
||||
39
openflexure_microscope/common/flask_labthings/schema.py
Normal file
39
openflexure_microscope/common/flask_labthings/schema.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import flask
|
||||
import marshmallow
|
||||
|
||||
_MARSHMALLOW_VERSION_INFO = tuple(
|
||||
[int(part) for part in marshmallow.__version__.split(".") if part.isdigit()]
|
||||
)
|
||||
|
||||
sentinel = object()
|
||||
|
||||
|
||||
class Schema(marshmallow.Schema):
|
||||
"""Base serializer with which to define custom serializers.
|
||||
See `marshmallow.Schema` for more details about the `Schema` API.
|
||||
"""
|
||||
|
||||
def jsonify(self, obj, many=sentinel, *args, **kwargs):
|
||||
"""Return a JSON response containing the serialized data.
|
||||
:param obj: Object to serialize.
|
||||
:param bool many: Whether `obj` should be serialized as an instance
|
||||
or as a collection. If unset, defaults to the value of the
|
||||
`many` attribute on this Schema.
|
||||
:param kwargs: Additional keyword arguments passed to `flask.jsonify`.
|
||||
.. versionchanged:: 0.6.0
|
||||
Takes the same arguments as `marshmallow.Schema.dump`. Additional
|
||||
keyword arguments are passed to `flask.jsonify`.
|
||||
.. versionchanged:: 0.6.3
|
||||
The `many` argument for this method defaults to the value of
|
||||
the `many` attribute on the Schema. Previously, the `many`
|
||||
argument of this method defaulted to False, regardless of the
|
||||
value of `Schema.many`.
|
||||
"""
|
||||
if many is sentinel:
|
||||
many = self.many
|
||||
if _MARSHMALLOW_VERSION_INFO[0] >= 3:
|
||||
data = self.dump(obj, many=many)
|
||||
else:
|
||||
data = self.dump(obj, many=many).data
|
||||
return flask.jsonify(data, *args, **kwargs)
|
||||
13
openflexure_microscope/common/flask_labthings/utilities.py
Normal file
13
openflexure_microscope/common/flask_labthings/utilities.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from openflexure_microscope.common.labthings_core.utilities import get_docstring
|
||||
|
||||
|
||||
def description_from_view(view_class):
|
||||
methods = []
|
||||
for method_key in ["get", "post", "put", "delete"]:
|
||||
if hasattr(view_class, method_key):
|
||||
methods.append(method_key.upper())
|
||||
brief_description = get_docstring(view_class).partition("\n")[0].strip()
|
||||
|
||||
d = {"methods": methods, "description": brief_description}
|
||||
|
||||
return d
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
Top-level representation of attached and enabled plugins
|
||||
"""
|
||||
|
||||
from openflexure_microscope.common.labthings_core.utilities import get_docstring
|
||||
from ..utilities import description_from_view
|
||||
|
||||
from openflexure_microscope.common.flask_labthings.find import registered_plugins
|
||||
from openflexure_microscope.common.flask_labthings.resource import Resource
|
||||
|
||||
from flask import jsonify, url_for
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
def plugins_representation(plugin_dict):
|
||||
"""
|
||||
Generate a dictionary representation of all plugins, including Flask route URLs
|
||||
|
||||
Args:
|
||||
plugin_dict (dict): Dictionary of plugin objects
|
||||
|
||||
Returns:
|
||||
dict: Dictionary representation of all plugins
|
||||
"""
|
||||
plugins = {}
|
||||
|
||||
for plugin_name, plugin in plugin_dict.items():
|
||||
logging.debug(f"Representing plugin {plugin._name}")
|
||||
d = {
|
||||
"python_name": plugin._name_python_safe,
|
||||
"plugin": str(plugin),
|
||||
"links": {},
|
||||
"gui": plugin.gui,
|
||||
"description": get_docstring(plugin),
|
||||
}
|
||||
|
||||
for view_id, view_data in plugin.views.items():
|
||||
uri = url_for(f"PluginListResource", _external=True) + "/" + view_data["rule"][1:]
|
||||
# Make links dictionary if it doesn't yet exist
|
||||
view_d = {"href": uri, **description_from_view(view_data["view"])}
|
||||
|
||||
d["links"][view_id] = view_d
|
||||
|
||||
plugins[plugin_name] = d
|
||||
|
||||
return plugins
|
||||
|
||||
|
||||
class PluginListResource(Resource):
|
||||
def get(self):
|
||||
"""
|
||||
Return the current plugin forms
|
||||
|
||||
.. :quickref: Plugin; Get forms
|
||||
|
||||
Returns an array of present plugin forms (describing plugin user interfaces.)
|
||||
Please note, this is *not* a list of all enabled plugins, only those with associated
|
||||
user interface forms.
|
||||
|
||||
A complete list of enabled plugins can be found in the microscope state.
|
||||
|
||||
"""
|
||||
return jsonify(plugins_representation(registered_plugins()))
|
||||
59
openflexure_microscope/common/flask_labthings/views/tasks.py
Normal file
59
openflexure_microscope/common/flask_labthings/views/tasks.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from flask import abort
|
||||
|
||||
from openflexure_microscope.common.flask_labthings.schema import Schema
|
||||
from openflexure_microscope.common.flask_labthings import fields
|
||||
from openflexure_microscope.common.labthings_core import tasks
|
||||
from openflexure_microscope.common.flask_labthings.resource import Resource
|
||||
from openflexure_microscope.common.flask_labthings.utilities import description_from_view
|
||||
|
||||
|
||||
class TaskList(Resource):
|
||||
def get(self):
|
||||
return task_list_schema.jsonify(tasks.tasks())
|
||||
|
||||
|
||||
class TaskResource(Resource):
|
||||
def get(self, id):
|
||||
try:
|
||||
task = tasks.dict()[id]
|
||||
except KeyError:
|
||||
return abort(404) # 404 Not Found
|
||||
|
||||
# Return task state
|
||||
return task_schema.jsonify(task)
|
||||
|
||||
def delete(self, id):
|
||||
try:
|
||||
task = tasks.dict()[id]
|
||||
except KeyError:
|
||||
return abort(404) # 404 Not Found
|
||||
|
||||
task.terminate()
|
||||
|
||||
return task_schema.jsonify(task)
|
||||
|
||||
|
||||
class TaskSchema(Schema):
|
||||
_ID = fields.String(data_key="id")
|
||||
target_string = fields.String(data_key="function")
|
||||
_status = fields.String(data_key="status")
|
||||
progress = fields.String()
|
||||
data = fields.Raw()
|
||||
_return_value = fields.Raw(data_key="return")
|
||||
_start_time = fields.String(data_key="start_time")
|
||||
_end_time = fields.String(data_key="end_time")
|
||||
|
||||
# TODO: Add HTTP methods
|
||||
links = fields.Hyperlinks(
|
||||
{
|
||||
"self": {
|
||||
"href": fields.AbsoluteUrlFor(TaskResource, id="<id>"),
|
||||
"mimetype": "application/json",
|
||||
**description_from_view(TaskResource)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
task_schema = TaskSchema()
|
||||
task_list_schema = TaskSchema(many=True)
|
||||
Loading…
Add table
Add a link
Reference in a new issue