From 477abb6970735c51bf880f881dbc37e8e0af065c Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 13 Dec 2019 13:48:50 +0000 Subject: [PATCH 001/122] First draft of new API plugin system --- openflexure_microscope/api/app.py | 77 +++++------- openflexure_microscope/api/microscope.py | 48 +++++++ openflexure_microscope/common/__init__.py | 1 + .../common/labthings/__init__.py | 37 ++++++ .../common/labthings/fields.py | 0 .../common/labthings/plugins.py | 119 ++++++++++++++++++ .../common/labthings/schema.py | 0 .../common/labthings/views.py | 0 openflexure_microscope/config.py | 1 + 9 files changed, 234 insertions(+), 49 deletions(-) create mode 100644 openflexure_microscope/api/microscope.py create mode 100644 openflexure_microscope/common/labthings/__init__.py create mode 100644 openflexure_microscope/common/labthings/fields.py create mode 100644 openflexure_microscope/common/labthings/plugins.py create mode 100644 openflexure_microscope/common/labthings/schema.py create mode 100644 openflexure_microscope/common/labthings/views.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index c3db5f89..c7d2a7b9 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -16,25 +16,18 @@ from flask_cors import CORS from openflexure_microscope.api.exceptions import JSONExceptionHandler from openflexure_microscope.api.utilities import list_routes -from openflexure_microscope import Microscope - -from openflexure_microscope.camera.capture import build_captures_from_exif -from openflexure_microscope.config import settings_file_path, JSONEncoder +from openflexure_microscope.config import ( + settings_file_path, + JSONEncoder, + USER_PLUGINS_PATH, +) from openflexure_microscope.api.v1 import blueprints from openflexure_microscope.api import v2 -# Import device modules -# NB this will eventually be handled by the RC file, so you can choose what device -# class should be attached. -try: - from openflexure_microscope.camera.pi import PiCameraStreamer -except ImportError: - logging.warning("Unable to import PiCameraStreamer") -from openflexure_microscope.camera.mock import MockStreamer - -from openflexure_microscope.stage.sanga import SangaStage -from openflexure_microscope.stage.mock import MockStage +from openflexure_microscope.common.labthings import LabThing +from openflexure_microscope.common.labthings.plugins import find_plugins +from openflexure_microscope.api.microscope import default_microscope as api_microscope # Handle logging is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "") @@ -54,7 +47,7 @@ else: ) rotating_logfile = logging.handlers.RotatingFileHandler( - DEFAULT_LOGFILE, maxBytes=1000000, backupCount=7 + DEFAULT_LOGFILE, maxBytes=1_000_000, backupCount=7 ) error_handlers = [rotating_logfile, logging.StreamHandler()] @@ -65,39 +58,6 @@ else: root.setLevel(logging.getLogger("gunicorn.error").level) -# Create a dummy microscope object, with no hardware attachments -api_microscope = Microscope() - -# Initialise camera -logging.debug("Creating camera object...") -try: - api_camera = PiCameraStreamer() -except Exception as e: - logging.error(e) - logging.warning("No valid camera hardware found. Falling back to mock camera!") - api_camera = MockStreamer() - -# Initialise stage -logging.debug("Creating stage object...") -try: - api_stage = SangaStage() -except Exception as e: - logging.error(e) - logging.warning("No valid stage hardware found. Falling back to mock stage!") - api_stage = MockStage() - -# Attach devices to microscope -logging.debug("Attaching devices to microscope...") -api_microscope.attach(api_camera, api_stage) - -# Restore loaded capture array to camera object -logging.debug("Restoring captures...") -api_microscope.camera.images = build_captures_from_exif( - api_microscope.camera.paths["default"] -) - -logging.debug("Microscope successfully attached!") - # Generate API URI based on version from filename def uri(suffix, api_version, base=None): @@ -121,6 +81,10 @@ CORS(app, resources=r"*") # Make errors more API friendly handler = JSONExceptionHandler(app) +# Attach lab devices +labthing = LabThing(app) +labthing.register_device(api_microscope, "openflexure_microscope") + # WEBAPP ROUTES # Base routes @@ -176,6 +140,21 @@ v2_actions_blueprint = v2.blueprints.actions.construct_blueprint(api_microscope) app.register_blueprint(v2_actions_blueprint, url_prefix=uri("/actions", "v2")) +plugins = find_plugins(USER_PLUGINS_PATH) +print(plugins.__plugins__) + +for plugin_obj in plugins.__plugins__: + for plugin_view_id, plugin_view in plugin_obj.views.items(): + # Add route to the plugins blueprint + app.add_url_rule( + "/new-plugins" + plugin_view["rule"], + view_func=plugin_view["view"].as_view( + f"{plugin_obj._name_python_safe}_{plugin_view_id}" + ), + **plugin_view["kwargs"], + ) + + @app.route("/routes") def routes(): """ diff --git a/openflexure_microscope/api/microscope.py b/openflexure_microscope/api/microscope.py new file mode 100644 index 00000000..c950b83d --- /dev/null +++ b/openflexure_microscope/api/microscope.py @@ -0,0 +1,48 @@ +from openflexure_microscope import Microscope +from openflexure_microscope.camera.capture import build_captures_from_exif + +import logging + +# Import device modules +# NB this will eventually be handled by the RC file, so you can choose what device +# class should be attached. +try: + from openflexure_microscope.camera.pi import PiCameraStreamer +except ImportError: + logging.warning("Unable to import PiCameraStreamer") +from openflexure_microscope.camera.mock import MockStreamer + +from openflexure_microscope.stage.sanga import SangaStage +from openflexure_microscope.stage.mock import MockStage + +default_microscope = Microscope() + +# Initialise camera +logging.debug("Creating camera object...") +try: + api_camera = PiCameraStreamer() +except Exception as e: + logging.error(e) + logging.warning("No valid camera hardware found. Falling back to mock camera!") + api_camera = MockStreamer() + +# Initialise stage +logging.debug("Creating stage object...") +try: + api_stage = SangaStage() +except Exception as e: + logging.error(e) + logging.warning("No valid stage hardware found. Falling back to mock stage!") + api_stage = MockStage() + +# Attach devices to microscope +logging.debug("Attaching devices to microscope...") +default_microscope.attach(api_camera, api_stage) + +# Restore loaded capture array to camera object +logging.debug("Restoring captures...") +default_microscope.camera.images = build_captures_from_exif( + default_microscope.camera.paths["default"] +) + +logging.debug("Microscope successfully attached!") diff --git a/openflexure_microscope/common/__init__.py b/openflexure_microscope/common/__init__.py index e69de29b..80899ff0 100644 --- a/openflexure_microscope/common/__init__.py +++ b/openflexure_microscope/common/__init__.py @@ -0,0 +1 @@ +from . import tasks, labthings diff --git a/openflexure_microscope/common/labthings/__init__.py b/openflexure_microscope/common/labthings/__init__.py new file mode 100644 index 00000000..0ed5c46d --- /dev/null +++ b/openflexure_microscope/common/labthings/__init__.py @@ -0,0 +1,37 @@ +from flask import current_app, _app_ctx_stack + +EXTENSION_NAME = "flask-lab" + + +class LabThing(object): + def __init__(self, app=None): + self.app = app + self.devices = {} + if app is not None: + self.init_app(app) + + def init_app(self, app): + app.teardown_appcontext(self.teardown) + + app.extensions = getattr(app, "extensions", {}) + app.extensions[EXTENSION_NAME] = self + + def teardown(self, exception): + print(f"Tearing down devices: {self.devices}") + + def register_device(self, device_object, device_name: str): + self.devices[device_name] = device_object + + +def find_device(device_name): + app = current_app + + if not app: + return None + + pylot_instance = app.extensions[EXTENSION_NAME] + + if device_name in pylot_instance.devices: + return pylot_instance.devices[device_name] + else: + return None diff --git a/openflexure_microscope/common/labthings/fields.py b/openflexure_microscope/common/labthings/fields.py new file mode 100644 index 00000000..e69de29b diff --git a/openflexure_microscope/common/labthings/plugins.py b/openflexure_microscope/common/labthings/plugins.py new file mode 100644 index 00000000..3926abbf --- /dev/null +++ b/openflexure_microscope/common/labthings/plugins.py @@ -0,0 +1,119 @@ +import logging +import collections +import copy + +from importlib import util +import sys + +from openflexure_microscope.config import USER_CONFIG_DIR +from openflexure_microscope.utilities import camel_to_snake, camel_to_spine + + +class BasePlugin: + """ + Parent class for all plugins. + + Handles binding route views and forms. + """ + + def __init__(self): + self._views = ( + {} + ) # Key: Full, Python-safe ID. Val: Original rule, and view class + self._rules = {} # Key: Original rule. Val: View class + self._gui = None + + @property + def views(self): + return self._views + + def add_view(self, rule, view_class, **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] + + @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.__class__.__name__ + + @property + def _name_python_safe(self): + return camel_to_snake(self._name) + + @property + def _name_uri_safe(self): + return camel_to_spine(self._name) + + def _full_name(self): + module = self.__class__.__module__ + if module is None or module == str.__class__.__module__: + return self.__class__.__name__ # Avoid reporting __builtin__ + else: + return module + "." + self.__class__.__name__ + + +def find_plugins(plugin_path, module_name="plugins"): + print(f"Loading plugins from {plugin_path}") + # plugin_path = os.path.join(USER_CONFIG_DIR, "microscope_plugins") + # plugins = importlib.machinery.SourceFileLoader( + # module_name, plugin_path + # ).exec_module() + 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) + + return mod diff --git a/openflexure_microscope/common/labthings/schema.py b/openflexure_microscope/common/labthings/schema.py new file mode 100644 index 00000000..e69de29b diff --git a/openflexure_microscope/common/labthings/views.py b/openflexure_microscope/common/labthings/views.py new file mode 100644 index 00000000..e69de29b diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index 0c6e56a3..cf4c990a 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -255,6 +255,7 @@ DEFAULT_CONFIG_FILE_PATH = os.path.join(HERE, "microscope_settings.default.json" USER_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".openflexure") USER_CONFIG_FILE_PATH = os.path.join(USER_CONFIG_DIR, "microscope_settings.json") +USER_PLUGINS_PATH = os.path.join(USER_CONFIG_DIR, "microscope_plugins", "__init__.py") # Load the default config with open(DEFAULT_CONFIG_FILE_PATH, "r") as default_rc: From 779c112eccda39e94ff60f809f239e9bb1c50878 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 13 Dec 2019 14:49:28 +0000 Subject: [PATCH 002/122] Fixed plugin route attaching --- openflexure_microscope/api/app.py | 22 +--------------- .../api/v2/blueprints/plugins.py | 25 ++++++++++++++++--- .../common/labthings/plugins.py | 24 +++++++++--------- 3 files changed, 34 insertions(+), 37 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index c7d2a7b9..389afed7 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -16,16 +16,11 @@ from flask_cors import CORS from openflexure_microscope.api.exceptions import JSONExceptionHandler from openflexure_microscope.api.utilities import list_routes -from openflexure_microscope.config import ( - settings_file_path, - JSONEncoder, - USER_PLUGINS_PATH, -) +from openflexure_microscope.config import settings_file_path, JSONEncoder from openflexure_microscope.api.v1 import blueprints from openflexure_microscope.api import v2 from openflexure_microscope.common.labthings import LabThing -from openflexure_microscope.common.labthings.plugins import find_plugins from openflexure_microscope.api.microscope import default_microscope as api_microscope @@ -140,21 +135,6 @@ v2_actions_blueprint = v2.blueprints.actions.construct_blueprint(api_microscope) app.register_blueprint(v2_actions_blueprint, url_prefix=uri("/actions", "v2")) -plugins = find_plugins(USER_PLUGINS_PATH) -print(plugins.__plugins__) - -for plugin_obj in plugins.__plugins__: - for plugin_view_id, plugin_view in plugin_obj.views.items(): - # Add route to the plugins blueprint - app.add_url_rule( - "/new-plugins" + plugin_view["rule"], - view_func=plugin_view["view"].as_view( - f"{plugin_obj._name_python_safe}_{plugin_view_id}" - ), - **plugin_view["kwargs"], - ) - - @app.route("/routes") def routes(): """ diff --git a/openflexure_microscope/api/v2/blueprints/plugins.py b/openflexure_microscope/api/v2/blueprints/plugins.py index f12ef19a..c9c5f07f 100644 --- a/openflexure_microscope/api/v2/blueprints/plugins.py +++ b/openflexure_microscope/api/v2/blueprints/plugins.py @@ -2,11 +2,13 @@ Top-level representation of attached and enabled plugins """ -from openflexure_microscope.plugins import PluginLoader, MicroscopePlugin from openflexure_microscope.api.views import MicroscopeViewPlugin from openflexure_microscope.utilities import get_docstring, description_from_view from openflexure_microscope.api.utilities import blueprint_for_module +from openflexure_microscope.common.labthings.plugins import find_plugins +from openflexure_microscope.config import USER_PLUGINS_PATH + from flask import Blueprint, jsonify, url_for from openflexure_microscope.api.views import MicroscopeView @@ -14,8 +16,11 @@ import copy import logging import warnings +_plugins = find_plugins(USER_PLUGINS_PATH) +print(_plugins) -def plugins_representation(plugin_loader_object: PluginLoader): + +def plugins_representation(plugin_list): """ Generate a dictionary representation of all plugins, including Flask route URLs @@ -27,7 +32,7 @@ def plugins_representation(plugin_loader_object: PluginLoader): """ plugins = {} - for plugin in plugin_loader_object.active: + for plugin in plugin_list: logging.debug(f"Representing plugin {plugin._name}") d = { "python_name": plugin._name_python_safe, @@ -67,13 +72,25 @@ class PluginFormAPI(MicroscopeView): A complete list of enabled plugins can be found in the microscope state. """ - return jsonify(plugins_representation(self.microscope.plugins)) + global _plugins + return jsonify(plugins_representation(_plugins)) def construct_blueprint(microscope_obj): blueprint = blueprint_for_module(__name__) + for plugin_obj in _plugins: + for plugin_view_id, plugin_view in plugin_obj.views.items(): + # Add route to the plugins blueprint + blueprint.add_url_rule( + plugin_view["rule"], + view_func=plugin_view["view"].as_view( + f"{plugin_obj._name_python_safe}_{plugin_view_id}" + ), + **plugin_view["kwargs"], + ) + # Create a base route to return plugin API forms, if any exist blueprint.add_url_rule( "/", view_func=PluginFormAPI.as_view("plugins", microscope=microscope_obj) diff --git a/openflexure_microscope/common/labthings/plugins.py b/openflexure_microscope/common/labthings/plugins.py index 3926abbf..bfb4d857 100644 --- a/openflexure_microscope/common/labthings/plugins.py +++ b/openflexure_microscope/common/labthings/plugins.py @@ -16,13 +16,15 @@ class BasePlugin: Handles binding route views and forms. """ - def __init__(self): + 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.name = name + @property def views(self): return self._views @@ -84,22 +86,17 @@ class BasePlugin: @property def _name(self): - return self.__class__.__name__ + return self.name @property def _name_python_safe(self): - return camel_to_snake(self._name) + name = camel_to_snake(self._name) # Camel to snake + name = name.replace(" ", "_") # Spaces to snake + return name @property def _name_uri_safe(self): - return camel_to_spine(self._name) - - def _full_name(self): - module = self.__class__.__module__ - if module is None or module == str.__class__.__module__: - return self.__class__.__name__ # Avoid reporting __builtin__ - else: - return module + "." + self.__class__.__name__ + return camel_to_spine(self._name_python_safe) def find_plugins(plugin_path, module_name="plugins"): @@ -116,4 +113,7 @@ def find_plugins(plugin_path, module_name="plugins"): spec.loader.exec_module(mod) - return mod + if hasattr(mod, "__plugins__"): + return mod.__plugins__ + else: + return None From 1224daac13a277bb585f18080b1dd1ead5ad42e2 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 13 Dec 2019 15:03:59 +0000 Subject: [PATCH 003/122] Fixed task termination --- openflexure_microscope/api/v2/blueprints/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/blueprints/tasks.py b/openflexure_microscope/api/v2/blueprints/tasks.py index 877269ff..123ececa 100644 --- a/openflexure_microscope/api/v2/blueprints/tasks.py +++ b/openflexure_microscope/api/v2/blueprints/tasks.py @@ -138,7 +138,7 @@ class TaskAPI(MethodView): """ try: - task = tasks_representation()[task_id] + task = tasks[task_id] except KeyError: return abort(404) # 404 Not Found From 9e404415aecec239da9a9eb1394816f2ab86cefd Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 13 Dec 2019 15:06:27 +0000 Subject: [PATCH 004/122] Actually fixed task termination --- openflexure_microscope/api/v2/blueprints/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/blueprints/tasks.py b/openflexure_microscope/api/v2/blueprints/tasks.py index 123ececa..cf841bb2 100644 --- a/openflexure_microscope/api/v2/blueprints/tasks.py +++ b/openflexure_microscope/api/v2/blueprints/tasks.py @@ -138,7 +138,7 @@ class TaskAPI(MethodView): """ try: - task = tasks[task_id] + task = tasks.tasks()[task_id] except KeyError: return abort(404) # 404 Not Found From cc931cc58023c033d09c2f7a92bac3d931402424 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 13 Dec 2019 15:10:27 +0000 Subject: [PATCH 005/122] Packaged v2 autofocus plugin --- openflexure_microscope/plugins/v2/__init__.py | 0 .../plugins/v2/autofocus.py | 357 ++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 openflexure_microscope/plugins/v2/__init__.py create mode 100644 openflexure_microscope/plugins/v2/autofocus.py diff --git a/openflexure_microscope/plugins/v2/__init__.py b/openflexure_microscope/plugins/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openflexure_microscope/plugins/v2/autofocus.py b/openflexure_microscope/plugins/v2/autofocus.py new file mode 100644 index 00000000..3524e421 --- /dev/null +++ b/openflexure_microscope/plugins/v2/autofocus.py @@ -0,0 +1,357 @@ +from openflexure_microscope.common.labthings import find_device +from openflexure_microscope.common.labthings.plugins import BasePlugin +from openflexure_microscope.microscope import Microscope + +from openflexure_microscope.devel import ( + JsonResponse, + request, + jsonify, + taskify, + abort, +) +from openflexure_microscope.utilities import set_properties + +from flask.views import MethodView + +import time +import numpy as np +import threading +import logging +from scipy import ndimage +from contextlib import contextmanager + +### Autofocus utilities + +class JPEGSharpnessMonitor: + def __init__(self, microscope, timeout=60): + self.microscope = microscope + self.camera = microscope.camera + self.stage = microscope.stage + self.jpeg_sizes = [] + self.jpeg_times = [] + self.stage_positions = [] + self.stage_times = [] + self.stop_event = threading.Event() + self.timeout = timeout + self.keep_alive() + self.background_thread = None + + def is_alive(self): + if self.background_thread is None: + return False + else: + return self.background_thread.is_alive() + + def should_stop(self): + import time + + return time.time() - self.kept_alive > self.timeout + + def keep_alive(self): + import time + + self.kept_alive = time.time() + + def start(self): + "Start monitoring sharpness by looking at JPEG size" + self.background_thread = threading.Thread(target=self._measure_jpegs) + self.background_thread.start() + return self + + def stop(self): + "Stop the background thread" + self.stop_event.set() + self.background_thread.join() + + def _measure_jpegs(self): + "Function that runs in a background thread to record sharpness" + logging.info("Starting sharpness measurement in background thread") + self.keep_alive() + while not self.stop_event.is_set() and not self.should_stop(): + self.jpeg_sizes.append(self.jpeg_size()) + self.jpeg_times.append(time.time()) + if self.stop_event.is_set(): + logging.info("Cleanly stopped sharpness measurement in background thread") + if self.should_stop(): + logging.info("Sharpness measurement timed out and has stopped") + + def jpeg_size(self): + """Return the size of a frame from the MJPEG stream""" + return len(self.camera.get_frame()) + + def focus_rel(self, dz, backlash=False, **kwargs): + self.keep_alive() + self.stage_times.append(time.time()) + self.stage_positions.append(self.stage.position) + self.stage.move_rel([0, 0, dz], backlash=backlash, **kwargs) + self.stage_times.append(time.time()) + self.stage_positions.append(self.stage.position) + i = len(self.stage_positions) - 2 + return i, self.stage_positions[-1][2] + + def move_data(self, istart, istop=None): + "Extract sharpness as a function of (interpolated) z" + global np, logging + if istop is None: + istop = istart + 2 + jpeg_times = np.array(self.jpeg_times) + jpeg_sizes = np.array(self.jpeg_sizes) + stage_times = np.array(self.stage_times)[istart:istop] + stage_zs = np.array(self.stage_positions)[istart:istop, 2] + start = np.argmax(jpeg_times > stage_times[0]) + stop = np.argmax(jpeg_times > stage_times[1]) + if stop < 1: + stop = len(jpeg_times) + logging.debug("changing stop to {}".format(stop)) + jpeg_times = jpeg_times[start:stop] + jpeg_zs = np.interp(jpeg_times, stage_times, stage_zs) + return jpeg_times, jpeg_zs, jpeg_sizes[start:stop] + + def sharpest_z_on_move(self, index): + """Return the z position of the sharpest image on a given move""" + jt, jz, js = self.move_data(index) + return jz[np.argmax(js)] + + def data_dict(self): + """Return the gathered data as a single convenient dictionary""" + data = {} + for k in ["jpeg_times", "jpeg_sizes", "stage_times", "stage_positions"]: + data[k] = getattr(self, k) + return data + + +def decimate_to(shape, image): + """Decimate an image to reduce its size if it's too big.""" + decimation = np.max( + np.ceil(np.array(image.shape, dtype=np.float)[: len(shape)] / np.array(shape)) + ) + return image[:: int(decimation), :: int(decimation), ...] + + +def sharpness_sum_lap2(rgb_image): + """Return an image sharpness metric: sum(laplacian(image)**")""" + # image_bw=np.mean(decimate_to((1000,1000), rgb_image),2) + image_bw = np.mean(rgb_image, 2) + image_lap = ndimage.filters.laplace(image_bw) + return np.mean(image_lap.astype(np.float) ** 4) + + +def sharpness_edge(image): + """Return a sharpness metric optimised for vertical lines""" + gray = np.mean(image.astype(float), 2) + n = 20 + edge = np.array([[-1] * n + [1] * n]) + return np.sum( + [np.sum(ndimage.filters.convolve(gray, W) ** 2) for W in [edge, edge.T]] + ) + + +### Autofocus plugin + +def measure_sharpness(microscope, metric_fn=sharpness_sum_lap2): + """Measure the sharpness of the camera's current view.""" + return metric_fn(microscope.camera.array(use_video_port=True)) + +def autofocus(microscope, dz, settle=0.5, metric_fn=sharpness_sum_lap2): + """Perform a simple autofocus routine. + The stage is moved to z positions (relative to current position) in dz, + and at each position an image is captured and the sharpness function + evaulated. We then move back to the position where the sharpness was + highest. No interpolation is performed. + dz is assumed to be in ascending order (starting at -ve values) + """ + camera = microscope.camera + stage = microscope.stage + + with set_properties(stage, backlash=256), stage.lock, camera.lock: + sharpnesses = [] + positions = [] + camera.annotate_text = "" + + for _ in stage.scan_z(dz, return_to_start=False): + positions.append(stage.position[2]) + time.sleep(settle) + sharpnesses.append(measure_sharpness(microscope, metric_fn)) + + newposition = positions[np.argmax(sharpnesses)] + stage.move_rel([0, 0, newposition - stage.position[2]]) + + return positions, sharpnesses + + +@contextmanager +def monitor_sharpness(microscope): + m = JPEGSharpnessMonitor(microscope) + m.start() + try: + yield m + finally: + m.stop() + + +def move_and_find_focus(microscope, dz): + """Make a relative Z move and return the peak sharpness position""" + with monitor_sharpness(microscope) as m: + m.focus_rel(dz) + return m.sharpest_z_on_move(0) + + +def fast_autofocus(microscope, dz=2000, backlash=None): + """Perform a down-up-down-up autofocus""" + with monitor_sharpness(microscope) as m: + i, z = m.focus_rel(-dz / 2) + i, z = m.focus_rel(dz) + fz = m.sharpest_z_on_move(i) + if backlash is None: + i, z = m.focus_rel( + -dz + ) # move all the way to the start so it's consistent + else: + i, z = m.focus_rel(fz - z - backlash) + m.focus_rel(fz - z) + return m.data_dict() + + + +def fast_up_down_up_autofocus( + microscope, dz=2000, target_z=0, initial_move_up=True, mini_backlash=150 +): + """Autofocus by measuring on the way down, and moving back up with feedback. + + This autofocus method is very efficient, as it only passes the peak once. + The sequence of moves it performs is: + + 1. Move to the top of the range `dz/2` (can be disabled) + + 2. Move down by `dz` while monitoring JPEG size to find the focus. + + 3. Move back up to the `target_z` position, relative to the sharpest image. + + 4. Measure the sharpness, and compare against the curve recorded in (2) to \\ + estimate how much further we need to go. Make this move, to reach our \\ + target position. + + Moving back to the target position in two steps allows us to correct for + backlash, by using the sharpness-vs-z curve as a rough encoder for Z. + + Parameters: + dz: number of steps over which to scan (optional, default 2000) + + target_z: we aim to finish at this position, relative to focus. This may + be useful if, for example, you want to acquire a stack of images in Z. + It is optional, and the default value of 0 will finish at the focus. + + initial_move_up: (optional, default True) set this to `False` to move down + from the starting position. Mostly useful if you're able to combine + the initial move with something else, e.g. moving to the next scan point. + + mini_backlash: (optional, default 50) is a small extra move made in step + 3 to help counteract backlash. It should be small enough that you + would always expect there to be greater backlash than this. Too small + might slightly hurt accuracy, but is unlikely to be a big issue. Too big + may cause you to overshoot, which is a problem. + """ + with monitor_sharpness(microscope) as m, microscope.camera.lock: + # Ensure the MJPEG stream has started + microscope.camera.start_stream_recording() + + df = dz # TODO: refactor so I actually use dz in the code below! + if initial_move_up: + m.focus_rel(df / 2) + # move down + i, z = m.focus_rel(-df) + # now inspect where the sharpest point is, and estimate the sharpness + # (JPEG size) that we should find at the start of the Z stack + jt, jz, js = m.move_data(i) + best_z = jz[np.argmax(js)] + target_s = np.interp( + [best_z + target_z], jz[::-1], js[::-1] + ) # NB jz is decreasing + + # now move to the start of the z stack + i, z = m.focus_rel( + best_z + target_z - z + mini_backlash + ) # takes us to the start of the stack + + # We've deliberately undershot - figure out how much further we should move based on the curve + current_js = m.jpeg_size() + imax = np.argmax(js) # we want to crop out just the bit below the peak + js = js[imax:] # NB z is in DECREASING order + jz = jz[imax:] + inow = np.argmax( + js < current_js + ) # use the curve we recorded to estimate our position + # TODO: fancy interpolation stuff + + # So, the Z position corresponding to our current sharpness value is zs[inow] + # That means we should move forwards, by best_z - zs[inow] + correction_move = best_z + target_z - jz[inow] + logging.debug( + "Fast autofocus scan: correcting backlash by moving {} steps".format( + correction_move + ) + ) + m.focus_rel(correction_move) + return m.data_dict() + + +class MeasureSharpnessAPI(MethodView): + def post(self): + microscope = find_device("openflexure_microscope") + + return jsonify({"sharpness": measure_sharpness(microscope)}) + + +class AutofocusAPI(MethodView): + """ + Run a standard autofocus + """ + + def post(self): + payload = JsonResponse(request) + microscope = find_device("openflexure_microscope") + + # Figure out the range of z values to use + dz = payload.param("dz", default=np.linspace(-300, 300, 7), convert=np.array) + + if microscope.has_real_stage(): + logging.info("Running autofocus...") + task = taskify(autofocus)(microscope, dz) + + # return a handle on the autofocus task + return jsonify(task.state), 201 + + else: + abort(503, "No stage connected. Unable to autofocus.") + + +class FastAutofocusAPI(MethodView): + """ + Run a fast autofocus + """ + + def post(self): + payload = JsonResponse(request) + microscope = find_device("openflexure_microscope") + + # Figure out the parameters to use + dz = payload.param("dz", default=2000, convert=int) + backlash = payload.param("backlash", default=0, convert=int) + if backlash < 0: + backlash = 0 + + if microscope.has_real_stage(): + logging.info("Running autofocus...") + task = taskify(fast_autofocus)(microscope, dz, backlash=backlash) + + # return a handle on the autofocus task + return jsonify(task.state), 201 + + else: + abort(503, "No stage connected. Unable to autofocus.") + + +autofocus_plugin_v2 = BasePlugin("autofocus") +autofocus_plugin_v2.add_view("/measure_sharpness", MeasureSharpnessAPI) +autofocus_plugin_v2.add_view("/autofocus", AutofocusAPI) +autofocus_plugin_v2.add_view("/fast_autofocus", FastAutofocusAPI) From 4eef909d13c2b4cdbd332a319515b3e0c73b2751 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 13 Dec 2019 15:14:16 +0000 Subject: [PATCH 006/122] Check for missing devices --- .../plugins/v2/autofocus.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/openflexure_microscope/plugins/v2/autofocus.py b/openflexure_microscope/plugins/v2/autofocus.py index 3524e421..9f8e745c 100644 --- a/openflexure_microscope/plugins/v2/autofocus.py +++ b/openflexure_microscope/plugins/v2/autofocus.py @@ -2,13 +2,7 @@ from openflexure_microscope.common.labthings import find_device from openflexure_microscope.common.labthings.plugins import BasePlugin from openflexure_microscope.microscope import Microscope -from openflexure_microscope.devel import ( - JsonResponse, - request, - jsonify, - taskify, - abort, -) +from openflexure_microscope.devel import JsonResponse, request, jsonify, taskify, abort from openflexure_microscope.utilities import set_properties from flask.views import MethodView @@ -22,6 +16,7 @@ from contextlib import contextmanager ### Autofocus utilities + class JPEGSharpnessMonitor: def __init__(self, microscope, timeout=60): self.microscope = microscope @@ -148,10 +143,12 @@ def sharpness_edge(image): ### Autofocus plugin + def measure_sharpness(microscope, metric_fn=sharpness_sum_lap2): """Measure the sharpness of the camera's current view.""" return metric_fn(microscope.camera.array(use_video_port=True)) + def autofocus(microscope, dz, settle=0.5, metric_fn=sharpness_sum_lap2): """Perform a simple autofocus routine. The stage is moved to z positions (relative to current position) in dz, @@ -203,16 +200,13 @@ def fast_autofocus(microscope, dz=2000, backlash=None): i, z = m.focus_rel(dz) fz = m.sharpest_z_on_move(i) if backlash is None: - i, z = m.focus_rel( - -dz - ) # move all the way to the start so it's consistent + i, z = m.focus_rel(-dz) # move all the way to the start so it's consistent else: i, z = m.focus_rel(fz - z - backlash) m.focus_rel(fz - z) return m.data_dict() - def fast_up_down_up_autofocus( microscope, dz=2000, target_z=0, initial_move_up=True, mini_backlash=150 ): @@ -299,6 +293,9 @@ class MeasureSharpnessAPI(MethodView): def post(self): microscope = find_device("openflexure_microscope") + if not microscope: + abort(503, "No microscope connected. Unable to measure sharpness.") + return jsonify({"sharpness": measure_sharpness(microscope)}) @@ -311,6 +308,9 @@ class AutofocusAPI(MethodView): payload = JsonResponse(request) microscope = find_device("openflexure_microscope") + if not microscope: + abort(503, "No microscope connected. Unable to autofocus.") + # Figure out the range of z values to use dz = payload.param("dz", default=np.linspace(-300, 300, 7), convert=np.array) @@ -334,6 +334,9 @@ class FastAutofocusAPI(MethodView): payload = JsonResponse(request) microscope = find_device("openflexure_microscope") + if not microscope: + abort(503, "No microscope connected. Unable to autofocus.") + # Figure out the parameters to use dz = payload.param("dz", default=2000, convert=int) backlash = payload.param("backlash", default=0, convert=int) From 469e8218e0a58b58c37c9946aed007109e24983c Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 13 Dec 2019 15:17:35 +0000 Subject: [PATCH 007/122] Added spine-to-snake function for new plugin routes --- openflexure_microscope/common/labthings/plugins.py | 8 ++++++-- openflexure_microscope/utilities.py | 8 +++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/openflexure_microscope/common/labthings/plugins.py b/openflexure_microscope/common/labthings/plugins.py index bfb4d857..a7891379 100644 --- a/openflexure_microscope/common/labthings/plugins.py +++ b/openflexure_microscope/common/labthings/plugins.py @@ -6,7 +6,11 @@ from importlib import util import sys from openflexure_microscope.config import USER_CONFIG_DIR -from openflexure_microscope.utilities import camel_to_snake, camel_to_spine +from openflexure_microscope.utilities import ( + camel_to_snake, + camel_to_spine, + snake_to_spine, +) class BasePlugin: @@ -96,7 +100,7 @@ class BasePlugin: @property def _name_uri_safe(self): - return camel_to_spine(self._name_python_safe) + return snake_to_spine(self._name_python_safe) def find_plugins(plugin_path, module_name="plugins"): diff --git a/openflexure_microscope/utilities.py b/openflexure_microscope/utilities.py index 20a543ad..7fec2e36 100644 --- a/openflexure_microscope/utilities.py +++ b/openflexure_microscope/utilities.py @@ -12,12 +12,14 @@ def deserialise_array_b64(b64_string, dtype, shape): flat_arr = np.fromstring(base64.b64decode(b64_string), dtype) return flat_arr.reshape(shape) + def serialise_array_b64(npy_arr): - b64_string = base64.b64encode(npy_arr).decode('ascii') + b64_string = base64.b64encode(npy_arr).decode("ascii") dtype = str(npy_arr.dtype) shape = npy_arr.shape return b64_string, dtype, shape + def get_by_path(root, items): """Access a nested object in root by item sequence.""" return reduce(operator.getitem, items, root) @@ -69,6 +71,10 @@ def camel_to_spine(name): return re.sub("([a-z0-9])([A-Z])", r"\1-\2", s1).lower() +def snake_to_spine(name): + return name.replace("_", "-") + + @contextmanager def set_properties(obj, **kwargs): """A context manager to set, then reset, certain properties of an object. From 2b5b0b874404c2fa3810bc3ab96aa1e6385f8f95 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 16 Dec 2019 16:20:13 +0000 Subject: [PATCH 008/122] Added base plugin resource --- openflexure_microscope/api/app.py | 35 ++--- .../common/labthings/__init__.py | 36 ----- .../common/labthings/find.py | 32 ++++ .../common/labthings/labthing.py | 139 ++++++++++++++++++ .../common/labthings/resource.py | 9 ++ .../labthings/{views.py => views/__init__.py} | 0 .../common/labthings/views/plugins.py | 106 +++++++++++++ .../plugins/v2/autofocus.py | 2 +- 8 files changed, 299 insertions(+), 60 deletions(-) create mode 100644 openflexure_microscope/common/labthings/find.py create mode 100644 openflexure_microscope/common/labthings/labthing.py create mode 100644 openflexure_microscope/common/labthings/resource.py rename openflexure_microscope/common/labthings/{views.py => views/__init__.py} (100%) create mode 100644 openflexure_microscope/common/labthings/views/plugins.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 389afed7..5917f0dc 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -20,7 +20,10 @@ from openflexure_microscope.config import settings_file_path, JSONEncoder from openflexure_microscope.api.v1 import blueprints from openflexure_microscope.api import v2 -from openflexure_microscope.common.labthings import LabThing +from openflexure_microscope.common.labthings.labthing import LabThing +from openflexure_microscope.common.labthings.find import registered_plugins +from openflexure_microscope.common.labthings.plugins import find_plugins +from openflexure_microscope.config import USER_PLUGINS_PATH from openflexure_microscope.api.microscope import default_microscope as api_microscope @@ -78,30 +81,16 @@ handler = JSONExceptionHandler(app) # Attach lab devices labthing = LabThing(app) +labthing.url_prefix = "/api/v2b" +labthing.description = "Test LabThing-based API for OpenFlexure Microscope" + labthing.register_device(api_microscope, "openflexure_microscope") +for _plugin in find_plugins(USER_PLUGINS_PATH): + labthing.register_plugin(_plugin) + # WEBAPP ROUTES -# Base routes -base_blueprint = blueprints.base.construct_blueprint(api_microscope) -app.register_blueprint(base_blueprint, url_prefix=uri("", "v1")) - -# Stage routes -stage_blueprint = blueprints.stage.construct_blueprint(api_microscope) -app.register_blueprint(stage_blueprint, url_prefix=uri("/stage", "v1")) - -# Camera routes -camera_blueprint = blueprints.camera.construct_blueprint(api_microscope) -app.register_blueprint(camera_blueprint, url_prefix=uri("/camera", "v1")) - -# Plugin routes -plugin_blueprint = blueprints.plugins.construct_blueprint(api_microscope) -app.register_blueprint(plugin_blueprint, url_prefix=uri("/plugin", "v1")) - -# Task routes -task_blueprint = blueprints.task.construct_blueprint(api_microscope) -app.register_blueprint(task_blueprint, url_prefix=uri("/task", "v1")) - ### V2 # Root routes v2_root_blueprint = v2.blueprints.root.construct_blueprint(api_microscope) @@ -123,8 +112,8 @@ v2_status_blueprint = v2.blueprints.status.construct_blueprint(api_microscope) app.register_blueprint(v2_status_blueprint, url_prefix=uri("/status", "v2")) # Plugins routes -v2_plugin_blueprint = v2.blueprints.plugins.construct_blueprint(api_microscope) -app.register_blueprint(v2_plugin_blueprint, url_prefix=uri("/plugins", "v2")) +# v2_plugin_blueprint = v2.blueprints.plugins.construct_blueprint(api_microscope) +# app.register_blueprint(v2_plugin_blueprint, url_prefix=uri("/plugins", "v2")) # Tasks routes v2_tasks_blueprint = v2.blueprints.tasks.construct_blueprint(api_microscope) diff --git a/openflexure_microscope/common/labthings/__init__.py b/openflexure_microscope/common/labthings/__init__.py index 0ed5c46d..3b4b1d5b 100644 --- a/openflexure_microscope/common/labthings/__init__.py +++ b/openflexure_microscope/common/labthings/__init__.py @@ -1,37 +1 @@ -from flask import current_app, _app_ctx_stack - EXTENSION_NAME = "flask-lab" - - -class LabThing(object): - def __init__(self, app=None): - self.app = app - self.devices = {} - if app is not None: - self.init_app(app) - - def init_app(self, app): - app.teardown_appcontext(self.teardown) - - app.extensions = getattr(app, "extensions", {}) - app.extensions[EXTENSION_NAME] = self - - def teardown(self, exception): - print(f"Tearing down devices: {self.devices}") - - def register_device(self, device_object, device_name: str): - self.devices[device_name] = device_object - - -def find_device(device_name): - app = current_app - - if not app: - return None - - pylot_instance = app.extensions[EXTENSION_NAME] - - if device_name in pylot_instance.devices: - return pylot_instance.devices[device_name] - else: - return None diff --git a/openflexure_microscope/common/labthings/find.py b/openflexure_microscope/common/labthings/find.py new file mode 100644 index 00000000..b5b6a7e0 --- /dev/null +++ b/openflexure_microscope/common/labthings/find.py @@ -0,0 +1,32 @@ +from flask import current_app + +from . import EXTENSION_NAME + + +def _current_labthing(): + app = current_app + if not app: + return None + 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 diff --git a/openflexure_microscope/common/labthings/labthing.py b/openflexure_microscope/common/labthings/labthing.py new file mode 100644 index 00000000..7ea2454d --- /dev/null +++ b/openflexure_microscope/common/labthings/labthing.py @@ -0,0 +1,139 @@ +from flask import current_app, _app_ctx_stack, request + +from .plugins import BasePlugin +from .views.plugins import PluginListResource + +from . import EXTENSION_NAME + + +class LabThing(object): + def __init__(self, app=None, url_prefix="", description=""): + self.app = app + + self.devices = {} + + self.plugins = {} + + self.resources = [] + self.endpoints = set() + + self.url_prefix = url_prefix + self.description = description + + if app is not None: + self.init_app(app) + + ### Flask stuff + + def init_app(self, app): + app.teardown_appcontext(self.teardown) + + app.extensions = getattr(app, "extensions", {}) + app.extensions[EXTENSION_NAME] = self + + self._create_base_routes() + + def teardown(self, exception): + print(f"Tearing down devices: {self.devices}") + + def _create_base_routes(self): + self.add_resource(PluginListResource, "/plugins") + + ### 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") + + ### 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 add_resource(self, resource, *urls, **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__.lower` + 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") + """ + if self.app is not None: + self._register_view(self.app, resource, *urls, **kwargs) + else: + self.resources.append((resource, urls, 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, **kwargs): + endpoint = kwargs.pop("endpoint", None) or resource.__name__.lower() + 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) diff --git a/openflexure_microscope/common/labthings/resource.py b/openflexure_microscope/common/labthings/resource.py new file mode 100644 index 00000000..fc902d3e --- /dev/null +++ b/openflexure_microscope/common/labthings/resource.py @@ -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) diff --git a/openflexure_microscope/common/labthings/views.py b/openflexure_microscope/common/labthings/views/__init__.py similarity index 100% rename from openflexure_microscope/common/labthings/views.py rename to openflexure_microscope/common/labthings/views/__init__.py diff --git a/openflexure_microscope/common/labthings/views/plugins.py b/openflexure_microscope/common/labthings/views/plugins.py new file mode 100644 index 00000000..dabc1180 --- /dev/null +++ b/openflexure_microscope/common/labthings/views/plugins.py @@ -0,0 +1,106 @@ +""" +Top-level representation of attached and enabled plugins +""" + +from openflexure_microscope.utilities import get_docstring, description_from_view +from openflexure_microscope.api.utilities import blueprint_for_module + +from openflexure_microscope.common.labthings.find import registered_plugins +from openflexure_microscope.common.labthings.resource import Resource + +from flask import Blueprint, jsonify, url_for + +import copy +import logging +import warnings + + +def plugins_representation(plugin_dict): + """ + Generate a dictionary representation of all plugins, including Flask route URLs + + Args: + plugin_loader_object (:py:class:`openflexure_microscope.plugins.PluginLoader`): Microscope plugin loader + + 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), + "views": {}, + "gui": plugin.gui, + "description": get_docstring(plugin), + } + + for view_id, view_data in plugin.views.items(): + logging.debug(f"Representing view {view_id}") + uri = url_for(f"pluginlistresource") + "/" + view_data["rule"][1:] + # uri = view_data["rule"] + # Make links dictionary if it doesn't yet exist + view_d = {"links": {"self": uri}} + + view_d.update(description_from_view(view_data["view"])) + + d["views"][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())) + + +""" +def construct_blueprint(): + + blueprint = blueprint_for_module(__name__) + + for plugin_obj in registered_plugins(): + for plugin_view_id, plugin_view in plugin_obj.views.items(): + # Add route to the plugins blueprint + blueprint.add_url_rule( + plugin_view["rule"], + view_func=plugin_view["view"].as_view( + f"{plugin_obj._name_python_safe}_{plugin_view_id}" + ), + **plugin_view["kwargs"], + ) + + # Create a base route to return plugin API forms, if any exist + blueprint.add_url_rule("/", view_func=PluginFormAPI.as_view("plugins")) + + # For each plugin attached to the microscope object + for plugin in microscope_obj.plugins.active: + + for plugin_view_id, plugin_view in plugin.views.items(): + # Add route to the plugins blueprint + blueprint.add_url_rule( + plugin_view["rule"], + view_func=plugin_view["view"].as_view( + f"{plugin._name_python_safe}_{plugin_view_id}", plugin=plugin + ), + **plugin_view["kwargs"], + ) + + return blueprint +""" diff --git a/openflexure_microscope/plugins/v2/autofocus.py b/openflexure_microscope/plugins/v2/autofocus.py index 9f8e745c..875bb58d 100644 --- a/openflexure_microscope/plugins/v2/autofocus.py +++ b/openflexure_microscope/plugins/v2/autofocus.py @@ -1,4 +1,4 @@ -from openflexure_microscope.common.labthings import find_device +from openflexure_microscope.common.labthings.find import find_device from openflexure_microscope.common.labthings.plugins import BasePlugin from openflexure_microscope.microscope import Microscope From c9ac940a27df89ca33ab194b07f592d820715444 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 16 Dec 2019 16:24:36 +0000 Subject: [PATCH 009/122] Fixed URL prefix --- openflexure_microscope/api/app.py | 3 +-- openflexure_microscope/common/labthings/labthing.py | 6 ++++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 5917f0dc..cb952312 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -80,8 +80,7 @@ CORS(app, resources=r"*") handler = JSONExceptionHandler(app) # Attach lab devices -labthing = LabThing(app) -labthing.url_prefix = "/api/v2b" +labthing = LabThing(app, prefix="/api/v2b") labthing.description = "Test LabThing-based API for OpenFlexure Microscope" labthing.register_device(api_microscope, "openflexure_microscope") diff --git a/openflexure_microscope/common/labthings/labthing.py b/openflexure_microscope/common/labthings/labthing.py index 7ea2454d..d6197dfb 100644 --- a/openflexure_microscope/common/labthings/labthing.py +++ b/openflexure_microscope/common/labthings/labthing.py @@ -7,7 +7,7 @@ from . import EXTENSION_NAME class LabThing(object): - def __init__(self, app=None, url_prefix="", description=""): + def __init__(self, app=None, prefix="", description=""): self.app = app self.devices = {} @@ -17,7 +17,7 @@ class LabThing(object): self.resources = [] self.endpoints = set() - self.url_prefix = url_prefix + self.url_prefix = prefix self.description = description if app is not None: @@ -51,6 +51,8 @@ class LabThing(object): else: raise TypeError("Plugin object must be an instance of BasePlugin") + # TODO: Add plugin routes + ### Resource stuff def _complete_url(self, url_part, registration_prefix): From f972609e4c0700c717c033f1c2763493fc824a77 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 11:01:56 +0000 Subject: [PATCH 010/122] Remove config contraction --- openflexure_microscope/config.py | 82 ++------------------------------ 1 file changed, 4 insertions(+), 78 deletions(-) diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index cf4c990a..659b7287 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -54,17 +54,11 @@ class OpenflexureSettingsFile: expand (bool): Expand paths to valid auxillary config files. """ - def __init__(self, config_path: str = None, expand: bool = True): + def __init__(self, config_path: str = None): global DEFAULT_CONFIG, USER_CONFIG_FILE_PATH - self.expandable_keys = { - "camera_settings": None, - "stage_settings": None, - } #: Dictionary of keys that can be passed as a file path string and expanded automatically - # Set arguments self.config_path = config_path or USER_CONFIG_FILE_PATH - self.expand = expand # Initialise basic config file with defaults if it doesn't exist initialise_file(self.config_path, populate=DEFAULT_CONFIG) @@ -76,11 +70,6 @@ class OpenflexureSettingsFile: # Unexpanded config dictionary (used at load/save time) loaded_config = load_json_file(self.config_path) - # If the loaded config is in contracted format - if self.expand: - # Expand self.raw_config into self._config - loaded_config = self.expand_config(loaded_config) - logging.debug("Reading settings from disk") return loaded_config @@ -88,12 +77,8 @@ class OpenflexureSettingsFile: """ Save config to a file on-disk, and splits into auxillary config files if available. """ - # If the loaded config was in contracted format - if self.expand: - # Contract self._config into self.raw_config - save_settings = self.contract_config(config) - else: - save_settings = config + + save_settings = config if backup: if os.path.isfile(self.config_path): @@ -109,68 +94,9 @@ class OpenflexureSettingsFile: return settings - def expand_config(self, config_dict): - """ - Search a config dictionary for paths to valid auxillary config files, - and expand into the config dictionary if available. - - Args: - config_dict (dict): Dictionary of config data to expand - """ - return_config = {} - if not config_dict: - config_dict = {} - # For each value in the raw loaded config - for key, value in config_dict.items(): - # If it's a valid expandable parameter - if key in self.expandable_keys and type(value) is str: - - logging.debug("Expanding {}".format(value)) - - # Store expansion path - self.expandable_keys[key] = config_dict[key] - # Create the expansion file if it doesn't yet exist - initialise_file(value) - # Load the expansion file into _config - return_config[key] = load_json_file(value) or {} - else: - return_config[key] = value - - return return_config - - def contract_config(self, config_dict): - """ - Split a config dictionary into auxillary config files, if available. - - Args: - config_dict (dict): Dictionary of config data to contract/split - """ - return_config = {} - # For each value in the expanded config - for key, value in config_dict.items(): - # If it's a valid expandable parameter - if ( - key in self.expandable_keys - and self.expandable_keys[key] is not None - and type(value) is dict - ): - - logging.debug("Saving to {}".format(self.expandable_keys[key])) - # Create the file if it doesn't exist - initialise_file(self.expandable_keys[key]) - # Save the expanded config dictionary to the file - save_json_file(self.expandable_keys[key], value) - # Replace the expanded dictionary with a file path - return_config[key] = self.expandable_keys[key] - else: - return_config[key] = value - - return return_config - # HANDLE BASIC LOADING AND SAVING OF SETTINGS FILES - def load_json_file(config_path) -> dict: """ Open a .json config file @@ -262,4 +188,4 @@ with open(DEFAULT_CONFIG_FILE_PATH, "r") as default_rc: DEFAULT_CONFIG = default_rc.read() # Create the default user settings object -user_settings = OpenflexureSettingsFile(config_path=USER_CONFIG_FILE_PATH, expand=True) +user_settings = OpenflexureSettingsFile(config_path=USER_CONFIG_FILE_PATH) From 480848b00916025a7e496954704b19ce110871a4 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 11:02:04 +0000 Subject: [PATCH 011/122] Remove old contracted values --- openflexure_microscope/microscope_settings.default.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/openflexure_microscope/microscope_settings.default.json b/openflexure_microscope/microscope_settings.default.json index c77883e4..3660048a 100644 --- a/openflexure_microscope/microscope_settings.default.json +++ b/openflexure_microscope/microscope_settings.default.json @@ -4,8 +4,6 @@ 3146 ], "jpeg_quality": 75, - "camera_settings": "~/.openflexure/camera_settings.json", - "stage_settings": "~/.openflexure/stage_settings.json", "plugins": [ "openflexure_microscope.plugins.default.autofocus:AutofocusPlugin", "openflexure_microscope.plugins.default.scan:ScanPlugin", From dee77c295aaa0b003cf4f84f7dd9531ea6e0e773 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 11:06:02 +0000 Subject: [PATCH 012/122] Migrated scan plugin --- openflexure_microscope/__init__.py | 3 +- openflexure_microscope/camera/pi.py | 3 - .../common/labthings/find.py | 19 +- .../common/labthings/labthing.py | 8 + .../common/labthings/plugins.py | 9 + openflexure_microscope/common/tasks/pool.py | 4 +- openflexure_microscope/microscope.py | 40 -- .../plugins/example/__init__.py | 4 - openflexure_microscope/plugins/example/api.py | 100 ----- .../plugins/example/plugin.py | 63 --- .../plugins/v2/autofocus.py | 4 + openflexure_microscope/plugins/v2/scan.py | 399 ++++++++++++++++++ openflexure_microscope/task.py | 76 ---- 13 files changed, 442 insertions(+), 290 deletions(-) delete mode 100644 openflexure_microscope/plugins/example/__init__.py delete mode 100644 openflexure_microscope/plugins/example/api.py delete mode 100644 openflexure_microscope/plugins/example/plugin.py create mode 100644 openflexure_microscope/plugins/v2/scan.py delete mode 100644 openflexure_microscope/task.py diff --git a/openflexure_microscope/__init__.py b/openflexure_microscope/__init__.py index ad56e205..85af80b2 100644 --- a/openflexure_microscope/__init__.py +++ b/openflexure_microscope/__init__.py @@ -1,8 +1,7 @@ -__all__ = ["Microscope", "config", "task", "utilities"] +__all__ = ["Microscope", "config", "utilities"] __version__ = "0.1.0" from .microscope import Microscope from . import config from . import utilities -from . import task from . import common diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index a3e1fc47..d73d0c02 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -317,9 +317,6 @@ class PiCameraStreamer(BaseCamera): """ Change the camera zoom, handling re-centering and scaling. """ - logging.warning( - "set_zoom is deprecated. Please use the 'zoom' property in picamera_settings." - ) with self.lock: self.status["zoom_value"] = float(zoom_value) if self.status["zoom_value"] < 1: diff --git a/openflexure_microscope/common/labthings/find.py b/openflexure_microscope/common/labthings/find.py index b5b6a7e0..7b09f43e 100644 --- a/openflexure_microscope/common/labthings/find.py +++ b/openflexure_microscope/common/labthings/find.py @@ -1,12 +1,17 @@ +import logging from flask import current_app from . import EXTENSION_NAME def _current_labthing(): - app = current_app + 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] @@ -30,3 +35,15 @@ def find_device(device_name, labthing_instance=None): 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 diff --git a/openflexure_microscope/common/labthings/labthing.py b/openflexure_microscope/common/labthings/labthing.py index d6197dfb..5c301fee 100644 --- a/openflexure_microscope/common/labthings/labthing.py +++ b/openflexure_microscope/common/labthings/labthing.py @@ -53,6 +53,14 @@ class LabThing(object): # TODO: Add plugin routes + 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"], + ) + ### Resource stuff def _complete_url(self, url_part, registration_prefix): diff --git a/openflexure_microscope/common/labthings/plugins.py b/openflexure_microscope/common/labthings/plugins.py index a7891379..2a3078ee 100644 --- a/openflexure_microscope/common/labthings/plugins.py +++ b/openflexure_microscope/common/labthings/plugins.py @@ -29,6 +29,8 @@ class BasePlugin: self.name = name + self.methods = {} + @property def views(self): return self._views @@ -102,6 +104,13 @@ class BasePlugin: def _name_uri_safe(self): return snake_to_spine(self._name_python_safe) + def add_method(self, method_name, method): + 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"): print(f"Loading plugins from {plugin_path}") diff --git a/openflexure_microscope/common/tasks/pool.py b/openflexure_microscope/common/tasks/pool.py index 258e46c5..b8b526e9 100644 --- a/openflexure_microscope/common/tasks/pool.py +++ b/openflexure_microscope/common/tasks/pool.py @@ -4,6 +4,7 @@ from functools import wraps from .thread import TaskThread +from flask import copy_current_request_context class TaskMaster: def __init__(self, *args, **kwargs): @@ -26,7 +27,8 @@ class TaskMaster: return {t.id: t.state for t in self._tasks} def new(self, f, *args, **kwargs): - task = TaskThread(target=f, args=args, kwargs=kwargs) + # copy_current_request_context allows threads to access flask current_app + task = TaskThread(target=copy_current_request_context(f), args=args, kwargs=kwargs) self._tasks.append(task) return task diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index 981e78d6..a62c6fea 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -13,7 +13,6 @@ from openflexure_microscope.camera.mock import MockStreamer from openflexure_microscope.utilities import serialise_array_b64 from openflexure_microscope.plugins import PluginLoader -from openflexure_microscope.task import TaskOrchestrator from openflexure_microscope.common.lock import CompositeLock from openflexure_microscope.config import user_settings @@ -46,9 +45,6 @@ class Microscope: # Initialise with an empty composite lock self.lock = CompositeLock([]) - # Create a task orchestrator - self.task = TaskOrchestrator() - # Apply settings loaded from file self.apply_settings(user_settings.load()) @@ -151,26 +147,6 @@ class Microscope: else: return False - # Create unified state - @property - def state(self): - """Dictionary of the basic microscope state. - - Return: - dict: Dictionary containing position data, - and :py:attr:`openflexure_microscope.camera.base.BaseCamera.status` - """ - # DEPRECATED - logging.warning( - "Microscope.state is deprecated. Use Microscope.status instead. State will be removed in a future version." - ) - state = { - "camera": self.camera.status, - "stage": self.stage.status, - "plugin": self.plugins.state, - "version": pkg_resources.get_distribution("openflexure_microscope").version, - } - return state # Create unified status @property @@ -256,22 +232,6 @@ class Microscope: self.stage.save_settings() user_settings.save(current_config, backup=True) - @property - def config(self) -> dict: - logging.warning( - "Reading microscope through config property is deprecated.\ - Please use read_settings method instead." - ) - return self.read_settings() - - @config.setter - def config(self, config: dict) -> None: - logging.warning( - "Setting microscope through config property is deprecated.\ - Please use apply_settings method instead." - ) - self.apply_settings(config) - @property def metadata(self): """ diff --git a/openflexure_microscope/plugins/example/__init__.py b/openflexure_microscope/plugins/example/__init__.py deleted file mode 100644 index ce9672da..00000000 --- a/openflexure_microscope/plugins/example/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -__all__ = ["Plugin", "HelloWorldAPI", "IdentifyAPI"] - -from .plugin import Plugin -from .api import HelloWorldAPI, IdentifyAPI diff --git a/openflexure_microscope/plugins/example/api.py b/openflexure_microscope/plugins/example/api.py deleted file mode 100644 index ac818184..00000000 --- a/openflexure_microscope/plugins/example/api.py +++ /dev/null @@ -1,100 +0,0 @@ -from openflexure_microscope.api.utilities import JsonResponse -from openflexure_microscope.api.views import MicroscopeViewPlugin -from openflexure_microscope.exceptions import TaskDeniedException - -from flask import request, Response, escape, jsonify, abort - - -class IdentifyAPI(MicroscopeViewPlugin): - """ - A simple example API plugin, attached through the main microscope plugin. - """ - - def get(self): - """ - Method to call when an HTTP GET request is made. - """ - data = ( - self.plugin.identify() - ) # Call a method from our plugin, using the MicroscopeViewPlugin.plugin shortcut - return Response(escape(data)) - - -class HelloWorldAPI(MicroscopeViewPlugin): - """ - A method to create, set, and return a new microscope parameter. - """ - - def get(self): - """ - Method to call when an HTTP GET request is made. - """ - - # If the microscope does not already contain our plugin_string attribute - if not hasattr(self.microscope, "plugin_string"): - # Make a string, using the MicroscopeViewPlugin.plugin shortcut - self.microscope.plugin_string = self.plugin.hello_world() - - return Response(self.microscope.plugin_string) - - def post(self): - """ - Method to call when an HTTP POST request is made. - Assumes request will include a JSON payload. - """ - # Get payload JSON - payload = JsonResponse(request) - - # Extract a value from the JSON key 'plugin_string', and convert to a string. If no value is given, default to empty. - new_plugin_string = payload.param("plugin_string", default="", convert=str) - - if new_plugin_string: # If not None or empty - # Set microscope attribute to the specified string - self.microscope.plugin_string = new_plugin_string - - return Response(self.microscope.plugin_string) - - -class LongRunningAPI(MicroscopeViewPlugin): - """ - An example API plugin that uses a long-running plugin method. - """ - - def post(self): - """ - Method to call when an HTTP POST request is made. - """ - # Get payload JSON - payload = JsonResponse(request) - - # Extract a values from the JSON payload. - time_to_run = payload.param("time", default=10, convert=int) - - # Attach the long-running method as a microscope task - try: - task = self.microscope.task.start(self.plugin.long_running, time_to_run) - return jsonify(task.state), 201 - - except TaskDeniedException: - return abort(409) - - -class SomeExceptionAPI(MicroscopeViewPlugin): - """ - An example API plugin that uses a long-running but broken plugin method. - """ - - def post(self): - """ - Method to call when an HTTP POST request is made. - """ - # Get payload JSON - payload = JsonResponse(request) - - # Attach the long-running method as a microscope task - try: - task = self.microscope.task.start(self.plugin.some_exception) - return jsonify(task.state), 201 - - except TaskDeniedException: - return abort(409) diff --git a/openflexure_microscope/plugins/example/plugin.py b/openflexure_microscope/plugins/example/plugin.py deleted file mode 100644 index c61cb16e..00000000 --- a/openflexure_microscope/plugins/example/plugin.py +++ /dev/null @@ -1,63 +0,0 @@ -import random -import time -from openflexure_microscope.plugins import MicroscopePlugin - -from .api import IdentifyAPI, HelloWorldAPI, LongRunningAPI, SomeExceptionAPI - - -class Plugin(MicroscopePlugin): - """ - A set of default plugins - """ - - api_views = { - "/identify": IdentifyAPI, - "/hello": HelloWorldAPI, - "/long_running": LongRunningAPI, - "/some_exception": SomeExceptionAPI, - } - - def identify(self): - """ - Demonstrate access to Microscope.camera, and Microscope.stage - """ - - response = "My parent camera is {}, and my parent stage is {}.".format( - self.microscope.camera, self.microscope.stage - ) - print(response) - return response - - def hello_world(self): - """ - Demonstrate passive method - """ - - return "Hello world!" - - def some_exception(self): - """ - Demonstrate some broken plugin method that would be long running - """ - with self.microscope.lock: - time.sleep(3) - result = 15.0 / 0 - - return result - - def long_running(self, t_run): - """ - Demonstrate a long-running method that requires microscope hardware - """ - print("Starting a long-running task...") - n_array = [] - - print("Acquiring composite microscope lock...") - with self.microscope.camera.lock, self.microscope.stage.lock: - for _ in range(t_run): - n_array.append(random.random()) - time.sleep(1) - - print("Long-running task finished! Releasing locks.") - - return n_array diff --git a/openflexure_microscope/plugins/v2/autofocus.py b/openflexure_microscope/plugins/v2/autofocus.py index 875bb58d..3b4408ad 100644 --- a/openflexure_microscope/plugins/v2/autofocus.py +++ b/openflexure_microscope/plugins/v2/autofocus.py @@ -355,6 +355,10 @@ class FastAutofocusAPI(MethodView): autofocus_plugin_v2 = BasePlugin("autofocus") + +autofocus_plugin_v2.add_method("fast_autofocus", fast_autofocus) +autofocus_plugin_v2.add_method("autofocus", autofocus) + autofocus_plugin_v2.add_view("/measure_sharpness", MeasureSharpnessAPI) autofocus_plugin_v2.add_view("/autofocus", AutofocusAPI) autofocus_plugin_v2.add_view("/fast_autofocus", FastAutofocusAPI) diff --git a/openflexure_microscope/plugins/v2/scan.py b/openflexure_microscope/plugins/v2/scan.py new file mode 100644 index 00000000..7a6e2d2f --- /dev/null +++ b/openflexure_microscope/plugins/v2/scan.py @@ -0,0 +1,399 @@ +import numpy as np +import itertools +import logging +import uuid +from typing import Tuple +from functools import reduce + +from openflexure_microscope.camera.base import generate_basename +from openflexure_microscope.common.labthings.find import find_device, find_plugin +from openflexure_microscope.common.labthings.plugins import BasePlugin + +from openflexure_microscope.devel import ( + JsonResponse, + request, + jsonify, + taskify, + abort, + update_task_progress, + update_task_data, +) + +from flask.views import MethodView +import time + + +### Grid construction + +def construct_grid(initial, step_sizes, n_steps, style="raster"): + """ + Given an initial position, step sizes, and number of steps, + construct a 2-dimensional list of scan x-y positions. + """ + arr = [] + + for i in range(n_steps[0]): # x axis + arr.append([]) + for j in range(n_steps[1]): # y axis + # Create a coordinate array + coord = [initial[ax] + [i, j][ax] * step_sizes[ax] for ax in range(2)] + # Append coordinate array to position grid + arr[i].append(tuple(coord)) + + # Style modifiers + if style == "snake": + for i, line in enumerate(arr): + if i % 2 != 0: + line.reverse() + + return arr + + +def flatten_grid(grid): + """ + Convert a 3D list of scan positions into a flat list + of sequential positions. + """ + + grid = list(itertools.chain(*grid)) + return grid + + +### Progress + +_images_to_be_captured: int = 1 +_images_captured_so_far: int = 0 + +def progress(): + progress = (_images_captured_so_far / _images_to_be_captured) * 100 + logging.info(progress) + return progress + + +### Capturing + +def capture( + microscope, + basename, + scan_id, + temporary: bool = False, + use_video_port: bool = False, + resize: Tuple[int, int] = None, + bayer: bool = False, + metadata: dict = {}, + tags: list = [], +): + + # Construct a tile filename + filename = "{}_{}_{}_{}".format(basename, *microscope.stage.position) + folder = "SCAN_{}".format(basename) + + # Create output object + output = microscope.camera.new_image( + temporary=temporary, filename=filename, folder=folder + ) + + # Capture + microscope.camera.capture( + output.file, use_video_port=use_video_port, resize=resize, bayer=bayer + ) + + # Affix metadata + if "scan" not in tags: + tags.append("scan") + + # Inject system metadata + output.put_metadata(microscope.metadata, system=True) + + # Insert custom metadata + output.put_metadata(metadata) + + # Insert custom tags + output.put_tags(tags) + + +### Scanning + +def tile( + microscope, + basename: str = None, + temporary: bool = False, + step_size: int = [2000, 1500, 100], + grid: list = [3, 3, 5], + style="raster", + autofocus_dz: int = 50, + use_video_port: bool = False, + resize: Tuple[int, int] = None, + bayer: bool = False, + fast_autofocus=False, + metadata: dict = {}, + tags: list = [], +): + global _images_to_be_captured + global _images_captured_so_far + + # Keep task progress + # TODO: Make this line not nasty + _images_to_be_captured = reduce((lambda x, y: x * y), grid) + _images_captured_so_far = 0 + + # Generate a basename if none given + if not basename: + basename = generate_basename() + + # Generate a stack ID + scan_id = uuid.uuid4().hex + + # Store initial position + initial_position = microscope.stage.position + + # Add scan metadata + if "time" not in metadata: + metadata["time"] = generate_basename() + + metadata.update( + { + "scan_id": scan_id, + "basename": basename, + "scan_parameters": { + "step_size": step_size, + "grid": grid, + "style": style, + "autofocus_dz": autofocus_dz, + }, + } + ) + + # Check if autofocus is enabled + autofocus_plugin = find_plugin("autofocus") + if ( + autofocus_dz + and autofocus_plugin + and microscope.has_real_stage() + and microscope.has_real_camera() + ): + autofocus_enabled = True + else: + autofocus_enabled = False + + z_stack_dz = ( + grid[2] * step_size[2] if grid[2] > 1 else 0 + ) # shorthand for Z stack range + + # Construct an x-y grid (worry about z later) + x_y_grid = construct_grid( + initial_position, step_size[:2], grid[:2], style=style + ) + + # Keep the initial Z position the same as our current position + next_z = initial_position[2] + if fast_autofocus: # If fast autofocus is enabled, make + next_z += autofocus_dz / 2 # sure we start from the top of the range + initial_z = next_z # Save this value for use in raster scans + + # Now step through each point in the x-y coordinate array + for line in x_y_grid: + # If rastering, rather than snake (or eventually spiral) + # Return focus to initial position + if style == "raster": + next_z = initial_z + logging.debug("Returning to initial z position") + microscope.stage.move_abs( + [line[0][0], line[0][1], next_z] + ) # RWB: I think this line is redundant + + for x_y in line: + # Move to new grid position without changing z + logging.debug("Moving to step {}".format([x_y[0], x_y[1], next_z])) + microscope.stage.move_abs([x_y[0], x_y[1], next_z]) + # Refocus + if autofocus_enabled: + if fast_autofocus: + autofocus_plugin.fast_autofocus( + dz=autofocus_dz, + target_z=-z_stack_dz / 2.0, # Finish below the focus + initial_move_up=False, # We're already at the top of the scan + ) + # TODO: save the focus data for future reference? Use it for diagnostics? + else: + logging.debug("Running autofocus") + autofocus_plugin.autofocus( + range(-3 * autofocus_dz, 4 * autofocus_dz, autofocus_dz) + ) + logging.debug("Finished autofocus") + time.sleep(1) # TODO: Remove + + # If we're not doing a z-stack, just capture + if grid[2] <= 1: + capture( + microscope, + basename, + scan_id, + temporary=temporary, + use_video_port=use_video_port, + resize=resize, + bayer=bayer, + metadata=metadata, + tags=tags, + ) + # Update task progress + _images_captured_so_far += 1 + update_task_progress(progress()) + else: + logging.debug("Entering z-stack") + stack( + microscope=microscope, + basename=basename, + temporary=temporary, + scan_id=scan_id, + step_size=step_size[2], + steps=grid[2], + center=not fast_autofocus, # fast_autofocus does this for us! + return_to_start=not fast_autofocus, + use_video_port=use_video_port, + resize=resize, + bayer=bayer, + metadata=metadata, + tags=tags, + ) + # Make sure we use our current best estimate of focus (i.e. the current position) next point + next_z = microscope.stage.position[2] + if fast_autofocus: + next_z += ( + autofocus_dz / 2 + ) # Fast autofocus requires us to start at the top of the range + if grid[2] > 1: + next_z -= int( + grid[2] / 2.0 * step_size[2] + ) # Z stacking means we're higher up to start with + + logging.debug("Returning to {}".format(initial_position)) + microscope.stage.move_abs(initial_position) + +def stack( + microscope, + basename: str = None, + temporary: bool = False, + scan_id: str = None, + step_size: int = 100, + steps: int = 5, + center: bool = True, + return_to_start: bool = True, + use_video_port: bool = False, + resize: Tuple[int, int] = None, + bayer: bool = False, + metadata: dict = {}, + tags: list = [], +): + global _images_captured_so_far + + # Generate a basename if none given + if not basename: + basename = generate_basename() + + # Generate a stack ID + if not scan_id: + scan_id = uuid.uuid4().hex + + # Add scan metadata + if not "time" in metadata: + metadata["time"] = generate_basename() + + # Store initial position + initial_position = microscope.stage.position + + with microscope.lock: + # Move to center scan + if center: + logging.debug("Moving to starting position") + microscope.stage.move_rel([0, 0, int((-step_size * steps) / 2)]) + + for i in range(steps): + time.sleep(0.1) + logging.debug("Capturing...") + capture( + microscope, + basename, + scan_id, + temporary=temporary, + use_video_port=use_video_port, + resize=resize, + bayer=bayer, + metadata=metadata, + tags=tags, + ) + # Update task progress + _images_captured_so_far += 1 + update_task_progress(progress()) + + if i != steps - 1: + logging.debug("Moving z by {}".format(step_size)) + microscope.stage.move_rel([0, 0, step_size]) + if return_to_start: + logging.debug("Returning to {}".format(initial_position)) + microscope.stage.move_abs(initial_position) + + +### Web views + +class TileScanAPI(MethodView): + def post(self): + payload = JsonResponse(request) + microscope = find_device("openflexure_microscope") + + if not microscope: + abort(503, "No microscope connected. Unable to autofocus.") + + # Get params + filename = payload.param("filename") + temporary = payload.param("temporary", default=False, convert=bool) + + step_size = payload.param("step_size", default=[2000, 1500, 100], convert=list) + step_size = [int(i) for i in step_size] + + grid = payload.param("grid", default=[3, 3, 5], convert=list) + grid = [int(i) for i in grid] + + style = payload.param("style", default="raster", convert=str) + autofocus_dz = payload.param("autofocus_dz", default=50, convert=int) + fast_autofocus = payload.param("fast_autofocus", default=False, convert=bool) + + use_video_port = payload.param("use_video_port", default=True, convert=bool) + resize = payload.param("size", default=None) + if resize: + if ("width" in resize) and ("height" in resize): + resize = ( + int(resize["width"]), + int(resize["height"]), + ) # Convert dict to tuple + else: + abort(404) + + bayer = payload.param("bayer", default=False, convert=bool) + metadata = payload.param("metadata", default={}, convert=dict) + tags = payload.param("tags", default=[], convert=list) + + logging.info("Running tile scan...") + task = taskify(tile)( + microscope, + basename=filename, + temporary=temporary, + step_size=step_size, + grid=grid, + style=style, + autofocus_dz=autofocus_dz, + use_video_port=use_video_port, + resize=resize, + bayer=bayer, + fast_autofocus=fast_autofocus, + metadata=metadata, + tags=tags, + ) + + # return a handle on the scan task + return jsonify(task.state), 201 + + +scan_plugin_v2 = BasePlugin("scan") + +scan_plugin_v2.add_view("/tile", TileScanAPI) diff --git a/openflexure_microscope/task.py b/openflexure_microscope/task.py deleted file mode 100644 index c195e3fc..00000000 --- a/openflexure_microscope/task.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging -from openflexure_microscope.common.tasks import ( - tasks, - states, - taskify, - cleanup_tasks, - remove_task, -) - -# DEPRECATED -class TaskOrchestrator: - """ - DEPRECATED: Class responsible for spawning threaded tasks, and storing their returns. - A microscope should contain exactly one instance of `TaskOrchestrator`. - - Attributes: - tasks (list): List of `Task` objects - - """ - - @property - def tasks(self): - logging.warning( - "TaskOrchestrator class is deprecated. Please use common.tasks.tasks().values() instead." - ) - return tasks().values() - - @property - def state(self): - """ - Returns a list of dictionary representations of all tasks in the session. - """ - logging.warning( - "TaskOrchestrator class is deprecated. Please use common.tasks.tasks() instead." - ) - return states() - - def task_from_id(self, task_id: str): - """ - Returns a particular task object with the specified `task_id`. - """ - return tasks()[task_id] - - def start(self, function, *args, **kwargs): - """ - Attach and start a new long-running task, if allowed by `start_condition()`. - Returns an instance of :py:class:`openflexure_microscope.task.Task`, which can be used to check the state - or eventual return of the task. - - Args: - function (function): The target function to run in the background - args, kwargs: Arguments that will be passed to `function` at runtime. - """ - logging.warning( - "TaskOrchestrator class is deprecated. Please use common.tasks.taskify() instead." - ) - return taskify(function)(*args, **kwargs) - - def clean(self): - """ - Remove all inactive tasks from the task array. - This will remove tasks that either finished or never started. - """ - logging.warning( - "TaskOrchestrator class is deprecated. Please use common.tasks.cleanup_tasks() instead." - ) - cleanup_tasks() - - def delete(self, task_id: str): - """ - Delete a given task by ID, only if task is not currently running. - """ - logging.warning( - "TaskOrchestrator class is deprecated. Please use common.tasks.remove_task() instead." - ) - remove_task(task_id) From 5efd58f56170f16ee8a4f17f91e0f3525a3fc0cc Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 11:06:35 +0000 Subject: [PATCH 013/122] Removed API v1 --- openflexure_microscope/api/v1/__init__.py | 0 .../api/v1/blueprints/__init__.py | 1 - .../api/v1/blueprints/base.py | 253 ---------- .../api/v1/blueprints/camera/__init__.py | 59 --- .../api/v1/blueprints/camera/capture.py | 459 ------------------ .../api/v1/blueprints/camera/function.py | 101 ---- .../api/v1/blueprints/camera/preview.py | 54 --- .../api/v1/blueprints/plugins.py | 120 ----- .../api/v1/blueprints/stage.py | 105 ---- .../api/v1/blueprints/task.py | 141 ------ 10 files changed, 1293 deletions(-) delete mode 100644 openflexure_microscope/api/v1/__init__.py delete mode 100644 openflexure_microscope/api/v1/blueprints/__init__.py delete mode 100644 openflexure_microscope/api/v1/blueprints/base.py delete mode 100644 openflexure_microscope/api/v1/blueprints/camera/__init__.py delete mode 100644 openflexure_microscope/api/v1/blueprints/camera/capture.py delete mode 100644 openflexure_microscope/api/v1/blueprints/camera/function.py delete mode 100644 openflexure_microscope/api/v1/blueprints/camera/preview.py delete mode 100644 openflexure_microscope/api/v1/blueprints/plugins.py delete mode 100644 openflexure_microscope/api/v1/blueprints/stage.py delete mode 100644 openflexure_microscope/api/v1/blueprints/task.py diff --git a/openflexure_microscope/api/v1/__init__.py b/openflexure_microscope/api/v1/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openflexure_microscope/api/v1/blueprints/__init__.py b/openflexure_microscope/api/v1/blueprints/__init__.py deleted file mode 100644 index eea7c36c..00000000 --- a/openflexure_microscope/api/v1/blueprints/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import camera, stage, base, plugins, task diff --git a/openflexure_microscope/api/v1/blueprints/base.py b/openflexure_microscope/api/v1/blueprints/base.py deleted file mode 100644 index cf3b014b..00000000 --- a/openflexure_microscope/api/v1/blueprints/base.py +++ /dev/null @@ -1,253 +0,0 @@ -from openflexure_microscope.api.utilities import gen, JsonResponse -from openflexure_microscope.api.views import MicroscopeView - -from flask import Response, Blueprint, jsonify, request -import logging - - -class StreamAPI(MicroscopeView): - def get(self): - """ - Real-time MJPEG stream from the microscope camera - - .. :quickref: State; Camera stream - - :>header Accept: image/jpeg - :>header Content-Type: image/jpeg - :status 200: stream active - """ - # Restart stream worker thread - self.microscope.camera.start_worker() - - return Response( - gen(self.microscope.camera), - mimetype="multipart/x-mixed-replace; boundary=frame", - ) - - -class SnapshotAPI(MicroscopeView): - def get(self): - """ - Single snapshot from the camera stream - - .. :quickref: State; Camera snapshot - - :>header Accept: image/jpeg - :>header Content-Type: image/jpeg - :status 200: stream active - """ - # Restart stream worker thread - self.microscope.camera.start_worker() - - return Response(self.microscope.camera.get_frame(), mimetype="image/jpeg") - - -class StateAPI(MicroscopeView): - def get(self): - """ - JSON representation of the microscope object. - - .. :quickref: State; Microscope state - - **Example request**: - - .. sourcecode:: http - - GET /state/ HTTP/1.1 - Accept: application/json - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "camera": { - "preview_active": false, - "record_active": false, - "stream_active": true - }, - "plugin": {}, - "stage": { - "backlash": { - "x": 128, - "y": 128, - "z": 128 - }, - "position": { - "x": -8080, - "y": 5665, - "z": -12600 - } - } - } - - :>header Accept: application/json - :>header Content-Type: application/json - :status 200: state available - """ - return jsonify(self.microscope.state) - - -class ConfigAPI(MicroscopeView): - def get(self): - """ - JSON representation of the microscope config. - - .. :quickref: Config; Get microscope config - - **Example request**: - - .. sourcecode:: http - - GET /config/ HTTP/1.1 - Accept: application/json - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "id": "0e2c6fac5421429aac67c7903107bdd8", - "name": "docuscope-2000", - "fov": [4100, 3146], - "camera_settings": { - "image_resolution": [2592, 1944], - "numpy_resolution": [1312, 976], - "video_resolution": [832, 624], - "jpeg_quality": 75, - "picamera_settings": { - "analog_gain": 1.0, - "digital_gain": 1.0, - "awb_gains": [0.92578125, 2.94921875], - "awb_mode": "off", - "exposure_mode": "off", - "framerate": 24.0, - "saturation": 0, - "shutter_speed": 5378 - }, - }, - "stage_settings": { - "backlash": { - "x": 256, - "y": 256, - "z": 0 - } - } - "plugins": [ - "openflexure_microscope.plugins.default.autofocus:AutofocusPlugin", - "openflexure_microscope.plugins.default.scan:ScanPlugin", - "openflexure_microscope.plugins.default.camera_calibration:Plugin" - ] - } - - :>header Accept: application/json - :>header Content-Type: application/json - :status 200: state available - """ - return jsonify(self.microscope.read_settings(json_safe=True)) - - def post(self): - """ - Modify microscope configuration - - .. :quickref: Config; Set microscope config - - **Example request**: - - .. sourcecode:: http - - POST /config HTTP/1.1 - Accept: application/json - - { - "id": "0e2c6fac5421429aac67c7903107bdd8", - "name": "docuscope-2000", - "fov": [4100, 3146], - "camera_settings": { - "image_resolution": [2592, 1944], - "numpy_resolution": [1312, 976], - "video_resolution": [832, 624], - "jpeg_quality": 75, - "picamera_settings": { - "analog_gain": 1.0, - "digital_gain": 1.0, - "awb_gains": [0.92578125, 2.94921875], - "awb_mode": "off", - "exposure_mode": "off", - "framerate": 24.0, - "saturation": 0, - "shutter_speed": 5378 - }, - }, - "stage_settings": { - "backlash": { - "x": 256, - "y": 256, - "z": 0 - } - } - "plugins": [ - "openflexure_microscope.plugins.default.autofocus:AutofocusPlugin", - "openflexure_microscope.plugins.default.scan:ScanPlugin", - "openflexure_microscope.plugins.default.camera_calibration:Plugin" - ] - } - - :>header Accept: application/json - - :/tags", - view_func=capture.TagsAPI.as_view("capture_tags", microscope=microscope_obj), - ) - - # Capture routes - blueprint.add_url_rule( - "/capture//download/", - view_func=capture.DownloadAPI.as_view( - "capture_download", microscope=microscope_obj - ), - ) - - blueprint.add_url_rule( - "/capture//download", - view_func=capture.DownloadRedirectAPI.as_view( - "capture_download_redirect", microscope=microscope_obj - ), - ) - - blueprint.add_url_rule( - "/capture//", - view_func=capture.CaptureAPI.as_view("capture", microscope=microscope_obj), - ) - - blueprint.add_url_rule( - "/capture/", - view_func=capture.ListAPI.as_view("capture_list", microscope=microscope_obj), - ) - - # Preview routes - blueprint.add_url_rule( - "/preview/", - view_func=preview.GPUPreviewAPI.as_view( - "gpu_preview", microscope=microscope_obj - ), - ) - - # Function routes - blueprint.add_url_rule( - "/overlay", - view_func=function.OverlayAPI.as_view("overlay", microscope=microscope_obj), - ) - - blueprint.add_url_rule( - "/zoom", view_func=function.ZoomAPI.as_view("zoom", microscope=microscope_obj) - ) - - return blueprint diff --git a/openflexure_microscope/api/v1/blueprints/camera/capture.py b/openflexure_microscope/api/v1/blueprints/camera/capture.py deleted file mode 100644 index d5e66dc5..00000000 --- a/openflexure_microscope/api/v1/blueprints/camera/capture.py +++ /dev/null @@ -1,459 +0,0 @@ -from openflexure_microscope.api.utilities import get_bool, JsonResponse -from openflexure_microscope.api.views import MicroscopeView -from openflexure_microscope.utilities import filter_dict - -from flask import jsonify, request, abort, url_for, redirect, send_file - - -class ListAPI(MicroscopeView): - def get(self): - """ - Get list of image captures. - - .. :quickref: Captures; Get collection of captures - - :>header Accept: application/json - :query include_unavailable: return json representations of captures that have been completely deleted - - :>jsonarr boolean available: availability of capture data - :>jsonarr string filename: filename of capture - :>jsonarr string id: unique id of the capture object - :>jsonarr boolean keep_on_disk: keep the capture file on microscope after closing - :>jsonarr boolean locked: file locked for modifications (mostly used for video recording) - :>jsonarr string path: path on pi storage to the capture file, if available - :>jsonarr boolean stream: capture stored in-memory as a BytesIO stream - :>jsonarr json uri: - **download** *(string)*: api uri to the capture file download - - **state** *(string)*: api uri to the capture json representation - - :>header Content-Type: application/json - :status 200: capture found - :status 404: no capture found with that id - """ - include_unavailable = get_bool(request.args.get("include_unavailable")) - - if include_unavailable: - captures = [image.state for image in self.microscope.camera.images] - else: - captures = [ - image.state - for image in self.microscope.camera.images - if image.state["available"] - ] - - return jsonify(captures) - - def delete(self): - """ - Delete all captures (not yet implemented) - - .. :quickref: Captures; Delete all captures - """ - for image in self.microscope.camera.images: - image.delete() - - captures = [image.state for image in self.microscope.camera.images] - - return jsonify(captures) - - def post(self): - """ - Create a new image capture. - - .. :quickref: Captures; New capture - - **Example request**: - - .. sourcecode:: http - - POST /camera/capture HTTP/1.1 - Accept: application/json - - { - "filename": "myfirstcapture", - "temporary": false, - "use_video_port": true, - "bayer": true, - "size": { - "width": 640, - "height": 480 - } - } - - :>header Accept: application/json - - :json boolean available: availability of capture data - :>json string filename: filename of capture - :>json string id: unique id of the capture object - :>json boolean temporary: delete the capture file on microscope after closing - :>json boolean locked: file locked for modifications (mostly used for video recording) - :>json string path: path on pi storage to the capture file, if available - :>json boolean stream: capture stored in-memory as a BytesIO stream - :>json json uri: - **download** *(string)*: api uri to the capture file download - - **state** *(string)*: api uri to the capture json representation - - :
json boolean available: availability of capture data - :>json string filename: filename of capture - :>json string id: unique id of the capture object - :>json boolean keep_on_disk: keep the capture file on microscope after closing - :>json boolean locked: file locked for modifications (mostly used for video recording) - :>json string path: path on pi storage to the capture file, if available - :>json boolean stream: capture stored in-memory as a BytesIO stream - :>json json uri: - **download** *(string)*: api uri to the capture file download - - **state** *(string)*: api uri to the capture json representation - - """ - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj: - return abort(404) # 404 Not Found - - # Get capture state - capture_metadata = capture_obj.state - - # Add API routes to returned state - uri_dict = { - "uri": { - "state": "{}".format(url_for(".capture", capture_id=capture_obj.id)) - } - } - - # If available, also add download link - if capture_metadata["available"]: - uri_dict["uri"]["download"] = "{}download/{}".format( - url_for(".capture", capture_id=capture_obj.id), capture_obj.filename - ) - - capture_metadata.update(uri_dict) - - return jsonify(capture_metadata) - - def delete(self, capture_id): - """ - Delete all capture data from the Pi, even if `keep_on_disk=true;`. - - .. :quickref: Capture; Delete capture. - """ - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj: - return abort(404) # 404 Not Found - - capture_obj.delete() - - return jsonify({"return": capture_id}) - - def put(self, capture_id): - """ - Add arbitrary metadata to the capture - - .. :quickref: Capture; Update capture metadata - - **Example request**: - - .. sourcecode:: http - - PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b HTTP/1.1 - Accept: application/json - - { - "user_id": "ofm_1234", - "patient_number": 1452, - } - - :>header Accept: application/json - - :
header Accept: image/jpeg - :query thumbnail: return an image thumbnail e.g. ?thumbnail=true - - :>header Content-Type: image/jpeg - :status 200: capture data found - :status 404: no capture found with that id - - """ - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj or not capture_obj.state["available"]: - return abort(404) # 404 Not Found - - thumbnail = get_bool(request.args.get("thumbnail")) - - return redirect( - url_for( - ".capture_download", - capture_id=capture_id, - filename=capture_obj.filename, - thumbnail=thumbnail, - ), - code=307, - ) - - -class DownloadAPI(MicroscopeView): - def get(self, capture_id, filename): - - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj or not capture_obj.state["available"]: - return abort(404) # 404 Not Found - - thumbnail = get_bool(request.args.get("thumbnail")) - - # If no filename is specified, redirect to the capture's currently set filename - if not filename: - return redirect( - url_for( - "capture_download", - capture_id=capture_id, - filename=capture_obj.filename, - thumbnail=thumbnail, - ), - code=307, - ) - - # Download the image data using the requested filename - if thumbnail: - img = capture_obj.thumbnail - else: - img = capture_obj.data - - return send_file(img, mimetype="image/jpeg") - - -class TagsAPI(MicroscopeView): - def get(self, capture_id): - """ - Return tag list for a capture. - - .. :quickref: Capture; Show capture tags - - **Example request**: - - .. sourcecode:: http - - GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1 - Accept: text/json - - :>header Accept: text/json - - :>header Content-Type: text/json - :status 200: capture data found - :status 404: no capture found with that id - """ - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj or not capture_obj.state["available"]: - return abort(404) # 404 Not Found - - metadata_tags = filter_dict(capture_obj.state, ["metadata", "tags"]) - - return jsonify(metadata_tags) - - def put(self, capture_id): - """ - Add tags to the capture - - .. :quickref: Capture; Update capture tags - - **Example request**: - - .. sourcecode:: http - - PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1 - Accept: application/json - - ["tests", "mytag", "someothertag"] - - :>header Accept: application/json - - :
header Accept: application/json - - :
header Accept: application/json - - :
header Accept: application/json - - :
header Accept: application/json - - :
header Accept: application/json - - :
header Accept: application/json - - :
header Accept: application/json - :query include_unavailable: return json representations of captures that have been completely deleted - - :>header Content-Type: application/json - """ - - data = tasks.states() - - return jsonify(data) - - def delete(self): - """ - Clean list of long-running tasks (running tasks persist). - - .. :quickref: Tasks; Clean collection of tasks - - :>header Accept: application/json - - :>header Content-Type: application/json - """ - - tasks.cleanup_tasks() - data = tasks.states() - - return jsonify(data) - - -class TaskAPI(MethodView): - def get(self, task_id): - """ - Get JSON representation of a task - - .. :quickref: Tasks; Get task - - **Example request**: - - .. sourcecode:: http - - GET /task/db13a66787e1419bb06b1504e4d80b0c/ HTTP/1.1 - Accept: application/json - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "end_time": "2019-01-23 16-33-33", - "id": "db13a66787e1419bb06b1504e4d80b0c", - "return": [ - 0.848622546386467, - 0.6106785018091292, - ], - "start_time": "2019-01-23 16-33-13", - "status": "success" - } - - """ - - try: - task = tasks.tasks()[task_id] - except KeyError: - return abort(404) # 404 Not Found - - # Return task state - return jsonify(task.state) - - def delete(self, task_id): - """ - Terminate a particular task. - - .. :quickref: Tasks; Terminate a task - - :>header Accept: application/json - - :>header Content-Type: application/json - """ - - try: - task = tasks.tasks()[task_id] - except KeyError: - return abort(404) # 404 Not Found - - task.terminate() - - return jsonify(task.state) - - -def construct_blueprint(microscope_obj): - - blueprint = Blueprint("task_blueprint", __name__) - - blueprint.add_url_rule("/", view_func=TaskListAPI.as_view("task_list")) - - blueprint.add_url_rule("//", view_func=TaskAPI.as_view("task")) - - return blueprint From 74f1d55bb2f7436d0024028a1ee727822beea71e Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 11:55:51 +0000 Subject: [PATCH 014/122] Added capture resources to new system --- openflexure_microscope/api/app.py | 4 +- .../api/v2/views/captures.py | 199 ++++++++++++++++++ .../common/labthings/fields.py | 153 ++++++++++++++ .../common/labthings/schema.py | 39 ++++ 4 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 openflexure_microscope/api/v2/views/captures.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index cb952312..9ce27a69 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -17,7 +17,6 @@ from openflexure_microscope.api.exceptions import JSONExceptionHandler from openflexure_microscope.api.utilities import list_routes from openflexure_microscope.config import settings_file_path, JSONEncoder -from openflexure_microscope.api.v1 import blueprints from openflexure_microscope.api import v2 from openflexure_microscope.common.labthings.labthing import LabThing @@ -88,6 +87,9 @@ labthing.register_device(api_microscope, "openflexure_microscope") for _plugin in find_plugins(USER_PLUGINS_PATH): labthing.register_plugin(_plugin) +from openflexure_microscope.api.v2.views.captures import add_captures_to_labthing +add_captures_to_labthing(labthing, prefix="") + # WEBAPP ROUTES ### V2 diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py new file mode 100644 index 00000000..76e0f7ba --- /dev/null +++ b/openflexure_microscope/api/v2/views/captures.py @@ -0,0 +1,199 @@ +import logging +from flask import abort, request, redirect, url_for, send_file, jsonify + +from openflexure_microscope.api.utilities import get_bool, JsonResponse + +from openflexure_microscope.common.labthings.schema import Schema +from openflexure_microscope.common.labthings import fields +from openflexure_microscope.common.labthings.resource import Resource + +from openflexure_microscope.common.labthings.find import find_device + + +class CaptureSchema(Schema): + id = fields.String() + file = fields.String(data_key="path") + exists = fields.Bool(data_key="available") + filename = fields.String() + metadata = fields.Dict() + + # TODO: Add HTTP methods + links = fields.Hyperlinks( + { + "self": { + "href": fields.AbsoluteUrlFor("CaptureResource", id=""), + "mimetype": "application/json", + }, + "tags": { + "href": fields.AbsoluteUrlFor("CaptureTags", id=""), + "mimetype": "application/json", + }, + "metadata": { + "href": fields.AbsoluteUrlFor("CaptureMetadata", id=""), + "mimetype": "application/json", + }, + "download": { + "href": fields.AbsoluteUrlFor("CaptureDownload", id="", filename=""), + "mimetype": "image/jpeg", + } + } + ) + + +capture_schema = CaptureSchema() +capture_list_schema = CaptureSchema(many=True) + + +class CaptureList(Resource): + def get(self): + microscope = find_device("openflexure_microscope") + image_list = microscope.camera.images + return capture_list_schema.jsonify(image_list) + + +class CaptureResource(Resource): + def get(self, id): + microscope = find_device("openflexure_microscope") + capture_obj = microscope.camera.image_from_id(id) + + if not capture_obj: + return abort(404) # 404 Not Found + + return capture_schema.jsonify(capture_obj) + + def delete(self, id): + microscope = find_device("openflexure_microscope") + capture_obj = microscope.camera.image_from_id(id) + + if not capture_obj: + return abort(404) # 404 Not Found + + capture_obj.delete() + + return ("", 204) + + +class CaptureDownload(Resource): + def get(self, id, filename): + + microscope = find_device("openflexure_microscope") + capture_obj = microscope.camera.image_from_id(id) + + if not capture_obj: + return abort(404) # 404 Not Found + + thumbnail = get_bool(request.args.get("thumbnail")) + + # If no filename is specified, redirect to the capture's currently set filename + if not filename: + return redirect( + url_for( + "DownloadAPI", + id=id, + filename=capture_obj.filename, + thumbnail=thumbnail, + ), + code=307, + ) + + # Download the image data using the requested filename + if thumbnail: + img = capture_obj.thumbnail + else: + img = capture_obj.data + + return send_file(img, mimetype="image/jpeg") + + +class CaptureTags(Resource): + def get(self, id): + + microscope = find_device("openflexure_microscope") + capture_obj = microscope.camera.image_from_id(id) + + if not capture_obj: + return abort(404) # 404 Not Found + + return jsonify(capture_obj.tags) + + def put(self, id): + + microscope = find_device("openflexure_microscope") + capture_obj = microscope.camera.image_from_id(id) + + if not capture_obj: + return abort(404) # 404 Not Found + + data_dict = JsonResponse(request).json + + if type(data_dict) != list: + return abort(400) + + capture_obj.put_tags(data_dict) + + return jsonify(capture_obj.tags) + + def delete(self, capture_id): + + microscope = find_device("openflexure_microscope") + capture_obj = microscope.camera.image_from_id(id) + + if not capture_obj: + return abort(404) # 404 Not Found + + data_dict = JsonResponse(request).json + + if type(data_dict) != list: + return abort(400) + + for tag in data_dict: + capture_obj.delete_tag(str(tag)) + + return jsonify(capture_obj.tags) + + +class CaptureMetadata(Resource): + def get(self, id): + microscope = find_device("openflexure_microscope") + capture_obj = microscope.camera.image_from_id(id) + + if not capture_obj: + return abort(404) # 404 Not Found + + return jsonify(capture_obj.metadata) + + def put(self, id): + microscope = find_device("openflexure_microscope") + capture_obj = microscope.camera.image_from_id(id) + + if not capture_obj: + return abort(404) # 404 Not Found + + data_dict = JsonResponse(request).json + + if type(data_dict) != list: + return abort(400) + + # TODO: Allow putting system metadata maybe? + capture_obj.put_metadata(data_dict) + + return jsonify(capture_obj.metadata) + + +def add_captures_to_labthing(labthing, prefix=""): + """ + Add all capture resources to a labthing + """ + labthing.add_resource(CaptureList, f"{prefix}/captures", endpoint="CaptureList") + labthing.add_resource( + CaptureResource, f"{prefix}/captures/", endpoint="CaptureResource" + ) + labthing.add_resource( + CaptureDownload, f"{prefix}/captures//download/", endpoint="CaptureDownload" + ) + labthing.add_resource( + CaptureTags, f"{prefix}/captures//tags", endpoint="CaptureTags" + ) + labthing.add_resource( + CaptureMetadata, f"{prefix}/captures//metadata", endpoint="CaptureMetadata" + ) diff --git a/openflexure_microscope/common/labthings/fields.py b/openflexure_microscope/common/labthings/fields.py index e69de29b..7920f005 100644 --- a/openflexure_microscope/common/labthings/fields.py +++ b/openflexure_microscope/common/labthings/fields.py @@ -0,0 +1,153 @@ +from marshmallow.fields import * +from marshmallow import missing +import re +from flask import url_for + +_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='') + https_url = URLFor('author_get', 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): + self.endpoint = endpoint + 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__``. + """ + 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=''), + 'collection': URLFor('author_list'), + }) + `URLFor` objects can be nested within the dictionary. :: + _links = Hyperlinks({ + 'self': { + 'href': URLFor('book', 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) \ No newline at end of file diff --git a/openflexure_microscope/common/labthings/schema.py b/openflexure_microscope/common/labthings/schema.py index e69de29b..4c2f3c95 100644 --- a/openflexure_microscope/common/labthings/schema.py +++ b/openflexure_microscope/common/labthings/schema.py @@ -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) \ No newline at end of file From d52453849c0d881fa8e01eddecf061097f45a29d Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 12:01:02 +0000 Subject: [PATCH 015/122] Removed plugins from old v2 route --- openflexure_microscope/api/v2/blueprints/root.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/blueprints/root.py b/openflexure_microscope/api/v2/blueprints/root.py index bdaf348b..6790283e 100644 --- a/openflexure_microscope/api/v2/blueprints/root.py +++ b/openflexure_microscope/api/v2/blueprints/root.py @@ -14,7 +14,7 @@ from openflexure_microscope.api.v2.blueprints import ( ) # List of submodules containing create_blueprint methods using standard blueprint_for_module naming -_root_blueprint_modules = [settings, status, plugins, captures, actions, streams] +_root_blueprint_modules = [settings, status, captures, actions, streams] def root_representation(): From f2af359b8b1b88efa1dc72e369734030c48d2a11 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 14:30:33 +0000 Subject: [PATCH 016/122] Added basic thing description to root --- openflexure_microscope/api/app.py | 60 ++++----- .../api/v2/blueprints/__init__.py | 2 +- .../api/v2/blueprints/plugins.py | 114 ------------------ .../api/v2/blueprints/root.py | 1 - .../api/v2/views/actions/__init__.py | 65 ++++++++++ .../api/v2/views/actions/camera.py | 82 +++++++++++++ .../api/v2/views/actions/stage.py | 55 +++++++++ .../api/v2/views/actions/system.py | 46 +++++++ .../api/v2/views/captures.py | 11 +- openflexure_microscope/api/v2/views/state.py | 94 +++++++++++++++ .../api/v2/views/streams.py | 62 ++++++++++ openflexure_microscope/api/v2/views/tasks.py | 71 +++++++++++ .../common/labthings/fields.py | 2 +- .../common/labthings/find.py | 13 +- .../common/labthings/labthing.py | 89 ++++++++++++-- .../common/labthings/plugins.py | 18 ++- .../common/tasks/__init__.py | 2 + openflexure_microscope/common/tasks/pool.py | 25 +++- openflexure_microscope/common/utilities.py | 6 + openflexure_microscope/microscope.py | 3 +- .../plugins/v2/autofocus.py | 11 +- openflexure_microscope/plugins/v2/scan.py | 13 +- poetry.lock | 14 ++- pyproject.toml | 1 + 24 files changed, 677 insertions(+), 183 deletions(-) delete mode 100644 openflexure_microscope/api/v2/blueprints/plugins.py create mode 100644 openflexure_microscope/api/v2/views/actions/__init__.py create mode 100644 openflexure_microscope/api/v2/views/actions/camera.py create mode 100644 openflexure_microscope/api/v2/views/actions/stage.py create mode 100644 openflexure_microscope/api/v2/views/actions/system.py create mode 100644 openflexure_microscope/api/v2/views/state.py create mode 100644 openflexure_microscope/api/v2/views/streams.py create mode 100644 openflexure_microscope/api/v2/views/tasks.py create mode 100644 openflexure_microscope/common/utilities.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 9ce27a69..528800d8 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -6,7 +6,7 @@ import logging import sys import os -from flask import Flask, jsonify, send_file +from flask import Flask, jsonify, send_file, url_for from serial import SerialException from datetime import datetime @@ -79,7 +79,7 @@ CORS(app, resources=r"*") handler = JSONExceptionHandler(app) # Attach lab devices -labthing = LabThing(app, prefix="/api/v2b") +labthing = LabThing(app, prefix="/api/v2") labthing.description = "Test LabThing-based API for OpenFlexure Microscope" labthing.register_device(api_microscope, "openflexure_microscope") @@ -88,41 +88,23 @@ for _plugin in find_plugins(USER_PLUGINS_PATH): labthing.register_plugin(_plugin) from openflexure_microscope.api.v2.views.captures import add_captures_to_labthing + add_captures_to_labthing(labthing, prefix="") +from openflexure_microscope.api.v2.views.state import add_states_to_labthing -# WEBAPP ROUTES +add_states_to_labthing(labthing, prefix="") -### V2 -# Root routes -v2_root_blueprint = v2.blueprints.root.construct_blueprint(api_microscope) -app.register_blueprint(v2_root_blueprint, url_prefix=uri("/", "v2")) +from openflexure_microscope.api.v2.views.tasks import add_tasks_to_labthing -v2_streams_blueprint = v2.blueprints.streams.construct_blueprint(api_microscope) -app.register_blueprint(v2_streams_blueprint, url_prefix=uri("/streams", "v2")) +add_tasks_to_labthing(labthing, prefix="") -# Captures routes -v2_captures_blueprint = v2.blueprints.captures.construct_blueprint(api_microscope) -app.register_blueprint(v2_captures_blueprint, url_prefix=uri("/captures", "v2")) +from openflexure_microscope.api.v2.views.streams import add_streams_to_labthing -# Settings routes -v2_settings_blueprint = v2.blueprints.settings.construct_blueprint(api_microscope) -app.register_blueprint(v2_settings_blueprint, url_prefix=uri("/settings", "v2")) +add_streams_to_labthing(labthing, prefix="") -# Status routes -v2_status_blueprint = v2.blueprints.status.construct_blueprint(api_microscope) -app.register_blueprint(v2_status_blueprint, url_prefix=uri("/status", "v2")) +from openflexure_microscope.api.v2.views.actions import add_actions_to_labthing -# Plugins routes -# v2_plugin_blueprint = v2.blueprints.plugins.construct_blueprint(api_microscope) -# app.register_blueprint(v2_plugin_blueprint, url_prefix=uri("/plugins", "v2")) - -# Tasks routes -v2_tasks_blueprint = v2.blueprints.tasks.construct_blueprint(api_microscope) -app.register_blueprint(v2_tasks_blueprint, url_prefix=uri("/tasks", "v2")) - -# Actions routes -v2_actions_blueprint = v2.blueprints.actions.construct_blueprint(api_microscope) -app.register_blueprint(v2_actions_blueprint, url_prefix=uri("/actions", "v2")) +add_actions_to_labthing(labthing) @app.route("/routes") @@ -139,6 +121,26 @@ def routes(): return jsonify(list_routes(app)) +@app.route("/props") +def props(): + p = {} + for key, prop in labthing.properties.items(): + p[key] = {} + p[key]["view"] = prop + p[key]["url"] = labthing.url_for(prop, _external=True) + return jsonify(p) + + +@app.route("/ac") +def actions(): + p = {} + for key, prop in labthing.actions.items(): + p[key] = {} + p[key]["view"] = prop + p[key]["url"] = labthing.url_for(prop, _external=True) + return jsonify(p) + + @app.route("/log") def err_log(): """ diff --git a/openflexure_microscope/api/v2/blueprints/__init__.py b/openflexure_microscope/api/v2/blueprints/__init__.py index 0ce1ebae..f4716594 100644 --- a/openflexure_microscope/api/v2/blueprints/__init__.py +++ b/openflexure_microscope/api/v2/blueprints/__init__.py @@ -1 +1 @@ -from . import root, captures, settings, status, tasks, streams, plugins, actions +from . import root, captures, settings, status, tasks, streams, actions diff --git a/openflexure_microscope/api/v2/blueprints/plugins.py b/openflexure_microscope/api/v2/blueprints/plugins.py deleted file mode 100644 index c9c5f07f..00000000 --- a/openflexure_microscope/api/v2/blueprints/plugins.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -Top-level representation of attached and enabled plugins -""" - -from openflexure_microscope.api.views import MicroscopeViewPlugin -from openflexure_microscope.utilities import get_docstring, description_from_view -from openflexure_microscope.api.utilities import blueprint_for_module - -from openflexure_microscope.common.labthings.plugins import find_plugins -from openflexure_microscope.config import USER_PLUGINS_PATH - -from flask import Blueprint, jsonify, url_for -from openflexure_microscope.api.views import MicroscopeView - -import copy -import logging -import warnings - -_plugins = find_plugins(USER_PLUGINS_PATH) -print(_plugins) - - -def plugins_representation(plugin_list): - """ - Generate a dictionary representation of all plugins, including Flask route URLs - - Args: - plugin_loader_object (:py:class:`openflexure_microscope.plugins.PluginLoader`): Microscope plugin loader - - Returns: - dict: Dictionary representation of all plugins - """ - plugins = {} - - for plugin in plugin_list: - logging.debug(f"Representing plugin {plugin._name}") - d = { - "python_name": plugin._name_python_safe, - "plugin": str(plugin), - "views": {}, - "gui": plugin.gui, - "description": get_docstring(plugin), - } - - for view_id, view_data in plugin.views.items(): - logging.debug(f"Representing view {view_id}") - uri = url_for(f"v2_plugins_blueprint.plugins") + view_data["rule"][1:] - # uri = view_data["rule"] - # Make links dictionary if it doesn't yet exist - view_d = {"links": {"self": uri}} - - view_d.update(description_from_view(view_data["view"])) - - d["views"][view_id] = view_d - - plugins[plugin._name] = d - - return plugins - - -class PluginFormAPI(MicroscopeView): - 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. - - """ - global _plugins - return jsonify(plugins_representation(_plugins)) - - -def construct_blueprint(microscope_obj): - - blueprint = blueprint_for_module(__name__) - - for plugin_obj in _plugins: - for plugin_view_id, plugin_view in plugin_obj.views.items(): - # Add route to the plugins blueprint - blueprint.add_url_rule( - plugin_view["rule"], - view_func=plugin_view["view"].as_view( - f"{plugin_obj._name_python_safe}_{plugin_view_id}" - ), - **plugin_view["kwargs"], - ) - - # Create a base route to return plugin API forms, if any exist - blueprint.add_url_rule( - "/", view_func=PluginFormAPI.as_view("plugins", microscope=microscope_obj) - ) - - # For each plugin attached to the microscope object - for plugin in microscope_obj.plugins.active: - - for plugin_view_id, plugin_view in plugin.views.items(): - # Add route to the plugins blueprint - blueprint.add_url_rule( - plugin_view["rule"], - view_func=plugin_view["view"].as_view( - f"{plugin._name_python_safe}_{plugin_view_id}", - microscope=microscope_obj, - plugin=plugin, - ), - **plugin_view["kwargs"], - ) - - return blueprint diff --git a/openflexure_microscope/api/v2/blueprints/root.py b/openflexure_microscope/api/v2/blueprints/root.py index 6790283e..0e8d148e 100644 --- a/openflexure_microscope/api/v2/blueprints/root.py +++ b/openflexure_microscope/api/v2/blueprints/root.py @@ -7,7 +7,6 @@ from openflexure_microscope.api.utilities import blueprint_name_for_module from openflexure_microscope.api.v2.blueprints import ( settings, status, - plugins, captures, actions, streams, diff --git a/openflexure_microscope/api/v2/views/actions/__init__.py b/openflexure_microscope/api/v2/views/actions/__init__.py new file mode 100644 index 00000000..897d67c6 --- /dev/null +++ b/openflexure_microscope/api/v2/views/actions/__init__.py @@ -0,0 +1,65 @@ +""" +Top-level representation of enabled actions +""" + +from flask import Blueprint, url_for, jsonify + +from openflexure_microscope.api.utilities import blueprint_for_module +from openflexure_microscope.utilities import get_docstring, description_from_view +from openflexure_microscope.api.views import MicroscopeView + +from . import camera, stage, system + +_actions = { + "capture": { + "rule": "/camera/capture/", + "view_class": camera.CaptureAPI, + "conditions": True, + }, + "previewStart": { + "rule": "/camera/preview/start", + "view_class": camera.GPUPreviewStartAPI, + "conditions": True, + }, + "previewStop": { + "rule": "/camera/preview/stop", + "view_class": camera.GPUPreviewStopAPI, + "conditions": True, + }, + "move": { + "rule": "/stage/move/", + "view_class": stage.MoveStageAPI, + "conditions": True, + }, + "zeroStage": { + "rule": "/stage/zero/", + "view_class": stage.ZeroStageAPI, + "conditions": True, + }, + "shutdown": { + "rule": "/system/shutdown/", + "view_class": system.ShutdownAPI, + "conditions": system.is_raspberrypi(), + }, + "reboot": { + "rule": "/system/reboot/", + "view_class": system.RebootAPI, + "conditions": system.is_raspberrypi(), + }, +} + + +def enabled_actions(): + global _actions + return {k: v for k, v in _actions.items() if v["conditions"]} + + +def add_actions_to_labthing(labthing, prefix=""): + """ + Add all capture resources to a labthing + """ + for name, action in enabled_actions().items(): + view_class = action["view_class"] + rule = action["rule"] + labthing.add_resource(view_class, f"{prefix}/actions{rule}") + labthing.register_action(view_class) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py new file mode 100644 index 00000000..b3637c5d --- /dev/null +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -0,0 +1,82 @@ +from openflexure_microscope.api.utilities import get_bool, JsonResponse +from openflexure_microscope.common.labthings.resource import Resource +from openflexure_microscope.common.labthings.find import find_device +from openflexure_microscope.utilities import filter_dict + +from openflexure_microscope.api.v2.views.captures import capture_schema + +import logging +from flask import jsonify, request, abort, url_for, redirect, send_file + + +class CaptureAPI(Resource): + """ + Create a new image capture. + """ + + def post(self): + microscope = find_device("openflexure_microscope") + payload = JsonResponse(request) + + filename = payload.param("filename") + temporary = payload.param("temporary", default=False, convert=bool) + use_video_port = payload.param("use_video_port", default=False, convert=bool) + bayer = payload.param("bayer", default=True, convert=bool) + metadata = payload.param("metadata", default={}, convert=dict) + tags = payload.param("tags", default=[], convert=list) + + resize = payload.param("size", default=None) + if resize: + if ("width" in resize) and ("height" in resize): + resize = ( + int(resize["width"]), + int(resize["height"]), + ) # Convert dict to tuple + else: + abort(404) + + # Explicitally acquire lock (prevents empty files being created if lock is unavailable) + with microscope.camera.lock: + output = microscope.camera.new_image(temporary=temporary, filename=filename) + + microscope.camera.capture( + output.file, use_video_port=use_video_port, resize=resize, bayer=bayer + ) + + # Inject system metadata + output.put_metadata(microscope.metadata, system=True) + + # Insert custom metadata + output.put_metadata(metadata) + + # Insert custom tags + output.put_tags(tags) + + return capture_schema.jsonify(output) + + +class GPUPreviewStartAPI(Resource): + def post(self): + microscope = find_device("openflexure_microscope") + payload = JsonResponse(request) + + window = payload.param("window", default=[]) + logging.debug(window) + + if len(window) != 4: + fullscreen = True + window = None + else: + fullscreen = False + window = [int(w) for w in window] + + microscope.camera.start_preview(fullscreen=fullscreen, window=window) + + return jsonify(microscope.state) + + +class GPUPreviewStopAPI(Resource): + def post(self): + microscope = find_device("openflexure_microscope") + microscope.camera.stop_preview() + return jsonify(microscope.state) diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py new file mode 100644 index 00000000..0d18d93d --- /dev/null +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -0,0 +1,55 @@ +from openflexure_microscope.api.utilities import JsonResponse +from openflexure_microscope.common.labthings.resource import Resource +from openflexure_microscope.common.labthings.find import find_device +from openflexure_microscope.utilities import axes_to_array, filter_dict + +from flask import Blueprint, jsonify, request + +import logging + + +class MoveStageAPI(Resource): + def post(self): + microscope = find_device("openflexure_microscope") + # Create response object + payload = JsonResponse(request) + logging.debug(payload.json) + + # Handle absolute positioning (calculate a relative move from current position and target) + if (payload.param("absolute") is True) and ( + microscope.stage + ): # Only if stage exists + target_position = axes_to_array(payload.json, ["x", "y", "z"]) + logging.debug("TARGET: {}".format(target_position)) + position = [ + target_position[i] - microscope.stage.position[i] for i in range(3) + ] + logging.debug("DELTA: {}".format(position)) + + else: + # Get coordinates from payload + position = axes_to_array(payload.json, ["x", "y", "z"], [0, 0, 0]) + + logging.debug(position) + + # Move if stage exists + if microscope.stage: + # Explicitally acquire lock + with microscope.stage.lock: + microscope.stage.move_rel(position) + else: + logging.warning("Unable to move. No stage found.") + + return jsonify(microscope.status["stage"]["position"]) + + +class ZeroStageAPI(Resource): + """ + Zero stage coordinates + """ + + def post(self): + microscope = find_device("openflexure_microscope") + microscope.stage.zero_position() + + return jsonify(microscope.status["stage"]) diff --git a/openflexure_microscope/api/v2/views/actions/system.py b/openflexure_microscope/api/v2/views/actions/system.py new file mode 100644 index 00000000..b4243b07 --- /dev/null +++ b/openflexure_microscope/api/v2/views/actions/system.py @@ -0,0 +1,46 @@ +from openflexure_microscope.common.labthings.resource import Resource +import subprocess +import os +from sys import platform + + +def is_raspberrypi(raise_on_errors=False): + """ + Checks if Raspberry Pi. + """ + # I mean, if it works, it works... + return os.path.exists("/usr/bin/raspi-config") + + +class ShutdownAPI(Resource): + """ + Attempt to shutdown the device + """ + + def post(self): + """ + Attempt to shutdown the device + + .. :quickref: Actions; Shutdown + + """ + subprocess.Popen(["sudo", "shutdown", "-h", "now"]) + + return "{}", 201 + + +class RebootAPI(Resource): + """ + Attempt to reboot the device + """ + + def post(self): + """ + Attempt to shutdown the device + + .. :quickref: Actions; Shutdown + + """ + subprocess.Popen(["sudo", "shutdown", "-r", "now"]) + + return "{}", 201 diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 76e0f7ba..f90623c9 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -33,9 +33,11 @@ class CaptureSchema(Schema): "mimetype": "application/json", }, "download": { - "href": fields.AbsoluteUrlFor("CaptureDownload", id="", filename=""), + "href": fields.AbsoluteUrlFor( + "CaptureDownload", id="", filename="" + ), "mimetype": "image/jpeg", - } + }, } ) @@ -185,11 +187,14 @@ def add_captures_to_labthing(labthing, prefix=""): Add all capture resources to a labthing """ labthing.add_resource(CaptureList, f"{prefix}/captures", endpoint="CaptureList") + labthing.register_property(CaptureList) labthing.add_resource( CaptureResource, f"{prefix}/captures/", endpoint="CaptureResource" ) labthing.add_resource( - CaptureDownload, f"{prefix}/captures//download/", endpoint="CaptureDownload" + CaptureDownload, + f"{prefix}/captures//download/", + endpoint="CaptureDownload", ) labthing.add_resource( CaptureTags, f"{prefix}/captures//tags", endpoint="CaptureTags" diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py new file mode 100644 index 00000000..c2dae44a --- /dev/null +++ b/openflexure_microscope/api/v2/views/state.py @@ -0,0 +1,94 @@ +from openflexure_microscope.api.utilities import JsonResponse +from openflexure_microscope.utilities import get_by_path, set_by_path, create_from_path + +from openflexure_microscope.common.labthings.find import find_device +from openflexure_microscope.common.labthings.resource import Resource + +from flask import jsonify, request, abort +import logging + + +class SettingsProperty(Resource): + def get(self): + microscope = find_device("openflexure_microscope") + return jsonify(microscope.read_settings()) + + def put(self): + microscope = find_device("openflexure_microscope") + payload = JsonResponse(request) + + logging.debug("Updating settings from PUT request:") + logging.debug(payload.json) + + microscope.apply_settings(payload.json) + microscope.save_settings() + + return self.get() + + +class NestedSettingsProperty(Resource): + def get(self, route): + microscope = find_device("openflexure_microscope") + keys = route.split("/") + + try: + value = get_by_path(microscope.read_settings(), keys) + except KeyError: + return abort(404) + + return jsonify(value) + + def put(self, route): + microscope = find_device("openflexure_microscope") + keys = route.split("/") + payload = JsonResponse(request) + + dictionary = create_from_path(keys) + set_by_path(dictionary, keys, payload.json) + + microscope.apply_settings(dictionary) + microscope.save_settings() + + return self.get(route) + + +class StatusProperty(Resource): + def get(self): + microscope = find_device("openflexure_microscope") + return jsonify(microscope.status) + + +class NestedStatusProperty(Resource): + def get(self, route): + microscope = find_device("openflexure_microscope") + keys = route.split("/") + + try: + value = get_by_path(microscope.status, keys) + except KeyError: + return abort(404) + + return jsonify(value) + + +def add_states_to_labthing(labthing, prefix=""): + """ + Add all settings and status resources to a labthing + """ + labthing.add_resource( + SettingsProperty, f"{prefix}/settings", endpoint="SettingsProperty" + ) + labthing.register_property(SettingsProperty) + labthing.add_resource( + NestedSettingsProperty, + f"{prefix}/settings/", + endpoint="NestedSettingsProperty", + ) + labthing.add_resource(StatusProperty, f"{prefix}/status", endpoint="StatusProperty") + labthing.register_property(StatusProperty) + labthing.add_resource( + NestedStatusProperty, + f"{prefix}/status/", + endpoint="NestedStatusProperty", + ) + diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py new file mode 100644 index 00000000..9527c941 --- /dev/null +++ b/openflexure_microscope/api/v2/views/streams.py @@ -0,0 +1,62 @@ +from openflexure_microscope.api.utilities import gen, JsonResponse +from openflexure_microscope.utilities import get_by_path, set_by_path, create_from_path + +from openflexure_microscope.common.labthings.find import find_device +from openflexure_microscope.common.labthings.resource import Resource + +from flask import jsonify, request, abort, Response +import logging + + +class MjpegStream(Resource): + """ + Real-time MJPEG stream from the microscope camera + """ + + def get(self): + """ + Real-time MJPEG stream from the microscope camera + """ + microscope = find_device("openflexure_microscope") + # Restart stream worker thread + microscope.camera.start_worker() + + return Response( + gen(microscope.camera), mimetype="multipart/x-mixed-replace; boundary=frame" + ) + + +class SnapshotStream(Resource): + """ + Single JPEG snapshot from the camera stream + """ + + def get(self): + """ + Single snapshot from the camera stream + + .. :quickref: Streams; Camera snapshot + + :>header Accept: image/jpeg + :>header Content-Type: image/jpeg + :status 200: stream active + """ + microscope = find_device("openflexure_microscope") + # Restart stream worker thread + microscope.camera.start_worker() + + return Response(microscope.camera.get_frame(), mimetype="image/jpeg") + + +def add_streams_to_labthing(labthing, prefix=""): + """ + Add all stream resources to a labthing + """ + labthing.add_resource( + MjpegStream, f"{prefix}/streams/mjpeg", endpoint="MjpegStream" + ) + labthing.register_property(MjpegStream) + labthing.add_resource( + SnapshotStream, f"{prefix}/streams/snapshot", endpoint="SnapshotStream" + ) + labthing.register_property(SnapshotStream) diff --git a/openflexure_microscope/api/v2/views/tasks.py b/openflexure_microscope/api/v2/views/tasks.py new file mode 100644 index 00000000..67a7e3da --- /dev/null +++ b/openflexure_microscope/api/v2/views/tasks.py @@ -0,0 +1,71 @@ +import logging +from flask import abort, request, redirect, url_for, send_file, jsonify + +from openflexure_microscope.api.utilities import get_bool, JsonResponse + +from openflexure_microscope.common.labthings.schema import Schema +from openflexure_microscope.common.labthings import fields +from openflexure_microscope.common.labthings.resource import Resource + +from openflexure_microscope.common import tasks + + +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=""), + "mimetype": "application/json", + } + } + ) + + +task_schema = TaskSchema() +task_list_schema = TaskSchema(many=True) + + +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 jsonify(task) + + def delete(self, id): + try: + task = tasks.dict()[id] + except KeyError: + return abort(404) # 404 Not Found + + task.terminate() + + return jsonify(task.state) + + +def add_tasks_to_labthing(labthing, prefix=""): + """ + Add all settings and status resources to a labthing + """ + labthing.add_resource(TaskList, f"{prefix}/tasks", endpoint="TasksProperty") + labthing.register_property(TaskList) + labthing.add_resource(TaskResource, f"{prefix}/tasks/", endpoint="TaskResource") + diff --git a/openflexure_microscope/common/labthings/fields.py b/openflexure_microscope/common/labthings/fields.py index 7920f005..f255065f 100644 --- a/openflexure_microscope/common/labthings/fields.py +++ b/openflexure_microscope/common/labthings/fields.py @@ -150,4 +150,4 @@ class Hyperlinks(Field): Field.__init__(self, **kwargs) def _serialize(self, value, attr, obj): - return _rapply(self.schema, _url_val, key=attr, obj=obj) \ No newline at end of file + return _rapply(self.schema, _url_val, key=attr, obj=obj) diff --git a/openflexure_microscope/common/labthings/find.py b/openflexure_microscope/common/labthings/find.py index 7b09f43e..6952e161 100644 --- a/openflexure_microscope/common/labthings/find.py +++ b/openflexure_microscope/common/labthings/find.py @@ -4,7 +4,7 @@ from flask import current_app from . import EXTENSION_NAME -def _current_labthing(): +def current_labthing(): app = current_app._get_current_object() if not app: return None @@ -17,31 +17,32 @@ def _current_labthing(): def registered_plugins(labthing_instance=None): if not labthing_instance: - labthing_instance = _current_labthing() + labthing_instance = current_labthing() return labthing_instance.plugins def registered_devices(labthing_instance=None): if not labthing_instance: - labthing_instance = _current_labthing() + labthing_instance = current_labthing() return labthing_instance.devices def find_device(device_name, labthing_instance=None): if not labthing_instance: - labthing_instance = _current_labthing() + 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() + labthing_instance = current_labthing() logging.debug("Current labthing:") - logging.debug(_current_labthing()) + logging.debug(current_labthing()) if plugin_name in labthing_instance.plugins: return labthing_instance.plugins[plugin_name] diff --git a/openflexure_microscope/common/labthings/labthing.py b/openflexure_microscope/common/labthings/labthing.py index 5c301fee..b65a848e 100644 --- a/openflexure_microscope/common/labthings/labthing.py +++ b/openflexure_microscope/common/labthings/labthing.py @@ -1,13 +1,17 @@ -from flask import current_app, _app_ctx_stack, request +from flask import current_app, _app_ctx_stack, request, url_for, jsonify from .plugins import BasePlugin from .views.plugins import PluginListResource +from .resource import Resource + +from ..utilities import get_docstring + from . import EXTENSION_NAME class LabThing(object): - def __init__(self, app=None, prefix="", description=""): + def __init__(self, app=None, prefix="", title="", description=""): self.app = app self.devices = {} @@ -15,10 +19,14 @@ class LabThing(object): self.plugins = {} self.resources = [] + self.properties = {} + self.actions = {} + self.endpoints = set() self.url_prefix = prefix self.description = description + self.title = title if app is not None: self.init_app(app) @@ -33,10 +41,17 @@ class LabThing(object): self._create_base_routes() + 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): print(f"Tearing down devices: {self.devices}") def _create_base_routes(self): + # Add thing description to root + self.app.add_url_rule(self._complete_url("/", ""), "td", self.td) + # Add plugin overview self.add_resource(PluginListResource, "/plugins") ### Device stuff @@ -51,8 +66,6 @@ class LabThing(object): else: raise TypeError("Plugin object must be an instance of BasePlugin") - # TODO: Add plugin routes - for plugin_view_id, plugin_view in plugin_object.views.items(): # Add route to the plugins blueprint self.add_resource( @@ -61,6 +74,12 @@ class LabThing(object): **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): @@ -73,7 +92,23 @@ class LabThing(object): parts = [registration_prefix, self.url_prefix, url_part] return "".join([part for part in parts if part]) - def add_resource(self, resource, *urls, **kwargs): + 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]` @@ -81,7 +116,7 @@ class LabThing(object): 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__.lower` + :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 @@ -97,10 +132,11 @@ class LabThing(object): 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, **kwargs) + self._register_view(self.app, resource, *urls, endpoint=endpoint, **kwargs) else: - self.resources.append((resource, urls, kwargs)) + self.resources.append((resource, urls, endpoint, kwargs)) def resource(self, *urls, **kwargs): """Wraps a :class:`~flask_restful.Resource` class, adding it to the @@ -120,8 +156,8 @@ class LabThing(object): return decorator - def _register_view(self, app, resource, *urls, **kwargs): - endpoint = kwargs.pop("endpoint", None) or resource.__name__.lower() + 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", {}) @@ -147,3 +183,36 @@ class LabThing(object): 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) + + ### Description + def td(self): + 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 = { + "title": self.title, + "description": self.description, + "properties": props, + "actions": actions, + } + + return jsonify(td) diff --git a/openflexure_microscope/common/labthings/plugins.py b/openflexure_microscope/common/labthings/plugins.py index 2a3078ee..fca5423d 100644 --- a/openflexure_microscope/common/labthings/plugins.py +++ b/openflexure_microscope/common/labthings/plugins.py @@ -27,6 +27,9 @@ class BasePlugin: self._rules = {} # Key: Original rule. Val: View class self._gui = None + self.actions = [] + self.properties = [] + self.name = name self.methods = {} @@ -35,7 +38,7 @@ class BasePlugin: def views(self): return self._views - def add_view(self, rule, view_class, **kwargs): + def add_view(self, view_class, rule, **kwargs): # Remove all leading slashes from view route cleaned_rule = rule while cleaned_rule[0] == "/": @@ -57,6 +60,12 @@ class BasePlugin: # 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) @@ -104,13 +113,16 @@ class BasePlugin: def _name_uri_safe(self): return snake_to_spine(self._name_python_safe) - def add_method(self, method_name, method): + 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.") + logging.warning( + "Unable to bind method to plugin. Method name already exists." + ) + def find_plugins(plugin_path, module_name="plugins"): print(f"Loading plugins from {plugin_path}") diff --git a/openflexure_microscope/common/tasks/__init__.py b/openflexure_microscope/common/tasks/__init__.py index 6b3e6dcb..282998ca 100644 --- a/openflexure_microscope/common/tasks/__init__.py +++ b/openflexure_microscope/common/tasks/__init__.py @@ -1,6 +1,7 @@ __all__ = [ "taskify", "tasks", + "dict", "states", "current_task", "update_task_progress", @@ -12,6 +13,7 @@ __all__ = [ from .pool import ( tasks, + dict, states, current_task, update_task_progress, diff --git a/openflexure_microscope/common/tasks/pool.py b/openflexure_microscope/common/tasks/pool.py index b8b526e9..69fa3ca0 100644 --- a/openflexure_microscope/common/tasks/pool.py +++ b/openflexure_microscope/common/tasks/pool.py @@ -6,12 +6,21 @@ from .thread import TaskThread from flask import copy_current_request_context + class TaskMaster: def __init__(self, *args, **kwargs): self._tasks = [] @property def tasks(self): + """ + Returns: + list: List of TaskThread objects. + """ + return self._tasks + + @property + def dict(self): """ Returns: dict: Dictionary of TaskThread objects. Key is TaskThread ID. @@ -28,7 +37,9 @@ class TaskMaster: def new(self, f, *args, **kwargs): # copy_current_request_context allows threads to access flask current_app - task = TaskThread(target=copy_current_request_context(f), args=args, kwargs=kwargs) + task = TaskThread( + target=copy_current_request_context(f), args=args, kwargs=kwargs + ) self._tasks.append(task) return task @@ -47,13 +58,23 @@ class TaskMaster: def tasks(): + """ + List of tasks in default taskmaster + Returns: + list: List of tasks in default taskmaster + """ + global _default_task_master + return _default_task_master.tasks + + +def dict(): """ Dictionary of tasks in default taskmaster Returns: dict: Dictionary of tasks in default taskmaster """ global _default_task_master - return _default_task_master.tasks + return _default_task_master.dict def states(): diff --git a/openflexure_microscope/common/utilities.py b/openflexure_microscope/common/utilities.py new file mode 100644 index 00000000..c82d54f9 --- /dev/null +++ b/openflexure_microscope/common/utilities.py @@ -0,0 +1,6 @@ +def get_docstring(obj): + ds = obj.__doc__ + if ds: + return ds.strip() + else: + return "" diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index a62c6fea..694a8c32 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -147,7 +147,6 @@ class Microscope: else: return False - # Create unified status @property def status(self): @@ -239,7 +238,7 @@ class Microscope: """ system_metadata = { "microscope_settings": self.read_settings(), - "microscope_state": self.state, + "microscope_state": self.status, "microscope_id": self.id, "microscope_name": self.name, } diff --git a/openflexure_microscope/plugins/v2/autofocus.py b/openflexure_microscope/plugins/v2/autofocus.py index 3b4408ad..6271c99d 100644 --- a/openflexure_microscope/plugins/v2/autofocus.py +++ b/openflexure_microscope/plugins/v2/autofocus.py @@ -356,9 +356,10 @@ class FastAutofocusAPI(MethodView): autofocus_plugin_v2 = BasePlugin("autofocus") -autofocus_plugin_v2.add_method("fast_autofocus", fast_autofocus) -autofocus_plugin_v2.add_method("autofocus", autofocus) +autofocus_plugin_v2.add_method(fast_autofocus, "fast_autofocus") +autofocus_plugin_v2.add_method(autofocus, "autofocus") + +autofocus_plugin_v2.add_view(MeasureSharpnessAPI, "/measure_sharpness") +autofocus_plugin_v2.add_view(AutofocusAPI, "/autofocus") +autofocus_plugin_v2.add_view(FastAutofocusAPI, "/fast_autofocus") -autofocus_plugin_v2.add_view("/measure_sharpness", MeasureSharpnessAPI) -autofocus_plugin_v2.add_view("/autofocus", AutofocusAPI) -autofocus_plugin_v2.add_view("/fast_autofocus", FastAutofocusAPI) diff --git a/openflexure_microscope/plugins/v2/scan.py b/openflexure_microscope/plugins/v2/scan.py index 7a6e2d2f..f774c2f8 100644 --- a/openflexure_microscope/plugins/v2/scan.py +++ b/openflexure_microscope/plugins/v2/scan.py @@ -25,6 +25,7 @@ import time ### Grid construction + def construct_grid(initial, step_sizes, n_steps, style="raster"): """ Given an initial position, step sizes, and number of steps, @@ -64,6 +65,7 @@ def flatten_grid(grid): _images_to_be_captured: int = 1 _images_captured_so_far: int = 0 + def progress(): progress = (_images_captured_so_far / _images_to_be_captured) * 100 logging.info(progress) @@ -72,6 +74,7 @@ def progress(): ### Capturing + def capture( microscope, basename, @@ -114,6 +117,7 @@ def capture( ### Scanning + def tile( microscope, basename: str = None, @@ -181,9 +185,7 @@ def tile( ) # shorthand for Z stack range # Construct an x-y grid (worry about z later) - x_y_grid = construct_grid( - initial_position, step_size[:2], grid[:2], style=style - ) + x_y_grid = construct_grid(initial_position, step_size[:2], grid[:2], style=style) # Keep the initial Z position the same as our current position next_z = initial_position[2] @@ -270,6 +272,7 @@ def tile( logging.debug("Returning to {}".format(initial_position)) microscope.stage.move_abs(initial_position) + def stack( microscope, basename: str = None, @@ -336,6 +339,7 @@ def stack( ### Web views + class TileScanAPI(MethodView): def post(self): payload = JsonResponse(request) @@ -396,4 +400,5 @@ class TileScanAPI(MethodView): scan_plugin_v2 = BasePlugin("scan") -scan_plugin_v2.add_view("/tile", TileScanAPI) +scan_plugin_v2.add_view(TileScanAPI, "/tile") +scan_plugin_v2.register_action(TileScanAPI) diff --git a/poetry.lock b/poetry.lock index c54fe7f2..2858799b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -190,6 +190,14 @@ optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" version = "1.1.1" +[[package]] +category = "main" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +name = "marshmallow" +optional = false +python-versions = ">=3.5" +version = "3.3.0" + [[package]] category = "dev" description = "McCabe checker, plugin for flake8" @@ -230,6 +238,7 @@ version = "1.13.1b0" reference = "b39c2b6e42f5f7f57bb46eafcb5c9e2bbdb5d0cb" type = "git" url = "https://github.com/rwb27/picamera.git" + [[package]] category = "main" description = "Python Imaging Library (Fork)" @@ -473,7 +482,7 @@ version = "1.11.2" rpi = ["picamera", "RPi.GPIO"] [metadata] -content-hash = "ea3fcd1909a76b04efa8ebefdcb77f0b449642820a94429fbbbf20bb4f9a64ab" +content-hash = "254e455bd1b5a5482f296f8afccf5bd10d109206e8350b9e8ec304576e0d0ae5" python-versions = "^3.6" [metadata.hashes] @@ -497,6 +506,7 @@ itsdangerous = ["321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f1 jinja2 = ["74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", "9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"] lazy-object-proxy = ["0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", "194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", "1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", "4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", "48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", "5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", "59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", "8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", "9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", "9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", "97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", "9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", "a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", "a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", "ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", "cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", "d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", "d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", "eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", "efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", "f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"] markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"] +marshmallow = ["0ba81b6da4ae69eb229b74b3c741ff13fe04fb899824377b1aff5aaa1a9fd46e", "3e53dd9e9358977a3929e45cdbe4a671f9eff53a7d6a23f33ed3eab8c1890d8f"] mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] numpy = ["0a7a1dd123aecc9f0076934288ceed7fd9a81ba3919f11a855a7887cbe82a02f", "0c0763787133dfeec19904c22c7e358b231c87ba3206b211652f8cbe1241deb6", "3d52298d0be333583739f1aec9026f3b09fdfe3ddf7c7028cb16d9d2af1cca7e", "43bb4b70585f1c2d153e45323a886839f98af8bfa810f7014b20be714c37c447", "475963c5b9e116c38ad7347e154e5651d05a2286d86455671f5b1eebba5feb76", "64874913367f18eb3013b16123c9fed113962e75d809fca5b78ebfbb73ed93ba", "683828e50c339fc9e68720396f2de14253992c495fdddef77a1e17de55f1decc", "6ca4000c4a6f95a78c33c7dadbb9495c10880be9c89316aa536eac359ab820ae", "75fd817b7061f6378e4659dd792c84c0b60533e867f83e0d1e52d5d8e53df88c", "7d81d784bdbed30137aca242ab307f3e65c8d93f4c7b7d8f322110b2e90177f9", "8d0af8d3664f142414fd5b15cabfd3b6cc3ef242a3c7a7493257025be5a6955f", "9679831005fb16c6df3dd35d17aa31dc0d4d7573d84f0b44cc481490a65c7725", "a8f67ebfae9f575d85fa859b54d3bdecaeece74e3274b0b5c5f804d7ca789fe1", "acbf5c52db4adb366c064d0b7c7899e3e778d89db585feadd23b06b587d64761", "ada4805ed51f5bcaa3a06d3dd94939351869c095e30a2b54264f5a5004b52170", "c7354e8f0eca5c110b7e978034cd86ed98a7a5ffcf69ca97535445a595e07b8e", "e2e9d8c87120ba2c591f60e32736b82b67f72c37ba88a4c23c81b5b8fa49c018", "e467c57121fe1b78a8f68dd9255fbb3bb3f4f7547c6b9e109f31d14569f490c3", "ede47b98de79565fcd7f2decb475e2dcc85ee4097743e551fe26cfc7eb3ff143", "f58913e9227400f1395c7b800503ebfdb0772f1c33ff8cb4d6451c06cabdf316", "fe39f5fd4103ec4ca3cb8600b19216cd1ff316b4990f4c0b6057ad982c0a34d5"] packaging = ["28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", "d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"] @@ -522,7 +532,7 @@ sphinxcontrib-jsmath = ["2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c sphinxcontrib-qthelp = ["513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", "79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f"] sphinxcontrib-serializinghtml = ["c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", "db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768"] toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] -typed-ast = ["1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", "18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", "262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", "2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", "354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", "48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", "4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", "630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", "66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", "71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", "7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", "838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", "95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", "bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", "cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", "d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", "d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", "d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", "fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", "ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"] +typed-ast = ["18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", "262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", "2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", "354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", "4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", "630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", "66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", "71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", "95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", "bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", "cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", "d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", "d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", "d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", "ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"] urllib3 = ["a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", "f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"] werkzeug = ["7280924747b5733b246fe23972186c6b348f9ae29724135a6dfc1e53cea433e7", "e5f4a1f98b52b18a93da705a7458e55afb26f32bff83ff5d19189f92462d65c4"] wrapt = ["565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"] diff --git a/pyproject.toml b/pyproject.toml index f35db200..dd1da294 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ scipy = "1.3.0" # Exact version until we can guarantee a wheel picamera = { git = "https://github.com/rwb27/picamera.git", branch = "lens-shading", optional = true } # Lens shading requires >1.14 or RWB fork, lens-shading branch. "RPi.GPIO" = { version = "^0.6.5", optional = true } +marshmallow = "^3.3" [tool.poetry.extras] rpi = ["picamera", "RPi.GPIO"] From 1047257242fcc4a4f1b2c16abfb6276270cd0717 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 14:56:45 +0000 Subject: [PATCH 017/122] Moved task routes into base labthing --- openflexure_microscope/api/app.py | 61 ++- openflexure_microscope/api/v2/__init__.py | 1 - .../api/v2/blueprints/__init__.py | 1 - .../api/v2/blueprints/actions/__init__.py | 98 ----- .../api/v2/blueprints/actions/camera.py | 172 -------- .../api/v2/blueprints/actions/stage.py | 77 ---- .../api/v2/blueprints/actions/system.py | 47 -- .../api/v2/blueprints/captures.py | 416 ------------------ .../api/v2/blueprints/root.py | 51 --- .../api/v2/blueprints/settings.py | 198 --------- .../api/v2/blueprints/status.py | 88 ---- .../api/v2/blueprints/streams.py | 107 ----- .../api/v2/blueprints/tasks.py | 158 ------- .../api/v2/views/__init__.py | 5 + .../api/v2/views/actions/__init__.py | 13 +- openflexure_microscope/api/v2/views/state.py | 22 - openflexure_microscope/api/v2/views/tasks.py | 10 - .../common/labthings/labthing.py | 9 +- .../common/labthings/views/plugins.py | 2 +- .../common/labthings/views/tasks.py | 61 +++ .../plugins/v2/autofocus.py | 7 +- 21 files changed, 123 insertions(+), 1481 deletions(-) delete mode 100644 openflexure_microscope/api/v2/blueprints/__init__.py delete mode 100644 openflexure_microscope/api/v2/blueprints/actions/__init__.py delete mode 100644 openflexure_microscope/api/v2/blueprints/actions/camera.py delete mode 100644 openflexure_microscope/api/v2/blueprints/actions/stage.py delete mode 100644 openflexure_microscope/api/v2/blueprints/actions/system.py delete mode 100644 openflexure_microscope/api/v2/blueprints/captures.py delete mode 100644 openflexure_microscope/api/v2/blueprints/root.py delete mode 100644 openflexure_microscope/api/v2/blueprints/settings.py delete mode 100644 openflexure_microscope/api/v2/blueprints/status.py delete mode 100644 openflexure_microscope/api/v2/blueprints/streams.py delete mode 100644 openflexure_microscope/api/v2/blueprints/tasks.py create mode 100644 openflexure_microscope/api/v2/views/__init__.py create mode 100644 openflexure_microscope/common/labthings/views/tasks.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 528800d8..070a8739 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -16,16 +16,21 @@ from flask_cors import CORS from openflexure_microscope.api.exceptions import JSONExceptionHandler from openflexure_microscope.api.utilities import list_routes -from openflexure_microscope.config import settings_file_path, JSONEncoder +from openflexure_microscope.config import ( + settings_file_path, + JSONEncoder, + USER_PLUGINS_PATH, +) from openflexure_microscope.api import v2 from openflexure_microscope.common.labthings.labthing import LabThing from openflexure_microscope.common.labthings.find import registered_plugins from openflexure_microscope.common.labthings.plugins import find_plugins -from openflexure_microscope.config import USER_PLUGINS_PATH from openflexure_microscope.api.microscope import default_microscope as api_microscope +from openflexure_microscope.api.v2 import views + # Handle logging is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "") @@ -78,33 +83,53 @@ CORS(app, resources=r"*") # Make errors more API friendly handler = JSONExceptionHandler(app) -# Attach lab devices +# Build a labthing labthing = LabThing(app, prefix="/api/v2") labthing.description = "Test LabThing-based API for OpenFlexure Microscope" +# Attach lab devices labthing.register_device(api_microscope, "openflexure_microscope") +# Attach plugins for _plugin in find_plugins(USER_PLUGINS_PATH): labthing.register_plugin(_plugin) -from openflexure_microscope.api.v2.views.captures import add_captures_to_labthing +# Attach captures resources +labthing.add_resource(views.CaptureList, f"/captures", endpoint="CaptureList") +labthing.register_property(views.CaptureList) +labthing.add_resource( + views.CaptureResource, f"/captures/", endpoint="CaptureResource" +) +labthing.add_resource( + views.CaptureDownload, + f"/captures//download/", + endpoint="CaptureDownload", +) +labthing.add_resource(views.CaptureTags, f"/captures//tags", endpoint="CaptureTags") +labthing.add_resource( + views.CaptureMetadata, f"/captures//metadata", endpoint="CaptureMetadata" +) -add_captures_to_labthing(labthing, prefix="") -from openflexure_microscope.api.v2.views.state import add_states_to_labthing +# Attach settings and status resources +labthing.add_resource(views.SettingsProperty, f"/settings") +labthing.register_property(views.SettingsProperty) +labthing.add_resource(views.NestedSettingsProperty, "/settings/") +labthing.add_resource(views.StatusProperty, "/status") +labthing.register_property(views.StatusProperty) +labthing.add_resource(views.NestedStatusProperty, "/status/") -add_states_to_labthing(labthing, prefix="") +# Attach streams resources +labthing.add_resource(views.MjpegStream, f"/streams/mjpeg") +labthing.register_property(views.MjpegStream) +labthing.add_resource(views.SnapshotStream, f"/streams/snapshot") +labthing.register_property(views.SnapshotStream) -from openflexure_microscope.api.v2.views.tasks import add_tasks_to_labthing - -add_tasks_to_labthing(labthing, prefix="") - -from openflexure_microscope.api.v2.views.streams import add_streams_to_labthing - -add_streams_to_labthing(labthing, prefix="") - -from openflexure_microscope.api.v2.views.actions import add_actions_to_labthing - -add_actions_to_labthing(labthing) +# Attach microscope action resources +for name, action in views.enabled_root_actions().items(): + view_class = action["view_class"] + rule = action["rule"] + labthing.add_resource(view_class, "/actions{rule}") + labthing.register_action(view_class) @app.route("/routes") diff --git a/openflexure_microscope/api/v2/__init__.py b/openflexure_microscope/api/v2/__init__.py index 3f07e575..e69de29b 100644 --- a/openflexure_microscope/api/v2/__init__.py +++ b/openflexure_microscope/api/v2/__init__.py @@ -1 +0,0 @@ -from . import blueprints diff --git a/openflexure_microscope/api/v2/blueprints/__init__.py b/openflexure_microscope/api/v2/blueprints/__init__.py deleted file mode 100644 index f4716594..00000000 --- a/openflexure_microscope/api/v2/blueprints/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import root, captures, settings, status, tasks, streams, actions diff --git a/openflexure_microscope/api/v2/blueprints/actions/__init__.py b/openflexure_microscope/api/v2/blueprints/actions/__init__.py deleted file mode 100644 index e57c2c04..00000000 --- a/openflexure_microscope/api/v2/blueprints/actions/__init__.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Top-level representation of enabled actions -""" - -from flask import Blueprint, url_for, jsonify - -from openflexure_microscope.api.utilities import blueprint_for_module -from openflexure_microscope.utilities import get_docstring, description_from_view -from openflexure_microscope.api.views import MicroscopeView - -from . import camera, stage, system - - -class ActionsAPI(MicroscopeView): - def get(self): - return jsonify(actions_representation()) - - -_actions = { - "capture": { - "rule": "/camera/capture/", - "view_class": camera.CaptureAPI, - "conditions": True, - }, - "previewStart": { - "rule": "/camera/preview/start", - "view_class": camera.GPUPreviewStartAPI, - "conditions": True, - }, - "previewStop": { - "rule": "/camera/preview/stop", - "view_class": camera.GPUPreviewStopAPI, - "conditions": True, - }, - "move": { - "rule": "/stage/move/", - "view_class": stage.MoveStageAPI, - "conditions": True, - }, - "zeroStage": { - "rule": "/stage/zero/", - "view_class": stage.ZeroStageAPI, - "conditions": True, - }, - "shutdown": { - "rule": "/system/shutdown/", - "view_class": system.ShutdownAPI, - "conditions": system.is_raspberrypi(), - }, - "reboot": { - "rule": "/system/reboot/", - "view_class": system.RebootAPI, - "conditions": system.is_raspberrypi(), - }, -} - - -def enabled_actions(): - global _actions - return {k: v for k, v in _actions.items() if v["conditions"]} - - -def actions_representation(): - global _actions - - actions = {} - for name, action in enabled_actions().items(): - d = { - "links": {"self": url_for(f".{name}")}, - "rule": action["rule"], - "view_class": str(action["view_class"]), - } - - d.update(description_from_view(action["view_class"])) - - actions[name] = d - - return actions - - -def construct_blueprint(microscope_obj): - global _actions - - blueprint = blueprint_for_module(__name__) - - # For each enabled action route defined in our dictionary above - for name, action in enabled_actions().items(): - # Add the action to our blueprint - blueprint.add_url_rule( - action["rule"], - view_func=action["view_class"].as_view(name, microscope=microscope_obj), - ) - - blueprint.add_url_rule( - "/", view_func=ActionsAPI.as_view("actions", microscope=microscope_obj) - ) - - return blueprint diff --git a/openflexure_microscope/api/v2/blueprints/actions/camera.py b/openflexure_microscope/api/v2/blueprints/actions/camera.py deleted file mode 100644 index 3b899102..00000000 --- a/openflexure_microscope/api/v2/blueprints/actions/camera.py +++ /dev/null @@ -1,172 +0,0 @@ -from openflexure_microscope.api.utilities import get_bool, JsonResponse -from openflexure_microscope.api.views import MicroscopeView -from openflexure_microscope.utilities import filter_dict - -import logging -from flask import jsonify, request, abort, url_for, redirect, send_file - - -class CaptureAPI(MicroscopeView): - """ - Create a new image capture. - """ - - def post(self): - """ - Create a new image capture. - - .. :quickref: Actions; New capture - - **Example request**: - - .. sourcecode:: http - - POST /actions/camera/capture HTTP/1.1 - Accept: application/json - - { - "filename": "myfirstcapture", - "temporary": false, - "use_video_port": true, - "bayer": true, - "size": { - "width": 640, - "height": 480 - } - } - - :>header Accept: application/json - - :json boolean available: availability of capture data - :>json string filename: filename of capture - :>json string id: unique id of the capture object - :>json boolean temporary: delete the capture file on microscope after closing - :>json boolean locked: file locked for modifications (mostly used for video recording) - :>json string path: path on pi storage to the capture file, if available - :>json boolean stream: capture stored in-memory as a BytesIO stream - :>json json uri: - **download** *(string)*: api uri to the capture file download - - **state** *(string)*: api uri to the capture json representation - - :
header Accept: application/json - - :
header Accept: application/json - - :
header Accept: application/json - :query include_unavailable: return json representations of captures that have been completely deleted - - :>jsonarr boolean available: availability of capture data - :>jsonarr string filename: filename of capture - :>jsonarr string id: unique id of the capture object - :>jsonarr boolean keep_on_disk: keep the capture file on microscope after closing - :>jsonarr boolean locked: file locked for modifications (mostly used for video recording) - :>jsonarr string path: path on pi storage to the capture file, if available - :>jsonarr boolean stream: capture stored in-memory as a BytesIO stream - :>jsonarr json uri: - **download** *(string)*: api uri to the capture file download - - **state** *(string)*: api uri to the capture json representation - - :>header Content-Type: application/json - :status 200: capture found - :status 404: no capture found with that id - """ - representation = captures_representation(self.microscope.camera.images) - - return jsonify(representation) - - def delete(self): - """ - Delete all captures - - .. :quickref: Captures; Delete all captures - """ - for image in self.microscope.camera.images: - image.delete() - - captures = [image.state for image in self.microscope.camera.images] - - return jsonify(captures) - - -class CaptureAPI(MicroscopeView): - def get(self, capture_id): - """ - Get JSON representation of a capture - - .. :quickref: Capture; Get capture - - **Example request**: - - .. sourcecode:: http - - GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/ HTTP/1.1 - Accept: application/json - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "available": true, - "bytestream": false, - "keep_on_disk": false, - "locked": false, - "metadata": { - "filename": "2018-11-16_10-21-53.jpeg", - "id": "d0b2067abbb946f19351e075c5e7cd5b", - "path": "capture/2018-11-16_10-21-53.jpeg", - "time": "2018-11-16_10-21-53" - } - "uri": { - "download": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/download", - "state": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/" - } - } - - :>json boolean available: availability of capture data - :>json string filename: filename of capture - :>json string id: unique id of the capture object - :>json boolean keep_on_disk: keep the capture file on microscope after closing - :>json boolean locked: file locked for modifications (mostly used for video recording) - :>json string path: path on pi storage to the capture file, if available - :>json boolean stream: capture stored in-memory as a BytesIO stream - :>json json uri: - **download** *(string)*: api uri to the capture file download - - **state** *(string)*: api uri to the capture json representation - - """ - - try: - representation = captures_representation(self.microscope.camera.images)[ - capture_id - ] - except KeyError: - return abort(404) # 404 Not Found - - return jsonify(representation) - - def delete(self, capture_id): - """ - Delete all capture data from the Pi, even if `keep_on_disk=true;`. - - .. :quickref: Capture; Delete capture. - """ - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj: - return abort(404) # 404 Not Found - - capture_obj.delete() - - return jsonify({"return": capture_id}) - - def put(self, capture_id): - """ - Add arbitrary metadata to the capture - - .. :quickref: Capture; Update capture metadata - - **Example request**: - - .. sourcecode:: http - - PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b HTTP/1.1 - Accept: application/json - - { - "user_id": "ofm_1234", - "patient_number": 1452, - } - - :>header Accept: application/json - - :
header Accept: image/jpeg - :query thumbnail: return an image thumbnail e.g. ?thumbnail=true - - :>header Content-Type: image/jpeg - :status 200: capture data found - :status 404: no capture found with that id - - """ - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj or not capture_obj.state["available"]: - return abort(404) # 404 Not Found - - thumbnail = get_bool(request.args.get("thumbnail")) - - return redirect( - url_for( - ".capture_download", - capture_id=capture_id, - filename=capture_obj.filename, - thumbnail=thumbnail, - ), - code=307, - ) - - -class DownloadAPI(MicroscopeView): - def get(self, capture_id, filename): - - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj or not capture_obj.state["available"]: - return abort(404) # 404 Not Found - - thumbnail = get_bool(request.args.get("thumbnail")) - - # If no filename is specified, redirect to the capture's currently set filename - if not filename: - return redirect( - url_for( - "capture_download", - capture_id=capture_id, - filename=capture_obj.filename, - thumbnail=thumbnail, - ), - code=307, - ) - - # Download the image data using the requested filename - if thumbnail: - img = capture_obj.thumbnail - else: - img = capture_obj.data - - return send_file(img, mimetype="image/jpeg") - - -class TagsAPI(MicroscopeView): - def get(self, capture_id): - """ - Return tag list for a capture. - - .. :quickref: Capture; Show capture tags - - **Example request**: - - .. sourcecode:: http - - GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1 - Accept: text/json - - :>header Accept: text/json - - :>header Content-Type: text/json - :status 200: capture data found - :status 404: no capture found with that id - """ - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj or not capture_obj.state["available"]: - return abort(404) # 404 Not Found - - return jsonify(capture_obj.tags) - - def put(self, capture_id): - """ - Add tags to the capture - - .. :quickref: Capture; Update capture tags - - **Example request**: - - .. sourcecode:: http - - PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1 - Accept: application/json - - ["tests", "mytag", "someothertag"] - - :>header Accept: application/json - - :
header Accept: application/json - - :
/tags", - view_func=TagsAPI.as_view("capture_tags", microscope=microscope_obj), - ) - - # Capture routes - blueprint.add_url_rule( - "//download/", - view_func=DownloadAPI.as_view("capture_download", microscope=microscope_obj), - ) - - blueprint.add_url_rule( - "//download", - view_func=DownloadRedirectAPI.as_view( - "capture_download_redirect", microscope=microscope_obj - ), - ) - - blueprint.add_url_rule( - "//", - view_func=CaptureAPI.as_view("capture", microscope=microscope_obj), - ) - - blueprint.add_url_rule( - "/", view_func=ListAPI.as_view("captures", microscope=microscope_obj) - ) - return blueprint diff --git a/openflexure_microscope/api/v2/blueprints/root.py b/openflexure_microscope/api/v2/blueprints/root.py deleted file mode 100644 index 0e8d148e..00000000 --- a/openflexure_microscope/api/v2/blueprints/root.py +++ /dev/null @@ -1,51 +0,0 @@ -from flask import Blueprint, jsonify, url_for -from openflexure_microscope import Microscope -from openflexure_microscope.api.views import MicroscopeView - -from openflexure_microscope.utilities import get_docstring, bottom_level_name -from openflexure_microscope.api.utilities import blueprint_name_for_module -from openflexure_microscope.api.v2.blueprints import ( - settings, - status, - captures, - actions, - streams, -) - -# List of submodules containing create_blueprint methods using standard blueprint_for_module naming -_root_blueprint_modules = [settings, status, captures, actions, streams] - - -def root_representation(): - """ - Generate a dictionar representation of all top-level blueprint rules - """ - global _root_blueprint_modules - d = {} - - for blueprint_module in _root_blueprint_modules: - module_short_name = bottom_level_name(blueprint_module) - blueprint_name = blueprint_name_for_module(blueprint_module.__name__) - - d[module_short_name] = { - "name": blueprint_module.__name__, - "description": get_docstring(blueprint_module), - "links": {"self": url_for(f"{blueprint_name}.{module_short_name}")}, - } - - return d - - -class RootAPI(MicroscopeView): - def get(self): - - return jsonify(root_representation()) - - -def construct_blueprint(microscope_obj): - blueprint = Blueprint("root_blueprint", __name__) - - blueprint.add_url_rule( - "/", view_func=RootAPI.as_view("root_repr", microscope=microscope_obj) - ) - return blueprint diff --git a/openflexure_microscope/api/v2/blueprints/settings.py b/openflexure_microscope/api/v2/blueprints/settings.py deleted file mode 100644 index e56e5b36..00000000 --- a/openflexure_microscope/api/v2/blueprints/settings.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -Writeable settings for the microscope, and attached hardware -""" - -from openflexure_microscope.api.utilities import gen, JsonResponse -from openflexure_microscope.api.views import MicroscopeView -from openflexure_microscope.api.utilities import blueprint_for_module - -from openflexure_microscope.utilities import get_by_path, set_by_path, create_from_path - -from flask import Blueprint, jsonify, request, abort -import logging - - -class SettingsAPI(MicroscopeView): - def get(self): - """ - JSON representation of the microscope settings. - - .. :quickref: Settings; Get microscope settings - - **Example request**: - - .. sourcecode:: http - - GET /settings/ HTTP/1.1 - Accept: application/json - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "id": "0e2c6fac5421429aac67c7903107bdd8", - "name": "docuscope-2000", - "fov": [4100, 3146], - "camera_settings": { - "image_resolution": [2592, 1944], - "numpy_resolution": [1312, 976], - "video_resolution": [832, 624], - "jpeg_quality": 75, - "picamera_settings": { - "analog_gain": 1.0, - "digital_gain": 1.0, - "awb_gains": [0.92578125, 2.94921875], - "awb_mode": "off", - "exposure_mode": "off", - "framerate": 24.0, - "saturation": 0, - "shutter_speed": 5378 - }, - }, - "stage_settings": { - "backlash": { - "x": 256, - "y": 256, - "z": 0 - } - } - "plugins": [ - "openflexure_microscope.plugins.default.autofocus:AutofocusPlugin", - "openflexure_microscope.plugins.default.scan:ScanPlugin", - "openflexure_microscope.plugins.default.camera_calibration:Plugin" - ] - } - - :>header Accept: application/json - :>header Content-Type: application/json - :status 200: state available - """ - return jsonify(self.microscope.read_settings()) - - def put(self): - """ - Modify microscope configuration - - .. :quickref: Settings; Update microscope settings - - **Example request**: - - .. sourcecode:: http - - PUT /config HTTP/1.1 - Accept: application/json - - { - "id": "0e2c6fac5421429aac67c7903107bdd8", - "name": "docuscope-2000", - "fov": [4100, 3146], - "camera_settings": { - "image_resolution": [2592, 1944], - "numpy_resolution": [1312, 976], - "video_resolution": [832, 624], - "jpeg_quality": 75, - "picamera_settings": { - "analog_gain": 1.0, - "digital_gain": 1.0, - "awb_gains": [0.92578125, 2.94921875], - "awb_mode": "off", - "exposure_mode": "off", - "framerate": 24.0, - "saturation": 0, - "shutter_speed": 5378 - }, - }, - "stage_settings": { - "backlash": { - "x": 256, - "y": 256, - "z": 0 - } - } - "plugins": [ - "openflexure_microscope.plugins.default.autofocus:AutofocusPlugin", - "openflexure_microscope.plugins.default.scan:ScanPlugin", - "openflexure_microscope.plugins.default.camera_calibration:Plugin" - ] - } - - :>header Accept: application/json - - :", - view_func=NestedSettingsAPI.as_view( - "nested_settings", microscope=microscope_obj - ), - ) - - return blueprint diff --git a/openflexure_microscope/api/v2/blueprints/status.py b/openflexure_microscope/api/v2/blueprints/status.py deleted file mode 100644 index 846a594f..00000000 --- a/openflexure_microscope/api/v2/blueprints/status.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Read-only status of the microscope, and attached hardware info -""" - -from openflexure_microscope.api.views import MicroscopeView -from openflexure_microscope.api.utilities import blueprint_for_module -from openflexure_microscope.utilities import get_by_path - -from flask import Blueprint, jsonify, abort - - -class StatusAPI(MicroscopeView): - def get(self): - """ - JSON representation of the microscope status (read-only properties, modifiable with actions) - - .. :quickref: Status; Microscope status - - **Example request**: - - .. sourcecode:: http - - GET /status/ HTTP/1.1 - Accept: application/json - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "camera": { - "preview_active": false, - "record_active": false, - "stream_active": true - }, - "plugin": {}, - "stage": { - "backlash": { - "x": 128, - "y": 128, - "z": 128 - }, - "position": { - "x": -8080, - "y": 5665, - "z": -12600 - } - } - } - - :>header Accept: application/json - :>header Content-Type: application/json - :status 200: state available - """ - return jsonify(self.microscope.status) - - -class NestedStatusAPI(MicroscopeView): - def get(self, route): - - keys = route.split("/") - - try: - value = get_by_path(self.microscope.status, keys) - except KeyError: - return abort(404) - - return jsonify(value) - - -def construct_blueprint(microscope_obj): - - blueprint = blueprint_for_module(__name__) - - blueprint.add_url_rule( - "/", view_func=StatusAPI.as_view("status", microscope=microscope_obj) - ) - - blueprint.add_url_rule( - "/", - view_func=NestedStatusAPI.as_view("nested_status", microscope=microscope_obj), - ) - - return blueprint diff --git a/openflexure_microscope/api/v2/blueprints/streams.py b/openflexure_microscope/api/v2/blueprints/streams.py deleted file mode 100644 index bc193d9a..00000000 --- a/openflexure_microscope/api/v2/blueprints/streams.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -Top-level description of routes related to live camera stream data -""" - -from openflexure_microscope.api.utilities import gen, JsonResponse -from openflexure_microscope.api.views import MicroscopeView -from openflexure_microscope.api.utilities import ( - blueprint_for_module, - blueprint_name_for_module, -) -from openflexure_microscope.utilities import description_from_view - -from flask import Response, Blueprint, jsonify, request, url_for - - -class StreamAPI(MicroscopeView): - def get(self): - return jsonify(streams_representation()) - - -class MjpegAPI(MicroscopeView): - """ - Real-time MJPEG stream from the microscope camera - """ - - def get(self): - """ - Real-time MJPEG stream from the microscope camera - - .. :quickref: Streams; Camera MJPEG stream - - :>header Accept: image/jpeg - :>header Content-Type: image/jpeg - :status 200: stream active - """ - # Restart stream worker thread - self.microscope.camera.start_worker() - - return Response( - gen(self.microscope.camera), - mimetype="multipart/x-mixed-replace; boundary=frame", - ) - - -class SnapshotAPI(MicroscopeView): - """ - Single JPEG snapshot from the camera stream - """ - - def get(self): - """ - Single snapshot from the camera stream - - .. :quickref: Streams; Camera snapshot - - :>header Accept: image/jpeg - :>header Content-Type: image/jpeg - :status 200: stream active - """ - # Restart stream worker thread - self.microscope.camera.start_worker() - - return Response(self.microscope.camera.get_frame(), mimetype="image/jpeg") - - -_streams = { - "mjpeg": {"rule": "/mjpeg", "view_class": MjpegAPI, "conditions": True}, - "snapshot": {"rule": "/snapshot", "view_class": SnapshotAPI, "conditions": True}, -} - - -def enabled_streams(): - global _streams - return {k: v for k, v in _streams.items() if v["conditions"]} - - -def streams_representation(): - global _streams - - streams = {} - for name, stream in enabled_streams().items(): - d = {"links": {"self": url_for(f".{name}")}} - - d.update(description_from_view(stream["view_class"])) - - streams[name] = d - - return streams - - -def construct_blueprint(microscope_obj): - - blueprint = blueprint_for_module(__name__) - - # For each enabled stream route defined in our dictionary above - for name, stream in enabled_streams().items(): - # Add the action to our blueprint - blueprint.add_url_rule( - stream["rule"], - view_func=stream["view_class"].as_view(name, microscope=microscope_obj), - ) - - blueprint.add_url_rule( - "/", view_func=StreamAPI.as_view("streams", microscope=microscope_obj) - ) - - return blueprint diff --git a/openflexure_microscope/api/v2/blueprints/tasks.py b/openflexure_microscope/api/v2/blueprints/tasks.py deleted file mode 100644 index cf841bb2..00000000 --- a/openflexure_microscope/api/v2/blueprints/tasks.py +++ /dev/null @@ -1,158 +0,0 @@ -from openflexure_microscope.api.views import MethodView -from flask import jsonify, abort, Blueprint, url_for - -from openflexure_microscope.common import tasks - - -def tasks_representation(): - """ - Generate a dictionary representation of all tasks, including Flask route URLs - - Returns: - dict: Dictionary representation of all tasks - """ - tasks_dict = tasks.states() - - for task_key, task_repr in tasks_dict.items(): - # Add API routes to returned representations - extra_state = { - "links": {"self": "{}".format(url_for(".task", task_id=task_key))} - } - - tasks_dict[task_key].update(extra_state) - - return tasks_dict - - -class TaskListAPI(MethodView): - def get(self): - """ - Get list of long-running tasks. - - .. :quickref: Tasks; Get collection of tasks - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - [ - { - "end_time": "2019-01-23 16-33-33", - "id": "db13a66787e1419bb06b1504e4d80b0c", - "return": [ - 0.848622546386467, - 0.6106785018091292, - ], - "start_time": "2019-01-23 16-33-13", - "status": "success" - }, - { - "end_time": null, - "id": "df46558cc8844924821bd0181881871e", - "return": null, - "start_time": "2019-01-23 16-34-54", - "status": "running" - } - ] - - :>header Accept: application/json - :query include_unavailable: return json representations of captures that have been completely deleted - - :>header Content-Type: application/json - """ - - return jsonify(tasks_representation()) - - def delete(self): - """ - Clean list of long-running tasks (running tasks persist). - - .. :quickref: Tasks; Clean collection of tasks - - :>header Accept: application/json - - :>header Content-Type: application/json - """ - - tasks.cleanup_tasks() - - return jsonify(tasks_representation()) - - -class TaskAPI(MethodView): - def get(self, task_id): - """ - Get JSON representation of a task - - .. :quickref: Tasks; Get task - - **Example request**: - - .. sourcecode:: http - - GET /task/db13a66787e1419bb06b1504e4d80b0c/ HTTP/1.1 - Accept: application/json - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "end_time": "2019-01-23 16-33-33", - "id": "db13a66787e1419bb06b1504e4d80b0c", - "return": [ - 0.848622546386467, - 0.6106785018091292, - ], - "start_time": "2019-01-23 16-33-13", - "status": "success" - } - - """ - - try: - task = tasks_representation()[task_id] - except KeyError: - return abort(404) # 404 Not Found - - # Return task state - return jsonify(task) - - def delete(self, task_id): - """ - Terminate a particular task. - - .. :quickref: Tasks; Terminate a task - - :>header Accept: application/json - - :>header Content-Type: application/json - """ - - try: - task = tasks.tasks()[task_id] - except KeyError: - return abort(404) # 404 Not Found - - task.terminate() - - return jsonify(task.state) - - -def construct_blueprint(microscope_obj): - - blueprint = Blueprint("v2_tasks_blueprint", __name__) - - blueprint.add_url_rule("/", view_func=TaskListAPI.as_view("task_list")) - - blueprint.add_url_rule("//", view_func=TaskAPI.as_view("task")) - - return blueprint diff --git a/openflexure_microscope/api/v2/views/__init__.py b/openflexure_microscope/api/v2/views/__init__.py new file mode 100644 index 00000000..473c368c --- /dev/null +++ b/openflexure_microscope/api/v2/views/__init__.py @@ -0,0 +1,5 @@ +from .actions import enabled_root_actions +from .captures import * +from .state import * +from .streams import * +from .tasks import * diff --git a/openflexure_microscope/api/v2/views/actions/__init__.py b/openflexure_microscope/api/v2/views/actions/__init__.py index 897d67c6..878c0c4e 100644 --- a/openflexure_microscope/api/v2/views/actions/__init__.py +++ b/openflexure_microscope/api/v2/views/actions/__init__.py @@ -49,17 +49,6 @@ _actions = { } -def enabled_actions(): +def enabled_root_actions(): global _actions return {k: v for k, v in _actions.items() if v["conditions"]} - - -def add_actions_to_labthing(labthing, prefix=""): - """ - Add all capture resources to a labthing - """ - for name, action in enabled_actions().items(): - view_class = action["view_class"] - rule = action["rule"] - labthing.add_resource(view_class, f"{prefix}/actions{rule}") - labthing.register_action(view_class) diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py index c2dae44a..e755289a 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/state.py @@ -70,25 +70,3 @@ class NestedStatusProperty(Resource): return jsonify(value) - -def add_states_to_labthing(labthing, prefix=""): - """ - Add all settings and status resources to a labthing - """ - labthing.add_resource( - SettingsProperty, f"{prefix}/settings", endpoint="SettingsProperty" - ) - labthing.register_property(SettingsProperty) - labthing.add_resource( - NestedSettingsProperty, - f"{prefix}/settings/", - endpoint="NestedSettingsProperty", - ) - labthing.add_resource(StatusProperty, f"{prefix}/status", endpoint="StatusProperty") - labthing.register_property(StatusProperty) - labthing.add_resource( - NestedStatusProperty, - f"{prefix}/status/", - endpoint="NestedStatusProperty", - ) - diff --git a/openflexure_microscope/api/v2/views/tasks.py b/openflexure_microscope/api/v2/views/tasks.py index 67a7e3da..6a7e39d3 100644 --- a/openflexure_microscope/api/v2/views/tasks.py +++ b/openflexure_microscope/api/v2/views/tasks.py @@ -59,13 +59,3 @@ class TaskResource(Resource): task.terminate() return jsonify(task.state) - - -def add_tasks_to_labthing(labthing, prefix=""): - """ - Add all settings and status resources to a labthing - """ - labthing.add_resource(TaskList, f"{prefix}/tasks", endpoint="TasksProperty") - labthing.register_property(TaskList) - labthing.add_resource(TaskResource, f"{prefix}/tasks/", endpoint="TaskResource") - diff --git a/openflexure_microscope/common/labthings/labthing.py b/openflexure_microscope/common/labthings/labthing.py index b65a848e..0f23f258 100644 --- a/openflexure_microscope/common/labthings/labthing.py +++ b/openflexure_microscope/common/labthings/labthing.py @@ -2,8 +2,7 @@ from flask import current_app, _app_ctx_stack, request, url_for, jsonify from .plugins import BasePlugin from .views.plugins import PluginListResource - -from .resource import Resource +from .views.tasks import TaskList, TaskResource from ..utilities import get_docstring @@ -53,6 +52,11 @@ class LabThing(object): self.app.add_url_rule(self._complete_url("/", ""), "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/", endpoint="TaskResource") ### Device stuff @@ -209,6 +213,7 @@ class LabThing(object): 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, diff --git a/openflexure_microscope/common/labthings/views/plugins.py b/openflexure_microscope/common/labthings/views/plugins.py index dabc1180..fa160b53 100644 --- a/openflexure_microscope/common/labthings/views/plugins.py +++ b/openflexure_microscope/common/labthings/views/plugins.py @@ -39,7 +39,7 @@ def plugins_representation(plugin_dict): for view_id, view_data in plugin.views.items(): logging.debug(f"Representing view {view_id}") - uri = url_for(f"pluginlistresource") + "/" + view_data["rule"][1:] + uri = url_for(f"PluginListResource") + "/" + view_data["rule"][1:] # uri = view_data["rule"] # Make links dictionary if it doesn't yet exist view_d = {"links": {"self": uri}} diff --git a/openflexure_microscope/common/labthings/views/tasks.py b/openflexure_microscope/common/labthings/views/tasks.py new file mode 100644 index 00000000..afcba39b --- /dev/null +++ b/openflexure_microscope/common/labthings/views/tasks.py @@ -0,0 +1,61 @@ +import logging +from flask import abort, request, redirect, url_for, send_file, jsonify + +from openflexure_microscope.api.utilities import get_bool, JsonResponse + +from openflexure_microscope.common.labthings.schema import Schema +from openflexure_microscope.common.labthings import fields +from openflexure_microscope.common.labthings.resource import Resource + +from openflexure_microscope.common import tasks + + +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=""), + "mimetype": "application/json", + } + } + ) + + +task_schema = TaskSchema() +task_list_schema = TaskSchema(many=True) + + +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) diff --git a/openflexure_microscope/plugins/v2/autofocus.py b/openflexure_microscope/plugins/v2/autofocus.py index 6271c99d..71c3c938 100644 --- a/openflexure_microscope/plugins/v2/autofocus.py +++ b/openflexure_microscope/plugins/v2/autofocus.py @@ -360,6 +360,9 @@ autofocus_plugin_v2.add_method(fast_autofocus, "fast_autofocus") autofocus_plugin_v2.add_method(autofocus, "autofocus") autofocus_plugin_v2.add_view(MeasureSharpnessAPI, "/measure_sharpness") -autofocus_plugin_v2.add_view(AutofocusAPI, "/autofocus") -autofocus_plugin_v2.add_view(FastAutofocusAPI, "/fast_autofocus") +autofocus_plugin_v2.add_view(AutofocusAPI, "/autofocus") +autofocus_plugin_v2.register_action(AutofocusAPI) + +autofocus_plugin_v2.add_view(FastAutofocusAPI, "/fast_autofocus") +autofocus_plugin_v2.register_action(FastAutofocusAPI) From 5a0188abd3663b7c14c6fb3fd9260a15108b2a1f Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 14:57:27 +0000 Subject: [PATCH 018/122] Removed old tasks view --- .../api/v2/views/__init__.py | 1 - openflexure_microscope/api/v2/views/tasks.py | 61 ------------------- 2 files changed, 62 deletions(-) delete mode 100644 openflexure_microscope/api/v2/views/tasks.py diff --git a/openflexure_microscope/api/v2/views/__init__.py b/openflexure_microscope/api/v2/views/__init__.py index 473c368c..a998c703 100644 --- a/openflexure_microscope/api/v2/views/__init__.py +++ b/openflexure_microscope/api/v2/views/__init__.py @@ -2,4 +2,3 @@ from .actions import enabled_root_actions from .captures import * from .state import * from .streams import * -from .tasks import * diff --git a/openflexure_microscope/api/v2/views/tasks.py b/openflexure_microscope/api/v2/views/tasks.py deleted file mode 100644 index 6a7e39d3..00000000 --- a/openflexure_microscope/api/v2/views/tasks.py +++ /dev/null @@ -1,61 +0,0 @@ -import logging -from flask import abort, request, redirect, url_for, send_file, jsonify - -from openflexure_microscope.api.utilities import get_bool, JsonResponse - -from openflexure_microscope.common.labthings.schema import Schema -from openflexure_microscope.common.labthings import fields -from openflexure_microscope.common.labthings.resource import Resource - -from openflexure_microscope.common import tasks - - -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=""), - "mimetype": "application/json", - } - } - ) - - -task_schema = TaskSchema() -task_list_schema = TaskSchema(many=True) - - -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 jsonify(task) - - def delete(self, id): - try: - task = tasks.dict()[id] - except KeyError: - return abort(404) # 404 Not Found - - task.terminate() - - return jsonify(task.state) From 15a6f13cd5888824206163160daa6be80d3a487c Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 14:58:51 +0000 Subject: [PATCH 019/122] Blackened --- openflexure_microscope/api/v2/views/state.py | 1 - openflexure_microscope/common/labthings/schema.py | 2 +- openflexure_microscope/config.py | 1 + .../stage/sangaboard/extensible_serial_instrument.py | 6 +++--- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py index e755289a..176ae0ea 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/state.py @@ -69,4 +69,3 @@ class NestedStatusProperty(Resource): return abort(404) return jsonify(value) - diff --git a/openflexure_microscope/common/labthings/schema.py b/openflexure_microscope/common/labthings/schema.py index 4c2f3c95..3652e208 100644 --- a/openflexure_microscope/common/labthings/schema.py +++ b/openflexure_microscope/common/labthings/schema.py @@ -36,4 +36,4 @@ class Schema(marshmallow.Schema): data = self.dump(obj, many=many) else: data = self.dump(obj, many=many).data - return flask.jsonify(data, *args, **kwargs) \ No newline at end of file + return flask.jsonify(data, *args, **kwargs) diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index 659b7287..d7c61510 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -97,6 +97,7 @@ class OpenflexureSettingsFile: # HANDLE BASIC LOADING AND SAVING OF SETTINGS FILES + def load_json_file(config_path) -> dict: """ Open a .json config file diff --git a/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py b/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py index 91c53a7b..90534d6c 100644 --- a/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py +++ b/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py @@ -213,9 +213,9 @@ class ExtensibleSerialInstrument(object): return self.read_multiline(termination_line) else: logging.debug("Reading response...") - line = ( - self.readline(timeout).strip() - ) # question: should we strip the final newline? + line = self.readline( + timeout + ).strip() # question: should we strip the final newline? logging.debug(f"Read finished. Got {line}") return line From a77ed8e273b76cb7abe194ac42cf1febb591cb26 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 15:00:08 +0000 Subject: [PATCH 020/122] Removed debug routes --- openflexure_microscope/api/app.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 070a8739..11ff9337 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -86,6 +86,7 @@ handler = JSONExceptionHandler(app) # Build a labthing labthing = LabThing(app, prefix="/api/v2") labthing.description = "Test LabThing-based API for OpenFlexure Microscope" +labthing.title = f"OpenFlexure Microscope {api_microscope.name}" # Attach lab devices labthing.register_device(api_microscope, "openflexure_microscope") @@ -146,26 +147,6 @@ def routes(): return jsonify(list_routes(app)) -@app.route("/props") -def props(): - p = {} - for key, prop in labthing.properties.items(): - p[key] = {} - p[key]["view"] = prop - p[key]["url"] = labthing.url_for(prop, _external=True) - return jsonify(p) - - -@app.route("/ac") -def actions(): - p = {} - for key, prop in labthing.actions.items(): - p[key] = {} - p[key]["view"] = prop - p[key]["url"] = labthing.url_for(prop, _external=True) - return jsonify(p) - - @app.route("/log") def err_log(): """ From 45c8d2b47fa524e0395291fe56ca082933e27869 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 15:04:55 +0000 Subject: [PATCH 021/122] Removed old plugin system --- openflexure_microscope/api/__init__.py | 3 - .../api/v2/views/actions/__init__.py | 1 - openflexure_microscope/api/views.py | 28 -- openflexure_microscope/devel/__init__.py | 3 - openflexure_microscope/microscope.py | 35 +- openflexure_microscope/plugins/__init__.py | 17 - openflexure_microscope/plugins/loader.py | 349 ------------------ 7 files changed, 1 insertion(+), 435 deletions(-) delete mode 100644 openflexure_microscope/api/views.py delete mode 100644 openflexure_microscope/plugins/loader.py diff --git a/openflexure_microscope/api/__init__.py b/openflexure_microscope/api/__init__.py index 9a39cfda..e69de29b 100644 --- a/openflexure_microscope/api/__init__.py +++ b/openflexure_microscope/api/__init__.py @@ -1,3 +0,0 @@ -__all__ = ["utilities", "views"] - -from . import utilities, views diff --git a/openflexure_microscope/api/v2/views/actions/__init__.py b/openflexure_microscope/api/v2/views/actions/__init__.py index 878c0c4e..15c287d7 100644 --- a/openflexure_microscope/api/v2/views/actions/__init__.py +++ b/openflexure_microscope/api/v2/views/actions/__init__.py @@ -6,7 +6,6 @@ from flask import Blueprint, url_for, jsonify from openflexure_microscope.api.utilities import blueprint_for_module from openflexure_microscope.utilities import get_docstring, description_from_view -from openflexure_microscope.api.views import MicroscopeView from . import camera, stage, system diff --git a/openflexure_microscope/api/views.py b/openflexure_microscope/api/views.py deleted file mode 100644 index 1ec58243..00000000 --- a/openflexure_microscope/api/views.py +++ /dev/null @@ -1,28 +0,0 @@ -from flask.views import MethodView - - -class MicroscopeView(MethodView): - """ - Create a generic MethodView with a globally available - microscope object passed as an argument. - """ - - def __init__(self, microscope, **kwargs): - - self.microscope = microscope - - MethodView.__init__(self, **kwargs) - - -class MicroscopeViewPlugin(MicroscopeView): - """ - Create a generic MethodView with a globally available - microscope object passed as an argument, and a plugin - reference stored in 'self'. Initially None. - """ - - def __init__(self, microscope, plugin=None, **kwargs): - - self.plugin = plugin - - MicroscopeView.__init__(self, microscope=microscope, **kwargs) diff --git a/openflexure_microscope/devel/__init__.py b/openflexure_microscope/devel/__init__.py index 95330291..05e9df1e 100644 --- a/openflexure_microscope/devel/__init__.py +++ b/openflexure_microscope/devel/__init__.py @@ -5,9 +5,6 @@ Here we include some classes used frequently in plugin development, as well as some Flask imports to simplify API route development """ -# Plugin classes -from openflexure_microscope.plugins import MicroscopePlugin -from openflexure_microscope.api.views import MicroscopeViewPlugin from openflexure_microscope.api.utilities import JsonResponse # Task management diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index 694a8c32..8c7ca5f8 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -12,7 +12,6 @@ from openflexure_microscope.camera.base import BaseCamera from openflexure_microscope.camera.mock import MockStreamer from openflexure_microscope.utilities import serialise_array_b64 -from openflexure_microscope.plugins import PluginLoader from openflexure_microscope.common.lock import CompositeLock from openflexure_microscope.config import user_settings @@ -30,7 +29,6 @@ class Microscope: stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): Stage object task: (:py:class:`openflexure_microscope.task.TaskOrchestrator`): Threaded ask orchestrator for managing background tasks using microscope hardware - plugins (:py:class:`openflexure_microscope.plugins.PluginLoader`): Mounting point for all microscope plugins """ def __init__(self): @@ -38,7 +36,6 @@ class Microscope: self.id = uuid.uuid4().hex self.name = self.id self.fov = [0, 0] - self.plugin_maps = [] self.camera = None self.stage = None @@ -48,10 +45,6 @@ class Microscope: # Apply settings loaded from file self.apply_settings(user_settings.load()) - # Create plugin mount-point and attach plugins from maps - self.plugins = PluginLoader(self) - self.attach_plugins(self.plugin_maps) - def __enter__(self): """Create microscope on context enter.""" return self @@ -116,25 +109,6 @@ class Microscope: logging.info("Reapplying settings to newly attached devices") self.apply_settings(settings_full) - def attach_plugins(self, plugin_maps: list): - """ - Automatically search for plugin maps in config, and attach. - """ - if plugin_maps: - for plugin_map in plugin_maps: - self.plugins.attach(plugin_map) - else: - logging.warning("No plugins specified. Skipping.") - - def reload_plugins(self): - """ - Empty the plugin mount and re-attach from config. - """ - logging.info("Tearing down existing PluginMount...") - self.plugins = PluginLoader(self) - logging.info("Repopulating PluginMount...") - self.attach_plugins(self.plugin_maps) - def has_real_stage(self): if hasattr(self, "stage") and not isinstance(self.stage, MockStage): return True @@ -183,8 +157,6 @@ class Microscope: self.name = config["name"] if "fov" in config: self.fov = config["fov"] - if "plugins" in config: - self.plugin_maps = config["plugins"] def read_settings(self, json_safe=False): """ @@ -197,12 +169,7 @@ class Microscope: don't get removed from the settings file. """ - settings_current = { - "id": self.id, - "name": self.name, - "fov": self.fov, - "plugins": self.plugin_maps, - } + settings_current = {"id": self.id, "name": self.name, "fov": self.fov} # If attached to a camera if self.camera: diff --git a/openflexure_microscope/plugins/__init__.py b/openflexure_microscope/plugins/__init__.py index c22207f5..e69de29b 100644 --- a/openflexure_microscope/plugins/__init__.py +++ b/openflexure_microscope/plugins/__init__.py @@ -1,17 +0,0 @@ -__all__ = [ - "module_from_file", - "load_plugin_class", - "load_plugin_module", - "class_from_map", - "PluginLoader", - "MicroscopePlugin", -] - -from .loader import ( - module_from_file, - load_plugin_class, - load_plugin_module, - class_from_map, - PluginLoader, - MicroscopePlugin, -) diff --git a/openflexure_microscope/plugins/loader.py b/openflexure_microscope/plugins/loader.py deleted file mode 100644 index 35f26c73..00000000 --- a/openflexure_microscope/plugins/loader.py +++ /dev/null @@ -1,349 +0,0 @@ -import importlib -import copy -import os -import collections -import inspect -import logging - -from openflexure_microscope.utilities import camel_to_snake, camel_to_spine -from openflexure_microscope.api.views import MicroscopeViewPlugin - - -class ConColors: - HEADER = "\033[95m" - OKBLUE = "\033[94m" - OKGREEN = "\033[92m" - WARNING = "\033[93m" - FAIL = "\033[91m" - ENDC = "\033[0m" - BOLD = "\033[1m" - UNDERLINE = "\033[4m" - - -def module_from_file(plugin_path): - # Expand environment variables in path string - plugin_path = os.path.expandvars(plugin_path) - # Expand user directory in path string - plugin_path = os.path.expanduser(plugin_path) - - # Check if the path is to a file - if not os.path.isfile(plugin_path): - logging.warning( - ConColors.FAIL - + "No valid plugin found at {}.".format(plugin_path) - + ConColors.ENDC - ) - return None, None, None - - else: - # Get name of plugin from the file - plugin_name = os.path.splitext(os.path.basename(plugin_path))[0] - - plugin_spec = importlib.util.spec_from_file_location(plugin_name, plugin_path) - plugin_module = importlib.util.module_from_spec(plugin_spec) - - return plugin_spec, plugin_module, plugin_name - - -def check_module(module_path): - """ - Used to check if a module exists, without ever importing it. - - This checks each nested level separately to avoid raising exceptions. - For example, checking "mymodule.submodule.subsubmodule" will first - check if 'mymodule' exists, then if it does, it will check - 'mymodule.submodule', and so on. - """ - module_split = module_path.split(".") - spec_path = "" - - for module_level in module_split: - spec_path += ".{}".format(module_level) - spec_path = spec_path.strip(".") - - logging.debug("Checking {}".format(spec_path)) - - # Try to find a module loader for the plugin path - plugin_spec = importlib.util.find_spec(spec_path) - - # If plugin spec doesn't exist, return False early - if plugin_spec is None: - return False - - # If all checks pass, return True - return True - - -def name_from_module(plugin_path): - path_array = plugin_path.split(".") - - # Truncate namespace of default plugins - if path_array[0:2] == ["openflexure_microscope", "plugins"]: - path_array = path_array[2:] - - return "/".join(path_array) - - -def load_plugin_module(plugin_path): - # If the loader was found (i.e. plugin probably exists) - if check_module(plugin_path): - try: - plugin_module = importlib.import_module(plugin_path) - plugin_name = name_from_module(plugin_path) - except Exception as e: - logging.warning("Error loading plugin.") - logging.error(e) - return None, None - - # If no loader was found, try finding a file from path - else: - plugin_spec, plugin_module, plugin_name = module_from_file(plugin_path) - # If a valid plugin was found - if plugin_spec and plugin_module: - # Execute the module, so we have access to it - plugin_spec.loader.exec_module(plugin_module) - - return plugin_module, plugin_name - - -def load_plugin_class(plugin_path, plugin_class_name): - plugin_module, plugin_name = load_plugin_module(plugin_path) - if plugin_module: - # Now try to extract the class - try: - plugin_class = getattr(plugin_module, plugin_class_name) - except AttributeError: - logging.warning( - ConColors.FAIL - + "Class {} does not exist in plugin {}. Skipping.".format( - plugin_class_name, plugin_path - ) - + ConColors.ENDC - ) - return None, None - else: - return plugin_class, plugin_name - else: - return None, None - - -def class_from_map(plugin_map): - plugin_arr = plugin_map.split(":") - - if not len(plugin_arr) == 2: - logging.warning( - ConColors.WARNING - + "Malformed plugin map {}. Skipping.".format(plugin_map) - + ConColors.ENDC - ) - return None, None - else: - return load_plugin_class(*plugin_arr) - - -class PluginLoader(object): - """ - A mount-point for all loaded plugins. Attaches to a Microscope object. - - Args: - parent (:py:class:`openflexure_microscope.microscope.Microscope`): The parent Microscope object to attach to. - """ - - def __init__(self, parent): - self.parent = parent - self._plugins = [] # List of plugin objects - self._legacy_plugins = {} # DEPRECATED: Dictionary of plugins with old names - self.forms = [] # List of plugin forms - logging.info("Creating plugin mount") - - @property - def state(self): - # DEPRECATED - logging.warning( - "PluginMount.state is deprecated. Use PluginMount.active instead. State will be removed in a future version." - ) - return list(self._legacy_plugins.keys()) - - @property - def active(self): - return self._plugins - - def attach(self, plugin_map): - """ - Attach a MicroscopePlugin instance to the plugin mount. - - Args: - plugin_map (str): A plugin map describing the file or module to load a MicroscopePlugin child from. Maps should be in the format 'module.to.load:ClassName' or '/path/to/file:ClassName'. - """ - plugin_class, plugin_name = class_from_map(plugin_map) - - if plugin_class is not None: - - logging.debug(f"Loading plugin class {plugin_class}, {plugin_name}") - - plugin_name_python_safe = plugin_name.replace("/", "_") - - if plugin_class and plugin_name: - plugin_object = plugin_class() - - if hasattr( - self, plugin_name - ): # If a plugin with the same name is already attached. - logging.warning( - ConColors.WARNING - + "A plugin named {} has already been loaded. Skipping {}.".format( - plugin_name, plugin_map - ) - + ConColors.ENDC - ) - - elif isinstance( - plugin_object, BasePlugin - ): # If plugin_object is an instance of MicroscopePlugin - # Attach plugin_object to the plugin mount - setattr(self, plugin_name_python_safe, plugin_object) - # Store the plugin object, and it's properties - self._plugins.append(plugin_object) - # DEPRECATED: Store the plugin with it's old name - self._legacy_plugins[plugin_name] = plugin_object - - # Grant plugin access to the hardware - if isinstance(plugin_object, MicroscopePlugin): - plugin_object.microscope = self.parent - - logging.info( - ConColors.OKGREEN - + "Plugin {} loaded as {}.".format( - plugin_map, plugin_object._name - ) - + ConColors.ENDC - ) - - else: - logging.warning(f"Error loading plugin {plugin_map}. Moving on.") - - -class BasePlugin: - """ - Parent class for all plugins. - - Handles binding route views and forms. - """ - - def __init__(self): - self._views = ( - {} - ) # Key: Full, Python-safe ID. Val: Original rule, and view class - self._rules = {} # Key: Original rule. Val: View class - self._gui = None - - # If old api_views dictionary is found - if hasattr(self, "api_views"): - # Convert to new format - self._convert_old_api_views() - # If old api_form dictionary is found - if hasattr(self, "api_form"): - # Convert to new format - self._convert_old_api_form() - - @property - def views(self): - return self._views - - def add_view(self, rule, view_class, **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] - - @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 - - def _convert_old_api_views(self): - for view_route, view_class in self.api_views.items(): - self.add_view(view_route, view_class) - - def _convert_old_api_form(self): - self.set_gui(self.api_form) - - @property - def _name(self): - return self.__class__.__name__ - - @property - def _name_python_safe(self): - return camel_to_snake(self._name) - - @property - def _name_uri_safe(self): - return camel_to_spine(self._name) - - def _full_name(self): - module = self.__class__.__module__ - if module is None or module == str.__class__.__module__: - return self.__class__.__name__ # Avoid reporting __builtin__ - else: - return module + "." + self.__class__.__name__ - - -class MicroscopePlugin(BasePlugin): - """ - Parent class for all microscope plugins. - - Initially only defines an empty object for microscope. All plugins - must be an instance of this class to successfully attach to PluginMount. - """ - - api_views = {} # Initially empty dictionary of API views associated with the plugin - - def __init__(self): - BasePlugin.__init__(self) - self.microscope = ( - None - ) #: :py:class:`openflexure_microscope.microscope.Microscope`: Microscope object From 206dd521ec9c347d890f280ddd4f7c7fa79ddae8 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 18 Dec 2019 15:06:15 +0000 Subject: [PATCH 022/122] Tidied up app --- openflexure_microscope/api/app.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 11ff9337..602a6a27 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -61,15 +61,6 @@ else: root.setLevel(logging.getLogger("gunicorn.error").level) -# Generate API URI based on version from filename -def uri(suffix, api_version, base=None): - if not base: - base = "/api/{}".format(api_version) - return_uri = base + suffix - logging.debug("Created app route: {}".format(return_uri)) - return return_uri - - # Create flask app app = Flask(__name__) app.url_map.strict_slashes = False @@ -92,8 +83,8 @@ labthing.title = f"OpenFlexure Microscope {api_microscope.name}" labthing.register_device(api_microscope, "openflexure_microscope") # Attach plugins -for _plugin in find_plugins(USER_PLUGINS_PATH): - labthing.register_plugin(_plugin) +for plugin in find_plugins(USER_PLUGINS_PATH): + labthing.register_plugin(plugin) # Attach captures resources labthing.add_resource(views.CaptureList, f"/captures", endpoint="CaptureList") From a8a3cafa978a74f6d3d0d39b1dc77b0158c1bcdb Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sat, 21 Dec 2019 12:10:09 +0000 Subject: [PATCH 023/122] Restructured labthings --- docs/source/plugins/example/plugin.py | 2 +- openflexure_microscope/api/app.py | 28 ++--- openflexure_microscope/api/microscope.py | 8 +- openflexure_microscope/api/utilities.py | 30 +++++ .../api/v2/views/actions/__init__.py | 5 - .../api/v2/views/actions/camera.py | 4 +- .../api/v2/views/actions/stage.py | 4 +- .../api/v2/views/actions/system.py | 2 +- .../api/v2/views/captures.py | 85 +++++++------- openflexure_microscope/api/v2/views/state.py | 4 +- .../api/v2/views/streams.py | 4 +- openflexure_microscope/camera/base.py | 2 +- openflexure_microscope/common/__init__.py | 3 +- .../__init__.py | 0 .../flask_labthings}/exceptions.py | 2 +- .../{labthings => flask_labthings}/fields.py | 20 +++- .../{labthings => flask_labthings}/find.py | 0 .../labthing.py | 43 ++++++- .../{labthings => flask_labthings}/plugins.py | 7 -- .../resource.py | 0 .../{labthings => flask_labthings}/schema.py | 0 .../common/flask_labthings/utilities.py | 13 +++ .../views/__init__.py | 0 .../common/flask_labthings/views/plugins.py | 64 +++++++++++ .../views/tasks.py | 66 ++++++----- .../common/labthings/views/plugins.py | 106 ------------------ .../common/labthings_core/__init__.py | 0 .../common/{ => labthings_core}/lock.py | 0 .../{ => labthings_core}/tasks/__init__.py | 0 .../common/{ => labthings_core}/tasks/pool.py | 0 .../{ => labthings_core}/tasks/thread.py | 0 .../common/{ => labthings_core}/utilities.py | 0 openflexure_microscope/devel/__init__.py | 2 +- openflexure_microscope/microscope.py | 2 +- .../microscope_settings.default.json | 8 +- .../plugins/v2/autofocus.py | 4 +- openflexure_microscope/plugins/v2/scan.py | 4 +- openflexure_microscope/stage/base.py | 2 +- openflexure_microscope/utilities.py | 20 ---- 39 files changed, 275 insertions(+), 269 deletions(-) rename openflexure_microscope/common/{labthings => flask_labthings}/__init__.py (100%) rename openflexure_microscope/{api => common/flask_labthings}/exceptions.py (93%) rename openflexure_microscope/common/{labthings => flask_labthings}/fields.py (83%) rename openflexure_microscope/common/{labthings => flask_labthings}/find.py (100%) rename openflexure_microscope/common/{labthings => flask_labthings}/labthing.py (84%) rename openflexure_microscope/common/{labthings => flask_labthings}/plugins.py (92%) rename openflexure_microscope/common/{labthings => flask_labthings}/resource.py (100%) rename openflexure_microscope/common/{labthings => flask_labthings}/schema.py (100%) create mode 100644 openflexure_microscope/common/flask_labthings/utilities.py rename openflexure_microscope/common/{labthings => flask_labthings}/views/__init__.py (100%) create mode 100644 openflexure_microscope/common/flask_labthings/views/plugins.py rename openflexure_microscope/common/{labthings => flask_labthings}/views/tasks.py (69%) delete mode 100644 openflexure_microscope/common/labthings/views/plugins.py create mode 100644 openflexure_microscope/common/labthings_core/__init__.py rename openflexure_microscope/common/{ => labthings_core}/lock.py (100%) rename openflexure_microscope/common/{ => labthings_core}/tasks/__init__.py (100%) rename openflexure_microscope/common/{ => labthings_core}/tasks/pool.py (100%) rename openflexure_microscope/common/{ => labthings_core}/tasks/thread.py (100%) rename openflexure_microscope/common/{ => labthings_core}/utilities.py (100%) diff --git a/docs/source/plugins/example/plugin.py b/docs/source/plugins/example/plugin.py index e99f9e85..0ea51b23 100644 --- a/docs/source/plugins/example/plugin.py +++ b/docs/source/plugins/example/plugin.py @@ -2,7 +2,7 @@ from openflexure_microscope.plugins import MicroscopePlugin from openflexure_microscope.api.views import MicroscopeViewPlugin from openflexure_microscope.api.utilities import JsonResponse -from openflexure_microscope.common.tasks import taskify +from openflexure_microscope.common.labthings_core.tasks import taskify import os import time diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 602a6a27..50123f81 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -3,29 +3,24 @@ import time import atexit import logging -import sys import os -from flask import Flask, jsonify, send_file, url_for +from flask import Flask, jsonify, send_file -from serial import SerialException from datetime import datetime from flask_cors import CORS -from openflexure_microscope.api.exceptions import JSONExceptionHandler -from openflexure_microscope.api.utilities import list_routes +from openflexure_microscope.api.utilities import list_routes, init_default_plugins from openflexure_microscope.config import ( settings_file_path, JSONEncoder, USER_PLUGINS_PATH, ) -from openflexure_microscope.api import v2 -from openflexure_microscope.common.labthings.labthing import LabThing -from openflexure_microscope.common.labthings.find import registered_plugins -from openflexure_microscope.common.labthings.plugins import find_plugins +from openflexure_microscope.common.flask_labthings.labthing import LabThing +from openflexure_microscope.common.flask_labthings.plugins import find_plugins from openflexure_microscope.api.microscope import default_microscope as api_microscope @@ -36,14 +31,13 @@ is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "") DEFAULT_LOGFILE = settings_file_path("openflexure_microscope.log") +logger = logging.getLogger() if (__name__ == "__main__") or (not is_gunicorn): # If imported, but not by gunicorn print("Letting sys handle logs") - logger = logging.getLogger() logger.setLevel(logging.DEBUG) else: # Direct standard Python logging to file and console - root = logging.getLogger() error_formatter = logging.Formatter( "[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s" ) @@ -56,9 +50,9 @@ else: for handler in error_handlers: handler.setFormatter(error_formatter) - root.addHandler(handler) + logger.addHandler(handler) - root.setLevel(logging.getLogger("gunicorn.error").level) + logger.setLevel(logging.getLogger("gunicorn.error").level) # Create flask app @@ -71,9 +65,6 @@ app.json_encoder = JSONEncoder # Enable CORS everywhere CORS(app, resources=r"*") -# Make errors more API friendly -handler = JSONExceptionHandler(app) - # Build a labthing labthing = LabThing(app, prefix="/api/v2") labthing.description = "Test LabThing-based API for OpenFlexure Microscope" @@ -83,6 +74,8 @@ labthing.title = f"OpenFlexure Microscope {api_microscope.name}" labthing.register_device(api_microscope, "openflexure_microscope") # Attach plugins +if not os.path.isfile(USER_PLUGINS_PATH): + init_default_plugins(USER_PLUGINS_PATH) for plugin in find_plugins(USER_PLUGINS_PATH): labthing.register_plugin(plugin) @@ -120,7 +113,7 @@ labthing.register_property(views.SnapshotStream) for name, action in views.enabled_root_actions().items(): view_class = action["view_class"] rule = action["rule"] - labthing.add_resource(view_class, "/actions{rule}") + labthing.add_resource(view_class, f"/actions{rule}") labthing.register_action(view_class) @@ -159,7 +152,6 @@ def err_log(): # Automatically clean up microscope at exit def cleanup(): - global api_microscope logging.debug("App teardown started...") logging.debug("Settling...") time.sleep(0.5) diff --git a/openflexure_microscope/api/microscope.py b/openflexure_microscope/api/microscope.py index c950b83d..04053892 100644 --- a/openflexure_microscope/api/microscope.py +++ b/openflexure_microscope/api/microscope.py @@ -28,12 +28,8 @@ except Exception as e: # Initialise stage logging.debug("Creating stage object...") -try: - api_stage = SangaStage() -except Exception as e: - logging.error(e) - logging.warning("No valid stage hardware found. Falling back to mock stage!") - api_stage = MockStage() + +api_stage = MockStage() # Attach devices to microscope logging.debug("Attaching devices to microscope...") diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index 32a854e5..98232ecb 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -1,4 +1,6 @@ import logging +import os +import errno from werkzeug.exceptions import BadRequest from flask import url_for, Blueprint @@ -95,3 +97,31 @@ def list_routes(app): output[url] = line return output + + +def create_file(config_path): + if not os.path.exists(os.path.dirname(config_path)): + try: + os.makedirs(os.path.dirname(config_path)) + except OSError as exc: # Guard against race condition + if exc.errno != errno.EEXIST: + raise + + +def init_default_plugins(plugin_path): + global _DEFAULT_PLUGIN_INIT + os.makedirs(os.path.dirname(plugin_path), exist_ok=True) + + if not os.path.exists(plugin_path): # If user plugins file doesn't exist + logging.warning("No plugin file found at {}. Creating...".format(plugin_path)) + create_file(plugin_path) + + logging.info("Populating {}...".format(plugin_path)) + with open(plugin_path, "w") as outfile: + outfile.write(_DEFAULT_PLUGIN_INIT) + + +_DEFAULT_PLUGIN_INIT = """from openflexure_microscope.plugins.v2.autofocus import autofocus_plugin_v2 +from openflexure_microscope.plugins.v2.scan import scan_plugin_v2 + +__plugins__ = [autofocus_plugin_v2, scan_plugin_v2]""" \ No newline at end of file diff --git a/openflexure_microscope/api/v2/views/actions/__init__.py b/openflexure_microscope/api/v2/views/actions/__init__.py index 15c287d7..20d3a4aa 100644 --- a/openflexure_microscope/api/v2/views/actions/__init__.py +++ b/openflexure_microscope/api/v2/views/actions/__init__.py @@ -2,11 +2,6 @@ Top-level representation of enabled actions """ -from flask import Blueprint, url_for, jsonify - -from openflexure_microscope.api.utilities import blueprint_for_module -from openflexure_microscope.utilities import get_docstring, description_from_view - from . import camera, stage, system _actions = { diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index b3637c5d..f3b59802 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -1,6 +1,6 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse -from openflexure_microscope.common.labthings.resource import Resource -from openflexure_microscope.common.labthings.find import find_device +from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.find import find_device from openflexure_microscope.utilities import filter_dict from openflexure_microscope.api.v2.views.captures import capture_schema diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index 0d18d93d..069ed059 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -1,6 +1,6 @@ from openflexure_microscope.api.utilities import JsonResponse -from openflexure_microscope.common.labthings.resource import Resource -from openflexure_microscope.common.labthings.find import find_device +from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.find import find_device from openflexure_microscope.utilities import axes_to_array, filter_dict from flask import Blueprint, jsonify, request diff --git a/openflexure_microscope/api/v2/views/actions/system.py b/openflexure_microscope/api/v2/views/actions/system.py index b4243b07..b148de7d 100644 --- a/openflexure_microscope/api/v2/views/actions/system.py +++ b/openflexure_microscope/api/v2/views/actions/system.py @@ -1,4 +1,4 @@ -from openflexure_microscope.common.labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.resource import Resource import subprocess import os from sys import platform diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index f90623c9..05c98624 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -3,47 +3,12 @@ from flask import abort, request, redirect, url_for, send_file, jsonify from openflexure_microscope.api.utilities import get_bool, JsonResponse -from openflexure_microscope.common.labthings.schema import Schema -from openflexure_microscope.common.labthings import fields -from openflexure_microscope.common.labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.schema import Schema +from openflexure_microscope.common.flask_labthings import fields +from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.utilities import description_from_view -from openflexure_microscope.common.labthings.find import find_device - - -class CaptureSchema(Schema): - id = fields.String() - file = fields.String(data_key="path") - exists = fields.Bool(data_key="available") - filename = fields.String() - metadata = fields.Dict() - - # TODO: Add HTTP methods - links = fields.Hyperlinks( - { - "self": { - "href": fields.AbsoluteUrlFor("CaptureResource", id=""), - "mimetype": "application/json", - }, - "tags": { - "href": fields.AbsoluteUrlFor("CaptureTags", id=""), - "mimetype": "application/json", - }, - "metadata": { - "href": fields.AbsoluteUrlFor("CaptureMetadata", id=""), - "mimetype": "application/json", - }, - "download": { - "href": fields.AbsoluteUrlFor( - "CaptureDownload", id="", filename="" - ), - "mimetype": "image/jpeg", - }, - } - ) - - -capture_schema = CaptureSchema() -capture_list_schema = CaptureSchema(many=True) +from openflexure_microscope.common.flask_labthings.find import find_device class CaptureList(Resource): @@ -182,6 +147,46 @@ class CaptureMetadata(Resource): return jsonify(capture_obj.metadata) +class CaptureSchema(Schema): + id = fields.String() + file = fields.String(data_key="path") + exists = fields.Bool(data_key="available") + filename = fields.String() + metadata = fields.Dict() + + # TODO: Add HTTP methods + links = fields.Hyperlinks( + { + "self": { + "href": fields.AbsoluteUrlFor(CaptureResource, id=""), + "mimetype": "application/json", + **description_from_view(CaptureResource) + }, + "tags": { + "href": fields.AbsoluteUrlFor(CaptureTags, id=""), + "mimetype": "application/json", + **description_from_view(CaptureTags) + }, + "metadata": { + "href": fields.AbsoluteUrlFor(CaptureMetadata, id=""), + "mimetype": "application/json", + **description_from_view(CaptureMetadata) + }, + "download": { + "href": fields.AbsoluteUrlFor( + CaptureDownload, id="", filename="" + ), + "mimetype": "image/jpeg", + **description_from_view(CaptureDownload) + }, + } + ) + + +capture_schema = CaptureSchema() +capture_list_schema = CaptureSchema(many=True) + + def add_captures_to_labthing(labthing, prefix=""): """ Add all capture resources to a labthing diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py index 176ae0ea..84c37850 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/state.py @@ -1,8 +1,8 @@ from openflexure_microscope.api.utilities import JsonResponse from openflexure_microscope.utilities import get_by_path, set_by_path, create_from_path -from openflexure_microscope.common.labthings.find import find_device -from openflexure_microscope.common.labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.resource import Resource from flask import jsonify, request, abort import logging diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index 9527c941..fe22c959 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -1,8 +1,8 @@ from openflexure_microscope.api.utilities import gen, JsonResponse from openflexure_microscope.utilities import get_by_path, set_by_path, create_from_path -from openflexure_microscope.common.labthings.find import find_device -from openflexure_microscope.common.labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.resource import Resource from flask import jsonify, request, abort, Response import logging diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index ebce1bf8..1bdf33bf 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -10,7 +10,7 @@ from abc import ABCMeta, abstractmethod from .capture import CaptureObject from openflexure_microscope.utilities import entry_by_id -from openflexure_microscope.common.lock import StrictLock +from openflexure_microscope.common.labthings_core.lock import StrictLock BASE_CAPTURE_PATH = os.path.join(os.path.expanduser("~"), "micrographs") diff --git a/openflexure_microscope/common/__init__.py b/openflexure_microscope/common/__init__.py index 80899ff0..c9450d14 100644 --- a/openflexure_microscope/common/__init__.py +++ b/openflexure_microscope/common/__init__.py @@ -1 +1,2 @@ -from . import tasks, labthings +from . import flask_labthings +from openflexure_microscope.common.labthings_core import tasks diff --git a/openflexure_microscope/common/labthings/__init__.py b/openflexure_microscope/common/flask_labthings/__init__.py similarity index 100% rename from openflexure_microscope/common/labthings/__init__.py rename to openflexure_microscope/common/flask_labthings/__init__.py diff --git a/openflexure_microscope/api/exceptions.py b/openflexure_microscope/common/flask_labthings/exceptions.py similarity index 93% rename from openflexure_microscope/api/exceptions.py rename to openflexure_microscope/common/flask_labthings/exceptions.py index 624d5dba..bbb83d1e 100644 --- a/openflexure_microscope/api/exceptions.py +++ b/openflexure_microscope/common/flask_labthings/exceptions.py @@ -24,7 +24,7 @@ class JSONExceptionHandler(object): status_code = error.code if isinstance(error, HTTPException) else 500 - response = {"status_code": status_code, "message": escape(message)} + response = {"code": status_code, "message": escape(message)} return jsonify(response), status_code def init_app(self, app): diff --git a/openflexure_microscope/common/labthings/fields.py b/openflexure_microscope/common/flask_labthings/fields.py similarity index 83% rename from openflexure_microscope/common/labthings/fields.py rename to openflexure_microscope/common/flask_labthings/fields.py index f255065f..13ea7da9 100644 --- a/openflexure_microscope/common/labthings/fields.py +++ b/openflexure_microscope/common/flask_labthings/fields.py @@ -2,6 +2,7 @@ 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*") @@ -62,7 +63,16 @@ class URLFor(Field): _CHECK_ATTRIBUTE = False def __init__(self, endpoint, **kwargs): - self.endpoint = endpoint + # 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) @@ -70,6 +80,14 @@ class URLFor(Field): """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)) diff --git a/openflexure_microscope/common/labthings/find.py b/openflexure_microscope/common/flask_labthings/find.py similarity index 100% rename from openflexure_microscope/common/labthings/find.py rename to openflexure_microscope/common/flask_labthings/find.py diff --git a/openflexure_microscope/common/labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py similarity index 84% rename from openflexure_microscope/common/labthings/labthing.py rename to openflexure_microscope/common/flask_labthings/labthing.py index 0f23f258..e917ca50 100644 --- a/openflexure_microscope/common/labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -1,16 +1,17 @@ -from flask import current_app, _app_ctx_stack, request, url_for, jsonify +from flask import url_for, jsonify from .plugins import BasePlugin from .views.plugins import PluginListResource from .views.tasks import TaskList, TaskResource -from ..utilities import get_docstring +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="", title="", description=""): + def __init__(self, app=None, prefix: str = "", title: str = "", description: str = "", handle_errors: bool = True): self.app = app self.devices = {} @@ -27,6 +28,11 @@ class LabThing(object): 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) @@ -35,21 +41,28 @@ class LabThing(object): 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): - print(f"Tearing down devices: {self.devices}") + pass def _create_base_routes(self): # Add thing description to root - self.app.add_url_rule(self._complete_url("/", ""), "td", self.td) + self.app.add_url_rule(self._complete_url("/td", ""), "td", self.td) # Add plugin overview self.add_resource(PluginListResource, "/plugins") self.register_property(PluginListResource) @@ -64,6 +77,7 @@ class LabThing(object): 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 @@ -196,8 +210,25 @@ class LabThing(object): 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] = {} @@ -221,3 +252,5 @@ class LabThing(object): } return jsonify(td) + + # TODO: Add a nicer root resource like the old self-documenting system \ No newline at end of file diff --git a/openflexure_microscope/common/labthings/plugins.py b/openflexure_microscope/common/flask_labthings/plugins.py similarity index 92% rename from openflexure_microscope/common/labthings/plugins.py rename to openflexure_microscope/common/flask_labthings/plugins.py index fca5423d..05a431a1 100644 --- a/openflexure_microscope/common/labthings/plugins.py +++ b/openflexure_microscope/common/flask_labthings/plugins.py @@ -5,10 +5,8 @@ import copy from importlib import util import sys -from openflexure_microscope.config import USER_CONFIG_DIR from openflexure_microscope.utilities import ( camel_to_snake, - camel_to_spine, snake_to_spine, ) @@ -125,11 +123,6 @@ class BasePlugin: def find_plugins(plugin_path, module_name="plugins"): - print(f"Loading plugins from {plugin_path}") - # plugin_path = os.path.join(USER_CONFIG_DIR, "microscope_plugins") - # plugins = importlib.machinery.SourceFileLoader( - # module_name, plugin_path - # ).exec_module() logging.debug(f"Loading plugins from {plugin_path}") spec = util.spec_from_file_location(module_name, plugin_path) diff --git a/openflexure_microscope/common/labthings/resource.py b/openflexure_microscope/common/flask_labthings/resource.py similarity index 100% rename from openflexure_microscope/common/labthings/resource.py rename to openflexure_microscope/common/flask_labthings/resource.py diff --git a/openflexure_microscope/common/labthings/schema.py b/openflexure_microscope/common/flask_labthings/schema.py similarity index 100% rename from openflexure_microscope/common/labthings/schema.py rename to openflexure_microscope/common/flask_labthings/schema.py diff --git a/openflexure_microscope/common/flask_labthings/utilities.py b/openflexure_microscope/common/flask_labthings/utilities.py new file mode 100644 index 00000000..681471c9 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/utilities.py @@ -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 diff --git a/openflexure_microscope/common/labthings/views/__init__.py b/openflexure_microscope/common/flask_labthings/views/__init__.py similarity index 100% rename from openflexure_microscope/common/labthings/views/__init__.py rename to openflexure_microscope/common/flask_labthings/views/__init__.py diff --git a/openflexure_microscope/common/flask_labthings/views/plugins.py b/openflexure_microscope/common/flask_labthings/views/plugins.py new file mode 100644 index 00000000..eb6b27b0 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/views/plugins.py @@ -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())) diff --git a/openflexure_microscope/common/labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py similarity index 69% rename from openflexure_microscope/common/labthings/views/tasks.py rename to openflexure_microscope/common/flask_labthings/views/tasks.py index afcba39b..bc2ef32b 100644 --- a/openflexure_microscope/common/labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -1,38 +1,10 @@ -import logging -from flask import abort, request, redirect, url_for, send_file, jsonify +from flask import abort -from openflexure_microscope.api.utilities import get_bool, JsonResponse - -from openflexure_microscope.common.labthings.schema import Schema -from openflexure_microscope.common.labthings import fields -from openflexure_microscope.common.labthings.resource import Resource - -from openflexure_microscope.common import tasks - - -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=""), - "mimetype": "application/json", - } - } - ) - - -task_schema = TaskSchema() -task_list_schema = TaskSchema(many=True) +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): @@ -59,3 +31,29 @@ class TaskResource(Resource): 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=""), + "mimetype": "application/json", + **description_from_view(TaskResource) + } + } + ) + + +task_schema = TaskSchema() +task_list_schema = TaskSchema(many=True) diff --git a/openflexure_microscope/common/labthings/views/plugins.py b/openflexure_microscope/common/labthings/views/plugins.py deleted file mode 100644 index fa160b53..00000000 --- a/openflexure_microscope/common/labthings/views/plugins.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Top-level representation of attached and enabled plugins -""" - -from openflexure_microscope.utilities import get_docstring, description_from_view -from openflexure_microscope.api.utilities import blueprint_for_module - -from openflexure_microscope.common.labthings.find import registered_plugins -from openflexure_microscope.common.labthings.resource import Resource - -from flask import Blueprint, jsonify, url_for - -import copy -import logging -import warnings - - -def plugins_representation(plugin_dict): - """ - Generate a dictionary representation of all plugins, including Flask route URLs - - Args: - plugin_loader_object (:py:class:`openflexure_microscope.plugins.PluginLoader`): Microscope plugin loader - - 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), - "views": {}, - "gui": plugin.gui, - "description": get_docstring(plugin), - } - - for view_id, view_data in plugin.views.items(): - logging.debug(f"Representing view {view_id}") - uri = url_for(f"PluginListResource") + "/" + view_data["rule"][1:] - # uri = view_data["rule"] - # Make links dictionary if it doesn't yet exist - view_d = {"links": {"self": uri}} - - view_d.update(description_from_view(view_data["view"])) - - d["views"][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())) - - -""" -def construct_blueprint(): - - blueprint = blueprint_for_module(__name__) - - for plugin_obj in registered_plugins(): - for plugin_view_id, plugin_view in plugin_obj.views.items(): - # Add route to the plugins blueprint - blueprint.add_url_rule( - plugin_view["rule"], - view_func=plugin_view["view"].as_view( - f"{plugin_obj._name_python_safe}_{plugin_view_id}" - ), - **plugin_view["kwargs"], - ) - - # Create a base route to return plugin API forms, if any exist - blueprint.add_url_rule("/", view_func=PluginFormAPI.as_view("plugins")) - - # For each plugin attached to the microscope object - for plugin in microscope_obj.plugins.active: - - for plugin_view_id, plugin_view in plugin.views.items(): - # Add route to the plugins blueprint - blueprint.add_url_rule( - plugin_view["rule"], - view_func=plugin_view["view"].as_view( - f"{plugin._name_python_safe}_{plugin_view_id}", plugin=plugin - ), - **plugin_view["kwargs"], - ) - - return blueprint -""" diff --git a/openflexure_microscope/common/labthings_core/__init__.py b/openflexure_microscope/common/labthings_core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openflexure_microscope/common/lock.py b/openflexure_microscope/common/labthings_core/lock.py similarity index 100% rename from openflexure_microscope/common/lock.py rename to openflexure_microscope/common/labthings_core/lock.py diff --git a/openflexure_microscope/common/tasks/__init__.py b/openflexure_microscope/common/labthings_core/tasks/__init__.py similarity index 100% rename from openflexure_microscope/common/tasks/__init__.py rename to openflexure_microscope/common/labthings_core/tasks/__init__.py diff --git a/openflexure_microscope/common/tasks/pool.py b/openflexure_microscope/common/labthings_core/tasks/pool.py similarity index 100% rename from openflexure_microscope/common/tasks/pool.py rename to openflexure_microscope/common/labthings_core/tasks/pool.py diff --git a/openflexure_microscope/common/tasks/thread.py b/openflexure_microscope/common/labthings_core/tasks/thread.py similarity index 100% rename from openflexure_microscope/common/tasks/thread.py rename to openflexure_microscope/common/labthings_core/tasks/thread.py diff --git a/openflexure_microscope/common/utilities.py b/openflexure_microscope/common/labthings_core/utilities.py similarity index 100% rename from openflexure_microscope/common/utilities.py rename to openflexure_microscope/common/labthings_core/utilities.py diff --git a/openflexure_microscope/devel/__init__.py b/openflexure_microscope/devel/__init__.py index 05e9df1e..d412d008 100644 --- a/openflexure_microscope/devel/__init__.py +++ b/openflexure_microscope/devel/__init__.py @@ -8,7 +8,7 @@ as well as some Flask imports to simplify API route development from openflexure_microscope.api.utilities import JsonResponse # Task management -from openflexure_microscope.common.tasks import ( +from openflexure_microscope.common.labthings_core.tasks import ( current_task, update_task_progress, update_task_data, diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index 8c7ca5f8..de9cf00e 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -12,7 +12,7 @@ from openflexure_microscope.camera.base import BaseCamera from openflexure_microscope.camera.mock import MockStreamer from openflexure_microscope.utilities import serialise_array_b64 -from openflexure_microscope.common.lock import CompositeLock +from openflexure_microscope.common.labthings_core.lock import CompositeLock from openflexure_microscope.config import user_settings diff --git a/openflexure_microscope/microscope_settings.default.json b/openflexure_microscope/microscope_settings.default.json index 3660048a..bde62f47 100644 --- a/openflexure_microscope/microscope_settings.default.json +++ b/openflexure_microscope/microscope_settings.default.json @@ -3,11 +3,5 @@ 4100, 3146 ], - "jpeg_quality": 75, - "plugins": [ - "openflexure_microscope.plugins.default.autofocus:AutofocusPlugin", - "openflexure_microscope.plugins.default.scan:ScanPlugin", - "openflexure_microscope.plugins.default.camera_calibration:AutocalibrationPlugin", - "openflexure_microscope.plugins.default.zip_builder:ZipBuilderPlugin" - ] + "jpeg_quality": 75 } \ No newline at end of file diff --git a/openflexure_microscope/plugins/v2/autofocus.py b/openflexure_microscope/plugins/v2/autofocus.py index 71c3c938..8f748088 100644 --- a/openflexure_microscope/plugins/v2/autofocus.py +++ b/openflexure_microscope/plugins/v2/autofocus.py @@ -1,5 +1,5 @@ -from openflexure_microscope.common.labthings.find import find_device -from openflexure_microscope.common.labthings.plugins import BasePlugin +from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.plugins import BasePlugin from openflexure_microscope.microscope import Microscope from openflexure_microscope.devel import JsonResponse, request, jsonify, taskify, abort diff --git a/openflexure_microscope/plugins/v2/scan.py b/openflexure_microscope/plugins/v2/scan.py index f774c2f8..adea2e54 100644 --- a/openflexure_microscope/plugins/v2/scan.py +++ b/openflexure_microscope/plugins/v2/scan.py @@ -6,8 +6,8 @@ from typing import Tuple from functools import reduce from openflexure_microscope.camera.base import generate_basename -from openflexure_microscope.common.labthings.find import find_device, find_plugin -from openflexure_microscope.common.labthings.plugins import BasePlugin +from openflexure_microscope.common.flask_labthings.find import find_device, find_plugin +from openflexure_microscope.common.flask_labthings.plugins import BasePlugin from openflexure_microscope.devel import ( JsonResponse, diff --git a/openflexure_microscope/stage/base.py b/openflexure_microscope/stage/base.py index 45607a77..4b591994 100644 --- a/openflexure_microscope/stage/base.py +++ b/openflexure_microscope/stage/base.py @@ -1,6 +1,6 @@ import numpy as np from abc import ABCMeta, abstractmethod -from openflexure_microscope.common.lock import StrictLock +from openflexure_microscope.common.labthings_core.lock import StrictLock class BaseStage(metaclass=ABCMeta): diff --git a/openflexure_microscope/utilities.py b/openflexure_microscope/utilities.py index 7fec2e36..0cf33c5c 100644 --- a/openflexure_microscope/utilities.py +++ b/openflexure_microscope/utilities.py @@ -41,26 +41,6 @@ def bottom_level_name(obj): return obj.__name__.split(".")[-1] -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 - - -def get_docstring(obj): - ds = obj.__doc__ - if ds: - return ds.strip() - else: - return "" - - def camel_to_snake(name): s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() From 2372ba26e19bfd8aeafed0fbf468650a63a1fbef Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sat, 21 Dec 2019 13:18:02 +0000 Subject: [PATCH 024/122] Added some basic docs (waiting on auto-doc for args) --- .../api/v2/views/actions/camera.py | 8 ++++++++ .../api/v2/views/actions/stage.py | 4 ++++ openflexure_microscope/api/v2/views/captures.py | 15 +++++++++++++++ .../common/flask_labthings/labthing.py | 1 + 4 files changed, 28 insertions(+) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index f3b59802..ab0b95e5 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -56,6 +56,11 @@ class CaptureAPI(Resource): class GPUPreviewStartAPI(Resource): + """ + Start the onboard GPU preview. + Optional "window" parameter can be passed to control the position and size of the preview window, + in the format ``[x, y, width, height]``. + """ def post(self): microscope = find_device("openflexure_microscope") payload = JsonResponse(request) @@ -76,6 +81,9 @@ class GPUPreviewStartAPI(Resource): class GPUPreviewStopAPI(Resource): + """ + Start the onboard GPU preview. + """ def post(self): microscope = find_device("openflexure_microscope") microscope.camera.stop_preview() diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index 069ed059..e1df4553 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -9,6 +9,10 @@ import logging class MoveStageAPI(Resource): + """ + Handle stage movements. + """ + def post(self): microscope = find_device("openflexure_microscope") # Create response object diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 05c98624..ebf81a38 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -12,6 +12,9 @@ from openflexure_microscope.common.flask_labthings.find import find_device class CaptureList(Resource): + """ + List all image captures + """ def get(self): microscope = find_device("openflexure_microscope") image_list = microscope.camera.images @@ -19,6 +22,9 @@ class CaptureList(Resource): class CaptureResource(Resource): + """ + Description of a single image capture + """ def get(self, id): microscope = find_device("openflexure_microscope") capture_obj = microscope.camera.image_from_id(id) @@ -41,6 +47,9 @@ class CaptureResource(Resource): class CaptureDownload(Resource): + """ + Image data for a single image capture + """ def get(self, id, filename): microscope = find_device("openflexure_microscope") @@ -73,6 +82,9 @@ class CaptureDownload(Resource): class CaptureTags(Resource): + """ + Tags associated with a single image capture + """ def get(self, id): microscope = find_device("openflexure_microscope") @@ -120,6 +132,9 @@ class CaptureTags(Resource): class CaptureMetadata(Resource): + """ + All metadata associated with a single image capture + """ def get(self, id): microscope = find_device("openflexure_microscope") capture_obj = microscope.camera.image_from_id(id) diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index e917ca50..0ec5264a 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -225,6 +225,7 @@ class LabThing(object): return endpoint in self.endpoints ### Description + def td(self): """ W3C-style Thing Description From 68541fdddb6ca30b833da490b29c9efc7fe802ad Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sat, 21 Dec 2019 16:19:52 +0000 Subject: [PATCH 025/122] Removed explicit endpoints --- openflexure_microscope/api/app.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 50123f81..461f3a71 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -80,19 +80,18 @@ for plugin in find_plugins(USER_PLUGINS_PATH): labthing.register_plugin(plugin) # Attach captures resources -labthing.add_resource(views.CaptureList, f"/captures", endpoint="CaptureList") +labthing.add_resource(views.CaptureList, f"/captures") labthing.register_property(views.CaptureList) labthing.add_resource( - views.CaptureResource, f"/captures/", endpoint="CaptureResource" + views.CaptureResource, f"/captures/" ) labthing.add_resource( views.CaptureDownload, f"/captures//download/", - endpoint="CaptureDownload", ) -labthing.add_resource(views.CaptureTags, f"/captures//tags", endpoint="CaptureTags") +labthing.add_resource(views.CaptureTags, f"/captures//tags") labthing.add_resource( - views.CaptureMetadata, f"/captures//metadata", endpoint="CaptureMetadata" + views.CaptureMetadata, f"/captures//metadata" ) # Attach settings and status resources From 8bda25ea7370cf0de3123239a2971d71ce570b5f Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sat, 21 Dec 2019 16:21:02 +0000 Subject: [PATCH 026/122] Lowercase for endpoint names --- openflexure_microscope/common/flask_labthings/labthing.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index 0ec5264a..c5f4da30 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -67,9 +67,9 @@ class LabThing(object): self.add_resource(PluginListResource, "/plugins") self.register_property(PluginListResource) # Add task routes - self.add_resource(TaskList, "/tasks", endpoint="TasksProperty") + self.add_resource(TaskList, "/tasks") self.register_property(TaskList) - self.add_resource(TaskResource, "/tasks/", endpoint="TaskResource") + self.add_resource(TaskResource, "/tasks/") ### Device stuff @@ -150,7 +150,7 @@ class LabThing(object): api.add_resource(Foo, '/foo', endpoint="foo") api.add_resource(FooSpecial, '/special/foo', endpoint="foo") """ - endpoint = endpoint or resource.__name__ + endpoint = endpoint or resource.__name__.lower() if self.app is not None: self._register_view(self.app, resource, *urls, endpoint=endpoint, **kwargs) else: @@ -175,7 +175,7 @@ class LabThing(object): return decorator def _register_view(self, app, resource, *urls, endpoint=None, **kwargs): - endpoint = endpoint or resource.__name__ + endpoint = endpoint or resource.__name__.lower() self.endpoints.add(endpoint) resource_class_args = kwargs.pop("resource_class_args", ()) resource_class_kwargs = kwargs.pop("resource_class_kwargs", {}) From e9bcaf7faf8eae974719c79eb7cef49319cfb561 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sat, 21 Dec 2019 16:21:22 +0000 Subject: [PATCH 027/122] Use LabThing Resource --- openflexure_microscope/plugins/v2/autofocus.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/openflexure_microscope/plugins/v2/autofocus.py b/openflexure_microscope/plugins/v2/autofocus.py index 8f748088..634cd47a 100644 --- a/openflexure_microscope/plugins/v2/autofocus.py +++ b/openflexure_microscope/plugins/v2/autofocus.py @@ -1,12 +1,10 @@ from openflexure_microscope.common.flask_labthings.find import find_device from openflexure_microscope.common.flask_labthings.plugins import BasePlugin -from openflexure_microscope.microscope import Microscope +from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.devel import JsonResponse, request, jsonify, taskify, abort from openflexure_microscope.utilities import set_properties -from flask.views import MethodView - import time import numpy as np import threading @@ -289,7 +287,7 @@ def fast_up_down_up_autofocus( return m.data_dict() -class MeasureSharpnessAPI(MethodView): +class MeasureSharpnessAPI(Resource): def post(self): microscope = find_device("openflexure_microscope") @@ -299,7 +297,7 @@ class MeasureSharpnessAPI(MethodView): return jsonify({"sharpness": measure_sharpness(microscope)}) -class AutofocusAPI(MethodView): +class AutofocusAPI(Resource): """ Run a standard autofocus """ @@ -325,7 +323,7 @@ class AutofocusAPI(MethodView): abort(503, "No stage connected. Unable to autofocus.") -class FastAutofocusAPI(MethodView): +class FastAutofocusAPI(Resource): """ Run a fast autofocus """ From d13f8d8b8c76bfccb4d661e0acfd981337ebc917 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sat, 21 Dec 2019 16:21:29 +0000 Subject: [PATCH 028/122] Use LabThing Resource --- openflexure_microscope/plugins/v2/scan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/plugins/v2/scan.py b/openflexure_microscope/plugins/v2/scan.py index adea2e54..a214ee5a 100644 --- a/openflexure_microscope/plugins/v2/scan.py +++ b/openflexure_microscope/plugins/v2/scan.py @@ -19,7 +19,7 @@ from openflexure_microscope.devel import ( update_task_data, ) -from flask.views import MethodView +from openflexure_microscope.common.flask_labthings.resource import Resource import time @@ -340,7 +340,7 @@ def stack( ### Web views -class TileScanAPI(MethodView): +class TileScanAPI(Resource): def post(self): payload = JsonResponse(request) microscope = find_device("openflexure_microscope") From 4793de128325d8ff167581c58ddd3642c7ff8a14 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sat, 21 Dec 2019 17:37:21 +0000 Subject: [PATCH 029/122] Generate links at pre_dump time --- .../api/v2/views/captures.py | 95 ++-- .../common/flask_labthings/decorators.py | 44 ++ .../common/flask_labthings/views/tasks.py | 17 +- poetry.lock | 492 ++++++++++++++++-- 4 files changed, 547 insertions(+), 101 deletions(-) create mode 100644 openflexure_microscope/common/flask_labthings/decorators.py diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index ebf81a38..a8203012 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -7,24 +7,73 @@ from openflexure_microscope.common.flask_labthings.schema import Schema from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.utilities import description_from_view +from openflexure_microscope.common.flask_labthings.decorators import marshal_with from openflexure_microscope.common.flask_labthings.find import find_device +from marshmallow import pre_dump + + +class CaptureSchema(Schema): + id = fields.String() + file = fields.String(data_key="path") + exists = fields.Bool(data_key="available") + filename = fields.String() + metadata = fields.Dict() + + links = fields.Dict() + + # TODO: Automate this somewhat + @pre_dump + def generate_links(self, data, **kwargs): + data.links = { + "self": { + "href": url_for(CaptureResource.endpoint, id=data.id, _external=True), + "mimetype": "application/json", + **description_from_view(CaptureResource) + }, + "tags": { + "href": url_for(CaptureTags.endpoint, id=data.id, _external=True), + "mimetype": "application/json", + **description_from_view(CaptureTags) + }, + "metadata": { + "href": url_for(CaptureMetadata.endpoint, id=data.id, _external=True), + "mimetype": "application/json", + **description_from_view(CaptureMetadata) + }, + "download": { + "href": url_for( + CaptureDownload.endpoint, id=data.id, filename=data.filename, _external=True + ), + "mimetype": "image/jpeg", + **description_from_view(CaptureDownload) + }, + } + return data + + +capture_schema = CaptureSchema() +capture_list_schema = CaptureSchema(many=True) + class CaptureList(Resource): """ List all image captures """ + @marshal_with(CaptureSchema(many=True)) def get(self): microscope = find_device("openflexure_microscope") image_list = microscope.camera.images - return capture_list_schema.jsonify(image_list) + return image_list class CaptureResource(Resource): """ Description of a single image capture """ + + @marshal_with(CaptureSchema()) def get(self, id): microscope = find_device("openflexure_microscope") capture_obj = microscope.camera.image_from_id(id) @@ -32,7 +81,7 @@ class CaptureResource(Resource): if not capture_obj: return abort(404) # 404 Not Found - return capture_schema.jsonify(capture_obj) + return capture_obj def delete(self, id): microscope = find_device("openflexure_microscope") @@ -43,7 +92,7 @@ class CaptureResource(Resource): capture_obj.delete() - return ("", 204) + return "", 204 class CaptureDownload(Resource): @@ -162,46 +211,6 @@ class CaptureMetadata(Resource): return jsonify(capture_obj.metadata) -class CaptureSchema(Schema): - id = fields.String() - file = fields.String(data_key="path") - exists = fields.Bool(data_key="available") - filename = fields.String() - metadata = fields.Dict() - - # TODO: Add HTTP methods - links = fields.Hyperlinks( - { - "self": { - "href": fields.AbsoluteUrlFor(CaptureResource, id=""), - "mimetype": "application/json", - **description_from_view(CaptureResource) - }, - "tags": { - "href": fields.AbsoluteUrlFor(CaptureTags, id=""), - "mimetype": "application/json", - **description_from_view(CaptureTags) - }, - "metadata": { - "href": fields.AbsoluteUrlFor(CaptureMetadata, id=""), - "mimetype": "application/json", - **description_from_view(CaptureMetadata) - }, - "download": { - "href": fields.AbsoluteUrlFor( - CaptureDownload, id="", filename="" - ), - "mimetype": "image/jpeg", - **description_from_view(CaptureDownload) - }, - } - ) - - -capture_schema = CaptureSchema() -capture_list_schema = CaptureSchema(many=True) - - def add_captures_to_labthing(labthing, prefix=""): """ Add all capture resources to a labthing diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py new file mode 100644 index 00000000..e911f887 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -0,0 +1,44 @@ +from webargs.flaskparser import use_args, use_kwargs +from functools import wraps +from flask import make_response + + +def unpack(value): + """Return a three tuple of data, code, and headers""" + if not isinstance(value, tuple): + return value, 200, {} + + try: + data, code, headers = value + return data, code, headers + except ValueError: + pass + + try: + data, code = value + return data, code, {} + except ValueError: + pass + + return value, 200, {} + + +class marshal_with(object): + def __init__(self, schema): + """ + :param schema: a dict of whose keys will make up the final + serialized response output + """ + self.schema = schema + + def __call__(self, f): + @wraps(f) + def wrapper(*args, **kwargs): + resp = f(*args, **kwargs) + if isinstance(resp, tuple): + data, code, headers = unpack(resp) + print((data, code, headers)) + return make_response(self.schema.jsonify(data), code, headers) + else: + return make_response(self.schema.jsonify(resp)) + return wrapper diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index bc2ef32b..8669559f 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -1,4 +1,4 @@ -from flask import abort +from flask import abort, url_for from openflexure_microscope.common.flask_labthings.schema import Schema from openflexure_microscope.common.flask_labthings import fields @@ -6,6 +6,8 @@ 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 +from marshmallow import pre_dump + class TaskList(Resource): def get(self): @@ -43,16 +45,17 @@ class TaskSchema(Schema): _start_time = fields.String(data_key="start_time") _end_time = fields.String(data_key="end_time") - # TODO: Add HTTP methods - links = fields.Hyperlinks( - { + # TODO: Automate this somewhat + @pre_dump + def generate_links(self, data, **kwargs): + data.links = { "self": { - "href": fields.AbsoluteUrlFor(TaskResource, id=""), + "href": url_for(TaskResource.endpoint, id=data.id, _external=True), "mimetype": "application/json", **description_from_view(TaskResource) - } + }, } - ) + return data task_schema = TaskSchema() diff --git a/poetry.lock b/poetry.lock index 2858799b..2efd26f1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -39,6 +39,12 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "19.3.0" +[package.extras] +azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] +dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] +docs = ["sphinx", "zope.interface"] +tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] + [[package]] category = "dev" description = "Internationalization utilities" @@ -64,6 +70,9 @@ attrs = ">=17.4.0" click = ">=6.5" toml = ">=0.9.4" +[package.extras] +d = ["aiohttp (>=3.3.2)"] + [[package]] category = "dev" description = "Python package for providing Mozilla's CA Bundle." @@ -119,6 +128,11 @@ Werkzeug = ">=0.15" click = ">=5.1" itsdangerous = ">=0.24" +[package.extras] +dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] +docs = ["sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] +dotenv = ["python-dotenv"] + [[package]] category = "main" description = "A Flask extension adding a decorator for CORS support" @@ -155,6 +169,12 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "4.3.21" +[package.extras] +pipfile = ["pipreqs", "requirementslib"] +pyproject = ["toml"] +requirements = ["pipreqs", "pip-api"] +xdg_home = ["appdirs (>=1.4.0)"] + [[package]] category = "main" description = "Various helpers to pass data to untrusted environments and back." @@ -174,6 +194,9 @@ version = "2.10.3" [package.dependencies] MarkupSafe = ">=0.23" +[package.extras] +i18n = ["Babel (>=0.8)"] + [[package]] category = "dev" description = "A fast and thorough lazy object proxy." @@ -198,6 +221,12 @@ optional = false python-versions = ">=3.5" version = "3.3.0" +[package.extras] +dev = ["pytest", "pytz", "simplejson", "mypy (0.750)", "flake8 (3.7.9)", "flake8-bugbear (19.8.0)", "pre-commit (>=1.20,<2.0)", "tox"] +docs = ["sphinx (2.2.2)", "sphinx-issues (1.2.0)", "alabaster (0.7.12)", "sphinx-version-warning (1.1.2)"] +lint = ["mypy (0.750)", "flake8 (3.7.9)", "flake8-bugbear (19.8.0)", "pre-commit (>=1.20,<2.0)"] +tests = ["pytest", "pytz", "simplejson"] + [[package]] category = "dev" description = "McCabe checker, plugin for flake8" @@ -307,6 +336,10 @@ chardet = ">=3.0.2,<3.1.0" idna = ">=2.5,<2.9" urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] + [[package]] category = "dev" description = "a python refactoring library..." @@ -377,6 +410,10 @@ sphinxcontrib-jsmath = "*" sphinxcontrib-qthelp = "*" sphinxcontrib-serializinghtml = "*" +[package.extras] +docs = ["sphinxcontrib-websupport"] +test = ["pytest", "pytest-cov", "html5lib", "flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.750)", "docutils-stubs"] + [[package]] category = "dev" description = "" @@ -385,6 +422,9 @@ optional = false python-versions = "*" version = "1.0.1" +[package.extras] +test = ["pytest", "flake8", "mypy"] + [[package]] category = "dev" description = "" @@ -393,6 +433,9 @@ optional = false python-versions = "*" version = "1.0.1" +[package.extras] +test = ["pytest", "flake8", "mypy"] + [[package]] category = "dev" description = "" @@ -401,6 +444,9 @@ optional = false python-versions = "*" version = "1.0.2" +[package.extras] +test = ["pytest", "flake8", "mypy", "html5lib"] + [[package]] category = "dev" description = "Sphinx domain for documenting HTTP APIs" @@ -421,6 +467,9 @@ optional = false python-versions = ">=3.5" version = "1.0.1" +[package.extras] +test = ["pytest", "flake8", "mypy"] + [[package]] category = "dev" description = "" @@ -429,6 +478,9 @@ optional = false python-versions = "*" version = "1.0.2" +[package.extras] +test = ["pytest", "flake8", "mypy"] + [[package]] category = "dev" description = "" @@ -437,6 +489,9 @@ optional = false python-versions = "*" version = "1.1.3" +[package.extras] +test = ["pytest", "flake8", "mypy"] + [[package]] category = "dev" description = "Python Library for Tom's Obvious, Minimal Language" @@ -462,6 +517,29 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" version = "1.25.7" +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] + +[[package]] +category = "main" +description = "Declarative parsing and validation of HTTP request objects, with built-in support for popular web frameworks, including Flask, Django, Bottle, Tornado, Pyramid, webapp2, Falcon, and aiohttp." +name = "webargs" +optional = false +python-versions = "*" +version = "5.5.2" + +[package.dependencies] +marshmallow = ">=2.15.2" + +[package.extras] +dev = ["pytest", "mock", "webtest (2.0.33)", "Flask (>=0.12.2)", "Django (>=1.11.16)", "bottle (>=0.12.13)", "tornado (>=4.5.2)", "pyramid (>=1.9.1)", "webapp2 (>=3.0.0b1)", "falcon (>=1.4.0,<2.0)", "flake8 (3.7.8)", "pre-commit (>=1.17,<2.0)", "tox", "webtest-aiohttp (2.0.0)", "pytest-aiohttp (>=0.3.0)", "aiohttp (>=3.0.0)", "mypy (0.730)", "flake8-bugbear (19.8.0)"] +docs = ["Sphinx (2.2.0)", "sphinx-issues (1.2.0)", "sphinx-typlog-theme (0.7.3)", "Flask (>=0.12.2)", "Django (>=1.11.16)", "bottle (>=0.12.13)", "tornado (>=4.5.2)", "pyramid (>=1.9.1)", "webapp2 (>=3.0.0b1)", "falcon (>=1.4.0,<2.0)", "aiohttp (>=3.0.0)"] +frameworks = ["Flask (>=0.12.2)", "Django (>=1.11.16)", "bottle (>=0.12.13)", "tornado (>=4.5.2)", "pyramid (>=1.9.1)", "webapp2 (>=3.0.0b1)", "falcon (>=1.4.0,<2.0)", "aiohttp (>=3.0.0)"] +lint = ["flake8 (3.7.8)", "pre-commit (>=1.17,<2.0)", "mypy (0.730)", "flake8-bugbear (19.8.0)"] +tests = ["pytest", "mock", "webtest (2.0.33)", "Flask (>=0.12.2)", "Django (>=1.11.16)", "bottle (>=0.12.13)", "tornado (>=4.5.2)", "pyramid (>=1.9.1)", "webapp2 (>=3.0.0b1)", "falcon (>=1.4.0,<2.0)", "webtest-aiohttp (2.0.0)", "pytest-aiohttp (>=0.3.0)", "aiohttp (>=3.0.0)"] + [[package]] category = "main" description = "The comprehensive WSGI web application library." @@ -470,6 +548,11 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "0.16.0" +[package.extras] +dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"] +termcolor = ["termcolor"] +watchdog = ["watchdog"] + [[package]] category = "dev" description = "Module for decorators, wrappers and monkey patching." @@ -482,57 +565,364 @@ version = "1.11.2" rpi = ["picamera", "RPi.GPIO"] [metadata] -content-hash = "254e455bd1b5a5482f296f8afccf5bd10d109206e8350b9e8ec304576e0d0ae5" +content-hash = "a1af645f32e0c3c01c8183ede8b6d5d649ddf8c8250702faa216e164e9e36335" python-versions = "^3.6" -[metadata.hashes] -alabaster = ["446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", "a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"] -appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"] -astroid = ["71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a", "840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42"] -attrs = ["08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"] -babel = ["af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab", "e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28"] -black = ["817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739", "e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"] -certifi = ["017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", "25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"] -chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] -click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] -colorama = ["7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", "e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"] -docutils = ["6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", "9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", "a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"] -flask = ["13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52", "45eb5a6fd193d6cf7e0cf5d8a5b31f83d5faae0293695626f539a823e93b13f6"] -flask-cors = ["72170423eb4612f0847318afff8c247b38bd516b7737adfc10d1c2cdbb382d16", "f4d97201660e6bbcff2d89d082b5b6d31abee04b1b3003ee073a6fd25ad1d69a"] -idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"] -imagesize = ["3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", "f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"] -isort = ["54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"] -itsdangerous = ["321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"] -jinja2 = ["74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", "9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"] -lazy-object-proxy = ["0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", "194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", "1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", "4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", "48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", "5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", "59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", "8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", "9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", "9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", "97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", "9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", "a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", "a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", "ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", "cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", "d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", "d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", "eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", "efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", "f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"] -markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"] -marshmallow = ["0ba81b6da4ae69eb229b74b3c741ff13fe04fb899824377b1aff5aaa1a9fd46e", "3e53dd9e9358977a3929e45cdbe4a671f9eff53a7d6a23f33ed3eab8c1890d8f"] -mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] -numpy = ["0a7a1dd123aecc9f0076934288ceed7fd9a81ba3919f11a855a7887cbe82a02f", "0c0763787133dfeec19904c22c7e358b231c87ba3206b211652f8cbe1241deb6", "3d52298d0be333583739f1aec9026f3b09fdfe3ddf7c7028cb16d9d2af1cca7e", "43bb4b70585f1c2d153e45323a886839f98af8bfa810f7014b20be714c37c447", "475963c5b9e116c38ad7347e154e5651d05a2286d86455671f5b1eebba5feb76", "64874913367f18eb3013b16123c9fed113962e75d809fca5b78ebfbb73ed93ba", "683828e50c339fc9e68720396f2de14253992c495fdddef77a1e17de55f1decc", "6ca4000c4a6f95a78c33c7dadbb9495c10880be9c89316aa536eac359ab820ae", "75fd817b7061f6378e4659dd792c84c0b60533e867f83e0d1e52d5d8e53df88c", "7d81d784bdbed30137aca242ab307f3e65c8d93f4c7b7d8f322110b2e90177f9", "8d0af8d3664f142414fd5b15cabfd3b6cc3ef242a3c7a7493257025be5a6955f", "9679831005fb16c6df3dd35d17aa31dc0d4d7573d84f0b44cc481490a65c7725", "a8f67ebfae9f575d85fa859b54d3bdecaeece74e3274b0b5c5f804d7ca789fe1", "acbf5c52db4adb366c064d0b7c7899e3e778d89db585feadd23b06b587d64761", "ada4805ed51f5bcaa3a06d3dd94939351869c095e30a2b54264f5a5004b52170", "c7354e8f0eca5c110b7e978034cd86ed98a7a5ffcf69ca97535445a595e07b8e", "e2e9d8c87120ba2c591f60e32736b82b67f72c37ba88a4c23c81b5b8fa49c018", "e467c57121fe1b78a8f68dd9255fbb3bb3f4f7547c6b9e109f31d14569f490c3", "ede47b98de79565fcd7f2decb475e2dcc85ee4097743e551fe26cfc7eb3ff143", "f58913e9227400f1395c7b800503ebfdb0772f1c33ff8cb4d6451c06cabdf316", "fe39f5fd4103ec4ca3cb8600b19216cd1ff316b4990f4c0b6057ad982c0a34d5"] -packaging = ["28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", "d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"] +[metadata.files] +alabaster = [ + {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, + {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, +] +appdirs = [ + {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, + {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, +] +astroid = [ + {file = "astroid-2.3.3-py3-none-any.whl", hash = "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42"}, + {file = "astroid-2.3.3.tar.gz", hash = "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a"}, +] +attrs = [ + {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, + {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, +] +babel = [ + {file = "Babel-2.7.0-py2.py3-none-any.whl", hash = "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab"}, + {file = "Babel-2.7.0.tar.gz", hash = "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28"}, +] +black = [ + {file = "black-18.9b0-py36-none-any.whl", hash = "sha256:817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739"}, + {file = "black-18.9b0.tar.gz", hash = "sha256:e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"}, +] +certifi = [ + {file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"}, + {file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"}, +] +chardet = [ + {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, + {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, +] +click = [ + {file = "Click-7.0-py2.py3-none-any.whl", hash = "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13"}, + {file = "Click-7.0.tar.gz", hash = "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"}, +] +colorama = [ + {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, + {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, +] +docutils = [ + {file = "docutils-0.15.2-py2-none-any.whl", hash = "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827"}, + {file = "docutils-0.15.2-py3-none-any.whl", hash = "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0"}, + {file = "docutils-0.15.2.tar.gz", hash = "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"}, +] +flask = [ + {file = "Flask-1.1.1-py2.py3-none-any.whl", hash = "sha256:45eb5a6fd193d6cf7e0cf5d8a5b31f83d5faae0293695626f539a823e93b13f6"}, + {file = "Flask-1.1.1.tar.gz", hash = "sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52"}, +] +flask-cors = [ + {file = "Flask-Cors-3.0.8.tar.gz", hash = "sha256:72170423eb4612f0847318afff8c247b38bd516b7737adfc10d1c2cdbb382d16"}, + {file = "Flask_Cors-3.0.8-py2.py3-none-any.whl", hash = "sha256:f4d97201660e6bbcff2d89d082b5b6d31abee04b1b3003ee073a6fd25ad1d69a"}, +] +idna = [ + {file = "idna-2.8-py2.py3-none-any.whl", hash = "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"}, + {file = "idna-2.8.tar.gz", hash = "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407"}, +] +imagesize = [ + {file = "imagesize-1.1.0-py2.py3-none-any.whl", hash = "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8"}, + {file = "imagesize-1.1.0.tar.gz", hash = "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"}, +] +isort = [ + {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, + {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, +] +itsdangerous = [ + {file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"}, + {file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"}, +] +jinja2 = [ + {file = "Jinja2-2.10.3-py2.py3-none-any.whl", hash = "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f"}, + {file = "Jinja2-2.10.3.tar.gz", hash = "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"}, +] +lazy-object-proxy = [ + {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, + {file = "lazy_object_proxy-1.4.3-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442"}, + {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win32.whl", hash = "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4"}, + {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win_amd64.whl", hash = "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a"}, + {file = "lazy_object_proxy-1.4.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d"}, + {file = "lazy_object_proxy-1.4.3-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a"}, + {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win32.whl", hash = "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e"}, + {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win_amd64.whl", hash = "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357"}, + {file = "lazy_object_proxy-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50"}, + {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db"}, + {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449"}, + {file = "lazy_object_proxy-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156"}, + {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531"}, + {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb"}, + {file = "lazy_object_proxy-1.4.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08"}, + {file = "lazy_object_proxy-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383"}, + {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142"}, + {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea"}, + {file = "lazy_object_proxy-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62"}, + {file = "lazy_object_proxy-1.4.3-cp38-cp38-win32.whl", hash = "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd"}, + {file = "lazy_object_proxy-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239"}, +] +markupsafe = [ + {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, +] +marshmallow = [ + {file = "marshmallow-3.3.0-py2.py3-none-any.whl", hash = "sha256:3e53dd9e9358977a3929e45cdbe4a671f9eff53a7d6a23f33ed3eab8c1890d8f"}, + {file = "marshmallow-3.3.0.tar.gz", hash = "sha256:0ba81b6da4ae69eb229b74b3c741ff13fe04fb899824377b1aff5aaa1a9fd46e"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +numpy = [ + {file = "numpy-1.17.4-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:ede47b98de79565fcd7f2decb475e2dcc85ee4097743e551fe26cfc7eb3ff143"}, + {file = "numpy-1.17.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43bb4b70585f1c2d153e45323a886839f98af8bfa810f7014b20be714c37c447"}, + {file = "numpy-1.17.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c7354e8f0eca5c110b7e978034cd86ed98a7a5ffcf69ca97535445a595e07b8e"}, + {file = "numpy-1.17.4-cp35-cp35m-win32.whl", hash = "sha256:64874913367f18eb3013b16123c9fed113962e75d809fca5b78ebfbb73ed93ba"}, + {file = "numpy-1.17.4-cp35-cp35m-win_amd64.whl", hash = "sha256:6ca4000c4a6f95a78c33c7dadbb9495c10880be9c89316aa536eac359ab820ae"}, + {file = "numpy-1.17.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:75fd817b7061f6378e4659dd792c84c0b60533e867f83e0d1e52d5d8e53df88c"}, + {file = "numpy-1.17.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:7d81d784bdbed30137aca242ab307f3e65c8d93f4c7b7d8f322110b2e90177f9"}, + {file = "numpy-1.17.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe39f5fd4103ec4ca3cb8600b19216cd1ff316b4990f4c0b6057ad982c0a34d5"}, + {file = "numpy-1.17.4-cp36-cp36m-win32.whl", hash = "sha256:e467c57121fe1b78a8f68dd9255fbb3bb3f4f7547c6b9e109f31d14569f490c3"}, + {file = "numpy-1.17.4-cp36-cp36m-win_amd64.whl", hash = "sha256:8d0af8d3664f142414fd5b15cabfd3b6cc3ef242a3c7a7493257025be5a6955f"}, + {file = "numpy-1.17.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9679831005fb16c6df3dd35d17aa31dc0d4d7573d84f0b44cc481490a65c7725"}, + {file = "numpy-1.17.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:acbf5c52db4adb366c064d0b7c7899e3e778d89db585feadd23b06b587d64761"}, + {file = "numpy-1.17.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:3d52298d0be333583739f1aec9026f3b09fdfe3ddf7c7028cb16d9d2af1cca7e"}, + {file = "numpy-1.17.4-cp37-cp37m-win32.whl", hash = "sha256:475963c5b9e116c38ad7347e154e5651d05a2286d86455671f5b1eebba5feb76"}, + {file = "numpy-1.17.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0c0763787133dfeec19904c22c7e358b231c87ba3206b211652f8cbe1241deb6"}, + {file = "numpy-1.17.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:683828e50c339fc9e68720396f2de14253992c495fdddef77a1e17de55f1decc"}, + {file = "numpy-1.17.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e2e9d8c87120ba2c591f60e32736b82b67f72c37ba88a4c23c81b5b8fa49c018"}, + {file = "numpy-1.17.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a8f67ebfae9f575d85fa859b54d3bdecaeece74e3274b0b5c5f804d7ca789fe1"}, + {file = "numpy-1.17.4-cp38-cp38-win32.whl", hash = "sha256:0a7a1dd123aecc9f0076934288ceed7fd9a81ba3919f11a855a7887cbe82a02f"}, + {file = "numpy-1.17.4-cp38-cp38-win_amd64.whl", hash = "sha256:ada4805ed51f5bcaa3a06d3dd94939351869c095e30a2b54264f5a5004b52170"}, + {file = "numpy-1.17.4.zip", hash = "sha256:f58913e9227400f1395c7b800503ebfdb0772f1c33ff8cb4d6451c06cabdf316"}, +] +packaging = [ + {file = "packaging-19.2-py2.py3-none-any.whl", hash = "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"}, + {file = "packaging-19.2.tar.gz", hash = "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47"}, +] picamera = [] -pillow = ["01a501be4ae05fd714d269cb9c9f145518e58e73faa3f140ddb67fae0c2607b1", "051de330a06c99d6f84bcf582960487835bcae3fc99365185dc2d4f65a390c0e", "07c35919f983c2c593498edcc126ad3a94154184899297cc9d27a6587672cbaa", "0ae5289948c5e0a16574750021bd8be921c27d4e3527800dc9c2c1d2abc81bf7", "0b1efce03619cdbf8bcc61cfae81fcda59249a469f31c6735ea59badd4a6f58a", "0cf0208500df8d0c3cad6383cd98a2d038b0678fd4f777a8f7e442c5faeee81d", "163136e09bd1d6c6c6026b0a662976e86c58b932b964f255ff384ecc8c3cefa3", "18e912a6ccddf28defa196bd2021fe33600cbe5da1aa2f2e2c6df15f720b73d1", "24ec3dea52339a610d34401d2d53d0fb3c7fd08e34b20c95d2ad3973193591f1", "267f8e4c0a1d7e36e97c6a604f5b03ef58e2b81c1becb4fccecddcb37e063cc7", "3273a28734175feebbe4d0a4cde04d4ed20f620b9b506d26f44379d3c72304e1", "39fbd5d62167197318a0371b2a9c699ce261b6800bb493eadde2ba30d868fe8c", "4132c78200372045bb348fcad8d52518c8f5cfc077b1089949381ee4a61f1c6d", "4baab2d2da57b0d9d544a2ce0f461374dd90ccbcf723fe46689aff906d43a964", "4c678e23006798fc8b6f4cef2eaad267d53ff4c1779bd1af8725cc11b72a63f3", "4d4bc2e6bb6861103ea4655d6b6f67af8e5336e7216e20fff3e18ffa95d7a055", "505738076350a337c1740a31646e1de09a164c62c07db3b996abdc0f9d2e50cf", "5233664eadfa342c639b9b9977190d64ad7aca4edc51a966394d7e08e7f38a9f", "52e2e56fc3706d8791761a157115dc8391319720ad60cc32992350fda74b6be2", "5337ac3280312aa065ed0a8ec1e4b6142e9f15c31baed36b5cd964745853243f", "5ccd97e0f01f42b7e35907272f0f8ad2c3660a482d799a0c564c7d50e83604d4", "5d95cb9f6cced2628f3e4de7e795e98b2659dfcc7176ab4a01a8b48c2c2f488f", "634209852cc06c0c1243cc74f8fdc8f7444d866221de51125f7b696d775ec5ca", "75d1f20bd8072eff92c5f457c266a61619a02d03ece56544195c56d41a1a0522", "7eda4c737637af74bac4b23aa82ea6fbb19002552be85f0b89bc27e3a762d239", "801ddaa69659b36abf4694fed5aa9f61d1ecf2daaa6c92541bbbbb775d97b9fe", "825aa6d222ce2c2b90d34a0ea31914e141a85edefc07e17342f1d2fdf121c07c", "87fe838f9dac0597f05f2605c0700b1926f9390c95df6af45d83141e0c514bd9", "9c215442ff8249d41ff58700e91ef61d74f47dfd431a50253e1a1ca9436b0697", "a3d90022f2202bbb14da991f26ca7a30b7e4c62bf0f8bf9825603b22d7e87494", "a631fd36a9823638fe700d9225f9698fb59d049c942d322d4c09544dc2115356", "a6523a23a205be0fe664b6b8747a5c86d55da960d9586db039eec9f5c269c0e6", "a756ecf9f4b9b3ed49a680a649af45a8767ad038de39e6c030919c2f443eb000", "ac036b6a6bac7010c58e643d78c234c2f7dc8bb7e591bd8bc3555cf4b1527c28", "b117287a5bdc81f1bac891187275ec7e829e961b8032c9e5ff38b70fd036c78f", "ba04f57d1715ca5ff74bb7f8a818bf929a204b3b3c2c2826d1e1cc3b1c13398c", "ba6ef2bd62671c7fb9cdb3277414e87a5cd38b86721039ada1464f7452ad30b2", "c8939dba1a37960a502b1a030a4465c46dd2c2bca7adf05fa3af6bea594e720e", "cd878195166723f30865e05d87cbaf9421614501a4bd48792c5ed28f90fd36ca", "cee815cc62d136e96cf76771b9d3eb58e0777ec18ea50de5cfcede8a7c429aa8", "d1722b7aa4b40cf93ac3c80d3edd48bf93b9208241d166a14ad8e7a20ee1d4f3", "d7c1c06246b05529f9984435fc4fa5a545ea26606e7f450bdbe00c153f5aeaad", "db418635ea20528f247203bf131b40636f77c8209a045b89fa3badb89e1fcea0", "e1555d4fda1db8005de72acf2ded1af660febad09b4708430091159e8ae1963e", "e9c8066249c040efdda84793a2a669076f92a301ceabe69202446abb4c5c5ef9", "e9f13711780c981d6eadd6042af40e172548c54b06266a1aabda7de192db0838", "f0e3288b92ca5dbb1649bd00e80ef652a72b657dc94989fa9c348253d179054b", "f227d7e574d050ff3996049e086e1f18c7bd2d067ef24131e50a1d3fe5831fbc", "f62b1aeb5c2ced8babd4fbba9c74cbef9de309f5ed106184b12d9778a3971f15", "f71ff657e63a9b24cac254bb8c9bd3c89c7a1b5e00ee4b3997ca1c18100dac28", "fc9a12aad714af36cf3ad0275a96a733526571e52710319855628f476dcb144e"] -pygments = ["2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b", "98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"] -pylint = ["3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd", "886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4"] -pyparsing = ["20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", "4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a"] -pytz = ["1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", "b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"] -pyyaml = ["3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", "3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf", "40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a", "558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3", "a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1", "aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1", "bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613", "d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04", "d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f", "e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537", "e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531"] -requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] -rope = ["6b728fdc3e98a83446c27a91fc5d56808a004f8beab7a31ab1d7224cecc7d969", "c5c5a6a87f7b1a2095fb311135e2a3d1f194f5ecb96900fdd0a9100881f48aaf", "f0dcf719b63200d492b85535ebe5ea9b29e0d0b8aebeb87fe03fc1a65924fdaf"] -"rpi.gpio" = ["a4210ad63bfe844e43995286de0d3950dfacfa0f3799bb9392770ac54a7d2e47"] -scipy = ["03b1e0775edbe6a4c64effb05fff2ce1429b76d29d754aa5ee2d848b60033351", "09d008237baabf52a5d4f5a6fcf9b3c03408f3f61a69c404472a16861a73917e", "10325f0ffac2400b1ec09537b7e403419dcd25d9fee602a44e8a32119af9079e", "1db9f964ed9c52dc5bd6127f0dd90ac89791daa690a5665cc01eae185912e1ba", "409846be9d6bdcbd78b9e5afe2f64b2da5a923dd7c1cd0615ce589489533fdbb", "4907040f62b91c2e170359c3d36c000af783f0fa1516a83d6c1517cde0af5340", "6c0543f2fdd38dee631fb023c0f31c284a532d205590b393d72009c14847f5b1", "826b9f5fbb7f908a13aa1efd4b7321e36992f5868d5d8311c7b40cf9b11ca0e7", "a7695a378c2ce402405ea37b12c7a338a8755e081869bd6b95858893ceb617ae", "a84c31e8409b420c3ca57fd30c7589378d6fdc8d155d866a7f8e6e80dec6fd06", "adadeeae5500de0da2b9e8dd478520d0a9945b577b2198f2462555e68f58e7ef", "b283a76a83fe463c9587a2c88003f800e08c3929dfbeba833b78260f9c209785", "c19a7389ab3cd712058a8c3c9ffd8d27a57f3d84b9c91a931f542682bb3d269d", "c3bb4bd2aca82fb498247deeac12265921fe231502a6bc6edea3ee7fe6c40a7a", "c5ea60ece0c0c1c849025bfc541b60a6751b491b6f11dd9ef37ab5b8c9041921", "db61a640ca20f237317d27bc658c1fc54c7581ff7f6502d112922dc285bdabee"] -six = ["1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", "30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"] -snowballstemmer = ["209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", "df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"] -sphinx = ["3b16e48e791a322d584489ab28d8800652123d1fbfdd173e2965a31d40bf22d7", "559c1a8ed1365a982f77650720b41114414139a635692a23c2990824d0a84cf2"] -sphinxcontrib-applehelp = ["edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", "fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"] -sphinxcontrib-devhelp = ["6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", "9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"] -sphinxcontrib-htmlhelp = ["4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", "d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7"] -sphinxcontrib-httpdomain = ["1fb5375007d70bf180cdd1c79e741082be7aa2d37ba99efe561e1c2e3f38191e", "ac40b4fba58c76b073b03931c7b8ead611066a6aebccafb34dc19694f4eb6335"] -sphinxcontrib-jsmath = ["2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"] -sphinxcontrib-qthelp = ["513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", "79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f"] -sphinxcontrib-serializinghtml = ["c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", "db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768"] -toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] -typed-ast = ["18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", "262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", "2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", "354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", "4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", "630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", "66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", "71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", "95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", "bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", "cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", "d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", "d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", "d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", "ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"] -urllib3 = ["a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", "f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"] -werkzeug = ["7280924747b5733b246fe23972186c6b348f9ae29724135a6dfc1e53cea433e7", "e5f4a1f98b52b18a93da705a7458e55afb26f32bff83ff5d19189f92462d65c4"] -wrapt = ["565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"] +pillow = [ + {file = "Pillow-5.4.1-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:18e912a6ccddf28defa196bd2021fe33600cbe5da1aa2f2e2c6df15f720b73d1"}, + {file = "Pillow-5.4.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:267f8e4c0a1d7e36e97c6a604f5b03ef58e2b81c1becb4fccecddcb37e063cc7"}, + {file = "Pillow-5.4.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:051de330a06c99d6f84bcf582960487835bcae3fc99365185dc2d4f65a390c0e"}, + {file = "Pillow-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:825aa6d222ce2c2b90d34a0ea31914e141a85edefc07e17342f1d2fdf121c07c"}, + {file = "Pillow-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:5d95cb9f6cced2628f3e4de7e795e98b2659dfcc7176ab4a01a8b48c2c2f488f"}, + {file = "Pillow-5.4.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ba04f57d1715ca5ff74bb7f8a818bf929a204b3b3c2c2826d1e1cc3b1c13398c"}, + {file = "Pillow-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:f227d7e574d050ff3996049e086e1f18c7bd2d067ef24131e50a1d3fe5831fbc"}, + {file = "Pillow-5.4.1-cp34-cp34m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:3273a28734175feebbe4d0a4cde04d4ed20f620b9b506d26f44379d3c72304e1"}, + {file = "Pillow-5.4.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:cee815cc62d136e96cf76771b9d3eb58e0777ec18ea50de5cfcede8a7c429aa8"}, + {file = "Pillow-5.4.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:4d4bc2e6bb6861103ea4655d6b6f67af8e5336e7216e20fff3e18ffa95d7a055"}, + {file = "Pillow-5.4.1-cp34-cp34m-win32.whl", hash = "sha256:a6523a23a205be0fe664b6b8747a5c86d55da960d9586db039eec9f5c269c0e6"}, + {file = "Pillow-5.4.1-cp34-cp34m-win_amd64.whl", hash = "sha256:505738076350a337c1740a31646e1de09a164c62c07db3b996abdc0f9d2e50cf"}, + {file = "Pillow-5.4.1-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:7eda4c737637af74bac4b23aa82ea6fbb19002552be85f0b89bc27e3a762d239"}, + {file = "Pillow-5.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:163136e09bd1d6c6c6026b0a662976e86c58b932b964f255ff384ecc8c3cefa3"}, + {file = "Pillow-5.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9c215442ff8249d41ff58700e91ef61d74f47dfd431a50253e1a1ca9436b0697"}, + {file = "Pillow-5.4.1-cp35-cp35m-win32.whl", hash = "sha256:0ae5289948c5e0a16574750021bd8be921c27d4e3527800dc9c2c1d2abc81bf7"}, + {file = "Pillow-5.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:801ddaa69659b36abf4694fed5aa9f61d1ecf2daaa6c92541bbbbb775d97b9fe"}, + {file = "Pillow-5.4.1-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:cd878195166723f30865e05d87cbaf9421614501a4bd48792c5ed28f90fd36ca"}, + {file = "Pillow-5.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fc9a12aad714af36cf3ad0275a96a733526571e52710319855628f476dcb144e"}, + {file = "Pillow-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d7c1c06246b05529f9984435fc4fa5a545ea26606e7f450bdbe00c153f5aeaad"}, + {file = "Pillow-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:0b1efce03619cdbf8bcc61cfae81fcda59249a469f31c6735ea59badd4a6f58a"}, + {file = "Pillow-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:a631fd36a9823638fe700d9225f9698fb59d049c942d322d4c09544dc2115356"}, + {file = "Pillow-5.4.1-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:24ec3dea52339a610d34401d2d53d0fb3c7fd08e34b20c95d2ad3973193591f1"}, + {file = "Pillow-5.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:e9c8066249c040efdda84793a2a669076f92a301ceabe69202446abb4c5c5ef9"}, + {file = "Pillow-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4c678e23006798fc8b6f4cef2eaad267d53ff4c1779bd1af8725cc11b72a63f3"}, + {file = "Pillow-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:b117287a5bdc81f1bac891187275ec7e829e961b8032c9e5ff38b70fd036c78f"}, + {file = "Pillow-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:d1722b7aa4b40cf93ac3c80d3edd48bf93b9208241d166a14ad8e7a20ee1d4f3"}, + {file = "Pillow-5.4.1-pp260-pypy_41-win32.whl", hash = "sha256:a3d90022f2202bbb14da991f26ca7a30b7e4c62bf0f8bf9825603b22d7e87494"}, + {file = "Pillow-5.4.1-pp360-pp360-win32.whl", hash = "sha256:a756ecf9f4b9b3ed49a680a649af45a8767ad038de39e6c030919c2f443eb000"}, + {file = "Pillow-5.4.1-py2.7-macosx-10.13-x86_64.egg", hash = "sha256:634209852cc06c0c1243cc74f8fdc8f7444d866221de51125f7b696d775ec5ca"}, + {file = "Pillow-5.4.1-py2.7-win-amd64.egg", hash = "sha256:0cf0208500df8d0c3cad6383cd98a2d038b0678fd4f777a8f7e442c5faeee81d"}, + {file = "Pillow-5.4.1-py2.7-win32.egg", hash = "sha256:f71ff657e63a9b24cac254bb8c9bd3c89c7a1b5e00ee4b3997ca1c18100dac28"}, + {file = "Pillow-5.4.1-py3.4-win-amd64.egg", hash = "sha256:01a501be4ae05fd714d269cb9c9f145518e58e73faa3f140ddb67fae0c2607b1"}, + {file = "Pillow-5.4.1-py3.4-win32.egg", hash = "sha256:4baab2d2da57b0d9d544a2ce0f461374dd90ccbcf723fe46689aff906d43a964"}, + {file = "Pillow-5.4.1-py3.5-win-amd64.egg", hash = "sha256:f62b1aeb5c2ced8babd4fbba9c74cbef9de309f5ed106184b12d9778a3971f15"}, + {file = "Pillow-5.4.1-py3.5-win32.egg", hash = "sha256:e9f13711780c981d6eadd6042af40e172548c54b06266a1aabda7de192db0838"}, + {file = "Pillow-5.4.1-py3.6-win-amd64.egg", hash = "sha256:07c35919f983c2c593498edcc126ad3a94154184899297cc9d27a6587672cbaa"}, + {file = "Pillow-5.4.1-py3.6-win32.egg", hash = "sha256:87fe838f9dac0597f05f2605c0700b1926f9390c95df6af45d83141e0c514bd9"}, + {file = "Pillow-5.4.1-py3.7-win-amd64.egg", hash = "sha256:c8939dba1a37960a502b1a030a4465c46dd2c2bca7adf05fa3af6bea594e720e"}, + {file = "Pillow-5.4.1-py3.7-win32.egg", hash = "sha256:5337ac3280312aa065ed0a8ec1e4b6142e9f15c31baed36b5cd964745853243f"}, + {file = "Pillow-5.4.1.tar.gz", hash = "sha256:5233664eadfa342c639b9b9977190d64ad7aca4edc51a966394d7e08e7f38a9f"}, + {file = "Pillow-5.4.1.win-amd64-py2.7.exe", hash = "sha256:e1555d4fda1db8005de72acf2ded1af660febad09b4708430091159e8ae1963e"}, + {file = "Pillow-5.4.1.win-amd64-py3.4.exe", hash = "sha256:52e2e56fc3706d8791761a157115dc8391319720ad60cc32992350fda74b6be2"}, + {file = "Pillow-5.4.1.win-amd64-py3.5.exe", hash = "sha256:ba6ef2bd62671c7fb9cdb3277414e87a5cd38b86721039ada1464f7452ad30b2"}, + {file = "Pillow-5.4.1.win-amd64-py3.6.exe", hash = "sha256:39fbd5d62167197318a0371b2a9c699ce261b6800bb493eadde2ba30d868fe8c"}, + {file = "Pillow-5.4.1.win-amd64-py3.7.exe", hash = "sha256:5ccd97e0f01f42b7e35907272f0f8ad2c3660a482d799a0c564c7d50e83604d4"}, + {file = "Pillow-5.4.1.win32-py2.7.exe", hash = "sha256:db418635ea20528f247203bf131b40636f77c8209a045b89fa3badb89e1fcea0"}, + {file = "Pillow-5.4.1.win32-py3.4.exe", hash = "sha256:ac036b6a6bac7010c58e643d78c234c2f7dc8bb7e591bd8bc3555cf4b1527c28"}, + {file = "Pillow-5.4.1.win32-py3.5.exe", hash = "sha256:f0e3288b92ca5dbb1649bd00e80ef652a72b657dc94989fa9c348253d179054b"}, + {file = "Pillow-5.4.1.win32-py3.6.exe", hash = "sha256:4132c78200372045bb348fcad8d52518c8f5cfc077b1089949381ee4a61f1c6d"}, + {file = "Pillow-5.4.1.win32-py3.7.exe", hash = "sha256:75d1f20bd8072eff92c5f457c266a61619a02d03ece56544195c56d41a1a0522"}, +] +pygments = [ + {file = "Pygments-2.5.2-py2.py3-none-any.whl", hash = "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b"}, + {file = "Pygments-2.5.2.tar.gz", hash = "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"}, +] +pylint = [ + {file = "pylint-2.4.4-py3-none-any.whl", hash = "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4"}, + {file = "pylint-2.4.4.tar.gz", hash = "sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd"}, +] +pyparsing = [ + {file = "pyparsing-2.4.5-py2.py3-none-any.whl", hash = "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f"}, + {file = "pyparsing-2.4.5.tar.gz", hash = "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a"}, +] +pytz = [ + {file = "pytz-2019.3-py2.py3-none-any.whl", hash = "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d"}, + {file = "pytz-2019.3.tar.gz", hash = "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"}, +] +pyyaml = [ + {file = "PyYAML-3.13-cp27-cp27m-win32.whl", hash = "sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f"}, + {file = "PyYAML-3.13-cp27-cp27m-win_amd64.whl", hash = "sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537"}, + {file = "PyYAML-3.13-cp34-cp34m-win32.whl", hash = "sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3"}, + {file = "PyYAML-3.13-cp34-cp34m-win_amd64.whl", hash = "sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04"}, + {file = "PyYAML-3.13-cp35-cp35m-win32.whl", hash = "sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1"}, + {file = "PyYAML-3.13-cp35-cp35m-win_amd64.whl", hash = "sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613"}, + {file = "PyYAML-3.13-cp36-cp36m-win32.whl", hash = "sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a"}, + {file = "PyYAML-3.13-cp36-cp36m-win_amd64.whl", hash = "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b"}, + {file = "PyYAML-3.13-cp37-cp37m-win32.whl", hash = "sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531"}, + {file = "PyYAML-3.13-cp37-cp37m-win_amd64.whl", hash = "sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1"}, + {file = "PyYAML-3.13.tar.gz", hash = "sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf"}, +] +requests = [ + {file = "requests-2.22.0-py2.py3-none-any.whl", hash = "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"}, + {file = "requests-2.22.0.tar.gz", hash = "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4"}, +] +rope = [ + {file = "rope-0.14.0-py2-none-any.whl", hash = "sha256:6b728fdc3e98a83446c27a91fc5d56808a004f8beab7a31ab1d7224cecc7d969"}, + {file = "rope-0.14.0-py3-none-any.whl", hash = "sha256:f0dcf719b63200d492b85535ebe5ea9b29e0d0b8aebeb87fe03fc1a65924fdaf"}, + {file = "rope-0.14.0.tar.gz", hash = "sha256:c5c5a6a87f7b1a2095fb311135e2a3d1f194f5ecb96900fdd0a9100881f48aaf"}, +] +"rpi.gpio" = [ + {file = "RPi.GPIO-0.6.5.tar.gz", hash = "sha256:a4210ad63bfe844e43995286de0d3950dfacfa0f3799bb9392770ac54a7d2e47"}, +] +scipy = [ + {file = "scipy-1.3.0-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:4907040f62b91c2e170359c3d36c000af783f0fa1516a83d6c1517cde0af5340"}, + {file = "scipy-1.3.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1db9f964ed9c52dc5bd6127f0dd90ac89791daa690a5665cc01eae185912e1ba"}, + {file = "scipy-1.3.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:adadeeae5500de0da2b9e8dd478520d0a9945b577b2198f2462555e68f58e7ef"}, + {file = "scipy-1.3.0-cp35-cp35m-win32.whl", hash = "sha256:03b1e0775edbe6a4c64effb05fff2ce1429b76d29d754aa5ee2d848b60033351"}, + {file = "scipy-1.3.0-cp35-cp35m-win_amd64.whl", hash = "sha256:a7695a378c2ce402405ea37b12c7a338a8755e081869bd6b95858893ceb617ae"}, + {file = "scipy-1.3.0-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:826b9f5fbb7f908a13aa1efd4b7321e36992f5868d5d8311c7b40cf9b11ca0e7"}, + {file = "scipy-1.3.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b283a76a83fe463c9587a2c88003f800e08c3929dfbeba833b78260f9c209785"}, + {file = "scipy-1.3.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:db61a640ca20f237317d27bc658c1fc54c7581ff7f6502d112922dc285bdabee"}, + {file = "scipy-1.3.0-cp36-cp36m-win32.whl", hash = "sha256:409846be9d6bdcbd78b9e5afe2f64b2da5a923dd7c1cd0615ce589489533fdbb"}, + {file = "scipy-1.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c19a7389ab3cd712058a8c3c9ffd8d27a57f3d84b9c91a931f542682bb3d269d"}, + {file = "scipy-1.3.0-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:09d008237baabf52a5d4f5a6fcf9b3c03408f3f61a69c404472a16861a73917e"}, + {file = "scipy-1.3.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:a84c31e8409b420c3ca57fd30c7589378d6fdc8d155d866a7f8e6e80dec6fd06"}, + {file = "scipy-1.3.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c5ea60ece0c0c1c849025bfc541b60a6751b491b6f11dd9ef37ab5b8c9041921"}, + {file = "scipy-1.3.0-cp37-cp37m-win32.whl", hash = "sha256:6c0543f2fdd38dee631fb023c0f31c284a532d205590b393d72009c14847f5b1"}, + {file = "scipy-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:10325f0ffac2400b1ec09537b7e403419dcd25d9fee602a44e8a32119af9079e"}, + {file = "scipy-1.3.0.tar.gz", hash = "sha256:c3bb4bd2aca82fb498247deeac12265921fe231502a6bc6edea3ee7fe6c40a7a"}, +] +six = [ + {file = "six-1.13.0-py2.py3-none-any.whl", hash = "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd"}, + {file = "six-1.13.0.tar.gz", hash = "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"}, +] +snowballstemmer = [ + {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, + {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, +] +sphinx = [ + {file = "Sphinx-2.2.2-py3-none-any.whl", hash = "sha256:3b16e48e791a322d584489ab28d8800652123d1fbfdd173e2965a31d40bf22d7"}, + {file = "Sphinx-2.2.2.tar.gz", hash = "sha256:559c1a8ed1365a982f77650720b41114414139a635692a23c2990824d0a84cf2"}, +] +sphinxcontrib-applehelp = [ + {file = "sphinxcontrib-applehelp-1.0.1.tar.gz", hash = "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897"}, + {file = "sphinxcontrib_applehelp-1.0.1-py2.py3-none-any.whl", hash = "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"}, +] +sphinxcontrib-devhelp = [ + {file = "sphinxcontrib-devhelp-1.0.1.tar.gz", hash = "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34"}, + {file = "sphinxcontrib_devhelp-1.0.1-py2.py3-none-any.whl", hash = "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"}, +] +sphinxcontrib-htmlhelp = [ + {file = "sphinxcontrib-htmlhelp-1.0.2.tar.gz", hash = "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422"}, + {file = "sphinxcontrib_htmlhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7"}, +] +sphinxcontrib-httpdomain = [ + {file = "sphinxcontrib-httpdomain-1.7.0.tar.gz", hash = "sha256:ac40b4fba58c76b073b03931c7b8ead611066a6aebccafb34dc19694f4eb6335"}, + {file = "sphinxcontrib_httpdomain-1.7.0-py2.py3-none-any.whl", hash = "sha256:1fb5375007d70bf180cdd1c79e741082be7aa2d37ba99efe561e1c2e3f38191e"}, +] +sphinxcontrib-jsmath = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] +sphinxcontrib-qthelp = [ + {file = "sphinxcontrib-qthelp-1.0.2.tar.gz", hash = "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f"}, + {file = "sphinxcontrib_qthelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20"}, +] +sphinxcontrib-serializinghtml = [ + {file = "sphinxcontrib-serializinghtml-1.1.3.tar.gz", hash = "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227"}, + {file = "sphinxcontrib_serializinghtml-1.1.3-py2.py3-none-any.whl", hash = "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768"}, +] +toml = [ + {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"}, + {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"}, + {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"}, +] +typed-ast = [ + {file = "typed_ast-1.4.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e"}, + {file = "typed_ast-1.4.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b"}, + {file = "typed_ast-1.4.0-cp35-cp35m-win32.whl", hash = "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4"}, + {file = "typed_ast-1.4.0-cp35-cp35m-win_amd64.whl", hash = "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"}, + {file = "typed_ast-1.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631"}, + {file = "typed_ast-1.4.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233"}, + {file = "typed_ast-1.4.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1"}, + {file = "typed_ast-1.4.0-cp36-cp36m-win32.whl", hash = "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a"}, + {file = "typed_ast-1.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c"}, + {file = "typed_ast-1.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a"}, + {file = "typed_ast-1.4.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e"}, + {file = "typed_ast-1.4.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d"}, + {file = "typed_ast-1.4.0-cp37-cp37m-win32.whl", hash = "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36"}, + {file = "typed_ast-1.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0"}, + {file = "typed_ast-1.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66"}, + {file = "typed_ast-1.4.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2"}, + {file = "typed_ast-1.4.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47"}, + {file = "typed_ast-1.4.0-cp38-cp38-win32.whl", hash = "sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161"}, + {file = "typed_ast-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e"}, + {file = "typed_ast-1.4.0.tar.gz", hash = "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34"}, +] +urllib3 = [ + {file = "urllib3-1.25.7-py2.py3-none-any.whl", hash = "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293"}, + {file = "urllib3-1.25.7.tar.gz", hash = "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"}, +] +webargs = [ + {file = "webargs-5.5.2-py2-none-any.whl", hash = "sha256:3f9dc15de183d356c9a0acc159c100ea0506c0c240c1e6f1d8b308c5fed4dbbd"}, + {file = "webargs-5.5.2-py3-none-any.whl", hash = "sha256:fa4ad3ad9b38bedd26c619264fdc50d7ae014b49186736bca851e5b5228f2a1b"}, + {file = "webargs-5.5.2.tar.gz", hash = "sha256:3beca296598067cec24a0b6f91c0afcc19b6e3c4d84ab026b931669628bb47b4"}, +] +werkzeug = [ + {file = "Werkzeug-0.16.0-py2.py3-none-any.whl", hash = "sha256:e5f4a1f98b52b18a93da705a7458e55afb26f32bff83ff5d19189f92462d65c4"}, + {file = "Werkzeug-0.16.0.tar.gz", hash = "sha256:7280924747b5733b246fe23972186c6b348f9ae29724135a6dfc1e53cea433e7"}, +] +wrapt = [ + {file = "wrapt-1.11.2.tar.gz", hash = "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"}, +] From 2574e38b47fe76c677f52d552f0300b10834ead4 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sat, 21 Dec 2019 17:38:29 +0000 Subject: [PATCH 030/122] Tidied up --- .../api/v2/views/captures.py | 22 ------------------- .../api/v2/views/streams.py | 14 ------------ 2 files changed, 36 deletions(-) diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index a8203012..3cc5fb85 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -209,25 +209,3 @@ class CaptureMetadata(Resource): capture_obj.put_metadata(data_dict) return jsonify(capture_obj.metadata) - - -def add_captures_to_labthing(labthing, prefix=""): - """ - Add all capture resources to a labthing - """ - labthing.add_resource(CaptureList, f"{prefix}/captures", endpoint="CaptureList") - labthing.register_property(CaptureList) - labthing.add_resource( - CaptureResource, f"{prefix}/captures/", endpoint="CaptureResource" - ) - labthing.add_resource( - CaptureDownload, - f"{prefix}/captures//download/", - endpoint="CaptureDownload", - ) - labthing.add_resource( - CaptureTags, f"{prefix}/captures//tags", endpoint="CaptureTags" - ) - labthing.add_resource( - CaptureMetadata, f"{prefix}/captures//metadata", endpoint="CaptureMetadata" - ) diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index fe22c959..962b8cda 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -46,17 +46,3 @@ class SnapshotStream(Resource): microscope.camera.start_worker() return Response(microscope.camera.get_frame(), mimetype="image/jpeg") - - -def add_streams_to_labthing(labthing, prefix=""): - """ - Add all stream resources to a labthing - """ - labthing.add_resource( - MjpegStream, f"{prefix}/streams/mjpeg", endpoint="MjpegStream" - ) - labthing.register_property(MjpegStream) - labthing.add_resource( - SnapshotStream, f"{prefix}/streams/snapshot", endpoint="SnapshotStream" - ) - labthing.register_property(SnapshotStream) From c85bdcc91dda7db33701a6bb2279c4a93f0831f6 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sat, 21 Dec 2019 17:40:04 +0000 Subject: [PATCH 031/122] Blackened --- openflexure_microscope/api/app.py | 13 +++--------- openflexure_microscope/api/utilities.py | 2 +- .../api/v2/views/actions/camera.py | 2 ++ .../api/v2/views/captures.py | 21 +++++++++++++------ .../common/flask_labthings/decorators.py | 1 + .../common/flask_labthings/fields.py | 8 +++++-- .../common/flask_labthings/labthing.py | 13 +++++++++--- .../common/flask_labthings/plugins.py | 5 +---- .../common/flask_labthings/views/plugins.py | 6 +++++- .../common/flask_labthings/views/tasks.py | 8 ++++--- 10 files changed, 49 insertions(+), 30 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 461f3a71..937c610e 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -82,17 +82,10 @@ for plugin in find_plugins(USER_PLUGINS_PATH): # Attach captures resources labthing.add_resource(views.CaptureList, f"/captures") labthing.register_property(views.CaptureList) -labthing.add_resource( - views.CaptureResource, f"/captures/" -) -labthing.add_resource( - views.CaptureDownload, - f"/captures//download/", -) +labthing.add_resource(views.CaptureResource, f"/captures/") +labthing.add_resource(views.CaptureDownload, f"/captures//download/") labthing.add_resource(views.CaptureTags, f"/captures//tags") -labthing.add_resource( - views.CaptureMetadata, f"/captures//metadata" -) +labthing.add_resource(views.CaptureMetadata, f"/captures//metadata") # Attach settings and status resources labthing.add_resource(views.SettingsProperty, f"/settings") diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index 98232ecb..2045ba0f 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -124,4 +124,4 @@ def init_default_plugins(plugin_path): _DEFAULT_PLUGIN_INIT = """from openflexure_microscope.plugins.v2.autofocus import autofocus_plugin_v2 from openflexure_microscope.plugins.v2.scan import scan_plugin_v2 -__plugins__ = [autofocus_plugin_v2, scan_plugin_v2]""" \ No newline at end of file +__plugins__ = [autofocus_plugin_v2, scan_plugin_v2]""" diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index ab0b95e5..fe2a707d 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -61,6 +61,7 @@ class GPUPreviewStartAPI(Resource): Optional "window" parameter can be passed to control the position and size of the preview window, in the format ``[x, y, width, height]``. """ + def post(self): microscope = find_device("openflexure_microscope") payload = JsonResponse(request) @@ -84,6 +85,7 @@ class GPUPreviewStopAPI(Resource): """ Start the onboard GPU preview. """ + def post(self): microscope = find_device("openflexure_microscope") microscope.camera.stop_preview() diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 3cc5fb85..80afdc24 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -6,7 +6,9 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse from openflexure_microscope.common.flask_labthings.schema import Schema from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.common.flask_labthings.resource import Resource -from openflexure_microscope.common.flask_labthings.utilities import description_from_view +from openflexure_microscope.common.flask_labthings.utilities import ( + description_from_view, +) from openflexure_microscope.common.flask_labthings.decorators import marshal_with from openflexure_microscope.common.flask_labthings.find import find_device @@ -30,24 +32,27 @@ class CaptureSchema(Schema): "self": { "href": url_for(CaptureResource.endpoint, id=data.id, _external=True), "mimetype": "application/json", - **description_from_view(CaptureResource) + **description_from_view(CaptureResource), }, "tags": { "href": url_for(CaptureTags.endpoint, id=data.id, _external=True), "mimetype": "application/json", - **description_from_view(CaptureTags) + **description_from_view(CaptureTags), }, "metadata": { "href": url_for(CaptureMetadata.endpoint, id=data.id, _external=True), "mimetype": "application/json", - **description_from_view(CaptureMetadata) + **description_from_view(CaptureMetadata), }, "download": { "href": url_for( - CaptureDownload.endpoint, id=data.id, filename=data.filename, _external=True + CaptureDownload.endpoint, + id=data.id, + filename=data.filename, + _external=True, ), "mimetype": "image/jpeg", - **description_from_view(CaptureDownload) + **description_from_view(CaptureDownload), }, } return data @@ -61,6 +66,7 @@ class CaptureList(Resource): """ List all image captures """ + @marshal_with(CaptureSchema(many=True)) def get(self): microscope = find_device("openflexure_microscope") @@ -99,6 +105,7 @@ class CaptureDownload(Resource): """ Image data for a single image capture """ + def get(self, id, filename): microscope = find_device("openflexure_microscope") @@ -134,6 +141,7 @@ class CaptureTags(Resource): """ Tags associated with a single image capture """ + def get(self, id): microscope = find_device("openflexure_microscope") @@ -184,6 +192,7 @@ class CaptureMetadata(Resource): """ All metadata associated with a single image capture """ + def get(self, id): microscope = find_device("openflexure_microscope") capture_obj = microscope.camera.image_from_id(id) diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index e911f887..1ffeebed 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -41,4 +41,5 @@ class marshal_with(object): return make_response(self.schema.jsonify(data), code, headers) else: return make_response(self.schema.jsonify(resp)) + return wrapper diff --git a/openflexure_microscope/common/flask_labthings/fields.py b/openflexure_microscope/common/flask_labthings/fields.py index 13ea7da9..32dbfb18 100644 --- a/openflexure_microscope/common/flask_labthings/fields.py +++ b/openflexure_microscope/common/flask_labthings/fields.py @@ -72,7 +72,9 @@ class URLFor(Field): self.view_class = None self.endpoint = endpoint else: - raise RuntimeError(f"Endpoint {endpoint} is not a valid Flask view or endpoint string.") + raise RuntimeError( + f"Endpoint {endpoint} is not a valid Flask view or endpoint string." + ) self.params = kwargs Field.__init__(self, **kwargs) @@ -85,7 +87,9 @@ class URLFor(Field): 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.") + raise RuntimeError( + f"Resource {self.endpoint} has not been added to a LabThing application. Unable to generate URL." + ) # Generate URL for param_values = {} diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index c5f4da30..c3662192 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -11,7 +11,14 @@ from . import EXTENSION_NAME class LabThing(object): - def __init__(self, app=None, prefix: str = "", title: str = "", description: str = "", handle_errors: bool = True): + def __init__( + self, + app=None, + prefix: str = "", + title: str = "", + description: str = "", + handle_errors: bool = True, + ): self.app = app self.devices = {} @@ -219,7 +226,7 @@ class LabThing(object): if self.blueprint: if endpoint.startswith(self.blueprint.name): - endpoint = endpoint.split(self.blueprint.name + '.', 1)[-1] + endpoint = endpoint.split(self.blueprint.name + ".", 1)[-1] else: return False return endpoint in self.endpoints @@ -254,4 +261,4 @@ class LabThing(object): return jsonify(td) - # TODO: Add a nicer root resource like the old self-documenting system \ No newline at end of file + # TODO: Add a nicer root resource like the old self-documenting system diff --git a/openflexure_microscope/common/flask_labthings/plugins.py b/openflexure_microscope/common/flask_labthings/plugins.py index 05a431a1..b39ee9b7 100644 --- a/openflexure_microscope/common/flask_labthings/plugins.py +++ b/openflexure_microscope/common/flask_labthings/plugins.py @@ -5,10 +5,7 @@ import copy from importlib import util import sys -from openflexure_microscope.utilities import ( - camel_to_snake, - snake_to_spine, -) +from openflexure_microscope.utilities import camel_to_snake, snake_to_spine class BasePlugin: diff --git a/openflexure_microscope/common/flask_labthings/views/plugins.py b/openflexure_microscope/common/flask_labthings/views/plugins.py index eb6b27b0..35d01b50 100644 --- a/openflexure_microscope/common/flask_labthings/views/plugins.py +++ b/openflexure_microscope/common/flask_labthings/views/plugins.py @@ -36,7 +36,11 @@ def plugins_representation(plugin_dict): } for view_id, view_data in plugin.views.items(): - uri = url_for(f"PluginListResource", _external=True) + "/" + view_data["rule"][1:] + 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"])} diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 8669559f..89fdfe1f 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -4,7 +4,9 @@ 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 +from openflexure_microscope.common.flask_labthings.utilities import ( + description_from_view, +) from marshmallow import pre_dump @@ -52,8 +54,8 @@ class TaskSchema(Schema): "self": { "href": url_for(TaskResource.endpoint, id=data.id, _external=True), "mimetype": "application/json", - **description_from_view(TaskResource) - }, + **description_from_view(TaskResource), + } } return data From 059360ac76135574accb9da447736a2ef04a51da Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sat, 21 Dec 2019 17:44:52 +0000 Subject: [PATCH 032/122] Add webargs dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index dd1da294..c51dc73e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ scipy = "1.3.0" # Exact version until we can guarantee a wheel picamera = { git = "https://github.com/rwb27/picamera.git", branch = "lens-shading", optional = true } # Lens shading requires >1.14 or RWB fork, lens-shading branch. "RPi.GPIO" = { version = "^0.6.5", optional = true } marshmallow = "^3.3" +webargs = "^5.5.2" [tool.poetry.extras] rpi = ["picamera", "RPi.GPIO"] From 2b90829020283e8b3041f61f75b25e8db011cd21 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Sat, 28 Dec 2019 18:36:46 +0000 Subject: [PATCH 033/122] Replace uuid().hex with just uuid() --- openflexure_microscope/camera/capture.py | 2 +- openflexure_microscope/common/labthings_core/tasks/thread.py | 2 +- openflexure_microscope/microscope.py | 2 +- openflexure_microscope/plugins/default/scan/plugin.py | 4 ++-- openflexure_microscope/plugins/default/zip_builder/plugin.py | 2 +- openflexure_microscope/plugins/v2/scan.py | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/camera/capture.py index e45dde80..20ec71da 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -129,7 +129,7 @@ class CaptureObject(object): """Create a new StreamObject, to manage capture data.""" # Store a nice ID - self.id = uuid.uuid4().hex #: str: Unique capture ID + self.id = uuid.uuid4() #: str: Unique capture ID logging.debug("Created StreamObject {}".format(self.id)) self.timestring = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") diff --git a/openflexure_microscope/common/labthings_core/tasks/thread.py b/openflexure_microscope/common/labthings_core/tasks/thread.py index fbe97d85..d3b06c34 100644 --- a/openflexure_microscope/common/labthings_core/tasks/thread.py +++ b/openflexure_microscope/common/labthings_core/tasks/thread.py @@ -26,7 +26,7 @@ class TaskThread(threading.Thread): ) # A UUID for the TaskThread (not the same as the threading.Thread ident) - self._ID = uuid.uuid4().hex # Task ID + self._ID = uuid.uuid4() # Task ID # Make _target, _args, and _kwargs available to the subclass self._target = target diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index de9cf00e..2624d658 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -33,7 +33,7 @@ class Microscope: def __init__(self): # Initial attributes - self.id = uuid.uuid4().hex + self.id = uuid.uuid4() self.name = self.id self.fov = [0, 0] self.camera = None diff --git a/openflexure_microscope/plugins/default/scan/plugin.py b/openflexure_microscope/plugins/default/scan/plugin.py index ac92fc68..0863ff51 100644 --- a/openflexure_microscope/plugins/default/scan/plugin.py +++ b/openflexure_microscope/plugins/default/scan/plugin.py @@ -134,7 +134,7 @@ class ScanPlugin(MicroscopePlugin): basename = generate_basename() # Generate a stack ID - scan_id = uuid.uuid4().hex + scan_id = uuid.uuid4() # Store initial position initial_position = self.microscope.stage.position @@ -288,7 +288,7 @@ class ScanPlugin(MicroscopePlugin): # Generate a stack ID if not scan_id: - scan_id = uuid.uuid4().hex + scan_id = uuid.uuid4() # Add scan metadata if not "time" in metadata: diff --git a/openflexure_microscope/plugins/default/zip_builder/plugin.py b/openflexure_microscope/plugins/default/zip_builder/plugin.py index d6b55a22..545f6a1f 100644 --- a/openflexure_microscope/plugins/default/zip_builder/plugin.py +++ b/openflexure_microscope/plugins/default/zip_builder/plugin.py @@ -123,7 +123,7 @@ class ZipBuilderPlugin(MicroscopePlugin): # Update task progress update_task_progress(int((index / n_files) * 100)) - session_id = uuid.uuid4().hex + session_id = uuid.uuid4() # self.session_zips[session_id] = fp self.session_zips[session_id] = { "id": session_id, diff --git a/openflexure_microscope/plugins/v2/scan.py b/openflexure_microscope/plugins/v2/scan.py index a214ee5a..b75aaa70 100644 --- a/openflexure_microscope/plugins/v2/scan.py +++ b/openflexure_microscope/plugins/v2/scan.py @@ -146,7 +146,7 @@ def tile( basename = generate_basename() # Generate a stack ID - scan_id = uuid.uuid4().hex + scan_id = uuid.uuid4() # Store initial position initial_position = microscope.stage.position @@ -296,7 +296,7 @@ def stack( # Generate a stack ID if not scan_id: - scan_id = uuid.uuid4().hex + scan_id = uuid.uuid4() # Add scan metadata if not "time" in metadata: From e4c6e892fa45e2d854dbbb7fb904ab46b3bfb704 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Sat, 28 Dec 2019 18:52:52 +0000 Subject: [PATCH 034/122] Fixed plugin list endpoint --- openflexure_microscope/common/flask_labthings/views/plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/common/flask_labthings/views/plugins.py b/openflexure_microscope/common/flask_labthings/views/plugins.py index 35d01b50..5f96c5f9 100644 --- a/openflexure_microscope/common/flask_labthings/views/plugins.py +++ b/openflexure_microscope/common/flask_labthings/views/plugins.py @@ -37,7 +37,7 @@ def plugins_representation(plugin_dict): for view_id, view_data in plugin.views.items(): uri = ( - url_for(f"PluginListResource", _external=True) + url_for(f"pluginlistresource", _external=True) + "/" + view_data["rule"][1:] ) From 419cc411b7a7ae15a1f028b095e6a05214e3b1c0 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Sat, 28 Dec 2019 18:53:10 +0000 Subject: [PATCH 035/122] Move new default plugins to API submodule --- .../{plugins/v2 => api/default_plugins}/__init__.py | 0 .../{plugins/v2 => api/default_plugins}/autofocus.py | 0 .../{plugins/v2 => api/default_plugins}/scan.py | 0 openflexure_microscope/api/utilities.py | 8 +++++--- 4 files changed, 5 insertions(+), 3 deletions(-) rename openflexure_microscope/{plugins/v2 => api/default_plugins}/__init__.py (100%) rename openflexure_microscope/{plugins/v2 => api/default_plugins}/autofocus.py (100%) rename openflexure_microscope/{plugins/v2 => api/default_plugins}/scan.py (100%) diff --git a/openflexure_microscope/plugins/v2/__init__.py b/openflexure_microscope/api/default_plugins/__init__.py similarity index 100% rename from openflexure_microscope/plugins/v2/__init__.py rename to openflexure_microscope/api/default_plugins/__init__.py diff --git a/openflexure_microscope/plugins/v2/autofocus.py b/openflexure_microscope/api/default_plugins/autofocus.py similarity index 100% rename from openflexure_microscope/plugins/v2/autofocus.py rename to openflexure_microscope/api/default_plugins/autofocus.py diff --git a/openflexure_microscope/plugins/v2/scan.py b/openflexure_microscope/api/default_plugins/scan.py similarity index 100% rename from openflexure_microscope/plugins/v2/scan.py rename to openflexure_microscope/api/default_plugins/scan.py diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index 2045ba0f..d70b5130 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -121,7 +121,9 @@ def init_default_plugins(plugin_path): outfile.write(_DEFAULT_PLUGIN_INIT) -_DEFAULT_PLUGIN_INIT = """from openflexure_microscope.plugins.v2.autofocus import autofocus_plugin_v2 -from openflexure_microscope.plugins.v2.scan import scan_plugin_v2 +_DEFAULT_PLUGIN_INIT = """ +from openflexure_microscope.api.default_plugins.autofocus import autofocus_plugin_v2 +from openflexure_microscope.api.default_plugins.scan import scan_plugin_v2 -__plugins__ = [autofocus_plugin_v2, scan_plugin_v2]""" +__plugins__ = [autofocus_plugin_v2, scan_plugin_v2] +""" From 20bbf5ab212830a0ed8499564ff2d42703e82d35 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Sat, 28 Dec 2019 18:55:13 +0000 Subject: [PATCH 036/122] Removed blueprint stuff from owns_endpoint --- .../common/flask_labthings/labthing.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index c3662192..a9085fab 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -218,17 +218,6 @@ class LabThing(object): 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 From b4e2d5b17218a019dce19c305efd17724f1817aa Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Sat, 28 Dec 2019 18:57:53 +0000 Subject: [PATCH 037/122] Only measure sharpness if camera has "array" method --- openflexure_microscope/api/default_plugins/autofocus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/default_plugins/autofocus.py b/openflexure_microscope/api/default_plugins/autofocus.py index 634cd47a..94ffdb83 100644 --- a/openflexure_microscope/api/default_plugins/autofocus.py +++ b/openflexure_microscope/api/default_plugins/autofocus.py @@ -144,7 +144,8 @@ def sharpness_edge(image): def measure_sharpness(microscope, metric_fn=sharpness_sum_lap2): """Measure the sharpness of the camera's current view.""" - return metric_fn(microscope.camera.array(use_video_port=True)) + if hasattr(microscope.camera, "array"): + return metric_fn(microscope.camera.array(use_video_port=True)) def autofocus(microscope, dz, settle=0.5, metric_fn=sharpness_sum_lap2): From f0c1547d827dc791e166116a015f14481d33cf69 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 30 Dec 2019 18:02:47 +0000 Subject: [PATCH 038/122] Added plugin JSON schema --- .../common/flask_labthings/plugins.py | 4 ++ .../common/flask_labthings/views/plugins.py | 60 +++++++++---------- 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/plugins.py b/openflexure_microscope/common/flask_labthings/plugins.py index b39ee9b7..b4dea914 100644 --- a/openflexure_microscope/common/flask_labthings/plugins.py +++ b/openflexure_microscope/common/flask_labthings/plugins.py @@ -5,6 +5,7 @@ import copy from importlib import util import sys +from openflexure_microscope.common.labthings_core.utilities import get_docstring from openflexure_microscope.utilities import camel_to_snake, snake_to_spine @@ -22,10 +23,13 @@ class BasePlugin: self._rules = {} # Key: Original rule. Val: View class self._gui = None + self._cls = str(self) # String description of plugin instance + self.actions = [] self.properties = [] self.name = name + self.description = get_docstring(self) self.methods = {} diff --git a/openflexure_microscope/common/flask_labthings/views/plugins.py b/openflexure_microscope/common/flask_labthings/views/plugins.py index 5f96c5f9..03bd212a 100644 --- a/openflexure_microscope/common/flask_labthings/views/plugins.py +++ b/openflexure_microscope/common/flask_labthings/views/plugins.py @@ -5,53 +5,47 @@ 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 openflexure_microscope.common.flask_labthings.find import registered_plugins +from openflexure_microscope.common.flask_labthings.schema import Schema +from openflexure_microscope.common.flask_labthings.decorators import marshal_with +from openflexure_microscope.common.flask_labthings import fields +from marshmallow import pre_dump from flask import jsonify, url_for import logging -def plugins_representation(plugin_dict): - """ - Generate a dictionary representation of all plugins, including Flask route URLs +class PluginSchema(Schema): + name = fields.String(data_key="title") + _name_python_safe = fields.String(data_key="pythonName") + _cls = fields.String(data_key="pythonObject") + gui = fields.Dict() + description = fields.String() - Args: - plugin_dict (dict): Dictionary of plugin objects + links = fields.Dict() - 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:] - ) + # TODO: Automate this somewhat + @pre_dump + def generate_links(self, data, **kwargs): + d = {} + for view_id, view_data in data.views.items(): + view_cls = view_data["view"] + view_kwargs = view_data["kwargs"] # Make links dictionary if it doesn't yet exist - view_d = {"href": uri, **description_from_view(view_data["view"])} + d[view_id] = { + "href": url_for(view_cls.endpoint, **view_kwargs, _external=True), + **description_from_view(view_cls), + } - d["links"][view_id] = view_d + data.links = d - plugins[plugin_name] = d - - return plugins + return data class PluginListResource(Resource): + @marshal_with(PluginSchema(many=True)) def get(self): """ Return the current plugin forms @@ -65,4 +59,4 @@ class PluginListResource(Resource): A complete list of enabled plugins can be found in the microscope state. """ - return jsonify(plugins_representation(registered_plugins())) + return registered_plugins().values() From 31018b0c92cc1e09b6f43e53229e75de7fb4a362 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 30 Dec 2019 18:07:53 +0000 Subject: [PATCH 039/122] Use marshall_with --- .../common/flask_labthings/views/tasks.py | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 89fdfe1f..6a3821c9 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -1,6 +1,7 @@ from flask import abort, url_for from openflexure_microscope.common.flask_labthings.schema import Schema +from openflexure_microscope.common.flask_labthings.decorators import marshal_with 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 @@ -11,32 +12,6 @@ from openflexure_microscope.common.flask_labthings.utilities import ( from marshmallow import pre_dump -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") @@ -60,5 +35,29 @@ class TaskSchema(Schema): return data -task_schema = TaskSchema() -task_list_schema = TaskSchema(many=True) +class TaskList(Resource): + @marshal_with(TaskSchema(many=True)) + def get(self): + return tasks.tasks() + + +class TaskResource(Resource): + @marshal_with(TaskSchema()) + def get(self, id): + try: + task = tasks.dict()[id] + except KeyError: + return abort(404) # 404 Not Found + + return task + + @marshal_with(TaskSchema()) + def delete(self, id): + try: + task = tasks.dict()[id] + except KeyError: + return abort(404) # 404 Not Found + + task.terminate() + + return task From 998d3b4109c480d4e39d20186e1c475913f3cb11 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 30 Dec 2019 18:08:03 +0000 Subject: [PATCH 040/122] Added empty class endpoint --- openflexure_microscope/common/flask_labthings/resource.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openflexure_microscope/common/flask_labthings/resource.py b/openflexure_microscope/common/flask_labthings/resource.py index fc902d3e..cb18092f 100644 --- a/openflexure_microscope/common/flask_labthings/resource.py +++ b/openflexure_microscope/common/flask_labthings/resource.py @@ -4,6 +4,7 @@ from flask.views import MethodView class Resource(MethodView): """Currently identical to MethodView """ + endpoint = None def __init__(self, *args, **kwargs): MethodView.__init__(self, *args, **kwargs) From 7174e6a60ab86ceda9b4d49a05ba6d1a6dcedda7 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 31 Dec 2019 16:19:50 +0000 Subject: [PATCH 041/122] First test of @use_args decorator --- .../api/v2/views/actions/camera.py | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index fe2a707d..ba4b1ebe 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -1,6 +1,8 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.decorators import use_args +from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.utilities import filter_dict from openflexure_microscope.api.v2.views.captures import capture_schema @@ -14,18 +16,21 @@ class CaptureAPI(Resource): Create a new image capture. """ - def post(self): + @use_args( + { + "filename": fields.String(), + "temporary": fields.Boolean(missing=False), + "use_video_port": fields.Boolean(missing=False), + "bayer": fields.Boolean(missing=False), + "metadata": fields.Dict(missing={}), + "tags": fields.List(fields.String, missing=[]), + "resize": fields.Dict(missing=None), # TODO: Validate keys + } + ) + def post(self, args): microscope = find_device("openflexure_microscope") - payload = JsonResponse(request) - filename = payload.param("filename") - temporary = payload.param("temporary", default=False, convert=bool) - use_video_port = payload.param("use_video_port", default=False, convert=bool) - bayer = payload.param("bayer", default=True, convert=bool) - metadata = payload.param("metadata", default={}, convert=dict) - tags = payload.param("tags", default=[], convert=list) - - resize = payload.param("size", default=None) + resize = args.get("resize", None) if resize: if ("width" in resize) and ("height" in resize): resize = ( @@ -37,20 +42,25 @@ class CaptureAPI(Resource): # Explicitally acquire lock (prevents empty files being created if lock is unavailable) with microscope.camera.lock: - output = microscope.camera.new_image(temporary=temporary, filename=filename) + output = microscope.camera.new_image( + temporary=args.get("temporary"), filename=args.get("filename") + ) microscope.camera.capture( - output.file, use_video_port=use_video_port, resize=resize, bayer=bayer + output.file, + use_video_port=args.get("use_video_port"), + resize=resize, + bayer=args.get("bayer"), ) # Inject system metadata output.put_metadata(microscope.metadata, system=True) # Insert custom metadata - output.put_metadata(metadata) + output.put_metadata(args.get("metadata")) # Insert custom tags - output.put_tags(tags) + output.put_tags(args.get("tags")) return capture_schema.jsonify(output) From dad89c0779546dd521c939fd34d04859cd21ef7d Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 31 Dec 2019 16:25:33 +0000 Subject: [PATCH 042/122] Use @marshal_with for capture action --- openflexure_microscope/api/v2/views/actions/camera.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index ba4b1ebe..948be81b 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -1,7 +1,7 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.find import find_device -from openflexure_microscope.common.flask_labthings.decorators import use_args +from openflexure_microscope.common.flask_labthings.decorators import use_args, marshal_with from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.utilities import filter_dict @@ -27,6 +27,7 @@ class CaptureAPI(Resource): "resize": fields.Dict(missing=None), # TODO: Validate keys } ) + @marshal_with(capture_schema) def post(self, args): microscope = find_device("openflexure_microscope") @@ -62,7 +63,7 @@ class CaptureAPI(Resource): # Insert custom tags output.put_tags(args.get("tags")) - return capture_schema.jsonify(output) + return output class GPUPreviewStartAPI(Resource): From 99d0d0055ea97ef79f2ca2d48d17721b024ea947 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 1 Jan 2020 18:07:16 +0000 Subject: [PATCH 043/122] Converted webargs decorators to classes (allowing auto-docs) --- .../common/flask_labthings/decorators.py | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index 1ffeebed..ed0cb8b2 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -1,5 +1,5 @@ -from webargs.flaskparser import use_args, use_kwargs -from functools import wraps +from webargs import flaskparser +from functools import wraps, update_wrapper from flask import make_response @@ -43,3 +43,30 @@ class marshal_with(object): return make_response(self.schema.jsonify(resp)) return wrapper + + +class use_args(object): + def __init__(self, argmap, **kwargs): + """ + Equivalent to webargs.flask_parser.use_args + """ + self.argmap = argmap + self.wrapper = flaskparser.use_args(argmap, **kwargs) + + def __call__(self, f): + update_wrapper(self.wrapper, f) + return self.wrapper(f) + + +class use_kwargs(object): + def __init__(self, argmap, **kwargs): + """ + Equivalent to webargs.flask_parser.use_kwargs + """ + kwargs["as_kwargs"] = True + self.argmap = argmap + self.wrapper = flaskparser.use_args(argmap, **kwargs) + + def __call__(self, f): + update_wrapper(self.wrapper, f) + return self.wrapper(f) From 6d7921e8b4cffe9e0b1371442fe39efc5c100e30 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 2 Jan 2020 21:33:50 +0000 Subject: [PATCH 044/122] First draft of auto-Swagger --- openflexure_microscope/api/app.py | 6 +- .../api/v2/views/actions/camera.py | 4 +- .../common/flask_labthings/decorators.py | 32 +++++-- .../common/flask_labthings/labthing.py | 63 +++++++++++-- .../common/flask_labthings/resource.py | 13 +++ .../common/flask_labthings/schema.py | 4 +- .../common/flask_labthings/spec.py | 89 +++++++++++++++++++ .../common/flask_labthings/utilities.py | 15 ++++ 8 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 openflexure_microscope/common/flask_labthings/spec.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 937c610e..4b028d8b 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -168,4 +168,8 @@ def cleanup(): atexit.register(cleanup) if __name__ == "__main__": - app.run(host="0.0.0.0", port="5000", threaded=True, debug=True, use_reloader=False) +# app.run(host="0.0.0.0", port="5000", threaded=True, debug=True, use_reloader=False) + #from pprint import pprint + #pprint(labthing.spec.to_dict()) + with open('spec.yaml', 'w') as f: + f.write(labthing.spec.to_yaml()) \ No newline at end of file diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 948be81b..b6a7ef7f 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -1,7 +1,7 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.find import find_device -from openflexure_microscope.common.flask_labthings.decorators import use_args, marshal_with +from openflexure_microscope.common.flask_labthings.decorators import use_args, marshal_with, doc from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.utilities import filter_dict @@ -10,7 +10,7 @@ from openflexure_microscope.api.v2.views.captures import capture_schema import logging from flask import jsonify, request, abort, url_for, redirect, send_file - +@doc(tags=["actions"]) class CaptureAPI(Resource): """ Create a new image capture. diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index ed0cb8b2..41bdd368 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -32,6 +32,10 @@ class marshal_with(object): self.schema = schema def __call__(self, f): + # Pass params to call function attribute for external access + f.__apispec__ = f.__dict__.get('__apispec__', {}) + f.__apispec__["_schema"] = self.schema + # Wrapper function @wraps(f) def wrapper(*args, **kwargs): resp = f(*args, **kwargs) @@ -46,27 +50,37 @@ class marshal_with(object): class use_args(object): - def __init__(self, argmap, **kwargs): + def __init__(self, schema, **kwargs): """ Equivalent to webargs.flask_parser.use_args """ - self.argmap = argmap - self.wrapper = flaskparser.use_args(argmap, **kwargs) + self.schema = schema + self.wrapper = flaskparser.use_args(schema, **kwargs) def __call__(self, f): + # Pass params to call function attribute for external access + f.__apispec__ = f.__dict__.get('__apispec__', {}) + f.__apispec__["_params"] = self.schema + # Wrapper function update_wrapper(self.wrapper, f) return self.wrapper(f) -class use_kwargs(object): - def __init__(self, argmap, **kwargs): +class use_kwargs(use_args): + def __init__(self, schema, **kwargs): """ Equivalent to webargs.flask_parser.use_kwargs """ kwargs["as_kwargs"] = True - self.argmap = argmap - self.wrapper = flaskparser.use_args(argmap, **kwargs) + use_args.__init__(self, schema, **kwargs) + + +class doc(object): + def __init__(self, **kwargs): + self.kwargs = kwargs def __call__(self, f): - update_wrapper(self.wrapper, f) - return self.wrapper(f) + # Pass params to call function attribute for external access + f.__apispec__ = f.__dict__.get('__apispec__', {}) + f.__apispec__.update(self.kwargs) + return f \ No newline at end of file diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index a9085fab..208e0313 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -1,14 +1,19 @@ from flask import url_for, jsonify +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin from .plugins import BasePlugin from .views.plugins import PluginListResource from .views.tasks import TaskList, TaskResource +from .spec import view2path + from openflexure_microscope.common.labthings_core.utilities import get_docstring from .exceptions import JSONExceptionHandler from . import EXTENSION_NAME +import logging class LabThing(object): def __init__( @@ -17,6 +22,7 @@ class LabThing(object): prefix: str = "", title: str = "", description: str = "", + version: str = "0.0.0", handle_errors: bool = True, ): self.app = app @@ -32,17 +38,53 @@ class LabThing(object): self.endpoints = set() self.url_prefix = prefix - self.description = description - self.title = title + self._description = description + self._title = title + self._version = version if handle_errors: self.error_handler = JSONExceptionHandler() else: self.error_handler = None + self.spec = APISpec( + title=self.title, + version=self.version, + openapi_version="3.0.2", + plugins=[MarshmallowPlugin()], + ) + if app is not None: self.init_app(app) + @property + def description(self, ): + return self._description + + @description.setter + def description(self, description: str): + self._description = description + self.spec.description = description + + @property + def title(self, ): + return self._title + + @title.setter + def title(self, title: str): + self._title = title + self.spec.title = title + + @property + def version(self, ): + return str(self._version) + + @version.setter + def version(self, version: str): + self._version = version + self.spec.version = version + + ### Flask stuff def init_app(self, app): @@ -56,14 +98,14 @@ class LabThing(object): 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) + # Create base routes + self._create_base_routes() + def teardown(self, exception): pass @@ -158,10 +200,13 @@ class LabThing(object): api.add_resource(FooSpecial, '/special/foo', endpoint="foo") """ endpoint = endpoint or resource.__name__.lower() + + logging.debug(f"{endpoint}: {type(resource)}") + if self.app is not None: self._register_view(self.app, resource, *urls, endpoint=endpoint, **kwargs) - else: - self.resources.append((resource, urls, endpoint, kwargs)) + + self.resources.append((resource, urls, endpoint, kwargs)) def resource(self, *urls, **kwargs): """Wraps a :class:`~flask_restful.Resource` class, adding it to the @@ -208,6 +253,10 @@ class LabThing(object): rule = self._complete_url(url, "") # Add the url to the application or blueprint app.add_url_rule(rule, view_func=resource_func, **kwargs) + # Add the resource to our API spec + self.spec.path( + **view2path(rule, resource, self.spec) + ) ### Utilities diff --git a/openflexure_microscope/common/flask_labthings/resource.py b/openflexure_microscope/common/flask_labthings/resource.py index cb18092f..5b675654 100644 --- a/openflexure_microscope/common/flask_labthings/resource.py +++ b/openflexure_microscope/common/flask_labthings/resource.py @@ -5,6 +5,19 @@ class Resource(MethodView): """Currently identical to MethodView """ endpoint = None + methods = ["get", "post", "put", "delete"] def __init__(self, *args, **kwargs): MethodView.__init__(self, *args, **kwargs) + + def doc(self): + docs = {"operations": {}} + if hasattr(self, "__apispec__"): + docs.update(self.__apispec__) + + + for meth in Resource.methods: + if hasattr(self, meth) and hasattr(getattr(self, meth), "__apispec__"): + docs["operations"][meth] = {} + docs["operations"][meth] = getattr(self, meth).__apispec__ + return docs diff --git a/openflexure_microscope/common/flask_labthings/schema.py b/openflexure_microscope/common/flask_labthings/schema.py index 3652e208..83294a0e 100644 --- a/openflexure_microscope/common/flask_labthings/schema.py +++ b/openflexure_microscope/common/flask_labthings/schema.py @@ -2,7 +2,7 @@ import flask import marshmallow -_MARSHMALLOW_VERSION_INFO = tuple( +MARSHMALLOW_VERSION_INFO = tuple( [int(part) for part in marshmallow.__version__.split(".") if part.isdigit()] ) @@ -32,7 +32,7 @@ class Schema(marshmallow.Schema): """ if many is sentinel: many = self.many - if _MARSHMALLOW_VERSION_INFO[0] >= 3: + if MARSHMALLOW_VERSION_INFO[0] >= 3: data = self.dump(obj, many=many) else: data = self.dump(obj, many=many).data diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec.py new file mode 100644 index 00000000..4b01f139 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -0,0 +1,89 @@ + +from .resource import Resource +from .utilities import rupdate +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin + +from .fields import Field +from marshmallow import Schema as BaseSchema + +from collections import Mapping + +def view2path(rule: str, view: Resource, spec: APISpec): + params = { + "path": rule, # TODO: Validate this slightly (leading / etc) + "operations": view2operations(view, spec), + } + + if hasattr(view, "__apispec__"): + # Recursively update params + rupdate(params, view.__apispec__) + + return params + +def view2operations(view: Resource, spec: APISpec): + ops = {} + for method in Resource.methods: + if hasattr(view, method): + ops[method] = {} + if hasattr(getattr(view, method), "__apispec__"): + ops[method] = doc2operation(getattr(view, method).__apispec__, spec) + + return ops + + +def doc2operation(apispec: dict, spec: APISpec): + op = {} + if "_params" in apispec: + op["requestBody"] = { + "content": { + "application/json": { + "schema": convert_schema(apispec.get("_params"), spec) + } + }, + } + + if "_schema" in apispec: + op["responses"] = { + 200: { + "description": "success", + "content": { + "application/json": { + "schema": convert_schema(apispec.get("_schema"), spec) + } + }, + } + } + + for key, val in apispec.items(): + if not key in ["_params", "_schema"]: + op[key] = val + + return op + + +def convert_schema(schema, spec: APISpec): + if isinstance(schema, BaseSchema): + return schema + elif isinstance(schema, Mapping): + return map2properties(schema, spec) + else: + raise TypeError("Unsupported schema type. Ensure schema is a Schema class, or dictionary of Field objects") + +def map2properties(schema, spec: APISpec): + marshmallow_plugin = next( + plugin for plugin in spec.plugins + if isinstance(plugin, MarshmallowPlugin) + ) + converter = marshmallow_plugin.converter + + d = {} + for k, v in schema.items(): + if isinstance(v, Field): + d[k] = converter.field2property(v) + elif isinstance(v, Mapping): + d[k] = map2properties(v, spec) + else: + d[k] = v + + return {"properties": d} \ No newline at end of file diff --git a/openflexure_microscope/common/flask_labthings/utilities.py b/openflexure_microscope/common/flask_labthings/utilities.py index 681471c9..7c6232a2 100644 --- a/openflexure_microscope/common/flask_labthings/utilities.py +++ b/openflexure_microscope/common/flask_labthings/utilities.py @@ -1,4 +1,10 @@ from openflexure_microscope.common.labthings_core.utilities import get_docstring +from .schema import Schema, marshmallow, MARSHMALLOW_VERSION_INFO +import collections.abc + +from webargs import dict2schema as wa_dict2schema + +import logging def description_from_view(view_class): @@ -11,3 +17,12 @@ def description_from_view(view_class): d = {"methods": methods, "description": brief_description} return d + + +def rupdate(d, u): + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = rupdate(d.get(k, {}), v) + else: + d[k] = v + return d \ No newline at end of file From b0316ae494a0b5368efc3bc78200f405026bd150 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 2 Jan 2020 21:42:03 +0000 Subject: [PATCH 045/122] Fixed default response docs --- .../common/flask_labthings/spec.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec.py index 4b01f139..618f34f0 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -21,11 +21,22 @@ def view2path(rule: str, view: Resource, spec: APISpec): return params -def view2operations(view: Resource, spec: APISpec): +def view2operations(view: Resource, spec: APISpec, populate_default: bool = True): ops = {} for method in Resource.methods: if hasattr(view, method): - ops[method] = {} + + if populate_default: + ops[method] = { + "responses": { + 200: { + "description": "success" + } + } + } + else: + ops[method] = {} + if hasattr(getattr(view, method), "__apispec__"): ops[method] = doc2operation(getattr(view, method).__apispec__, spec) From 3bcfd86cccfe3a28fd6bcc32ba70f12407df5db0 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 2 Jan 2020 22:43:24 +0000 Subject: [PATCH 046/122] Get default description and summary from docstring --- openflexure_microscope/common/flask_labthings/spec.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec.py index 618f34f0..356f4de3 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -4,6 +4,8 @@ from .utilities import rupdate from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin +from openflexure_microscope.common.labthings_core.utilities import get_docstring + from .fields import Field from marshmallow import Schema as BaseSchema @@ -13,6 +15,8 @@ def view2path(rule: str, view: Resource, spec: APISpec): params = { "path": rule, # TODO: Validate this slightly (leading / etc) "operations": view2operations(view, spec), + "description": get_docstring(view), + "summary": get_docstring(view).partition("\n")[0].strip() } if hasattr(view, "__apispec__"): From b3f32f23095c45f9dfc1d7962793a04f211a66aa Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 2 Jan 2020 22:43:44 +0000 Subject: [PATCH 047/122] Added decorator for documenting response codes --- .../api/v2/views/actions/camera.py | 3 ++- .../common/flask_labthings/decorators.py | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index b6a7ef7f..66a3af92 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -1,7 +1,7 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.find import find_device -from openflexure_microscope.common.flask_labthings.decorators import use_args, marshal_with, doc +from openflexure_microscope.common.flask_labthings.decorators import use_args, marshal_with, doc, response from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.utilities import filter_dict @@ -28,6 +28,7 @@ class CaptureAPI(Resource): } ) @marshal_with(capture_schema) + @response(200, "Capture successful") def post(self, args): microscope = find_device("openflexure_microscope") diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index 41bdd368..2952ca91 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -2,6 +2,7 @@ from webargs import flaskparser from functools import wraps, update_wrapper from flask import make_response +from .utilities import rupdate def unpack(value): """Return a three tuple of data, code, and headers""" @@ -83,4 +84,25 @@ class doc(object): # Pass params to call function attribute for external access f.__apispec__ = f.__dict__.get('__apispec__', {}) f.__apispec__.update(self.kwargs) + return f + + +class response(object): + def __init__(self, code, description, **kwargs): + self.code = code + self.description = description + self.kwargs = kwargs + + def __call__(self, f): + # Pass params to call function attribute for external access + f.__apispec__ = f.__dict__.get('__apispec__', {}) + d = { + "responses": { + self.code: { + "description": self.description, + **self.kwargs + } + } + } + rupdate(f.__apispec__, d) return f \ No newline at end of file From a75b6073f245ad36c6d1e722071242926ea5b7e8 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 2 Jan 2020 23:01:04 +0000 Subject: [PATCH 048/122] Renamed @response to @doc_response --- openflexure_microscope/api/v2/views/actions/camera.py | 2 +- openflexure_microscope/common/flask_labthings/decorators.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 66a3af92..7eb138d3 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -28,7 +28,7 @@ class CaptureAPI(Resource): } ) @marshal_with(capture_schema) - @response(200, "Capture successful") + @doc_response(200, "Capture successful") def post(self, args): microscope = find_device("openflexure_microscope") diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index 2952ca91..c17ef5c9 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -87,7 +87,7 @@ class doc(object): return f -class response(object): +class doc_response(object): def __init__(self, code, description, **kwargs): self.code = code self.description = description From 9bc4e6171a1b5a744e8a45e65b54a1e70c234f0c Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 2 Jan 2020 23:01:20 +0000 Subject: [PATCH 049/122] Fixed doc_response import --- openflexure_microscope/api/v2/views/actions/camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 7eb138d3..e0112995 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -1,7 +1,7 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.find import find_device -from openflexure_microscope.common.flask_labthings.decorators import use_args, marshal_with, doc, response +from openflexure_microscope.common.flask_labthings.decorators import use_args, marshal_with, doc, doc_response from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.utilities import filter_dict From 4918215ea75d027af0b80cfce678e0133b88f570 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 2 Jan 2020 23:36:59 +0000 Subject: [PATCH 050/122] Tested Field description --- openflexure_microscope/api/v2/views/captures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 80afdc24..618419b6 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -18,7 +18,7 @@ from marshmallow import pre_dump class CaptureSchema(Schema): id = fields.String() - file = fields.String(data_key="path") + file = fields.String(data_key="path", description="Path of file on microscope device") exists = fields.Bool(data_key="available") filename = fields.String() metadata = fields.Dict() From 5bdb6764bead1e2f7c27f84552b7b43841038f86 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 2 Jan 2020 23:37:28 +0000 Subject: [PATCH 051/122] Fixed automatic Swagger from docstrings --- .../api/v2/views/actions/camera.py | 3 ++ .../api/v2/views/streams.py | 2 +- .../common/flask_labthings/spec.py | 54 +++++++++++-------- .../common/flask_labthings/utilities.py | 2 + .../common/labthings_core/utilities.py | 3 ++ 5 files changed, 42 insertions(+), 22 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index e0112995..7adec1e0 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -30,6 +30,9 @@ class CaptureAPI(Resource): @marshal_with(capture_schema) @doc_response(200, "Capture successful") def post(self, args): + """ + Create a new capture + """ microscope = find_device("openflexure_microscope") resize = args.get("resize", None) diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index 962b8cda..51d8ecb1 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -15,7 +15,7 @@ class MjpegStream(Resource): def get(self): """ - Real-time MJPEG stream from the microscope camera + MJPEG stream from the microscope camera """ microscope = find_device("openflexure_microscope") # Restart stream worker thread diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec.py index 356f4de3..fef8fd84 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -4,7 +4,7 @@ from .utilities import rupdate from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin -from openflexure_microscope.common.labthings_core.utilities import get_docstring +from openflexure_microscope.common.labthings_core.utilities import get_docstring, get_summary from .fields import Field from marshmallow import Schema as BaseSchema @@ -16,7 +16,7 @@ def view2path(rule: str, view: Resource, spec: APISpec): "path": rule, # TODO: Validate this slightly (leading / etc) "operations": view2operations(view, spec), "description": get_docstring(view), - "summary": get_docstring(view).partition("\n")[0].strip() + "summary": get_summary(view) } if hasattr(view, "__apispec__"): @@ -29,20 +29,28 @@ def view2operations(view: Resource, spec: APISpec, populate_default: bool = True ops = {} for method in Resource.methods: if hasattr(view, method): - + # Populate with default responses if populate_default: ops[method] = { "responses": { 200: { - "description": "success" + "description": get_summary(getattr(view, method)) or "Success" + }, + 404: { + "description": "Resource not found" } - } + }, } else: ops[method] = {} + + rupdate(ops[method], { + "description": get_docstring(getattr(view, method)), + "summary": get_summary(getattr(view, method)) + }) if hasattr(getattr(view, method), "__apispec__"): - ops[method] = doc2operation(getattr(view, method).__apispec__, spec) + rupdate(ops[method], doc2operation(getattr(view, method).__apispec__, spec)) return ops @@ -50,25 +58,29 @@ def view2operations(view: Resource, spec: APISpec, populate_default: bool = True def doc2operation(apispec: dict, spec: APISpec): op = {} if "_params" in apispec: - op["requestBody"] = { - "content": { - "application/json": { - "schema": convert_schema(apispec.get("_params"), spec) - } - }, - } - - if "_schema" in apispec: - op["responses"] = { - 200: { - "description": "success", + rupdate(op, { + "requestBody": { "content": { "application/json": { - "schema": convert_schema(apispec.get("_schema"), spec) + "schema": convert_schema(apispec.get("_params"), spec) } - }, + } } - } + }) + + if "_schema" in apispec: + rupdate(op, + { + "responses": { + 200: { + "content": { + "application/json": { + "schema": convert_schema(apispec.get("_schema"), spec) + } + }, + } + } + }) for key, val in apispec.items(): if not key in ["_params", "_schema"]: diff --git a/openflexure_microscope/common/flask_labthings/utilities.py b/openflexure_microscope/common/flask_labthings/utilities.py index 7c6232a2..68c328e8 100644 --- a/openflexure_microscope/common/flask_labthings/utilities.py +++ b/openflexure_microscope/common/flask_labthings/utilities.py @@ -22,6 +22,8 @@ def description_from_view(view_class): def rupdate(d, u): for k, v in u.items(): if isinstance(v, collections.abc.Mapping): + if not k in d: + d[k] = {} d[k] = rupdate(d.get(k, {}), v) else: d[k] = v diff --git a/openflexure_microscope/common/labthings_core/utilities.py b/openflexure_microscope/common/labthings_core/utilities.py index c82d54f9..a4e880d3 100644 --- a/openflexure_microscope/common/labthings_core/utilities.py +++ b/openflexure_microscope/common/labthings_core/utilities.py @@ -4,3 +4,6 @@ def get_docstring(obj): return ds.strip() else: return "" + +def get_summary(obj): + return get_docstring(obj).partition("\n")[0].strip() \ No newline at end of file From bcfc8e6eeb6ccfc8d079afab43385c82275d4db3 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 2 Jan 2020 23:52:46 +0000 Subject: [PATCH 052/122] Added update_spec function --- .../common/flask_labthings/decorators.py | 10 ++++------ openflexure_microscope/common/flask_labthings/spec.py | 5 +++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index c17ef5c9..40e0eb3e 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -3,6 +3,7 @@ from functools import wraps, update_wrapper from flask import make_response from .utilities import rupdate +from .spec import update_spec def unpack(value): """Return a three tuple of data, code, and headers""" @@ -34,8 +35,7 @@ class marshal_with(object): def __call__(self, f): # Pass params to call function attribute for external access - f.__apispec__ = f.__dict__.get('__apispec__', {}) - f.__apispec__["_schema"] = self.schema + update_spec(f, {"_schema": self.schema}) # Wrapper function @wraps(f) def wrapper(*args, **kwargs): @@ -60,8 +60,7 @@ class use_args(object): def __call__(self, f): # Pass params to call function attribute for external access - f.__apispec__ = f.__dict__.get('__apispec__', {}) - f.__apispec__["_params"] = self.schema + update_spec(f, {"_params": self.schema}) # Wrapper function update_wrapper(self.wrapper, f) return self.wrapper(f) @@ -82,8 +81,7 @@ class doc(object): def __call__(self, f): # Pass params to call function attribute for external access - f.__apispec__ = f.__dict__.get('__apispec__', {}) - f.__apispec__.update(self.kwargs) + update_spec(f, self.kwargs) return f diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec.py index fef8fd84..3bbf0633 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -11,6 +11,11 @@ from marshmallow import Schema as BaseSchema from collections import Mapping +def update_spec(obj, spec): + obj.__apispec__ = obj.__dict__.get('__apispec__', {}) + rupdate(obj.__apispec__, spec) + return obj.__apispec__ + def view2path(rule: str, view: Resource, spec: APISpec): params = { "path": rule, # TODO: Validate this slightly (leading / etc) From 6be8b0044a30563d2d9eed0f50a3c452064aef9c Mon Sep 17 00:00:00 2001 From: jtc42 Date: Fri, 3 Jan 2020 00:19:25 +0000 Subject: [PATCH 053/122] Added simple root and swagger routes --- openflexure_microscope/api/app.py | 6 +-- .../common/flask_labthings/labthing.py | 49 ++++++++++++++++++- .../common/flask_labthings/utilities.py | 8 ++- .../common/flask_labthings/views/plugins.py | 3 ++ .../common/flask_labthings/views/tasks.py | 3 ++ 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 4b028d8b..5ecacda3 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -168,8 +168,8 @@ def cleanup(): atexit.register(cleanup) if __name__ == "__main__": -# app.run(host="0.0.0.0", port="5000", threaded=True, debug=True, use_reloader=False) + app.run(host="0.0.0.0", port="5000", threaded=True, debug=True, use_reloader=False) #from pprint import pprint #pprint(labthing.spec.to_dict()) - with open('spec.yaml', 'w') as f: - f.write(labthing.spec.to_yaml()) \ No newline at end of file + #with open('spec.yaml', 'w') as f: + # f.write(labthing.spec.to_yaml()) \ No newline at end of file diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index 208e0313..ea895b4b 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -8,6 +8,8 @@ from .views.tasks import TaskList, TaskResource from .spec import view2path +from .utilities import description_from_view + from openflexure_microscope.common.labthings_core.utilities import get_docstring from .exceptions import JSONExceptionHandler @@ -110,8 +112,13 @@ class LabThing(object): pass def _create_base_routes(self): - # Add thing description to root + # Add root representation + self.app.add_url_rule(self._complete_url("/", ""), "rootrep", self.rootrep) + # Add thing description self.app.add_url_rule(self._complete_url("/td", ""), "td", self.td) + # Add swagger spec + self.app.add_url_rule(self._complete_url("/swagger", ""), "swagger", self.swagger) + # Add plugin overview self.add_resource(PluginListResource, "/plugins") self.register_property(PluginListResource) @@ -299,4 +306,42 @@ class LabThing(object): return jsonify(td) - # TODO: Add a nicer root resource like the old self-documenting system + def rootrep(self): + """ + Root representation + """ + # TODO: Allow custom root representations + + rr = { + "id": url_for("rootrep", _external=True), + "title": self.title, + "description": self.description, + "links": { + "thingDescription": { + "href": url_for("td", _external=True), + "description": get_docstring(self.td), + "methods": ["GET"], + }, + "swagger": { + "href": url_for("swagger", _external=True), + "description": get_docstring(self.swagger), + "methods": ["GET"], + }, + "plugins": { + "href": self.url_for(PluginListResource, _external=True), + **description_from_view(PluginListResource), + }, + "tasks": { + "href": self.url_for(TaskList, _external=True), + **description_from_view(TaskList), + } + } + } + + return jsonify(rr) + + def swagger(self): + """ + OpenAPI v3 documentation + """ + return jsonify(self.spec.to_dict()) \ No newline at end of file diff --git a/openflexure_microscope/common/flask_labthings/utilities.py b/openflexure_microscope/common/flask_labthings/utilities.py index 68c328e8..9fb110cf 100644 --- a/openflexure_microscope/common/flask_labthings/utilities.py +++ b/openflexure_microscope/common/flask_labthings/utilities.py @@ -1,9 +1,7 @@ -from openflexure_microscope.common.labthings_core.utilities import get_docstring +from openflexure_microscope.common.labthings_core.utilities import get_docstring, get_summary from .schema import Schema, marshmallow, MARSHMALLOW_VERSION_INFO import collections.abc -from webargs import dict2schema as wa_dict2schema - import logging @@ -12,9 +10,9 @@ def description_from_view(view_class): 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() + summary = get_summary(view_class) - d = {"methods": methods, "description": brief_description} + d = {"methods": methods, "description": summary} return d diff --git a/openflexure_microscope/common/flask_labthings/views/plugins.py b/openflexure_microscope/common/flask_labthings/views/plugins.py index 03bd212a..c4e90c8c 100644 --- a/openflexure_microscope/common/flask_labthings/views/plugins.py +++ b/openflexure_microscope/common/flask_labthings/views/plugins.py @@ -45,6 +45,9 @@ class PluginSchema(Schema): class PluginListResource(Resource): + """ + List and basic documentation for all enabled plugins + """ @marshal_with(PluginSchema(many=True)) def get(self): """ diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 6a3821c9..4b1450cf 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -36,6 +36,9 @@ class TaskSchema(Schema): class TaskList(Resource): + """ + List and basic documentation for all session tasks + """ @marshal_with(TaskSchema(many=True)) def get(self): return tasks.tasks() From ab6fa9f0e52e20f2e08ebd2c1ede22b590bd4648 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Fri, 3 Jan 2020 00:21:16 +0000 Subject: [PATCH 054/122] Blackened --- openflexure_microscope/api/app.py | 8 +- .../api/v2/views/actions/camera.py | 8 +- .../api/v2/views/captures.py | 4 +- .../common/flask_labthings/decorators.py | 14 +-- .../common/flask_labthings/labthing.py | 34 +++--- .../common/flask_labthings/resource.py | 2 +- .../common/flask_labthings/spec.py | 102 ++++++++++-------- .../common/flask_labthings/utilities.py | 7 +- .../common/flask_labthings/views/plugins.py | 1 + .../common/flask_labthings/views/tasks.py | 1 + .../common/labthings_core/utilities.py | 3 +- 11 files changed, 104 insertions(+), 80 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 5ecacda3..4d486dd1 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -169,7 +169,7 @@ atexit.register(cleanup) if __name__ == "__main__": app.run(host="0.0.0.0", port="5000", threaded=True, debug=True, use_reloader=False) - #from pprint import pprint - #pprint(labthing.spec.to_dict()) - #with open('spec.yaml', 'w') as f: - # f.write(labthing.spec.to_yaml()) \ No newline at end of file + # from pprint import pprint + # pprint(labthing.spec.to_dict()) + # with open('spec.yaml', 'w') as f: + # f.write(labthing.spec.to_yaml()) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 7adec1e0..7ae5635c 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -1,7 +1,12 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.find import find_device -from openflexure_microscope.common.flask_labthings.decorators import use_args, marshal_with, doc, doc_response +from openflexure_microscope.common.flask_labthings.decorators import ( + use_args, + marshal_with, + doc, + doc_response, +) from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.utilities import filter_dict @@ -10,6 +15,7 @@ from openflexure_microscope.api.v2.views.captures import capture_schema import logging from flask import jsonify, request, abort, url_for, redirect, send_file + @doc(tags=["actions"]) class CaptureAPI(Resource): """ diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 618419b6..bfb31e87 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -18,7 +18,9 @@ from marshmallow import pre_dump class CaptureSchema(Schema): id = fields.String() - file = fields.String(data_key="path", description="Path of file on microscope device") + file = fields.String( + data_key="path", description="Path of file on microscope device" + ) exists = fields.Bool(data_key="available") filename = fields.String() metadata = fields.Dict() diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index 40e0eb3e..dbab2caa 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -5,6 +5,7 @@ from flask import make_response from .utilities import rupdate from .spec import update_spec + def unpack(value): """Return a three tuple of data, code, and headers""" if not isinstance(value, tuple): @@ -93,14 +94,7 @@ class doc_response(object): def __call__(self, f): # Pass params to call function attribute for external access - f.__apispec__ = f.__dict__.get('__apispec__', {}) - d = { - "responses": { - self.code: { - "description": self.description, - **self.kwargs - } - } - } + f.__apispec__ = f.__dict__.get("__apispec__", {}) + d = {"responses": {self.code: {"description": self.description, **self.kwargs}}} rupdate(f.__apispec__, d) - return f \ No newline at end of file + return f diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index ea895b4b..f98fb4a8 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -17,6 +17,7 @@ from . import EXTENSION_NAME import logging + class LabThing(object): def __init__( self, @@ -60,32 +61,31 @@ class LabThing(object): self.init_app(app) @property - def description(self, ): + def description(self,): return self._description - + @description.setter def description(self, description: str): self._description = description self.spec.description = description - + @property - def title(self, ): + def title(self,): return self._title - + @title.setter def title(self, title: str): self._title = title self.spec.title = title - + @property - def version(self, ): + def version(self,): return str(self._version) - + @version.setter def version(self, version: str): self._version = version self.spec.version = version - ### Flask stuff @@ -117,7 +117,9 @@ class LabThing(object): # Add thing description self.app.add_url_rule(self._complete_url("/td", ""), "td", self.td) # Add swagger spec - self.app.add_url_rule(self._complete_url("/swagger", ""), "swagger", self.swagger) + self.app.add_url_rule( + self._complete_url("/swagger", ""), "swagger", self.swagger + ) # Add plugin overview self.add_resource(PluginListResource, "/plugins") @@ -212,7 +214,7 @@ class LabThing(object): if self.app is not None: self._register_view(self.app, resource, *urls, endpoint=endpoint, **kwargs) - + self.resources.append((resource, urls, endpoint, kwargs)) def resource(self, *urls, **kwargs): @@ -261,9 +263,7 @@ class LabThing(object): # Add the url to the application or blueprint app.add_url_rule(rule, view_func=resource_func, **kwargs) # Add the resource to our API spec - self.spec.path( - **view2path(rule, resource, self.spec) - ) + self.spec.path(**view2path(rule, resource, self.spec)) ### Utilities @@ -334,8 +334,8 @@ class LabThing(object): "tasks": { "href": self.url_for(TaskList, _external=True), **description_from_view(TaskList), - } - } + }, + }, } return jsonify(rr) @@ -344,4 +344,4 @@ class LabThing(object): """ OpenAPI v3 documentation """ - return jsonify(self.spec.to_dict()) \ No newline at end of file + return jsonify(self.spec.to_dict()) diff --git a/openflexure_microscope/common/flask_labthings/resource.py b/openflexure_microscope/common/flask_labthings/resource.py index 5b675654..fd2575e2 100644 --- a/openflexure_microscope/common/flask_labthings/resource.py +++ b/openflexure_microscope/common/flask_labthings/resource.py @@ -4,6 +4,7 @@ from flask.views import MethodView class Resource(MethodView): """Currently identical to MethodView """ + endpoint = None methods = ["get", "post", "put", "delete"] @@ -15,7 +16,6 @@ class Resource(MethodView): if hasattr(self, "__apispec__"): docs.update(self.__apispec__) - for meth in Resource.methods: if hasattr(self, meth) and hasattr(getattr(self, meth), "__apispec__"): docs["operations"][meth] = {} diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec.py index 3bbf0633..dd53defa 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -1,27 +1,31 @@ - from .resource import Resource from .utilities import rupdate from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin -from openflexure_microscope.common.labthings_core.utilities import get_docstring, get_summary +from openflexure_microscope.common.labthings_core.utilities import ( + get_docstring, + get_summary, +) from .fields import Field from marshmallow import Schema as BaseSchema from collections import Mapping + def update_spec(obj, spec): - obj.__apispec__ = obj.__dict__.get('__apispec__', {}) - rupdate(obj.__apispec__, spec) - return obj.__apispec__ + obj.__apispec__ = obj.__dict__.get("__apispec__", {}) + rupdate(obj.__apispec__, spec) + return obj.__apispec__ + def view2path(rule: str, view: Resource, spec: APISpec): params = { "path": rule, # TODO: Validate this slightly (leading / etc) "operations": view2operations(view, spec), "description": get_docstring(view), - "summary": get_summary(view) + "summary": get_summary(view), } if hasattr(view, "__apispec__"): @@ -30,6 +34,7 @@ def view2path(rule: str, view: Resource, spec: APISpec): return params + def view2operations(view: Resource, spec: APISpec, populate_default: bool = True): ops = {} for method in Resource.methods: @@ -39,58 +44,67 @@ def view2operations(view: Resource, spec: APISpec, populate_default: bool = True ops[method] = { "responses": { 200: { - "description": get_summary(getattr(view, method)) or "Success" + "description": get_summary(getattr(view, method)) + or "Success" }, - 404: { - "description": "Resource not found" - } - }, + 404: {"description": "Resource not found"}, + } } else: ops[method] = {} - - rupdate(ops[method], { - "description": get_docstring(getattr(view, method)), - "summary": get_summary(getattr(view, method)) - }) - + + rupdate( + ops[method], + { + "description": get_docstring(getattr(view, method)), + "summary": get_summary(getattr(view, method)), + }, + ) + if hasattr(getattr(view, method), "__apispec__"): - rupdate(ops[method], doc2operation(getattr(view, method).__apispec__, spec)) - + rupdate( + ops[method], doc2operation(getattr(view, method).__apispec__, spec) + ) + return ops def doc2operation(apispec: dict, spec: APISpec): op = {} if "_params" in apispec: - rupdate(op, { - "requestBody": { - "content": { - "application/json": { - "schema": convert_schema(apispec.get("_params"), spec) - } - } - } - }) - - if "_schema" in apispec: - rupdate(op, - { - "responses": { - 200: { + rupdate( + op, + { + "requestBody": { "content": { "application/json": { - "schema": convert_schema(apispec.get("_schema"), spec) + "schema": convert_schema(apispec.get("_params"), spec) } - }, + } } - } - }) + }, + ) + + if "_schema" in apispec: + rupdate( + op, + { + "responses": { + 200: { + "content": { + "application/json": { + "schema": convert_schema(apispec.get("_schema"), spec) + } + } + } + } + }, + ) for key, val in apispec.items(): if not key in ["_params", "_schema"]: op[key] = val - + return op @@ -100,12 +114,14 @@ def convert_schema(schema, spec: APISpec): elif isinstance(schema, Mapping): return map2properties(schema, spec) else: - raise TypeError("Unsupported schema type. Ensure schema is a Schema class, or dictionary of Field objects") + raise TypeError( + "Unsupported schema type. Ensure schema is a Schema class, or dictionary of Field objects" + ) + def map2properties(schema, spec: APISpec): marshmallow_plugin = next( - plugin for plugin in spec.plugins - if isinstance(plugin, MarshmallowPlugin) + plugin for plugin in spec.plugins if isinstance(plugin, MarshmallowPlugin) ) converter = marshmallow_plugin.converter @@ -118,4 +134,4 @@ def map2properties(schema, spec: APISpec): else: d[k] = v - return {"properties": d} \ No newline at end of file + return {"properties": d} diff --git a/openflexure_microscope/common/flask_labthings/utilities.py b/openflexure_microscope/common/flask_labthings/utilities.py index 9fb110cf..4eff13ef 100644 --- a/openflexure_microscope/common/flask_labthings/utilities.py +++ b/openflexure_microscope/common/flask_labthings/utilities.py @@ -1,4 +1,7 @@ -from openflexure_microscope.common.labthings_core.utilities import get_docstring, get_summary +from openflexure_microscope.common.labthings_core.utilities import ( + get_docstring, + get_summary, +) from .schema import Schema, marshmallow, MARSHMALLOW_VERSION_INFO import collections.abc @@ -25,4 +28,4 @@ def rupdate(d, u): d[k] = rupdate(d.get(k, {}), v) else: d[k] = v - return d \ No newline at end of file + return d diff --git a/openflexure_microscope/common/flask_labthings/views/plugins.py b/openflexure_microscope/common/flask_labthings/views/plugins.py index c4e90c8c..df4d53b3 100644 --- a/openflexure_microscope/common/flask_labthings/views/plugins.py +++ b/openflexure_microscope/common/flask_labthings/views/plugins.py @@ -48,6 +48,7 @@ class PluginListResource(Resource): """ List and basic documentation for all enabled plugins """ + @marshal_with(PluginSchema(many=True)) def get(self): """ diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 4b1450cf..8c00ec07 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -39,6 +39,7 @@ class TaskList(Resource): """ List and basic documentation for all session tasks """ + @marshal_with(TaskSchema(many=True)) def get(self): return tasks.tasks() diff --git a/openflexure_microscope/common/labthings_core/utilities.py b/openflexure_microscope/common/labthings_core/utilities.py index a4e880d3..f2795517 100644 --- a/openflexure_microscope/common/labthings_core/utilities.py +++ b/openflexure_microscope/common/labthings_core/utilities.py @@ -5,5 +5,6 @@ def get_docstring(obj): else: return "" + def get_summary(obj): - return get_docstring(obj).partition("\n")[0].strip() \ No newline at end of file + return get_docstring(obj).partition("\n")[0].strip() From d96e188d168f7c5c41d57f179650e231040b095a Mon Sep 17 00:00:00 2001 From: jtc42 Date: Fri, 3 Jan 2020 00:23:00 +0000 Subject: [PATCH 055/122] Removed old debug code --- openflexure_microscope/api/app.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 4d486dd1..937c610e 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -169,7 +169,3 @@ atexit.register(cleanup) if __name__ == "__main__": app.run(host="0.0.0.0", port="5000", threaded=True, debug=True, use_reloader=False) - # from pprint import pprint - # pprint(labthing.spec.to_dict()) - # with open('spec.yaml', 'w') as f: - # f.write(labthing.spec.to_yaml()) From 440f35d9e1a038e9a9a562fbd0edd5cf2f814101 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 3 Jan 2020 15:21:32 +0000 Subject: [PATCH 056/122] Added draft v2 zip builder plugin --- .../api/default_plugins/zip_builder.py | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 openflexure_microscope/api/default_plugins/zip_builder.py diff --git a/openflexure_microscope/api/default_plugins/zip_builder.py b/openflexure_microscope/api/default_plugins/zip_builder.py new file mode 100644 index 00000000..483d694e --- /dev/null +++ b/openflexure_microscope/api/default_plugins/zip_builder.py @@ -0,0 +1,148 @@ +from openflexure_microscope.devel import ( + JsonResponse, + request, + jsonify, + taskify, + update_task_progress, +) + +from flask import send_file, abort + +import uuid +import os +import zipfile +import tempfile +import logging + +from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.plugins import BasePlugin + + +class ZipManager: + """ + ZIP-builder manager + """ + + def __init__(self): + super().__init__() + + self.session_zips = {} + + def build_zip_from_capture_ids(self, microscope, capture_id_list): + logging.debug(capture_id_list) + + # Get array of captures from IDs + capture_list = [ + microscope.camera.image_from_id(capture_id) + for capture_id in capture_id_list + ] + # Remove Nones from list (missing/invalid captures) + capture_list = [capture for capture in capture_list if capture] + + # Get size (in bytes) of each capture + capture_sizes = [ + os.path.getsize(capture_obj.file) for capture_obj in capture_list + ] + # Calculate size of input data in megabytes + data_size_megabytes = sum(capture_sizes) * 1e-6 + + # If more than 1GB + if data_size_megabytes > 1000: + # Throw exception + raise Exception( + "Zip data cannot exceed 1GB. Please transfer data manually." + ) + + # Number of files to add (used for task progress) + n_files = len(capture_id_list) + + # Create temporary file + fp = tempfile.NamedTemporaryFile(delete=False) + + # Open temp file as a ZIP file + with zipfile.ZipFile(fp, "w") as zipObj: + for index, capture_obj in enumerate(capture_list): + # Add to ZIP file if it exists + file_path = capture_obj.file + rel_path = os.path.relpath( + file_path, microscope.camera.paths["default"] + ) + zipObj.write(file_path, arcname=rel_path) + # Update task progress + update_task_progress(int((index / n_files) * 100)) + + session_id = uuid.uuid4() + # self.session_zips[session_id] = fp + self.session_zips[session_id] = { + "id": session_id, + "fp": fp, + "data_size": data_size_megabytes, + "zip_size": os.path.getsize(fp.name) * 1e-6, + } + + return self.session_zips[session_id] + + def zip_from_id(self, session_id): + return self.session_zips[session_id]["fp"] + +# Create a global ZIP manager +default_zip_manager = ZipManager() + +class ZipBuilderAPIView(Resource): + def post(self): + + ids = list(JsonResponse(request).json) + microscope = find_device("openflexure_microscope") + + task = taskify(default_zip_manager.build_zip_from_capture_ids)(microscope, ids) + + # Return a handle on the autofocus task + return jsonify(task.state), 201 + + +class ZipListAPIView(Resource): + def get(self): + return jsonify(default_zip_manager.session_zips) + + +class ZipGetterAPIView(Resource): + def get(self, session_id): + if not session_id in default_zip_manager.session_zips: + return abort(404) # 404 Not Found + + logging.info(f"Session ID: {session_id}") + + return send_file( + default_zip_manager.zip_from_id(session_id).name, + mimetype="application/zip", + as_attachment=True, + attachment_filename=f"{session_id}.zip", + ) + + def delete(self, session_id): + if not session_id in default_zip_manager.session_zips: + return abort(404) # 404 Not Found + + logging.info(f"Session ID: {session_id}") + + fp = default_zip_manager.zip_from_id(session_id) + logging.debug(fp.name) + fp.close() + os.unlink(fp.name) + + assert not os.path.exists(fp.name) + + del default_zip_manager.session_zips[session_id] + + return jsonify({"return": session_id}) + + +zip_plugin_v2 = BasePlugin("zip_builder") + +zip_plugin_v2.add_view(ZipGetterAPIView, "/get/") +zip_plugin_v2.add_view(ZipListAPIView, "/get") + +zip_plugin_v2.add_view(ZipBuilderAPIView, "/build") +zip_plugin_v2.register_action(ZipBuilderAPIView) + From 1d7eb4700caca239e78e89dbb5c19e34de246633 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 3 Jan 2020 15:22:09 +0000 Subject: [PATCH 057/122] Added zip_plugin_v2 to defaults --- openflexure_microscope/api/utilities.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index d70b5130..a2499e05 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -124,6 +124,7 @@ def init_default_plugins(plugin_path): _DEFAULT_PLUGIN_INIT = """ from openflexure_microscope.api.default_plugins.autofocus import autofocus_plugin_v2 from openflexure_microscope.api.default_plugins.scan import scan_plugin_v2 +from openflexure_microscope.api.default_plugins.zip_builder import zip_plugin_v2 -__plugins__ = [autofocus_plugin_v2, scan_plugin_v2] +__plugins__ = [autofocus_plugin_v2, scan_plugin_v2, zip_plugin_v2] """ From 2a245185a64adb3532e603b03d8ebd45f949873a Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sun, 5 Jan 2020 18:56:46 +0000 Subject: [PATCH 058/122] Renamed LabThing plugins to extensions --- openflexure_microscope/api/app.py | 16 +++---- .../__init__.py | 0 .../autofocus.py | 20 ++++----- .../scan.py | 18 ++++---- openflexure_microscope/api/utilities.py | 26 ++++++------ .../{plugins.py => extensions.py} | 20 ++++----- .../common/flask_labthings/find.py | 10 ++--- .../common/flask_labthings/labthing.py | 42 +++++++++---------- .../views/{plugins.py => extensions.py} | 24 +++++------ openflexure_microscope/config.py | 2 +- 10 files changed, 89 insertions(+), 89 deletions(-) rename openflexure_microscope/api/{default_plugins => default_extensions}/__init__.py (100%) rename openflexure_microscope/api/{default_plugins => default_extensions}/autofocus.py (95%) rename openflexure_microscope/api/{default_plugins => default_extensions}/scan.py (96%) rename openflexure_microscope/common/flask_labthings/{plugins.py => extensions.py} (87%) rename openflexure_microscope/common/flask_labthings/views/{plugins.py => extensions.py} (68%) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 937c610e..0a50ca38 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -11,16 +11,16 @@ from datetime import datetime from flask_cors import CORS -from openflexure_microscope.api.utilities import list_routes, init_default_plugins +from openflexure_microscope.api.utilities import list_routes, init_default_extensions from openflexure_microscope.config import ( settings_file_path, JSONEncoder, - USER_PLUGINS_PATH, + USER_EXTENSIONS_PATH, ) from openflexure_microscope.common.flask_labthings.labthing import LabThing -from openflexure_microscope.common.flask_labthings.plugins import find_plugins +from openflexure_microscope.common.flask_labthings.extensions import find_extensions from openflexure_microscope.api.microscope import default_microscope as api_microscope @@ -73,11 +73,11 @@ labthing.title = f"OpenFlexure Microscope {api_microscope.name}" # Attach lab devices labthing.register_device(api_microscope, "openflexure_microscope") -# Attach plugins -if not os.path.isfile(USER_PLUGINS_PATH): - init_default_plugins(USER_PLUGINS_PATH) -for plugin in find_plugins(USER_PLUGINS_PATH): - labthing.register_plugin(plugin) +# Attach extensions +if not os.path.isfile(USER_EXTENSIONS_PATH): + init_default_extensions(USER_EXTENSIONS_PATH) +for extension in find_extensions(USER_EXTENSIONS_PATH): + labthing.register_extension(extension) # Attach captures resources labthing.add_resource(views.CaptureList, f"/captures") diff --git a/openflexure_microscope/api/default_plugins/__init__.py b/openflexure_microscope/api/default_extensions/__init__.py similarity index 100% rename from openflexure_microscope/api/default_plugins/__init__.py rename to openflexure_microscope/api/default_extensions/__init__.py diff --git a/openflexure_microscope/api/default_plugins/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py similarity index 95% rename from openflexure_microscope/api/default_plugins/autofocus.py rename to openflexure_microscope/api/default_extensions/autofocus.py index 94ffdb83..7e656543 100644 --- a/openflexure_microscope/api/default_plugins/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -1,5 +1,5 @@ from openflexure_microscope.common.flask_labthings.find import find_device -from openflexure_microscope.common.flask_labthings.plugins import BasePlugin +from openflexure_microscope.common.flask_labthings.extensions import BaseExtension from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.devel import JsonResponse, request, jsonify, taskify, abort @@ -139,7 +139,7 @@ def sharpness_edge(image): ) -### Autofocus plugin +### Autofocus extension def measure_sharpness(microscope, metric_fn=sharpness_sum_lap2): @@ -353,15 +353,15 @@ class FastAutofocusAPI(Resource): abort(503, "No stage connected. Unable to autofocus.") -autofocus_plugin_v2 = BasePlugin("autofocus") +autofocus_extension_v2 = BaseExtension("autofocus") -autofocus_plugin_v2.add_method(fast_autofocus, "fast_autofocus") -autofocus_plugin_v2.add_method(autofocus, "autofocus") +autofocus_extension_v2.add_method(fast_autofocus, "fast_autofocus") +autofocus_extension_v2.add_method(autofocus, "autofocus") -autofocus_plugin_v2.add_view(MeasureSharpnessAPI, "/measure_sharpness") +autofocus_extension_v2.add_view(MeasureSharpnessAPI, "/measure_sharpness") -autofocus_plugin_v2.add_view(AutofocusAPI, "/autofocus") -autofocus_plugin_v2.register_action(AutofocusAPI) +autofocus_extension_v2.add_view(AutofocusAPI, "/autofocus") +autofocus_extension_v2.register_action(AutofocusAPI) -autofocus_plugin_v2.add_view(FastAutofocusAPI, "/fast_autofocus") -autofocus_plugin_v2.register_action(FastAutofocusAPI) +autofocus_extension_v2.add_view(FastAutofocusAPI, "/fast_autofocus") +autofocus_extension_v2.register_action(FastAutofocusAPI) diff --git a/openflexure_microscope/api/default_plugins/scan.py b/openflexure_microscope/api/default_extensions/scan.py similarity index 96% rename from openflexure_microscope/api/default_plugins/scan.py rename to openflexure_microscope/api/default_extensions/scan.py index b75aaa70..8cb4dd71 100644 --- a/openflexure_microscope/api/default_plugins/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -6,8 +6,8 @@ from typing import Tuple from functools import reduce from openflexure_microscope.camera.base import generate_basename -from openflexure_microscope.common.flask_labthings.find import find_device, find_plugin -from openflexure_microscope.common.flask_labthings.plugins import BasePlugin +from openflexure_microscope.common.flask_labthings.find import find_device, find_extension +from openflexure_microscope.common.flask_labthings.extensions import BaseExtension from openflexure_microscope.devel import ( JsonResponse, @@ -169,10 +169,10 @@ def tile( ) # Check if autofocus is enabled - autofocus_plugin = find_plugin("autofocus") + autofocus_extension = find_extension("autofocus") if ( autofocus_dz - and autofocus_plugin + and autofocus_extension and microscope.has_real_stage() and microscope.has_real_camera() ): @@ -211,7 +211,7 @@ def tile( # Refocus if autofocus_enabled: if fast_autofocus: - autofocus_plugin.fast_autofocus( + autofocus_extension.fast_autofocus( dz=autofocus_dz, target_z=-z_stack_dz / 2.0, # Finish below the focus initial_move_up=False, # We're already at the top of the scan @@ -219,7 +219,7 @@ def tile( # TODO: save the focus data for future reference? Use it for diagnostics? else: logging.debug("Running autofocus") - autofocus_plugin.autofocus( + autofocus_extension.autofocus( range(-3 * autofocus_dz, 4 * autofocus_dz, autofocus_dz) ) logging.debug("Finished autofocus") @@ -398,7 +398,7 @@ class TileScanAPI(Resource): return jsonify(task.state), 201 -scan_plugin_v2 = BasePlugin("scan") +scan_extension_v2 = BaseExtension("scan") -scan_plugin_v2.add_view(TileScanAPI, "/tile") -scan_plugin_v2.register_action(TileScanAPI) +scan_extension_v2.add_view(TileScanAPI, "/tile") +scan_extension_v2.register_action(TileScanAPI) diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index d70b5130..0bc4b612 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -108,22 +108,22 @@ def create_file(config_path): raise -def init_default_plugins(plugin_path): - global _DEFAULT_PLUGIN_INIT - os.makedirs(os.path.dirname(plugin_path), exist_ok=True) +def init_default_extensions(extension_path): + global _DEFAULT_extension_INIT + os.makedirs(os.path.dirname(extension_path), exist_ok=True) - if not os.path.exists(plugin_path): # If user plugins file doesn't exist - logging.warning("No plugin file found at {}. Creating...".format(plugin_path)) - create_file(plugin_path) + if not os.path.exists(extension_path): # If user extensions file doesn't exist + logging.warning("No extension file found at {}. Creating...".format(extension_path)) + create_file(extension_path) - logging.info("Populating {}...".format(plugin_path)) - with open(plugin_path, "w") as outfile: - outfile.write(_DEFAULT_PLUGIN_INIT) + logging.info("Populating {}...".format(extension_path)) + with open(extension_path, "w") as outfile: + outfile.write(_DEFAULT_extension_INIT) -_DEFAULT_PLUGIN_INIT = """ -from openflexure_microscope.api.default_plugins.autofocus import autofocus_plugin_v2 -from openflexure_microscope.api.default_plugins.scan import scan_plugin_v2 +_DEFAULT_extension_INIT = """ +from openflexure_microscope.api.default_extensions.autofocus import autofocus_extension_v2 +from openflexure_microscope.api.default_extensions.scan import scan_extension_v2 -__plugins__ = [autofocus_plugin_v2, scan_plugin_v2] +__extensions__ = [autofocus_extension_v2, scan_extension_v2] """ diff --git a/openflexure_microscope/common/flask_labthings/plugins.py b/openflexure_microscope/common/flask_labthings/extensions.py similarity index 87% rename from openflexure_microscope/common/flask_labthings/plugins.py rename to openflexure_microscope/common/flask_labthings/extensions.py index b4dea914..d433b235 100644 --- a/openflexure_microscope/common/flask_labthings/plugins.py +++ b/openflexure_microscope/common/flask_labthings/extensions.py @@ -9,9 +9,9 @@ from openflexure_microscope.common.labthings_core.utilities import get_docstring from openflexure_microscope.utilities import camel_to_snake, snake_to_spine -class BasePlugin: +class BaseExtension: """ - Parent class for all plugins. + Parent class for all extensions. Handles binding route views and forms. """ @@ -23,7 +23,7 @@ class BasePlugin: self._rules = {} # Key: Original rule. Val: View class self._gui = None - self._cls = str(self) # String description of plugin instance + self._cls = str(self) # String description of extension instance self.actions = [] self.properties = [] @@ -43,7 +43,7 @@ class BasePlugin: while cleaned_rule[0] == "/": cleaned_rule = cleaned_rule[1:] - # Expand the rule to include plugin name + # Expand the rule to include extension name full_rule = "/{}/{}".format(self._name_uri_safe, cleaned_rule) view_id = cleaned_rule.replace("/", "_").replace("<", "").replace(">", "") @@ -119,20 +119,20 @@ class BasePlugin: setattr(self, method_name, method) else: logging.warning( - "Unable to bind method to plugin. Method name already exists." + "Unable to bind method to extension. Method name already exists." ) -def find_plugins(plugin_path, module_name="plugins"): - logging.debug(f"Loading plugins from {plugin_path}") +def find_extensions(extension_path, module_name="extensions"): + logging.debug(f"Loading extensions from {extension_path}") - spec = util.spec_from_file_location(module_name, plugin_path) + spec = util.spec_from_file_location(module_name, extension_path) mod = util.module_from_spec(spec) sys.modules[spec.name] = mod spec.loader.exec_module(mod) - if hasattr(mod, "__plugins__"): - return mod.__plugins__ + if hasattr(mod, "__extensions__"): + return mod.__extensions__ else: return None diff --git a/openflexure_microscope/common/flask_labthings/find.py b/openflexure_microscope/common/flask_labthings/find.py index 6952e161..d31f5b5d 100644 --- a/openflexure_microscope/common/flask_labthings/find.py +++ b/openflexure_microscope/common/flask_labthings/find.py @@ -15,10 +15,10 @@ def current_labthing(): return app.extensions[EXTENSION_NAME] -def registered_plugins(labthing_instance=None): +def registered_extensions(labthing_instance=None): if not labthing_instance: labthing_instance = current_labthing() - return labthing_instance.plugins + return labthing_instance.extensions def registered_devices(labthing_instance=None): @@ -37,14 +37,14 @@ def find_device(device_name, labthing_instance=None): return None -def find_plugin(plugin_name, labthing_instance=None): +def find_extension(extension_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] + if extension_name in labthing_instance.extensions: + return labthing_instance.extensions[extension_name] else: return None diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index f98fb4a8..687b62f3 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -2,8 +2,8 @@ from flask import url_for, jsonify from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin -from .plugins import BasePlugin -from .views.plugins import PluginListResource +from .extensions import BaseExtension +from .views.extensions import ExtensionListResource from .views.tasks import TaskList, TaskResource from .spec import view2path @@ -32,7 +32,7 @@ class LabThing(object): self.devices = {} - self.plugins = {} + self.extensions = {} self.resources = [] self.properties = {} @@ -121,9 +121,9 @@ class LabThing(object): self._complete_url("/swagger", ""), "swagger", self.swagger ) - # Add plugin overview - self.add_resource(PluginListResource, "/plugins") - self.register_property(PluginListResource) + # Add extension overview + self.add_resource(ExtensionListResource, "/extensions") + self.register_property(ExtensionListResource) # Add task routes self.add_resource(TaskList, "/tasks") self.register_property(TaskList) @@ -134,26 +134,26 @@ class LabThing(object): def register_device(self, device_object, device_name: str): self.devices[device_name] = device_object - ### Plugin stuff + ### Extension stuff - def register_plugin(self, plugin_object): - if isinstance(plugin_object, BasePlugin): - self.plugins[plugin_object.name] = plugin_object + def register_extension(self, extension_object): + if isinstance(extension_object, BaseExtension): + self.extensions[extension_object.name] = extension_object else: - raise TypeError("Plugin object must be an instance of BasePlugin") + raise TypeError("Extension object must be an instance of BaseExtension") - for plugin_view_id, plugin_view in plugin_object.views.items(): - # Add route to the plugins blueprint + for extension_view_id, extension_view in extension_object.views.items(): + # Add route to the extensions blueprint self.add_resource( - plugin_view["view"], - "/plugins" + plugin_view["rule"], - **plugin_view["kwargs"], + extension_view["view"], + "/extensions" + extension_view["rule"], + **extension_view["kwargs"], ) - for prop in plugin_object.properties: + for prop in extension_object.properties: self.register_property(prop) - for action in plugin_object.actions: + for action in extension_object.actions: self.register_action(action) ### Resource stuff @@ -327,9 +327,9 @@ class LabThing(object): "description": get_docstring(self.swagger), "methods": ["GET"], }, - "plugins": { - "href": self.url_for(PluginListResource, _external=True), - **description_from_view(PluginListResource), + "extensions": { + "href": self.url_for(ExtensionListResource, _external=True), + **description_from_view(ExtensionListResource), }, "tasks": { "href": self.url_for(TaskList, _external=True), diff --git a/openflexure_microscope/common/flask_labthings/views/plugins.py b/openflexure_microscope/common/flask_labthings/views/extensions.py similarity index 68% rename from openflexure_microscope/common/flask_labthings/views/plugins.py rename to openflexure_microscope/common/flask_labthings/views/extensions.py index df4d53b3..057b4ce1 100644 --- a/openflexure_microscope/common/flask_labthings/views/plugins.py +++ b/openflexure_microscope/common/flask_labthings/views/extensions.py @@ -1,12 +1,12 @@ """ -Top-level representation of attached and enabled plugins +Top-level representation of attached and enabled Extensions """ from openflexure_microscope.common.labthings_core.utilities import get_docstring from ..utilities import description_from_view from openflexure_microscope.common.flask_labthings.resource import Resource -from openflexure_microscope.common.flask_labthings.find import registered_plugins +from openflexure_microscope.common.flask_labthings.find import registered_extensions from openflexure_microscope.common.flask_labthings.schema import Schema from openflexure_microscope.common.flask_labthings.decorators import marshal_with from openflexure_microscope.common.flask_labthings import fields @@ -17,7 +17,7 @@ from flask import jsonify, url_for import logging -class PluginSchema(Schema): +class ExtensionSchema(Schema): name = fields.String(data_key="title") _name_python_safe = fields.String(data_key="pythonName") _cls = fields.String(data_key="pythonObject") @@ -44,23 +44,23 @@ class PluginSchema(Schema): return data -class PluginListResource(Resource): +class ExtensionListResource(Resource): """ - List and basic documentation for all enabled plugins + List and basic documentation for all enabled Extensions """ - @marshal_with(PluginSchema(many=True)) + @marshal_with(ExtensionSchema(many=True)) def get(self): """ - Return the current plugin forms + Return the current Extension forms - .. :quickref: Plugin; Get forms + .. :quickref: Extension; 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 + Returns an array of present Extension forms (describing Extension user interfaces.) + Please note, this is *not* a list of all enabled Extensions, only those with associated user interface forms. - A complete list of enabled plugins can be found in the microscope state. + A complete list of enabled Extensions can be found in the microscope state. """ - return registered_plugins().values() + return registered_extensions().values() diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index d7c61510..ed813c5c 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -182,7 +182,7 @@ DEFAULT_CONFIG_FILE_PATH = os.path.join(HERE, "microscope_settings.default.json" USER_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".openflexure") USER_CONFIG_FILE_PATH = os.path.join(USER_CONFIG_DIR, "microscope_settings.json") -USER_PLUGINS_PATH = os.path.join(USER_CONFIG_DIR, "microscope_plugins", "__init__.py") +USER_EXTENSIONS_PATH = os.path.join(USER_CONFIG_DIR, "microscope_extensions", "__init__.py") # Load the default config with open(DEFAULT_CONFIG_FILE_PATH, "r") as default_rc: From 932353de3182850d9917bdbfbcdc1189c393aa1f Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sun, 5 Jan 2020 19:06:45 +0000 Subject: [PATCH 059/122] Updated ZIP builder to extension --- .../api/default_extensions/zip_builder.py | 12 ++++++------ openflexure_microscope/api/utilities.py | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 483d694e..26591a1f 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -16,7 +16,7 @@ import logging from openflexure_microscope.common.flask_labthings.find import find_device from openflexure_microscope.common.flask_labthings.resource import Resource -from openflexure_microscope.common.flask_labthings.plugins import BasePlugin +from openflexure_microscope.common.flask_labthings.extensions import BaseExtension class ZipManager: @@ -138,11 +138,11 @@ class ZipGetterAPIView(Resource): return jsonify({"return": session_id}) -zip_plugin_v2 = BasePlugin("zip_builder") +zip_extension_v2 = BaseExtension("zip_builder") -zip_plugin_v2.add_view(ZipGetterAPIView, "/get/") -zip_plugin_v2.add_view(ZipListAPIView, "/get") +zip_extension_v2.add_view(ZipGetterAPIView, "/get/") +zip_extension_v2.add_view(ZipListAPIView, "/get") -zip_plugin_v2.add_view(ZipBuilderAPIView, "/build") -zip_plugin_v2.register_action(ZipBuilderAPIView) +zip_extension_v2.add_view(ZipBuilderAPIView, "/build") +zip_extension_v2.register_action(ZipBuilderAPIView) diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index 0bc4b612..393bd736 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -124,6 +124,7 @@ def init_default_extensions(extension_path): _DEFAULT_extension_INIT = """ from openflexure_microscope.api.default_extensions.autofocus import autofocus_extension_v2 from openflexure_microscope.api.default_extensions.scan import scan_extension_v2 +from openflexure_microscope.api.default_extensions.zip_builder import zip_extension_v2 -__extensions__ = [autofocus_extension_v2, scan_extension_v2] +__extensions__ = [autofocus_extension_v2, scan_extension_v2, zip_extension_v2] """ From 7888d972202181ca0af8ae0a6863041cff3f3845 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sun, 5 Jan 2020 19:07:28 +0000 Subject: [PATCH 060/122] Fixed extension URLs with URL params --- .../common/flask_labthings/views/extensions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/common/flask_labthings/views/extensions.py b/openflexure_microscope/common/flask_labthings/views/extensions.py index 057b4ce1..a9bc14a0 100644 --- a/openflexure_microscope/common/flask_labthings/views/extensions.py +++ b/openflexure_microscope/common/flask_labthings/views/extensions.py @@ -33,9 +33,10 @@ class ExtensionSchema(Schema): for view_id, view_data in data.views.items(): view_cls = view_data["view"] view_kwargs = view_data["kwargs"] + view_rule = view_data["rule"] # Make links dictionary if it doesn't yet exist d[view_id] = { - "href": url_for(view_cls.endpoint, **view_kwargs, _external=True), + "href": url_for(ExtensionListResource.endpoint, **view_kwargs, _external=True) + view_rule, **description_from_view(view_cls), } From 585633020b12a291549f9dc46c0c6f1d5483de36 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Sun, 5 Jan 2020 20:02:22 +0000 Subject: [PATCH 061/122] Automatically register all extensions in the extension directory (without explicit init import) --- .../api/default_extensions/__init__.py | 3 ++ openflexure_microscope/api/utilities.py | 12 ++------ .../common/flask_labthings/extensions.py | 29 +++++++++++++++++-- openflexure_microscope/config.py | 2 +- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/__init__.py b/openflexure_microscope/api/default_extensions/__init__.py index e69de29b..3508bead 100644 --- a/openflexure_microscope/api/default_extensions/__init__.py +++ b/openflexure_microscope/api/default_extensions/__init__.py @@ -0,0 +1,3 @@ +from .autofocus import autofocus_extension_v2 +from .scan import scan_extension_v2 +from .zip_builder import zip_extension_v2 \ No newline at end of file diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index 393bd736..ada63330 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -109,7 +109,7 @@ def create_file(config_path): def init_default_extensions(extension_path): - global _DEFAULT_extension_INIT + global _DEFAULT_EXTENSION_INIT os.makedirs(os.path.dirname(extension_path), exist_ok=True) if not os.path.exists(extension_path): # If user extensions file doesn't exist @@ -118,13 +118,7 @@ def init_default_extensions(extension_path): logging.info("Populating {}...".format(extension_path)) with open(extension_path, "w") as outfile: - outfile.write(_DEFAULT_extension_INIT) + outfile.write(_DEFAULT_EXTENSION_INIT) -_DEFAULT_extension_INIT = """ -from openflexure_microscope.api.default_extensions.autofocus import autofocus_extension_v2 -from openflexure_microscope.api.default_extensions.scan import scan_extension_v2 -from openflexure_microscope.api.default_extensions.zip_builder import zip_extension_v2 - -__extensions__ = [autofocus_extension_v2, scan_extension_v2, zip_extension_v2] -""" +_DEFAULT_EXTENSION_INIT = "from openflexure_microscope.api.default_extensions import *" diff --git a/openflexure_microscope/common/flask_labthings/extensions.py b/openflexure_microscope/common/flask_labthings/extensions.py index d433b235..5571bd83 100644 --- a/openflexure_microscope/common/flask_labthings/extensions.py +++ b/openflexure_microscope/common/flask_labthings/extensions.py @@ -4,6 +4,8 @@ import copy from importlib import util import sys +import os +import glob from openflexure_microscope.common.labthings_core.utilities import get_docstring from openflexure_microscope.utilities import camel_to_snake, snake_to_spine @@ -123,7 +125,16 @@ class BaseExtension: ) -def find_extensions(extension_path, module_name="extensions"): +def find_instances_in_module(module, class_to_find): + objs = [] + for attribute in dir(module): + if not attribute.startswith("__"): + if isinstance(getattr(module, attribute), class_to_find): + objs.append(getattr(module, attribute)) + return objs + + +def find_extensions_in_file(extension_path, module_name="extensions"): logging.debug(f"Loading extensions from {extension_path}") spec = util.spec_from_file_location(module_name, extension_path) @@ -133,6 +144,18 @@ def find_extensions(extension_path, module_name="extensions"): spec.loader.exec_module(mod) if hasattr(mod, "__extensions__"): - return mod.__extensions__ + return [getattr(mod, ext_name) for ext_name in mod.__extensions__] else: - return None + return find_instances_in_module(mod, BaseExtension) + + +def find_extensions(extension_dir, module_name="extensions"): + logging.debug(f"Loading extensions from {extension_dir}") + + extensions = [] + extension_paths = glob.glob(os.path.join(extension_dir, "*.py")) + + for extension_path in extension_paths: + extensions.extend(find_extensions_in_file(extension_path, module_name=module_name)) + + return extensions \ No newline at end of file diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index ed813c5c..bfe09ced 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -182,7 +182,7 @@ DEFAULT_CONFIG_FILE_PATH = os.path.join(HERE, "microscope_settings.default.json" USER_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".openflexure") USER_CONFIG_FILE_PATH = os.path.join(USER_CONFIG_DIR, "microscope_settings.json") -USER_EXTENSIONS_PATH = os.path.join(USER_CONFIG_DIR, "microscope_extensions", "__init__.py") +USER_EXTENSIONS_PATH = os.path.join(USER_CONFIG_DIR, "microscope_extensions") # Load the default config with open(DEFAULT_CONFIG_FILE_PATH, "r") as default_rc: From 829c58d3fb329f41aa81ca65604cbac3499089be Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 15:00:48 +0000 Subject: [PATCH 062/122] Blackened --- openflexure_microscope/api/default_extensions/__init__.py | 2 +- openflexure_microscope/api/default_extensions/scan.py | 5 ++++- .../api/default_extensions/zip_builder.py | 3 ++- openflexure_microscope/api/utilities.py | 4 +++- openflexure_microscope/common/flask_labthings/extensions.py | 6 ++++-- .../common/flask_labthings/views/extensions.py | 5 ++++- 6 files changed, 18 insertions(+), 7 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/__init__.py b/openflexure_microscope/api/default_extensions/__init__.py index 3508bead..9ad73b3c 100644 --- a/openflexure_microscope/api/default_extensions/__init__.py +++ b/openflexure_microscope/api/default_extensions/__init__.py @@ -1,3 +1,3 @@ from .autofocus import autofocus_extension_v2 from .scan import scan_extension_v2 -from .zip_builder import zip_extension_v2 \ No newline at end of file +from .zip_builder import zip_extension_v2 diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 8cb4dd71..0cf42084 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -6,7 +6,10 @@ from typing import Tuple from functools import reduce from openflexure_microscope.camera.base import generate_basename -from openflexure_microscope.common.flask_labthings.find import find_device, find_extension +from openflexure_microscope.common.flask_labthings.find import ( + find_device, + find_extension, +) from openflexure_microscope.common.flask_labthings.extensions import BaseExtension from openflexure_microscope.devel import ( diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 26591a1f..45b119a2 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -86,9 +86,11 @@ class ZipManager: def zip_from_id(self, session_id): return self.session_zips[session_id]["fp"] + # Create a global ZIP manager default_zip_manager = ZipManager() + class ZipBuilderAPIView(Resource): def post(self): @@ -145,4 +147,3 @@ zip_extension_v2.add_view(ZipListAPIView, "/get") zip_extension_v2.add_view(ZipBuilderAPIView, "/build") zip_extension_v2.register_action(ZipBuilderAPIView) - diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index ada63330..d4d8c804 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -113,7 +113,9 @@ def init_default_extensions(extension_path): os.makedirs(os.path.dirname(extension_path), exist_ok=True) if not os.path.exists(extension_path): # If user extensions file doesn't exist - logging.warning("No extension file found at {}. Creating...".format(extension_path)) + logging.warning( + "No extension file found at {}. Creating...".format(extension_path) + ) create_file(extension_path) logging.info("Populating {}...".format(extension_path)) diff --git a/openflexure_microscope/common/flask_labthings/extensions.py b/openflexure_microscope/common/flask_labthings/extensions.py index 5571bd83..66dbfc4e 100644 --- a/openflexure_microscope/common/flask_labthings/extensions.py +++ b/openflexure_microscope/common/flask_labthings/extensions.py @@ -156,6 +156,8 @@ def find_extensions(extension_dir, module_name="extensions"): extension_paths = glob.glob(os.path.join(extension_dir, "*.py")) for extension_path in extension_paths: - extensions.extend(find_extensions_in_file(extension_path, module_name=module_name)) + extensions.extend( + find_extensions_in_file(extension_path, module_name=module_name) + ) - return extensions \ No newline at end of file + return extensions diff --git a/openflexure_microscope/common/flask_labthings/views/extensions.py b/openflexure_microscope/common/flask_labthings/views/extensions.py index a9bc14a0..f4f7c749 100644 --- a/openflexure_microscope/common/flask_labthings/views/extensions.py +++ b/openflexure_microscope/common/flask_labthings/views/extensions.py @@ -36,7 +36,10 @@ class ExtensionSchema(Schema): view_rule = view_data["rule"] # Make links dictionary if it doesn't yet exist d[view_id] = { - "href": url_for(ExtensionListResource.endpoint, **view_kwargs, _external=True) + view_rule, + "href": url_for( + ExtensionListResource.endpoint, **view_kwargs, _external=True + ) + + view_rule, **description_from_view(view_cls), } From 79abc3fee373c8e8e699280e59b7b6d621d080f3 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 15:09:29 +0000 Subject: [PATCH 063/122] Added quick create_app function --- openflexure_microscope/api/app.py | 18 ++++---- .../common/flask_labthings/labthing.py | 12 +----- .../common/flask_labthings/quick.py | 41 +++++++++++++++++++ 3 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 openflexure_microscope/common/flask_labthings/quick.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 0a50ca38..a08d5602 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -19,7 +19,7 @@ from openflexure_microscope.config import ( USER_EXTENSIONS_PATH, ) -from openflexure_microscope.common.flask_labthings.labthing import LabThing +from openflexure_microscope.common.flask_labthings.quick import create_app from openflexure_microscope.common.flask_labthings.extensions import find_extensions from openflexure_microscope.api.microscope import default_microscope as api_microscope @@ -56,20 +56,16 @@ else: # Create flask app -app = Flask(__name__) -app.url_map.strict_slashes = False +app, labthing = create_app( + __name__, + prefix="/api/v2", + description="Test LabThing-based API for OpenFlexure Microscope", + title=f"OpenFlexure Microscope {api_microscope.name}", +) # Use custom JSON encoder app.json_encoder = JSONEncoder -# Enable CORS everywhere -CORS(app, resources=r"*") - -# Build a labthing -labthing = LabThing(app, prefix="/api/v2") -labthing.description = "Test LabThing-based API for OpenFlexure Microscope" -labthing.title = f"OpenFlexure Microscope {api_microscope.name}" - # Attach lab devices labthing.register_device(api_microscope, "openflexure_microscope") diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index 687b62f3..46ca187d 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -11,7 +11,6 @@ from .spec import view2path from .utilities import description_from_view from openflexure_microscope.common.labthings_core.utilities import get_docstring -from .exceptions import JSONExceptionHandler from . import EXTENSION_NAME @@ -26,7 +25,6 @@ class LabThing(object): title: str = "", description: str = "", version: str = "0.0.0", - handle_errors: bool = True, ): self.app = app @@ -45,10 +43,8 @@ class LabThing(object): self._title = title self._version = version - if handle_errors: - self.error_handler = JSONExceptionHandler() - else: - self.error_handler = None + # Store handlers for things like errors and CORS + self.handlers = {} self.spec = APISpec( title=self.title, @@ -96,10 +92,6 @@ class LabThing(object): 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) - # Add resources, if registered before tying to a Flask app if len(self.resources) > 0: for resource, urls, endpoint, kwargs in self.resources: diff --git a/openflexure_microscope/common/flask_labthings/quick.py b/openflexure_microscope/common/flask_labthings/quick.py new file mode 100644 index 00000000..67b0dc71 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/quick.py @@ -0,0 +1,41 @@ +from flask import Flask +from flask_cors import CORS + +from .labthing import LabThing +from .exceptions import JSONExceptionHandler + + +def create_app( + import_name, + prefix: str = "", + title: str = "", + description: str = "", + version: str = "0.0.0", + handle_errors: bool = True, + handle_cors: bool = True, + flask_kwargs: dict = {}, +): + app = Flask(import_name, **flask_kwargs) + app.url_map.strict_slashes = False + + # Handle CORS + if handle_cors: + cors_handler = CORS(app, resources=r"{prefix}/*") + + # Handle errors + if handle_errors: + error_handler = JSONExceptionHandler() + error_handler.init_app(app) + + # Create a LabThing + labthing = LabThing( + app, prefix=prefix, title=title, description=description, version=version + ) + + # Store references to added-in handlers + if cors_handler: + labthing.handlers["cors"] = cors_handler + if error_handler: + labthing.handlers["error"] = error_handler + + return app, labthing From 70149e973237c59e32c66dd0c694225c82962a1d Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 16:45:38 +0000 Subject: [PATCH 064/122] Cleaned code structure --- .../api/v2/views/actions/camera.py | 1 + openflexure_microscope/api/v2/views/state.py | 7 +- .../api/v2/views/streams.py | 7 +- .../common/flask_labthings/decorators.py | 30 +-- .../common/flask_labthings/extensions.py | 14 +- .../common/flask_labthings/fields.py | 176 +----------------- .../common/flask_labthings/labthing.py | 17 +- .../common/flask_labthings/resource.py | 15 +- .../common/flask_labthings/schema.py | 40 +--- .../common/flask_labthings/utilities.py | 15 -- .../flask_labthings/views/extensions.py | 14 +- .../common/flask_labthings/views/tasks.py | 7 +- .../common/labthings_core/decorators.py | 26 +++ .../common/labthings_core/fields.py | 1 + .../common/labthings_core/resource.py | 20 ++ .../common/labthings_core/schema.py | 39 ++++ .../spec.py | 8 +- .../common/labthings_core/utilities.py | 53 ++++++ openflexure_microscope/microscope.py | 3 +- openflexure_microscope/utilities.py | 35 ---- 20 files changed, 196 insertions(+), 332 deletions(-) create mode 100644 openflexure_microscope/common/labthings_core/decorators.py create mode 100644 openflexure_microscope/common/labthings_core/fields.py create mode 100644 openflexure_microscope/common/labthings_core/resource.py create mode 100644 openflexure_microscope/common/labthings_core/schema.py rename openflexure_microscope/common/{flask_labthings => labthings_core}/spec.py (94%) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 7ae5635c..900f3f17 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -7,6 +7,7 @@ from openflexure_microscope.common.flask_labthings.decorators import ( doc, doc_response, ) + from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.utilities import filter_dict diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py index 84c37850..e1ae2745 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/state.py @@ -1,5 +1,10 @@ from openflexure_microscope.api.utilities import JsonResponse -from openflexure_microscope.utilities import get_by_path, set_by_path, create_from_path + +from openflexure_microscope.common.labthings_core.utilities import ( + get_by_path, + set_by_path, + create_from_path, +) from openflexure_microscope.common.flask_labthings.find import find_device from openflexure_microscope.common.flask_labthings.resource import Resource diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index 51d8ecb1..70cb8d3a 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -1,5 +1,10 @@ from openflexure_microscope.api.utilities import gen, JsonResponse -from openflexure_microscope.utilities import get_by_path, set_by_path, create_from_path + +from openflexure_microscope.common.labthings_core.utilities import ( + get_by_path, + set_by_path, + create_from_path, +) from openflexure_microscope.common.flask_labthings.find import find_device from openflexure_microscope.common.flask_labthings.resource import Resource diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index dbab2caa..fa8e9ac0 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -2,8 +2,10 @@ from webargs import flaskparser from functools import wraps, update_wrapper from flask import make_response -from .utilities import rupdate -from .spec import update_spec +from openflexure_microscope.common.labthings_core.utilities import rupdate +from openflexure_microscope.common.labthings_core.spec import update_spec + +from openflexure_microscope.common.labthings_core.decorators import doc, doc_response def unpack(value): @@ -74,27 +76,3 @@ class use_kwargs(use_args): """ kwargs["as_kwargs"] = True use_args.__init__(self, schema, **kwargs) - - -class doc(object): - def __init__(self, **kwargs): - self.kwargs = kwargs - - def __call__(self, f): - # Pass params to call function attribute for external access - update_spec(f, self.kwargs) - return f - - -class doc_response(object): - def __init__(self, code, description, **kwargs): - self.code = code - self.description = description - self.kwargs = kwargs - - def __call__(self, f): - # Pass params to call function attribute for external access - f.__apispec__ = f.__dict__.get("__apispec__", {}) - d = {"responses": {self.code: {"description": self.description, **self.kwargs}}} - rupdate(f.__apispec__, d) - return f diff --git a/openflexure_microscope/common/flask_labthings/extensions.py b/openflexure_microscope/common/flask_labthings/extensions.py index 66dbfc4e..dabc65f1 100644 --- a/openflexure_microscope/common/flask_labthings/extensions.py +++ b/openflexure_microscope/common/flask_labthings/extensions.py @@ -7,8 +7,14 @@ import sys import os import glob -from openflexure_microscope.common.labthings_core.utilities import get_docstring -from openflexure_microscope.utilities import camel_to_snake, snake_to_spine +from openflexure_microscope.common.labthings_core.utilities import ( + get_docstring, + camel_to_snake, + snake_to_spine, +) + + +# TODO: Move into core? class BaseExtension: @@ -134,7 +140,7 @@ def find_instances_in_module(module, class_to_find): return objs -def find_extensions_in_file(extension_path, module_name="extensions"): +def find_extensions_in_file(extension_path: str, module_name="extensions"): logging.debug(f"Loading extensions from {extension_path}") spec = util.spec_from_file_location(module_name, extension_path) @@ -149,7 +155,7 @@ def find_extensions_in_file(extension_path, module_name="extensions"): return find_instances_in_module(mod, BaseExtension) -def find_extensions(extension_dir, module_name="extensions"): +def find_extensions(extension_dir: str, module_name="extensions"): logging.debug(f"Loading extensions from {extension_dir}") extensions = [] diff --git a/openflexure_microscope/common/flask_labthings/fields.py b/openflexure_microscope/common/flask_labthings/fields.py index 32dbfb18..7d413696 100644 --- a/openflexure_microscope/common/flask_labthings/fields.py +++ b/openflexure_microscope/common/flask_labthings/fields.py @@ -1,175 +1 @@ -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='') - https_url = URLFor('author_get', 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=''), - 'collection': URLFor('author_list'), - }) - `URLFor` objects can be nested within the dictionary. :: - _links = Hyperlinks({ - 'self': { - 'href': URLFor('book', 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) +from openflexure_microscope.common.labthings_core.fields import * diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index 46ca187d..b1a64db3 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -3,14 +3,13 @@ from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin from .extensions import BaseExtension -from .views.extensions import ExtensionListResource -from .views.tasks import TaskList, TaskResource - -from .spec import view2path - from .utilities import description_from_view +from .views.extensions import ExtensionList +from .views.tasks import TaskList, TaskResource + from openflexure_microscope.common.labthings_core.utilities import get_docstring +from openflexure_microscope.common.labthings_core.spec import view2path from . import EXTENSION_NAME @@ -114,8 +113,8 @@ class LabThing(object): ) # Add extension overview - self.add_resource(ExtensionListResource, "/extensions") - self.register_property(ExtensionListResource) + self.add_resource(ExtensionList, "/extensions") + self.register_property(ExtensionList) # Add task routes self.add_resource(TaskList, "/tasks") self.register_property(TaskList) @@ -320,8 +319,8 @@ class LabThing(object): "methods": ["GET"], }, "extensions": { - "href": self.url_for(ExtensionListResource, _external=True), - **description_from_view(ExtensionListResource), + "href": self.url_for(ExtensionList, _external=True), + **description_from_view(ExtensionList), }, "tasks": { "href": self.url_for(TaskList, _external=True), diff --git a/openflexure_microscope/common/flask_labthings/resource.py b/openflexure_microscope/common/flask_labthings/resource.py index fd2575e2..6f598adb 100644 --- a/openflexure_microscope/common/flask_labthings/resource.py +++ b/openflexure_microscope/common/flask_labthings/resource.py @@ -1,23 +1,12 @@ from flask.views import MethodView +from openflexure_microscope.common.labthings_core.resource import BaseResource -class Resource(MethodView): +class Resource(MethodView, BaseResource): """Currently identical to MethodView """ endpoint = None - methods = ["get", "post", "put", "delete"] def __init__(self, *args, **kwargs): MethodView.__init__(self, *args, **kwargs) - - def doc(self): - docs = {"operations": {}} - if hasattr(self, "__apispec__"): - docs.update(self.__apispec__) - - for meth in Resource.methods: - if hasattr(self, meth) and hasattr(getattr(self, meth), "__apispec__"): - docs["operations"][meth] = {} - docs["operations"][meth] = getattr(self, meth).__apispec__ - return docs diff --git a/openflexure_microscope/common/flask_labthings/schema.py b/openflexure_microscope/common/flask_labthings/schema.py index 83294a0e..b9ae889a 100644 --- a/openflexure_microscope/common/flask_labthings/schema.py +++ b/openflexure_microscope/common/flask_labthings/schema.py @@ -1,39 +1 @@ -# -*- 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) +from openflexure_microscope.common.labthings_core.schema import * diff --git a/openflexure_microscope/common/flask_labthings/utilities.py b/openflexure_microscope/common/flask_labthings/utilities.py index 4eff13ef..a0b152e5 100644 --- a/openflexure_microscope/common/flask_labthings/utilities.py +++ b/openflexure_microscope/common/flask_labthings/utilities.py @@ -2,10 +2,6 @@ from openflexure_microscope.common.labthings_core.utilities import ( get_docstring, get_summary, ) -from .schema import Schema, marshmallow, MARSHMALLOW_VERSION_INFO -import collections.abc - -import logging def description_from_view(view_class): @@ -18,14 +14,3 @@ def description_from_view(view_class): d = {"methods": methods, "description": summary} return d - - -def rupdate(d, u): - for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): - if not k in d: - d[k] = {} - d[k] = rupdate(d.get(k, {}), v) - else: - d[k] = v - return d diff --git a/openflexure_microscope/common/flask_labthings/views/extensions.py b/openflexure_microscope/common/flask_labthings/views/extensions.py index f4f7c749..a4fcca41 100644 --- a/openflexure_microscope/common/flask_labthings/views/extensions.py +++ b/openflexure_microscope/common/flask_labthings/views/extensions.py @@ -3,13 +3,15 @@ Top-level representation of attached and enabled Extensions """ from openflexure_microscope.common.labthings_core.utilities import get_docstring +from openflexure_microscope.common.labthings_core.schema import Schema +from openflexure_microscope.common.labthings_core import fields + from ..utilities import description_from_view -from openflexure_microscope.common.flask_labthings.resource import Resource -from openflexure_microscope.common.flask_labthings.find import registered_extensions -from openflexure_microscope.common.flask_labthings.schema import Schema -from openflexure_microscope.common.flask_labthings.decorators import marshal_with -from openflexure_microscope.common.flask_labthings import fields +from ..resource import Resource +from ..find import registered_extensions + +from ..decorators import marshal_with from marshmallow import pre_dump from flask import jsonify, url_for @@ -48,7 +50,7 @@ class ExtensionSchema(Schema): return data -class ExtensionListResource(Resource): +class ExtensionList(Resource): """ List and basic documentation for all enabled Extensions """ diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 8c00ec07..310a6d2a 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -1,14 +1,15 @@ from flask import abort, url_for -from openflexure_microscope.common.flask_labthings.schema import Schema from openflexure_microscope.common.flask_labthings.decorators import marshal_with -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, ) +from openflexure_microscope.common.labthings_core import tasks +from openflexure_microscope.common.labthings_core.schema import Schema +from openflexure_microscope.common.labthings_core import fields + from marshmallow import pre_dump diff --git a/openflexure_microscope/common/labthings_core/decorators.py b/openflexure_microscope/common/labthings_core/decorators.py new file mode 100644 index 00000000..0fa8775e --- /dev/null +++ b/openflexure_microscope/common/labthings_core/decorators.py @@ -0,0 +1,26 @@ +from .spec import update_spec +from .utilities import rupdate + + +class doc(object): + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __call__(self, f): + # Pass params to call function attribute for external access + update_spec(f, self.kwargs) + return f + + +class doc_response(object): + def __init__(self, code, description, **kwargs): + self.code = code + self.description = description + self.kwargs = kwargs + + def __call__(self, f): + # Pass params to call function attribute for external access + f.__apispec__ = f.__dict__.get("__apispec__", {}) + d = {"responses": {self.code: {"description": self.description, **self.kwargs}}} + rupdate(f.__apispec__, d) + return f diff --git a/openflexure_microscope/common/labthings_core/fields.py b/openflexure_microscope/common/labthings_core/fields.py new file mode 100644 index 00000000..b8eb3d36 --- /dev/null +++ b/openflexure_microscope/common/labthings_core/fields.py @@ -0,0 +1 @@ +from marshmallow.fields import * diff --git a/openflexure_microscope/common/labthings_core/resource.py b/openflexure_microscope/common/labthings_core/resource.py new file mode 100644 index 00000000..57882a94 --- /dev/null +++ b/openflexure_microscope/common/labthings_core/resource.py @@ -0,0 +1,20 @@ +class BaseResource: + """ + A LabThing Resource class should, regardless of the underlying framework, make use of + functions get(), put(), post(), and delete() corresponding to HTTP methods. + + These functions will allow for automated documentation generation + """ + + methods = ["get", "post", "put", "delete"] + + def doc(self): + docs = {"operations": {}} + if hasattr(self, "__apispec__"): + docs.update(self.__apispec__) + + for meth in BaseResource.methods: + if hasattr(self, meth) and hasattr(getattr(self, meth), "__apispec__"): + docs["operations"][meth] = {} + docs["operations"][meth] = getattr(self, meth).__apispec__ + return docs diff --git a/openflexure_microscope/common/labthings_core/schema.py b/openflexure_microscope/common/labthings_core/schema.py new file mode 100644 index 00000000..83294a0e --- /dev/null +++ b/openflexure_microscope/common/labthings_core/schema.py @@ -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) diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/labthings_core/spec.py similarity index 94% rename from openflexure_microscope/common/flask_labthings/spec.py rename to openflexure_microscope/common/labthings_core/spec.py index dd53defa..99ba0015 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/labthings_core/spec.py @@ -1,4 +1,4 @@ -from .resource import Resource +from .resource import BaseResource from .utilities import rupdate from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin @@ -20,7 +20,7 @@ def update_spec(obj, spec): return obj.__apispec__ -def view2path(rule: str, view: Resource, spec: APISpec): +def view2path(rule: str, view: BaseResource, spec: APISpec): params = { "path": rule, # TODO: Validate this slightly (leading / etc) "operations": view2operations(view, spec), @@ -35,9 +35,9 @@ def view2path(rule: str, view: Resource, spec: APISpec): return params -def view2operations(view: Resource, spec: APISpec, populate_default: bool = True): +def view2operations(view: BaseResource, spec: APISpec, populate_default: bool = True): ops = {} - for method in Resource.methods: + for method in BaseResource.methods: if hasattr(view, method): # Populate with default responses if populate_default: diff --git a/openflexure_microscope/common/labthings_core/utilities.py b/openflexure_microscope/common/labthings_core/utilities.py index f2795517..1d0ae94a 100644 --- a/openflexure_microscope/common/labthings_core/utilities.py +++ b/openflexure_microscope/common/labthings_core/utilities.py @@ -1,3 +1,10 @@ +import collections.abc +import re +import base64 +import operator +from functools import reduce + + def get_docstring(obj): ds = obj.__doc__ if ds: @@ -8,3 +15,49 @@ def get_docstring(obj): def get_summary(obj): return get_docstring(obj).partition("\n")[0].strip() + + +def rupdate(d, u): + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + if not k in d: + d[k] = {} + d[k] = rupdate(d.get(k, {}), v) + else: + d[k] = v + return d + + +def get_by_path(root, items): + """Access a nested object in root by item sequence.""" + return reduce(operator.getitem, items, root) + + +def set_by_path(root, items, value): + """Set a value in a nested object in root by item sequence.""" + get_by_path(root, items[:-1])[items[-1]] = value + + +def create_from_path(items): + tree_dict = {} + for key in reversed(items): + tree_dict = {key: tree_dict} + return tree_dict + + +def bottom_level_name(obj): + return obj.__name__.split(".")[-1] + + +def camel_to_snake(name): + s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() + + +def camel_to_spine(name): + s1 = re.sub("(.)([A-Z][a-z]+)", r"\1-\2", name) + return re.sub("([a-z0-9])([A-Z])", r"\1-\2", s1).lower() + + +def snake_to_spine(name): + return name.replace("_", "-") diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index 2624d658..e57e1882 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -12,9 +12,10 @@ from openflexure_microscope.camera.base import BaseCamera from openflexure_microscope.camera.mock import MockStreamer from openflexure_microscope.utilities import serialise_array_b64 -from openflexure_microscope.common.labthings_core.lock import CompositeLock from openflexure_microscope.config import user_settings +from openflexure_microscope.common.labthings_core.lock import CompositeLock + class Microscope: """ diff --git a/openflexure_microscope/utilities.py b/openflexure_microscope/utilities.py index 0cf33c5c..64379f4d 100644 --- a/openflexure_microscope/utilities.py +++ b/openflexure_microscope/utilities.py @@ -20,41 +20,6 @@ def serialise_array_b64(npy_arr): return b64_string, dtype, shape -def get_by_path(root, items): - """Access a nested object in root by item sequence.""" - return reduce(operator.getitem, items, root) - - -def set_by_path(root, items, value): - """Set a value in a nested object in root by item sequence.""" - get_by_path(root, items[:-1])[items[-1]] = value - - -def create_from_path(items): - tree_dict = {} - for key in reversed(items): - tree_dict = {key: tree_dict} - return tree_dict - - -def bottom_level_name(obj): - return obj.__name__.split(".")[-1] - - -def camel_to_snake(name): - s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) - return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() - - -def camel_to_spine(name): - s1 = re.sub("(.)([A-Z][a-z]+)", r"\1-\2", name) - return re.sub("([a-z0-9])([A-Z])", r"\1-\2", s1).lower() - - -def snake_to_spine(name): - return name.replace("_", "-") - - @contextmanager def set_properties(obj, **kwargs): """A context manager to set, then reset, certain properties of an object. From 6bf178750b3f64e2edae4ef6e5ccfdf28c17a52a Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 16:47:27 +0000 Subject: [PATCH 065/122] Fixed ExtensionList reference --- .../common/flask_labthings/views/extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/common/flask_labthings/views/extensions.py b/openflexure_microscope/common/flask_labthings/views/extensions.py index a4fcca41..65bb3e56 100644 --- a/openflexure_microscope/common/flask_labthings/views/extensions.py +++ b/openflexure_microscope/common/flask_labthings/views/extensions.py @@ -39,7 +39,7 @@ class ExtensionSchema(Schema): # Make links dictionary if it doesn't yet exist d[view_id] = { "href": url_for( - ExtensionListResource.endpoint, **view_kwargs, _external=True + ExtensionList.endpoint, **view_kwargs, _external=True ) + view_rule, **description_from_view(view_cls), From 851d70842c43ff3f7fc00b3c8831a0eeeb7fb32f Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 17:03:05 +0000 Subject: [PATCH 066/122] Moved all API stuff to flask_labthings --- .../common/flask_labthings/decorators.py | 27 ++++++++++++- .../common/flask_labthings/fields.py | 2 +- .../common/flask_labthings/labthing.py | 5 +-- .../common/flask_labthings/resource.py | 21 ++++++++-- .../common/flask_labthings/schema.py | 40 ++++++++++++++++++- .../spec.py | 10 ++--- .../flask_labthings/views/extensions.py | 10 ++--- .../common/flask_labthings/views/tasks.py | 4 +- .../common/labthings_core/decorators.py | 26 ------------ .../common/labthings_core/fields.py | 1 - .../common/labthings_core/resource.py | 20 ---------- .../common/labthings_core/schema.py | 39 ------------------ 12 files changed, 96 insertions(+), 109 deletions(-) rename openflexure_microscope/common/{labthings_core => flask_labthings}/spec.py (93%) delete mode 100644 openflexure_microscope/common/labthings_core/decorators.py delete mode 100644 openflexure_microscope/common/labthings_core/fields.py delete mode 100644 openflexure_microscope/common/labthings_core/resource.py delete mode 100644 openflexure_microscope/common/labthings_core/schema.py diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index fa8e9ac0..12e94edb 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -3,9 +3,8 @@ from functools import wraps, update_wrapper from flask import make_response from openflexure_microscope.common.labthings_core.utilities import rupdate -from openflexure_microscope.common.labthings_core.spec import update_spec -from openflexure_microscope.common.labthings_core.decorators import doc, doc_response +from .spec import update_spec def unpack(value): @@ -76,3 +75,27 @@ class use_kwargs(use_args): """ kwargs["as_kwargs"] = True use_args.__init__(self, schema, **kwargs) + + +class doc(object): + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __call__(self, f): + # Pass params to call function attribute for external access + update_spec(f, self.kwargs) + return f + + +class doc_response(object): + def __init__(self, code, description, **kwargs): + self.code = code + self.description = description + self.kwargs = kwargs + + def __call__(self, f): + # Pass params to call function attribute for external access + f.__apispec__ = f.__dict__.get("__apispec__", {}) + d = {"responses": {self.code: {"description": self.description, **self.kwargs}}} + rupdate(f.__apispec__, d) + return f diff --git a/openflexure_microscope/common/flask_labthings/fields.py b/openflexure_microscope/common/flask_labthings/fields.py index 7d413696..b8eb3d36 100644 --- a/openflexure_microscope/common/flask_labthings/fields.py +++ b/openflexure_microscope/common/flask_labthings/fields.py @@ -1 +1 @@ -from openflexure_microscope.common.labthings_core.fields import * +from marshmallow.fields import * diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index b1a64db3..b090ef2f 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -2,16 +2,15 @@ from flask import url_for, jsonify from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin +from . import EXTENSION_NAME from .extensions import BaseExtension from .utilities import description_from_view +from .spec import view2path from .views.extensions import ExtensionList from .views.tasks import TaskList, TaskResource from openflexure_microscope.common.labthings_core.utilities import get_docstring -from openflexure_microscope.common.labthings_core.spec import view2path - -from . import EXTENSION_NAME import logging diff --git a/openflexure_microscope/common/flask_labthings/resource.py b/openflexure_microscope/common/flask_labthings/resource.py index 6f598adb..ed3ef119 100644 --- a/openflexure_microscope/common/flask_labthings/resource.py +++ b/openflexure_microscope/common/flask_labthings/resource.py @@ -1,12 +1,27 @@ from flask.views import MethodView -from openflexure_microscope.common.labthings_core.resource import BaseResource -class Resource(MethodView, BaseResource): - """Currently identical to MethodView +class Resource(MethodView): + """ + A LabThing Resource class should make use of functions get(), put(), post(), and delete() + corresponding to HTTP methods. + + These functions will allow for automated documentation generation """ + methods = ["get", "post", "put", "delete"] endpoint = None def __init__(self, *args, **kwargs): MethodView.__init__(self, *args, **kwargs) + + def doc(self): + docs = {"operations": {}} + if hasattr(self, "__apispec__"): + docs.update(self.__apispec__) + + for meth in BaseResource.methods: + if hasattr(self, meth) and hasattr(getattr(self, meth), "__apispec__"): + docs["operations"][meth] = {} + docs["operations"][meth] = getattr(self, meth).__apispec__ + return docs diff --git a/openflexure_microscope/common/flask_labthings/schema.py b/openflexure_microscope/common/flask_labthings/schema.py index b9ae889a..83294a0e 100644 --- a/openflexure_microscope/common/flask_labthings/schema.py +++ b/openflexure_microscope/common/flask_labthings/schema.py @@ -1 +1,39 @@ -from openflexure_microscope.common.labthings_core.schema import * +# -*- 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) diff --git a/openflexure_microscope/common/labthings_core/spec.py b/openflexure_microscope/common/flask_labthings/spec.py similarity index 93% rename from openflexure_microscope/common/labthings_core/spec.py rename to openflexure_microscope/common/flask_labthings/spec.py index 99ba0015..fa8c3190 100644 --- a/openflexure_microscope/common/labthings_core/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -1,11 +1,11 @@ -from .resource import BaseResource -from .utilities import rupdate +from .resource import Resource from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin from openflexure_microscope.common.labthings_core.utilities import ( get_docstring, get_summary, + rupdate, ) from .fields import Field @@ -20,7 +20,7 @@ def update_spec(obj, spec): return obj.__apispec__ -def view2path(rule: str, view: BaseResource, spec: APISpec): +def view2path(rule: str, view: Resource, spec: APISpec): params = { "path": rule, # TODO: Validate this slightly (leading / etc) "operations": view2operations(view, spec), @@ -35,9 +35,9 @@ def view2path(rule: str, view: BaseResource, spec: APISpec): return params -def view2operations(view: BaseResource, spec: APISpec, populate_default: bool = True): +def view2operations(view: Resource, spec: APISpec, populate_default: bool = True): ops = {} - for method in BaseResource.methods: + for method in Resource.methods: if hasattr(view, method): # Populate with default responses if populate_default: diff --git a/openflexure_microscope/common/flask_labthings/views/extensions.py b/openflexure_microscope/common/flask_labthings/views/extensions.py index 65bb3e56..947fa6cb 100644 --- a/openflexure_microscope/common/flask_labthings/views/extensions.py +++ b/openflexure_microscope/common/flask_labthings/views/extensions.py @@ -2,9 +2,9 @@ Top-level representation of attached and enabled Extensions """ -from openflexure_microscope.common.labthings_core.utilities import get_docstring -from openflexure_microscope.common.labthings_core.schema import Schema -from openflexure_microscope.common.labthings_core import fields +from openflexure_microscope.common.flask_labthings.utilities import get_docstring +from openflexure_microscope.common.flask_labthings.schema import Schema +from openflexure_microscope.common.flask_labthings import fields from ..utilities import description_from_view @@ -38,9 +38,7 @@ class ExtensionSchema(Schema): view_rule = view_data["rule"] # Make links dictionary if it doesn't yet exist d[view_id] = { - "href": url_for( - ExtensionList.endpoint, **view_kwargs, _external=True - ) + "href": url_for(ExtensionList.endpoint, **view_kwargs, _external=True) + view_rule, **description_from_view(view_cls), } diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 310a6d2a..dad04b3e 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -2,13 +2,13 @@ from flask import abort, url_for from openflexure_microscope.common.flask_labthings.decorators import marshal_with from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.schema import Schema +from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.common.flask_labthings.utilities import ( description_from_view, ) from openflexure_microscope.common.labthings_core import tasks -from openflexure_microscope.common.labthings_core.schema import Schema -from openflexure_microscope.common.labthings_core import fields from marshmallow import pre_dump diff --git a/openflexure_microscope/common/labthings_core/decorators.py b/openflexure_microscope/common/labthings_core/decorators.py deleted file mode 100644 index 0fa8775e..00000000 --- a/openflexure_microscope/common/labthings_core/decorators.py +++ /dev/null @@ -1,26 +0,0 @@ -from .spec import update_spec -from .utilities import rupdate - - -class doc(object): - def __init__(self, **kwargs): - self.kwargs = kwargs - - def __call__(self, f): - # Pass params to call function attribute for external access - update_spec(f, self.kwargs) - return f - - -class doc_response(object): - def __init__(self, code, description, **kwargs): - self.code = code - self.description = description - self.kwargs = kwargs - - def __call__(self, f): - # Pass params to call function attribute for external access - f.__apispec__ = f.__dict__.get("__apispec__", {}) - d = {"responses": {self.code: {"description": self.description, **self.kwargs}}} - rupdate(f.__apispec__, d) - return f diff --git a/openflexure_microscope/common/labthings_core/fields.py b/openflexure_microscope/common/labthings_core/fields.py deleted file mode 100644 index b8eb3d36..00000000 --- a/openflexure_microscope/common/labthings_core/fields.py +++ /dev/null @@ -1 +0,0 @@ -from marshmallow.fields import * diff --git a/openflexure_microscope/common/labthings_core/resource.py b/openflexure_microscope/common/labthings_core/resource.py deleted file mode 100644 index 57882a94..00000000 --- a/openflexure_microscope/common/labthings_core/resource.py +++ /dev/null @@ -1,20 +0,0 @@ -class BaseResource: - """ - A LabThing Resource class should, regardless of the underlying framework, make use of - functions get(), put(), post(), and delete() corresponding to HTTP methods. - - These functions will allow for automated documentation generation - """ - - methods = ["get", "post", "put", "delete"] - - def doc(self): - docs = {"operations": {}} - if hasattr(self, "__apispec__"): - docs.update(self.__apispec__) - - for meth in BaseResource.methods: - if hasattr(self, meth) and hasattr(getattr(self, meth), "__apispec__"): - docs["operations"][meth] = {} - docs["operations"][meth] = getattr(self, meth).__apispec__ - return docs diff --git a/openflexure_microscope/common/labthings_core/schema.py b/openflexure_microscope/common/labthings_core/schema.py deleted file mode 100644 index 83294a0e..00000000 --- a/openflexure_microscope/common/labthings_core/schema.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- 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) From f1fae05cddaf0d1a486362fbcd5db972094b3432 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 17:22:48 +0000 Subject: [PATCH 067/122] Fixed task dictionary keys --- openflexure_microscope/common/labthings_core/tasks/pool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/common/labthings_core/tasks/pool.py b/openflexure_microscope/common/labthings_core/tasks/pool.py index 69fa3ca0..6c152fa5 100644 --- a/openflexure_microscope/common/labthings_core/tasks/pool.py +++ b/openflexure_microscope/common/labthings_core/tasks/pool.py @@ -25,7 +25,7 @@ class TaskMaster: Returns: dict: Dictionary of TaskThread objects. Key is TaskThread ID. """ - return {t.id: t for t in self._tasks} + return {str(t.id): t for t in self._tasks} @property def states(self): @@ -33,7 +33,7 @@ class TaskMaster: Returns: dict: Dictionary of TaskThread.state dictionaries. Key is TaskThread ID. """ - return {t.id: t.state for t in self._tasks} + return {str(t.id): t.state for t in self._tasks} def new(self, f, *args, **kwargs): # copy_current_request_context allows threads to access flask current_app From 964258a1b7196c924eb579beb40d5b0611cd6309 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 17:22:57 +0000 Subject: [PATCH 068/122] Fixed missing task links --- openflexure_microscope/common/flask_labthings/views/tasks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index dad04b3e..d3c0a39c 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -23,6 +23,8 @@ class TaskSchema(Schema): _start_time = fields.String(data_key="start_time") _end_time = fields.String(data_key="end_time") + links = fields.Dict() + # TODO: Automate this somewhat @pre_dump def generate_links(self, data, **kwargs): @@ -50,6 +52,7 @@ class TaskResource(Resource): @marshal_with(TaskSchema()) def get(self, id): try: + print(tasks.dict()) task = tasks.dict()[id] except KeyError: return abort(404) # 404 Not Found From 4a6e672630aee4b6de2935ca82d53366215bd5fc Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 17:23:37 +0000 Subject: [PATCH 069/122] Marshal with TaskSchema --- openflexure_microscope/api/default_extensions/scan.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 0cf42084..62af7d4a 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -11,6 +11,8 @@ from openflexure_microscope.common.flask_labthings.find import ( find_extension, ) from openflexure_microscope.common.flask_labthings.extensions import BaseExtension +from openflexure_microscope.common.flask_labthings.views.tasks import TaskSchema +from openflexure_microscope.common.flask_labthings.decorators import marshal_with from openflexure_microscope.devel import ( JsonResponse, @@ -344,6 +346,7 @@ def stack( class TileScanAPI(Resource): + @marshal_with(TaskSchema()) def post(self): payload = JsonResponse(request) microscope = find_device("openflexure_microscope") @@ -398,7 +401,7 @@ class TileScanAPI(Resource): ) # return a handle on the scan task - return jsonify(task.state), 201 + return task scan_extension_v2 = BaseExtension("scan") From d656ce24ec826d9467348e4c17bad91ffd355a29 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 17:47:53 +0000 Subject: [PATCH 070/122] Use update_spec --- .../common/flask_labthings/decorators.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index 12e94edb..a7748f04 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -95,7 +95,12 @@ class doc_response(object): def __call__(self, f): # Pass params to call function attribute for external access - f.__apispec__ = f.__dict__.get("__apispec__", {}) - d = {"responses": {self.code: {"description": self.description, **self.kwargs}}} - rupdate(f.__apispec__, d) + update_spec( + f, + { + "responses": { + self.code: {"description": self.description, **self.kwargs} + } + }, + ) return f From 39cba667438e6d6988a5192cc15f8afa388448c0 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 17:48:03 +0000 Subject: [PATCH 071/122] Use relative imports --- .../common/flask_labthings/views/tasks.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index d3c0a39c..75bae06f 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -1,12 +1,11 @@ from flask import abort, url_for -from openflexure_microscope.common.flask_labthings.decorators import marshal_with -from openflexure_microscope.common.flask_labthings.resource import Resource -from openflexure_microscope.common.flask_labthings.schema import Schema -from openflexure_microscope.common.flask_labthings import fields -from openflexure_microscope.common.flask_labthings.utilities import ( - description_from_view, -) +from ..decorators import marshal_with +from ..resource import Resource +from ..schema import Schema +from .. import fields +from ..utilities import description_from_view +from ..spec import update_spec from openflexure_microscope.common.labthings_core import tasks From 8b472eab1bddccf495cff388106aa4e6e979e80e Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 17:48:16 +0000 Subject: [PATCH 072/122] Use @use_args --- .../api/default_extensions/scan.py | 67 ++++++++++--------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 62af7d4a..13f0045f 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -12,7 +12,11 @@ from openflexure_microscope.common.flask_labthings.find import ( ) from openflexure_microscope.common.flask_labthings.extensions import BaseExtension from openflexure_microscope.common.flask_labthings.views.tasks import TaskSchema -from openflexure_microscope.common.flask_labthings.decorators import marshal_with +from openflexure_microscope.common.flask_labthings.decorators import ( + marshal_with, + use_args, +) +from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.devel import ( JsonResponse, @@ -346,30 +350,31 @@ def stack( class TileScanAPI(Resource): + @use_args( + { + "filename": fields.String(), + "temporary": fields.Boolean(missing=False), + "step_size": fields.List(fields.Integer, missing=[2000, 1500, 100]), + "grid": fields.List(fields.Integer, missing=[3, 3, 5]), + "style": fields.String(missing="raster"), + "autofocus_dz": fields.Integer(missing=50), + "fast_autofocus": fields.Boolean(missing=False), + "use_video_port": fields.Boolean(missing=False), + "bayer": fields.Boolean(missing=False), + "metadata": fields.Dict(missing={}), + "tags": fields.List(fields.String, missing=[]), + "resize": fields.Dict(missing=None), # TODO: Validate keys + } + ) @marshal_with(TaskSchema()) - def post(self): + def post(self, args): payload = JsonResponse(request) microscope = find_device("openflexure_microscope") if not microscope: abort(503, "No microscope connected. Unable to autofocus.") - # Get params - filename = payload.param("filename") - temporary = payload.param("temporary", default=False, convert=bool) - - step_size = payload.param("step_size", default=[2000, 1500, 100], convert=list) - step_size = [int(i) for i in step_size] - - grid = payload.param("grid", default=[3, 3, 5], convert=list) - grid = [int(i) for i in grid] - - style = payload.param("style", default="raster", convert=str) - autofocus_dz = payload.param("autofocus_dz", default=50, convert=int) - fast_autofocus = payload.param("fast_autofocus", default=False, convert=bool) - - use_video_port = payload.param("use_video_port", default=True, convert=bool) - resize = payload.param("size", default=None) + resize = args.get("resize", None) if resize: if ("width" in resize) and ("height" in resize): resize = ( @@ -379,25 +384,21 @@ class TileScanAPI(Resource): else: abort(404) - bayer = payload.param("bayer", default=False, convert=bool) - metadata = payload.param("metadata", default={}, convert=dict) - tags = payload.param("tags", default=[], convert=list) - logging.info("Running tile scan...") task = taskify(tile)( microscope, - basename=filename, - temporary=temporary, - step_size=step_size, - grid=grid, - style=style, - autofocus_dz=autofocus_dz, - use_video_port=use_video_port, + basename=args.get("filename"), + temporary=args.get("temporary"), + step_size=args.get("step_size"), + grid=args.get("grid"), + style=args.get("style"), + autofocus_dz=args.get("autofocus_dz"), + use_video_port=args.get("use_video_port"), resize=resize, - bayer=bayer, - fast_autofocus=fast_autofocus, - metadata=metadata, - tags=tags, + bayer=args.get("bayer"), + fast_autofocus=args.get("fast_autofocus"), + metadata=args.get("metadata"), + tags=args.get("tags"), ) # return a handle on the scan task From dfdd1412f8f79f73fc12adb0361c8c222bd81c96 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 17:49:14 +0000 Subject: [PATCH 073/122] Removed redundant todo --- openflexure_microscope/common/flask_labthings/extensions.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/extensions.py b/openflexure_microscope/common/flask_labthings/extensions.py index dabc65f1..9fa66652 100644 --- a/openflexure_microscope/common/flask_labthings/extensions.py +++ b/openflexure_microscope/common/flask_labthings/extensions.py @@ -13,10 +13,6 @@ from openflexure_microscope.common.labthings_core.utilities import ( snake_to_spine, ) - -# TODO: Move into core? - - class BaseExtension: """ Parent class for all extensions. From 1ccdad8a862fd249a08d00d6eca7b7cb65b8114c Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 17:55:43 +0000 Subject: [PATCH 074/122] Removed some unused imports --- openflexure_microscope/api/default_extensions/scan.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 13f0045f..c86733f4 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -1,4 +1,3 @@ -import numpy as np import itertools import logging import uuid @@ -19,13 +18,9 @@ from openflexure_microscope.common.flask_labthings.decorators import ( from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.devel import ( - JsonResponse, - request, - jsonify, taskify, abort, update_task_progress, - update_task_data, ) from openflexure_microscope.common.flask_labthings.resource import Resource @@ -368,7 +363,6 @@ class TileScanAPI(Resource): ) @marshal_with(TaskSchema()) def post(self, args): - payload = JsonResponse(request) microscope = find_device("openflexure_microscope") if not microscope: From 8afb0757bd58491ef72f89c32991aa73fa76275d Mon Sep 17 00:00:00 2001 From: jtc42 Date: Mon, 6 Jan 2020 22:28:00 +0000 Subject: [PATCH 075/122] Better handle response documentation --- .../api/default_extensions/scan.py | 10 +--- openflexure_microscope/api/utilities.py | 6 +- .../api/v2/views/actions/camera.py | 2 +- .../common/flask_labthings/decorators.py | 32 ++++++++-- .../common/flask_labthings/extensions.py | 1 + .../common/flask_labthings/labthing.py | 11 ++-- .../common/flask_labthings/names.py | 3 + .../common/flask_labthings/schema.py | 60 ++++++++++++++++++- .../common/flask_labthings/spec.py | 58 +++++++++--------- .../common/flask_labthings/utilities.py | 5 ++ .../flask_labthings/views/extensions.py | 38 +----------- .../common/flask_labthings/views/tasks.py | 32 +--------- 12 files changed, 143 insertions(+), 115 deletions(-) create mode 100644 openflexure_microscope/common/flask_labthings/names.py diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index c86733f4..dd9f4986 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -10,18 +10,14 @@ from openflexure_microscope.common.flask_labthings.find import ( find_extension, ) from openflexure_microscope.common.flask_labthings.extensions import BaseExtension -from openflexure_microscope.common.flask_labthings.views.tasks import TaskSchema from openflexure_microscope.common.flask_labthings.decorators import ( + marshal_task, marshal_with, use_args, ) from openflexure_microscope.common.flask_labthings import fields -from openflexure_microscope.devel import ( - taskify, - abort, - update_task_progress, -) +from openflexure_microscope.devel import taskify, abort, update_task_progress from openflexure_microscope.common.flask_labthings.resource import Resource import time @@ -361,7 +357,7 @@ class TileScanAPI(Resource): "resize": fields.Dict(missing=None), # TODO: Validate keys } ) - @marshal_with(TaskSchema()) + @marshal_task def post(self, args): microscope = find_device("openflexure_microscope") diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index d4d8c804..0f1735d9 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -2,7 +2,11 @@ import logging import os import errno from werkzeug.exceptions import BadRequest -from flask import url_for, Blueprint +from flask import url_for, Blueprint, current_app + + +def view_class_from_endpoint(endpoint: str): + return current_app.view_functions[endpoint].view_class def blueprint_for_module(module_name, api_ver=2, suffix=""): diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 900f3f17..bcb3cd20 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -35,7 +35,7 @@ class CaptureAPI(Resource): } ) @marshal_with(capture_schema) - @doc_response(200, "Capture successful") + @doc_response(200, description="Capture successful") def post(self, args): """ Create a new capture diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index a7748f04..d3aafa16 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -1,10 +1,12 @@ from webargs import flaskparser from functools import wraps, update_wrapper from flask import make_response +from http import HTTPStatus from openflexure_microscope.common.labthings_core.utilities import rupdate from .spec import update_spec +from .schema import TaskSchema def unpack(value): @@ -28,23 +30,23 @@ def unpack(value): class marshal_with(object): - def __init__(self, schema): + def __init__(self, schema, code=200): """ :param schema: a dict of whose keys will make up the final serialized response output """ self.schema = schema + self.code = code def __call__(self, f): # Pass params to call function attribute for external access - update_spec(f, {"_schema": self.schema}) + update_spec(f, {"_schema": {self.code: self.schema}}) # Wrapper function @wraps(f) def wrapper(*args, **kwargs): resp = f(*args, **kwargs) if isinstance(resp, tuple): data, code, headers = unpack(resp) - print((data, code, headers)) return make_response(self.schema.jsonify(data), code, headers) else: return make_response(self.schema.jsonify(resp)) @@ -52,6 +54,23 @@ class marshal_with(object): return wrapper +def marshal_task(f): + # Pass params to call function attribute for external access + update_spec(f, {"responses": {201: {"description": "Task started successfully"}}}) + update_spec(f, {"_schema": {201: TaskSchema()}}) + # Wrapper function + @wraps(f) + def wrapper(*args, **kwargs): + resp = f(*args, **kwargs) + if isinstance(resp, tuple): + data, code, headers = unpack(resp) + return make_response(TaskSchema().jsonify(data), code, headers) + else: + return make_response(TaskSchema().jsonify(resp)) + + return wrapper + + class use_args(object): def __init__(self, schema, **kwargs): """ @@ -88,7 +107,7 @@ class doc(object): class doc_response(object): - def __init__(self, code, description, **kwargs): + def __init__(self, code, description=None, **kwargs): self.code = code self.description = description self.kwargs = kwargs @@ -99,7 +118,10 @@ class doc_response(object): f, { "responses": { - self.code: {"description": self.description, **self.kwargs} + self.code: { + "description": self.description or HTTPStatus(self.code).phrase, + **self.kwargs, + } } }, ) diff --git a/openflexure_microscope/common/flask_labthings/extensions.py b/openflexure_microscope/common/flask_labthings/extensions.py index 9fa66652..538609df 100644 --- a/openflexure_microscope/common/flask_labthings/extensions.py +++ b/openflexure_microscope/common/flask_labthings/extensions.py @@ -13,6 +13,7 @@ from openflexure_microscope.common.labthings_core.utilities import ( snake_to_spine, ) + class BaseExtension: """ Parent class for all extensions. diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index b090ef2f..029ba64f 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -2,7 +2,8 @@ from flask import url_for, jsonify from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin -from . import EXTENSION_NAME +from . import EXTENSION_NAME # TODO: Move into .names +from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT from .extensions import BaseExtension from .utilities import description_from_view from .spec import view2path @@ -112,12 +113,14 @@ class LabThing(object): ) # Add extension overview - self.add_resource(ExtensionList, "/extensions") + self.add_resource( + ExtensionList, "/extensions", endpoint=EXTENSION_LIST_ENDPOINT + ) self.register_property(ExtensionList) # Add task routes - self.add_resource(TaskList, "/tasks") + self.add_resource(TaskList, "/tasks", endpoint=TASK_LIST_ENDPOINT) self.register_property(TaskList) - self.add_resource(TaskResource, "/tasks/") + self.add_resource(TaskResource, "/tasks/", endpoint=TASK_ENDPOINT) ### Device stuff diff --git a/openflexure_microscope/common/flask_labthings/names.py b/openflexure_microscope/common/flask_labthings/names.py new file mode 100644 index 00000000..417c5138 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/names.py @@ -0,0 +1,3 @@ +TASK_ENDPOINT = "labthing_task" +TASK_LIST_ENDPOINT = "labthing_task_list" +EXTENSION_LIST_ENDPOINT = "labthing_extension_list" diff --git a/openflexure_microscope/common/flask_labthings/schema.py b/openflexure_microscope/common/flask_labthings/schema.py index 83294a0e..bc2691b5 100644 --- a/openflexure_microscope/common/flask_labthings/schema.py +++ b/openflexure_microscope/common/flask_labthings/schema.py @@ -1,7 +1,11 @@ # -*- coding: utf-8 -*- -import flask +from flask import jsonify, url_for import marshmallow +from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT +from .utilities import view_class_from_endpoint, description_from_view +from . import fields + MARSHMALLOW_VERSION_INFO = tuple( [int(part) for part in marshmallow.__version__.split(".") if part.isdigit()] ) @@ -36,4 +40,56 @@ class Schema(marshmallow.Schema): data = self.dump(obj, many=many) else: data = self.dump(obj, many=many).data - return flask.jsonify(data, *args, **kwargs) + return jsonify(data, *args, **kwargs) + + +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") + + links = fields.Dict() + + @marshmallow.pre_dump + def generate_links(self, data, **kwargs): + data.links = { + "self": { + "href": url_for(TASK_ENDPOINT, id=data.id, _external=True), + "mimetype": "application/json", + **description_from_view(view_class_from_endpoint(TASK_ENDPOINT)), + } + } + return data + + +class ExtensionSchema(Schema): + name = fields.String(data_key="title") + _name_python_safe = fields.String(data_key="pythonName") + _cls = fields.String(data_key="pythonObject") + gui = fields.Dict() + description = fields.String() + + links = fields.Dict() + + @marshmallow.pre_dump + def generate_links(self, data, **kwargs): + d = {} + for view_id, view_data in data.views.items(): + view_cls = view_data["view"] + view_kwargs = view_data["kwargs"] + view_rule = view_data["rule"] + # Make links dictionary if it doesn't yet exist + d[view_id] = { + "href": url_for(EXTENSION_LIST_ENDPOINT, **view_kwargs, _external=True) + + view_rule, + **description_from_view(view_cls), + } + + data.links = d + + return data diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec.py index fa8c3190..2ccfc042 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -12,6 +12,7 @@ from .fields import Field from marshmallow import Schema as BaseSchema from collections import Mapping +from http import HTTPStatus def update_spec(obj, spec): @@ -35,23 +36,11 @@ def view2path(rule: str, view: Resource, spec: APISpec): return params -def view2operations(view: Resource, spec: APISpec, populate_default: bool = True): +def view2operations(view: Resource, spec: APISpec): ops = {} for method in Resource.methods: if hasattr(view, method): - # Populate with default responses - if populate_default: - ops[method] = { - "responses": { - 200: { - "description": get_summary(getattr(view, method)) - or "Success" - }, - 404: {"description": "Resource not found"}, - } - } - else: - ops[method] = {} + ops[method] = {} rupdate( ops[method], @@ -61,15 +50,17 @@ def view2operations(view: Resource, spec: APISpec, populate_default: bool = True }, ) - if hasattr(getattr(view, method), "__apispec__"): - rupdate( - ops[method], doc2operation(getattr(view, method).__apispec__, spec) - ) + rupdate(ops[method], method2operation(getattr(view, method), spec)) return ops -def doc2operation(apispec: dict, spec: APISpec): +def method2operation(method: callable, spec: APISpec): + if hasattr(method, "__apispec__"): + apispec = getattr(method, "__apispec__") + else: + apispec = {} + op = {} if "_params" in apispec: rupdate( @@ -86,24 +77,37 @@ def doc2operation(apispec: dict, spec: APISpec): ) if "_schema" in apispec: + for code, schema in apispec.get("_schema", {}).items(): + rupdate( + op, + { + "responses": { + code: { + "description": HTTPStatus(code).phrase, + "content": { + "application/json": { + "schema": convert_schema(schema, spec) + } + }, + } + } + }, + ) + else: + # If no explicit responses are known, populate with defaults rupdate( op, { "responses": { - 200: { - "content": { - "application/json": { - "schema": convert_schema(apispec.get("_schema"), spec) - } - } - } + 200: {"description": get_summary(method) or HTTPStatus(200).phrase} } }, ) + # Bung in any extra swagger fields supplied for key, val in apispec.items(): if not key in ["_params", "_schema"]: - op[key] = val + rupdate(op, {key: val}) return op diff --git a/openflexure_microscope/common/flask_labthings/utilities.py b/openflexure_microscope/common/flask_labthings/utilities.py index a0b152e5..e06dad4c 100644 --- a/openflexure_microscope/common/flask_labthings/utilities.py +++ b/openflexure_microscope/common/flask_labthings/utilities.py @@ -2,6 +2,7 @@ from openflexure_microscope.common.labthings_core.utilities import ( get_docstring, get_summary, ) +from flask import current_app def description_from_view(view_class): @@ -14,3 +15,7 @@ def description_from_view(view_class): d = {"methods": methods, "description": summary} return d + + +def view_class_from_endpoint(endpoint: str): + return current_app.view_functions[endpoint].view_class diff --git a/openflexure_microscope/common/flask_labthings/views/extensions.py b/openflexure_microscope/common/flask_labthings/views/extensions.py index 947fa6cb..364c061d 100644 --- a/openflexure_microscope/common/flask_labthings/views/extensions.py +++ b/openflexure_microscope/common/flask_labthings/views/extensions.py @@ -6,46 +6,10 @@ from openflexure_microscope.common.flask_labthings.utilities import get_docstrin from openflexure_microscope.common.flask_labthings.schema import Schema from openflexure_microscope.common.flask_labthings import fields -from ..utilities import description_from_view - from ..resource import Resource from ..find import registered_extensions - +from ..schema import ExtensionSchema from ..decorators import marshal_with -from marshmallow import pre_dump - -from flask import jsonify, url_for - -import logging - - -class ExtensionSchema(Schema): - name = fields.String(data_key="title") - _name_python_safe = fields.String(data_key="pythonName") - _cls = fields.String(data_key="pythonObject") - gui = fields.Dict() - description = fields.String() - - links = fields.Dict() - - # TODO: Automate this somewhat - @pre_dump - def generate_links(self, data, **kwargs): - d = {} - for view_id, view_data in data.views.items(): - view_cls = view_data["view"] - view_kwargs = view_data["kwargs"] - view_rule = view_data["rule"] - # Make links dictionary if it doesn't yet exist - d[view_id] = { - "href": url_for(ExtensionList.endpoint, **view_kwargs, _external=True) - + view_rule, - **description_from_view(view_cls), - } - - data.links = d - - return data class ExtensionList(Resource): diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 75bae06f..2a203c23 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -2,40 +2,10 @@ from flask import abort, url_for from ..decorators import marshal_with from ..resource import Resource -from ..schema import Schema -from .. import fields -from ..utilities import description_from_view -from ..spec import update_spec +from ..schema import TaskSchema from openflexure_microscope.common.labthings_core import tasks -from marshmallow import pre_dump - - -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") - - links = fields.Dict() - - # TODO: Automate this somewhat - @pre_dump - def generate_links(self, data, **kwargs): - data.links = { - "self": { - "href": url_for(TaskResource.endpoint, id=data.id, _external=True), - "mimetype": "application/json", - **description_from_view(TaskResource), - } - } - return data - class TaskList(Resource): """ From d724c3d121f2be793d527318a4f5eb402c88ef44 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 7 Jan 2020 10:39:41 +0000 Subject: [PATCH 076/122] Fixed default extension init path --- openflexure_microscope/api/utilities.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index 0f1735d9..1e29cc3a 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -112,18 +112,20 @@ def create_file(config_path): raise -def init_default_extensions(extension_path): +def init_default_extensions(extension_dir): global _DEFAULT_EXTENSION_INIT - os.makedirs(os.path.dirname(extension_path), exist_ok=True) + os.makedirs(extension_dir, exist_ok=True) - if not os.path.exists(extension_path): # If user extensions file doesn't exist + default_ext_path = os.path.join(extension_dir, "defaults.py") + + if not os.path.isfile(default_ext_path): # If user extensions file doesn't exist logging.warning( - "No extension file found at {}. Creating...".format(extension_path) + "No extension file found at {}. Creating...".format(extension_dir) ) - create_file(extension_path) + create_file(default_ext_path) - logging.info("Populating {}...".format(extension_path)) - with open(extension_path, "w") as outfile: + logging.info("Populating {}...".format(default_ext_path)) + with open(default_ext_path, "w") as outfile: outfile.write(_DEFAULT_EXTENSION_INIT) From 5c6708cddd39447507dd88b096f229d063e4ece7 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 7 Jan 2020 13:21:09 +0000 Subject: [PATCH 077/122] Removed a couple of unused prints --- openflexure_microscope/common/flask_labthings/extensions.py | 1 - openflexure_microscope/common/flask_labthings/views/tasks.py | 1 - 2 files changed, 2 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/extensions.py b/openflexure_microscope/common/flask_labthings/extensions.py index 538609df..53b61aec 100644 --- a/openflexure_microscope/common/flask_labthings/extensions.py +++ b/openflexure_microscope/common/flask_labthings/extensions.py @@ -72,7 +72,6 @@ class BaseExtension: @property def gui(self): - print(self._gui) # Handle missing/no GUI if not self._gui: return None diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 2a203c23..11768fb0 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -21,7 +21,6 @@ class TaskResource(Resource): @marshal_with(TaskSchema()) def get(self, id): try: - print(tasks.dict()) task = tasks.dict()[id] except KeyError: return abort(404) # 404 Not Found From b2824251ae47f1c7d23e30d02663400a08c40788 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 7 Jan 2020 14:19:52 +0000 Subject: [PATCH 078/122] Include Swagger UI --- openflexure_microscope/api/app.py | 4 +- .../common/flask_labthings/labthing.py | 56 ++------ .../common/flask_labthings/quick.py | 2 +- .../flask_labthings/views/docs/__init__.py | 79 +++++++++++ .../views/docs/static/favicon-16x16.png | Bin 0 -> 665 bytes .../views/docs/static/favicon-32x32.png | Bin 0 -> 628 bytes .../views/docs/static/index.html | 60 ++++++++ .../views/docs/static/oauth2-redirect.html | 68 +++++++++ .../views/docs/static/swagger-ui-bundle.js | 134 ++++++++++++++++++ .../docs/static/swagger-ui-bundle.js.map | 1 + .../static/swagger-ui-standalone-preset.js | 22 +++ .../swagger-ui-standalone-preset.js.map | 1 + .../views/docs/static/swagger-ui.css | 4 + .../views/docs/static/swagger-ui.css.map | 1 + .../views/docs/static/swagger-ui.js | 9 ++ .../views/docs/static/swagger-ui.js.map | 1 + .../views/docs/templates/swagger-ui.html | 38 +++++ 17 files changed, 433 insertions(+), 47 deletions(-) create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/__init__.py create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/favicon-16x16.png create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/favicon-32x32.png create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/index.html create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/oauth2-redirect.html create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-bundle.js create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-bundle.js.map create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-standalone-preset.js create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-standalone-preset.js.map create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.css create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.css.map create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.js create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.js.map create mode 100644 openflexure_microscope/common/flask_labthings/views/docs/templates/swagger-ui.html diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index a08d5602..dad8d1c9 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -4,6 +4,7 @@ import time import atexit import logging import os +import pkg_resources from flask import Flask, jsonify, send_file @@ -59,8 +60,9 @@ else: app, labthing = create_app( __name__, prefix="/api/v2", - description="Test LabThing-based API for OpenFlexure Microscope", title=f"OpenFlexure Microscope {api_microscope.name}", + description="Test LabThing-based API for OpenFlexure Microscope", + version=pkg_resources.get_distribution("openflexure_microscope").version, ) # Use custom JSON encoder diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index 029ba64f..99248ae3 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -10,6 +10,7 @@ from .spec import view2path from .views.extensions import ExtensionList from .views.tasks import TaskList, TaskResource +from .views.docs import docs_blueprint, APISpecResource, W3CThingDescriptionResource from openflexure_microscope.common.labthings_core.utilities import get_docstring @@ -106,11 +107,12 @@ class LabThing(object): # Add root representation self.app.add_url_rule(self._complete_url("/", ""), "rootrep", self.rootrep) # Add thing description - self.app.add_url_rule(self._complete_url("/td", ""), "td", self.td) + # self.app.add_url_rule(self._complete_url("/td", ""), "td", self.td) # Add swagger spec - self.app.add_url_rule( - self._complete_url("/swagger", ""), "swagger", self.swagger - ) + # self.app.add_url_rule( + # self._complete_url("/swagger", ""), "swagger", self.swagger + # ) + self.app.register_blueprint(docs_blueprint, url_prefix=self.url_prefix) # Add extension overview self.add_resource( @@ -270,35 +272,6 @@ class LabThing(object): 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) - def rootrep(self): """ Root representation @@ -311,14 +284,13 @@ class LabThing(object): "description": self.description, "links": { "thingDescription": { - "href": url_for("td", _external=True), - "description": get_docstring(self.td), + "href": url_for("labthings_docs.w3c_td", _external=True), + "description": get_docstring(W3CThingDescriptionResource), "methods": ["GET"], }, - "swagger": { - "href": url_for("swagger", _external=True), - "description": get_docstring(self.swagger), - "methods": ["GET"], + "swaggerUI": { + "href": url_for("labthings_docs.swagger_ui", _external=True), + **description_from_view(APISpecResource), }, "extensions": { "href": self.url_for(ExtensionList, _external=True), @@ -332,9 +304,3 @@ class LabThing(object): } return jsonify(rr) - - def swagger(self): - """ - OpenAPI v3 documentation - """ - return jsonify(self.spec.to_dict()) diff --git a/openflexure_microscope/common/flask_labthings/quick.py b/openflexure_microscope/common/flask_labthings/quick.py index 67b0dc71..97f919a8 100644 --- a/openflexure_microscope/common/flask_labthings/quick.py +++ b/openflexure_microscope/common/flask_labthings/quick.py @@ -29,7 +29,7 @@ def create_app( # Create a LabThing labthing = LabThing( - app, prefix=prefix, title=title, description=description, version=version + app, prefix=prefix, title=title, description=description, version=str(version) ) # Store references to added-in handlers diff --git a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py new file mode 100644 index 00000000..9fbf4e25 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py @@ -0,0 +1,79 @@ +from flask import abort, url_for, jsonify, render_template, Blueprint + +from openflexure_microscope.common.labthings_core.utilities import get_docstring + +from ...resource import Resource +from ...find import current_labthing + +import os + + +class APISpecResource(Resource): + """ + OpenAPI v3 documentation + """ + + def get(self): + """ + OpenAPI v3 documentation + """ + return jsonify(current_labthing().spec.to_dict()) + + +class SwaggerUIResource(Resource): + """ + Swagger UI documentation + """ + + def get(self): + return render_template("swagger-ui.html") + + +class W3CThingDescriptionResource(Resource): + """ + W3C-style Thing Description + """ + + def get(self): + props = {} + for key, prop in current_labthing().properties.items(): + props[key] = {} + props[key]["title"] = prop.__name__ + props[key]["description"] = get_docstring(prop) + props[key]["links"] = [ + {"href": current_labthing().url_for(prop, _external=True)} + ] + + actions = {} + for key, prop in current_labthing().actions.items(): + actions[key] = {} + actions[key]["title"] = prop.__name__ + actions[key]["description"] = get_docstring(prop) + actions[key]["links"] = [ + {"href": current_labthing().url_for(prop, _external=True)} + ] + + td = { + "id": url_for("labthings_docs.w3c_td", _external=True), + "title": current_labthing().title, + "description": current_labthing().description, + "properties": props, + "actions": actions, + } + + return jsonify(td) + + +docs_blueprint = Blueprint( + "labthings_docs", __name__, static_folder="./static", template_folder="./templates" +) + +docs_blueprint.add_url_rule( + "/swagger", view_func=APISpecResource.as_view("swagger_json") +) +docs_blueprint.add_url_rule( + "/swagger-ui", view_func=SwaggerUIResource.as_view("swagger_ui") +) +docs_blueprint.add_url_rule( + "/td", view_func=W3CThingDescriptionResource.as_view("w3c_td") +) diff --git a/openflexure_microscope/common/flask_labthings/views/docs/static/favicon-16x16.png b/openflexure_microscope/common/flask_labthings/views/docs/static/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..8b194e617af1c135e6b37939591d24ac3a5efa18 GIT binary patch literal 665 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4rT@h1`S>QUldMA14)GCski36+dTHKW7<77a4n3d1n_{XBT-F7X?>mB~K7i_Ht4Wc2x0k zRts=ca(7blaMlcS)Q+@Q@pV-7brLai6E$%WHF6g;b`dgl6EJk*G4vGGc3@EPWH9z+ zFg9iYgVtBNEiZMNU+6SF(`|gJQ~yY>;h|pbUA>ySdS#akN-h`_oiZ#uX;^T=F#niQ z?oq?sBZfJLjj|6IW$ic0+-IDz+c;yFarzEp?_Nu_I^ZWPgTC;lP%7rthP3h}wZSgTwk&~4d z*@{4T&dKy&G3BjwcXL1TZE(? zdrYTTmp*7)B6Kk7tl#{_|Nozhlg!qe?pem~qq{Tu*c$%wDc%8Aem(1UB_I{ z%hx*X#ogN~)68dmfB1><^+&l|Vj&z4Yq_(Z^B(g2%JM<#!5)VT%SB@DzL_sSM{Zt- zp2FL|p8E^`pI-lS>-!mB7UwYdyUuy8gTe~ HDWM4fUsDF? literal 0 HcmV?d00001 diff --git a/openflexure_microscope/common/flask_labthings/views/docs/static/favicon-32x32.png b/openflexure_microscope/common/flask_labthings/views/docs/static/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..249737fe44558e679f0b67134e274461d988fa98 GIT binary patch literal 628 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4rT@h2A3sW#~2tGCIt9|xH2#>2pGGFnYxLY zxrv*(G3Z%|nYu|>xQd&*N!hsa8#;&>yNH5iL2680#Z28qO+bo4Tx0jvSGwY6?uzct zjZbxq)9edQ8tTV6m}lEXEj6oqpy%9RU4BJBex0dhzFpo?Bl`-Q;&TRS{!TiPj&e?} zsoPDeZt69^(D7)u^6Ikk?6C5iU|Esh`;>u!L9`^uFPK5}?3ah^GynbgC!kP%WTjhI z@b$gAUD-cM7#JAqJY5_^DsHs~-z;k~5Rg?BP7#0Pv$)zVJWt0!>^hUMd$00x?iKYC)<2bZL)F6{(by(TD#|y^8%_n zZcFTSzi~ERA^phgH=O3rj&Z7630C`h|7Z2uwJo%62H*dEF}plJYA{Ef{IJ^c*y3eN z3(Za(k!_M$`6nb|$>hwQ@JyGv@lC4t>w0Du#wBY%y}I>b!>U7UN0+>3HTzU}nAyVT zYyaXYQd7JI4cPTIRwX?@e<8rNU*Cc2s)f(97N%g;^9w%lPP|@wJJs=U>jU5TVxH7# zVmp)-xh?i{pKD%|{d4b4YCEwx*Iq?uFx$=` + + + + + Swagger UI + + + + + + + +
+ + + + + + diff --git a/openflexure_microscope/common/flask_labthings/views/docs/static/oauth2-redirect.html b/openflexure_microscope/common/flask_labthings/views/docs/static/oauth2-redirect.html new file mode 100644 index 00000000..a013fc82 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/views/docs/static/oauth2-redirect.html @@ -0,0 +1,68 @@ + + +Swagger UI: OAuth2 Redirect + + + + diff --git a/openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-bundle.js b/openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-bundle.js new file mode 100644 index 00000000..559df3ca --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-bundle.js @@ -0,0 +1,134 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(function(){try{return require("esprima")}catch(e){}}()):"function"==typeof define&&define.amd?define(["esprima"],t):"object"==typeof exports?exports.SwaggerUIBundle=t(function(){try{return require("esprima")}catch(e){}}()):e.SwaggerUIBundle=t(e.esprima)}(window,function(e){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist",n(n.s=488)}([function(e,t,n){"use strict";e.exports=n(104)},function(e,t,n){e.exports=function(){"use strict";var e=Array.prototype.slice;function t(e,t){t&&(e.prototype=Object.create(t.prototype)),e.prototype.constructor=e}function n(e){return a(e)?e:J(e)}function r(e){return s(e)?e:K(e)}function o(e){return u(e)?e:Y(e)}function i(e){return a(e)&&!c(e)?e:$(e)}function a(e){return!(!e||!e[p])}function s(e){return!(!e||!e[f])}function u(e){return!(!e||!e[h])}function c(e){return s(e)||u(e)}function l(e){return!(!e||!e[d])}t(r,n),t(o,n),t(i,n),n.isIterable=a,n.isKeyed=s,n.isIndexed=u,n.isAssociative=c,n.isOrdered=l,n.Keyed=r,n.Indexed=o,n.Set=i;var p="@@__IMMUTABLE_ITERABLE__@@",f="@@__IMMUTABLE_KEYED__@@",h="@@__IMMUTABLE_INDEXED__@@",d="@@__IMMUTABLE_ORDERED__@@",m=5,v=1<>>0;if(""+n!==t||4294967295===n)return NaN;t=n}return t<0?C(e)+t:t}function O(){return!0}function A(e,t,n){return(0===e||void 0!==n&&e<=-n)&&(void 0===t||void 0!==n&&t>=n)}function T(e,t){return P(e,t,0)}function j(e,t){return P(e,t,t)}function P(e,t,n){return void 0===e?n:e<0?Math.max(0,t+e):void 0===t?e:Math.min(t,e)}var I=0,M=1,N=2,R="function"==typeof Symbol&&Symbol.iterator,D="@@iterator",L=R||D;function U(e){this.next=e}function q(e,t,n,r){var o=0===e?t:1===e?n:[t,n];return r?r.value=o:r={value:o,done:!1},r}function F(){return{value:void 0,done:!0}}function B(e){return!!H(e)}function z(e){return e&&"function"==typeof e.next}function V(e){var t=H(e);return t&&t.call(e)}function H(e){var t=e&&(R&&e[R]||e[D]);if("function"==typeof t)return t}function W(e){return e&&"number"==typeof e.length}function J(e){return null==e?ie():a(e)?e.toSeq():function(e){var t=ue(e)||"object"==typeof e&&new te(e);if(!t)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+e);return t}(e)}function K(e){return null==e?ie().toKeyedSeq():a(e)?s(e)?e.toSeq():e.fromEntrySeq():ae(e)}function Y(e){return null==e?ie():a(e)?s(e)?e.entrySeq():e.toIndexedSeq():se(e)}function $(e){return(null==e?ie():a(e)?s(e)?e.entrySeq():e:se(e)).toSetSeq()}U.prototype.toString=function(){return"[Iterator]"},U.KEYS=I,U.VALUES=M,U.ENTRIES=N,U.prototype.inspect=U.prototype.toSource=function(){return this.toString()},U.prototype[L]=function(){return this},t(J,n),J.of=function(){return J(arguments)},J.prototype.toSeq=function(){return this},J.prototype.toString=function(){return this.__toString("Seq {","}")},J.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},J.prototype.__iterate=function(e,t){return ce(this,e,t,!0)},J.prototype.__iterator=function(e,t){return le(this,e,t,!0)},t(K,J),K.prototype.toKeyedSeq=function(){return this},t(Y,J),Y.of=function(){return Y(arguments)},Y.prototype.toIndexedSeq=function(){return this},Y.prototype.toString=function(){return this.__toString("Seq [","]")},Y.prototype.__iterate=function(e,t){return ce(this,e,t,!1)},Y.prototype.__iterator=function(e,t){return le(this,e,t,!1)},t($,J),$.of=function(){return $(arguments)},$.prototype.toSetSeq=function(){return this},J.isSeq=oe,J.Keyed=K,J.Set=$,J.Indexed=Y;var G,Z,X,Q="@@__IMMUTABLE_SEQ__@@";function ee(e){this._array=e,this.size=e.length}function te(e){var t=Object.keys(e);this._object=e,this._keys=t,this.size=t.length}function ne(e){this._iterable=e,this.size=e.length||e.size}function re(e){this._iterator=e,this._iteratorCache=[]}function oe(e){return!(!e||!e[Q])}function ie(){return G||(G=new ee([]))}function ae(e){var t=Array.isArray(e)?new ee(e).fromEntrySeq():z(e)?new re(e).fromEntrySeq():B(e)?new ne(e).fromEntrySeq():"object"==typeof e?new te(e):void 0;if(!t)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+e);return t}function se(e){var t=ue(e);if(!t)throw new TypeError("Expected Array or iterable object of values: "+e);return t}function ue(e){return W(e)?new ee(e):z(e)?new re(e):B(e)?new ne(e):void 0}function ce(e,t,n,r){var o=e._cache;if(o){for(var i=o.length-1,a=0;a<=i;a++){var s=o[n?i-a:a];if(!1===t(s[1],r?s[0]:a,e))return a+1}return a}return e.__iterateUncached(t,n)}function le(e,t,n,r){var o=e._cache;if(o){var i=o.length-1,a=0;return new U(function(){var e=o[n?i-a:a];return a++>i?{value:void 0,done:!0}:q(t,r?e[0]:a-1,e[1])})}return e.__iteratorUncached(t,n)}function pe(e,t){return t?function e(t,n,r,o){return Array.isArray(n)?t.call(o,r,Y(n).map(function(r,o){return e(t,r,o,n)})):he(n)?t.call(o,r,K(n).map(function(r,o){return e(t,r,o,n)})):n}(t,e,"",{"":e}):fe(e)}function fe(e){return Array.isArray(e)?Y(e).map(fe).toList():he(e)?K(e).map(fe).toMap():e}function he(e){return e&&(e.constructor===Object||void 0===e.constructor)}function de(e,t){if(e===t||e!=e&&t!=t)return!0;if(!e||!t)return!1;if("function"==typeof e.valueOf&&"function"==typeof t.valueOf){if((e=e.valueOf())===(t=t.valueOf())||e!=e&&t!=t)return!0;if(!e||!t)return!1}return!("function"!=typeof e.equals||"function"!=typeof t.equals||!e.equals(t))}function me(e,t){if(e===t)return!0;if(!a(t)||void 0!==e.size&&void 0!==t.size&&e.size!==t.size||void 0!==e.__hash&&void 0!==t.__hash&&e.__hash!==t.__hash||s(e)!==s(t)||u(e)!==u(t)||l(e)!==l(t))return!1;if(0===e.size&&0===t.size)return!0;var n=!c(e);if(l(e)){var r=e.entries();return t.every(function(e,t){var o=r.next().value;return o&&de(o[1],e)&&(n||de(o[0],t))})&&r.next().done}var o=!1;if(void 0===e.size)if(void 0===t.size)"function"==typeof e.cacheResult&&e.cacheResult();else{o=!0;var i=e;e=t,t=i}var p=!0,f=t.__iterate(function(t,r){if(n?!e.has(t):o?!de(t,e.get(r,y)):!de(e.get(r,y),t))return p=!1,!1});return p&&e.size===f}function ve(e,t){if(!(this instanceof ve))return new ve(e,t);if(this._value=e,this.size=void 0===t?1/0:Math.max(0,t),0===this.size){if(Z)return Z;Z=this}}function ge(e,t){if(!e)throw new Error(t)}function ye(e,t,n){if(!(this instanceof ye))return new ye(e,t,n);if(ge(0!==n,"Cannot step a Range by 0"),e=e||0,void 0===t&&(t=1/0),n=void 0===n?1:Math.abs(n),tr?{value:void 0,done:!0}:q(e,o,n[t?r-o++:o++])})},t(te,K),te.prototype.get=function(e,t){return void 0===t||this.has(e)?this._object[e]:t},te.prototype.has=function(e){return this._object.hasOwnProperty(e)},te.prototype.__iterate=function(e,t){for(var n=this._object,r=this._keys,o=r.length-1,i=0;i<=o;i++){var a=r[t?o-i:i];if(!1===e(n[a],a,this))return i+1}return i},te.prototype.__iterator=function(e,t){var n=this._object,r=this._keys,o=r.length-1,i=0;return new U(function(){var a=r[t?o-i:i];return i++>o?{value:void 0,done:!0}:q(e,a,n[a])})},te.prototype[d]=!0,t(ne,Y),ne.prototype.__iterateUncached=function(e,t){if(t)return this.cacheResult().__iterate(e,t);var n=V(this._iterable),r=0;if(z(n))for(var o;!(o=n.next()).done&&!1!==e(o.value,r++,this););return r},ne.prototype.__iteratorUncached=function(e,t){if(t)return this.cacheResult().__iterator(e,t);var n=V(this._iterable);if(!z(n))return new U(F);var r=0;return new U(function(){var t=n.next();return t.done?t:q(e,r++,t.value)})},t(re,Y),re.prototype.__iterateUncached=function(e,t){if(t)return this.cacheResult().__iterate(e,t);for(var n,r=this._iterator,o=this._iteratorCache,i=0;i=r.length){var t=n.next();if(t.done)return t;r[o]=t.value}return q(e,o,r[o++])})},t(ve,Y),ve.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},ve.prototype.get=function(e,t){return this.has(e)?this._value:t},ve.prototype.includes=function(e){return de(this._value,e)},ve.prototype.slice=function(e,t){var n=this.size;return A(e,t,n)?this:new ve(this._value,j(t,n)-T(e,n))},ve.prototype.reverse=function(){return this},ve.prototype.indexOf=function(e){return de(this._value,e)?0:-1},ve.prototype.lastIndexOf=function(e){return de(this._value,e)?this.size:-1},ve.prototype.__iterate=function(e,t){for(var n=0;n=0&&t=0&&nn?{value:void 0,done:!0}:q(e,i++,a)})},ye.prototype.equals=function(e){return e instanceof ye?this._start===e._start&&this._end===e._end&&this._step===e._step:me(this,e)},t(be,n),t(_e,be),t(we,be),t(xe,be),be.Keyed=_e,be.Indexed=we,be.Set=xe;var Ee="function"==typeof Math.imul&&-2===Math.imul(4294967295,2)?Math.imul:function(e,t){var n=65535&(e|=0),r=65535&(t|=0);return n*r+((e>>>16)*r+n*(t>>>16)<<16>>>0)|0};function Se(e){return e>>>1&1073741824|3221225471&e}function Ce(e){if(!1===e||null==e)return 0;if("function"==typeof e.valueOf&&(!1===(e=e.valueOf())||null==e))return 0;if(!0===e)return 1;var t=typeof e;if("number"===t){if(e!=e||e===1/0)return 0;var n=0|e;for(n!==e&&(n^=4294967295*e);e>4294967295;)n^=e/=4294967295;return Se(n)}if("string"===t)return e.length>Me?function(e){var t=De[e];return void 0===t&&(t=ke(e),Re===Ne&&(Re=0,De={}),Re++,De[e]=t),t}(e):ke(e);if("function"==typeof e.hashCode)return e.hashCode();if("object"===t)return function(e){var t;if(je&&void 0!==(t=Oe.get(e)))return t;if(void 0!==(t=e[Ie]))return t;if(!Te){if(void 0!==(t=e.propertyIsEnumerable&&e.propertyIsEnumerable[Ie]))return t;if(void 0!==(t=function(e){if(e&&e.nodeType>0)switch(e.nodeType){case 1:return e.uniqueID;case 9:return e.documentElement&&e.documentElement.uniqueID}}(e)))return t}if(t=++Pe,1073741824&Pe&&(Pe=0),je)Oe.set(e,t);else{if(void 0!==Ae&&!1===Ae(e))throw new Error("Non-extensible objects are not allowed as keys.");if(Te)Object.defineProperty(e,Ie,{enumerable:!1,configurable:!1,writable:!1,value:t});else if(void 0!==e.propertyIsEnumerable&&e.propertyIsEnumerable===e.constructor.prototype.propertyIsEnumerable)e.propertyIsEnumerable=function(){return this.constructor.prototype.propertyIsEnumerable.apply(this,arguments)},e.propertyIsEnumerable[Ie]=t;else{if(void 0===e.nodeType)throw new Error("Unable to set a non-enumerable property on object.");e[Ie]=t}}return t}(e);if("function"==typeof e.toString)return ke(e.toString());throw new Error("Value type "+t+" cannot be hashed.")}function ke(e){for(var t=0,n=0;n=t.length)throw new Error("Missing value for key: "+t[n]);e.set(t[n],t[n+1])}})},Ue.prototype.toString=function(){return this.__toString("Map {","}")},Ue.prototype.get=function(e,t){return this._root?this._root.get(0,void 0,e,t):t},Ue.prototype.set=function(e,t){return Qe(this,e,t)},Ue.prototype.setIn=function(e,t){return this.updateIn(e,y,function(){return t})},Ue.prototype.remove=function(e){return Qe(this,e,y)},Ue.prototype.deleteIn=function(e){return this.updateIn(e,function(){return y})},Ue.prototype.update=function(e,t,n){return 1===arguments.length?e(this):this.updateIn([e],t,n)},Ue.prototype.updateIn=function(e,t,n){n||(n=t,t=void 0);var r=function e(t,n,r,o){var i=t===y,a=n.next();if(a.done){var s=i?r:t,u=o(s);return u===s?t:u}ge(i||t&&t.set,"invalid keyPath");var c=a.value,l=i?y:t.get(c,y),p=e(l,n,r,o);return p===l?t:p===y?t.remove(c):(i?Xe():t).set(c,p)}(this,rn(e),t,n);return r===y?void 0:r},Ue.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):Xe()},Ue.prototype.merge=function(){return rt(this,void 0,arguments)},Ue.prototype.mergeWith=function(t){var n=e.call(arguments,1);return rt(this,t,n)},Ue.prototype.mergeIn=function(t){var n=e.call(arguments,1);return this.updateIn(t,Xe(),function(e){return"function"==typeof e.merge?e.merge.apply(e,n):n[n.length-1]})},Ue.prototype.mergeDeep=function(){return rt(this,ot,arguments)},Ue.prototype.mergeDeepWith=function(t){var n=e.call(arguments,1);return rt(this,it(t),n)},Ue.prototype.mergeDeepIn=function(t){var n=e.call(arguments,1);return this.updateIn(t,Xe(),function(e){return"function"==typeof e.mergeDeep?e.mergeDeep.apply(e,n):n[n.length-1]})},Ue.prototype.sort=function(e){return Tt(Jt(this,e))},Ue.prototype.sortBy=function(e,t){return Tt(Jt(this,t,e))},Ue.prototype.withMutations=function(e){var t=this.asMutable();return e(t),t.wasAltered()?t.__ensureOwner(this.__ownerID):this},Ue.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new E)},Ue.prototype.asImmutable=function(){return this.__ensureOwner()},Ue.prototype.wasAltered=function(){return this.__altered},Ue.prototype.__iterator=function(e,t){return new Ye(this,e,t)},Ue.prototype.__iterate=function(e,t){var n=this,r=0;return this._root&&this._root.iterate(function(t){return r++,e(t[1],t[0],n)},t),r},Ue.prototype.__ensureOwner=function(e){return e===this.__ownerID?this:e?Ze(this.size,this._root,e,this.__hash):(this.__ownerID=e,this.__altered=!1,this)},Ue.isMap=qe;var Fe,Be="@@__IMMUTABLE_MAP__@@",ze=Ue.prototype;function Ve(e,t){this.ownerID=e,this.entries=t}function He(e,t,n){this.ownerID=e,this.bitmap=t,this.nodes=n}function We(e,t,n){this.ownerID=e,this.count=t,this.nodes=n}function Je(e,t,n){this.ownerID=e,this.keyHash=t,this.entries=n}function Ke(e,t,n){this.ownerID=e,this.keyHash=t,this.entry=n}function Ye(e,t,n){this._type=t,this._reverse=n,this._stack=e._root&&Ge(e._root)}function $e(e,t){return q(e,t[0],t[1])}function Ge(e,t){return{node:e,index:0,__prev:t}}function Ze(e,t,n,r){var o=Object.create(ze);return o.size=e,o._root=t,o.__ownerID=n,o.__hash=r,o.__altered=!1,o}function Xe(){return Fe||(Fe=Ze(0))}function Qe(e,t,n){var r,o;if(e._root){var i=w(b),a=w(_);if(r=et(e._root,e.__ownerID,0,void 0,t,n,i,a),!a.value)return e;o=e.size+(i.value?n===y?-1:1:0)}else{if(n===y)return e;o=1,r=new Ve(e.__ownerID,[[t,n]])}return e.__ownerID?(e.size=o,e._root=r,e.__hash=void 0,e.__altered=!0,e):r?Ze(o,r):Xe()}function et(e,t,n,r,o,i,a,s){return e?e.update(t,n,r,o,i,a,s):i===y?e:(x(s),x(a),new Ke(t,r,[o,i]))}function tt(e){return e.constructor===Ke||e.constructor===Je}function nt(e,t,n,r,o){if(e.keyHash===r)return new Je(t,r,[e.entry,o]);var i,a=(0===n?e.keyHash:e.keyHash>>>n)&g,s=(0===n?r:r>>>n)&g;return new He(t,1<>1&1431655765))+(e>>2&858993459))+(e>>4)&252645135,e+=e>>8,127&(e+=e>>16)}function ut(e,t,n,r){var o=r?e:S(e);return o[t]=n,o}ze[Be]=!0,ze.delete=ze.remove,ze.removeIn=ze.deleteIn,Ve.prototype.get=function(e,t,n,r){for(var o=this.entries,i=0,a=o.length;i=ct)return function(e,t,n,r){e||(e=new E);for(var o=new Ke(e,Ce(n),[n,r]),i=0;i>>e)&g),i=this.bitmap;return 0==(i&o)?r:this.nodes[st(i&o-1)].get(e+m,t,n,r)},He.prototype.update=function(e,t,n,r,o,i,a){void 0===n&&(n=Ce(r));var s=(0===t?n:n>>>t)&g,u=1<=lt)return function(e,t,n,r,o){for(var i=0,a=new Array(v),s=0;0!==n;s++,n>>>=1)a[s]=1&n?t[i++]:void 0;return a[r]=o,new We(e,i+1,a)}(e,f,c,s,d);if(l&&!d&&2===f.length&&tt(f[1^p]))return f[1^p];if(l&&d&&1===f.length&&tt(d))return d;var b=e&&e===this.ownerID,_=l?d?c:c^u:c|u,w=l?d?ut(f,p,d,b):function(e,t,n){var r=e.length-1;if(n&&t===r)return e.pop(),e;for(var o=new Array(r),i=0,a=0;a>>e)&g,i=this.nodes[o];return i?i.get(e+m,t,n,r):r},We.prototype.update=function(e,t,n,r,o,i,a){void 0===n&&(n=Ce(r));var s=(0===t?n:n>>>t)&g,u=o===y,c=this.nodes,l=c[s];if(u&&!l)return this;var p=et(l,e,t+m,n,r,o,i,a);if(p===l)return this;var f=this.count;if(l){if(!p&&--f0&&r=0&&e=e.size||t<0)return e.withMutations(function(e){t<0?kt(e,t).set(0,n):kt(e,0,t+1).set(t,n)});t+=e._origin;var r=e._tail,o=e._root,i=w(_);return t>=At(e._capacity)?r=Et(r,e.__ownerID,0,t,n,i):o=Et(o,e.__ownerID,e._level,t,n,i),i.value?e.__ownerID?(e._root=o,e._tail=r,e.__hash=void 0,e.__altered=!0,e):wt(e._origin,e._capacity,e._level,o,r):e}(this,e,t)},ft.prototype.remove=function(e){return this.has(e)?0===e?this.shift():e===this.size-1?this.pop():this.splice(e,1):this},ft.prototype.insert=function(e,t){return this.splice(e,0,t)},ft.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=this._origin=this._capacity=0,this._level=m,this._root=this._tail=null,this.__hash=void 0,this.__altered=!0,this):xt()},ft.prototype.push=function(){var e=arguments,t=this.size;return this.withMutations(function(n){kt(n,0,t+e.length);for(var r=0;r>>t&g;if(r>=this.array.length)return new vt([],e);var o,i=0===r;if(t>0){var a=this.array[r];if((o=a&&a.removeBefore(e,t-m,n))===a&&i)return this}if(i&&!o)return this;var s=St(this,e);if(!i)for(var u=0;u>>t&g;if(o>=this.array.length)return this;if(t>0){var i=this.array[o];if((r=i&&i.removeAfter(e,t-m,n))===i&&o===this.array.length-1)return this}var a=St(this,e);return a.array.splice(o+1),r&&(a.array[o]=r),a};var gt,yt,bt={};function _t(e,t){var n=e._origin,r=e._capacity,o=At(r),i=e._tail;return a(e._root,e._level,0);function a(e,s,u){return 0===s?function(e,a){var s=a===o?i&&i.array:e&&e.array,u=a>n?0:n-a,c=r-a;return c>v&&(c=v),function(){if(u===c)return bt;var e=t?--c:u++;return s&&s[e]}}(e,u):function(e,o,i){var s,u=e&&e.array,c=i>n?0:n-i>>o,l=1+(r-i>>o);return l>v&&(l=v),function(){for(;;){if(s){var e=s();if(e!==bt)return e;s=null}if(c===l)return bt;var n=t?--l:c++;s=a(u&&u[n],o-m,i+(n<>>n&g,u=e&&s0){var c=e&&e.array[s],l=Et(c,t,n-m,r,o,i);return l===c?e:((a=St(e,t)).array[s]=l,a)}return u&&e.array[s]===o?e:(x(i),a=St(e,t),void 0===o&&s===a.array.length-1?a.array.pop():a.array[s]=o,a)}function St(e,t){return t&&e&&t===e.ownerID?e:new vt(e?e.array.slice():[],t)}function Ct(e,t){if(t>=At(e._capacity))return e._tail;if(t<1<0;)n=n.array[t>>>r&g],r-=m;return n}}function kt(e,t,n){void 0!==t&&(t|=0),void 0!==n&&(n|=0);var r=e.__ownerID||new E,o=e._origin,i=e._capacity,a=o+t,s=void 0===n?i:n<0?i+n:o+n;if(a===o&&s===i)return e;if(a>=s)return e.clear();for(var u=e._level,c=e._root,l=0;a+l<0;)c=new vt(c&&c.array.length?[void 0,c]:[],r),l+=1<<(u+=m);l&&(a+=l,o+=l,s+=l,i+=l);for(var p=At(i),f=At(s);f>=1<p?new vt([],r):h;if(h&&f>p&&am;y-=m){var b=p>>>y&g;v=v.array[b]=St(v.array[b],r)}v.array[p>>>m&g]=h}if(s=f)a-=f,s-=f,u=m,c=null,d=d&&d.removeBefore(r,0,a);else if(a>o||f>>u&g;if(_!==f>>>u&g)break;_&&(l+=(1<o&&(c=c.removeBefore(r,u,a-l)),c&&fi&&(i=c.size),a(u)||(c=c.map(function(e){return pe(e)})),r.push(c)}return i>e.size&&(e=e.setSize(i)),at(e,t,r)}function At(e){return e>>m<=v&&a.size>=2*i.size?(r=(o=a.filter(function(e,t){return void 0!==e&&s!==t})).toKeyedSeq().map(function(e){return e[0]}).flip().toMap(),e.__ownerID&&(r.__ownerID=o.__ownerID=e.__ownerID)):(r=i.remove(t),o=s===a.size-1?a.pop():a.set(s,void 0))}else if(u){if(n===a.get(s)[1])return e;r=i,o=a.set(s,[t,n])}else r=i.set(t,a.size),o=a.set(a.size,[t,n]);return e.__ownerID?(e.size=r.size,e._map=r,e._list=o,e.__hash=void 0,e):Pt(r,o)}function Nt(e,t){this._iter=e,this._useKeys=t,this.size=e.size}function Rt(e){this._iter=e,this.size=e.size}function Dt(e){this._iter=e,this.size=e.size}function Lt(e){this._iter=e,this.size=e.size}function Ut(e){var t=en(e);return t._iter=e,t.size=e.size,t.flip=function(){return e},t.reverse=function(){var t=e.reverse.apply(this);return t.flip=function(){return e.reverse()},t},t.has=function(t){return e.includes(t)},t.includes=function(t){return e.has(t)},t.cacheResult=tn,t.__iterateUncached=function(t,n){var r=this;return e.__iterate(function(e,n){return!1!==t(n,e,r)},n)},t.__iteratorUncached=function(t,n){if(t===N){var r=e.__iterator(t,n);return new U(function(){var e=r.next();if(!e.done){var t=e.value[0];e.value[0]=e.value[1],e.value[1]=t}return e})}return e.__iterator(t===M?I:M,n)},t}function qt(e,t,n){var r=en(e);return r.size=e.size,r.has=function(t){return e.has(t)},r.get=function(r,o){var i=e.get(r,y);return i===y?o:t.call(n,i,r,e)},r.__iterateUncached=function(r,o){var i=this;return e.__iterate(function(e,o,a){return!1!==r(t.call(n,e,o,a),o,i)},o)},r.__iteratorUncached=function(r,o){var i=e.__iterator(N,o);return new U(function(){var o=i.next();if(o.done)return o;var a=o.value,s=a[0];return q(r,s,t.call(n,a[1],s,e),o)})},r}function Ft(e,t){var n=en(e);return n._iter=e,n.size=e.size,n.reverse=function(){return e},e.flip&&(n.flip=function(){var t=Ut(e);return t.reverse=function(){return e.flip()},t}),n.get=function(n,r){return e.get(t?n:-1-n,r)},n.has=function(n){return e.has(t?n:-1-n)},n.includes=function(t){return e.includes(t)},n.cacheResult=tn,n.__iterate=function(t,n){var r=this;return e.__iterate(function(e,n){return t(e,n,r)},!n)},n.__iterator=function(t,n){return e.__iterator(t,!n)},n}function Bt(e,t,n,r){var o=en(e);return r&&(o.has=function(r){var o=e.get(r,y);return o!==y&&!!t.call(n,o,r,e)},o.get=function(r,o){var i=e.get(r,y);return i!==y&&t.call(n,i,r,e)?i:o}),o.__iterateUncached=function(o,i){var a=this,s=0;return e.__iterate(function(e,i,u){if(t.call(n,e,i,u))return s++,o(e,r?i:s-1,a)},i),s},o.__iteratorUncached=function(o,i){var a=e.__iterator(N,i),s=0;return new U(function(){for(;;){var i=a.next();if(i.done)return i;var u=i.value,c=u[0],l=u[1];if(t.call(n,l,c,e))return q(o,r?c:s++,l,i)}})},o}function zt(e,t,n,r){var o=e.size;if(void 0!==t&&(t|=0),void 0!==n&&(n===1/0?n=o:n|=0),A(t,n,o))return e;var i=T(t,o),a=j(n,o);if(i!=i||a!=a)return zt(e.toSeq().cacheResult(),t,n,r);var s,u=a-i;u==u&&(s=u<0?0:u);var c=en(e);return c.size=0===s?s:e.size&&s||void 0,!r&&oe(e)&&s>=0&&(c.get=function(t,n){return(t=k(this,t))>=0&&ts)return{value:void 0,done:!0};var e=o.next();return r||t===M?e:q(t,u-1,t===I?void 0:e.value[1],e)})},c}function Vt(e,t,n,r){var o=en(e);return o.__iterateUncached=function(o,i){var a=this;if(i)return this.cacheResult().__iterate(o,i);var s=!0,u=0;return e.__iterate(function(e,i,c){if(!s||!(s=t.call(n,e,i,c)))return u++,o(e,r?i:u-1,a)}),u},o.__iteratorUncached=function(o,i){var a=this;if(i)return this.cacheResult().__iterator(o,i);var s=e.__iterator(N,i),u=!0,c=0;return new U(function(){var e,i,l;do{if((e=s.next()).done)return r||o===M?e:q(o,c++,o===I?void 0:e.value[1],e);var p=e.value;i=p[0],l=p[1],u&&(u=t.call(n,l,i,a))}while(u);return o===N?e:q(o,i,l,e)})},o}function Ht(e,t){var n=s(e),o=[e].concat(t).map(function(e){return a(e)?n&&(e=r(e)):e=n?ae(e):se(Array.isArray(e)?e:[e]),e}).filter(function(e){return 0!==e.size});if(0===o.length)return e;if(1===o.length){var i=o[0];if(i===e||n&&s(i)||u(e)&&u(i))return i}var c=new ee(o);return n?c=c.toKeyedSeq():u(e)||(c=c.toSetSeq()),(c=c.flatten(!0)).size=o.reduce(function(e,t){if(void 0!==e){var n=t.size;if(void 0!==n)return e+n}},0),c}function Wt(e,t,n){var r=en(e);return r.__iterateUncached=function(r,o){var i=0,s=!1;return function e(u,c){var l=this;u.__iterate(function(o,u){return(!t||c0}function $t(e,t,r){var o=en(e);return o.size=new ee(r).map(function(e){return e.size}).min(),o.__iterate=function(e,t){for(var n,r=this.__iterator(M,t),o=0;!(n=r.next()).done&&!1!==e(n.value,o++,this););return o},o.__iteratorUncached=function(e,o){var i=r.map(function(e){return e=n(e),V(o?e.reverse():e)}),a=0,s=!1;return new U(function(){var n;return s||(n=i.map(function(e){return e.next()}),s=n.some(function(e){return e.done})),s?{value:void 0,done:!0}:q(e,a++,t.apply(null,n.map(function(e){return e.value})))})},o}function Gt(e,t){return oe(e)?t:e.constructor(t)}function Zt(e){if(e!==Object(e))throw new TypeError("Expected [K, V] tuple: "+e)}function Xt(e){return Le(e.size),C(e)}function Qt(e){return s(e)?r:u(e)?o:i}function en(e){return Object.create((s(e)?K:u(e)?Y:$).prototype)}function tn(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):J.prototype.cacheResult.call(this)}function nn(e,t){return e>t?1:e=0;n--)t={value:arguments[n],next:t};return this.__ownerID?(this.size=e,this._head=t,this.__hash=void 0,this.__altered=!0,this):An(e,t)},En.prototype.pushAll=function(e){if(0===(e=o(e)).size)return this;Le(e.size);var t=this.size,n=this._head;return e.reverse().forEach(function(e){t++,n={value:e,next:n}}),this.__ownerID?(this.size=t,this._head=n,this.__hash=void 0,this.__altered=!0,this):An(t,n)},En.prototype.pop=function(){return this.slice(1)},En.prototype.unshift=function(){return this.push.apply(this,arguments)},En.prototype.unshiftAll=function(e){return this.pushAll(e)},En.prototype.shift=function(){return this.pop.apply(this,arguments)},En.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):Tn()},En.prototype.slice=function(e,t){if(A(e,t,this.size))return this;var n=T(e,this.size);if(j(t,this.size)!==this.size)return we.prototype.slice.call(this,e,t);for(var r=this.size-n,o=this._head;n--;)o=o.next;return this.__ownerID?(this.size=r,this._head=o,this.__hash=void 0,this.__altered=!0,this):An(r,o)},En.prototype.__ensureOwner=function(e){return e===this.__ownerID?this:e?An(this.size,this._head,e,this.__hash):(this.__ownerID=e,this.__altered=!1,this)},En.prototype.__iterate=function(e,t){if(t)return this.reverse().__iterate(e);for(var n=0,r=this._head;r&&!1!==e(r.value,n++,this);)r=r.next;return n},En.prototype.__iterator=function(e,t){if(t)return this.reverse().__iterator(e);var n=0,r=this._head;return new U(function(){if(r){var t=r.value;return r=r.next,q(e,n++,t)}return{value:void 0,done:!0}})},En.isStack=Sn;var Cn,kn="@@__IMMUTABLE_STACK__@@",On=En.prototype;function An(e,t,n,r){var o=Object.create(On);return o.size=e,o._head=t,o.__ownerID=n,o.__hash=r,o.__altered=!1,o}function Tn(){return Cn||(Cn=An(0))}function jn(e,t){var n=function(n){e.prototype[n]=t[n]};return Object.keys(t).forEach(n),Object.getOwnPropertySymbols&&Object.getOwnPropertySymbols(t).forEach(n),e}On[kn]=!0,On.withMutations=ze.withMutations,On.asMutable=ze.asMutable,On.asImmutable=ze.asImmutable,On.wasAltered=ze.wasAltered,n.Iterator=U,jn(n,{toArray:function(){Le(this.size);var e=new Array(this.size||0);return this.valueSeq().__iterate(function(t,n){e[n]=t}),e},toIndexedSeq:function(){return new Rt(this)},toJS:function(){return this.toSeq().map(function(e){return e&&"function"==typeof e.toJS?e.toJS():e}).__toJS()},toJSON:function(){return this.toSeq().map(function(e){return e&&"function"==typeof e.toJSON?e.toJSON():e}).__toJS()},toKeyedSeq:function(){return new Nt(this,!0)},toMap:function(){return Ue(this.toKeyedSeq())},toObject:function(){Le(this.size);var e={};return this.__iterate(function(t,n){e[n]=t}),e},toOrderedMap:function(){return Tt(this.toKeyedSeq())},toOrderedSet:function(){return gn(s(this)?this.valueSeq():this)},toSet:function(){return cn(s(this)?this.valueSeq():this)},toSetSeq:function(){return new Dt(this)},toSeq:function(){return u(this)?this.toIndexedSeq():s(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return En(s(this)?this.valueSeq():this)},toList:function(){return ft(s(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(e,t){return 0===this.size?e+t:e+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+t},concat:function(){var t=e.call(arguments,0);return Gt(this,Ht(this,t))},includes:function(e){return this.some(function(t){return de(t,e)})},entries:function(){return this.__iterator(N)},every:function(e,t){Le(this.size);var n=!0;return this.__iterate(function(r,o,i){if(!e.call(t,r,o,i))return n=!1,!1}),n},filter:function(e,t){return Gt(this,Bt(this,e,t,!0))},find:function(e,t,n){var r=this.findEntry(e,t);return r?r[1]:n},forEach:function(e,t){return Le(this.size),this.__iterate(t?e.bind(t):e)},join:function(e){Le(this.size),e=void 0!==e?""+e:",";var t="",n=!0;return this.__iterate(function(r){n?n=!1:t+=e,t+=null!=r?r.toString():""}),t},keys:function(){return this.__iterator(I)},map:function(e,t){return Gt(this,qt(this,e,t))},reduce:function(e,t,n){var r,o;return Le(this.size),arguments.length<2?o=!0:r=t,this.__iterate(function(t,i,a){o?(o=!1,r=t):r=e.call(n,r,t,i,a)}),r},reduceRight:function(e,t,n){var r=this.toKeyedSeq().reverse();return r.reduce.apply(r,arguments)},reverse:function(){return Gt(this,Ft(this,!0))},slice:function(e,t){return Gt(this,zt(this,e,t,!0))},some:function(e,t){return!this.every(Rn(e),t)},sort:function(e){return Gt(this,Jt(this,e))},values:function(){return this.__iterator(M)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some(function(){return!0})},count:function(e,t){return C(e?this.toSeq().filter(e,t):this)},countBy:function(e,t){return function(e,t,n){var r=Ue().asMutable();return e.__iterate(function(o,i){r.update(t.call(n,o,i,e),0,function(e){return e+1})}),r.asImmutable()}(this,e,t)},equals:function(e){return me(this,e)},entrySeq:function(){var e=this;if(e._cache)return new ee(e._cache);var t=e.toSeq().map(Nn).toIndexedSeq();return t.fromEntrySeq=function(){return e.toSeq()},t},filterNot:function(e,t){return this.filter(Rn(e),t)},findEntry:function(e,t,n){var r=n;return this.__iterate(function(n,o,i){if(e.call(t,n,o,i))return r=[o,n],!1}),r},findKey:function(e,t){var n=this.findEntry(e,t);return n&&n[0]},findLast:function(e,t,n){return this.toKeyedSeq().reverse().find(e,t,n)},findLastEntry:function(e,t,n){return this.toKeyedSeq().reverse().findEntry(e,t,n)},findLastKey:function(e,t){return this.toKeyedSeq().reverse().findKey(e,t)},first:function(){return this.find(O)},flatMap:function(e,t){return Gt(this,function(e,t,n){var r=Qt(e);return e.toSeq().map(function(o,i){return r(t.call(n,o,i,e))}).flatten(!0)}(this,e,t))},flatten:function(e){return Gt(this,Wt(this,e,!0))},fromEntrySeq:function(){return new Lt(this)},get:function(e,t){return this.find(function(t,n){return de(n,e)},void 0,t)},getIn:function(e,t){for(var n,r=this,o=rn(e);!(n=o.next()).done;){var i=n.value;if((r=r&&r.get?r.get(i,y):y)===y)return t}return r},groupBy:function(e,t){return function(e,t,n){var r=s(e),o=(l(e)?Tt():Ue()).asMutable();e.__iterate(function(i,a){o.update(t.call(n,i,a,e),function(e){return(e=e||[]).push(r?[a,i]:i),e})});var i=Qt(e);return o.map(function(t){return Gt(e,i(t))})}(this,e,t)},has:function(e){return this.get(e,y)!==y},hasIn:function(e){return this.getIn(e,y)!==y},isSubset:function(e){return e="function"==typeof e.includes?e:n(e),this.every(function(t){return e.includes(t)})},isSuperset:function(e){return(e="function"==typeof e.isSubset?e:n(e)).isSubset(this)},keyOf:function(e){return this.findKey(function(t){return de(t,e)})},keySeq:function(){return this.toSeq().map(Mn).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(e){return this.toKeyedSeq().reverse().keyOf(e)},max:function(e){return Kt(this,e)},maxBy:function(e,t){return Kt(this,t,e)},min:function(e){return Kt(this,e?Dn(e):qn)},minBy:function(e,t){return Kt(this,t?Dn(t):qn,e)},rest:function(){return this.slice(1)},skip:function(e){return this.slice(Math.max(0,e))},skipLast:function(e){return Gt(this,this.toSeq().reverse().skip(e).reverse())},skipWhile:function(e,t){return Gt(this,Vt(this,e,t,!0))},skipUntil:function(e,t){return this.skipWhile(Rn(e),t)},sortBy:function(e,t){return Gt(this,Jt(this,t,e))},take:function(e){return this.slice(0,Math.max(0,e))},takeLast:function(e){return Gt(this,this.toSeq().reverse().take(e).reverse())},takeWhile:function(e,t){return Gt(this,function(e,t,n){var r=en(e);return r.__iterateUncached=function(r,o){var i=this;if(o)return this.cacheResult().__iterate(r,o);var a=0;return e.__iterate(function(e,o,s){return t.call(n,e,o,s)&&++a&&r(e,o,i)}),a},r.__iteratorUncached=function(r,o){var i=this;if(o)return this.cacheResult().__iterator(r,o);var a=e.__iterator(N,o),s=!0;return new U(function(){if(!s)return{value:void 0,done:!0};var e=a.next();if(e.done)return e;var o=e.value,u=o[0],c=o[1];return t.call(n,c,u,i)?r===N?e:q(r,u,c,e):(s=!1,{value:void 0,done:!0})})},r}(this,e,t))},takeUntil:function(e,t){return this.takeWhile(Rn(e),t)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=function(e){if(e.size===1/0)return 0;var t=l(e),n=s(e),r=t?1:0;return function(e,t){return t=Ee(t,3432918353),t=Ee(t<<15|t>>>-15,461845907),t=Ee(t<<13|t>>>-13,5),t=Ee((t=(t+3864292196|0)^e)^t>>>16,2246822507),t=Se((t=Ee(t^t>>>13,3266489909))^t>>>16)}(e.__iterate(n?t?function(e,t){r=31*r+Fn(Ce(e),Ce(t))|0}:function(e,t){r=r+Fn(Ce(e),Ce(t))|0}:t?function(e){r=31*r+Ce(e)|0}:function(e){r=r+Ce(e)|0}),r)}(this))}});var Pn=n.prototype;Pn[p]=!0,Pn[L]=Pn.values,Pn.__toJS=Pn.toArray,Pn.__toStringMapper=Ln,Pn.inspect=Pn.toSource=function(){return this.toString()},Pn.chain=Pn.flatMap,Pn.contains=Pn.includes,jn(r,{flip:function(){return Gt(this,Ut(this))},mapEntries:function(e,t){var n=this,r=0;return Gt(this,this.toSeq().map(function(o,i){return e.call(t,[i,o],r++,n)}).fromEntrySeq())},mapKeys:function(e,t){var n=this;return Gt(this,this.toSeq().flip().map(function(r,o){return e.call(t,r,o,n)}).flip())}});var In=r.prototype;function Mn(e,t){return t}function Nn(e,t){return[t,e]}function Rn(e){return function(){return!e.apply(this,arguments)}}function Dn(e){return function(){return-e.apply(this,arguments)}}function Ln(e){return"string"==typeof e?JSON.stringify(e):String(e)}function Un(){return S(arguments)}function qn(e,t){return et?-1:0}function Fn(e,t){return e^t+2654435769+(e<<6)+(e>>2)|0}return In[f]=!0,In[L]=Pn.entries,In.__toJS=Pn.toObject,In.__toStringMapper=function(e,t){return JSON.stringify(t)+": "+Ln(e)},jn(o,{toKeyedSeq:function(){return new Nt(this,!1)},filter:function(e,t){return Gt(this,Bt(this,e,t,!1))},findIndex:function(e,t){var n=this.findEntry(e,t);return n?n[0]:-1},indexOf:function(e){var t=this.keyOf(e);return void 0===t?-1:t},lastIndexOf:function(e){var t=this.lastKeyOf(e);return void 0===t?-1:t},reverse:function(){return Gt(this,Ft(this,!1))},slice:function(e,t){return Gt(this,zt(this,e,t,!1))},splice:function(e,t){var n=arguments.length;if(t=Math.max(0|t,0),0===n||2===n&&!t)return this;e=T(e,e<0?this.count():this.size);var r=this.slice(0,e);return Gt(this,1===n?r:r.concat(S(arguments,2),this.slice(e+t)))},findLastIndex:function(e,t){var n=this.findLastEntry(e,t);return n?n[0]:-1},first:function(){return this.get(0)},flatten:function(e){return Gt(this,Wt(this,e,!1))},get:function(e,t){return(e=k(this,e))<0||this.size===1/0||void 0!==this.size&&e>this.size?t:this.find(function(t,n){return n===e},void 0,t)},has:function(e){return(e=k(this,e))>=0&&(void 0!==this.size?this.size===1/0||e5e3)return e.textContent;return function(e){for(var n,r,o,i,a,s=e.textContent,u=0,c=s[0],l=1,p=e.innerHTML="",f=0;r=n,n=f<7&&"\\"==n?1:l;){if(l=c,c=s[++u],i=p.length>1,!l||f>8&&"\n"==l||[/\S/.test(l),1,1,!/[$\w]/.test(l),("/"==n||"\n"==n)&&i,'"'==n&&i,"'"==n&&i,s[u-4]+r+n=="--\x3e",r+n=="*/"][f])for(p&&(e.appendChild(a=t.createElement("span")).setAttribute("style",["color: #555; font-weight: bold;","","","color: #555;",""][f?f<3?2:f>6?4:f>3?3:+/^(a(bstract|lias|nd|rguments|rray|s(m|sert)?|uto)|b(ase|egin|ool(ean)?|reak|yte)|c(ase|atch|har|hecked|lass|lone|ompl|onst|ontinue)|de(bugger|cimal|clare|f(ault|er)?|init|l(egate|ete)?)|do|double|e(cho|ls?if|lse(if)?|nd|nsure|num|vent|x(cept|ec|p(licit|ort)|te(nds|nsion|rn)))|f(allthrough|alse|inal(ly)?|ixed|loat|or(each)?|riend|rom|unc(tion)?)|global|goto|guard|i(f|mp(lements|licit|ort)|n(it|clude(_once)?|line|out|stanceof|t(erface|ernal)?)?|s)|l(ambda|et|ock|ong)|m(icrolight|odule|utable)|NaN|n(amespace|ative|ext|ew|il|ot|ull)|o(bject|perator|r|ut|verride)|p(ackage|arams|rivate|rotected|rotocol|ublic)|r(aise|e(adonly|do|f|gister|peat|quire(_once)?|scue|strict|try|turn))|s(byte|ealed|elf|hort|igned|izeof|tatic|tring|truct|ubscript|uper|ynchronized|witch)|t(emplate|hen|his|hrows?|ransient|rue|ry|ype(alias|def|id|name|of))|u(n(checked|def(ined)?|ion|less|signed|til)|se|sing)|v(ar|irtual|oid|olatile)|w(char_t|hen|here|hile|ith)|xor|yield)$/.test(p):0]),a.appendChild(t.createTextNode(p))),o=f&&f<7?f:o,p="",f=11;![1,/[\/{}[(\-+*=<>:;|\\.,?!&@~]/.test(l),/[\])]/.test(l),/[$\w]/.test(l),"/"==l&&o<2&&"<"!=n,'"'==l,"'"==l,l+c+s[u+1]+s[u+2]=="\x3c!--",l+c=="/*",l+c=="//","#"==l][--f];);p+=l}}(e)}function Q(e){var t;if([/filename\*=[^']+'\w*'"([^"]+)";?/i,/filename\*=[^']+'\w*'([^;]+);?/i,/filename="([^;]*);?"/i,/filename=([^;]*);?/i].some(function(n){return null!==(t=n.exec(e))}),null!==t&&t.length>1)try{return decodeURIComponent(t[1])}catch(e){console.error(e)}return null}function ee(e){return t=e.replace(/\.[^.\/]*$/,""),b()(g()(t));var t}var te=function(e,t){if(e>t)return"Value must be less than Maximum"},ne=function(e,t){if(et)return"Value must be less than MaxLength"},pe=function(e,t){if(e.length2&&void 0!==arguments[2]?arguments[2]:{},r=n.isOAS3,o=void 0!==r&&r,i=n.bypassRequiredCheck,a=void 0!==i&&i,s=[],u=e.get("required"),c=Object(P.a)(e,{isOAS3:o}),p=c.schema,h=c.parameterContentMediaType;if(!p)return s;var m=p.get("required"),v=p.get("maximum"),g=p.get("minimum"),y=p.get("type"),b=p.get("format"),_=p.get("maxLength"),w=p.get("minLength"),x=p.get("pattern");if(y&&(u||m||t)){var E="string"===y&&t,S="array"===y&&l()(t)&&t.length,C="array"===y&&d.a.List.isList(t)&&t.count(),k="array"===y&&"string"==typeof t&&t,O="file"===y&&t instanceof A.a.File,T="boolean"===y&&(t||!1===t),j="number"===y&&(t||0===t),I="integer"===y&&(t||0===t),M="object"===y&&"object"===f()(t)&&null!==t,N="object"===y&&"string"==typeof t&&t,R=[E,S,C,k,O,T,j,I,M,N],D=R.some(function(e){return!!e});if((u||m)&&!D&&!a)return s.push("Required field is not provided"),s;if("object"===y&&"string"==typeof t&&(null===h||"application/json"===h))try{JSON.parse(t)}catch(e){return s.push("Parameter string value must be valid JSON"),s}if(x){var L=fe(t,x);L&&s.push(L)}if(_||0===_){var U=le(t,_);U&&s.push(U)}if(w){var q=pe(t,w);q&&s.push(q)}if(v||0===v){var F=te(t,v);F&&s.push(F)}if(g||0===g){var B=ne(t,g);B&&s.push(B)}if("string"===y){var z;if(!(z="date-time"===b?ue(t):"uuid"===b?ce(t):se(t)))return s;s.push(z)}else if("boolean"===y){var V=ae(t);if(!V)return s;s.push(V)}else if("number"===y){var H=re(t);if(!H)return s;s.push(H)}else if("integer"===y){var W=oe(t);if(!W)return s;s.push(W)}else if("array"===y){var J;if(!C||!t.count())return s;J=p.getIn(["items","type"]),t.forEach(function(e,t){var n;"number"===J?n=re(e):"integer"===J?n=oe(e):"string"===J&&(n=se(e)),n&&s.push({index:t,error:n})})}else if("file"===y){var K=ie(t);if(!K)return s;s.push(K)}}return s},de=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};if(/xml/.test(t)){if(!e.xml||!e.xml.name){if(e.xml=e.xml||{},!e.$$ref)return e.type||e.items||e.properties||e.additionalProperties?'\n\x3c!-- XML example cannot be generated; root element name is undefined --\x3e':null;var r=e.$$ref.match(/\S*\/(\S+)$/);e.xml.name=r[1]}return Object(k.memoizedCreateXMLExample)(e,n)}var i=Object(k.memoizedSampleFromSchema)(e,n);return"object"===f()(i)?o()(i,null,2):i},me=function(){var e={},t=A.a.location.search;if(!t)return{};if(""!=t){var n=t.substr(1).split("&");for(var r in n)n.hasOwnProperty(r)&&(r=n[r].split("="),e[decodeURIComponent(r[0])]=r[1]&&decodeURIComponent(r[1])||"")}return e},ve=function(t){return(t instanceof e?t:new e(t.toString(),"utf-8")).toString("base64")},ge={operationsSorter:{alpha:function(e,t){return e.get("path").localeCompare(t.get("path"))},method:function(e,t){return e.get("method").localeCompare(t.get("method"))}},tagsSorter:{alpha:function(e,t){return e.localeCompare(t)}}},ye=function(e){var t=[];for(var n in e){var r=e[n];void 0!==r&&""!==r&&t.push([n,"=",encodeURIComponent(r).replace(/%20/g,"+")].join(""))}return t.join("&")},be=function(e,t,n){return!!E()(n,function(n){return C()(e[n],t[n])})};function _e(e){return"string"!=typeof e||""===e?"":Object(m.sanitizeUrl)(e)}function we(e){if(!d.a.OrderedMap.isOrderedMap(e))return null;if(!e.size)return null;var t=e.find(function(e,t){return t.startsWith("2")&&u()(e.get("content")||{}).length>0}),n=e.get("default")||d.a.OrderedMap(),r=(n.get("content")||d.a.OrderedMap()).keySeq().toJS().length?n:null;return t||r}var xe=function(e){return"string"==typeof e||e instanceof String?e.trim().replace(/\s/g,"%20"):""},Ee=function(e){return j()(xe(e).replace(/%20/g,"_"))},Se=function(e){return e.filter(function(e,t){return/^x-/.test(t)})},Ce=function(e){return e.filter(function(e,t){return/^pattern|maxLength|minLength|maximum|minimum/.test(t)})};function ke(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:function(){return!0};if("object"!==f()(e)||l()(e)||null===e||!t)return e;var r=a()({},e);return u()(r).forEach(function(e){e===t&&n(r[e],e)?delete r[e]:r[e]=ke(r[e],t,n)}),r}function Oe(e){if("string"==typeof e)return e;if(e&&e.toJS&&(e=e.toJS()),"object"===f()(e)&&null!==e)try{return o()(e,null,2)}catch(t){return String(e)}return null==e?"":e.toString()}function Ae(e){return"number"==typeof e?e.toString():e}function Te(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.returnAll,r=void 0!==n&&n,o=t.allowHashes,i=void 0===o||o;if(!d.a.Map.isMap(e))throw new Error("paramToIdentifier: received a non-Im.Map parameter as input");var a=e.get("name"),s=e.get("in"),u=[];return e&&e.hashCode&&s&&a&&i&&u.push("".concat(s,".").concat(a,".hash-").concat(e.hashCode())),s&&a&&u.push("".concat(s,".").concat(a)),u.push(a),r?u:u[0]||""}function je(e,t){return Te(e,{returnAll:!0}).map(function(e){return t[e]}).filter(function(e){return void 0!==e})[0]}function Pe(){return Me(M()(32).toString("base64"))}function Ie(e){return Me(R()("sha256").update(e).digest("base64"))}function Me(e){return e.replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}}).call(this,n(64).Buffer)},function(e,t){e.exports=function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}},function(e,t,n){var r=n(54);function o(e,t){for(var n=0;n1?t-1:0),o=1;o2?n-2:0),i=2;i>",i={listOf:function(e){return c(e,"List",r.List.isList)},mapOf:function(e,t){return l(e,t,"Map",r.Map.isMap)},orderedMapOf:function(e,t){return l(e,t,"OrderedMap",r.OrderedMap.isOrderedMap)},setOf:function(e){return c(e,"Set",r.Set.isSet)},orderedSetOf:function(e){return c(e,"OrderedSet",r.OrderedSet.isOrderedSet)},stackOf:function(e){return c(e,"Stack",r.Stack.isStack)},iterableOf:function(e){return c(e,"Iterable",r.Iterable.isIterable)},recordOf:function(e){return s(function(t,n,o,i,s){for(var u=arguments.length,c=Array(u>5?u-5:0),l=5;l6?u-6:0),l=6;l5?c-5:0),p=5;p5?i-5:0),s=5;s key("+l[p]+")"].concat(a));if(h instanceof Error)return h}})).apply(void 0,i);var u})}function p(e){var t=void 0===arguments[1]?"Iterable":arguments[1],n=void 0===arguments[2]?r.Iterable.isIterable:arguments[2];return s(function(r,o,i,s,u){for(var c=arguments.length,l=Array(c>5?c-5:0),p=5;p4)}function u(e){var t=e.get("swagger");return"string"==typeof t&&t.startsWith("2.0")}function c(e){return function(t,n){return function(r){return n&&n.specSelectors&&n.specSelectors.specJson?s(n.specSelectors.specJson())?a.a.createElement(e,o()({},r,n,{Ori:t})):a.a.createElement(t,r):(console.warn("OAS3 wrapper: couldn't get spec"),null)}}}},function(e,t,n){"use strict"; +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/var r=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable;function a(e){if(null==e)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}e.exports=function(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de","5"===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;n<10;n++)t["_"+String.fromCharCode(n)]=n;if("0123456789"!==Object.getOwnPropertyNames(t).map(function(e){return t[e]}).join(""))return!1;var r={};return"abcdefghijklmnopqrst".split("").forEach(function(e){r[e]=e}),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},r)).join("")}catch(e){return!1}}()?Object.assign:function(e,t){for(var n,s,u=a(e),c=1;c0){var o=n.map(function(e){return console.error(e),e.line=e.fullPath?g(y,e.fullPath):null,e.path=e.fullPath?e.fullPath.join("."):null,e.level="error",e.type="thrown",e.source="resolver",A()(e,"message",{enumerable:!0,value:e.message}),e});i.newThrownErrBatch(o)}return r.updateResolved(t)})}},_e=[],we=V()(k()(S.a.mark(function e(){var t,n,r,o,i,a,s,u,c,l,p,f,h,d,m,v,g;return S.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if(t=_e.system){e.next=4;break}return console.error("debResolveSubtrees: don't have a system to operate on, aborting."),e.abrupt("return");case 4:if(n=t.errActions,r=t.errSelectors,o=t.fn,i=o.resolveSubtree,a=o.AST,s=void 0===a?{}:a,u=t.specSelectors,c=t.specActions,i){e.next=8;break}return console.error("Error: Swagger-Client did not provide a `resolveSubtree` method, doing nothing."),e.abrupt("return");case 8:return l=s.getLineNumberForPath?s.getLineNumberForPath:function(){},p=u.specStr(),f=t.getConfigs(),h=f.modelPropertyMacro,d=f.parameterMacro,m=f.requestInterceptor,v=f.responseInterceptor,e.prev=11,e.next=14,_e.reduce(function(){var e=k()(S.a.mark(function e(t,o){var a,s,c,f,g,y,b;return S.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return e.next=2,t;case 2:return a=e.sent,s=a.resultMap,c=a.specWithCurrentSubtrees,e.next=7,i(c,o,{baseDoc:u.url(),modelPropertyMacro:h,parameterMacro:d,requestInterceptor:m,responseInterceptor:v});case 7:return f=e.sent,g=f.errors,y=f.spec,r.allErrors().size&&n.clearBy(function(e){return"thrown"!==e.get("type")||"resolver"!==e.get("source")||!e.get("fullPath").every(function(e,t){return e===o[t]||void 0===o[t]})}),j()(g)&&g.length>0&&(b=g.map(function(e){return e.line=e.fullPath?l(p,e.fullPath):null,e.path=e.fullPath?e.fullPath.join("."):null,e.level="error",e.type="thrown",e.source="resolver",A()(e,"message",{enumerable:!0,value:e.message}),e}),n.newThrownErrBatch(b)),W()(s,o,y),W()(c,o,y),e.abrupt("return",{resultMap:s,specWithCurrentSubtrees:c});case 15:case"end":return e.stop()}},e)}));return function(t,n){return e.apply(this,arguments)}}(),x.a.resolve({resultMap:(u.specResolvedSubtree([])||Object(R.Map)()).toJS(),specWithCurrentSubtrees:u.specJson().toJS()}));case 14:g=e.sent,delete _e.system,_e=[],e.next=22;break;case 19:e.prev=19,e.t0=e.catch(11),console.error(e.t0);case 22:c.updateResolvedSubtree([],g.resultMap);case 23:case"end":return e.stop()}},e,null,[[11,19]])})),35),xe=function(e){return function(t){_e.map(function(e){return e.join("@@")}).indexOf(e.join("@@"))>-1||(_e.push(e),_e.system=t,we())}};function Ee(e,t,n,r,o){return{type:X,payload:{path:e,value:r,paramName:t,paramIn:n,isXml:o}}}function Se(e,t,n,r){return{type:X,payload:{path:e,param:t,value:n,isXml:r}}}var Ce=function(e,t){return{type:le,payload:{path:e,value:t}}},ke=function(){return{type:le,payload:{path:[],value:Object(R.Map)()}}},Oe=function(e,t){return{type:ee,payload:{pathMethod:e,isOAS3:t}}},Ae=function(e,t,n,r){return{type:Q,payload:{pathMethod:e,paramName:t,paramIn:n,includeEmptyValue:r}}};function Te(e){return{type:se,payload:{pathMethod:e}}}function je(e,t){return{type:ue,payload:{path:e,value:t,key:"consumes_value"}}}function Pe(e,t){return{type:ue,payload:{path:e,value:t,key:"produces_value"}}}var Ie=function(e,t,n){return{payload:{path:e,method:t,res:n},type:te}},Me=function(e,t,n){return{payload:{path:e,method:t,req:n},type:ne}},Ne=function(e,t,n){return{payload:{path:e,method:t,req:n},type:re}},Re=function(e){return{payload:e,type:oe}},De=function(e){return function(t){var n=t.fn,r=t.specActions,o=t.specSelectors,i=t.getConfigs,a=t.oas3Selectors,s=e.pathName,u=e.method,c=e.operation,l=i(),p=l.requestInterceptor,f=l.responseInterceptor,h=c.toJS();if(c&&c.get("parameters")&&c.get("parameters").filter(function(e){return e&&!0===e.get("allowEmptyValue")}).forEach(function(t){if(o.parameterInclusionSettingFor([s,u],t.get("name"),t.get("in"))){e.parameters=e.parameters||{};var n=Object(J.C)(t,e.parameters);(!n||n&&0===n.size)&&(e.parameters[t.get("name")]="")}}),e.contextUrl=L()(o.url()).toString(),h&&h.operationId?e.operationId=h.operationId:h&&s&&u&&(e.operationId=n.opId(h,s,u)),o.isOAS3()){var d="".concat(s,":").concat(u);e.server=a.selectedServer(d)||a.selectedServer();var m=a.serverVariables({server:e.server,namespace:d}).toJS(),g=a.serverVariables({server:e.server}).toJS();e.serverVariables=_()(m).length?m:g,e.requestContentType=a.requestContentType(s,u),e.responseContentType=a.responseContentType(s,u)||"*/*";var b=a.requestBodyValue(s,u);Object(J.t)(b)?e.requestBody=JSON.parse(b):b&&b.toJS?e.requestBody=b.toJS():e.requestBody=b}var w=y()({},e);w=n.buildRequest(w),r.setRequest(e.pathName,e.method,w);e.requestInterceptor=function(t){var n=p.apply(this,[t]),o=y()({},n);return r.setMutatedRequest(e.pathName,e.method,o),n},e.responseInterceptor=f;var x=v()();return n.execute(e).then(function(t){t.duration=v()()-x,r.setResponse(e.pathName,e.method,t)}).catch(function(t){console.error(t),r.setResponse(e.pathName,e.method,{error:!0,err:q()(t)})})}},Le=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=e.path,n=e.method,r=d()(e,["path","method"]);return function(e){var o=e.fn.fetch,i=e.specSelectors,a=e.specActions,s=i.specJsonWithResolvedSubtrees().toJS(),u=i.operationScheme(t,n),c=i.contentTypeValues([t,n]).toJS(),l=c.requestContentType,p=c.responseContentType,f=/xml/i.test(l),h=i.parameterValues([t,n],f).toJS();return a.executeRequest(Y({},r,{fetch:o,spec:s,pathName:t,method:n,parameters:h,requestContentType:l,scheme:u,responseContentType:p}))}};function Ue(e,t){return{type:ie,payload:{path:e,method:t}}}function qe(e,t){return{type:ae,payload:{path:e,method:t}}}function Fe(e,t,n){return{type:pe,payload:{scheme:e,path:t,method:n}}}},function(e,t,n){var r=n(32),o=n(22),i=n(63),a=n(77),s=n(75),u=function(e,t,n){var c,l,p,f=e&u.F,h=e&u.G,d=e&u.S,m=e&u.P,v=e&u.B,g=e&u.W,y=h?o:o[t]||(o[t]={}),b=y.prototype,_=h?r:d?r[t]:(r[t]||{}).prototype;for(c in h&&(n=t),n)(l=!f&&_&&void 0!==_[c])&&s(y,c)||(p=l?_[c]:n[c],y[c]=h&&"function"!=typeof _[c]?n[c]:v&&l?i(p,r):g&&_[c]==p?function(e){var t=function(t,n,r){if(this instanceof e){switch(arguments.length){case 0:return new e;case 1:return new e(t);case 2:return new e(t,n)}return new e(t,n,r)}return e.apply(this,arguments)};return t.prototype=e.prototype,t}(p):m&&"function"==typeof p?i(Function.call,p):p,m&&((y.virtual||(y.virtual={}))[c]=p,e&u.R&&b&&!b[c]&&a(b,c,p)))};u.F=1,u.G=2,u.S=4,u.P=8,u.B=16,u.W=32,u.U=64,u.R=128,e.exports=u},function(e,t,n){"use strict";var r=n(138),o=["kind","resolve","construct","instanceOf","predicate","represent","defaultStyle","styleAliases"],i=["scalar","sequence","mapping"];e.exports=function(e,t){var n,a;if(t=t||{},Object.keys(t).forEach(function(t){if(-1===o.indexOf(t))throw new r('Unknown option "'+t+'" is met in definition of "'+e+'" YAML type.')}),this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.defaultStyle=t.defaultStyle||null,this.styleAliases=(n=t.styleAliases||null,a={},null!==n&&Object.keys(n).forEach(function(e){n[e].forEach(function(t){a[String(t)]=e})}),a),-1===i.indexOf(this.kind))throw new r('Unknown kind "'+this.kind+'" is specified for "'+e+'" YAML type.')}},function(e,t){var n=e.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=n)},function(e,t,n){var r=n(197)("wks"),o=n(199),i=n(41).Symbol,a="function"==typeof i;(e.exports=function(e){return r[e]||(r[e]=a&&i[e]||(a?i:o)("Symbol."+e))}).store=r},function(e,t,n){var r=n(214)("wks"),o=n(159),i=n(32).Symbol,a="function"==typeof i;(e.exports=function(e){return r[e]||(r[e]=a&&i[e]||(a?i:o)("Symbol."+e))}).store=r},function(e,t,n){var r=n(41),o=n(72),i=n(81),a=n(97),s=n(153),u=function(e,t,n){var c,l,p,f,h=e&u.F,d=e&u.G,m=e&u.S,v=e&u.P,g=e&u.B,y=d?r:m?r[t]||(r[t]={}):(r[t]||{}).prototype,b=d?o:o[t]||(o[t]={}),_=b.prototype||(b.prototype={});for(c in d&&(n=t),n)p=((l=!h&&y&&void 0!==y[c])?y:n)[c],f=g&&l?s(p,r):v&&"function"==typeof p?s(Function.call,p):p,y&&a(y,c,p,e&u.U),b[c]!=p&&i(b,c,f),v&&_[c]!=p&&(_[c]=p)};r.core=o,u.F=1,u.G=2,u.S=4,u.P=8,u.B=16,u.W=32,u.U=64,u.R=128,e.exports=u},function(e,t){var n;n=function(){return this}();try{n=n||new Function("return this")()}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t){var n=Array.isArray;e.exports=n},function(e,t,n){"use strict";var r=!("undefined"==typeof window||!window.document||!window.document.createElement),o={canUseDOM:r,canUseWorkers:"undefined"!=typeof Worker,canUseEventListeners:r&&!(!window.addEventListener&&!window.attachEvent),canUseViewport:r&&!!window.screen,isInWorker:!r};e.exports=o},function(e,t,n){"use strict";var r=Object.prototype.hasOwnProperty;function o(e,t){return!!e&&r.call(e,t)}var i=/\\([\\!"#$%&'()*+,.\/:;<=>?@[\]^_`{|}~-])/g;function a(e){return!(e>=55296&&e<=57343)&&(!(e>=64976&&e<=65007)&&(65535!=(65535&e)&&65534!=(65535&e)&&(!(e>=0&&e<=8)&&(11!==e&&(!(e>=14&&e<=31)&&(!(e>=127&&e<=159)&&!(e>1114111)))))))}function s(e){if(e>65535){var t=55296+((e-=65536)>>10),n=56320+(1023&e);return String.fromCharCode(t,n)}return String.fromCharCode(e)}var u=/&([a-z#][a-z0-9]{1,31});/gi,c=/^#((?:x[a-f0-9]{1,8}|[0-9]{1,8}))/i,l=n(463);function p(e,t){var n=0;return o(l,t)?l[t]:35===t.charCodeAt(0)&&c.test(t)&&a(n="x"===t[1].toLowerCase()?parseInt(t.slice(2),16):parseInt(t.slice(1),10))?s(n):e}var f=/[&<>"]/,h=/[&<>"]/g,d={"&":"&","<":"<",">":">",'"':"""};function m(e){return d[e]}t.assign=function(e){return[].slice.call(arguments,1).forEach(function(t){if(t){if("object"!=typeof t)throw new TypeError(t+"must be object");Object.keys(t).forEach(function(n){e[n]=t[n]})}}),e},t.isString=function(e){return"[object String]"===function(e){return Object.prototype.toString.call(e)}(e)},t.has=o,t.unescapeMd=function(e){return e.indexOf("\\")<0?e:e.replace(i,"$1")},t.isValidEntityCode=a,t.fromCodePoint=s,t.replaceEntities=function(e){return e.indexOf("&")<0?e:e.replace(u,p)},t.escapeHtml=function(e){return f.test(e)?e.replace(h,m):e}},function(e,t,n){var r=n(55),o=n(771);e.exports=function(e,t){if(null==e)return{};var n,i,a=o(e,t);if(r){var s=r(e);for(i=0;i=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(a[n]=e[n])}return a}},function(e,t){var n=e.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=n)},function(e,t,n){var r=n(35),o=n(99),i=n(73),a=/"/g,s=function(e,t,n,r){var o=String(i(e)),s="<"+t;return""!==n&&(s+=" "+n+'="'+String(r).replace(a,""")+'"'),s+">"+o+""};e.exports=function(e,t){var n={};n[e]=t(s),r(r.P+r.F*o(function(){var t=""[e]('"');return t!==t.toLowerCase()||t.split('"').length>3}),"String",n)}},function(e,t){e.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},function(e,t,n){"use strict";n.r(t),n.d(t,"NEW_THROWN_ERR",function(){return i}),n.d(t,"NEW_THROWN_ERR_BATCH",function(){return a}),n.d(t,"NEW_SPEC_ERR",function(){return s}),n.d(t,"NEW_SPEC_ERR_BATCH",function(){return u}),n.d(t,"NEW_AUTH_ERR",function(){return c}),n.d(t,"CLEAR",function(){return l}),n.d(t,"CLEAR_BY",function(){return p}),n.d(t,"newThrownErr",function(){return f}),n.d(t,"newThrownErrBatch",function(){return h}),n.d(t,"newSpecErr",function(){return d}),n.d(t,"newSpecErrBatch",function(){return m}),n.d(t,"newAuthErr",function(){return v}),n.d(t,"clear",function(){return g}),n.d(t,"clearBy",function(){return y});var r=n(119),o=n.n(r),i="err_new_thrown_err",a="err_new_thrown_err_batch",s="err_new_spec_err",u="err_new_spec_err_batch",c="err_new_auth_err",l="err_clear",p="err_clear_by";function f(e){return{type:i,payload:o()(e)}}function h(e){return{type:a,payload:e}}function d(e){return{type:s,payload:e}}function m(e){return{type:u,payload:e}}function v(e){return{type:c,payload:e}}function g(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return{type:l,payload:e}}function y(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(){return!0};return{type:p,payload:e}}},function(e,t,n){var r=n(98);e.exports=function(e){if(!r(e))throw TypeError(e+" is not an object!");return e}},function(e,t,n){var r=n(43);e.exports=function(e){if(!r(e))throw TypeError(e+" is not an object!");return e}},function(e,t){"function"==typeof Object.create?e.exports=function(e,t){e.super_=t,e.prototype=Object.create(t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}})}:e.exports=function(e,t){e.super_=t;var n=function(){};n.prototype=t.prototype,e.prototype=new n,e.prototype.constructor=e}},function(e,t,n){var r=n(64),o=r.Buffer;function i(e,t){for(var n in e)t[n]=e[n]}function a(e,t,n){return o(e,t,n)}o.from&&o.alloc&&o.allocUnsafe&&o.allocUnsafeSlow?e.exports=r:(i(r,t),t.Buffer=a),i(o,a),a.from=function(e,t,n){if("number"==typeof e)throw new TypeError("Argument must not be a number");return o(e,t,n)},a.alloc=function(e,t,n){if("number"!=typeof e)throw new TypeError("Argument must be a number");var r=o(e);return void 0!==t?"string"==typeof n?r.fill(t,n):r.fill(t):r.fill(0),r},a.allocUnsafe=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return o(e)},a.allocUnsafeSlow=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return r.SlowBuffer(e)}},function(e,t,n){var r=n(46),o=n(349),i=n(218),a=Object.defineProperty;t.f=n(50)?Object.defineProperty:function(e,t,n){if(r(e),t=i(t,!0),r(n),o)try{return a(e,t,n)}catch(e){}if("get"in n||"set"in n)throw TypeError("Accessors not supported!");return"value"in n&&(e[t]=n.value),e}},function(e,t,n){e.exports=!n(82)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(e,t,n){var r=n(366),o="object"==typeof self&&self&&self.Object===Object&&self,i=r||o||Function("return this")();e.exports=i},function(e,t){e.exports=function(e){var t=typeof e;return null!=e&&("object"==t||"function"==t)}},function(e,t,n){"use strict";e.exports={debugTool:null}},function(e,t,n){e.exports=n(573)},function(e,t,n){e.exports=n(770)},function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=45)}([function(e,t){e.exports=n(17)},function(e,t){e.exports=n(14)},function(e,t){e.exports=n(26)},function(e,t){e.exports=n(16)},function(e,t){e.exports=n(123)},function(e,t){e.exports=n(60)},function(e,t){e.exports=n(61)},function(e,t){e.exports=n(55)},function(e,t){e.exports=n(2)},function(e,t){e.exports=n(54)},function(e,t){e.exports=n(94)},function(e,t){e.exports=n(28)},function(e,t){e.exports=n(930)},function(e,t){e.exports=n(12)},function(e,t){e.exports=n(192)},function(e,t){e.exports=n(936)},function(e,t){e.exports=n(93)},function(e,t){e.exports=n(193)},function(e,t){e.exports=n(939)},function(e,t){e.exports=n(943)},function(e,t){e.exports=n(944)},function(e,t){e.exports=n(92)},function(e,t){e.exports=n(13)},function(e,t){e.exports=n(146)},function(e,t){e.exports=n(4)},function(e,t){e.exports=n(5)},function(e,t){e.exports=n(946)},function(e,t){e.exports=n(421)},function(e,t){e.exports=n(949)},function(e,t){e.exports=n(52)},function(e,t){e.exports=n(64)},function(e,t){e.exports=n(283)},function(e,t){e.exports=n(272)},function(e,t){e.exports=n(950)},function(e,t){e.exports=n(145)},function(e,t){e.exports=n(951)},function(e,t){e.exports=n(959)},function(e,t){e.exports=n(960)},function(e,t){e.exports=n(961)},function(e,t){e.exports=n(40)},function(e,t){e.exports=n(264)},function(e,t){e.exports=n(37)},function(e,t){e.exports=n(964)},function(e,t){e.exports=n(965)},function(e,t){e.exports=n(966)},function(e,t,n){e.exports=n(50)},function(e,t){e.exports=n(967)},function(e,t){e.exports=n(968)},function(e,t){e.exports=n(969)},function(e,t){e.exports=n(970)},function(e,t,n){"use strict";n.r(t);var r={};n.r(r),n.d(r,"path",function(){return mn}),n.d(r,"query",function(){return vn}),n.d(r,"header",function(){return yn}),n.d(r,"cookie",function(){return bn});var o=n(9),i=n.n(o),a=n(10),s=n.n(a),u=n(5),c=n.n(u),l=n(6),p=n.n(l),f=n(7),h=n.n(f),d=n(0),m=n.n(d),v=n(8),g=n.n(v),y=(n(46),n(15)),b=n.n(y),_=n(20),w=n.n(_),x=n(12),E=n.n(x),S=n(4),C=n.n(S),k=n(22),O=n.n(k),A=n(11),T=n.n(A),j=n(2),P=n.n(j),I=n(1),M=n.n(I),N=n(17),R=n.n(N),D=(n(47),n(26)),L=n.n(D),U=n(23),q=n.n(U),F=n(31),B=n.n(F),z={serializeRes:J,mergeInQueryOrForm:Z};function V(e){return H.apply(this,arguments)}function H(){return(H=R()(C.a.mark(function e(t){var n,r,o,i,a,s=arguments;return C.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if(n=s.length>1&&void 0!==s[1]?s[1]:{},"object"===P()(t)&&(t=(n=t).url),n.headers=n.headers||{},z.mergeInQueryOrForm(n),n.headers&&m()(n.headers).forEach(function(e){var t=n.headers[e];"string"==typeof t&&(n.headers[e]=t.replace(/\n+/g," "))}),!n.requestInterceptor){e.next=12;break}return e.next=8,n.requestInterceptor(n);case 8:if(e.t0=e.sent,e.t0){e.next=11;break}e.t0=n;case 11:n=e.t0;case 12:return r=n.headers["content-type"]||n.headers["Content-Type"],/multipart\/form-data/i.test(r)&&(delete n.headers["content-type"],delete n.headers["Content-Type"]),e.prev=14,e.next=17,(n.userFetch||fetch)(n.url,n);case 17:return o=e.sent,e.next=20,z.serializeRes(o,t,n);case 20:if(o=e.sent,!n.responseInterceptor){e.next=28;break}return e.next=24,n.responseInterceptor(o);case 24:if(e.t1=e.sent,e.t1){e.next=27;break}e.t1=o;case 27:o=e.t1;case 28:e.next=38;break;case 30:if(e.prev=30,e.t2=e.catch(14),o){e.next=34;break}throw e.t2;case 34:throw(i=new Error(o.statusText)).statusCode=i.status=o.status,i.responseError=e.t2,i;case 38:if(o.ok){e.next=43;break}throw(a=new Error(o.statusText)).statusCode=a.status=o.status,a.response=o,a;case 43:return e.abrupt("return",o);case 44:case"end":return e.stop()}},e,null,[[14,30]])}))).apply(this,arguments)}var W=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"";return/(json|xml|yaml|text)\b/.test(e)};function J(e,t){var n=(arguments.length>2&&void 0!==arguments[2]?arguments[2]:{}).loadSpec,r=void 0!==n&&n,o={ok:e.ok,url:e.url||t,status:e.status,statusText:e.statusText,headers:K(e.headers)},i=o.headers["content-type"],a=r||W(i);return(a?e.text:e.blob||e.buffer).call(e).then(function(e){if(o.text=e,o.data=e,a)try{var t=function(e,t){return t&&(0===t.indexOf("application/json")||t.indexOf("+json")>0)?JSON.parse(e):q.a.safeLoad(e)}(e,i);o.body=t,o.obj=t}catch(e){o.parseError=e}return o})}function K(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t={};return"function"==typeof e.forEach?(e.forEach(function(e,n){void 0!==t[n]?(t[n]=M()(t[n])?t[n]:[t[n]],t[n].push(e)):t[n]=e}),t):t}function Y(e,t){return t||"undefined"==typeof navigator||(t=navigator),t&&"ReactNative"===t.product?!(!e||"object"!==P()(e)||"string"!=typeof e.uri):"undefined"!=typeof File?e instanceof File:null!==e&&"object"===P()(e)&&"function"==typeof e.pipe}function $(e,t){var n=e.collectionFormat,r=e.allowEmptyValue,o="object"===P()(e)?e.value:e;if(void 0===o&&r)return"";if(Y(o)||"boolean"==typeof o)return o;var i=encodeURIComponent;return t&&(i=B()(o)?function(e){return e}:function(e){return T()(e)}),"object"!==P()(o)||M()(o)?M()(o)?M()(o)&&!n?o.map(i).join(","):"multi"===n?o.map(i):o.map(i).join({csv:",",ssv:"%20",tsv:"%09",pipes:"|"}[n]):i(o):""}function G(e){var t=m()(e).reduce(function(t,n){var r,o=e[n],i=!!o.skipEncoding,a=i?n:encodeURIComponent(n),s=(r=o)&&"object"===P()(r)&&!M()(o);return t[a]=$(s?o:{value:o},i),t},{});return L.a.stringify(t,{encode:!1,indices:!1})||""}function Z(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=e.url,r=void 0===t?"":t,o=e.query,i=e.form;if(i){var a=m()(i).some(function(e){return Y(i[e].value)}),s=e.headers["content-type"]||e.headers["Content-Type"];if(a||/multipart\/form-data/i.test(s)){var u=n(48);e.body=new u,m()(i).forEach(function(t){e.body.append(t,$(i[t],!0))})}else e.body=G(i);delete e.form}if(o){var c=r.split("?"),l=O()(c,2),p=l[0],f=l[1],h="";if(f){var d=L.a.parse(f);m()(o).forEach(function(e){return delete d[e]}),h=L.a.stringify(d,{encode:!0})}var v=function(){for(var e=arguments.length,t=new Array(e),n=0;n0){var o=t(e,n[n.length-1],n);o&&(r=r.concat(o))}if(M()(e)){var i=e.map(function(e,r){return Ce(e,t,n.concat(r))});i&&(r=r.concat(i))}else if(Te(e)){var a=m()(e).map(function(r){return Ce(e[r],t,n.concat(r))});a&&(r=r.concat(a))}return r=Oe(r)}function ke(e){return M()(e)?e:[e]}function Oe(e){var t;return(t=[]).concat.apply(t,he()(e.map(function(e){return M()(e)?Oe(e):e})))}function Ae(e){return e.filter(function(e){return void 0!==e})}function Te(e){return e&&"object"===P()(e)}function je(e){return e&&"function"==typeof e}function Pe(e){if(Ne(e)){var t=e.op;return"add"===t||"remove"===t||"replace"===t}return!1}function Ie(e){return Pe(e)||Ne(e)&&"mutation"===e.type}function Me(e){return Ie(e)&&("add"===e.op||"replace"===e.op||"merge"===e.op||"mergeDeep"===e.op)}function Ne(e){return e&&"object"===P()(e)}function Re(e,t){try{return me.a.getValueByPointer(e,t)}catch(e){return console.error(e),{}}}var De=n(35),Le=n.n(De),Ue=n(36),qe=n(28),Fe=n.n(qe);function Be(e,t){function n(){Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=(new Error).stack;for(var e=arguments.length,n=new Array(e),r=0;r-1&&-1===We.indexOf(n)||Je.indexOf(r)>-1||Ke.some(function(e){return r.indexOf(e)>-1})}function $e(e,t){var n=e.split("#"),r=O()(n,2),o=r[0],i=r[1],a=E.a.resolve(o||"",t||"");return i?"".concat(a,"#").concat(i):a}var Ge="application/json, application/yaml",Ze=new RegExp("^([a-z]+://|//)","i"),Xe=Be("JSONRefError",function(e,t,n){this.originalError=n,ie()(this,t||{})}),Qe={},et=new Le.a,tt=[function(e){return"paths"===e[0]&&"responses"===e[3]&&"content"===e[5]&&"example"===e[7]},function(e){return"paths"===e[0]&&"requestBody"===e[3]&&"content"===e[4]&&"example"===e[6]}],nt={key:"$ref",plugin:function(e,t,n,r){var o=r.getInstance(),i=n.slice(0,-1);if(!Ye(i)&&(a=i,!tt.some(function(e){return e(a)}))){var a,s=r.getContext(n).baseDoc;if("string"!=typeof e)return new Xe("$ref: must be a string (JSON-Ref)",{$ref:e,baseDoc:s,fullPath:n});var u,c,l,p=st(e),f=p[0],h=p[1]||"";try{u=s||f?it(f,s):null}catch(t){return at(t,{pointer:h,$ref:e,basePath:u,fullPath:n})}if(function(e,t,n,r){var o=et.get(r);o||(o={},et.set(r,o));var i=function(e){if(0===e.length)return"";return"/".concat(e.map(ht).join("/"))}(n),a="".concat(t||"","#").concat(e),s=i.replace(/allOf\/\d+\/?/g,""),u=r.contextTree.get([]).baseDoc;if(t==u&&mt(s,e))return!0;var c="";if(n.some(function(e){return c="".concat(c,"/").concat(ht(e)),o[c]&&o[c].some(function(e){return mt(e,a)||mt(a,e)})}))return!0;o[s]=(o[s]||[]).concat(a)}(h,u,i,r)&&!o.useCircularStructures){var d=$e(e,u);return e===d?null:_e.replace(n,d)}if(null==u?(l=pt(h),void 0===(c=r.get(l))&&(c=new Xe("Could not resolve reference: ".concat(e),{pointer:h,$ref:e,baseDoc:s,fullPath:n}))):c=null!=(c=ut(u,h)).__value?c.__value:c.catch(function(t){throw at(t,{pointer:h,$ref:e,baseDoc:s,fullPath:n})}),c instanceof Error)return[_e.remove(n),c];var v=$e(e,u),g=_e.replace(i,c,{$$ref:v});if(u&&u!==s)return[g,_e.context(i,{baseDoc:u})];try{if(!function(e,t){var n=[e];return t.path.reduce(function(e,t){return n.push(e[t]),e[t]},e),function e(t){return _e.isObject(t)&&(n.indexOf(t)>=0||m()(t).some(function(n){return e(t[n])}))}(t.value)}(r.state,g)||o.useCircularStructures)return g}catch(e){return null}}}},rt=ie()(nt,{docCache:Qe,absoluteify:it,clearCache:function(e){void 0!==e?delete Qe[e]:m()(Qe).forEach(function(e){delete Qe[e]})},JSONRefError:Xe,wrapError:at,getDoc:ct,split:st,extractFromDoc:ut,fetchJSON:function(e){return Object(Ue.fetch)(e,{headers:{Accept:Ge},loadSpec:!0}).then(function(e){return e.text()}).then(function(e){return q.a.safeLoad(e)})},extract:lt,jsonPointerToArray:pt,unescapeJsonPointerToken:ft}),ot=rt;function it(e,t){if(!Ze.test(e)){if(!t)throw new Xe("Tried to resolve a relative URL, without having a basePath. path: '".concat(e,"' basePath: '").concat(t,"'"));return E.a.resolve(t,e)}return e}function at(e,t){var n;return n=e&&e.response&&e.response.body?"".concat(e.response.body.code," ").concat(e.response.body.message):e.message,new Xe("Could not resolve reference: ".concat(n),t,e)}function st(e){return(e+"").split("#")}function ut(e,t){var n=Qe[e];if(n&&!_e.isPromise(n))try{var r=lt(t,n);return ie()(Q.a.resolve(r),{__value:r})}catch(e){return Q.a.reject(e)}return ct(e).then(function(e){return lt(t,e)})}function ct(e){var t=Qe[e];return t?_e.isPromise(t)?t:Q.a.resolve(t):(Qe[e]=rt.fetchJSON(e).then(function(t){return Qe[e]=t,t}),Qe[e])}function lt(e,t){var n=pt(e);if(n.length<1)return t;var r=_e.getIn(t,n);if(void 0===r)throw new Xe("Could not resolve pointer: ".concat(e," does not exist in document"),{pointer:e});return r}function pt(e){if("string"!=typeof e)throw new TypeError("Expected a string, got a ".concat(P()(e)));return"/"===e[0]&&(e=e.substr(1)),""===e?[]:e.split("/").map(ft)}function ft(e){return"string"!=typeof e?e:Fe.a.unescape(e.replace(/~1/g,"/").replace(/~0/g,"~"))}function ht(e){return Fe.a.escape(e.replace(/~/g,"~0").replace(/\//g,"~1"))}var dt=function(e){return!e||"/"===e||"#"===e};function mt(e,t){if(dt(t))return!0;var n=e.charAt(t.length),r=t.slice(-1);return 0===e.indexOf(t)&&(!n||"/"===n||"#"===n)&&"#"!==r}var vt={key:"allOf",plugin:function(e,t,n,r,o){if(!o.meta||!o.meta.$$ref){var i=n.slice(0,-1);if(!Ye(i)){if(!M()(e)){var a=new TypeError("allOf must be an array");return a.fullPath=n,a}var s=!1,u=o.value;i.forEach(function(e){u&&(u=u[e])}),delete(u=ie()({},u)).allOf;var c=[];return c.push(r.replace(i,{})),e.forEach(function(e,t){if(!r.isObject(e)){if(s)return null;s=!0;var o=new TypeError("Elements in allOf must be objects");return o.fullPath=n,c.push(o)}c.push(r.mergeDeep(i,e));var a=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r=n.specmap,o=n.getBaseUrlForNodePath,i=void 0===o?function(e){return r.getContext([].concat(he()(t),he()(e))).baseDoc}:o,a=n.targetKeys,s=void 0===a?["$ref","$$ref"]:a,u=[];return Ve()(e).forEach(function(){if(s.indexOf(this.key)>-1){var e=this.path,n=t.concat(this.path),o=$e(this.node,i(e));u.push(r.replace(n,o))}}),u}(e,n.slice(0,-1),{getBaseUrlForNodePath:function(e){return r.getContext([].concat(he()(n),[t],he()(e))).baseDoc},specmap:r});c.push.apply(c,he()(a))}),c.push(r.mergeDeep(i,u)),u.$$ref||c.push(r.remove([].concat(i,"$$ref"))),c}}}},gt={key:"parameters",plugin:function(e,t,n,r,o){if(M()(e)&&e.length){var i=ie()([],e),a=n.slice(0,-1),s=ie()({},_e.getIn(r.spec,a));return e.forEach(function(e,t){try{i[t].default=r.parameterMacro(s,e)}catch(e){var o=new Error(e);return o.fullPath=n,o}}),_e.replace(n,i)}return _e.replace(n,e)}},yt={key:"properties",plugin:function(e,t,n,r){var o=ie()({},e);for(var i in e)try{o[i].default=r.modelPropertyMacro(o[i])}catch(e){var a=new Error(e);return a.fullPath=n,a}return _e.replace(n,o)}};function bt(e,t){var n=m()(e);if(h.a){var r=h()(e);t&&(r=r.filter(function(t){return p()(e,t).enumerable})),n.push.apply(n,r)}return n}var _t=function(){function e(t){se()(this,e),this.root=wt(t||{})}return ce()(e,[{key:"set",value:function(e,t){var n=this.getParent(e,!0);if(n){var r=e[e.length-1],o=n.children;o[r]?xt(o[r],t,n):o[r]=wt(t,n)}else xt(this.root,t,null)}},{key:"get",value:function(e){if((e=e||[]).length<1)return this.root.value;for(var t,n,r=this.root,o=0;o1?n-1:0),o=1;o1?n-1:0),o=1;o0})}},{key:"nextPromisedPatch",value:function(){if(this.promisedPatches.length>0)return Q.a.race(this.promisedPatches.map(function(e){return e.value}))}},{key:"getPluginHistory",value:function(e){var t=this.getPluginName(e);return this.pluginHistory[t]||[]}},{key:"getPluginRunCount",value:function(e){return this.getPluginHistory(e).length}},{key:"getPluginHistoryTip",value:function(e){var t=this.getPluginHistory(e);return t&&t[t.length-1]||{}}},{key:"getPluginMutationIndex",value:function(e){var t=this.getPluginHistoryTip(e).mutationIndex;return"number"!=typeof t?-1:t}},{key:"getPluginName",value:function(e){return e.pluginName}},{key:"updatePluginHistory",value:function(e,t){var n=this.getPluginName(e);(this.pluginHistory[n]=this.pluginHistory[n]||[]).push(t)}},{key:"updatePatches",value:function(e,t){var n=this;_e.normalizeArray(e).forEach(function(e){if(e instanceof Error)n.errors.push(e);else try{if(!_e.isObject(e))return void n.debug("updatePatches","Got a non-object patch",e);if(n.showDebug&&n.allPatches.push(e),_e.isPromise(e.value))return n.promisedPatches.push(e),void n.promisedPatchThen(e);if(_e.isContextPatch(e))return void n.setContext(e.path,e.value);if(_e.isMutation(e))return void n.updateMutations(e)}catch(e){console.error(e),n.errors.push(e)}})}},{key:"updateMutations",value:function(e){"object"===P()(e.value)&&!M()(e.value)&&this.allowMetaPatches&&(e.value=ie()({},e.value));var t=_e.applyPatch(this.state,e,{allowMetaPatches:this.allowMetaPatches});t&&(this.mutations.push(e),this.state=t)}},{key:"removePromisedPatch",value:function(e){var t=this.promisedPatches.indexOf(e);t<0?this.debug("Tried to remove a promisedPatch that isn't there!"):this.promisedPatches.splice(t,1)}},{key:"promisedPatchThen",value:function(e){var t=this;return e.value=e.value.then(function(n){var r=ie()({},e,{value:n});t.removePromisedPatch(e),t.updatePatches(r)}).catch(function(n){t.removePromisedPatch(e),t.updatePatches(n)})}},{key:"getMutations",value:function(e,t){return e=e||0,"number"!=typeof t&&(t=this.mutations.length),this.mutations.slice(e,t)}},{key:"getCurrentMutations",value:function(){return this.getMutationsForPlugin(this.getCurrentPlugin())}},{key:"getMutationsForPlugin",value:function(e){var t=this.getPluginMutationIndex(e);return this.getMutations(t+1)}},{key:"getCurrentPlugin",value:function(){return this.currentPlugin}},{key:"getPatchesOfType",value:function(e,t){return e.filter(t)}},{key:"getLib",value:function(){return this.libMethods}},{key:"_get",value:function(e){return _e.getIn(this.state,e)}},{key:"_getContext",value:function(e){return this.contextTree.get(e)}},{key:"setContext",value:function(e,t){return this.contextTree.set(e,t)}},{key:"_hasRun",value:function(e){return this.getPluginRunCount(this.getCurrentPlugin())>(e||0)}},{key:"_clone",value:function(e){return JSON.parse(T()(e))}},{key:"dispatch",value:function(){var e=this,t=this,n=this.nextPlugin();if(!n){var r=this.nextPromisedPatch();if(r)return r.then(function(){return e.dispatch()}).catch(function(){return e.dispatch()});var o={spec:this.state,errors:this.errors};return this.showDebug&&(o.patches=this.allPatches),Q.a.resolve(o)}if(t.pluginCount=t.pluginCount||{},t.pluginCount[n]=(t.pluginCount[n]||0)+1,t.pluginCount[n]>100)return Q.a.resolve({spec:t.state,errors:t.errors.concat(new Error("We've reached a hard limit of ".concat(100," plugin runs")))});if(n!==this.currentPlugin&&this.promisedPatches.length){var i=this.promisedPatches.map(function(e){return e.value});return Q.a.all(i.map(function(e){return e.then(Function,Function)})).then(function(){return e.dispatch()})}return function(){t.currentPlugin=n;var e=t.getCurrentMutations(),r=t.mutations.length-1;try{if(n.isGenerator){var o=!0,i=!1,s=void 0;try{for(var u,c=te()(n(e,t.getLib()));!(o=(u=c.next()).done);o=!0){a(u.value)}}catch(e){i=!0,s=e}finally{try{o||null==c.return||c.return()}finally{if(i)throw s}}}else{a(n(e,t.getLib()))}}catch(e){console.error(e),a([ie()(re()(e),{plugin:n})])}finally{t.updatePluginHistory(n,{mutationIndex:r})}return t.dispatch()}();function a(e){e&&(e=_e.fullyNormalizeArray(e),t.updatePatches(e,n))}}}]),e}();var St={refs:ot,allOf:vt,parameters:gt,properties:yt},Ct=n(29),kt=n.n(Ct),Ot=function(e){return String.prototype.toLowerCase.call(e)},At=function(e){return e.replace(/[^\w]/gi,"_")};function Tt(e){var t=e.openapi;return!!t&&w()(t,"3")}function jt(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"",r=(arguments.length>3&&void 0!==arguments[3]?arguments[3]:{}).v2OperationIdCompatibilityMode;return e&&"object"===P()(e)?(e.operationId||"").replace(/\s/g,"").length?At(e.operationId):function(e,t){if((arguments.length>2&&void 0!==arguments[2]?arguments[2]:{}).v2OperationIdCompatibilityMode){var n="".concat(t.toLowerCase(),"_").concat(e).replace(/[\s!@#$%^&*()_+=[{\]};:<>|.\/?,\\'""-]/g,"_");return(n=n||"".concat(e.substring(1),"_").concat(t)).replace(/((_){2,})/g,"_").replace(/^(_)*/g,"").replace(/([_])*$/g,"")}return"".concat(Ot(t)).concat(At(e))}(t,n,{v2OperationIdCompatibilityMode:r}):null}function Pt(e,t){return"".concat(Ot(t),"-").concat(e)}function It(e,t){return e&&e.paths?function(e,t){return Mt(e,t,!0)||null}(e,function(e){var n=e.pathName,r=e.method,o=e.operation;if(!o||"object"!==P()(o))return!1;var i=o.operationId;return[jt(o,n,r),Pt(n,r),i].some(function(e){return e&&e===t})}):null}function Mt(e,t,n){if(!e||"object"!==P()(e)||!e.paths||"object"!==P()(e.paths))return null;var r=e.paths;for(var o in r)for(var i in r[o])if("PARAMETERS"!==i.toUpperCase()){var a=r[o][i];if(a&&"object"===P()(a)){var s={spec:e,pathName:o,method:i.toUpperCase(),operation:a},u=t(s);if(n&&u)return s}}}function Nt(e){var t=e.spec,n=t.paths,r={};if(!n||t.$$normalized)return e;for(var o in n){var i=n[o];if(kt()(i)){var a=i.parameters,s=function(e){var n=i[e];if(!kt()(n))return"continue";var s=jt(n,o,e);if(s){r[s]?r[s].push(n):r[s]=[n];var u=r[s];if(u.length>1)u.forEach(function(e,t){e.__originalOperationId=e.__originalOperationId||e.operationId,e.operationId="".concat(s).concat(t+1)});else if(void 0!==n.operationId){var c=u[0];c.__originalOperationId=c.__originalOperationId||n.operationId,c.operationId=s}}if("parameters"!==e){var l=[],p={};for(var f in t)"produces"!==f&&"consumes"!==f&&"security"!==f||(p[f]=t[f],l.push(p));if(a&&(p.parameters=a,l.push(p)),l.length)for(var h=0,d=l;h1&&void 0!==arguments[1]?arguments[1]:{},n=t.requestInterceptor,r=t.responseInterceptor,o=e.withCredentials?"include":"same-origin";return function(t){return e({url:t,loadSpec:!0,requestInterceptor:n,responseInterceptor:r,headers:{Accept:Ge},credentials:o}).then(function(e){return e.body})}}function Dt(e){var t=e.fetch,n=e.spec,r=e.url,o=e.mode,i=e.allowMetaPatches,a=void 0===i||i,s=e.pathDiscriminator,u=e.modelPropertyMacro,c=e.parameterMacro,l=e.requestInterceptor,p=e.responseInterceptor,f=e.skipNormalization,h=e.useCircularStructures,d=e.http,m=e.baseDoc;return m=m||r,d=t||d||V,n?v(n):Rt(d,{requestInterceptor:l,responseInterceptor:p})(m).then(v);function v(e){m&&(St.refs.docCache[m]=e),St.refs.fetchJSON=Rt(d,{requestInterceptor:l,responseInterceptor:p});var t,n=[St.refs];return"function"==typeof c&&n.push(St.parameters),"function"==typeof u&&n.push(St.properties),"strict"!==o&&n.push(St.allOf),(t={spec:e,context:{baseDoc:m},plugins:n,allowMetaPatches:a,pathDiscriminator:s,parameterMacro:c,modelPropertyMacro:u,useCircularStructures:h},new Et(t).dispatch()).then(f?function(){var e=R()(C.a.mark(function e(t){return C.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return e.abrupt("return",t);case 1:case"end":return e.stop()}},e)}));return function(t){return e.apply(this,arguments)}}():Nt)}}var Lt=n(16),Ut=n.n(Lt);function qt(e,t){var n=m()(e);if(h.a){var r=h()(e);t&&(r=r.filter(function(t){return p()(e,t).enumerable})),n.push.apply(n,r)}return n}function Ft(e){for(var t=1;t2&&void 0!==m[2]?m[2]:{},o=r.returnEntireTree,i=r.baseDoc,a=r.requestInterceptor,s=r.responseInterceptor,u=r.parameterMacro,c=r.modelPropertyMacro,l=r.useCircularStructures,p={pathDiscriminator:n,baseDoc:i,requestInterceptor:a,responseInterceptor:s,parameterMacro:u,modelPropertyMacro:c,useCircularStructures:l},f=Nt({spec:t}),h=f.spec,e.next=6,Dt(Ft({},p,{spec:h,allowMetaPatches:!0,skipNormalization:!0}));case 6:return d=e.sent,!o&&M()(n)&&n.length&&(d.spec=Ut()(d.spec,n)||null),e.abrupt("return",d);case 9:case"end":return e.stop()}},e)}))).apply(this,arguments)}var zt=n(38),Vt=n.n(zt);function Ht(e,t){var n=m()(e);if(h.a){var r=h()(e);t&&(r=r.filter(function(t){return p()(e,t).enumerable})),n.push.apply(n,r)}return n}function Wt(e){for(var t=1;t0&&void 0!==arguments[0]?arguments[0]:{};return function(t){var n=t.pathName,r=t.method,o=t.operationId;return function(t){var i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return e.execute(Wt({spec:e.spec},Vt()(e,"requestInterceptor","responseInterceptor","userFetch"),{pathName:n,method:r,parameters:t,operationId:o},i))}}}};var $t=n(39),Gt=n.n($t),Zt=n(40),Xt=n.n(Zt),Qt=n(41),en=n.n(Qt),tn=n(19),nn=n.n(tn),rn=n(42),on=n.n(rn),an={body:function(e){var t=e.req,n=e.value;t.body=n},header:function(e){var t=e.req,n=e.parameter,r=e.value;t.headers=t.headers||{},void 0!==r&&(t.headers[n.name]=r)},query:function(e){var t=e.req,n=e.value,r=e.parameter;t.query=t.query||{},!1===n&&"boolean"===r.type&&(n="false");0===n&&["number","integer"].indexOf(r.type)>-1&&(n="0");if(n)t.query[r.name]={collectionFormat:r.collectionFormat,value:n};else if(r.allowEmptyValue&&void 0!==n){var o=r.name;t.query[o]=t.query[o]||{},t.query[o].allowEmptyValue=!0}},path:function(e){var t=e.req,n=e.value,r=e.parameter;t.url=t.url.split("{".concat(r.name,"}")).join(encodeURIComponent(n))},formData:function(e){var t=e.req,n=e.value,r=e.parameter;(n||r.allowEmptyValue)&&(t.form=t.form||{},t.form[r.name]={value:n,allowEmptyValue:r.allowEmptyValue,collectionFormat:r.collectionFormat})}};n(49);var sn=n(43),un=n.n(sn),cn=n(44),ln=function(e){return":/?#[]@!$&'()*+,;=".indexOf(e)>-1},pn=function(e){return/^[a-z0-9\-._~]+$/i.test(e)};function fn(e){var t=(arguments.length>1&&void 0!==arguments[1]?arguments[1]:{}).escape,n=arguments.length>2?arguments[2]:void 0;return"number"==typeof e&&(e=e.toString()),"string"==typeof e&&e.length&&t?n?JSON.parse(e):Object(cn.stringToCharArray)(e).map(function(e){return pn(e)?e:ln(e)&&"unsafe"===t?e:(un()(e)||[]).map(function(e){return"0".concat(e.toString(16).toUpperCase()).slice(-2)}).map(function(e){return"%".concat(e)}).join("")}).join(""):e}function hn(e){var t=e.value;return M()(t)?function(e){var t=e.key,n=e.value,r=e.style,o=e.explode,i=e.escape,a=function(e){return fn(e,{escape:i})};if("simple"===r)return n.map(function(e){return a(e)}).join(",");if("label"===r)return".".concat(n.map(function(e){return a(e)}).join("."));if("matrix"===r)return n.map(function(e){return a(e)}).reduce(function(e,n){return!e||o?"".concat(e||"",";").concat(t,"=").concat(n):"".concat(e,",").concat(n)},"");if("form"===r){var s=o?"&".concat(t,"="):",";return n.map(function(e){return a(e)}).join(s)}if("spaceDelimited"===r){var u=o?"".concat(t,"="):"";return n.map(function(e){return a(e)}).join(" ".concat(u))}if("pipeDelimited"===r){var c=o?"".concat(t,"="):"";return n.map(function(e){return a(e)}).join("|".concat(c))}}(e):"object"===P()(t)?function(e){var t=e.key,n=e.value,r=e.style,o=e.explode,i=e.escape,a=function(e){return fn(e,{escape:i})},s=m()(n);if("simple"===r)return s.reduce(function(e,t){var r=a(n[t]),i=o?"=":",",s=e?"".concat(e,","):"";return"".concat(s).concat(t).concat(i).concat(r)},"");if("label"===r)return s.reduce(function(e,t){var r=a(n[t]),i=o?"=":".",s=e?"".concat(e,"."):".";return"".concat(s).concat(t).concat(i).concat(r)},"");if("matrix"===r&&o)return s.reduce(function(e,t){var r=a(n[t]),o=e?"".concat(e,";"):";";return"".concat(o).concat(t,"=").concat(r)},"");if("matrix"===r)return s.reduce(function(e,r){var o=a(n[r]),i=e?"".concat(e,","):";".concat(t,"=");return"".concat(i).concat(r,",").concat(o)},"");if("form"===r)return s.reduce(function(e,t){var r=a(n[t]),i=e?"".concat(e).concat(o?"&":","):"",s=o?"=":",";return"".concat(i).concat(t).concat(s).concat(r)},"")}(e):function(e){var t=e.key,n=e.value,r=e.style,o=e.escape,i=function(e){return fn(e,{escape:o})};if("simple"===r)return i(n);if("label"===r)return".".concat(i(n));if("matrix"===r)return";".concat(t,"=").concat(i(n));if("form"===r)return i(n);if("deepObject"===r)return i(n)}(e)}function dn(e,t){return t.includes("application/json")?"string"==typeof e?e:T()(e):e.toString()}function mn(e){var t=e.req,n=e.value,r=e.parameter,o=r.name,i=r.style,a=r.explode,s=r.content;if(s){var u=m()(s)[0];t.url=t.url.split("{".concat(o,"}")).join(fn(dn(n,u),{escape:!0}))}else{var c=hn({key:r.name,value:n,style:i||"simple",explode:a||!1,escape:!0});t.url=t.url.split("{".concat(o,"}")).join(c)}}function vn(e){var t=e.req,n=e.value,r=e.parameter;if(t.query=t.query||{},r.content){var o=m()(r.content)[0];t.query[r.name]=dn(n,o)}else if(!1===n&&(n="false"),0===n&&(n="0"),n){var i=P()(n);if("deepObject"===r.style)m()(n).forEach(function(e){var o=n[e];t.query["".concat(r.name,"[").concat(e,"]")]={value:hn({key:e,value:o,style:"deepObject",escape:r.allowReserved?"unsafe":"reserved"}),skipEncoding:!0}});else if("object"!==i||M()(n)||"form"!==r.style&&r.style||!r.explode&&void 0!==r.explode)t.query[r.name]={value:hn({key:r.name,value:n,style:r.style||"form",explode:void 0===r.explode||r.explode,escape:r.allowReserved?"unsafe":"reserved"}),skipEncoding:!0};else{m()(n).forEach(function(e){var o=n[e];t.query[e]={value:hn({key:e,value:o,style:r.style||"form",escape:r.allowReserved?"unsafe":"reserved"}),skipEncoding:!0}})}}else if(r.allowEmptyValue&&void 0!==n){var a=r.name;t.query[a]=t.query[a]||{},t.query[a].allowEmptyValue=!0}}var gn=["accept","authorization","content-type"];function yn(e){var t=e.req,n=e.parameter,r=e.value;if(t.headers=t.headers||{},!(gn.indexOf(n.name.toLowerCase())>-1))if(n.content){var o=m()(n.content)[0];t.headers[n.name]=dn(r,o)}else void 0!==r&&(t.headers[n.name]=hn({key:n.name,value:r,style:n.style||"simple",explode:void 0!==n.explode&&n.explode,escape:!1}))}function bn(e){var t=e.req,n=e.parameter,r=e.value;t.headers=t.headers||{};var o=P()(r);if(n.content){var i=m()(n.content)[0];t.headers.Cookie="".concat(n.name,"=").concat(dn(r,i))}else if("undefined"!==o){var a="object"===o&&!M()(r)&&n.explode?"":"".concat(n.name,"=");t.headers.Cookie=a+hn({key:n.name,value:r,escape:!1,style:n.style||"form",explode:void 0!==n.explode&&n.explode})}}var _n=n(30),wn=function(e,t){var n=e.operation,r=e.requestBody,o=e.securities,i=e.spec,a=e.attachContentTypeForEmptyPayload,s=e.requestContentType;t=function(e){var t=e.request,n=e.securities,r=void 0===n?{}:n,o=e.operation,i=void 0===o?{}:o,a=e.spec,s=b()({},t),u=r.authorized,c=void 0===u?{}:u,l=i.security||a.security||[],p=c&&!!m()(c).length,f=Ut()(a,["components","securitySchemes"])||{};if(s.headers=s.headers||{},s.query=s.query||{},!m()(r).length||!p||!l||M()(i.security)&&!i.security.length)return t;return l.forEach(function(e,t){for(var n in e){var r=c[n],o=f[n];if(r){var i=r.value||r,a=o.type;if(r)if("apiKey"===a)"query"===o.in&&(s.query[o.name]=i),"header"===o.in&&(s.headers[o.name]=i),"cookie"===o.in&&(s.cookies[o.name]=i);else if("http"===a){if("basic"===o.scheme){var u=i.username,l=i.password,p=nn()("".concat(u,":").concat(l));s.headers.Authorization="Basic ".concat(p)}"bearer"===o.scheme&&(s.headers.Authorization="Bearer ".concat(i))}else if("oauth2"===a){var h=r.token||{},d=h.access_token,m=h.token_type;m&&"bearer"!==m.toLowerCase()||(m="Bearer"),s.headers.Authorization="".concat(m," ").concat(d)}}}}),s}({request:t,securities:o,operation:n,spec:i});var u=n.requestBody||{},c=m()(u.content||{}),l=s&&c.indexOf(s)>-1;if(r||a){if(s&&l)t.headers["Content-Type"]=s;else if(!s){var p=c[0];p&&(t.headers["Content-Type"]=p,s=p)}}else s&&l&&(t.headers["Content-Type"]=s);return r&&(s?c.indexOf(s)>-1&&("application/x-www-form-urlencoded"===s||0===s.indexOf("multipart/")?"object"===P()(r)?(t.form={},m()(r).forEach(function(e){var n,o,i=r[e];"undefined"!=typeof File&&(o=i instanceof File),"undefined"!=typeof Blob&&(o=o||i instanceof Blob),void 0!==_n.Buffer&&(o=o||_n.Buffer.isBuffer(i)),n="object"!==P()(i)||o?i:M()(i)?i.toString():T()(i),t.form[e]={value:n}})):t.form=r:t.body=r):t.body=r),t};var xn=function(e,t){var n=e.spec,r=e.operation,o=e.securities,i=e.requestContentType,a=e.attachContentTypeForEmptyPayload;if((t=function(e){var t=e.request,n=e.securities,r=void 0===n?{}:n,o=e.operation,i=void 0===o?{}:o,a=e.spec,s=b()({},t),u=r.authorized,c=void 0===u?{}:u,l=r.specSecurity,p=void 0===l?[]:l,f=i.security||p,h=c&&!!m()(c).length,d=a.securityDefinitions;if(s.headers=s.headers||{},s.query=s.query||{},!m()(r).length||!h||!f||M()(i.security)&&!i.security.length)return t;return f.forEach(function(e,t){for(var n in e){var r=c[n];if(r){var o=r.token,i=r.value||r,a=d[n],u=a.type,l=a["x-tokenName"]||"access_token",p=o&&o[l],f=o&&o.token_type;if(r)if("apiKey"===u){var h="query"===a.in?"query":"headers";s[h]=s[h]||{},s[h][a.name]=i}else"basic"===u?i.header?s.headers.authorization=i.header:(i.base64=nn()("".concat(i.username,":").concat(i.password)),s.headers.authorization="Basic ".concat(i.base64)):"oauth2"===u&&p&&(f=f&&"bearer"!==f.toLowerCase()?f:"Bearer",s.headers.authorization="".concat(f," ").concat(p))}}}),s}({request:t,securities:o,operation:r,spec:n})).body||t.form||a)i?t.headers["Content-Type"]=i:M()(r.consumes)?t.headers["Content-Type"]=r.consumes[0]:M()(n.consumes)?t.headers["Content-Type"]=n.consumes[0]:r.parameters&&r.parameters.filter(function(e){return"file"===e.type}).length?t.headers["Content-Type"]="multipart/form-data":r.parameters&&r.parameters.filter(function(e){return"formData"===e.in}).length&&(t.headers["Content-Type"]="application/x-www-form-urlencoded");else if(i){var s=r.parameters&&r.parameters.filter(function(e){return"body"===e.in}).length>0,u=r.parameters&&r.parameters.filter(function(e){return"formData"===e.in}).length>0;(s||u)&&(t.headers["Content-Type"]=i)}return t};function En(e,t){var n=m()(e);if(h.a){var r=h()(e);t&&(r=r.filter(function(t){return p()(e,t).enumerable})),n.push.apply(n,r)}return n}function Sn(e){for(var t=1;t-1&&(c=o,l=u[p.indexOf(o)])}return!c&&u&&u.length&&(c=u[0].url,l=u[0]),c.indexOf("{")>-1&&function(e){for(var t,n=[],r=/{([^}]+)}/g;t=r.exec(e);)n.push(t[1]);return n}(c).forEach(function(e){if(l.variables&&l.variables[e]){var t=l.variables[e],n=s[e]||t.default,r=new RegExp("{".concat(e,"}"),"g");c=c.replace(r,n)}}),function(){var e,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",r=E.a.parse(t),o=E.a.parse(n),i=Pn(r.protocol)||Pn(o.protocol)||"",a=r.host||o.host,s=r.pathname||"";return"/"===(e=i&&a?"".concat(i,"://").concat(a+s):s)[e.length-1]?e.slice(0,-1):e}(c,i)}(b):function(e){var t,n=e.spec,r=e.scheme,o=e.contextUrl,i=void 0===o?"":o,a=E.a.parse(i),s=M()(n.schemes)?n.schemes[0]:null,u=r||s||Pn(a.protocol)||"http",c=n.host||a.host||"",l=n.basePath||"";return"/"===(t=u&&c?"".concat(u,"://").concat(c+l):l)[t.length-1]?t.slice(0,-1):t}(b),!n)return delete g.cookies,g;g.url+=S,g.method="".concat(x).toUpperCase(),h=h||{};var C=t.paths[S]||{};o&&(g.headers.accept=o);var k=An([].concat(Cn(w.parameters)).concat(Cn(C.parameters)));k.forEach(function(e){var n,r=d[e.in];if("body"===e.in&&e.schema&&e.schema.properties&&(n=h),void 0===(n=e&&e.name&&h[e.name])?n=e&&e.name&&h["".concat(e.in,".").concat(e.name)]:On(e.name,k).length>1&&console.warn("Parameter '".concat(e.name,"' is ambiguous because the defined spec has more than one parameter with the name: '").concat(e.name,"' and the passed-in parameter values did not define an 'in' value.")),null!==n){if(void 0!==e.default&&void 0===n&&(n=e.default),void 0===n&&e.required&&!e.allowEmptyValue)throw new Error("Required parameter ".concat(e.name," is not provided"));if(v&&e.schema&&"object"===e.schema.type&&"string"==typeof n)try{n=JSON.parse(n)}catch(e){throw new Error("Could not parse object parameter value string as JSON")}r&&r({req:g,parameter:e,value:n,operation:w,spec:t})}});var O=Sn({},e,{operation:w});if((g=v?wn(O,g):xn(O,g)).cookies&&m()(g.cookies).length){var A=m()(g.cookies).reduce(function(e,t){var n=g.cookies[t];return e+(e?"&":"")+on.a.serialize(t,n)},"");g.headers.Cookie=A}return g.cookies&&delete g.cookies,Z(g),g}var Pn=function(e){return e?e.replace(/\W/g,""):null};function In(e,t){var n=m()(e);if(h.a){var r=h()(e);t&&(r=r.filter(function(t){return p()(e,t).enumerable})),n.push.apply(n,r)}return n}function Mn(e){var t=this,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if("string"==typeof e?n.url=e:n=e,!(this instanceof Mn))return new Mn(n);b()(this,n);var r=this.resolve().then(function(){return t.disableInterfaces||b()(t,Mn.makeApisTagOperation(t)),t});return r.client=this,r}Mn.http=V,Mn.makeHttp=function(e,t,n){return n=n||function(e){return e},t=t||function(e){return e},function(r){return"string"==typeof r&&(r={url:r}),z.mergeInQueryOrForm(r),r=t(r),n(e(r))}}.bind(null,Mn.http),Mn.resolve=Dt,Mn.resolveSubtree=function(e,t){return Bt.apply(this,arguments)},Mn.execute=function(e){var t=e.http,n=e.fetch,r=e.spec,o=e.operationId,i=e.pathName,a=e.method,s=e.parameters,u=e.securities,c=Gt()(e,["http","fetch","spec","operationId","pathName","method","parameters","securities"]),l=t||n||V;i&&a&&!o&&(o=Pt(i,a));var p=Tn.buildRequest(Sn({spec:r,operationId:o,parameters:s,securities:u,http:l},c));return p.body&&(Xt()(p.body)||en()(p.body))&&(p.body=T()(p.body)),l(p)},Mn.serializeRes=J,Mn.serializeHeaders=K,Mn.clearCache=function(){St.refs.clearCache()},Mn.makeApisTagOperation=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=Yt.makeExecute(e);return{apis:Yt.mapTagOperations({v2OperationIdCompatibilityMode:e.v2OperationIdCompatibilityMode,spec:e.spec,cb:t})}},Mn.buildRequest=jn,Mn.helpers={opId:jt},Mn.prototype={http:V,execute:function(e){return this.applyDefaults(),Mn.execute(function(e){for(var t=1;t + * @license MIT + */ +var r=n(569),o=n(570),i=n(355);function a(){return u.TYPED_ARRAY_SUPPORT?2147483647:1073741823}function s(e,t){if(a()=a())throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+a().toString(16)+" bytes");return 0|e}function d(e,t){if(u.isBuffer(e))return e.length;if("undefined"!=typeof ArrayBuffer&&"function"==typeof ArrayBuffer.isView&&(ArrayBuffer.isView(e)||e instanceof ArrayBuffer))return e.byteLength;"string"!=typeof e&&(e=""+e);var n=e.length;if(0===n)return 0;for(var r=!1;;)switch(t){case"ascii":case"latin1":case"binary":return n;case"utf8":case"utf-8":case void 0:return B(e).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*n;case"hex":return n>>>1;case"base64":return z(e).length;default:if(r)return B(e).length;t=(""+t).toLowerCase(),r=!0}}function m(e,t,n){var r=!1;if((void 0===t||t<0)&&(t=0),t>this.length)return"";if((void 0===n||n>this.length)&&(n=this.length),n<=0)return"";if((n>>>=0)<=(t>>>=0))return"";for(e||(e="utf8");;)switch(e){case"hex":return j(this,t,n);case"utf8":case"utf-8":return k(this,t,n);case"ascii":return A(this,t,n);case"latin1":case"binary":return T(this,t,n);case"base64":return C(this,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return P(this,t,n);default:if(r)throw new TypeError("Unknown encoding: "+e);e=(e+"").toLowerCase(),r=!0}}function v(e,t,n){var r=e[t];e[t]=e[n],e[n]=r}function g(e,t,n,r,o){if(0===e.length)return-1;if("string"==typeof n?(r=n,n=0):n>2147483647?n=2147483647:n<-2147483648&&(n=-2147483648),n=+n,isNaN(n)&&(n=o?0:e.length-1),n<0&&(n=e.length+n),n>=e.length){if(o)return-1;n=e.length-1}else if(n<0){if(!o)return-1;n=0}if("string"==typeof t&&(t=u.from(t,r)),u.isBuffer(t))return 0===t.length?-1:y(e,t,n,r,o);if("number"==typeof t)return t&=255,u.TYPED_ARRAY_SUPPORT&&"function"==typeof Uint8Array.prototype.indexOf?o?Uint8Array.prototype.indexOf.call(e,t,n):Uint8Array.prototype.lastIndexOf.call(e,t,n):y(e,[t],n,r,o);throw new TypeError("val must be string, number or Buffer")}function y(e,t,n,r,o){var i,a=1,s=e.length,u=t.length;if(void 0!==r&&("ucs2"===(r=String(r).toLowerCase())||"ucs-2"===r||"utf16le"===r||"utf-16le"===r)){if(e.length<2||t.length<2)return-1;a=2,s/=2,u/=2,n/=2}function c(e,t){return 1===a?e[t]:e.readUInt16BE(t*a)}if(o){var l=-1;for(i=n;is&&(n=s-u),i=n;i>=0;i--){for(var p=!0,f=0;fo&&(r=o):r=o;var i=t.length;if(i%2!=0)throw new TypeError("Invalid hex string");r>i/2&&(r=i/2);for(var a=0;a>8,o=n%256,i.push(o),i.push(r);return i}(t,e.length-n),e,n,r)}function C(e,t,n){return 0===t&&n===e.length?r.fromByteArray(e):r.fromByteArray(e.slice(t,n))}function k(e,t,n){n=Math.min(e.length,n);for(var r=[],o=t;o239?4:c>223?3:c>191?2:1;if(o+p<=n)switch(p){case 1:c<128&&(l=c);break;case 2:128==(192&(i=e[o+1]))&&(u=(31&c)<<6|63&i)>127&&(l=u);break;case 3:i=e[o+1],a=e[o+2],128==(192&i)&&128==(192&a)&&(u=(15&c)<<12|(63&i)<<6|63&a)>2047&&(u<55296||u>57343)&&(l=u);break;case 4:i=e[o+1],a=e[o+2],s=e[o+3],128==(192&i)&&128==(192&a)&&128==(192&s)&&(u=(15&c)<<18|(63&i)<<12|(63&a)<<6|63&s)>65535&&u<1114112&&(l=u)}null===l?(l=65533,p=1):l>65535&&(l-=65536,r.push(l>>>10&1023|55296),l=56320|1023&l),r.push(l),o+=p}return function(e){var t=e.length;if(t<=O)return String.fromCharCode.apply(String,e);var n="",r=0;for(;r0&&(e=this.toString("hex",0,n).match(/.{2}/g).join(" "),this.length>n&&(e+=" ... ")),""},u.prototype.compare=function(e,t,n,r,o){if(!u.isBuffer(e))throw new TypeError("Argument must be a Buffer");if(void 0===t&&(t=0),void 0===n&&(n=e?e.length:0),void 0===r&&(r=0),void 0===o&&(o=this.length),t<0||n>e.length||r<0||o>this.length)throw new RangeError("out of range index");if(r>=o&&t>=n)return 0;if(r>=o)return-1;if(t>=n)return 1;if(this===e)return 0;for(var i=(o>>>=0)-(r>>>=0),a=(n>>>=0)-(t>>>=0),s=Math.min(i,a),c=this.slice(r,o),l=e.slice(t,n),p=0;po)&&(n=o),e.length>0&&(n<0||t<0)||t>this.length)throw new RangeError("Attempt to write outside buffer bounds");r||(r="utf8");for(var i=!1;;)switch(r){case"hex":return b(this,e,t,n);case"utf8":case"utf-8":return _(this,e,t,n);case"ascii":return w(this,e,t,n);case"latin1":case"binary":return x(this,e,t,n);case"base64":return E(this,e,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return S(this,e,t,n);default:if(i)throw new TypeError("Unknown encoding: "+r);r=(""+r).toLowerCase(),i=!0}},u.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};var O=4096;function A(e,t,n){var r="";n=Math.min(e.length,n);for(var o=t;or)&&(n=r);for(var o="",i=t;in)throw new RangeError("Trying to access beyond buffer length")}function M(e,t,n,r,o,i){if(!u.isBuffer(e))throw new TypeError('"buffer" argument must be a Buffer instance');if(t>o||te.length)throw new RangeError("Index out of range")}function N(e,t,n,r){t<0&&(t=65535+t+1);for(var o=0,i=Math.min(e.length-n,2);o>>8*(r?o:1-o)}function R(e,t,n,r){t<0&&(t=4294967295+t+1);for(var o=0,i=Math.min(e.length-n,4);o>>8*(r?o:3-o)&255}function D(e,t,n,r,o,i){if(n+r>e.length)throw new RangeError("Index out of range");if(n<0)throw new RangeError("Index out of range")}function L(e,t,n,r,i){return i||D(e,0,n,4),o.write(e,t,n,r,23,4),n+4}function U(e,t,n,r,i){return i||D(e,0,n,8),o.write(e,t,n,r,52,8),n+8}u.prototype.slice=function(e,t){var n,r=this.length;if((e=~~e)<0?(e+=r)<0&&(e=0):e>r&&(e=r),(t=void 0===t?r:~~t)<0?(t+=r)<0&&(t=0):t>r&&(t=r),t0&&(o*=256);)r+=this[e+--t]*o;return r},u.prototype.readUInt8=function(e,t){return t||I(e,1,this.length),this[e]},u.prototype.readUInt16LE=function(e,t){return t||I(e,2,this.length),this[e]|this[e+1]<<8},u.prototype.readUInt16BE=function(e,t){return t||I(e,2,this.length),this[e]<<8|this[e+1]},u.prototype.readUInt32LE=function(e,t){return t||I(e,4,this.length),(this[e]|this[e+1]<<8|this[e+2]<<16)+16777216*this[e+3]},u.prototype.readUInt32BE=function(e,t){return t||I(e,4,this.length),16777216*this[e]+(this[e+1]<<16|this[e+2]<<8|this[e+3])},u.prototype.readIntLE=function(e,t,n){e|=0,t|=0,n||I(e,t,this.length);for(var r=this[e],o=1,i=0;++i=(o*=128)&&(r-=Math.pow(2,8*t)),r},u.prototype.readIntBE=function(e,t,n){e|=0,t|=0,n||I(e,t,this.length);for(var r=t,o=1,i=this[e+--r];r>0&&(o*=256);)i+=this[e+--r]*o;return i>=(o*=128)&&(i-=Math.pow(2,8*t)),i},u.prototype.readInt8=function(e,t){return t||I(e,1,this.length),128&this[e]?-1*(255-this[e]+1):this[e]},u.prototype.readInt16LE=function(e,t){t||I(e,2,this.length);var n=this[e]|this[e+1]<<8;return 32768&n?4294901760|n:n},u.prototype.readInt16BE=function(e,t){t||I(e,2,this.length);var n=this[e+1]|this[e]<<8;return 32768&n?4294901760|n:n},u.prototype.readInt32LE=function(e,t){return t||I(e,4,this.length),this[e]|this[e+1]<<8|this[e+2]<<16|this[e+3]<<24},u.prototype.readInt32BE=function(e,t){return t||I(e,4,this.length),this[e]<<24|this[e+1]<<16|this[e+2]<<8|this[e+3]},u.prototype.readFloatLE=function(e,t){return t||I(e,4,this.length),o.read(this,e,!0,23,4)},u.prototype.readFloatBE=function(e,t){return t||I(e,4,this.length),o.read(this,e,!1,23,4)},u.prototype.readDoubleLE=function(e,t){return t||I(e,8,this.length),o.read(this,e,!0,52,8)},u.prototype.readDoubleBE=function(e,t){return t||I(e,8,this.length),o.read(this,e,!1,52,8)},u.prototype.writeUIntLE=function(e,t,n,r){(e=+e,t|=0,n|=0,r)||M(this,e,t,n,Math.pow(2,8*n)-1,0);var o=1,i=0;for(this[t]=255&e;++i=0&&(i*=256);)this[t+o]=e/i&255;return t+n},u.prototype.writeUInt8=function(e,t,n){return e=+e,t|=0,n||M(this,e,t,1,255,0),u.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),this[t]=255&e,t+1},u.prototype.writeUInt16LE=function(e,t,n){return e=+e,t|=0,n||M(this,e,t,2,65535,0),u.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8):N(this,e,t,!0),t+2},u.prototype.writeUInt16BE=function(e,t,n){return e=+e,t|=0,n||M(this,e,t,2,65535,0),u.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=255&e):N(this,e,t,!1),t+2},u.prototype.writeUInt32LE=function(e,t,n){return e=+e,t|=0,n||M(this,e,t,4,4294967295,0),u.TYPED_ARRAY_SUPPORT?(this[t+3]=e>>>24,this[t+2]=e>>>16,this[t+1]=e>>>8,this[t]=255&e):R(this,e,t,!0),t+4},u.prototype.writeUInt32BE=function(e,t,n){return e=+e,t|=0,n||M(this,e,t,4,4294967295,0),u.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e):R(this,e,t,!1),t+4},u.prototype.writeIntLE=function(e,t,n,r){if(e=+e,t|=0,!r){var o=Math.pow(2,8*n-1);M(this,e,t,n,o-1,-o)}var i=0,a=1,s=0;for(this[t]=255&e;++i>0)-s&255;return t+n},u.prototype.writeIntBE=function(e,t,n,r){if(e=+e,t|=0,!r){var o=Math.pow(2,8*n-1);M(this,e,t,n,o-1,-o)}var i=n-1,a=1,s=0;for(this[t+i]=255&e;--i>=0&&(a*=256);)e<0&&0===s&&0!==this[t+i+1]&&(s=1),this[t+i]=(e/a>>0)-s&255;return t+n},u.prototype.writeInt8=function(e,t,n){return e=+e,t|=0,n||M(this,e,t,1,127,-128),u.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),e<0&&(e=255+e+1),this[t]=255&e,t+1},u.prototype.writeInt16LE=function(e,t,n){return e=+e,t|=0,n||M(this,e,t,2,32767,-32768),u.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8):N(this,e,t,!0),t+2},u.prototype.writeInt16BE=function(e,t,n){return e=+e,t|=0,n||M(this,e,t,2,32767,-32768),u.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=255&e):N(this,e,t,!1),t+2},u.prototype.writeInt32LE=function(e,t,n){return e=+e,t|=0,n||M(this,e,t,4,2147483647,-2147483648),u.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8,this[t+2]=e>>>16,this[t+3]=e>>>24):R(this,e,t,!0),t+4},u.prototype.writeInt32BE=function(e,t,n){return e=+e,t|=0,n||M(this,e,t,4,2147483647,-2147483648),e<0&&(e=4294967295+e+1),u.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e):R(this,e,t,!1),t+4},u.prototype.writeFloatLE=function(e,t,n){return L(this,e,t,!0,n)},u.prototype.writeFloatBE=function(e,t,n){return L(this,e,t,!1,n)},u.prototype.writeDoubleLE=function(e,t,n){return U(this,e,t,!0,n)},u.prototype.writeDoubleBE=function(e,t,n){return U(this,e,t,!1,n)},u.prototype.copy=function(e,t,n,r){if(n||(n=0),r||0===r||(r=this.length),t>=e.length&&(t=e.length),t||(t=0),r>0&&r=this.length)throw new RangeError("sourceStart out of bounds");if(r<0)throw new RangeError("sourceEnd out of bounds");r>this.length&&(r=this.length),e.length-t=0;--o)e[o+t]=this[o+n];else if(i<1e3||!u.TYPED_ARRAY_SUPPORT)for(o=0;o>>=0,n=void 0===n?this.length:n>>>0,e||(e=0),"number"==typeof e)for(i=t;i55295&&n<57344){if(!o){if(n>56319){(t-=3)>-1&&i.push(239,191,189);continue}if(a+1===r){(t-=3)>-1&&i.push(239,191,189);continue}o=n;continue}if(n<56320){(t-=3)>-1&&i.push(239,191,189),o=n;continue}n=65536+(o-55296<<10|n-56320)}else o&&(t-=3)>-1&&i.push(239,191,189);if(o=null,n<128){if((t-=1)<0)break;i.push(n)}else if(n<2048){if((t-=2)<0)break;i.push(n>>6|192,63&n|128)}else if(n<65536){if((t-=3)<0)break;i.push(n>>12|224,n>>6&63|128,63&n|128)}else{if(!(n<1114112))throw new Error("Invalid code point");if((t-=4)<0)break;i.push(n>>18|240,n>>12&63|128,n>>6&63|128,63&n|128)}}return i}function z(e){return r.toByteArray(function(e){if((e=function(e){return e.trim?e.trim():e.replace(/^\s+|\s+$/g,"")}(e).replace(q,"")).length<2)return"";for(;e.length%4!=0;)e+="=";return e}(e))}function V(e,t,n,r){for(var o=0;o=t.length||o>=e.length);++o)t[o+n]=e[o];return o}}).call(this,n(36))},function(e,t,n){"use strict";e.exports={current:null}},function(e,t){e.exports=function(e){return null!=e&&"object"==typeof e}},function(e,t){var n,r,o=e.exports={};function i(){throw new Error("setTimeout has not been defined")}function a(){throw new Error("clearTimeout has not been defined")}function s(e){if(n===setTimeout)return setTimeout(e,0);if((n===i||!n)&&setTimeout)return n=setTimeout,setTimeout(e,0);try{return n(e,0)}catch(t){try{return n.call(null,e,0)}catch(t){return n.call(this,e,0)}}}!function(){try{n="function"==typeof setTimeout?setTimeout:i}catch(e){n=i}try{r="function"==typeof clearTimeout?clearTimeout:a}catch(e){r=a}}();var u,c=[],l=!1,p=-1;function f(){l&&u&&(l=!1,u.length?c=u.concat(c):p=-1,c.length&&h())}function h(){if(!l){var e=s(f);l=!0;for(var t=c.length;t;){for(u=c,c=[];++p1)for(var n=1;n0&&"/"!==t[0]});function oe(e,t,n){return t=t||[],te.apply(void 0,[e].concat(u()(t))).get("parameters",Object(p.List)()).reduce(function(e,t){var r=n&&"body"===t.get("in")?t.get("value_xml"):t.get("value");return e.set(Object(l.B)(t,{allowHashes:!1}),r)},Object(p.fromJS)({}))}function ie(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";if(p.List.isList(e))return e.some(function(e){return p.Map.isMap(e)&&e.get("in")===t})}function ae(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";if(p.List.isList(e))return e.some(function(e){return p.Map.isMap(e)&&e.get("type")===t})}function se(e,t){t=t||[];var n=x(e).getIn(["paths"].concat(u()(t)),Object(p.fromJS)({})),r=e.getIn(["meta","paths"].concat(u()(t)),Object(p.fromJS)({})),o=ue(e,t),i=n.get("parameters")||new p.List,a=r.get("consumes_value")?r.get("consumes_value"):ae(i,"file")?"multipart/form-data":ae(i,"formData")?"application/x-www-form-urlencoded":void 0;return Object(p.fromJS)({requestContentType:a,responseContentType:o})}function ue(e,t){t=t||[];var n=x(e).getIn(["paths"].concat(u()(t)),null);if(null!==n){var r=e.getIn(["meta","paths"].concat(u()(t),["produces_value"]),null),o=n.getIn(["produces",0],null);return r||o||"application/json"}}function ce(e,t){t=t||[];var n=x(e),r=n.getIn(["paths"].concat(u()(t)),null);if(null!==r){var o=t,i=a()(o,1)[0],s=r.get("produces",null),c=n.getIn(["paths",i,"produces"],null),l=n.getIn(["produces"],null);return s||c||l}}function le(e,t){t=t||[];var n=x(e),r=n.getIn(["paths"].concat(u()(t)),null);if(null!==r){var o=t,i=a()(o,1)[0],s=r.get("consumes",null),c=n.getIn(["paths",i,"consumes"],null),l=n.getIn(["consumes"],null);return s||c||l}}var pe=function(e,t,n){var r=e.get("url").match(/^([a-z][a-z0-9+\-.]*):/),i=o()(r)?r[1]:null;return e.getIn(["scheme",t,n])||e.getIn(["scheme","_defaultScheme"])||i||""},fe=function(e,t,n){return["http","https"].indexOf(pe(e,t,n))>-1},he=function(e,t){t=t||[];var n=e.getIn(["meta","paths"].concat(u()(t),["parameters"]),Object(p.fromJS)([])),r=!0;return n.forEach(function(e){var t=e.get("errors");t&&t.count()&&(r=!1)}),r};function de(e){return p.Map.isMap(e)?e:new p.Map}},function(e,t,n){"use strict";n.r(t),n.d(t,"SHOW_AUTH_POPUP",function(){return d}),n.d(t,"AUTHORIZE",function(){return m}),n.d(t,"LOGOUT",function(){return v}),n.d(t,"PRE_AUTHORIZE_OAUTH2",function(){return g}),n.d(t,"AUTHORIZE_OAUTH2",function(){return y}),n.d(t,"VALIDATE",function(){return b}),n.d(t,"CONFIGURE_AUTH",function(){return _}),n.d(t,"showDefinitions",function(){return w}),n.d(t,"authorize",function(){return x}),n.d(t,"logout",function(){return E}),n.d(t,"preAuthorizeImplicit",function(){return S}),n.d(t,"authorizeOauth2",function(){return C}),n.d(t,"authorizePassword",function(){return k}),n.d(t,"authorizeApplication",function(){return O}),n.d(t,"authorizeAccessCodeWithFormParams",function(){return A}),n.d(t,"authorizeAccessCodeWithBasicAuthentication",function(){return T}),n.d(t,"authorizeRequest",function(){return j}),n.d(t,"configureAuth",function(){return P});var r=n(26),o=n.n(r),i=n(16),a=n.n(i),s=n(28),u=n.n(s),c=n(95),l=n.n(c),p=n(18),f=n.n(p),h=n(3),d="show_popup",m="authorize",v="logout",g="pre_authorize_oauth2",y="authorize_oauth2",b="validate",_="configure_auth";function w(e){return{type:d,payload:e}}function x(e){return{type:m,payload:e}}function E(e){return{type:v,payload:e}}var S=function(e){return function(t){var n=t.authActions,r=t.errActions,o=e.auth,i=e.token,a=e.isValid,s=o.schema,c=o.name,l=s.get("flow");delete f.a.swaggerUIRedirectOauth2,"accessCode"===l||a||r.newAuthErr({authId:c,source:"auth",level:"warning",message:"Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"}),i.error?r.newAuthErr({authId:c,source:"auth",level:"error",message:u()(i)}):n.authorizeOauth2({auth:o,token:i})}};function C(e){return{type:y,payload:e}}var k=function(e){return function(t){var n=t.authActions,r=e.schema,o=e.name,i=e.username,s=e.password,u=e.passwordType,c=e.clientId,l=e.clientSecret,p={grant_type:"password",scope:e.scopes.join(" "),username:i,password:s},f={};switch(u){case"request-body":!function(e,t,n){t&&a()(e,{client_id:t});n&&a()(e,{client_secret:n})}(p,c,l);break;case"basic":f.Authorization="Basic "+Object(h.a)(c+":"+l);break;default:console.warn("Warning: invalid passwordType ".concat(u," was passed, not including client id and secret"))}return n.authorizeRequest({body:Object(h.b)(p),url:r.get("tokenUrl"),name:o,headers:f,query:{},auth:e})}};var O=function(e){return function(t){var n=t.authActions,r=e.schema,o=e.scopes,i=e.name,a=e.clientId,s=e.clientSecret,u={Authorization:"Basic "+Object(h.a)(a+":"+s)},c={grant_type:"client_credentials",scope:o.join(" ")};return n.authorizeRequest({body:Object(h.b)(c),name:i,url:r.get("tokenUrl"),auth:e,headers:u})}},A=function(e){var t=e.auth,n=e.redirectUrl;return function(e){var r=e.authActions,o=t.schema,i=t.name,a=t.clientId,s=t.clientSecret,u=t.codeVerifier,c={grant_type:"authorization_code",code:t.code,client_id:a,client_secret:s,redirect_uri:n,code_verifier:u};return r.authorizeRequest({body:Object(h.b)(c),name:i,url:o.get("tokenUrl"),auth:t})}},T=function(e){var t=e.auth,n=e.redirectUrl;return function(e){var r=e.authActions,o=t.schema,i=t.name,a=t.clientId,s=t.clientSecret,u={Authorization:"Basic "+Object(h.a)(a+":"+s)},c={grant_type:"authorization_code",code:t.code,client_id:a,redirect_uri:n};return r.authorizeRequest({body:Object(h.b)(c),name:i,url:o.get("tokenUrl"),auth:t,headers:u})}},j=function(e){return function(t){var n,r=t.fn,i=t.getConfigs,s=t.authActions,c=t.errActions,p=t.oas3Selectors,f=t.specSelectors,h=t.authSelectors,d=e.body,m=e.query,v=void 0===m?{}:m,g=e.headers,y=void 0===g?{}:g,b=e.name,_=e.url,w=e.auth,x=(h.getConfigs()||{}).additionalQueryStringParams;n=f.isOAS3()?l()(_,p.selectedServer(),!0):l()(_,f.url(),!0),"object"===o()(x)&&(n.query=a()({},n.query,x));var E=n.toString(),S=a()({Accept:"application/json, text/plain, */*","Content-Type":"application/x-www-form-urlencoded","X-Requested-With":"XMLHttpRequest"},y);r.fetch({url:E,method:"post",headers:S,query:v,body:d,requestInterceptor:i().requestInterceptor,responseInterceptor:i().responseInterceptor}).then(function(e){var t=JSON.parse(e.data),n=t&&(t.error||""),r=t&&(t.parseError||"");e.ok?n||r?c.newAuthErr({authId:b,level:"error",source:"auth",message:u()(t)}):s.authorizeOauth2({auth:w,token:t}):c.newAuthErr({authId:b,level:"error",source:"auth",message:e.statusText})}).catch(function(e){var t=new Error(e).message;if(e.response&&e.response.data){var n=e.response.data;try{var r="string"==typeof n?JSON.parse(n):n;r.error&&(t+=", error: ".concat(r.error)),r.error_description&&(t+=", description: ".concat(r.error_description))}catch(e){}}c.newAuthErr({authId:b,level:"error",source:"auth",message:t})})}};function P(e){return{type:_,payload:e}}},function(e,t){var n=e.exports={version:"2.6.5"};"number"==typeof __e&&(__e=n)},function(e,t){e.exports=function(e){if(null==e)throw TypeError("Can't call method on "+e);return e}},function(e,t,n){var r=n(127),o=Math.min;e.exports=function(e){return e>0?o(r(e),9007199254740991):0}},function(e,t){var n={}.hasOwnProperty;e.exports=function(e,t){return n.call(e,t)}},function(e,t,n){var r=n(211),o=n(210);e.exports=function(e){return r(o(e))}},function(e,t,n){var r=n(49),o=n(133);e.exports=n(50)?function(e,t,n){return r.f(e,t,o(1,n))}:function(e,t,n){return e[t]=n,e}},function(e,t,n){"use strict";e.exports=function(e){if("function"!=typeof e)throw new TypeError(e+" is not a function");return e}},function(e,t,n){"use strict";n.r(t),n.d(t,"UPDATE_LAYOUT",function(){return o}),n.d(t,"UPDATE_FILTER",function(){return i}),n.d(t,"UPDATE_MODE",function(){return a}),n.d(t,"SHOW",function(){return s}),n.d(t,"updateLayout",function(){return u}),n.d(t,"updateFilter",function(){return c}),n.d(t,"show",function(){return l}),n.d(t,"changeMode",function(){return p});var r=n(3),o="layout_update_layout",i="layout_update_filter",a="layout_update_mode",s="layout_show";function u(e){return{type:o,payload:e}}function c(e){return{type:i,payload:e}}function l(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return e=Object(r.w)(e),{type:s,payload:{thing:e,shown:t}}}function p(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";return e=Object(r.w)(e),{type:a,payload:{thing:e,mode:t}}}},function(e,t,n){"use strict";(function(t){ +/*! + * @description Recursive object extending + * @author Viacheslav Lotsmanov + * @license MIT + * + * The MIT License (MIT) + * + * Copyright (c) 2013-2018 Viacheslav Lotsmanov + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +function n(e){return e instanceof t||e instanceof Date||e instanceof RegExp}function r(e){if(e instanceof t){var n=t.alloc?t.alloc(e.length):new t(e.length);return e.copy(n),n}if(e instanceof Date)return new Date(e.getTime());if(e instanceof RegExp)return new RegExp(e);throw new Error("Unexpected situation")}function o(e){var t=[];return e.forEach(function(e,i){"object"==typeof e&&null!==e?Array.isArray(e)?t[i]=o(e):n(e)?t[i]=r(e):t[i]=a({},e):t[i]=e}),t}function i(e,t){return"__proto__"===t?void 0:e[t]}var a=e.exports=function(){if(arguments.length<1||"object"!=typeof arguments[0])return!1;if(arguments.length<2)return arguments[0];var e,t,s=arguments[0],u=Array.prototype.slice.call(arguments,1);return u.forEach(function(u){"object"!=typeof u||null===u||Array.isArray(u)||Object.keys(u).forEach(function(c){return t=i(s,c),(e=i(u,c))===s?void 0:"object"!=typeof e||null===e?void(s[c]=e):Array.isArray(e)?void(s[c]=o(e)):n(e)?void(s[c]=r(e)):"object"!=typeof t||null===t||Array.isArray(t)?void(s[c]=a({},e)):void(s[c]=a(t,e))})}),s}}).call(this,n(64).Buffer)},function(e,t,n){var r=n(151),o=n(336);e.exports=n(126)?function(e,t,n){return r.f(e,t,o(1,n))}:function(e,t,n){return e[t]=n,e}},function(e,t){e.exports=function(e){try{return!!e()}catch(e){return!0}}},function(e,t,n){var r=n(106),o=n(603),i=n(604),a="[object Null]",s="[object Undefined]",u=r?r.toStringTag:void 0;e.exports=function(e){return null==e?void 0===e?s:a:u&&u in Object(e)?o(e):i(e)}},function(e,t,n){var r=n(621),o=n(624);e.exports=function(e,t){var n=o(e,t);return r(n)?n:void 0}},function(e,t,n){var r=n(380),o=n(661),i=n(107);e.exports=function(e){return i(e)?r(e):o(e)}},function(e,t,n){"use strict";var r=n(178),o=Object.keys||function(e){var t=[];for(var n in e)t.push(n);return t};e.exports=p;var i=n(137);i.inherits=n(47);var a=n(390),s=n(240);i.inherits(p,a);for(var u=o(s.prototype),c=0;c=t.length?{value:void 0,done:!0}:(e=r(t,n),this._i+=e.length,{value:e,done:!1})})},function(e,t){e.exports={}},function(e,t,n){n(561);for(var r=n(32),o=n(77),i=n(102),a=n(34)("toStringTag"),s="CSSRuleList,CSSStyleDeclaration,CSSValueList,ClientRectList,DOMRectList,DOMStringList,DOMTokenList,DataTransferItemList,FileList,HTMLAllCollection,HTMLCollection,HTMLFormElement,HTMLSelectElement,MediaList,MimeTypeArray,NamedNodeMap,NodeList,PaintRequestList,Plugin,PluginArray,SVGLengthList,SVGNumberList,SVGPathSegList,SVGPointList,SVGStringList,SVGTransformList,SourceBufferList,StyleSheetList,TextTrackCueList,TextTrackList,TouchList".split(","),u=0;u1){for(var d=Array(h),m=0;m1){for(var g=Array(v),y=0;y=this._finalSize&&(this._update(this._block),this._block.fill(0));var n=8*this._len;if(n<=4294967295)this._block.writeUInt32BE(n,this._blockSize-4);else{var r=(4294967295&n)>>>0,o=(n-r)/4294967296;this._block.writeUInt32BE(o,this._blockSize-8),this._block.writeUInt32BE(r,this._blockSize-4)}this._update(this._block);var i=this._hash();return e?i.toString(e):i},o.prototype._update=function(){throw new Error("_update must be implemented by subclass")},e.exports=o},function(e,t,n){var r=n(63),o=n(406),i=n(407),a=n(46),s=n(158),u=n(225),c={},l={};(t=e.exports=function(e,t,n,p,f){var h,d,m,v,g=f?function(){return e}:u(e),y=r(n,p,t?2:1),b=0;if("function"!=typeof g)throw TypeError(e+" is not iterable!");if(i(g)){for(h=s(e.length);h>b;b++)if((v=t?y(a(d=e[b])[0],d[1]):y(e[b]))===c||v===l)return v}else for(m=g.call(e);!(d=m.next()).done;)if((v=o(m,y,d.value,t))===c||v===l)return v}).BREAK=c,t.RETURN=l},function(e,t,n){"use strict";function r(e){return null==e}e.exports.isNothing=r,e.exports.isObject=function(e){return"object"==typeof e&&null!==e},e.exports.toArray=function(e){return Array.isArray(e)?e:r(e)?[]:[e]},e.exports.repeat=function(e,t){var n,r="";for(n=0;n1&&void 0!==arguments[1]?arguments[1]:{},r=Object(i.A)(t),a=r.type,s=r.example,u=r.properties,c=r.additionalProperties,l=r.items,p=n.includeReadOnly,f=n.includeWriteOnly;if(void 0!==s)return Object(i.e)(s,"$$ref",function(e){return"string"==typeof e&&e.indexOf("#")>-1});if(!a)if(u)a="object";else{if(!l)return;a="array"}if("object"===a){var d=Object(i.A)(u),m={};for(var v in d)d[v]&&d[v].deprecated||d[v]&&d[v].readOnly&&!p||d[v]&&d[v].writeOnly&&!f||(m[v]=e(d[v],n));if(!0===c)m.additionalProp1={};else if(c)for(var g=Object(i.A)(c),y=e(g,n),b=1;b<4;b++)m["additionalProp"+b]=y;return m}return"array"===a?o()(l.anyOf)?l.anyOf.map(function(t){return e(t,n)}):o()(l.oneOf)?l.oneOf.map(function(t){return e(t,n)}):[e(l,n)]:t.enum?t.default?t.default:Object(i.w)(t.enum)[0]:"file"!==a?h(t):void 0},m=function(e){return e.schema&&(e=e.schema),e.properties&&(e.type="object"),e},v=function e(t){var n,r,a=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},s=p()({},Object(i.A)(t)),u=s.type,c=s.properties,l=s.additionalProperties,f=s.items,d=s.example,m=a.includeReadOnly,v=a.includeWriteOnly,g=s.default,y={},b={},_=t.xml,w=_.name,x=_.prefix,E=_.namespace,S=s.enum;if(!u)if(c||l)u="object";else{if(!f)return;u="array"}if(n=(x?x+":":"")+(w=w||"notagname"),E){var C=x?"xmlns:"+x:"xmlns";b[C]=E}if("array"===u&&f){if(f.xml=f.xml||_||{},f.xml.name=f.xml.name||_.name,_.wrapped)return y[n]=[],o()(d)?d.forEach(function(t){f.example=t,y[n].push(e(f,a))}):o()(g)?g.forEach(function(t){f.default=t,y[n].push(e(f,a))}):y[n]=[e(f,a)],b&&y[n].push({_attr:b}),y;var k=[];return o()(d)?(d.forEach(function(t){f.example=t,k.push(e(f,a))}),k):o()(g)?(g.forEach(function(t){f.default=t,k.push(e(f,a))}),k):e(f,a)}if("object"===u){var O=Object(i.A)(c);for(var A in y[n]=[],d=d||{},O)if(O.hasOwnProperty(A)&&(!O[A].readOnly||m)&&(!O[A].writeOnly||v))if(O[A].xml=O[A].xml||{},O[A].xml.attribute){var T=o()(O[A].enum)&&O[A].enum[0],j=O[A].example,P=O[A].default;b[O[A].xml.name||A]=void 0!==j&&j||void 0!==d[A]&&d[A]||void 0!==P&&P||T||h(O[A])}else{O[A].xml.name=O[A].xml.name||A,void 0===O[A].example&&void 0!==d[A]&&(O[A].example=d[A]);var I=e(O[A]);o()(I)?y[n]=y[n].concat(I):y[n].push(I)}return!0===l?y[n].push({additionalProp:"Anything can be here"}):l&&y[n].push({additionalProp:h(l)}),b&&y[n].push({_attr:b}),y}return r=void 0!==d?d:void 0!==g?g:o()(S)?S[0]:h(t),y[n]=b?[{_attr:b},r]:r,y};function g(e,t){var n=v(e,t);if(n)return s()(n,{declaration:!0,indent:"\t"})}var y=c()(g),b=c()(d)},function(e,t,n){"use strict";n.r(t),n.d(t,"UPDATE_CONFIGS",function(){return i}),n.d(t,"TOGGLE_CONFIGS",function(){return a}),n.d(t,"update",function(){return s}),n.d(t,"toggle",function(){return u}),n.d(t,"loaded",function(){return c});var r=n(2),o=n.n(r),i="configs_update",a="configs_toggle";function s(e,t){return{type:i,payload:o()({},e,t)}}function u(e){return{type:a,payload:e}}var c=function(){return function(){}}},function(e,t,n){"use strict";n.d(t,"a",function(){return a});var r=n(1),o=n.n(r),i=o.a.Set.of("type","format","items","default","maximum","exclusiveMaximum","minimum","exclusiveMinimum","maxLength","minLength","pattern","maxItems","minItems","uniqueItems","enum","multipleOf");function a(e){var t=(arguments.length>1&&void 0!==arguments[1]?arguments[1]:{}).isOAS3;if(!o.a.Map.isMap(e))return{schema:o.a.Map(),parameterContentMediaType:null};if(!t)return"body"===e.get("in")?{schema:e.get("schema",o.a.Map()),parameterContentMediaType:null}:{schema:e.filter(function(e,t){return i.includes(t)}),parameterContentMediaType:null};if(e.get("content")){var n=e.get("content",o.a.Map({})).keySeq().first();return{schema:e.getIn(["content",n,"schema"],o.a.Map()),parameterContentMediaType:n}}return{schema:e.get("schema",o.a.Map()),parameterContentMediaType:null}}},function(e,t,n){e.exports=n(781)},function(e,t,n){"use strict";n.r(t);var r=n(469),o="object"==typeof self&&self&&self.Object===Object&&self,i=(r.a||o||Function("return this")()).Symbol,a=Object.prototype,s=a.hasOwnProperty,u=a.toString,c=i?i.toStringTag:void 0;var l=function(e){var t=s.call(e,c),n=e[c];try{e[c]=void 0;var r=!0}catch(e){}var o=u.call(e);return r&&(t?e[c]=n:delete e[c]),o},p=Object.prototype.toString;var f=function(e){return p.call(e)},h="[object Null]",d="[object Undefined]",m=i?i.toStringTag:void 0;var v=function(e){return null==e?void 0===e?d:h:m&&m in Object(e)?l(e):f(e)};var g=function(e,t){return function(n){return e(t(n))}}(Object.getPrototypeOf,Object);var y=function(e){return null!=e&&"object"==typeof e},b="[object Object]",_=Function.prototype,w=Object.prototype,x=_.toString,E=w.hasOwnProperty,S=x.call(Object);var C=function(e){if(!y(e)||v(e)!=b)return!1;var t=g(e);if(null===t)return!0;var n=E.call(t,"constructor")&&t.constructor;return"function"==typeof n&&n instanceof n&&x.call(n)==S},k=n(330),O={INIT:"@@redux/INIT"};function A(e,t,n){var r;if("function"==typeof t&&void 0===n&&(n=t,t=void 0),void 0!==n){if("function"!=typeof n)throw new Error("Expected the enhancer to be a function.");return n(A)(e,t)}if("function"!=typeof e)throw new Error("Expected the reducer to be a function.");var o=e,i=t,a=[],s=a,u=!1;function c(){s===a&&(s=a.slice())}function l(){return i}function p(e){if("function"!=typeof e)throw new Error("Expected listener to be a function.");var t=!0;return c(),s.push(e),function(){if(t){t=!1,c();var n=s.indexOf(e);s.splice(n,1)}}}function f(e){if(!C(e))throw new Error("Actions must be plain objects. Use custom middleware for async actions.");if(void 0===e.type)throw new Error('Actions may not have an undefined "type" property. Have you misspelled a constant?');if(u)throw new Error("Reducers may not dispatch actions.");try{u=!0,i=o(i,e)}finally{u=!1}for(var t=a=s,n=0;n0&&void 0!==arguments[0]?arguments[0]:{},t=arguments[1];if(a)throw a;for(var r=!1,o={},s=0;s0?r:n)(e)}},function(e,t){e.exports={}},function(e,t,n){var r=n(348),o=n(215);e.exports=Object.keys||function(e){return r(e,o)}},function(e,t){var n={}.toString;e.exports=function(e){return n.call(e).slice(8,-1)}},function(e,t){e.exports=!0},function(e,t){e.exports=function(e){if("function"!=typeof e)throw TypeError(e+" is not a function!");return e}},function(e,t){e.exports=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}}},function(e,t,n){var r=n(49).f,o=n(75),i=n(34)("toStringTag");e.exports=function(e,t,n){e&&!o(e=n?e:e.prototype,i)&&r(e,i,{configurable:!0,value:t})}},function(e,t,n){var r=n(159)("meta"),o=n(43),i=n(75),a=n(49).f,s=0,u=Object.isExtensible||function(){return!0},c=!n(82)(function(){return u(Object.preventExtensions({}))}),l=function(e){a(e,r,{value:{i:"O"+ ++s,w:{}}})},p=e.exports={KEY:r,NEED:!1,fastKey:function(e,t){if(!o(e))return"symbol"==typeof e?e:("string"==typeof e?"S":"P")+e;if(!i(e,r)){if(!u(e))return"F";if(!t)return"E";l(e)}return e[r].i},getWeak:function(e,t){if(!i(e,r)){if(!u(e))return!0;if(!t)return!1;l(e)}return e[r].w},onFreeze:function(e){return c&&p.NEED&&u(e)&&!i(e,r)&&l(e),e}}},function(e,t,n){"use strict";e.exports=function(e){for(var t=arguments.length-1,n="Minified React error #"+e+"; visit http://facebook.github.io/react/docs/error-decoder.html?invariant="+e,r=0;r1&&void 0!==arguments[1]?arguments[1]:[],n={arrayBehaviour:(arguments.length>2&&void 0!==arguments[2]?arguments[2]:{}).arrayBehaviour||"replace"},r=t.map(function(e){return e||{}}),i=e||{},c=0;c1?t-1:0),r=1;r")}),p=function(){var e=/(?:)/,t=e.exec;e.exec=function(){return t.apply(this,arguments)};var n="ab".split(e);return 2===n.length&&"a"===n[0]&&"b"===n[1]}();e.exports=function(e,t,n){var f=s(e),h=!i(function(){var t={};return t[f]=function(){return 7},7!=""[e](t)}),d=h?!i(function(){var t=!1,n=/a/;return n.exec=function(){return t=!0,null},"split"===e&&(n.constructor={},n.constructor[c]=function(){return n}),n[f](""),!t}):void 0;if(!h||!d||"replace"===e&&!l||"split"===e&&!p){var m=/./[f],v=n(a,f,""[e],function(e,t,n,r,o){return t.exec===u?h&&!o?{done:!0,value:m.call(t,n,r)}:{done:!0,value:e.call(n,t,r)}:{done:!1}}),g=v[0],y=v[1];r(String.prototype,e,g),o(RegExp.prototype,f,2==t?function(e,t){return y.call(e,this,t)}:function(e){return y.call(e,this)})}}},function(e,t,n){var r=n(212),o=Math.min;e.exports=function(e){return e>0?o(r(e),9007199254740991):0}},function(e,t){var n=0,r=Math.random();e.exports=function(e){return"Symbol(".concat(void 0===e?"":e,")_",(++n+r).toString(36))}},function(e,t,n){var r=n(46),o=n(350),i=n(215),a=n(213)("IE_PROTO"),s=function(){},u=function(){var e,t=n(217)("iframe"),r=i.length;for(t.style.display="none",n(351).appendChild(t),t.src="javascript:",(e=t.contentWindow.document).open(),e.write(" + + + + + \ No newline at end of file From a2bb1a1a380a76004cd077d7efd6aa8974b6a321 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 7 Jan 2020 14:21:24 +0000 Subject: [PATCH 079/122] Fixed root swagger UI description --- openflexure_microscope/common/flask_labthings/labthing.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index 99248ae3..567b0e89 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -10,7 +10,7 @@ from .spec import view2path from .views.extensions import ExtensionList from .views.tasks import TaskList, TaskResource -from .views.docs import docs_blueprint, APISpecResource, W3CThingDescriptionResource +from .views.docs import docs_blueprint, SwaggerUIResource, W3CThingDescriptionResource from openflexure_microscope.common.labthings_core.utilities import get_docstring @@ -286,11 +286,10 @@ class LabThing(object): "thingDescription": { "href": url_for("labthings_docs.w3c_td", _external=True), "description": get_docstring(W3CThingDescriptionResource), - "methods": ["GET"], }, "swaggerUI": { "href": url_for("labthings_docs.swagger_ui", _external=True), - **description_from_view(APISpecResource), + **description_from_view(SwaggerUIResource), }, "extensions": { "href": self.url_for(ExtensionList, _external=True), From ed13749abe0d599dd3900cb7361946e36ba4da73 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 7 Jan 2020 15:04:47 +0000 Subject: [PATCH 080/122] Improved documentation --- .../api/v2/views/actions/camera.py | 28 +++++++----- .../api/v2/views/actions/stage.py | 43 +++++++++++-------- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index bcb3cd20..70b00380 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -25,13 +25,19 @@ class CaptureAPI(Resource): @use_args( { - "filename": fields.String(), - "temporary": fields.Boolean(missing=False), + "filename": fields.String(example="MyFileName"), + "temporary": fields.Boolean( + missing=False, description="Delete capture on shutdown" + ), "use_video_port": fields.Boolean(missing=False), - "bayer": fields.Boolean(missing=False), - "metadata": fields.Dict(missing={}), - "tags": fields.List(fields.String, missing=[]), - "resize": fields.Dict(missing=None), # TODO: Validate keys + "bayer": fields.Boolean( + missing=False, description="Store raw bayer data in file" + ), + "metadata": fields.Dict(missing={}, example={"Client": "SwaggerUI"}), + "tags": fields.List(fields.String, missing=[], example=["docs"]), + "resize": fields.Dict( + missing=None, example={"width": 640, "height": 480} + ), # TODO: Validate keys } ) @marshal_with(capture_schema) @@ -85,6 +91,9 @@ class GPUPreviewStartAPI(Resource): """ def post(self): + """ + Start the onboard GPU preview. + """ microscope = find_device("openflexure_microscope") payload = JsonResponse(request) @@ -104,11 +113,10 @@ class GPUPreviewStartAPI(Resource): class GPUPreviewStopAPI(Resource): - """ - Start the onboard GPU preview. - """ - def post(self): + """ + Stop the onboard GPU preview. + """ microscope = find_device("openflexure_microscope") microscope.camera.stop_preview() return jsonify(microscope.state) diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index e1df4553..e04f448c 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -1,6 +1,13 @@ from openflexure_microscope.api.utilities import JsonResponse from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.decorators import ( + use_args, + marshal_with, + doc, +) +from openflexure_microscope.common.flask_labthings import fields + from openflexure_microscope.utilities import axes_to_array, filter_dict from flask import Blueprint, jsonify, request @@ -9,21 +16,23 @@ import logging class MoveStageAPI(Resource): - """ - Handle stage movements. - """ - - def post(self): + @use_args( + { + "absolute": fields.Boolean(default=False, example=False), + "x": fields.Int(default=0, example=100), + "y": fields.Int(default=0, example=100), + "z": fields.Int(default=0, example=20), + } + ) + def post(self, args): + """ + Move the microscope stage in x, y, z + """ microscope = find_device("openflexure_microscope") - # Create response object - payload = JsonResponse(request) - logging.debug(payload.json) # Handle absolute positioning (calculate a relative move from current position and target) - if (payload.param("absolute") is True) and ( - microscope.stage - ): # Only if stage exists - target_position = axes_to_array(payload.json, ["x", "y", "z"]) + if (args.get("absolute")) and (microscope.stage): # Only if stage exists + target_position = axes_to_array(args, ["x", "y", "z"]) logging.debug("TARGET: {}".format(target_position)) position = [ target_position[i] - microscope.stage.position[i] for i in range(3) @@ -32,7 +41,7 @@ class MoveStageAPI(Resource): else: # Get coordinates from payload - position = axes_to_array(payload.json, ["x", "y", "z"], [0, 0, 0]) + position = axes_to_array(args, ["x", "y", "z"], [0, 0, 0]) logging.debug(position) @@ -48,11 +57,11 @@ class MoveStageAPI(Resource): class ZeroStageAPI(Resource): - """ - Zero stage coordinates - """ - def post(self): + """ + Zero the stage coordinates. + Does not move the stage, but rather makes the current position read as [0, 0, 0] + """ microscope = find_device("openflexure_microscope") microscope.stage.zero_position() From eaf2fc5f7dc4877c2b117db23067fe41c8431998 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 7 Jan 2020 21:23:42 +0000 Subject: [PATCH 081/122] Enable tag inheritance --- openflexure_microscope/api/v2/views/actions/camera.py | 1 + openflexure_microscope/common/flask_labthings/spec.py | 7 +++++++ .../common/labthings_core/utilities.py | 10 +++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 70b00380..69e6016b 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -42,6 +42,7 @@ class CaptureAPI(Resource): ) @marshal_with(capture_schema) @doc_response(200, description="Capture successful") + @doc(tags=["foo"]) def post(self, args): """ Create a new capture diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec.py index 2ccfc042..7a85c55f 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -37,6 +37,12 @@ def view2path(rule: str, view: Resource, spec: APISpec): def view2operations(view: Resource, spec: APISpec): + # Operations inherit tags from parent + inherited_tags = [] + if hasattr(view, "__apispec__"): + inherited_tags = getattr(view, "__apispec__").get("tags", []) + + # Build dictionary of operations (HTTP methods) ops = {} for method in Resource.methods: if hasattr(view, method): @@ -47,6 +53,7 @@ def view2operations(view: Resource, spec: APISpec): { "description": get_docstring(getattr(view, method)), "summary": get_summary(getattr(view, method)), + "tags": inherited_tags, }, ) diff --git a/openflexure_microscope/common/labthings_core/utilities.py b/openflexure_microscope/common/labthings_core/utilities.py index 1d0ae94a..20a90796 100644 --- a/openflexure_microscope/common/labthings_core/utilities.py +++ b/openflexure_microscope/common/labthings_core/utilities.py @@ -19,10 +19,18 @@ def get_summary(obj): def rupdate(d, u): for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): + # Merge lists if they're present in both objects + if isinstance(v, list): + if not k in d: + d[k] = [] + if isinstance(d[k], list): + d[k].extend(v) + # Recursively merge dictionaries if the element is a dictionary + elif isinstance(v, collections.abc.Mapping): if not k in d: d[k] = {} d[k] = rupdate(d.get(k, {}), v) + # If not a list or dictionary, overwrite old value with new value else: d[k] = v return d From 5d1f3be6d77bd376c0316f65814905bd17062ae7 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 7 Jan 2020 21:23:49 +0000 Subject: [PATCH 082/122] Extra documentation --- openflexure_microscope/api/v2/views/actions/stage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index e04f448c..3680ecd6 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -18,7 +18,7 @@ import logging class MoveStageAPI(Resource): @use_args( { - "absolute": fields.Boolean(default=False, example=False), + "absolute": fields.Boolean(default=False, example=False, description="Move to an absolute position"), "x": fields.Int(default=0, example=100), "y": fields.Int(default=0, example=100), "z": fields.Int(default=0, example=20), From 3fef94dc954b9da96feda2bba4eb57514dd4ca1a Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 7 Jan 2020 22:25:00 +0000 Subject: [PATCH 083/122] Removed old swagger routes --- openflexure_microscope/common/flask_labthings/labthing.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index 567b0e89..8e3dc0e0 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -106,12 +106,7 @@ class LabThing(object): def _create_base_routes(self): # Add root representation self.app.add_url_rule(self._complete_url("/", ""), "rootrep", self.rootrep) - # Add thing description - # self.app.add_url_rule(self._complete_url("/td", ""), "td", self.td) - # Add swagger spec - # self.app.add_url_rule( - # self._complete_url("/swagger", ""), "swagger", self.swagger - # ) + # Add thing descriptions self.app.register_blueprint(docs_blueprint, url_prefix=self.url_prefix) # Add extension overview From eeb15453d046936561d3a03453b9472aa16784ef Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 7 Jan 2020 22:25:19 +0000 Subject: [PATCH 084/122] Moved eV GUI stuff out of LabThings --- .../api/example_extensions/ev_gui.py | 113 ++++++++++++++++++ .../{utilities.py => utilities/__init__.py} | 2 + openflexure_microscope/api/utilities/gui.py | 41 +++++++ .../common/flask_labthings/extensions.py | 40 ++----- .../common/flask_labthings/schema.py | 6 +- 5 files changed, 169 insertions(+), 33 deletions(-) create mode 100644 openflexure_microscope/api/example_extensions/ev_gui.py rename openflexure_microscope/api/{utilities.py => utilities/__init__.py} (99%) create mode 100644 openflexure_microscope/api/utilities/gui.py diff --git a/openflexure_microscope/api/example_extensions/ev_gui.py b/openflexure_microscope/api/example_extensions/ev_gui.py new file mode 100644 index 00000000..aa950e4b --- /dev/null +++ b/openflexure_microscope/api/example_extensions/ev_gui.py @@ -0,0 +1,113 @@ +from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.extensions import BaseExtension + +from openflexure_microscope.api.utilities.gui import expand_routes + +import logging + +from flask import jsonify + +# Some value that will change over time +# Here, we add 1 to it every time a GET request is made +val_int = 0 + + +def dynamic_form(): + return { + "id": "test-plugin", + "icon": "pets", + "forms": [ + { + "name": "Simple request", + "isCollapsible": False, + "isTask": False, + "selfUpdate": True, + "route": "/do", + "submitLabel": "Do things", + "schema": [ + { + "fieldType": "numberInput", + "name": "val_int", + "label": "Number value", + "minValue": 0, + }, + { + "fieldType": "htmlBlock", + "name": "html_block", + "content": f"Value is:
{val_int}.", # We dynamically use the val_int variable + }, + ], + } + ], + } + + +# Alternate form without any dynamic parts +static_form = { + "id": "test-plugin", + "icon": "pets", + "forms": [ + { + "name": "Simple request", + "isCollapsible": False, + "isTask": False, + "selfUpdate": True, + "route": "/do", + "submitLabel": "Do things", + "schema": [ + { + "fieldType": "numberInput", + "name": "val_int", + "label": "Number value", + "minValue": 0, + }, + { + "fieldType": "htmlBlock", + "name": "html_block", + "content": f"Value is fixed.", + }, + ], + } + ], +} + + +class TestAPIView(Resource): + def get(self): + global val_int + val_int += 1 + return jsonify({"val": True}) + + +class TestDoAPIView(Resource): + def post(self): + global val_int + val_int += 1 + return jsonify({"val": True}) + + +# Using the dynamic form +dynamic_test_extension_v2 = BaseExtension("dynamic_test_extension") +dynamic_test_extension_v2.add_view( + TestAPIView, "/get", endpoint="dynamic_test_extension_get" +) +dynamic_test_extension_v2.add_view( + TestDoAPIView, "/do", endpoint="dynamic_test_extension_do" +) + +dynamic_test_extension_v2.add_meta( + "gui", expand_routes(dynamic_form, dynamic_test_extension_v2) +) + + +# Using the static form +static_test_extension_v2 = BaseExtension("static_test_extension") +static_test_extension_v2.add_view( + TestAPIView, "/get", endpoint="static_test_extension_get" +) +static_test_extension_v2.add_view( + TestDoAPIView, "/do", endpoint="static_test_extension_do" +) +static_test_extension_v2.add_meta( + "gui", expand_routes(static_form, static_test_extension_v2) +) diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities/__init__.py similarity index 99% rename from openflexure_microscope/api/utilities.py rename to openflexure_microscope/api/utilities/__init__.py index 1e29cc3a..4b5a7cee 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities/__init__.py @@ -4,6 +4,8 @@ import errno from werkzeug.exceptions import BadRequest from flask import url_for, Blueprint, current_app +from . import gui + def view_class_from_endpoint(endpoint: str): return current_app.view_functions[endpoint].view_class diff --git a/openflexure_microscope/api/utilities/gui.py b/openflexure_microscope/api/utilities/gui.py new file mode 100644 index 00000000..170f8657 --- /dev/null +++ b/openflexure_microscope/api/utilities/gui.py @@ -0,0 +1,41 @@ +import copy +import logging +from functools import wraps + + +def expand_routes_from_dict(gui_description, extension_object): + api_gui = copy.deepcopy(gui_description) + if "forms" in gui_description and isinstance(api_gui["forms"], list): + for form in api_gui["forms"]: + if "route" in form and form["route"] in extension_object._rules.keys(): + form["route"] = extension_object._rules[form["route"]]["rule"] + else: + logging.warn( + "No valid expandable route found for {}".format(form["route"]) + ) + return api_gui + + +def expand_routes_from_func(func, extension_object): + @wraps(func) + def wrapped(*args, **kwargs): + return expand_routes_from_dict(func(*args, **kwargs), extension_object) + + return wrapped + + +def expand_routes(gui_description, extension_object): + # If given a function that generates a GUI dictionary + if callable(gui_description): + # Wrap in the route expander + return expand_routes_from_func(gui_description, extension_object) + # If given a dictionary directly + elif isinstance(gui_description, dict): + # Build a GUI generator function + def gui_description_func(): + return gui_description + + # Wrap in the route expander + return expand_routes_from_func(gui_description_func, extension_object) + else: + raise RuntimeError("GUI description must be a function or a dictionary") diff --git a/openflexure_microscope/common/flask_labthings/extensions.py b/openflexure_microscope/common/flask_labthings/extensions.py index 53b61aec..aa75e910 100644 --- a/openflexure_microscope/common/flask_labthings/extensions.py +++ b/openflexure_microscope/common/flask_labthings/extensions.py @@ -26,6 +26,7 @@ class BaseExtension: {} ) # Key: Full, Python-safe ID. Val: Original rule, and view class self._rules = {} # Key: Original rule. Val: View class + self._meta = {} # Extra metadata to add to the extension description self._gui = None self._cls = str(self) # String description of extension instance @@ -71,36 +72,17 @@ class BaseExtension: self.properties.append(view_class) @property - def gui(self): - # 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." - ) + def meta(self): + d = {} + for k, v in self._meta.items(): + if callable(v): + d[k] = v() + else: + d[k] = v + return d - 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 + def add_meta(self, key, val): + self._meta[key] = val @property def _name(self): diff --git a/openflexure_microscope/common/flask_labthings/schema.py b/openflexure_microscope/common/flask_labthings/schema.py index bc2691b5..7a8166e7 100644 --- a/openflexure_microscope/common/flask_labthings/schema.py +++ b/openflexure_microscope/common/flask_labthings/schema.py @@ -71,7 +71,7 @@ class ExtensionSchema(Schema): name = fields.String(data_key="title") _name_python_safe = fields.String(data_key="pythonName") _cls = fields.String(data_key="pythonObject") - gui = fields.Dict() + meta = fields.Dict() description = fields.String() links = fields.Dict() @@ -81,12 +81,10 @@ class ExtensionSchema(Schema): d = {} for view_id, view_data in data.views.items(): view_cls = view_data["view"] - view_kwargs = view_data["kwargs"] view_rule = view_data["rule"] # Make links dictionary if it doesn't yet exist d[view_id] = { - "href": url_for(EXTENSION_LIST_ENDPOINT, **view_kwargs, _external=True) - + view_rule, + "href": url_for(EXTENSION_LIST_ENDPOINT, _external=True) + view_rule, **description_from_view(view_cls), } From d12fd3e02c475f91a7f891b3c22bfa2130df5e70 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 7 Jan 2020 22:28:51 +0000 Subject: [PATCH 085/122] Removed old _gui property --- openflexure_microscope/common/flask_labthings/extensions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openflexure_microscope/common/flask_labthings/extensions.py b/openflexure_microscope/common/flask_labthings/extensions.py index aa75e910..94227c43 100644 --- a/openflexure_microscope/common/flask_labthings/extensions.py +++ b/openflexure_microscope/common/flask_labthings/extensions.py @@ -27,7 +27,6 @@ class BaseExtension: ) # Key: Full, Python-safe ID. Val: Original rule, and view class self._rules = {} # Key: Original rule. Val: View class self._meta = {} # Extra metadata to add to the extension description - self._gui = None self._cls = str(self) # String description of extension instance From 3cbd0416d0408286beac8d21fba353d1aefaa312 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 8 Jan 2020 18:38:52 +0000 Subject: [PATCH 086/122] Created @ltaction and @ltproperty decorators --- .../api/v2/views/actions/camera.py | 6 ++- .../common/flask_labthings/decorators.py | 27 ++++++++++++++ .../common/flask_labthings/labthing.py | 37 +++++++------------ .../common/flask_labthings/spec.py | 3 ++ 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 69e6016b..4169e042 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -5,6 +5,8 @@ from openflexure_microscope.common.flask_labthings.decorators import ( use_args, marshal_with, doc, + tag, + ltaction, doc_response, ) @@ -17,7 +19,7 @@ import logging from flask import jsonify, request, abort, url_for, redirect, send_file -@doc(tags=["actions"]) +@ltaction class CaptureAPI(Resource): """ Create a new image capture. @@ -42,7 +44,7 @@ class CaptureAPI(Resource): ) @marshal_with(capture_schema) @doc_response(200, description="Capture successful") - @doc(tags=["foo"]) + @tag("foo") def post(self, args): """ Create a new capture diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index d3aafa16..67e56eee 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -71,6 +71,18 @@ def marshal_task(f): return wrapper +def ltaction(f): + # Pass params to call function attribute for external access + update_spec(f, {"tags": ["actions"]}) + update_spec(f, {"_groups": ["actions"]}) + return f + +def ltproperty(f): + # Pass params to call function attribute for external access + update_spec(f, {"tags": ["properties"]}) + update_spec(f, {"_groups": ["properties"]}) + return f + class use_args(object): def __init__(self, schema, **kwargs): """ @@ -106,6 +118,21 @@ class doc(object): return f +class tag(object): + def __init__(self, tags): + if isinstance(tags, str): + self.tags = [tags] + elif isinstance(tags, list) and all([isinstance(e, str) for e in tags]): + self.tags = tags + else: + raise TypeError("Tags must be a string or list of strings") + + def __call__(self, f): + # Pass params to call function attribute for external access + update_spec(f, {"tags": self.tags}) + return f + + class doc_response(object): def __init__(self, code, description=None, **kwargs): self.code = code diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index 8e3dc0e0..58bbe574 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -6,7 +6,7 @@ from . import EXTENSION_NAME # TODO: Move into .names from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT from .extensions import BaseExtension from .utilities import description_from_view -from .spec import view2path +from .spec import view2path, get_spec from .views.extensions import ExtensionList from .views.tasks import TaskList, TaskResource @@ -159,20 +159,12 @@ class LabThing(object): 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." - ) + logging.warning("register_property is deprecated. Use the @ltproperty class decorator instead.") + pass 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." - ) + logging.warning("register_action is deprecated. Use the @ltaction class decorator instead.") + pass def add_resource(self, resource, *urls, endpoint=None, **kwargs): """Adds a resource to the api. @@ -208,17 +200,6 @@ class LabThing(object): 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 @@ -255,6 +236,14 @@ class LabThing(object): # Add the resource to our API spec self.spec.path(**view2path(rule, resource, self.spec)) + # Handle resource groups listed in API spec + view_spec = get_spec(resource) + view_groups = view_spec.get("_groups", {}) + if "actions" in view_groups: + self.actions[resource.endpoint] = resource + if "properties" in view_groups: + self.properties[resource.endpoint] = resource + ### Utilities def url_for(self, resource, **values): diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec.py index 7a85c55f..d21687a5 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -20,6 +20,9 @@ def update_spec(obj, spec): rupdate(obj.__apispec__, spec) return obj.__apispec__ +def get_spec(obj): + obj.__apispec__ = obj.__dict__.get("__apispec__", {}) + return obj.__apispec__ def view2path(rule: str, view: Resource, spec: APISpec): params = { From e44da64ed473f2a35e17bf1a5d753b2c51597c81 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 8 Jan 2020 18:43:07 +0000 Subject: [PATCH 087/122] Improved property and action style --- .../api/v2/views/actions/camera.py | 4 ++-- .../common/flask_labthings/decorators.py | 20 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 4169e042..4e8ef19e 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -6,7 +6,7 @@ from openflexure_microscope.common.flask_labthings.decorators import ( marshal_with, doc, tag, - ltaction, + ThingAction, doc_response, ) @@ -19,7 +19,7 @@ import logging from flask import jsonify, request, abort, url_for, redirect, send_file -@ltaction +@ThingAction class CaptureAPI(Resource): """ Create a new image capture. diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index 67e56eee..94ace85b 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -71,17 +71,21 @@ def marshal_task(f): return wrapper -def ltaction(f): +def ThingAction(viewcls): # Pass params to call function attribute for external access - update_spec(f, {"tags": ["actions"]}) - update_spec(f, {"_groups": ["actions"]}) - return f + update_spec(viewcls, {"tags": ["actions"]}) + update_spec(viewcls, {"_groups": ["actions"]}) + return viewcls -def ltproperty(f): +thing_action = ThingAction + +def ThingProperty(viewcls): # Pass params to call function attribute for external access - update_spec(f, {"tags": ["properties"]}) - update_spec(f, {"_groups": ["properties"]}) - return f + update_spec(viewcls, {"tags": ["properties"]}) + update_spec(viewcls, {"_groups": ["properties"]}) + return viewcls + +thing_property = ThingProperty class use_args(object): def __init__(self, schema, **kwargs): From cf1993937994b68a63a87a77ab9739d2308ba1d2 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 8 Jan 2020 18:44:10 +0000 Subject: [PATCH 088/122] Added class style to decorators that can be applied to view classes --- openflexure_microscope/common/flask_labthings/decorators.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index 94ace85b..2ebdc540 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -112,7 +112,7 @@ class use_kwargs(use_args): use_args.__init__(self, schema, **kwargs) -class doc(object): +class Doc(object): def __init__(self, **kwargs): self.kwargs = kwargs @@ -121,8 +121,9 @@ class doc(object): update_spec(f, self.kwargs) return f +doc = Doc -class tag(object): +class Tag(object): def __init__(self, tags): if isinstance(tags, str): self.tags = [tags] @@ -136,6 +137,7 @@ class tag(object): update_spec(f, {"tags": self.tags}) return f +tag = Tag class doc_response(object): def __init__(self, code, description=None, **kwargs): From 8a282db12bac930324d0697270b12c43438c2b58 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 9 Jan 2020 20:04:22 +0000 Subject: [PATCH 089/122] Moved to collision-robust device name --- openflexure_microscope/api/app.py | 2 +- .../api/default_extensions/autofocus.py | 6 +++--- .../api/default_extensions/scan.py | 2 +- .../api/default_extensions/zip_builder.py | 2 +- .../api/v2/views/actions/camera.py | 6 +++--- .../api/v2/views/actions/stage.py | 4 ++-- .../api/v2/views/captures.py | 18 +++++++++--------- openflexure_microscope/api/v2/views/state.py | 12 ++++++------ openflexure_microscope/api/v2/views/streams.py | 4 ++-- 9 files changed, 28 insertions(+), 28 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index dad8d1c9..afbae740 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -69,7 +69,7 @@ app, labthing = create_app( app.json_encoder = JSONEncoder # Attach lab devices -labthing.register_device(api_microscope, "openflexure_microscope") +labthing.register_device(api_microscope, "org.openflexure.microscope") # Attach extensions if not os.path.isfile(USER_EXTENSIONS_PATH): diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index 7e656543..913ce367 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -290,7 +290,7 @@ def fast_up_down_up_autofocus( class MeasureSharpnessAPI(Resource): def post(self): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") if not microscope: abort(503, "No microscope connected. Unable to measure sharpness.") @@ -305,7 +305,7 @@ class AutofocusAPI(Resource): def post(self): payload = JsonResponse(request) - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") if not microscope: abort(503, "No microscope connected. Unable to autofocus.") @@ -331,7 +331,7 @@ class FastAutofocusAPI(Resource): def post(self): payload = JsonResponse(request) - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") if not microscope: abort(503, "No microscope connected. Unable to autofocus.") diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index dd9f4986..7cff8621 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -359,7 +359,7 @@ class TileScanAPI(Resource): ) @marshal_task def post(self, args): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") if not microscope: abort(503, "No microscope connected. Unable to autofocus.") diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 45b119a2..286789fc 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -95,7 +95,7 @@ class ZipBuilderAPIView(Resource): def post(self): ids = list(JsonResponse(request).json) - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") task = taskify(default_zip_manager.build_zip_from_capture_ids)(microscope, ids) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 4e8ef19e..16f3313d 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -49,7 +49,7 @@ class CaptureAPI(Resource): """ Create a new capture """ - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") resize = args.get("resize", None) if resize: @@ -97,7 +97,7 @@ class GPUPreviewStartAPI(Resource): """ Start the onboard GPU preview. """ - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") payload = JsonResponse(request) window = payload.param("window", default=[]) @@ -120,6 +120,6 @@ class GPUPreviewStopAPI(Resource): """ Stop the onboard GPU preview. """ - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") microscope.camera.stop_preview() return jsonify(microscope.state) diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index 3680ecd6..5aa3e501 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -28,7 +28,7 @@ class MoveStageAPI(Resource): """ Move the microscope stage in x, y, z """ - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") # Handle absolute positioning (calculate a relative move from current position and target) if (args.get("absolute")) and (microscope.stage): # Only if stage exists @@ -62,7 +62,7 @@ class ZeroStageAPI(Resource): Zero the stage coordinates. Does not move the stage, but rather makes the current position read as [0, 0, 0] """ - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") microscope.stage.zero_position() return jsonify(microscope.status["stage"]) diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index bfb31e87..819b46e0 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -71,7 +71,7 @@ class CaptureList(Resource): @marshal_with(CaptureSchema(many=True)) def get(self): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") image_list = microscope.camera.images return image_list @@ -83,7 +83,7 @@ class CaptureResource(Resource): @marshal_with(CaptureSchema()) def get(self, id): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -92,7 +92,7 @@ class CaptureResource(Resource): return capture_obj def delete(self, id): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -110,7 +110,7 @@ class CaptureDownload(Resource): def get(self, id, filename): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -146,7 +146,7 @@ class CaptureTags(Resource): def get(self, id): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -156,7 +156,7 @@ class CaptureTags(Resource): def put(self, id): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -173,7 +173,7 @@ class CaptureTags(Resource): def delete(self, capture_id): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -196,7 +196,7 @@ class CaptureMetadata(Resource): """ def get(self, id): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -205,7 +205,7 @@ class CaptureMetadata(Resource): return jsonify(capture_obj.metadata) def put(self, id): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py index e1ae2745..09408c00 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/state.py @@ -15,11 +15,11 @@ import logging class SettingsProperty(Resource): def get(self): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") return jsonify(microscope.read_settings()) def put(self): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") payload = JsonResponse(request) logging.debug("Updating settings from PUT request:") @@ -33,7 +33,7 @@ class SettingsProperty(Resource): class NestedSettingsProperty(Resource): def get(self, route): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") keys = route.split("/") try: @@ -44,7 +44,7 @@ class NestedSettingsProperty(Resource): return jsonify(value) def put(self, route): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") keys = route.split("/") payload = JsonResponse(request) @@ -59,13 +59,13 @@ class NestedSettingsProperty(Resource): class StatusProperty(Resource): def get(self): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") return jsonify(microscope.status) class NestedStatusProperty(Resource): def get(self, route): - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") keys = route.split("/") try: diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index 70cb8d3a..3ba63cca 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -22,7 +22,7 @@ class MjpegStream(Resource): """ MJPEG stream from the microscope camera """ - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") # Restart stream worker thread microscope.camera.start_worker() @@ -46,7 +46,7 @@ class SnapshotStream(Resource): :>header Content-Type: image/jpeg :status 200: stream active """ - microscope = find_device("openflexure_microscope") + microscope = find_device("org.openflexure.microscope") # Restart stream worker thread microscope.camera.start_worker() From 4848593d8de386a676ad2289d76652b17bf91056 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 9 Jan 2020 22:34:45 +0000 Subject: [PATCH 090/122] Started smarter type conversion --- .../common/flask_labthings/fields.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/openflexure_microscope/common/flask_labthings/fields.py b/openflexure_microscope/common/flask_labthings/fields.py index b8eb3d36..46e04e86 100644 --- a/openflexure_microscope/common/flask_labthings/fields.py +++ b/openflexure_microscope/common/flask_labthings/fields.py @@ -1 +1,57 @@ +# Marshmallow fields from marshmallow.fields import * + +# Marshmallow fields to JSON schema types +# Note: We shouldn't ever need to use this directly. We should go via the apispec converter +from apispec.ext.marshmallow.field_converter import DEFAULT_FIELD_MAPPING + +# Extra standard library Python types +from datetime import date, datetime, time, timedelta +from decimal import Decimal +from typing import Dict, List, Tuple, Union +from uuid import UUID + +# TODO: Use this to convert arbitrary dictionary into its own schema, for W3C TD +# Python types to Marshmallow fields +DEFAULT_TYPE_MAPPING = { + bool: Boolean, + date: Date, + datetime: DateTime, + Decimal: Decimal, + float: Float, + int: Integer, + str: String, + time: Time, + timedelta: TimeDelta, + UUID: UUID, + dict: Dict, + Dict: Dict, +} + +# Functions to handle conversion of common Python types into serialisable Python types + + +def ndarray_to_list(o): + return o.tolist() + + +def to_int(o): + return int(o) + + +def to_float(o): + return float(o) + + +def to_string(o): + return str(o) + + +# Map of Python type conversions +DEFAULT_TYPE_CONVERSIONS = { + "numpy.ndarray": ndarray_to_list, + "numpy.int": to_int, + "fractions.Fraction": to_float, +} + +# Use with [x.__module__+"."+x.__name__ for x in inspect.getmro(type(POSSIBLE_MATCHER))] From 5895a0f516becf12aa277b3501c9901293be7d7b Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 9 Jan 2020 22:37:41 +0000 Subject: [PATCH 091/122] Added notes on type conversion --- .../common/flask_labthings/fields.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/fields.py b/openflexure_microscope/common/flask_labthings/fields.py index 46e04e86..37b3533a 100644 --- a/openflexure_microscope/common/flask_labthings/fields.py +++ b/openflexure_microscope/common/flask_labthings/fields.py @@ -11,7 +11,13 @@ from decimal import Decimal from typing import Dict, List, Tuple, Union from uuid import UUID -# TODO: Use this to convert arbitrary dictionary into its own schema, for W3C TD +""" +TODO: Use this to convert arbitrary dictionary into its own schema, for W3C TD + +First: Convert Python non-builtins to builtins using DEFAULT_BUILTIN_CONVERSIONS +Then match types of each element to Field using DEFAULT_TYPE_MAPPING +Finally convert Fields to JSON using converter (preferred due to extra metadata), or DEFAULT_FIELD_MAPPING +""" # Python types to Marshmallow fields DEFAULT_TYPE_MAPPING = { bool: Boolean, @@ -48,7 +54,7 @@ def to_string(o): # Map of Python type conversions -DEFAULT_TYPE_CONVERSIONS = { +DEFAULT_BUILTIN_CONVERSIONS = { "numpy.ndarray": ndarray_to_list, "numpy.int": to_int, "fractions.Fraction": to_float, From b26c89652869e903333e83271ab17ebda06bf02f Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 9 Jan 2020 22:37:41 +0000 Subject: [PATCH 092/122] Added notes on type conversion --- .../common/flask_labthings/fields.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/fields.py b/openflexure_microscope/common/flask_labthings/fields.py index 46e04e86..e7ae7bbd 100644 --- a/openflexure_microscope/common/flask_labthings/fields.py +++ b/openflexure_microscope/common/flask_labthings/fields.py @@ -11,7 +11,13 @@ from decimal import Decimal from typing import Dict, List, Tuple, Union from uuid import UUID -# TODO: Use this to convert arbitrary dictionary into its own schema, for W3C TD +""" +TODO: Use this to convert arbitrary dictionary into its own schema, for W3C TD + +First: Convert Python non-builtins to builtins using DEFAULT_BUILTIN_CONVERSIONS +Then match types of each element to Field using DEFAULT_TYPE_MAPPING +Finally convert Fields to JSON using converter (preferred due to extra metadata), or DEFAULT_FIELD_MAPPING +""" # Python types to Marshmallow fields DEFAULT_TYPE_MAPPING = { bool: Boolean, @@ -48,10 +54,11 @@ def to_string(o): # Map of Python type conversions -DEFAULT_TYPE_CONVERSIONS = { +DEFAULT_BUILTIN_CONVERSIONS = { "numpy.ndarray": ndarray_to_list, "numpy.int": to_int, "fractions.Fraction": to_float, } # Use with [x.__module__+"."+x.__name__ for x in inspect.getmro(type(POSSIBLE_MATCHER))] +# Resulting array will contain strings with the same format as keys in DEFAULT_BUILTIN_CONVERSIONS From 4862c7a47482d55650ca41f21962718f2e9e690d Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 10 Jan 2020 19:44:50 +0000 Subject: [PATCH 093/122] Moved conflicting code into 'types' submodule --- .../common/flask_labthings/fields.py | 62 ----------------- .../common/flask_labthings/types.py | 66 +++++++++++++++++++ 2 files changed, 66 insertions(+), 62 deletions(-) create mode 100644 openflexure_microscope/common/flask_labthings/types.py diff --git a/openflexure_microscope/common/flask_labthings/fields.py b/openflexure_microscope/common/flask_labthings/fields.py index e7ae7bbd..68149fcd 100644 --- a/openflexure_microscope/common/flask_labthings/fields.py +++ b/openflexure_microscope/common/flask_labthings/fields.py @@ -1,64 +1,2 @@ # Marshmallow fields from marshmallow.fields import * - -# Marshmallow fields to JSON schema types -# Note: We shouldn't ever need to use this directly. We should go via the apispec converter -from apispec.ext.marshmallow.field_converter import DEFAULT_FIELD_MAPPING - -# Extra standard library Python types -from datetime import date, datetime, time, timedelta -from decimal import Decimal -from typing import Dict, List, Tuple, Union -from uuid import UUID - -""" -TODO: Use this to convert arbitrary dictionary into its own schema, for W3C TD - -First: Convert Python non-builtins to builtins using DEFAULT_BUILTIN_CONVERSIONS -Then match types of each element to Field using DEFAULT_TYPE_MAPPING -Finally convert Fields to JSON using converter (preferred due to extra metadata), or DEFAULT_FIELD_MAPPING -""" -# Python types to Marshmallow fields -DEFAULT_TYPE_MAPPING = { - bool: Boolean, - date: Date, - datetime: DateTime, - Decimal: Decimal, - float: Float, - int: Integer, - str: String, - time: Time, - timedelta: TimeDelta, - UUID: UUID, - dict: Dict, - Dict: Dict, -} - -# Functions to handle conversion of common Python types into serialisable Python types - - -def ndarray_to_list(o): - return o.tolist() - - -def to_int(o): - return int(o) - - -def to_float(o): - return float(o) - - -def to_string(o): - return str(o) - - -# Map of Python type conversions -DEFAULT_BUILTIN_CONVERSIONS = { - "numpy.ndarray": ndarray_to_list, - "numpy.int": to_int, - "fractions.Fraction": to_float, -} - -# Use with [x.__module__+"."+x.__name__ for x in inspect.getmro(type(POSSIBLE_MATCHER))] -# Resulting array will contain strings with the same format as keys in DEFAULT_BUILTIN_CONVERSIONS diff --git a/openflexure_microscope/common/flask_labthings/types.py b/openflexure_microscope/common/flask_labthings/types.py new file mode 100644 index 00000000..b0304368 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/types.py @@ -0,0 +1,66 @@ +# Marshmallow fields to JSON schema types +# Note: We shouldn't ever need to use this directly. We should go via the apispec converter +from apispec.ext.marshmallow.field_converter import DEFAULT_FIELD_MAPPING + +from . import fields + +# Extra standard library Python types +from datetime import date, datetime, time, timedelta +from decimal import Decimal +from typing import Dict, List, Tuple, Union +from uuid import UUID + +""" +TODO: Use this to convert arbitrary dictionary into its own schema, for W3C TD + +First: Convert Python non-builtins to builtins using DEFAULT_BUILTIN_CONVERSIONS +Then match types of each element to Field using DEFAULT_TYPE_MAPPING +Finally convert Fields to JSON using converter (preferred due to extra metadata), or DEFAULT_FIELD_MAPPING +""" +# Python types to Marshmallow fields +DEFAULT_TYPE_MAPPING = { + bool: fields.Boolean, + date: fields.Date, + datetime: fields.DateTime, + Decimal: fields.Decimal, + float: fields.Float, + int: fields.Integer, + str: fields.String, + time: fields.Time, + timedelta: fields.TimeDelta, + UUID: fields.UUID, + dict: fields.Dict, + Dict: fields.Dict, +} + +# Functions to handle conversion of common Python types into serialisable Python types + + +def ndarray_to_list(o): + return o.tolist() + + +def to_int(o): + return int(o) + + +def to_float(o): + return float(o) + + +def to_string(o): + return str(o) + + +# Map of Python type conversions +DEFAULT_BUILTIN_CONVERSIONS = { + "numpy.ndarray": ndarray_to_list, + "numpy.int": to_int, + "fractions.Fraction": to_float, +} + +# TODO: Deserialiser with inverse defaults +# TODO: Option to switch to .npy serialisation/deserialisation (or look for a better common array format) + +# Use with [x.__module__+"."+x.__name__ for x in inspect.getmro(type(POSSIBLE_MATCHER))] +# Resulting array will contain strings with the same format as keys in DEFAULT_BUILTIN_CONVERSIONS From 23c10dc292809ddf31449c306fe708935f6051a6 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 10 Jan 2020 19:55:41 +0000 Subject: [PATCH 094/122] Added LabThings decorators --- .../api/v2/views/actions/camera.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 16f3313d..e896f180 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -44,7 +44,6 @@ class CaptureAPI(Resource): ) @marshal_with(capture_schema) @doc_response(200, description="Capture successful") - @tag("foo") def post(self, args): """ Create a new capture @@ -86,6 +85,7 @@ class CaptureAPI(Resource): return output +@ThingAction class GPUPreviewStartAPI(Resource): """ Start the onboard GPU preview. @@ -93,14 +93,16 @@ class GPUPreviewStartAPI(Resource): in the format ``[x, y, width, height]``. """ - def post(self): + @use_args( + {"window": fields.List(fields.Integer, missing=[], example=[0, 0, 640, 480])} + ) + def post(self, args): """ Start the onboard GPU preview. """ microscope = find_device("org.openflexure.microscope") - payload = JsonResponse(request) - window = payload.param("window", default=[]) + window = args.get("window") logging.debug(window) if len(window) != 4: @@ -112,9 +114,11 @@ class GPUPreviewStartAPI(Resource): microscope.camera.start_preview(fullscreen=fullscreen, window=window) + # TODO: Make schema for microscope state return jsonify(microscope.state) +@ThingAction class GPUPreviewStopAPI(Resource): def post(self): """ @@ -122,4 +126,5 @@ class GPUPreviewStopAPI(Resource): """ microscope = find_device("org.openflexure.microscope") microscope.camera.stop_preview() + # TODO: Make schema for microscope state return jsonify(microscope.state) From 5616e76f505cfb6e3ef3b693914de53cca953992 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 10 Jan 2020 19:58:30 +0000 Subject: [PATCH 095/122] Use LabThings decorators --- openflexure_microscope/api/v2/views/actions/stage.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index 5aa3e501..cad4a776 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -5,6 +5,7 @@ from openflexure_microscope.common.flask_labthings.decorators import ( use_args, marshal_with, doc, + ThingAction, ) from openflexure_microscope.common.flask_labthings import fields @@ -15,10 +16,13 @@ from flask import Blueprint, jsonify, request import logging +@ThingAction class MoveStageAPI(Resource): @use_args( { - "absolute": fields.Boolean(default=False, example=False, description="Move to an absolute position"), + "absolute": fields.Boolean( + default=False, example=False, description="Move to an absolute position" + ), "x": fields.Int(default=0, example=100), "y": fields.Int(default=0, example=100), "z": fields.Int(default=0, example=20), @@ -53,9 +57,11 @@ class MoveStageAPI(Resource): else: logging.warning("Unable to move. No stage found.") + # TODO: Make schema for microscope status return jsonify(microscope.status["stage"]["position"]) +@ThingAction class ZeroStageAPI(Resource): def post(self): """ @@ -65,4 +71,5 @@ class ZeroStageAPI(Resource): microscope = find_device("org.openflexure.microscope") microscope.stage.zero_position() + # TODO: Make schema for microscope status return jsonify(microscope.status["stage"]) From 3f259591507e20a457cffcbb27a1b89f38d5519f Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 10 Jan 2020 19:58:45 +0000 Subject: [PATCH 096/122] Switch to return status, not deprecated state --- openflexure_microscope/api/v2/views/actions/camera.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index e896f180..cb458d9e 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -114,8 +114,8 @@ class GPUPreviewStartAPI(Resource): microscope.camera.start_preview(fullscreen=fullscreen, window=window) - # TODO: Make schema for microscope state - return jsonify(microscope.state) + # TODO: Make schema for microscope status + return jsonify(microscope.status) @ThingAction @@ -126,5 +126,5 @@ class GPUPreviewStopAPI(Resource): """ microscope = find_device("org.openflexure.microscope") microscope.camera.stop_preview() - # TODO: Make schema for microscope state - return jsonify(microscope.state) + # TODO: Make schema for microscope status + return jsonify(microscope.status) From 39866626dccb5366969c549d9c24aab7f3d62ed6 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 10 Jan 2020 20:02:36 +0000 Subject: [PATCH 097/122] Use LabThings decorators --- .../api/v2/views/actions/system.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/system.py b/openflexure_microscope/api/v2/views/actions/system.py index b148de7d..82b67b92 100644 --- a/openflexure_microscope/api/v2/views/actions/system.py +++ b/openflexure_microscope/api/v2/views/actions/system.py @@ -3,6 +3,11 @@ import subprocess import os from sys import platform +from openflexure_microscope.common.flask_labthings.decorators import ( + ThingAction, + doc_response, +) + def is_raspberrypi(raise_on_errors=False): """ @@ -12,34 +17,32 @@ def is_raspberrypi(raise_on_errors=False): return os.path.exists("/usr/bin/raspi-config") +@ThingAction class ShutdownAPI(Resource): """ Attempt to shutdown the device """ + @doc_response(201) def post(self): """ Attempt to shutdown the device - - .. :quickref: Actions; Shutdown - """ subprocess.Popen(["sudo", "shutdown", "-h", "now"]) return "{}", 201 +@ThingAction class RebootAPI(Resource): """ Attempt to reboot the device """ + @doc_response(201) def post(self): """ - Attempt to shutdown the device - - .. :quickref: Actions; Shutdown - + Attempt to reboot the device """ subprocess.Popen(["sudo", "shutdown", "-r", "now"]) From fe1704f22329f7aeb53e0079314abfa69d2b35db Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 10 Jan 2020 20:02:55 +0000 Subject: [PATCH 098/122] Fall back to POST docstring for Action description --- .../common/flask_labthings/views/docs/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py index 9fbf4e25..f154018d 100644 --- a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py +++ b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py @@ -48,7 +48,9 @@ class W3CThingDescriptionResource(Resource): for key, prop in current_labthing().actions.items(): actions[key] = {} actions[key]["title"] = prop.__name__ - actions[key]["description"] = get_docstring(prop) + actions[key]["description"] = get_docstring(prop) or ( + get_docstring(prop.post) if hasattr(prop, "post") else "" + ) actions[key]["links"] = [ {"href": current_labthing().url_for(prop, _external=True)} ] From 6caa663628dea981a20d46eb1c341572964676f0 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Sat, 11 Jan 2020 14:20:42 +0000 Subject: [PATCH 099/122] Added more info to TD properties --- .../common/flask_labthings/views/docs/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py index f154018d..3331f4eb 100644 --- a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py +++ b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py @@ -39,7 +39,14 @@ class W3CThingDescriptionResource(Resource): for key, prop in current_labthing().properties.items(): props[key] = {} props[key]["title"] = prop.__name__ - props[key]["description"] = get_docstring(prop) + # TODO: Get description from __apispec__ preferentially + props[key]["description"] = get_docstring(prop) or ( + get_docstring(prop.get) if hasattr(prop, "get") else "" + ) + props[key]["readOnly"] = not ( + hasattr(prop, "post") or hasattr(prop, "put") or hasattr(prop, "delete") + ) + props[key]["writeOnly"] = not hasattr(prop, "get") props[key]["links"] = [ {"href": current_labthing().url_for(prop, _external=True)} ] @@ -48,6 +55,7 @@ class W3CThingDescriptionResource(Resource): for key, prop in current_labthing().actions.items(): actions[key] = {} actions[key]["title"] = prop.__name__ + # TODO: Get description from __apispec__ preferentially actions[key]["description"] = get_docstring(prop) or ( get_docstring(prop.post) if hasattr(prop, "post") else "" ) @@ -56,6 +64,7 @@ class W3CThingDescriptionResource(Resource): ] td = { + "@context": "https://www.w3.org/2019/wot/td/v1", "id": url_for("labthings_docs.w3c_td", _external=True), "title": current_labthing().title, "description": current_labthing().description, From 7715c1930580e002470bf9ef1fcb8aca95655a9e Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Sat, 11 Jan 2020 14:20:54 +0000 Subject: [PATCH 100/122] Register as Thing properties --- openflexure_microscope/api/v2/views/state.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py index 09408c00..e9ad5c3a 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/state.py @@ -9,10 +9,13 @@ from openflexure_microscope.common.labthings_core.utilities import ( from openflexure_microscope.common.flask_labthings.find import find_device from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.decorators import ThingProperty + from flask import jsonify, request, abort import logging +@ThingProperty class SettingsProperty(Resource): def get(self): microscope = find_device("org.openflexure.microscope") @@ -57,6 +60,7 @@ class NestedSettingsProperty(Resource): return self.get(route) +@ThingProperty class StatusProperty(Resource): def get(self): microscope = find_device("org.openflexure.microscope") From a4196b6765c1e2a1cb2dc3272a848977658f7b37 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Sat, 11 Jan 2020 14:58:50 +0000 Subject: [PATCH 101/122] Refactored adding views, actions, props and components --- openflexure_microscope/api/app.py | 32 +++++-------- .../api/default_extensions/autofocus.py | 10 ++-- .../api/default_extensions/scan.py | 4 +- .../api/default_extensions/zip_builder.py | 7 ++- .../api/example_extensions/ev_gui.py | 6 +++ .../common/flask_labthings/decorators.py | 8 ++++ .../common/flask_labthings/labthing.py | 48 ++++++------------- .../common/flask_labthings/spec.py | 2 + .../flask_labthings/views/extensions.py | 8 +--- .../common/flask_labthings/views/tasks.py | 3 +- 10 files changed, 62 insertions(+), 66 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index afbae740..f90d24c1 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -69,7 +69,7 @@ app, labthing = create_app( app.json_encoder = JSONEncoder # Attach lab devices -labthing.register_device(api_microscope, "org.openflexure.microscope") +labthing.add_component(api_microscope, "org.openflexure.microscope") # Attach extensions if not os.path.isfile(USER_EXTENSIONS_PATH): @@ -78,33 +78,27 @@ for extension in find_extensions(USER_EXTENSIONS_PATH): labthing.register_extension(extension) # Attach captures resources -labthing.add_resource(views.CaptureList, f"/captures") -labthing.register_property(views.CaptureList) -labthing.add_resource(views.CaptureResource, f"/captures/") -labthing.add_resource(views.CaptureDownload, f"/captures//download/") -labthing.add_resource(views.CaptureTags, f"/captures//tags") -labthing.add_resource(views.CaptureMetadata, f"/captures//metadata") +labthing.add_view(views.CaptureList, f"/captures") +labthing.add_view(views.CaptureResource, f"/captures/") +labthing.add_view(views.CaptureDownload, f"/captures//download/") +labthing.add_view(views.CaptureTags, f"/captures//tags") +labthing.add_view(views.CaptureMetadata, f"/captures//metadata") # Attach settings and status resources -labthing.add_resource(views.SettingsProperty, f"/settings") -labthing.register_property(views.SettingsProperty) -labthing.add_resource(views.NestedSettingsProperty, "/settings/") -labthing.add_resource(views.StatusProperty, "/status") -labthing.register_property(views.StatusProperty) -labthing.add_resource(views.NestedStatusProperty, "/status/") +labthing.add_view(views.SettingsProperty, f"/settings") +labthing.add_view(views.NestedSettingsProperty, "/settings/") +labthing.add_view(views.StatusProperty, "/status") +labthing.add_view(views.NestedStatusProperty, "/status/") # Attach streams resources -labthing.add_resource(views.MjpegStream, f"/streams/mjpeg") -labthing.register_property(views.MjpegStream) -labthing.add_resource(views.SnapshotStream, f"/streams/snapshot") -labthing.register_property(views.SnapshotStream) +labthing.add_view(views.MjpegStream, f"/streams/mjpeg") +labthing.add_view(views.SnapshotStream, f"/streams/snapshot") # Attach microscope action resources for name, action in views.enabled_root_actions().items(): view_class = action["view_class"] rule = action["rule"] - labthing.add_resource(view_class, f"/actions{rule}") - labthing.register_action(view_class) + labthing.add_view(view_class, f"/actions{rule}") @app.route("/routes") diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index 913ce367..bfbe6cdc 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -1,6 +1,10 @@ from openflexure_microscope.common.flask_labthings.find import find_device from openflexure_microscope.common.flask_labthings.extensions import BaseExtension from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.decorators import ( + ThingAction, + ThingProperty, +) from openflexure_microscope.devel import JsonResponse, request, jsonify, taskify, abort from openflexure_microscope.utilities import set_properties @@ -298,6 +302,7 @@ class MeasureSharpnessAPI(Resource): return jsonify({"sharpness": measure_sharpness(microscope)}) +@ThingAction class AutofocusAPI(Resource): """ Run a standard autofocus @@ -324,6 +329,7 @@ class AutofocusAPI(Resource): abort(503, "No stage connected. Unable to autofocus.") +@ThingAction class FastAutofocusAPI(Resource): """ Run a fast autofocus @@ -359,9 +365,5 @@ autofocus_extension_v2.add_method(fast_autofocus, "fast_autofocus") autofocus_extension_v2.add_method(autofocus, "autofocus") autofocus_extension_v2.add_view(MeasureSharpnessAPI, "/measure_sharpness") - autofocus_extension_v2.add_view(AutofocusAPI, "/autofocus") -autofocus_extension_v2.register_action(AutofocusAPI) - autofocus_extension_v2.add_view(FastAutofocusAPI, "/fast_autofocus") -autofocus_extension_v2.register_action(FastAutofocusAPI) diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 7cff8621..214c0694 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -12,8 +12,8 @@ from openflexure_microscope.common.flask_labthings.find import ( from openflexure_microscope.common.flask_labthings.extensions import BaseExtension from openflexure_microscope.common.flask_labthings.decorators import ( marshal_task, - marshal_with, use_args, + ThingAction, ) from openflexure_microscope.common.flask_labthings import fields @@ -340,6 +340,7 @@ def stack( ### Web views +@ThingAction class TileScanAPI(Resource): @use_args( { @@ -398,4 +399,3 @@ class TileScanAPI(Resource): scan_extension_v2 = BaseExtension("scan") scan_extension_v2.add_view(TileScanAPI, "/tile") -scan_extension_v2.register_action(TileScanAPI) diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 286789fc..fa1ea199 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -17,6 +17,10 @@ import logging from openflexure_microscope.common.flask_labthings.find import find_device from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.extensions import BaseExtension +from openflexure_microscope.common.flask_labthings.decorators import ( + ThingAction, + ThingProperty, +) class ZipManager: @@ -91,6 +95,7 @@ class ZipManager: default_zip_manager = ZipManager() +@ThingAction class ZipBuilderAPIView(Resource): def post(self): @@ -103,6 +108,7 @@ class ZipBuilderAPIView(Resource): return jsonify(task.state), 201 +@ThingProperty class ZipListAPIView(Resource): def get(self): return jsonify(default_zip_manager.session_zips) @@ -146,4 +152,3 @@ zip_extension_v2.add_view(ZipGetterAPIView, "/get/") zip_extension_v2.add_view(ZipListAPIView, "/get") zip_extension_v2.add_view(ZipBuilderAPIView, "/build") -zip_extension_v2.register_action(ZipBuilderAPIView) diff --git a/openflexure_microscope/api/example_extensions/ev_gui.py b/openflexure_microscope/api/example_extensions/ev_gui.py index aa950e4b..561e4acf 100644 --- a/openflexure_microscope/api/example_extensions/ev_gui.py +++ b/openflexure_microscope/api/example_extensions/ev_gui.py @@ -1,5 +1,9 @@ from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.extensions import BaseExtension +from openflexure_microscope.common.flask_labthings.decorators import ( + ThingAction, + ThingProperty, +) from openflexure_microscope.api.utilities.gui import expand_routes @@ -72,6 +76,7 @@ static_form = { } +@ThingProperty class TestAPIView(Resource): def get(self): global val_int @@ -79,6 +84,7 @@ class TestAPIView(Resource): return jsonify({"val": True}) +@ThingAction class TestDoAPIView(Resource): def post(self): global val_int diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index 2ebdc540..e186a5e1 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -77,16 +77,20 @@ def ThingAction(viewcls): update_spec(viewcls, {"_groups": ["actions"]}) return viewcls + thing_action = ThingAction + def ThingProperty(viewcls): # Pass params to call function attribute for external access update_spec(viewcls, {"tags": ["properties"]}) update_spec(viewcls, {"_groups": ["properties"]}) return viewcls + thing_property = ThingProperty + class use_args(object): def __init__(self, schema, **kwargs): """ @@ -121,8 +125,10 @@ class Doc(object): update_spec(f, self.kwargs) return f + doc = Doc + class Tag(object): def __init__(self, tags): if isinstance(tags, str): @@ -137,8 +143,10 @@ class Tag(object): update_spec(f, {"tags": self.tags}) return f + tag = Tag + class doc_response(object): def __init__(self, code, description=None, **kwargs): self.code = code diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index 58bbe574..df8888a4 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -28,11 +28,11 @@ class LabThing(object): ): self.app = app - self.devices = {} + self.components = {} self.extensions = {} - self.resources = [] + self.views = [] self.properties = {} self.actions = {} @@ -93,8 +93,8 @@ class LabThing(object): app.extensions[EXTENSION_NAME] = self # Add resources, if registered before tying to a Flask app - if len(self.resources) > 0: - for resource, urls, endpoint, kwargs in self.resources: + if len(self.views) > 0: + for resource, urls, endpoint, kwargs in self.views: self._register_view(app, resource, *urls, endpoint=endpoint, **kwargs) # Create base routes @@ -110,19 +110,15 @@ class LabThing(object): self.app.register_blueprint(docs_blueprint, url_prefix=self.url_prefix) # Add extension overview - self.add_resource( - ExtensionList, "/extensions", endpoint=EXTENSION_LIST_ENDPOINT - ) - self.register_property(ExtensionList) + self.add_view(ExtensionList, "/extensions", endpoint=EXTENSION_LIST_ENDPOINT) # Add task routes - self.add_resource(TaskList, "/tasks", endpoint=TASK_LIST_ENDPOINT) - self.register_property(TaskList) - self.add_resource(TaskResource, "/tasks/", endpoint=TASK_ENDPOINT) + self.add_view(TaskList, "/tasks", endpoint=TASK_LIST_ENDPOINT) + self.add_view(TaskResource, "/tasks/", endpoint=TASK_ENDPOINT) ### Device stuff - def register_device(self, device_object, device_name: str): - self.devices[device_name] = device_object + def add_component(self, device_object, device_name: str): + self.components[device_name] = device_object ### Extension stuff @@ -134,18 +130,12 @@ class LabThing(object): for extension_view_id, extension_view in extension_object.views.items(): # Add route to the extensions blueprint - self.add_resource( + self.add_view( extension_view["view"], "/extensions" + extension_view["rule"], **extension_view["kwargs"], ) - for prop in extension_object.properties: - self.register_property(prop) - - for action in extension_object.actions: - self.register_action(action) - ### Resource stuff def _complete_url(self, url_part, registration_prefix): @@ -158,16 +148,8 @@ class LabThing(object): parts = [registration_prefix, self.url_prefix, url_part] return "".join([part for part in parts if part]) - def register_property(self, resource): - logging.warning("register_property is deprecated. Use the @ltproperty class decorator instead.") - pass - - def register_action(self, resource): - logging.warning("register_action is deprecated. Use the @ltaction class decorator instead.") - pass - - def add_resource(self, resource, *urls, endpoint=None, **kwargs): - """Adds a resource to the api. + def add_view(self, resource, *urls, endpoint=None, **kwargs): + """Adds a view 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 @@ -197,11 +179,11 @@ class LabThing(object): if self.app is not None: self._register_view(self.app, resource, *urls, endpoint=endpoint, **kwargs) - self.resources.append((resource, urls, endpoint, kwargs)) + self.views.append((resource, urls, endpoint, kwargs)) - def resource(self, *urls, **kwargs): + def view(self, *urls, **kwargs): def decorator(cls): - self.add_resource(cls, *urls, **kwargs) + self.add_view(cls, *urls, **kwargs) return cls return decorator diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec.py index d21687a5..13d9fcb3 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -20,10 +20,12 @@ def update_spec(obj, spec): rupdate(obj.__apispec__, spec) return obj.__apispec__ + def get_spec(obj): obj.__apispec__ = obj.__dict__.get("__apispec__", {}) return obj.__apispec__ + def view2path(rule: str, view: Resource, spec: APISpec): params = { "path": rule, # TODO: Validate this slightly (leading / etc) diff --git a/openflexure_microscope/common/flask_labthings/views/extensions.py b/openflexure_microscope/common/flask_labthings/views/extensions.py index 364c061d..6e1c01c8 100644 --- a/openflexure_microscope/common/flask_labthings/views/extensions.py +++ b/openflexure_microscope/common/flask_labthings/views/extensions.py @@ -1,17 +1,13 @@ """ Top-level representation of attached and enabled Extensions """ - -from openflexure_microscope.common.flask_labthings.utilities import get_docstring -from openflexure_microscope.common.flask_labthings.schema import Schema -from openflexure_microscope.common.flask_labthings import fields - from ..resource import Resource from ..find import registered_extensions from ..schema import ExtensionSchema -from ..decorators import marshal_with +from ..decorators import marshal_with, ThingProperty +@ThingProperty class ExtensionList(Resource): """ List and basic documentation for all enabled Extensions diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 11768fb0..3e446277 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -1,12 +1,13 @@ from flask import abort, url_for -from ..decorators import marshal_with +from ..decorators import marshal_with, ThingProperty from ..resource import Resource from ..schema import TaskSchema from openflexure_microscope.common.labthings_core import tasks +@ThingProperty class TaskList(Resource): """ List and basic documentation for all session tasks From f0f2a30d7e1f9568d865be596786dda4327d55d5 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Sat, 11 Jan 2020 21:17:08 +0000 Subject: [PATCH 102/122] Added versions and full names to default extensions --- .../api/default_extensions/autofocus.py | 2 +- openflexure_microscope/api/default_extensions/scan.py | 4 ++-- .../api/default_extensions/zip_builder.py | 2 +- .../common/flask_labthings/extensions.py | 10 +++------- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index bfbe6cdc..85c35e40 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -359,7 +359,7 @@ class FastAutofocusAPI(Resource): abort(503, "No stage connected. Unable to autofocus.") -autofocus_extension_v2 = BaseExtension("autofocus") +autofocus_extension_v2 = BaseExtension("org.openflexure.autofocus", version="2.0.0-beta.1") autofocus_extension_v2.add_method(fast_autofocus, "fast_autofocus") autofocus_extension_v2.add_method(autofocus, "autofocus") diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 214c0694..1ba66b4b 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -169,7 +169,7 @@ def tile( ) # Check if autofocus is enabled - autofocus_extension = find_extension("autofocus") + autofocus_extension = find_extension("org.openflexure.autofocus") if ( autofocus_dz and autofocus_extension @@ -396,6 +396,6 @@ class TileScanAPI(Resource): return task -scan_extension_v2 = BaseExtension("scan") +scan_extension_v2 = BaseExtension("org.openflexure.scan", version="2.0.0-beta.1") scan_extension_v2.add_view(TileScanAPI, "/tile") diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index fa1ea199..634a772c 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -146,7 +146,7 @@ class ZipGetterAPIView(Resource): return jsonify({"return": session_id}) -zip_extension_v2 = BaseExtension("zip_builder") +zip_extension_v2 = BaseExtension("org.openflexure.zipbuilder", version="2.0.0-beta.1") zip_extension_v2.add_view(ZipGetterAPIView, "/get/") zip_extension_v2.add_view(ZipListAPIView, "/get") diff --git a/openflexure_microscope/common/flask_labthings/extensions.py b/openflexure_microscope/common/flask_labthings/extensions.py index 94227c43..37bff401 100644 --- a/openflexure_microscope/common/flask_labthings/extensions.py +++ b/openflexure_microscope/common/flask_labthings/extensions.py @@ -20,8 +20,9 @@ class BaseExtension: Handles binding route views and forms. """ + # TODO: Allow adding components to extensions - def __init__(self, name: str, description=""): + def __init__(self, name: str, description="", version="0.0.0"): self._views = ( {} ) # Key: Full, Python-safe ID. Val: Original rule, and view class @@ -35,6 +36,7 @@ class BaseExtension: self.name = name self.description = get_docstring(self) + self.version = str(version) self.methods = {} @@ -64,12 +66,6 @@ class BaseExtension: # 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 meta(self): d = {} From c5d4f230c8d5469d48bb95b235b66affd0f029f5 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 13 Jan 2020 11:06:51 +0000 Subject: [PATCH 103/122] Fixed finding new components --- openflexure_microscope/common/flask_labthings/find.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openflexure_microscope/common/flask_labthings/find.py b/openflexure_microscope/common/flask_labthings/find.py index d31f5b5d..b866f992 100644 --- a/openflexure_microscope/common/flask_labthings/find.py +++ b/openflexure_microscope/common/flask_labthings/find.py @@ -21,18 +21,18 @@ def registered_extensions(labthing_instance=None): return labthing_instance.extensions -def registered_devices(labthing_instance=None): +def registered_components(labthing_instance=None): if not labthing_instance: labthing_instance = current_labthing() - return labthing_instance.devices + return labthing_instance.components -def find_device(device_name, labthing_instance=None): +def find_component(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] + if device_name in labthing_instance.components: + return labthing_instance.components[device_name] else: return None From c1da6784395ab9f529a4940c8540430887e23b11 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 13 Jan 2020 11:07:03 +0000 Subject: [PATCH 104/122] Added decorators to state routes --- openflexure_microscope/api/v2/views/state.py | 25 +++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py index e9ad5c3a..c3df403d 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/state.py @@ -6,10 +6,10 @@ from openflexure_microscope.common.labthings_core.utilities import ( create_from_path, ) -from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.resource import Resource -from openflexure_microscope.common.flask_labthings.decorators import ThingProperty +from openflexure_microscope.common.flask_labthings.decorators import ThingProperty, marshal_with from flask import jsonify, request, abort import logging @@ -18,11 +18,17 @@ import logging @ThingProperty class SettingsProperty(Resource): def get(self): - microscope = find_device("org.openflexure.microscope") + """ + Current microscope settings, including camera and stage + """ + microscope = find_component("org.openflexure.microscope") return jsonify(microscope.read_settings()) def put(self): - microscope = find_device("org.openflexure.microscope") + """ + Update current microscope settings, including camera and stage + """ + microscope = find_component("org.openflexure.microscope") payload = JsonResponse(request) logging.debug("Updating settings from PUT request:") @@ -36,7 +42,7 @@ class SettingsProperty(Resource): class NestedSettingsProperty(Resource): def get(self, route): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") keys = route.split("/") try: @@ -47,7 +53,7 @@ class NestedSettingsProperty(Resource): return jsonify(value) def put(self, route): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") keys = route.split("/") payload = JsonResponse(request) @@ -63,13 +69,16 @@ class NestedSettingsProperty(Resource): @ThingProperty class StatusProperty(Resource): def get(self): - microscope = find_device("org.openflexure.microscope") + """ + Show current read-only state of the microscope + """ + microscope = find_component("org.openflexure.microscope") return jsonify(microscope.status) class NestedStatusProperty(Resource): def get(self, route): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") keys = route.split("/") try: From 6b99873d538dda6061986d6efd49dd442016946b Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 13 Jan 2020 11:07:47 +0000 Subject: [PATCH 105/122] Added LabThing decorators --- .../api/v2/views/streams.py | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index 3ba63cca..24bfef96 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -6,23 +6,24 @@ from openflexure_microscope.common.labthings_core.utilities import ( create_from_path, ) -from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.decorators import doc_response, ThingProperty -from flask import jsonify, request, abort, Response -import logging - +from flask import Response +@ThingProperty class MjpegStream(Resource): """ Real-time MJPEG stream from the microscope camera """ + @doc_response(200, mimetype="multipart/x-mixed-replace") def get(self): """ MJPEG stream from the microscope camera """ - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") # Restart stream worker thread microscope.camera.start_worker() @@ -31,22 +32,18 @@ class MjpegStream(Resource): ) +@ThingProperty class SnapshotStream(Resource): """ Single JPEG snapshot from the camera stream """ + @doc_response(200, description="Snapshot taken", mimetype="image/jpeg") def get(self): """ Single snapshot from the camera stream - - .. :quickref: Streams; Camera snapshot - - :>header Accept: image/jpeg - :>header Content-Type: image/jpeg - :status 200: stream active """ - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") # Restart stream worker thread microscope.camera.start_worker() From 4003e4e65c553d5f0f84fb7236a98ae220683491 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 13 Jan 2020 11:08:05 +0000 Subject: [PATCH 106/122] Renamed devices to components --- .../api/default_extensions/autofocus.py | 8 ++--- .../api/default_extensions/scan.py | 4 +-- .../api/default_extensions/zip_builder.py | 4 +-- .../api/example_extensions/ev_gui.py | 4 +-- .../api/v2/views/actions/camera.py | 8 ++--- .../api/v2/views/actions/stage.py | 6 ++-- .../api/v2/views/captures.py | 20 +++++------ .../common/flask_labthings/decorators.py | 33 ++++++++++++------- openflexure_microscope/stage/base.py | 8 +++++ openflexure_microscope/stage/mock.py | 9 ++--- openflexure_microscope/stage/sanga.py | 6 +--- 11 files changed, 60 insertions(+), 50 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index 85c35e40..3c37caf0 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -1,4 +1,4 @@ -from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.extensions import BaseExtension from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.decorators import ( @@ -294,7 +294,7 @@ def fast_up_down_up_autofocus( class MeasureSharpnessAPI(Resource): def post(self): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") if not microscope: abort(503, "No microscope connected. Unable to measure sharpness.") @@ -310,7 +310,7 @@ class AutofocusAPI(Resource): def post(self): payload = JsonResponse(request) - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") if not microscope: abort(503, "No microscope connected. Unable to autofocus.") @@ -337,7 +337,7 @@ class FastAutofocusAPI(Resource): def post(self): payload = JsonResponse(request) - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") if not microscope: abort(503, "No microscope connected. Unable to autofocus.") diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 1ba66b4b..5dd87446 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -6,7 +6,7 @@ from functools import reduce from openflexure_microscope.camera.base import generate_basename from openflexure_microscope.common.flask_labthings.find import ( - find_device, + find_component, find_extension, ) from openflexure_microscope.common.flask_labthings.extensions import BaseExtension @@ -360,7 +360,7 @@ class TileScanAPI(Resource): ) @marshal_task def post(self, args): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") if not microscope: abort(503, "No microscope connected. Unable to autofocus.") diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 634a772c..9b1d8757 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -14,7 +14,7 @@ import zipfile import tempfile import logging -from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.extensions import BaseExtension from openflexure_microscope.common.flask_labthings.decorators import ( @@ -100,7 +100,7 @@ class ZipBuilderAPIView(Resource): def post(self): ids = list(JsonResponse(request).json) - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") task = taskify(default_zip_manager.build_zip_from_capture_ids)(microscope, ids) diff --git a/openflexure_microscope/api/example_extensions/ev_gui.py b/openflexure_microscope/api/example_extensions/ev_gui.py index 561e4acf..da6f9efc 100644 --- a/openflexure_microscope/api/example_extensions/ev_gui.py +++ b/openflexure_microscope/api/example_extensions/ev_gui.py @@ -93,7 +93,7 @@ class TestDoAPIView(Resource): # Using the dynamic form -dynamic_test_extension_v2 = BaseExtension("dynamic_test_extension") +dynamic_test_extension_v2 = BaseExtension("org.openflexure.examples.dynamic-gui") dynamic_test_extension_v2.add_view( TestAPIView, "/get", endpoint="dynamic_test_extension_get" ) @@ -107,7 +107,7 @@ dynamic_test_extension_v2.add_meta( # Using the static form -static_test_extension_v2 = BaseExtension("static_test_extension") +static_test_extension_v2 = BaseExtension("org.openflexure.examples.static-gui") static_test_extension_v2.add_view( TestAPIView, "/get", endpoint="static_test_extension_get" ) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index cb458d9e..ef507fe5 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -1,6 +1,6 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse from openflexure_microscope.common.flask_labthings.resource import Resource -from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.decorators import ( use_args, marshal_with, @@ -48,7 +48,7 @@ class CaptureAPI(Resource): """ Create a new capture """ - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") resize = args.get("resize", None) if resize: @@ -100,7 +100,7 @@ class GPUPreviewStartAPI(Resource): """ Start the onboard GPU preview. """ - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") window = args.get("window") logging.debug(window) @@ -124,7 +124,7 @@ class GPUPreviewStopAPI(Resource): """ Stop the onboard GPU preview. """ - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") microscope.camera.stop_preview() # TODO: Make schema for microscope status return jsonify(microscope.status) diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index cad4a776..c1fec897 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -1,6 +1,6 @@ from openflexure_microscope.api.utilities import JsonResponse from openflexure_microscope.common.flask_labthings.resource import Resource -from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.decorators import ( use_args, marshal_with, @@ -32,7 +32,7 @@ class MoveStageAPI(Resource): """ Move the microscope stage in x, y, z """ - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") # Handle absolute positioning (calculate a relative move from current position and target) if (args.get("absolute")) and (microscope.stage): # Only if stage exists @@ -68,7 +68,7 @@ class ZeroStageAPI(Resource): Zero the stage coordinates. Does not move the stage, but rather makes the current position read as [0, 0, 0] """ - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") microscope.stage.zero_position() # TODO: Make schema for microscope status diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 819b46e0..1ef852ab 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -11,7 +11,7 @@ from openflexure_microscope.common.flask_labthings.utilities import ( ) from openflexure_microscope.common.flask_labthings.decorators import marshal_with -from openflexure_microscope.common.flask_labthings.find import find_device +from openflexure_microscope.common.flask_labthings.find import find_component from marshmallow import pre_dump @@ -71,7 +71,7 @@ class CaptureList(Resource): @marshal_with(CaptureSchema(many=True)) def get(self): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") image_list = microscope.camera.images return image_list @@ -83,7 +83,7 @@ class CaptureResource(Resource): @marshal_with(CaptureSchema()) def get(self, id): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -92,7 +92,7 @@ class CaptureResource(Resource): return capture_obj def delete(self, id): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -110,7 +110,7 @@ class CaptureDownload(Resource): def get(self, id, filename): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -146,7 +146,7 @@ class CaptureTags(Resource): def get(self, id): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -156,7 +156,7 @@ class CaptureTags(Resource): def put(self, id): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -173,7 +173,7 @@ class CaptureTags(Resource): def delete(self, capture_id): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -196,7 +196,7 @@ class CaptureMetadata(Resource): """ def get(self, id): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: @@ -205,7 +205,7 @@ class CaptureMetadata(Resource): return jsonify(capture_obj.metadata) def put(self, id): - microscope = find_device("org.openflexure.microscope") + microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index e186a5e1..7d6bf2c6 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -148,22 +148,31 @@ tag = Tag class doc_response(object): - def __init__(self, code, description=None, **kwargs): + def __init__(self, code, description=None, mimetype=None, **kwargs): self.code = code self.description = description self.kwargs = kwargs + self.mimetype = mimetype + + self.response_dict = { + "responses": { + self.code: { + "description": self.description or HTTPStatus(self.code).phrase, + **self.kwargs, + } + } + } + + if self.mimetype: + self.response_dict.update({ + "responses": { + self.code: { + "content": {self.mimetype: {}} + } + } + }) def __call__(self, f): # Pass params to call function attribute for external access - update_spec( - f, - { - "responses": { - self.code: { - "description": self.description or HTTPStatus(self.code).phrase, - **self.kwargs, - } - } - }, - ) + update_spec(f, self.response_dict) return f diff --git a/openflexure_microscope/stage/base.py b/openflexure_microscope/stage/base.py index 4b591994..edb40158 100644 --- a/openflexure_microscope/stage/base.py +++ b/openflexure_microscope/stage/base.py @@ -49,6 +49,14 @@ class BaseStage(metaclass=ABCMeta): """The current position, as a list""" pass + @property + def position_map(self): + return { + "x": self.position[0], + "y": self.position[1], + "z": self.position[2], + } + @property @abstractmethod def backlash(self): diff --git a/openflexure_microscope/stage/mock.py b/openflexure_microscope/stage/mock.py index 92765ec3..fe2d2ac6 100644 --- a/openflexure_microscope/stage/mock.py +++ b/openflexure_microscope/stage/mock.py @@ -20,13 +20,10 @@ class MockStage(BaseStage): def status(self): """The general status dictionary of the board.""" status = { - "position": { - "x": self.position[0], - "y": self.position[1], - "z": self.position[2], - }, + "position": self.position_map, "board": None, - "version": "0", + "firmware": None, + "version": None } return status diff --git a/openflexure_microscope/stage/sanga.py b/openflexure_microscope/stage/sanga.py index 27b192a5..2c4fa6d9 100644 --- a/openflexure_microscope/stage/sanga.py +++ b/openflexure_microscope/stage/sanga.py @@ -34,11 +34,7 @@ class SangaStage(BaseStage): def status(self): """The general status dictionary of the board.""" status = { - "position": { - "x": self.position[0], - "y": self.position[1], - "z": self.position[2], - }, + "position": self.position_map, "board": self.board.board, "firmware": self.board.firmware, } From 9faa05aec18a30c50a9d1c536be91c00330d0862 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 13 Jan 2020 16:24:32 +0000 Subject: [PATCH 107/122] Added missing API documentation --- .../api/v2/views/captures.py | 61 +++++++++++-------- openflexure_microscope/api/v2/views/state.py | 13 +++- .../api/v2/views/streams.py | 1 + .../common/flask_labthings/labthing.py | 25 +++++--- .../{spec.py => spec/__init__.py} | 19 ++++-- .../common/flask_labthings/spec/paths.py | 50 +++++++++++++++ .../flask_labthings/views/docs/__init__.py | 23 ++++--- .../common/flask_labthings/views/tasks.py | 20 ++++-- 8 files changed, 160 insertions(+), 52 deletions(-) rename openflexure_microscope/common/flask_labthings/{spec.py => spec/__init__.py} (89%) create mode 100644 openflexure_microscope/common/flask_labthings/spec/paths.py diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 1ef852ab..74bf97e6 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -9,7 +9,7 @@ from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.utilities import ( description_from_view, ) -from openflexure_microscope.common.flask_labthings.decorators import marshal_with +from openflexure_microscope.common.flask_labthings.decorators import marshal_with, doc_response, Tag, ThingProperty from openflexure_microscope.common.flask_labthings.find import find_component @@ -64,25 +64,26 @@ capture_schema = CaptureSchema() capture_list_schema = CaptureSchema(many=True) +@ThingProperty +@Tag("captures") class CaptureList(Resource): - """ - List all image captures - """ - @marshal_with(CaptureSchema(many=True)) def get(self): + """ + List all image captures + """ microscope = find_component("org.openflexure.microscope") image_list = microscope.camera.images return image_list +@Tag("captures") class CaptureResource(Resource): - """ - Description of a single image capture - """ - @marshal_with(CaptureSchema()) def get(self, id): + """ + Description of a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -92,6 +93,9 @@ class CaptureResource(Resource): return capture_obj def delete(self, id): + """ + Delete a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -103,13 +107,13 @@ class CaptureResource(Resource): return "", 204 +@Tag("captures") class CaptureDownload(Resource): - """ - Image data for a single image capture - """ - + @doc_response(200, mimetype="image/jpeg") def get(self, id, filename): - + """ + Image data for a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -139,13 +143,12 @@ class CaptureDownload(Resource): return send_file(img, mimetype="image/jpeg") +@Tag("captures") class CaptureTags(Resource): - """ - Tags associated with a single image capture - """ - def get(self, id): - + """ + Get tags associated with a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -155,13 +158,16 @@ class CaptureTags(Resource): return jsonify(capture_obj.tags) def put(self, id): - + """ + Add tags to a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: return abort(404) # 404 Not Found + # TODO: Replace with normal Flask request JSON thing data_dict = JsonResponse(request).json if type(data_dict) != list: @@ -172,7 +178,9 @@ class CaptureTags(Resource): return jsonify(capture_obj.tags) def delete(self, capture_id): - + """ + Delete tags from a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -190,12 +198,12 @@ class CaptureTags(Resource): return jsonify(capture_obj.tags) +@Tag("captures") class CaptureMetadata(Resource): - """ - All metadata associated with a single image capture - """ - def get(self, id): + """ + Get metadata associated with a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -205,6 +213,9 @@ class CaptureMetadata(Resource): return jsonify(capture_obj.metadata) def put(self, id): + """ + Update metadata for a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py index c3df403d..378608da 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/state.py @@ -9,7 +9,7 @@ from openflexure_microscope.common.labthings_core.utilities import ( from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.resource import Resource -from openflexure_microscope.common.flask_labthings.decorators import ThingProperty, marshal_with +from openflexure_microscope.common.flask_labthings.decorators import ThingProperty, Tag from flask import jsonify, request, abort import logging @@ -40,8 +40,12 @@ class SettingsProperty(Resource): return self.get() +@Tag("properties") class NestedSettingsProperty(Resource): def get(self, route): + """ + Show a nested section of the current microscope settings + """ microscope = find_component("org.openflexure.microscope") keys = route.split("/") @@ -53,6 +57,9 @@ class NestedSettingsProperty(Resource): return jsonify(value) def put(self, route): + """ + Update a nested section of the current microscope settings + """ microscope = find_component("org.openflexure.microscope") keys = route.split("/") payload = JsonResponse(request) @@ -76,8 +83,12 @@ class StatusProperty(Resource): return jsonify(microscope.status) +@Tag("properties") class NestedStatusProperty(Resource): def get(self, route): + """ + Show a nested section of the current microscope state + """ microscope = find_component("org.openflexure.microscope") keys = route.split("/") diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index 24bfef96..967c6da7 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -12,6 +12,7 @@ from openflexure_microscope.common.flask_labthings.decorators import doc_respons from flask import Response + @ThingProperty class MjpegStream(Resource): """ diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index df8888a4..c49d7e25 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -6,7 +6,7 @@ from . import EXTENSION_NAME # TODO: Move into .names from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT from .extensions import BaseExtension from .utilities import description_from_view -from .spec import view2path, get_spec +from .spec import rule2path, get_spec from .views.extensions import ExtensionList from .views.tasks import TaskList, TaskResource @@ -188,8 +188,8 @@ class LabThing(object): return decorator - def _register_view(self, app, resource, *urls, endpoint=None, **kwargs): - endpoint = endpoint or resource.__name__.lower() + def _register_view(self, app, view, *urls, endpoint=None, **kwargs): + endpoint = endpoint or view.__name__.lower() self.endpoints.add(endpoint) resource_class_args = kwargs.pop("resource_class_args", ()) resource_class_kwargs = kwargs.pop("resource_class_kwargs", {}) @@ -199,14 +199,14 @@ class LabThing(object): 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: + if previous_view_class != view: 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( + view.endpoint = endpoint + resource_func = view.as_view( endpoint, *resource_class_args, **resource_class_kwargs ) @@ -216,15 +216,20 @@ class LabThing(object): # Add the url to the application or blueprint app.add_url_rule(rule, view_func=resource_func, **kwargs) # Add the resource to our API spec - self.spec.path(**view2path(rule, resource, self.spec)) + #self.spec.path(**view2path(rule, view, self.spec)) + + # TEST: Getting Flask rule objects + flask_rules = app.url_map._rules_by_endpoint.get(endpoint) + for flask_rule in flask_rules: + self.spec.path(**rule2path(flask_rule, view, self.spec)) # Handle resource groups listed in API spec - view_spec = get_spec(resource) + view_spec = get_spec(view) view_groups = view_spec.get("_groups", {}) if "actions" in view_groups: - self.actions[resource.endpoint] = resource + self.actions[view.endpoint] = view if "properties" in view_groups: - self.properties[resource.endpoint] = resource + self.properties[view.endpoint] = view ### Utilities diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec/__init__.py similarity index 89% rename from openflexure_microscope/common/flask_labthings/spec.py rename to openflexure_microscope/common/flask_labthings/spec/__init__.py index 13d9fcb3..59a1bae7 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec/__init__.py @@ -1,4 +1,4 @@ -from .resource import Resource +from ..resource import Resource from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin @@ -8,9 +8,12 @@ from openflexure_microscope.common.labthings_core.utilities import ( rupdate, ) -from .fields import Field +from ..fields import Field from marshmallow import Schema as BaseSchema +from .paths import rule_to_path, rule_to_params + +from werkzeug.routing import Rule from collections import Mapping from http import HTTPStatus @@ -26,14 +29,22 @@ def get_spec(obj): return obj.__apispec__ -def view2path(rule: str, view: Resource, spec: APISpec): +def rule2path(rule: Rule, view: Resource, spec: APISpec): params = { - "path": rule, # TODO: Validate this slightly (leading / etc) + "path": rule_to_path(rule), "operations": view2operations(view, spec), "description": get_docstring(view), "summary": get_summary(view), } + # Add URL arguments + if rule.arguments: + for op in params.get("operations").keys(): + params["operations"][op].update({ + "parameters": rule_to_params(rule) + }) + + # Add extra parameters if hasattr(view, "__apispec__"): # Recursively update params rupdate(params, view.__apispec__) diff --git a/openflexure_microscope/common/flask_labthings/spec/paths.py b/openflexure_microscope/common/flask_labthings/spec/paths.py new file mode 100644 index 00000000..6c70c2c4 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/spec/paths.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- + +import re + +import werkzeug.routing + +PATH_RE = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>') + + +def rule_to_path(rule): + return PATH_RE.sub(r'{\1}', rule.rule) + + +# Conversion map of werkzeug rule converters to Javascript schema types +CONVERTER_MAPPING = { + werkzeug.routing.UnicodeConverter: ('string', None), + werkzeug.routing.IntegerConverter: ('integer', 'int32'), + werkzeug.routing.FloatConverter: ('number', 'float'), +} + +DEFAULT_TYPE = ('string', None) + + +def rule_to_params(rule, overrides=None): + overrides = (overrides or {}) + result = [ + argument_to_param(argument, rule, overrides.get(argument, {})) + for argument in rule.arguments + ] + for key in overrides.keys(): + if overrides[key].get('in') in ('header', 'query'): + overrides[key]['name'] = overrides[key].get('name', key) + result.append(overrides[key]) + return result + + +def argument_to_param(argument, rule, override=None): + param = { + 'in': 'path', + 'name': argument, + 'required': True, + } + type_, format_ = CONVERTER_MAPPING.get(type(rule._converters[argument]), DEFAULT_TYPE) + param['type'] = type_ + if format_ is not None: + param['format'] = format_ + if rule.defaults and argument in rule.defaults: + param['default'] = rule.defaults[argument] + param.update(override or {}) + return param diff --git a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py index 3331f4eb..0768926c 100644 --- a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py +++ b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py @@ -1,9 +1,10 @@ -from flask import abort, url_for, jsonify, render_template, Blueprint +from flask import abort, url_for, jsonify, render_template, Blueprint, current_app, request from openflexure_microscope.common.labthings_core.utilities import get_docstring from ...resource import Resource from ...find import current_labthing +from ...spec import rule_to_path import os @@ -35,8 +36,13 @@ class W3CThingDescriptionResource(Resource): """ def get(self): + base_url = request.host_url.rstrip('/') + props = {} for key, prop in current_labthing().properties.items(): + prop_rules = current_app.url_map._rules_by_endpoint.get(prop.endpoint) + prop_urls = [rule_to_path(rule) for rule in prop_rules] + props[key] = {} props[key]["title"] = prop.__name__ # TODO: Get description from __apispec__ preferentially @@ -48,19 +54,22 @@ class W3CThingDescriptionResource(Resource): ) props[key]["writeOnly"] = not hasattr(prop, "get") props[key]["links"] = [ - {"href": current_labthing().url_for(prop, _external=True)} + {"href": f"{base_url}{url}"} for url in prop_urls ] actions = {} - for key, prop in current_labthing().actions.items(): + for key, action in current_labthing().actions.items(): + action_rules = current_app.url_map._rules_by_endpoint.get(action.endpoint) + action_urls = [rule_to_path(rule) for rule in action_rules] + actions[key] = {} - actions[key]["title"] = prop.__name__ + actions[key]["title"] = action.__name__ # TODO: Get description from __apispec__ preferentially - actions[key]["description"] = get_docstring(prop) or ( - get_docstring(prop.post) if hasattr(prop, "post") else "" + actions[key]["description"] = get_docstring(action) or ( + get_docstring(action.post) if hasattr(action, "post") else "" ) actions[key]["links"] = [ - {"href": current_labthing().url_for(prop, _external=True)} + {"href": f"{base_url}{url}"} for url in action_urls ] td = { diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 3e446277..d9475f65 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -1,6 +1,6 @@ from flask import abort, url_for -from ..decorators import marshal_with, ThingProperty +from ..decorators import marshal_with, ThingProperty, Tag from ..resource import Resource from ..schema import TaskSchema @@ -9,18 +9,23 @@ from openflexure_microscope.common.labthings_core import tasks @ThingProperty class TaskList(Resource): - """ - List and basic documentation for all session tasks - """ - @marshal_with(TaskSchema(many=True)) def get(self): + """ + List of all session tasks + """ return tasks.tasks() +@Tag("properties") class TaskResource(Resource): @marshal_with(TaskSchema()) def get(self, id): + """ + Show status of a session task + + Includes progress and intermediate data. + """ try: task = tasks.dict()[id] except KeyError: @@ -30,6 +35,11 @@ class TaskResource(Resource): @marshal_with(TaskSchema()) def delete(self, id): + """ + Terminate a running task. + + If the task is finished, deletes its entry. + """ try: task = tasks.dict()[id] except KeyError: From 9ec77955430812b02e88b9955eddaa156f1e871e Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 13 Jan 2020 16:32:24 +0000 Subject: [PATCH 108/122] Added 404 description --- openflexure_microscope/api/v2/views/state.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py index 378608da..1da71698 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/state.py @@ -9,7 +9,7 @@ from openflexure_microscope.common.labthings_core.utilities import ( from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.resource import Resource -from openflexure_microscope.common.flask_labthings.decorators import ThingProperty, Tag +from openflexure_microscope.common.flask_labthings.decorators import ThingProperty, Tag, doc_response from flask import jsonify, request, abort import logging @@ -42,6 +42,7 @@ class SettingsProperty(Resource): @Tag("properties") class NestedSettingsProperty(Resource): + @doc_response(404, description="Settings key cannot be found") def get(self, route): """ Show a nested section of the current microscope settings @@ -56,6 +57,7 @@ class NestedSettingsProperty(Resource): return jsonify(value) + @doc_response(404, description="Settings key cannot be found") def put(self, route): """ Update a nested section of the current microscope settings @@ -85,6 +87,7 @@ class StatusProperty(Resource): @Tag("properties") class NestedStatusProperty(Resource): + @doc_response(404, description="Status key cannot be found") def get(self, route): """ Show a nested section of the current microscope state From 3ba874e786c0bfa308734dac36ba371ba96b4105 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 13 Jan 2020 16:32:37 +0000 Subject: [PATCH 109/122] Tagged "tasks" views --- openflexure_microscope/common/flask_labthings/views/tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index d9475f65..06a136a7 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -8,6 +8,7 @@ from openflexure_microscope.common.labthings_core import tasks @ThingProperty +@Tag("tasks") class TaskList(Resource): @marshal_with(TaskSchema(many=True)) def get(self): @@ -17,7 +18,7 @@ class TaskList(Resource): return tasks.tasks() -@Tag("properties") +@Tag(["properties", "tasks"]) class TaskResource(Resource): @marshal_with(TaskSchema()) def get(self, id): From 64f89c5355a3d37b2c57842958f329b351187fd9 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 13 Jan 2020 16:32:51 +0000 Subject: [PATCH 110/122] Automatically tag extensions --- openflexure_microscope/common/flask_labthings/labthing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index c49d7e25..8c3fecfd 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -7,6 +7,7 @@ from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT from .extensions import BaseExtension from .utilities import description_from_view from .spec import rule2path, get_spec +from .decorators import tag from .views.extensions import ExtensionList from .views.tasks import TaskList, TaskResource @@ -131,7 +132,7 @@ class LabThing(object): for extension_view_id, extension_view in extension_object.views.items(): # Add route to the extensions blueprint self.add_view( - extension_view["view"], + tag("extensions")(extension_view["view"]), "/extensions" + extension_view["rule"], **extension_view["kwargs"], ) From 234ebc1cbbb49576ce6fa40901948ed36db218c8 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 13 Jan 2020 16:50:10 +0000 Subject: [PATCH 111/122] Support uriVariables in thing description --- .../flask_labthings/views/docs/__init__.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py index 0768926c..2adad8ab 100644 --- a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py +++ b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py @@ -4,7 +4,7 @@ from openflexure_microscope.common.labthings_core.utilities import get_docstring from ...resource import Resource from ...find import current_labthing -from ...spec import rule_to_path +from ...spec import rule_to_path, rule_to_params import os @@ -57,6 +57,20 @@ class W3CThingDescriptionResource(Resource): {"href": f"{base_url}{url}"} for url in prop_urls ] + props[key]["uriVariables"] = {} + for prop_rule in prop_rules: + params = rule_to_params(prop_rule) + params_dict = {} + for param in params: + params_dict.update({ + param.get("name"): { + "type": param.get("type") + } + }) + props[key]["uriVariables"].update(params_dict) + if not props[key]["uriVariables"]: + del props[key]["uriVariables"] + actions = {} for key, action in current_labthing().actions.items(): action_rules = current_app.url_map._rules_by_endpoint.get(action.endpoint) From a329b9197b01e6c960ca213081af5dcfccab350a Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 14 Jan 2020 11:41:56 +0000 Subject: [PATCH 112/122] Renamed Resource to View, and allow custom root links --- openflexure_microscope/api/app.py | 6 ++++- .../api/default_extensions/autofocus.py | 8 +++--- .../api/default_extensions/scan.py | 4 +-- .../api/default_extensions/zip_builder.py | 8 +++--- .../api/example_extensions/ev_gui.py | 6 ++--- .../api/v2/views/actions/camera.py | 8 +++--- .../api/v2/views/actions/stage.py | 6 ++--- .../api/v2/views/actions/system.py | 6 ++--- .../api/v2/views/captures.py | 16 ++++++------ openflexure_microscope/api/v2/views/state.py | 10 ++++---- .../api/v2/views/streams.py | 6 ++--- .../common/flask_labthings/labthing.py | 25 +++++++++++++------ .../common/flask_labthings/spec/__init__.py | 8 +++--- .../common/flask_labthings/utilities.py | 12 +++++++-- .../flask_labthings/{resource.py => view.py} | 4 +-- .../flask_labthings/views/docs/__init__.py | 14 +++++------ .../flask_labthings/views/extensions.py | 4 +-- .../common/flask_labthings/views/tasks.py | 6 ++--- 18 files changed, 90 insertions(+), 67 deletions(-) rename openflexure_microscope/common/flask_labthings/{resource.py => view.py} (91%) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index f90d24c1..23f957b8 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -79,16 +79,20 @@ for extension in find_extensions(USER_EXTENSIONS_PATH): # Attach captures resources labthing.add_view(views.CaptureList, f"/captures") -labthing.add_view(views.CaptureResource, f"/captures/") +labthing.add_root_link(views.CaptureList, "captures") + +labthing.add_view(views.CaptureView, f"/captures/") labthing.add_view(views.CaptureDownload, f"/captures//download/") labthing.add_view(views.CaptureTags, f"/captures//tags") labthing.add_view(views.CaptureMetadata, f"/captures//metadata") # Attach settings and status resources labthing.add_view(views.SettingsProperty, f"/settings") +labthing.add_root_link(views.SettingsProperty, "settings") labthing.add_view(views.NestedSettingsProperty, "/settings/") labthing.add_view(views.StatusProperty, "/status") labthing.add_view(views.NestedStatusProperty, "/status/") +labthing.add_root_link(views.StatusProperty, "status") # Attach streams resources labthing.add_view(views.MjpegStream, f"/streams/mjpeg") diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index 3c37caf0..2832835a 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -1,6 +1,6 @@ from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.extensions import BaseExtension -from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.view import View from openflexure_microscope.common.flask_labthings.decorators import ( ThingAction, ThingProperty, @@ -292,7 +292,7 @@ def fast_up_down_up_autofocus( return m.data_dict() -class MeasureSharpnessAPI(Resource): +class MeasureSharpnessAPI(View): def post(self): microscope = find_component("org.openflexure.microscope") @@ -303,7 +303,7 @@ class MeasureSharpnessAPI(Resource): @ThingAction -class AutofocusAPI(Resource): +class AutofocusAPI(View): """ Run a standard autofocus """ @@ -330,7 +330,7 @@ class AutofocusAPI(Resource): @ThingAction -class FastAutofocusAPI(Resource): +class FastAutofocusAPI(View): """ Run a fast autofocus """ diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 5dd87446..3b25e2e0 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -19,7 +19,7 @@ from openflexure_microscope.common.flask_labthings import fields from openflexure_microscope.devel import taskify, abort, update_task_progress -from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.view import View import time @@ -341,7 +341,7 @@ def stack( @ThingAction -class TileScanAPI(Resource): +class TileScanAPI(View): @use_args( { "filename": fields.String(), diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 9b1d8757..8558036f 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -15,7 +15,7 @@ import tempfile import logging from openflexure_microscope.common.flask_labthings.find import find_component -from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.view import View from openflexure_microscope.common.flask_labthings.extensions import BaseExtension from openflexure_microscope.common.flask_labthings.decorators import ( ThingAction, @@ -96,7 +96,7 @@ default_zip_manager = ZipManager() @ThingAction -class ZipBuilderAPIView(Resource): +class ZipBuilderAPIView(View): def post(self): ids = list(JsonResponse(request).json) @@ -109,12 +109,12 @@ class ZipBuilderAPIView(Resource): @ThingProperty -class ZipListAPIView(Resource): +class ZipListAPIView(View): def get(self): return jsonify(default_zip_manager.session_zips) -class ZipGetterAPIView(Resource): +class ZipGetterAPIView(View): def get(self, session_id): if not session_id in default_zip_manager.session_zips: return abort(404) # 404 Not Found diff --git a/openflexure_microscope/api/example_extensions/ev_gui.py b/openflexure_microscope/api/example_extensions/ev_gui.py index da6f9efc..469c02aa 100644 --- a/openflexure_microscope/api/example_extensions/ev_gui.py +++ b/openflexure_microscope/api/example_extensions/ev_gui.py @@ -1,4 +1,4 @@ -from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.view import View from openflexure_microscope.common.flask_labthings.extensions import BaseExtension from openflexure_microscope.common.flask_labthings.decorators import ( ThingAction, @@ -77,7 +77,7 @@ static_form = { @ThingProperty -class TestAPIView(Resource): +class TestAPIView(View): def get(self): global val_int val_int += 1 @@ -85,7 +85,7 @@ class TestAPIView(Resource): @ThingAction -class TestDoAPIView(Resource): +class TestDoAPIView(View): def post(self): global val_int val_int += 1 diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index ef507fe5..917a72eb 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -1,5 +1,5 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse -from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.view import View from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.decorators import ( use_args, @@ -20,7 +20,7 @@ from flask import jsonify, request, abort, url_for, redirect, send_file @ThingAction -class CaptureAPI(Resource): +class CaptureAPI(View): """ Create a new image capture. """ @@ -86,7 +86,7 @@ class CaptureAPI(Resource): @ThingAction -class GPUPreviewStartAPI(Resource): +class GPUPreviewStartAPI(View): """ Start the onboard GPU preview. Optional "window" parameter can be passed to control the position and size of the preview window, @@ -119,7 +119,7 @@ class GPUPreviewStartAPI(Resource): @ThingAction -class GPUPreviewStopAPI(Resource): +class GPUPreviewStopAPI(View): def post(self): """ Stop the onboard GPU preview. diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index c1fec897..35795781 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -1,5 +1,5 @@ from openflexure_microscope.api.utilities import JsonResponse -from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.view import View from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.decorators import ( use_args, @@ -17,7 +17,7 @@ import logging @ThingAction -class MoveStageAPI(Resource): +class MoveStageAPI(View): @use_args( { "absolute": fields.Boolean( @@ -62,7 +62,7 @@ class MoveStageAPI(Resource): @ThingAction -class ZeroStageAPI(Resource): +class ZeroStageAPI(View): def post(self): """ Zero the stage coordinates. diff --git a/openflexure_microscope/api/v2/views/actions/system.py b/openflexure_microscope/api/v2/views/actions/system.py index 82b67b92..a370d05e 100644 --- a/openflexure_microscope/api/v2/views/actions/system.py +++ b/openflexure_microscope/api/v2/views/actions/system.py @@ -1,4 +1,4 @@ -from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.view import View import subprocess import os from sys import platform @@ -18,7 +18,7 @@ def is_raspberrypi(raise_on_errors=False): @ThingAction -class ShutdownAPI(Resource): +class ShutdownAPI(View): """ Attempt to shutdown the device """ @@ -34,7 +34,7 @@ class ShutdownAPI(Resource): @ThingAction -class RebootAPI(Resource): +class RebootAPI(View): """ Attempt to reboot the device """ diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 74bf97e6..31eef23c 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -5,7 +5,7 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse from openflexure_microscope.common.flask_labthings.schema import Schema from openflexure_microscope.common.flask_labthings import fields -from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.view import View from openflexure_microscope.common.flask_labthings.utilities import ( description_from_view, ) @@ -32,9 +32,9 @@ class CaptureSchema(Schema): def generate_links(self, data, **kwargs): data.links = { "self": { - "href": url_for(CaptureResource.endpoint, id=data.id, _external=True), + "href": url_for(CaptureView.endpoint, id=data.id, _external=True), "mimetype": "application/json", - **description_from_view(CaptureResource), + **description_from_view(CaptureView), }, "tags": { "href": url_for(CaptureTags.endpoint, id=data.id, _external=True), @@ -66,7 +66,7 @@ capture_list_schema = CaptureSchema(many=True) @ThingProperty @Tag("captures") -class CaptureList(Resource): +class CaptureList(View): @marshal_with(CaptureSchema(many=True)) def get(self): """ @@ -78,7 +78,7 @@ class CaptureList(Resource): @Tag("captures") -class CaptureResource(Resource): +class CaptureView(View): @marshal_with(CaptureSchema()) def get(self, id): """ @@ -108,7 +108,7 @@ class CaptureResource(Resource): @Tag("captures") -class CaptureDownload(Resource): +class CaptureDownload(View): @doc_response(200, mimetype="image/jpeg") def get(self, id, filename): """ @@ -144,7 +144,7 @@ class CaptureDownload(Resource): @Tag("captures") -class CaptureTags(Resource): +class CaptureTags(View): def get(self, id): """ Get tags associated with a single image capture @@ -199,7 +199,7 @@ class CaptureTags(Resource): @Tag("captures") -class CaptureMetadata(Resource): +class CaptureMetadata(View): def get(self, id): """ Get metadata associated with a single image capture diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py index 1da71698..b46b4e63 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/state.py @@ -7,7 +7,7 @@ from openflexure_microscope.common.labthings_core.utilities import ( ) from openflexure_microscope.common.flask_labthings.find import find_component -from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.view import View from openflexure_microscope.common.flask_labthings.decorators import ThingProperty, Tag, doc_response @@ -16,7 +16,7 @@ import logging @ThingProperty -class SettingsProperty(Resource): +class SettingsProperty(View): def get(self): """ Current microscope settings, including camera and stage @@ -41,7 +41,7 @@ class SettingsProperty(Resource): @Tag("properties") -class NestedSettingsProperty(Resource): +class NestedSettingsProperty(View): @doc_response(404, description="Settings key cannot be found") def get(self, route): """ @@ -76,7 +76,7 @@ class NestedSettingsProperty(Resource): @ThingProperty -class StatusProperty(Resource): +class StatusProperty(View): def get(self): """ Show current read-only state of the microscope @@ -86,7 +86,7 @@ class StatusProperty(Resource): @Tag("properties") -class NestedStatusProperty(Resource): +class NestedStatusProperty(View): @doc_response(404, description="Status key cannot be found") def get(self, route): """ diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index 967c6da7..8234d52f 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -7,14 +7,14 @@ from openflexure_microscope.common.labthings_core.utilities import ( ) from openflexure_microscope.common.flask_labthings.find import find_component -from openflexure_microscope.common.flask_labthings.resource import Resource +from openflexure_microscope.common.flask_labthings.view import View from openflexure_microscope.common.flask_labthings.decorators import doc_response, ThingProperty from flask import Response @ThingProperty -class MjpegStream(Resource): +class MjpegStream(View): """ Real-time MJPEG stream from the microscope camera """ @@ -34,7 +34,7 @@ class MjpegStream(Resource): @ThingProperty -class SnapshotStream(Resource): +class SnapshotStream(View): """ Single JPEG snapshot from the camera stream """ diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index 8c3fecfd..ce1e17f3 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -10,8 +10,8 @@ from .spec import rule2path, get_spec from .decorators import tag from .views.extensions import ExtensionList -from .views.tasks import TaskList, TaskResource -from .views.docs import docs_blueprint, SwaggerUIResource, W3CThingDescriptionResource +from .views.tasks import TaskList, TaskView +from .views.docs import docs_blueprint, SwaggerUIView, W3CThingDescriptionView from openflexure_microscope.common.labthings_core.utilities import get_docstring @@ -37,6 +37,8 @@ class LabThing(object): self.properties = {} self.actions = {} + self.custom_root_links = {} + self.endpoints = set() self.url_prefix = prefix @@ -114,7 +116,7 @@ class LabThing(object): self.add_view(ExtensionList, "/extensions", endpoint=EXTENSION_LIST_ENDPOINT) # Add task routes self.add_view(TaskList, "/tasks", endpoint=TASK_LIST_ENDPOINT) - self.add_view(TaskResource, "/tasks/", endpoint=TASK_ENDPOINT) + self.add_view(TaskView, "/tasks/", endpoint=TASK_ENDPOINT) ### Device stuff @@ -234,15 +236,18 @@ class LabThing(object): ### Utilities - def url_for(self, resource, **values): + def url_for(self, view, **values): """Generates a URL to the given resource. Works like :func:`flask.url_for`.""" - endpoint = resource.endpoint + endpoint = view.endpoint return url_for(endpoint, **values) def owns_endpoint(self, endpoint): return endpoint in self.endpoints + def add_root_link(self, view, title, kwargs={}): + self.custom_root_links[title] = (view, kwargs) + ### Description def rootrep(self): """ @@ -257,11 +262,11 @@ class LabThing(object): "links": { "thingDescription": { "href": url_for("labthings_docs.w3c_td", _external=True), - "description": get_docstring(W3CThingDescriptionResource), + "description": get_docstring(W3CThingDescriptionView), }, "swaggerUI": { "href": url_for("labthings_docs.swagger_ui", _external=True), - **description_from_view(SwaggerUIResource), + **description_from_view(SwaggerUIView), }, "extensions": { "href": self.url_for(ExtensionList, _external=True), @@ -274,4 +279,10 @@ class LabThing(object): }, } + for title, (view, kwargs) in self.custom_root_links.items(): + rr["links"][title] = { + "href": self.url_for(view, **kwargs, _external=True), + **description_from_view(view), + } + return jsonify(rr) diff --git a/openflexure_microscope/common/flask_labthings/spec/__init__.py b/openflexure_microscope/common/flask_labthings/spec/__init__.py index 59a1bae7..eb5bef96 100644 --- a/openflexure_microscope/common/flask_labthings/spec/__init__.py +++ b/openflexure_microscope/common/flask_labthings/spec/__init__.py @@ -1,4 +1,4 @@ -from ..resource import Resource +from ..view import View from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin @@ -29,7 +29,7 @@ def get_spec(obj): return obj.__apispec__ -def rule2path(rule: Rule, view: Resource, spec: APISpec): +def rule2path(rule: Rule, view: View, spec: APISpec): params = { "path": rule_to_path(rule), "operations": view2operations(view, spec), @@ -52,7 +52,7 @@ def rule2path(rule: Rule, view: Resource, spec: APISpec): return params -def view2operations(view: Resource, spec: APISpec): +def view2operations(view: View, spec: APISpec): # Operations inherit tags from parent inherited_tags = [] if hasattr(view, "__apispec__"): @@ -60,7 +60,7 @@ def view2operations(view: Resource, spec: APISpec): # Build dictionary of operations (HTTP methods) ops = {} - for method in Resource.methods: + for method in View.methods: if hasattr(view, method): ops[method] = {} diff --git a/openflexure_microscope/common/flask_labthings/utilities.py b/openflexure_microscope/common/flask_labthings/utilities.py index e06dad4c..86c904da 100644 --- a/openflexure_microscope/common/flask_labthings/utilities.py +++ b/openflexure_microscope/common/flask_labthings/utilities.py @@ -2,15 +2,23 @@ from openflexure_microscope.common.labthings_core.utilities import ( get_docstring, get_summary, ) + +from .view import View + from flask import current_app def description_from_view(view_class): + summary = get_summary(view_class) + methods = [] - for method_key in ["get", "post", "put", "delete"]: + for method_key in View.methods: if hasattr(view_class, method_key): methods.append(method_key.upper()) - summary = get_summary(view_class) + + # If no class summary was given, try using summaries from method functions + if not summary: + summary = get_summary(getattr(view_class, method_key)) d = {"methods": methods, "description": summary} diff --git a/openflexure_microscope/common/flask_labthings/resource.py b/openflexure_microscope/common/flask_labthings/view.py similarity index 91% rename from openflexure_microscope/common/flask_labthings/resource.py rename to openflexure_microscope/common/flask_labthings/view.py index ed3ef119..0998a199 100644 --- a/openflexure_microscope/common/flask_labthings/resource.py +++ b/openflexure_microscope/common/flask_labthings/view.py @@ -1,7 +1,7 @@ from flask.views import MethodView -class Resource(MethodView): +class View(MethodView): """ A LabThing Resource class should make use of functions get(), put(), post(), and delete() corresponding to HTTP methods. @@ -20,7 +20,7 @@ class Resource(MethodView): if hasattr(self, "__apispec__"): docs.update(self.__apispec__) - for meth in BaseResource.methods: + for meth in View.methods: if hasattr(self, meth) and hasattr(getattr(self, meth), "__apispec__"): docs["operations"][meth] = {} docs["operations"][meth] = getattr(self, meth).__apispec__ diff --git a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py index 2adad8ab..4da62d29 100644 --- a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py +++ b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py @@ -2,14 +2,14 @@ from flask import abort, url_for, jsonify, render_template, Blueprint, current_a from openflexure_microscope.common.labthings_core.utilities import get_docstring -from ...resource import Resource +from ...view import View from ...find import current_labthing from ...spec import rule_to_path, rule_to_params import os -class APISpecResource(Resource): +class APISpecView(View): """ OpenAPI v3 documentation """ @@ -21,7 +21,7 @@ class APISpecResource(Resource): return jsonify(current_labthing().spec.to_dict()) -class SwaggerUIResource(Resource): +class SwaggerUIView(View): """ Swagger UI documentation """ @@ -30,7 +30,7 @@ class SwaggerUIResource(Resource): return render_template("swagger-ui.html") -class W3CThingDescriptionResource(Resource): +class W3CThingDescriptionView(View): """ W3C-style Thing Description """ @@ -103,11 +103,11 @@ docs_blueprint = Blueprint( ) docs_blueprint.add_url_rule( - "/swagger", view_func=APISpecResource.as_view("swagger_json") + "/swagger", view_func=APISpecView.as_view("swagger_json") ) docs_blueprint.add_url_rule( - "/swagger-ui", view_func=SwaggerUIResource.as_view("swagger_ui") + "/swagger-ui", view_func=SwaggerUIView.as_view("swagger_ui") ) docs_blueprint.add_url_rule( - "/td", view_func=W3CThingDescriptionResource.as_view("w3c_td") + "/td", view_func=W3CThingDescriptionView.as_view("w3c_td") ) diff --git a/openflexure_microscope/common/flask_labthings/views/extensions.py b/openflexure_microscope/common/flask_labthings/views/extensions.py index 6e1c01c8..66684682 100644 --- a/openflexure_microscope/common/flask_labthings/views/extensions.py +++ b/openflexure_microscope/common/flask_labthings/views/extensions.py @@ -1,14 +1,14 @@ """ Top-level representation of attached and enabled Extensions """ -from ..resource import Resource +from ..view import View from ..find import registered_extensions from ..schema import ExtensionSchema from ..decorators import marshal_with, ThingProperty @ThingProperty -class ExtensionList(Resource): +class ExtensionList(View): """ List and basic documentation for all enabled Extensions """ diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 06a136a7..f583ba17 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -1,7 +1,7 @@ from flask import abort, url_for from ..decorators import marshal_with, ThingProperty, Tag -from ..resource import Resource +from ..view import View from ..schema import TaskSchema from openflexure_microscope.common.labthings_core import tasks @@ -9,7 +9,7 @@ from openflexure_microscope.common.labthings_core import tasks @ThingProperty @Tag("tasks") -class TaskList(Resource): +class TaskList(View): @marshal_with(TaskSchema(many=True)) def get(self): """ @@ -19,7 +19,7 @@ class TaskList(Resource): @Tag(["properties", "tasks"]) -class TaskResource(Resource): +class TaskView(View): @marshal_with(TaskSchema()) def get(self, id): """ From 26e719feff765c3dca7a4697fa47532ce57de0e7 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 14 Jan 2020 11:45:04 +0000 Subject: [PATCH 113/122] Tidied up docstrings --- openflexure_microscope/api/app.py | 12 ------------ .../common/flask_labthings/views/extensions.py | 2 -- 2 files changed, 14 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 23f957b8..a095c6c1 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -109,12 +109,6 @@ for name, action in views.enabled_root_actions().items(): def routes(): """ List of all connected API routes - - .. :quickref: Global; Routes - - :>header Accept: application/json - :>header Content-Type: application/json - :status 200: stream active """ return jsonify(list_routes(app)) @@ -123,12 +117,6 @@ def routes(): def err_log(): """ Most recent 1mb of log output - - .. :quickref: Global; Log - - :>header Accept: application/json - :>header Content-Type: application/json - :status 200: stream active """ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") return send_file( diff --git a/openflexure_microscope/common/flask_labthings/views/extensions.py b/openflexure_microscope/common/flask_labthings/views/extensions.py index 66684682..836af0bb 100644 --- a/openflexure_microscope/common/flask_labthings/views/extensions.py +++ b/openflexure_microscope/common/flask_labthings/views/extensions.py @@ -18,8 +18,6 @@ class ExtensionList(View): """ Return the current Extension forms - .. :quickref: Extension; Get forms - Returns an array of present Extension forms (describing Extension user interfaces.) Please note, this is *not* a list of all enabled Extensions, only those with associated user interface forms. From 1576bb75531bc7c0e7d01fcae4ddc38241ebd2df Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 14 Jan 2020 15:05:42 +0000 Subject: [PATCH 114/122] Fixed UUID keys --- openflexure_microscope/api/default_extensions/zip_builder.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 8558036f..67bac694 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -77,15 +77,16 @@ class ZipManager: update_task_progress(int((index / n_files) * 100)) session_id = uuid.uuid4() + session_key = str(session_id) # self.session_zips[session_id] = fp - self.session_zips[session_id] = { + self.session_zips[session_key] = { "id": session_id, "fp": fp, "data_size": data_size_megabytes, "zip_size": os.path.getsize(fp.name) * 1e-6, } - return self.session_zips[session_id] + return self.session_zips[session_key] def zip_from_id(self, session_id): return self.session_zips[session_id]["fp"] From 6e46ab589159576abd198e55da11ed7752cd251e Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 14 Jan 2020 15:05:50 +0000 Subject: [PATCH 115/122] Added default UUID serialisation --- openflexure_microscope/config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index bfe09ced..d0d730f8 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -3,6 +3,7 @@ import os import errno import logging import shutil +from uuid import UUID import numpy as np from fractions import Fraction @@ -23,8 +24,10 @@ class JSONEncoder(json.JSONEncoder): """ def default(self, o, markers=None): + if isinstance(o, UUID): + return str(o) # PiCamera fractions - if isinstance(o, Fraction): + elif isinstance(o, Fraction): return float(o) # Numpy integers elif isinstance(o, np.integer): From b1303926db2a4a51faf2762eeeeabb06365bd374 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 14 Jan 2020 15:06:00 +0000 Subject: [PATCH 116/122] Enable CORS on /routes --- openflexure_microscope/api/app.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index a095c6c1..6e769489 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -10,7 +10,7 @@ from flask import Flask, jsonify, send_file from datetime import datetime -from flask_cors import CORS +from flask_cors import CORS, cross_origin from openflexure_microscope.api.utilities import list_routes, init_default_extensions @@ -65,6 +65,9 @@ app, labthing = create_app( version=pkg_resources.get_distribution("openflexure_microscope").version, ) +# Enable CORS for some routes outside of LabThings +cors = CORS(app) + # Use custom JSON encoder app.json_encoder = JSONEncoder @@ -99,6 +102,7 @@ labthing.add_view(views.MjpegStream, f"/streams/mjpeg") labthing.add_view(views.SnapshotStream, f"/streams/snapshot") # Attach microscope action resources +labthing.add_view(views.actions.ActionsView, "/actions") for name, action in views.enabled_root_actions().items(): view_class = action["view_class"] rule = action["rule"] @@ -106,6 +110,7 @@ for name, action in views.enabled_root_actions().items(): @app.route("/routes") +@cross_origin() def routes(): """ List of all connected API routes From 38f92c6261156a9a7439894b897646db71f36b57 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 14 Jan 2020 15:06:31 +0000 Subject: [PATCH 117/122] Fixed f-string --- openflexure_microscope/common/flask_labthings/quick.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/common/flask_labthings/quick.py b/openflexure_microscope/common/flask_labthings/quick.py index 97f919a8..2f9f5ed4 100644 --- a/openflexure_microscope/common/flask_labthings/quick.py +++ b/openflexure_microscope/common/flask_labthings/quick.py @@ -20,7 +20,7 @@ def create_app( # Handle CORS if handle_cors: - cors_handler = CORS(app, resources=r"{prefix}/*") + cors_handler = CORS(app, resources=f"{prefix}/*") # Handle errors if handle_errors: From fc1ba3d83499013e4fb6e62059196b9f34ebc369 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 14 Jan 2020 15:06:50 +0000 Subject: [PATCH 118/122] Add system actions list --- .../api/v2/views/actions/__init__.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/openflexure_microscope/api/v2/views/actions/__init__.py b/openflexure_microscope/api/v2/views/actions/__init__.py index 20d3a4aa..076548a5 100644 --- a/openflexure_microscope/api/v2/views/actions/__init__.py +++ b/openflexure_microscope/api/v2/views/actions/__init__.py @@ -4,6 +4,11 @@ Top-level representation of enabled actions from . import camera, stage, system +from openflexure_microscope.common.flask_labthings.view import View +from openflexure_microscope.common.flask_labthings.find import current_labthing +from openflexure_microscope.common.flask_labthings.utilities import description_from_view +from openflexure_microscope.common.flask_labthings.decorators import Tag + _actions = { "capture": { "rule": "/camera/capture/", @@ -46,3 +51,33 @@ _actions = { def enabled_root_actions(): global _actions return {k: v for k, v in _actions.items() if v["conditions"]} + + +@Tag("actions") +class ActionsView(View): + def get(self): + """ + List of enabled default API actions. + + This list does not include any actions added by LabThings extensions, only + those part of the default OpenFlexure Microscope API. + """ + global _actions + + actions = {} + for name, action in enabled_root_actions().items(): + d = { + "links": { + "self": { + "href": current_labthing().url_for(action["view_class"]), + "mimetype": "application/json", + **description_from_view(action["view_class"]) + } + }, + "rule": action["rule"], + "view_class": str(action["view_class"]), + } + + actions[name] = d + + return actions From aa4445de7a91704e32f404b13e8d47e5f3b50e8e Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 14 Jan 2020 15:07:06 +0000 Subject: [PATCH 119/122] Fixed borked metadata PUT validation --- openflexure_microscope/api/v2/views/captures.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 31eef23c..4cb82325 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -223,8 +223,9 @@ class CaptureMetadata(View): return abort(404) # 404 Not Found data_dict = JsonResponse(request).json + logging.debug(data_dict) - if type(data_dict) != list: + if type(data_dict) != dict: return abort(400) # TODO: Allow putting system metadata maybe? From ab08f18ff031561241d625c33f09d337acde8ebe Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 14 Jan 2020 15:07:30 +0000 Subject: [PATCH 120/122] Automatically include extension info in expanded plugin form --- openflexure_microscope/api/utilities/gui.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/openflexure_microscope/api/utilities/gui.py b/openflexure_microscope/api/utilities/gui.py index 170f8657..19d83bd8 100644 --- a/openflexure_microscope/api/utilities/gui.py +++ b/openflexure_microscope/api/utilities/gui.py @@ -3,8 +3,11 @@ import logging from functools import wraps -def expand_routes_from_dict(gui_description, extension_object): +def build_gui_from_dict(gui_description, extension_object): + # Make a working copy of GUI description api_gui = copy.deepcopy(gui_description) + + # Expand shorthand routes into full relative URLs if "forms" in gui_description and isinstance(api_gui["forms"], list): for form in api_gui["forms"]: if "route" in form and form["route"] in extension_object._rules.keys(): @@ -13,22 +16,26 @@ def expand_routes_from_dict(gui_description, extension_object): logging.warn( "No valid expandable route found for {}".format(form["route"]) ) + + # Inject extension information + api_gui["id"] = extension_object.name + api_gui["version"] = extension_object.version return api_gui -def expand_routes_from_func(func, extension_object): +def build_gui_from_func(func, extension_object): @wraps(func) def wrapped(*args, **kwargs): - return expand_routes_from_dict(func(*args, **kwargs), extension_object) + return build_gui_from_dict(func(*args, **kwargs), extension_object) return wrapped -def expand_routes(gui_description, extension_object): +def build_gui(gui_description, extension_object): # If given a function that generates a GUI dictionary if callable(gui_description): # Wrap in the route expander - return expand_routes_from_func(gui_description, extension_object) + return build_gui_from_func(gui_description, extension_object) # If given a dictionary directly elif isinstance(gui_description, dict): # Build a GUI generator function @@ -36,6 +43,6 @@ def expand_routes(gui_description, extension_object): return gui_description # Wrap in the route expander - return expand_routes_from_func(gui_description_func, extension_object) + return build_gui_from_func(gui_description_func, extension_object) else: raise RuntimeError("GUI description must be a function or a dictionary") From 0f16c5c894670b241d879ba2d07432cf108754dc Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 14 Jan 2020 15:07:43 +0000 Subject: [PATCH 121/122] Fixed GUI building --- openflexure_microscope/api/example_extensions/ev_gui.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openflexure_microscope/api/example_extensions/ev_gui.py b/openflexure_microscope/api/example_extensions/ev_gui.py index 469c02aa..9a7c1552 100644 --- a/openflexure_microscope/api/example_extensions/ev_gui.py +++ b/openflexure_microscope/api/example_extensions/ev_gui.py @@ -5,7 +5,7 @@ from openflexure_microscope.common.flask_labthings.decorators import ( ThingProperty, ) -from openflexure_microscope.api.utilities.gui import expand_routes +from openflexure_microscope.api.utilities.gui import build_gui import logging @@ -17,6 +17,7 @@ val_int = 0 def dynamic_form(): + global val_int return { "id": "test-plugin", "icon": "pets", @@ -34,6 +35,7 @@ def dynamic_form(): "name": "val_int", "label": "Number value", "minValue": 0, + "value": val_int }, { "fieldType": "htmlBlock", @@ -64,6 +66,7 @@ static_form = { "name": "val_int", "label": "Number value", "minValue": 0, + "default": 1 }, { "fieldType": "htmlBlock", @@ -102,7 +105,7 @@ dynamic_test_extension_v2.add_view( ) dynamic_test_extension_v2.add_meta( - "gui", expand_routes(dynamic_form, dynamic_test_extension_v2) + "gui", build_gui(dynamic_form, dynamic_test_extension_v2) ) @@ -115,5 +118,5 @@ static_test_extension_v2.add_view( TestDoAPIView, "/do", endpoint="static_test_extension_do" ) static_test_extension_v2.add_meta( - "gui", expand_routes(static_form, static_test_extension_v2) + "gui", build_gui(static_form, static_test_extension_v2) ) From bf9a1e358ec4276d7dfbd5a0d23fc1c4e8edb246 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 14 Jan 2020 15:29:35 +0000 Subject: [PATCH 122/122] Fixed finding captures by UUID --- openflexure_microscope/camera/base.py | 6 +++--- openflexure_microscope/utilities.py | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index 1bdf33bf..ff1c3527 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -9,7 +9,7 @@ import logging from abc import ABCMeta, abstractmethod from .capture import CaptureObject -from openflexure_microscope.utilities import entry_by_id +from openflexure_microscope.utilities import entry_by_uuid from openflexure_microscope.common.labthings_core.lock import StrictLock @@ -250,11 +250,11 @@ class BaseCamera(metaclass=ABCMeta): def image_from_id(self, image_id): """Return an image StreamObject with a matching ID.""" - return entry_by_id(image_id, self.images) + return entry_by_uuid(image_id, self.images) def video_from_id(self, video_id): """Return a video StreamObject with a matching ID.""" - return entry_by_id(video_id, self.videos) + return entry_by_uuid(video_id, self.videos) # CREATING NEW CAPTURES diff --git a/openflexure_microscope/utilities.py b/openflexure_microscope/utilities.py index 64379f4d..bd88664e 100644 --- a/openflexure_microscope/utilities.py +++ b/openflexure_microscope/utilities.py @@ -2,6 +2,7 @@ import re import copy import operator import base64 +from uuid import UUID import numpy as np from collections import abc from functools import reduce @@ -78,11 +79,20 @@ def filter_dict(dictionary: dict, keys: list): return out -def entry_by_id(entry_id: str, object_list: list): +def entry_by_uuid(entry_id: str, object_list: list): """Return an object from a list, if .id matches id argument.""" found = None + if type(entry_id) == str: + converter = str + elif type(entry_id) == int: + converter = int + elif isinstance(entry_id, UUID): + converter = int + else: + raise TypeError("Argument entry_id must be a string, integer, or UUID object.") for o in object_list: - if o.id == entry_id: + # Convert to strings (in case of UUID objects, for example) + if converter(o.id) == converter(entry_id): found = o return found