From 8157fa6d58f62c51496af9d46834046d9abca6b7 Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 27 Jun 2021 21:08:48 +0100 Subject: [PATCH 01/36] Add YAML export from APISpec --- docs/Makefile | 5 +++++ openflexure_microscope/api/app.py | 2 ++ .../api/v2/views/__init__.py | 1 + openflexure_microscope/api/v2/views/docs.py | 21 +++++++++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 openflexure_microscope/api/v2/views/docs.py diff --git a/docs/Makefile b/docs/Makefile index 5d25534e..4d2bdb40 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -13,6 +13,11 @@ help: .PHONY: help Makefile +openapi: $(BUILDDIR)/swagger.yaml + +$(BUILDDIR)/swagger.yaml: Makefile + curl http://localhost:5000/api/v2/docs/swagger.yaml > $@ + # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index d288bf65..9bc7f9d9 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -153,6 +153,8 @@ labthing.add_view( views.NestedConfigurationProperty, "/instrument/configuration/" ) labthing.add_root_link(views.ConfigurationProperty, "instrumentConfiguration") +labthing.add_view(views.APISpecJSONView, "/docs/swagger.json") +labthing.add_view(views.APISpecYAMLView, "/docs/swagger.yaml") # Attach stage resources labthing.add_view(views.StageTypeProperty, "/instrument/stage/type") diff --git a/openflexure_microscope/api/v2/views/__init__.py b/openflexure_microscope/api/v2/views/__init__.py index 5771e204..c5f59242 100644 --- a/openflexure_microscope/api/v2/views/__init__.py +++ b/openflexure_microscope/api/v2/views/__init__.py @@ -4,3 +4,4 @@ from .captures import * from .instrument import * from .stage import * from .streams import * +from .docs import * \ No newline at end of file diff --git a/openflexure_microscope/api/v2/views/docs.py b/openflexure_microscope/api/v2/views/docs.py new file mode 100644 index 00000000..999668ac --- /dev/null +++ b/openflexure_microscope/api/v2/views/docs.py @@ -0,0 +1,21 @@ +from labthings.views import View +from labthings import current_labthing +from flask import Response + +class APISpecJSONView(View): + """OpenAPI v3 documentation + + A JSON document containing an API description in OpenAPI format + """ + + def get(self): + return current_labthing().spec.to_dict() + +class APISpecYAMLView(View): + """OpenAPI v3 documentation + + A YAML document containing an API description in OpenAPI format + """ + + def get(self): + return Response(current_labthing().spec.to_yaml(), mimetype='text/yaml') \ No newline at end of file From 1e21ce51fcb1b5763d4a7909d3b83f82ccd85d46 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 6 Jul 2021 15:18:53 +0100 Subject: [PATCH 02/36] Some basic validation for OpenAPI I was getting unhelpful OpenAPI errors, this Python script makes it easier to figure out where they come from. --- docs/test_swagger_yaml.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/test_swagger_yaml.py diff --git a/docs/test_swagger_yaml.py b/docs/test_swagger_yaml.py new file mode 100644 index 00000000..d37fcbd7 --- /dev/null +++ b/docs/test_swagger_yaml.py @@ -0,0 +1,15 @@ +import yaml +with open("./build/swagger.yaml", "r") as f: + api = yaml.load(f) + +for path, methods in api["paths"].items(): + for method, properties in methods.items(): + try: + for k in ["responses", "description"]: + current_key = k + _ = properties[k] + for code, r in properties["responses"].items(): + current_key = f"responses/{code}/description" + _ = r["description"] + except KeyError as e: + print(f"❌ {method} {path} has a problem: {e} from {current_key}") From 202d942c90a4550a54cbb30d593ac4694a9fd7b1 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 6 Jul 2021 15:23:42 +0100 Subject: [PATCH 03/36] Add entry point to generate OpenAPI yaml/json This is designed to be used as a console entry point so we can generate the OpenAPI description without starting the server. --- openflexure_microscope/api/app.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 9bc7f9d9..1739bec0 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +import argparse import atexit import logging import logging.handlers @@ -243,6 +244,27 @@ def ofm_serve(): server: Server = Server(app) server.run(host="0.0.0.0", port=5000, debug=debug_app, zeroconf=True) +def generate_openapi(): + parser = argparse.ArgumentParser("Generate an OpenAPI specification document") + parser.add_argument( + '-o', + dest='output', + default="openapi.yaml", + help=( + 'Specify the output filename. If it ends in .json, we output JSON.' + 'Use .yml or .yaml for YAML (which is the default' + ) + ) + args = parser.parse_args() + fname = args.output + if fname.endswith(".json"): + import json + with open(fname, 'w') as fd: + json.dump(labthing.spec.to_dict(), fd) + else: + with open(fname, 'w') as fd: + fd.write(labthing.spec.to_yaml()) + # Start the app if the module is run directly if __name__ == "__main__": From c81c4eb64d743eaf01282de5f150077d58779b90 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 6 Jul 2021 15:24:17 +0100 Subject: [PATCH 04/36] Better API docs for autofocus extension --- .../api/default_extensions/autofocus.py | 380 ++++++++++++------ 1 file changed, 249 insertions(+), 131 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index 15044f70..ebbd6630 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -1,3 +1,4 @@ +import inspect import logging import time from contextlib import contextmanager @@ -14,6 +15,7 @@ from openflexure_microscope.devel import abort from openflexure_microscope.microscope import Microscope from openflexure_microscope.stage.base import BaseStage from openflexure_microscope.utilities import set_properties +from labthings.utilities import get_docstring, get_summary ### Autofocus utilities @@ -144,6 +146,117 @@ def sharpness_edge(image: np.ndarray) -> float: ) +def find_microscope() -> dict: + """Find the microscope component or raise an exception. + + This function will fail with HTTPError extensions if it can't + find the appropriate hardware. + + It is returned as a dictionary with one key, "microscope". + This means it can be easily merged into an arguments dictionary. + """ + microscope = find_component("org.openflexure.microscope") + + if not microscope: + abort(503, "No microscope connected. Unable to autofocus.") + + return {"microscope": microscope} + +def find_microscope_with_real_stage() -> dict: + """Find the microscope and ensure it has a real stage. + + This function wraps `find_microscope()` and additionally asserts + that there is a real stage, raising a `503` code if not. + """ + args = find_microscope() + + if not args["microscope"].has_real_stage(): + abort(503, "No stage connected. Unable to autofocus.") + + return args + + +def extension_action(args=None, extra_args_functions=None): + """A decorator to auto-create an Action endpoint for a method + + Use this decorator on any method of an extension (`BaseExtension` subclass) + and it will automatically be added to the API. At present it is deliberately + basic, and the plan is to expand the options in the future. + + Currently, you may specify `args` (which is a Marshmallow-format schema + or dictionary, determining the datatype of the arguments, which will be + taken from the JSON payload of the POST request initiating the action). + + It is also possible to specify a list of functions, which will be executed + in order, and their results will be added to the arguments. This allows, + for example, hardware components to be injected into the arguments. This + feature is experimental and should not be relied upon. + + The parsed arguments dictionary is expanded as the function arguments, + i.e. we call `decorated_method(self, **kwargs). This means that any + unused arguments will cause an error, which is probably good practice... + + NB this decorator does **not** replace the function with a `View` or + register it with the parent `Extension`. It adds the created `View` as + a property of the function, `flask_view`. The parent `Extension` is + responsible for collating and adding the views in its `__init__` method. + """ + supplied_args = args + def decorator(func): + class_docstring = f"""Manage actions for {func.__name__}. + + This `View` class will return a list of `Action` objects representing + each time {func.__name__} has been run in response to a `GET` request, + and will start a new `Action` in response to a `POST` request. + """ + class ActionViewWrapper(ActionView): + __doc__ = class_docstring + args = supplied_args + + def post(self, args): + # Inject arguments, if functions are supplied (TODO: I don't love this...) + if extra_args_functions: + for f in extra_args_functions: + args.update(f()) + # Run the action + return func(**args) + + def get(self, *args, **kwargs): + # Explicitly wrap the `get` method to allow us to add a docstring + return super().get(*args, **kwargs) + + # Create a nice docstring. NB because the source function and this docstring + # aren't guaranteed to have the same leading whitespace, we just make sure + # both docstrings are stripped of leading indent, using `inspect.cleandoc()` + ActionViewWrapper.post.description = ( + get_docstring(func, remove_newlines=False) + + "\n\n" + + inspect.cleandoc( + """ + This `POST` request starts an Action, i.e. the hardware will do something + that may continue after the HTTP request has been responded to. The + response will always be an Action object, that details the current + status of the action and provides an interface to poll for completion. + + If the action completes within a specified timeout, we will return + an HTTP status code of `200` and the return value will include any + output from the action. If it does not complete, we will return a + `201` response code, and the action's endpoint may be polled to follow + its progress. + """ + ) + ) + ActionViewWrapper.post.summary = get_summary(func) + ActionViewWrapper.post.__doc__ = ActionViewWrapper.post.description + ActionViewWrapper.__name__ = func.__name__ + ActionViewWrapper.get.summary = f"List running and completed `{func.__name__}` actions." + + func.flask_view = ActionViewWrapper + return func + return decorator + + + ### Autofocus extension @@ -157,21 +270,30 @@ class AutofocusExtension(BaseExtension): self.add_view( MeasureSharpnessAPI, "/measure_sharpness", endpoint="measure_sharpness" ) - self.add_view(AutofocusAPI, "/autofocus", endpoint="autofocus") - self.add_view(FastAutofocusAPI, "/fast_autofocus", endpoint="fast_autofocus") - self.add_view( - UpDownUpAutofocusAPI, "/updownup_autofocus", endpoint="updownup_autofocus" - ) + self.add_decorated_method_views() - self.add_view( - MoveAndMeasureAPI, "/move_and_measure", endpoint="move_and_measure" - ) + def add_decorated_method_views(self): + """Add views from any methods that have been decorated + + Using the decorators `@extension_action()` et al will add a + property to the decorated method, `method_view`. If this is + present, this function will add the views to the extension. + """ + for k in dir(self): + obj = getattr(self, k) + if hasattr(obj, "flask_view"): + name = obj.__name__ + self.add_view(obj.flask_view, f"/{name}", name) def measure_sharpness( self, microscope: Microscope, metric_fn: Callable = sharpness_sum_lap2 ) -> float: - """Measure the sharpness of the camera's current view.""" - + """Measure the sharpness from the MJPEG stream + + Take a JPEG snapshot from the camera (extracted from the live preview stream) + and return its size. This is the sharpness metric used by the fast autofocus + method. + """ if hasattr(microscope.camera, "array") and callable( getattr(microscope.camera, "array") ): @@ -179,14 +301,19 @@ class AutofocusExtension(BaseExtension): else: raise RuntimeError(f"Object {microscope.camera} has no method `array`") + @extension_action( + args = {"dz": fields.List(fields.Int())}, + extra_args_functions = [find_microscope_with_real_stage], + ) def autofocus( self, microscope: Microscope, - dz: List[int], + dz: Optional[List[int]] = None, settle: float = 0.5, metric_fn: Callable = sharpness_sum_lap2, ) -> Tuple[List[int], List[float]]: """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 @@ -195,6 +322,8 @@ class AutofocusExtension(BaseExtension): """ camera: BaseCamera = microscope.camera stage: BaseStage = microscope.stage + if not dz: + dz = np.linspace(-300, 300, 7) with set_properties(stage, backlash=256), stage.lock, camera.lock: sharpnesses: List[float] = [] @@ -221,20 +350,96 @@ class AutofocusExtension(BaseExtension): with monitor_sharpness(microscope) as m: m.focus_rel(dz) return m.sharpest_z_on_move(0) - + + @extension_action( + args={"dz": fields.Int(required=True)}, + extra_args_functions=[find_microscope_with_real_stage], + ) def move_and_measure( self, microscope: Microscope, dz: int ) -> Dict[str, np.ndarray]: - """Make a relative Z move and return the sharpness data""" + """Make a relative move in Z and measure dynamic sharpness + + This accesses the underlying method used by the fast autofocus routines, to + move the stage while monitoring the sharpness, as reported by the size of + each JPEG frame in the preview stream. It returns a dictionary with + stage position vs time and image size (i.e. sharpness) vs time. + """ with monitor_sharpness(microscope) as m: m.focus_rel(dz) return m.data_dict() + @extension_action( + args={"dz": fields.Int(missing=2000, example=2000)}, + extra_args_functions=[find_microscope_with_real_stage], + ) def fast_autofocus( self, microscope: Microscope, dz: int = 2000 ) -> Dict[str, np.ndarray]: - """Perform a down-up-down-up autofocus""" - with microscope.camera.lock, microscope.stage.lock: + """Perform a fast down-up-down-up autofocus + + This "fast" autofocus method moves the stage continuously in Z, while + following the sharpness using the MJPEG stream. This version is the + simplest "fast" autofocus method, and performs the following sequence + of moves: + + 1. Move to `-dz/2`, i.e. the bottom of the range + 2. Move up by `dz`, i.e. to the top of the range, while recording the + sharpness of the image as a function of time. Record the estimated + position of the stage when the sharpness was maximised, `fz`. + 3. Move back to the bottom (by `-dz`) + 4. Move up to the position where it was sharpest. + + ## Backlash correction + This routine should cancel out backlash: the stage is moving upwards as + we record the sharpnes vs z data, and it is also moving upwards when + we make the final move to the sharpest point. Mechanical backlash should + therefore be the same in both cases. + + This does not account for lag between the sharpness measurements and the + stage's motion; that has been tested for and seems not to be a big issue + most of the time, but may need to be accounted for in the future, if + hardware or software changes increase the latency. + + ## Sharpness metric + This method uses the MJPEG preview stream to estimate the sharpness of + the image. MJPEG streams consist of a series of independent JPEG images, + so each frame can be looked at in isolation (though see later for an + important caveat). JPEG images are compressed lossily, by taking the + discrete cosine transform (DCT) of each 8x8 block in the image. A very + rough precis of how this works is that after the DCT, cosine components + that are deemed unimportant (i.e. smaller than a threshold) are discarded. + The effect is that images with lots of high-frequency information have a + larger file size. + + We look only at the size of each JPEG frame in the stream, so we get a + remarkably robust estimate of image sharpness without even opening the + images! That's what lets us analyse 30 images/second even on the very + limited processing power available to the Raspberry Pi 3. + + > *Warning* + > We assume that JPEG frames are independent. This is only true if the + > MJPEG stream is encoded at *constant quality* without any additional + > bit rate control. By default, many streams will reduce the quality + > factor if they exceed a target bit rate, which badly affects this + > method. We turn off bit rate limiting for the Raspberry Pi camera, + > which fixes the problem, at the expense of sometimes failing if + > particularly sharp images appear in the stream, as there is a fairly + > small maximum size for each JPEG frame beyond which empty images are + > returned. + + ## Estimation of sharpness vs z + What we record during an autofocus is two time series, from two parallel + threads. One thread monitors the camera, and records the size of each + JPEG frame as a function of time. NB this is time from `time.time()` + in Python, so will not be microsecond-accurate. The other thread is + responsible for moving the stage, and records its current Z position + before and after each move. Interpolating between these `(t, z)` points + gives us a `z` value for each JPEG size, and so we can estimate the + JPEG size as a function of `z` and hence determine the `z` value at + which sharpness is maximised. + """ + with microscope.lock(timeout=1), microscope.camera.lock, microscope.stage.lock: with monitor_sharpness(microscope) as m: # Move to (-dz / 2) m.focus_rel(-dz / 2) @@ -253,15 +458,28 @@ class AutofocusExtension(BaseExtension): # Return all focus data return m.data_dict() + @extension_action( + args = { + "dz": fields.Int(missing=2000, example=2000), + "backlash": fields.Int(missing=25, minimum=0), + }, + extra_args_functions=[find_microscope_with_real_stage], + ) def fast_up_down_up_autofocus( self, microscope: Microscope, dz: int = 2000, target_z: int = 0, initial_move_up: bool = True, - mini_backlash: int = 25, + backlash: Optional[int] = None, + mini_backlash: Optional[int] = None, ) -> Dict[str, np.ndarray]: - """Autofocus by measuring on the way down, and moving back up with feedback. + """Perform a fast up-down-up autofocus, with feedback + + Autofocus by measuring on the way down, and moving back up with feedback. + This is a "fast" autofocus method, i.e. it moves the stage continuously + while monitoring the sharpness using the MJPEG stream. See `fast_autofocus` + for more details. This autofocus method is very efficient, as it only passes the peak once. The sequence of moves it performs is: @@ -277,7 +495,10 @@ class AutofocusExtension(BaseExtension): 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. + backlash, by using the sharpness-vs-z curve as a rough encoder for Z. The + main source of error is that the curves on the way up and the way down are + not always identical, largely due to small lateral shifts as the Z axis is + moved. Parameters: dz: number of steps over which to scan (optional, default 2000) @@ -290,13 +511,18 @@ class AutofocusExtension(BaseExtension): 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 25) is a small extra move made in step + backlash: (optional, default 25) 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. + + mini_backlash: (optional, default 25) is an alias for `backlash` and will be + removed in due course. """ - with microscope.camera.lock, microscope.stage.lock: + if not mini_backlash: # I renamed the argument, + mini_backlash = backlash or 25 + with microscope.lock(timeout=1), microscope.camera.lock, microscope.stage.lock: with monitor_sharpness(microscope) as m: # Ensure the MJPEG stream has started microscope.camera.start_stream() @@ -347,116 +573,8 @@ class AutofocusExtension(BaseExtension): class MeasureSharpnessAPI(View): + __doc__ = AutofocusExtension.measure_sharpness.__doc__ def post(self): - microscope = find_component("org.openflexure.microscope") + microscope = find_microscope()["microscope"] - if not microscope: - abort(503, "No microscope connected. Unable to measure sharpness.") - - return {"sharpness": self.extension.measure_sharpness(microscope)} - - -class MoveAndMeasureAPI(ActionView): - """ - Move the stage and measure dynamic sharpness - """ - - args = {"dz": fields.Int(required=True)} - - def post(self, args): - microscope = find_component("org.openflexure.microscope") - - if not microscope: - abort(503, "No microscope connected. Unable to autofocus.") - - if microscope.has_real_stage(): - # Acquire microscope lock with 1s timeout - with microscope.camera.lock, microscope.stage.lock: - return self.extension.move_and_measure(microscope, dz=args.get("dz")) - - else: - abort(503, "No stage connected. Unable to autofocus.") - - -class AutofocusAPI(ActionView): - """ - Run a standard autofocus - """ - - args = {"dz": fields.List(fields.Int())} - - def post(self, args): - microscope = find_component("org.openflexure.microscope") - - if not microscope: - abort(503, "No microscope connected. Unable to autofocus.") - - # Figure out the range of z values to use - dz = np.array(args.get("dz", np.linspace(-300, 300, 7))) - - if microscope.has_real_stage(): - logging.debug("Running autofocus...") - - # return a handle on the autofocus task - return self.extension.autofocus(microscope, dz) - - else: - abort(503, "No stage connected. Unable to autofocus.") - - -class FastAutofocusAPI(ActionView): - """ - Run a fast autofocus - """ - - args = {"dz": fields.Int(missing=2000, example=2000)} - - def post(self, args): - microscope = find_component("org.openflexure.microscope") - - if not microscope: - abort(503, "No microscope connected. Unable to autofocus.") - - if microscope.has_real_stage(): - logging.debug("Running autofocus...") - # Acquire microscope lock with 1s timeout - with microscope.lock(timeout=1): - # Run fast_autofocus - return self.extension.fast_autofocus(microscope, dz=args.get("dz")) - - else: - abort(503, "No stage connected. Unable to autofocus.") - - -class UpDownUpAutofocusAPI(ActionView): - """ - Run a fast up-down-up autofocus - """ - - args = { - "dz": fields.Int(missing=2000, example=2000), - "backlash": fields.Int(missing=25, minimum=0), - } - - def post(self, args): - microscope = find_component("org.openflexure.microscope") - - if not microscope: - abort(503, "No microscope connected. Unable to autofocus.") - - # Figure out the parameters to use - dz = args.get("dz") - backlash = args.get("backlash") - - if microscope.has_real_stage(): - logging.debug("Running autofocus...") - - # Acquire microscope lock with 1s timeout - with microscope.lock(timeout=1): - # Run fast_up_down_up_autofocus - return self.extension.fast_up_down_up_autofocus( - microscope, dz=dz, mini_backlash=backlash - ) - - else: - abort(503, "No stage connected. Unable to autofocus.") + return {"sharpness": self.extension.measure_sharpness(microscope)} \ No newline at end of file From 00531a93bf39f8207bc2117c4f52986a76af33b1 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 6 Jul 2021 15:24:43 +0100 Subject: [PATCH 05/36] Fixed response descriptions for stream/RAM capture --- .../api/v2/views/actions/camera.py | 11 +++++++---- openflexure_microscope/api/v2/views/streams.py | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index b5dab436..9d66ded6 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -66,12 +66,15 @@ class CaptureAPI(ActionView): class RAMCaptureAPI(ActionView): - """ - Take a non-persistant image capture. - """ + """Take a non-persistent image capture.""" args = BasicCaptureArgs() - responses = {200: {"content_type": "image/jpeg"}} + responses = { + 200: { + "content_type": "image/jpeg", + "description": "A JPEG image, representing the capture" + } + } def post(self, args): """ diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index 38a781c2..a7c3805e 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -17,7 +17,23 @@ class MjpegStream(PropertyView): Real-time MJPEG stream from the microscope camera """ - responses = {200: {"content_type": "multipart/x-mixed-replace"}} + responses = { + 200: { + "content_type": "multipart/x-mixed-replace", + "description": ( + "An MJPEG stream of camera images.\n\n" + "This endpoint will serve JPEG images sequentially, \n" + "with each frame separated by `--frame` and a \n" + "`Content-Type: image/jpeg` header.\n" + "Using this endpoint as the `src` of an HTML `` \n" + "tag will result in the video stream displaying without \n" + "further effort.\n\n" + "If you save the stream to disk (e.g. with `curl`), be \n" + "aware that the text in between frames may confuse some \n" + "video players." + ), + } + } def get(self): """ From 19abfff061f44488aa724a12f6508555e6510cca Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 6 Jul 2021 15:26:27 +0100 Subject: [PATCH 06/36] Add HTTP API to docs --- docs/Makefile | 4 ++-- docs/source/api.rst | 8 +++++++- docs/source/conf.py | 25 +++++++++++++++++++++---- setup.py | 5 ++++- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 4d2bdb40..d2829e5b 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -16,9 +16,9 @@ help: openapi: $(BUILDDIR)/swagger.yaml $(BUILDDIR)/swagger.yaml: Makefile - curl http://localhost:5000/api/v2/docs/swagger.yaml > $@ + ofm-generate-openapi -o $@ # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile +html dirhtml singlehtml json htmlhelp epub latex latexpdf text changes xml linkcheck doctest coverage clean: openapi Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/source/api.rst b/docs/source/api.rst index 4fd4b616..f33d32a0 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -6,4 +6,10 @@ Live documentation Full, interactive Swagger documentation for your microscopes web API is available from the microscope itself. From any browser, go to ``http://{your microscope IP address}/api/v2/docs/swagger-ui``. -.. note:: We should have an online copy of the API SwaggerUI documentation up soon. \ No newline at end of file +.. note:: We should have an online copy of the API SwaggerUI documentation up soon. + +The API is now also detailed below, or there is a more interactive version of the docs statically compiled using ReDoc_ + +.. openapi:: ../build/swagger.yaml + +.. _ReDoc: api_redoc.html \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 4fd4b0c0..b1a2f862 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -15,12 +15,15 @@ import os import sys -# Load module from relative imports +# Load module from relative imports by modifying the path module_path = os.path.abspath("../..") sys.path.insert(0, module_path) # Handle mock imports for non-platform-agnostic modules +# This allows modules to load that depend on hardware that's not present, e.g. +# the `picamera` related modules. They won't *work* but if they load, we can +# extract the docstrings, which is what we care about here. from unittest.mock import MagicMock @@ -41,6 +44,7 @@ project = "OpenFlexure Microscope Software" copyright = "2018, Bath Open Instrumentation Group" # pylint: disable=redefined-builtin author = "Bath Open Instrumentation Group" +#TODO: extract version from ../setup.py # The short X.Y version version = "" # The full version, including alpha/beta/rc tags @@ -64,9 +68,11 @@ extensions = [ "sphinx.ext.viewcode", "sphinx.ext.githubpages", "sphinx.ext.ifconfig", - "sphinxcontrib.httpdomain", - "sphinxcontrib.autohttp.flask", - "sphinxcontrib.autohttp.flaskqref", + #"sphinxcontrib.httpdomain", + #"sphinxcontrib.autohttp.flask", + #"sphinxcontrib.autohttp.flaskqref", + "sphinxcontrib.openapi", + "sphinxcontrib.redoc" ] # Override ordering @@ -222,6 +228,7 @@ epub_exclude_files = ["search.html"] # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. +#TODO: update with pysangaboard? intersphinx_mapping = { "openflexure_stage": ("https://openflexure-stage.readthedocs.io/en/latest/", None), "picamerax": ("https://picamerax.readthedocs.io/en/latest//", None), @@ -233,3 +240,13 @@ intersphinx_mapping = { # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True + +# -- Options for redoc extension --------------------------------------------- +redoc = [ + { + 'name': 'OpenFlexure Microscope HTTP API', + 'page': 'api_redoc', + 'spec': '../build/swagger.yaml', + 'embed': True, + } +] \ No newline at end of file diff --git a/setup.py b/setup.py index b6f552d8..ed4cfd36 100644 --- a/setup.py +++ b/setup.py @@ -67,7 +67,9 @@ setup( # them to specific versions to enable consistent builds and testing. extras_require={ "dev": [ - "sphinxcontrib-httpdomain ~= 1.7", + "sphinx < 4.0", # Currently httpdomain isn't ready for 4.0 + "sphinxcontrib-openapi ~= 0.7", + "sphinx_rtd_theme ~=0.5.2", "rope ~= 0.14.0", "pylint ~= 2.3", "pytest ~= 6.1.2", @@ -87,6 +89,7 @@ setup( "console_scripts": [ "ofm-serve=openflexure_microscope.api.app:ofm_serve", "ofm-rescue=openflexure_microscope.rescue.auto:main", + "ofm-generate-openapi=openflexure_microscope.api.app:generate_openapi" ] }, project_urls={ From 65f5a69bad4f61f561d36e105ad2cfa693c99508 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 13 Jul 2021 22:49:06 +0100 Subject: [PATCH 07/36] fix openapi response in docs --- docs/source/extensions/actions.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/extensions/actions.rst b/docs/source/extensions/actions.rst index 5463ccfd..1a9fb2ac 100644 --- a/docs/source/extensions/actions.rst +++ b/docs/source/extensions/actions.rst @@ -22,7 +22,9 @@ Like properties, we use a special view class to identify a view as an action: `` # Our success response (200) returns an image (image/jpeg mimetype) responses = { - 200: {"content_type": "image/jpeg"} + 200: { + "content": { "image/jpeg": {} }, + } } def post(self, args): From 9892c7079af579682973767b40b7037b7026ca91 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 13 Jul 2021 22:50:42 +0100 Subject: [PATCH 08/36] moved find_microscope into functions --- .../api/default_extensions/autofocus.py | 167 +++++++++++------- 1 file changed, 104 insertions(+), 63 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index ebbd6630..3efd7973 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -21,6 +21,9 @@ from labthings.utilities import get_docstring, get_summary class JPEGSharpnessMonitor: + """Monitor JPEG frame size in a background thread + + This class starts a background thread """ def __init__(self, microscope: Microscope): self.microscope: Microscope = microscope self.camera: BaseCamera = microscope.camera @@ -146,37 +149,36 @@ def sharpness_edge(image: np.ndarray) -> float: ) -def find_microscope() -> dict: +def find_microscope() -> Microscope: """Find the microscope component or raise an exception. This function will fail with HTTPError extensions if it can't find the appropriate hardware. - It is returned as a dictionary with one key, "microscope". - This means it can be easily merged into an arguments dictionary. + We return a `Microscope` object """ microscope = find_component("org.openflexure.microscope") if not microscope: abort(503, "No microscope connected. Unable to autofocus.") - return {"microscope": microscope} + return microscope -def find_microscope_with_real_stage() -> dict: +def find_microscope_with_real_stage() -> Microscope: """Find the microscope and ensure it has a real stage. This function wraps `find_microscope()` and additionally asserts that there is a real stage, raising a `503` code if not. """ - args = find_microscope() + microscope = find_microscope() - if not args["microscope"].has_real_stage(): + if not microscope.has_real_stage(): abort(503, "No stage connected. Unable to autofocus.") - return args + return microscope -def extension_action(args=None, extra_args_functions=None): +def extension_action(args=None): """A decorator to auto-create an Action endpoint for a method Use this decorator on any method of an extension (`BaseExtension` subclass) @@ -187,13 +189,8 @@ def extension_action(args=None, extra_args_functions=None): or dictionary, determining the datatype of the arguments, which will be taken from the JSON payload of the POST request initiating the action). - It is also possible to specify a list of functions, which will be executed - in order, and their results will be added to the arguments. This allows, - for example, hardware components to be injected into the arguments. This - feature is experimental and should not be relied upon. - The parsed arguments dictionary is expanded as the function arguments, - i.e. we call `decorated_method(self, **kwargs). This means that any + i.e. we call `decorated_method(self, **kwargs)`. This means that any unused arguments will cause an error, which is probably good practice... NB this decorator does **not** replace the function with a `View` or @@ -213,13 +210,9 @@ def extension_action(args=None, extra_args_functions=None): __doc__ = class_docstring args = supplied_args - def post(self, args): - # Inject arguments, if functions are supplied (TODO: I don't love this...) - if extra_args_functions: - for f in extra_args_functions: - args.update(f()) + def post(self, arguments): # Run the action - return func(**args) + return func(**arguments) def get(self, *args, **kwargs): # Explicitly wrap the `get` method to allow us to add a docstring @@ -250,6 +243,18 @@ def extension_action(args=None, extra_args_functions=None): ActionViewWrapper.post.__doc__ = ActionViewWrapper.post.description ActionViewWrapper.__name__ = func.__name__ ActionViewWrapper.get.summary = f"List running and completed `{func.__name__}` actions." + ActionViewWrapper.get.description = ( + ActionViewWrapper.get.summary + + "\n\n" + + inspect.cleandoc( + f""" + This `GET` request will return a list of `Action` objects corresponding + to the `{func.__name__}` action. It will include all the times it has + been run since the server was last restarted, including running, completed, + and failed attempts. + """ + ) + ) func.flask_view = ActionViewWrapper return func @@ -286,7 +291,7 @@ class AutofocusExtension(BaseExtension): self.add_view(obj.flask_view, f"/{name}", name) def measure_sharpness( - self, microscope: Microscope, metric_fn: Callable = sharpness_sum_lap2 + self, microscope: Optional[Microscope] = None, metric_fn: Callable = sharpness_sum_lap2 ) -> float: """Measure the sharpness from the MJPEG stream @@ -294,6 +299,8 @@ class AutofocusExtension(BaseExtension): and return its size. This is the sharpness metric used by the fast autofocus method. """ + if not microscope: + microscope = find_microscope() if hasattr(microscope.camera, "array") and callable( getattr(microscope.camera, "array") ): @@ -302,12 +309,17 @@ class AutofocusExtension(BaseExtension): raise RuntimeError(f"Object {microscope.camera} has no method `array`") @extension_action( - args = {"dz": fields.List(fields.Int())}, - extra_args_functions = [find_microscope_with_real_stage], + args = { + "dz": fields.List( + fields.Int(), + description="An ascending list of relative z positions", + example = [int(x) for x in np.linspace(-300, 300, 7)], + ) + }, ) def autofocus( self, - microscope: Microscope, + microscope: Optional[Microscope] = None, dz: Optional[List[int]] = None, settle: float = 0.5, metric_fn: Callable = sharpness_sum_lap2, @@ -318,8 +330,11 @@ class AutofocusExtension(BaseExtension): 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) """ + if not microscope: + microscope = find_microscope_with_real_stage() camera: BaseCamera = microscope.camera stage: BaseStage = microscope.stage if not dz: @@ -345,18 +360,19 @@ class AutofocusExtension(BaseExtension): return positions, sharpnesses - def move_and_find_focus(self, microscope: Microscope, dz: int) -> int: + def move_and_find_focus(self, microscope: Optional[Microscope] = None, dz: int = 0) -> int: """Make a relative Z move and return the peak sharpness position""" + if not microscope: + microscope = find_microscope_with_real_stage() with monitor_sharpness(microscope) as m: m.focus_rel(dz) return m.sharpest_z_on_move(0) @extension_action( - args={"dz": fields.Int(required=True)}, - extra_args_functions=[find_microscope_with_real_stage], + args={"dz": fields.Int(required=True, description="The relative Z move to make")}, ) def move_and_measure( - self, microscope: Microscope, dz: int + self, microscope: Optional[Microscope] = None, dz: int = 0 ) -> Dict[str, np.ndarray]: """Make a relative move in Z and measure dynamic sharpness @@ -365,16 +381,23 @@ class AutofocusExtension(BaseExtension): each JPEG frame in the preview stream. It returns a dictionary with stage position vs time and image size (i.e. sharpness) vs time. """ + if not microscope: + microscope = find_microscope_with_real_stage() with monitor_sharpness(microscope) as m: m.focus_rel(dz) return m.data_dict() @extension_action( - args={"dz": fields.Int(missing=2000, example=2000)}, - extra_args_functions=[find_microscope_with_real_stage], + args={ + "dz": fields.Int( + missing=2000, + example=2000, + description="Total Z range to search over (in stage steps)" + ) + }, ) def fast_autofocus( - self, microscope: Microscope, dz: int = 2000 + self, microscope: Optional[Microscope] = None, dz: int = 2000 ) -> Dict[str, np.ndarray]: """Perform a fast down-up-down-up autofocus @@ -417,16 +440,16 @@ class AutofocusExtension(BaseExtension): images! That's what lets us analyse 30 images/second even on the very limited processing power available to the Raspberry Pi 3. - > *Warning* - > We assume that JPEG frames are independent. This is only true if the - > MJPEG stream is encoded at *constant quality* without any additional - > bit rate control. By default, many streams will reduce the quality - > factor if they exceed a target bit rate, which badly affects this - > method. We turn off bit rate limiting for the Raspberry Pi camera, - > which fixes the problem, at the expense of sometimes failing if - > particularly sharp images appear in the stream, as there is a fairly - > small maximum size for each JPEG frame beyond which empty images are - > returned. + ## Warning: frame independence + We assume that JPEG frames are independent. This is only true if the + MJPEG stream is encoded at *constant quality* without any additional + bit rate control. By default, many streams will reduce the quality + factor if they exceed a target bit rate, which badly affects this + method. We turn off bit rate limiting for the Raspberry Pi camera, + which fixes the problem, at the expense of sometimes failing if + particularly sharp images appear in the stream, as there is a fairly + small maximum size for each JPEG frame beyond which empty images are + returned. ## Estimation of sharpness vs z What we record during an autofocus is two time series, from two parallel @@ -439,6 +462,8 @@ class AutofocusExtension(BaseExtension): JPEG size as a function of `z` and hence determine the `z` value at which sharpness is maximised. """ + if not microscope: + microscope = find_microscope_with_real_stage() with microscope.lock(timeout=1), microscope.camera.lock, microscope.stage.lock: with monitor_sharpness(microscope) as m: # Move to (-dz / 2) @@ -460,14 +485,30 @@ class AutofocusExtension(BaseExtension): @extension_action( args = { - "dz": fields.Int(missing=2000, example=2000), - "backlash": fields.Int(missing=25, minimum=0), + "dz": fields.Int( + missing = 2000, + example = 2000, + description = "Total Z range to search over (in stage steps)" + ), + "target_z": fields.Int( + missing = 0, + example = -100, + description = "Target finishing position, relative to the focus." + ), + "initial_move_up": fields.Bool( + missing = True, + description = "Set to Flase to disable the initial move upwards" + ), + "backlash": fields.Int( + missing=25, + minimum=0, + description="Distance to undershoot, before correction move." + ), }, - extra_args_functions=[find_microscope_with_real_stage], ) def fast_up_down_up_autofocus( self, - microscope: Microscope, + microscope: Optional[Microscope] = None, dz: int = 2000, target_z: int = 0, initial_move_up: bool = True, @@ -501,25 +542,27 @@ class AutofocusExtension(BaseExtension): moved. Parameters: - dz: number of steps over which to scan (optional, default 2000) + * `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. + * `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. + * `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. - backlash: (optional, default 25) 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. + * **backlash**: (optional, default 25) 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. - mini_backlash: (optional, default 25) is an alias for `backlash` and will be - removed in due course. + * **mini_backlash**: (optional, default 25) is an alias for `backlash` and will be + removed in due course. """ + if not microscope: + microscope = find_microscope_with_real_stage() if not mini_backlash: # I renamed the argument, mini_backlash = backlash or 25 with microscope.lock(timeout=1), microscope.camera.lock, microscope.stage.lock: @@ -575,6 +618,4 @@ class AutofocusExtension(BaseExtension): class MeasureSharpnessAPI(View): __doc__ = AutofocusExtension.measure_sharpness.__doc__ def post(self): - microscope = find_microscope()["microscope"] - - return {"sharpness": self.extension.measure_sharpness(microscope)} \ No newline at end of file + return {"sharpness": self.extension.measure_sharpness()} \ No newline at end of file From 50ad9d4369b8f1e0ed67320afdb274345f35c378 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 13 Jul 2021 22:52:10 +0100 Subject: [PATCH 09/36] Fixed content dicts to be valid OpenAPI For functions that return JPEGs, we were using "content_type": "image/jpeg" instead we should use "content": { "image/jpeg": {}} --- openflexure_microscope/api/v2/views/actions/camera.py | 2 +- openflexure_microscope/api/v2/views/camera.py | 4 ++-- openflexure_microscope/api/v2/views/streams.py | 11 +++++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 9d66ded6..65b94eee 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -71,7 +71,7 @@ class RAMCaptureAPI(ActionView): args = BasicCaptureArgs() responses = { 200: { - "content_type": "image/jpeg", + "content": { "image/jpeg": {} }, "description": "A JPEG image, representing the capture" } } diff --git a/openflexure_microscope/api/v2/views/camera.py b/openflexure_microscope/api/v2/views/camera.py index 5b96b0f8..abe0c32e 100644 --- a/openflexure_microscope/api/v2/views/camera.py +++ b/openflexure_microscope/api/v2/views/camera.py @@ -14,14 +14,14 @@ class LSTImageProperty(PropertyView): responses = { 200: { - "content_type": "image/png", + "content": { "image/jpeg": {} }, "description": "Lens-shading table in RGB format", } } def get(self): """ - Get the stage geometry. + Get the lens shading table as an image. """ microscope = find_component("org.openflexure.microscope") diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index a7c3805e..b9602103 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -19,7 +19,9 @@ class MjpegStream(PropertyView): responses = { 200: { - "content_type": "multipart/x-mixed-replace", + "content": { + "multipart/x-mixed-replace": {} + }, "description": ( "An MJPEG stream of camera images.\n\n" "This endpoint will serve JPEG images sequentially, \n" @@ -60,7 +62,12 @@ class SnapshotStream(PropertyView): Single JPEG snapshot from the camera stream """ - responses = {200: {"content_type": "image/jpeg", "description": "Snapshot taken"}} + responses = { + 200: { + "content": { "image/jpeg": {} }, + "description": "Snapshot taken" + } + } def get(self): """ From 48fa1842a9b6878ffafe9ac6d38a6168c78a16c5 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 13 Jul 2021 22:53:35 +0100 Subject: [PATCH 10/36] Fixed stage schemas If missing=None, we need to specify allow_None=False in order to generate valid OpenAPI. This may also be fixed by an upstream change in LabThings. Also, OneOf was being used incorrectly in StageTypeProperty. --- .../api/v2/views/actions/stage.py | 14 +++++++++----- openflexure_microscope/api/v2/views/stage.py | 4 +++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index 206d9623..dd87e7e0 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -9,16 +9,19 @@ class MoveStageAPI(ActionView): "absolute": fields.Boolean( missing=False, example=False, description="Move to an absolute position" ), - "x": fields.Int(missing=None, example=100), - "y": fields.Int(missing=None, example=100), - "z": fields.Int(missing=None, example=20), + "x": fields.Int(missing=None, example=100, allow_none=False), + "y": fields.Int(missing=None, example=100, allow_none=False), + "z": fields.Int(missing=None, example=20, allow_none=False), } def post(self, args): """ Move the microscope stage in x, y, z - Any axes that are not specifed will not move. + This action moves the stage. Any axes that are not specifed will not move. + If `absolute=True` is specified, the stage will move to the absolute + coordinates given. If not (the default), a relative move is made, i.e. + `x=0, y=0, z=0` corresponds to no motion. """ microscope = find_component("org.openflexure.microscope") @@ -50,7 +53,8 @@ class ZeroStageAPI(ActionView): def post(self): """ Zero the stage coordinates. - Does not move the stage, but rather makes the current position read as [0, 0, 0] + + This action does not move the stage, but rather makes the current position read as [0, 0, 0] """ microscope = find_component("org.openflexure.microscope") diff --git a/openflexure_microscope/api/v2/views/stage.py b/openflexure_microscope/api/v2/views/stage.py index e65138eb..21d19e94 100644 --- a/openflexure_microscope/api/v2/views/stage.py +++ b/openflexure_microscope/api/v2/views/stage.py @@ -1,5 +1,6 @@ from labthings import fields, find_component from labthings.views import PropertyView +from marshmallow import validate class StageTypeProperty(PropertyView): @@ -8,8 +9,9 @@ class StageTypeProperty(PropertyView): schema = fields.String( missing=None, example="SangaStage", - OneOf=["SangaStage", "SangaDeltaStage"], + validate=validate.OneOf(["SangaStage", "SangaDeltaStage"]), description="The translation stage geometry", + allow_none=False, ) def get(self): From 8422e8bfefd18e750253861b399f9d78aa129f88 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 13 Jul 2021 22:54:11 +0100 Subject: [PATCH 11/36] ignore local installation of swagger-cli --- docs/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 docs/.gitignore diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..504afef8 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json From 6a7c305468ce9c96f24865a4a16bdf10254ba649 Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 14 Jul 2021 19:15:26 +0100 Subject: [PATCH 12/36] Started adding more content to openapi --- openflexure_microscope/api/app.py | 3 ++ openflexure_microscope/api/openapi.py | 43 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 openflexure_microscope/api/openapi.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 1739bec0..6e3c4e0e 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -39,6 +39,7 @@ from openflexure_microscope.paths import ( OPENFLEXURE_VAR_PATH, logs_file_path, ) +from .openapi import add_spec_extras # Custom RotatingFileHandler subclass @@ -209,6 +210,8 @@ def routes(): def api_v1_catch_all(path): # pylint: disable=W0613 abort(410, "API v1 is no longer in use. Please upgrade your client.") +add_spec_extras(labthing.spec) + # Automatically clean up microscope at exit def cleanup(): diff --git a/openflexure_microscope/api/openapi.py b/openflexure_microscope/api/openapi.py new file mode 100644 index 00000000..d96aa491 --- /dev/null +++ b/openflexure_microscope/api/openapi.py @@ -0,0 +1,43 @@ + + +API_TAGS = [ + { + "name": "actions", + "description": ( + "Actions that can be run on the microscope. These endpoints represent " + "actions that many not complete immediately, so they run on the server " + "as `Action` objects and their status can be queried using the links " + "embedded in the JSON action description." + ), + "externalDocs": "https://iot.mozilla.org/wot/#action-resource", + }, + { + "name": "properties", + "description": ( + "Properties can be read and/or written to, and affect the " + "state of the microscope." + ), + "externalDocs": "https://iot.mozilla.org/wot/#property-resource", + }, + { + "name": "captures", + "description": "", + "externalDocs": "" + }, + { + "name": "extensions", + "description": "", + "externalDocs": "" + }, + { + "name": "events", + "description": "", + "externalDocs": "" + } +] + +def add_spec_extras(spec): + """Add extra documentation and features to the OpenAPI spec""" + # Add a list of tags, so we can control ordering and add descriptions + for t in API_TAGS: + spec.tag(t) From 077d6fdc4bedc9dc240b01d13d714cad02fa98cc Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 19 Jul 2021 11:50:50 +0100 Subject: [PATCH 13/36] Added OpenAPI validation from apispec --- Pipfile.lock | 691 ++++++++++++++++++++---------- openflexure_microscope/api/app.py | 10 +- setup.py | 1 + 3 files changed, 471 insertions(+), 231 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index d3a660f7..cccc5ec5 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -23,14 +23,15 @@ "default": { "apispec": { "extras": [ + "validation", "yaml" ], "hashes": [ - "sha256:aaded4ae451752c58b70e579923d8db7daf0b71f62def8c8fe7a2a52bd7c06a2", - "sha256:f49c49e0dee3fa545462d4536856458aae61b972727b3c32eabc7d6db8c074f8" + "sha256:6613dbc39f41cd58942a697f11c8762ba18422bd173fe0bdfc1535b83d3f84f0", + "sha256:79029486d36a0d7f3c659dbf6ae50a91fbed0c22dcd5376f592e076c130bc7f9" ], "markers": "python_version >= '3.6'", - "version": "==4.4.1" + "version": "==4.7.1" }, "apispec-webframeworks": { "hashes": [ @@ -53,9 +54,32 @@ "sha256:02a409bd2cb193b20d9eb015ca31400bd3e1721ec5d702e1b96de54608ff5a8f", "sha256:d39c3afebb59ec865fb1247dfd951d7090c159ab0cf3e4291e0869d3be33776c" ], - "markers": "python_version >= '3.6' and python_version < '4.0'", + "markers": "python_version >= '3.6' and python_version < '4'", "version": "==0.1.4" }, + "certifi": { + "hashes": [ + "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", + "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" + ], + "version": "==2021.5.30" + }, + "chardet": { + "hashes": [ + "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", + "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==4.0.0" + }, + "charset-normalizer": { + "hashes": [ + "sha256:88fce3fa5b1a84fdcb3f603d889f723d1dd89b26059d0123ca435570e848d5e1", + "sha256:c46c3ace2d744cfbdebceaa3c19ae691f53ae621b39fd7570f59d14fb7f2fd12" + ], + "markers": "python_version >= '3'", + "version": "==2.0.3" + }, "click": { "hashes": [ "sha256:a3747c864f8e400a3664f5f4fd6dae11b4605bf6b727dae7b6f22ba9bd0a194a", @@ -127,7 +151,7 @@ "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==5.5" }, "expiringdict": { @@ -160,6 +184,14 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, + "idna": { + "hashes": [ + "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", + "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" + ], + "markers": "python_version >= '3'", + "version": "==3.2" + }, "ifaddr": { "hashes": [ "sha256:1f9e8a6ca6f16db5a37d3356f07b6e52344f6f9f7e806d618537731669eb1a94", @@ -169,11 +201,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581", - "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d" + "sha256:079ada16b7fc30dfbb5d13399a5113110dab1aa7c2bc62f66af75f0b717c8cac", + "sha256:9f55f560e116f8643ecf2922d9cd3e1c7e8d52e683178fecd9d08f6aa357e11e" ], "markers": "python_version < '3.8'", - "version": "==4.0.1" + "version": "==4.6.1" }, "iniconfig": { "hashes": [ @@ -183,6 +215,13 @@ ], "version": "==1.1.1" }, + "isodate": { + "hashes": [ + "sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8", + "sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81" + ], + "version": "==0.6.0" + }, "itsdangerous": { "hashes": [ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", @@ -199,12 +238,19 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.3" }, + "jsonschema": { + "hashes": [ + "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163", + "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a" + ], + "version": "==3.2.0" + }, "labthings": { "hashes": [ "sha256:4f286ea585e596aeb67a02d5bd822b2c89828693026085829e15e415a2d2d287", "sha256:970bb6a81f0e007b55e575e6eafe6bba10974d671f2767b91631f001ff53cf0e" ], - "markers": "python_version >= '3.6' and python_version < '4.0'", + "markers": "python_version >= '3.6' and python_version < '4'", "version": "==1.2.4" }, "markupsafe": { @@ -250,74 +296,94 @@ }, "marshmallow": { "hashes": [ - "sha256:8050475b70470cc58f4441ee92375db611792ba39ca1ad41d39cad193ea9e040", - "sha256:b45cde981d1835145257b4a3c5cb7b80786dcf5f50dd2990749a50c16cb48e01" + "sha256:77368dfedad93c3a041cbbdbce0b33fac1d8608c9e2e2288408a43ce3493d2ff", + "sha256:d4090ca9a36cd129126ad8b10c3982c47d4644a6e3ccb20534b512badce95f35" ], "markers": "python_version >= '3.5'", - "version": "==3.12.1" + "version": "==3.12.2" }, "numpy": { "hashes": [ - "sha256:1676b0a292dd3c99e49305a16d7a9f42a4ab60ec522eac0d3dd20cdf362ac010", - "sha256:16f221035e8bd19b9dc9a57159e38d2dd060b48e93e1d843c49cb370b0f415fd", - "sha256:43909c8bb289c382170e0282158a38cf306a8ad2ff6dfadc447e90f9961bef43", - "sha256:4e465afc3b96dbc80cf4a5273e5e2b1e3451286361b4af70ce1adb2984d392f9", - "sha256:55b745fca0a5ab738647d0e4db099bd0a23279c32b31a783ad2ccea729e632df", - "sha256:5d050e1e4bc9ddb8656d7b4f414557720ddcca23a5b88dd7cff65e847864c400", - "sha256:637d827248f447e63585ca3f4a7d2dfaa882e094df6cfa177cc9cf9cd6cdf6d2", - "sha256:6690080810f77485667bfbff4f69d717c3be25e5b11bb2073e76bb3f578d99b4", - "sha256:66fbc6fed94a13b9801fb70b96ff30605ab0a123e775a5e7a26938b717c5d71a", - "sha256:67d44acb72c31a97a3d5d33d103ab06d8ac20770e1c5ad81bdb3f0c086a56cf6", - "sha256:6ca2b85a5997dabc38301a22ee43c82adcb53ff660b89ee88dded6b33687e1d8", - "sha256:6e51534e78d14b4a009a062641f465cfaba4fdcb046c3ac0b1f61dd97c861b1b", - "sha256:70eb5808127284c4e5c9e836208e09d685a7978b6a216db85960b1a112eeace8", - "sha256:830b044f4e64a76ba71448fce6e604c0fc47a0e54d8f6467be23749ac2cbd2fb", - "sha256:8b7bb4b9280da3b2856cb1fc425932f46fba609819ee1c62256f61799e6a51d2", - "sha256:a9c65473ebc342715cb2d7926ff1e202c26376c0dcaaee85a1fd4b8d8c1d3b2f", - "sha256:c1c09247ccea742525bdb5f4b5ceeacb34f95731647fe55774aa36557dbb5fa4", - "sha256:c5bf0e132acf7557fc9bb8ded8b53bbbbea8892f3c9a1738205878ca9434206a", - "sha256:d840d80623c96696fab0d15489f49425bec1c0f47f3bf0b3362ce0c75dbad993", - "sha256:db250fd3e90117e0312b611574cd1b3f78bec046783195075cbd7ba9c3d73f16", - "sha256:e515c9a93aebe27166ec9593411c58494fa98e5fcc219e47260d9ab8a1cc7f9f", - "sha256:e55185e51b18d788e49fe8305fd73ef4470596b33fc2c1ceb304566b99c71a69", - "sha256:ea9cff01e75a956dbee133fa8e5b68f2f92175233de2f88de3a682dd94deda65", - "sha256:f1452578d0516283c87608a5a5548b0cdde15b99650efdfd85182102ef7a7c17", - "sha256:f39a995e47cb8649673cfa0579fbdd1cdd33ea497d1728a6cb194d6252268e48" + "sha256:01721eefe70544d548425a07c80be8377096a54118070b8a62476866d5208e33", + "sha256:0318c465786c1f63ac05d7c4dbcecd4d2d7e13f0959b01b534ea1e92202235c5", + "sha256:05a0f648eb28bae4bcb204e6fd14603de2908de982e761a2fc78efe0f19e96e1", + "sha256:1412aa0aec3e00bc23fbb8664d76552b4efde98fb71f60737c83efbac24112f1", + "sha256:25b40b98ebdd272bc3020935427a4530b7d60dfbe1ab9381a39147834e985eac", + "sha256:2c6413051a74c317d36f4bac04f61e8d1a6708969cc910d876664c76e36817a7", + "sha256:2d4d1de6e6fb3d28781c73fbde702ac97f03d79e4ffd6598b880b2d95d62ead4", + "sha256:38e8648f9449a549a7dfe8d8755a5979b45b3538520d1e735637ef28e8c2dc50", + "sha256:4a3d5fb89bfe21be2ef47c0614b9c9c707b7362386c9a3ff1feae63e0267ccb6", + "sha256:635e6bd31c9fb3d475c8f44a089569070d10a9ef18ed13738b03049280281267", + "sha256:73101b2a1fef16602696d133db402a7e7586654682244344b8329cdcbbb82172", + "sha256:791492091744b0fe390a6ce85cc1bf5149968ac7d5f0477288f78c89b385d9af", + "sha256:7a708a79c9a9d26904d1cca8d383bf869edf6f8e7650d85dbc77b041e8c5a0f8", + "sha256:88c0b89ad1cc24a5efbb99ff9ab5db0f9a86e9cc50240177a571fbe9c2860ac2", + "sha256:8a326af80e86d0e9ce92bcc1e65c8ff88297de4fa14ee936cb2293d414c9ec63", + "sha256:8a92c5aea763d14ba9d6475803fc7904bda7decc2a0a68153f587ad82941fec1", + "sha256:91c6f5fc58df1e0a3cc0c3a717bb3308ff850abdaa6d2d802573ee2b11f674a8", + "sha256:95b995d0c413f5d0428b3f880e8fe1660ff9396dcd1f9eedbc311f37b5652e16", + "sha256:9749a40a5b22333467f02fe11edc98f022133ee1bfa8ab99bda5e5437b831214", + "sha256:978010b68e17150db8765355d1ccdd450f9fc916824e8c4e35ee620590e234cd", + "sha256:9a513bd9c1551894ee3d31369f9b07460ef223694098cf27d399513415855b68", + "sha256:a75b4498b1e93d8b700282dc8e655b8bd559c0904b3910b144646dbbbc03e062", + "sha256:c6a2324085dd52f96498419ba95b5777e40b6bcbc20088fddb9e8cbb58885e8e", + "sha256:d7a4aeac3b94af92a9373d6e77b37691b86411f9745190d2c351f410ab3a791f", + "sha256:d9e7912a56108aba9b31df688a4c4f5cb0d9d3787386b87d504762b6754fbb1b", + "sha256:dff4af63638afcc57a3dfb9e4b26d434a7a602d225b42d746ea7fe2edf1342fd", + "sha256:e46ceaff65609b5399163de5893d8f2a82d3c77d5e56d976c8b5fb01faa6b671", + "sha256:f01f28075a92eede918b965e86e8f0ba7b7797a95aa8d35e1cc8821f5fc3ad6a", + "sha256:fd7d7409fa643a91d0a05c7554dd68aa9c9bb16e186f6ccfe40d6e003156e33a" ], "markers": "python_version >= '3.7'", - "version": "==1.20.3" + "version": "==1.21.1" + }, + "openapi-schema-validator": { + "hashes": [ + "sha256:215b516d0942f4e8e2446cf3f7d4ff2ed71d102ebddcc30526d8a3f706ab1df6", + "sha256:a4b2712020284cee880b4c55faa513fbc2f8f07f365deda6098f8ab943c9f0df", + "sha256:b65d6c2242620bfe76d4c749b61cd9657e4528895a8f4fb6f916085b508ebd24" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.1.5" + }, + "openapi-spec-validator": { + "hashes": [ + "sha256:0a7da925bad4576f4518f77302c0b1990adb2fbcbe7d63fb4ed0de894cad8bdd", + "sha256:3d70e6592754799f7e77a45b98c6a91706bdd309a425169d17d8e92173e198a2", + "sha256:ba28b06e63274f2bc6de995a07fb572c657e534425b5baf68d9f7911efe6929f" + ], + "version": "==0.3.1" }, "opencv-python-headless": { "hashes": [ - "sha256:0e02809db2968e54f3c23340be6ff9a1428b3f03f5dca7cd5aceda66e319ce86", - "sha256:1f7c14f5d4e5af4dc4669fc6b4a983b36072a934c439ac11266b930496da8255", - "sha256:243aea91cc1e36a47c46da4cc408071af39444a48df1fe1539ea8d7990500fd2", - "sha256:2560dcf3c1158226b066302f777bfe0f65282410b8d90871dd872306c967d1f1", - "sha256:2e6a9a88617a0ef7219cff24ba78a58416670a77e6ac63975f9009af3319ab63", - "sha256:372149d007e20bf556b7687591d22b58b56b3c225f492da051d81587e5dc7411", - "sha256:522f12dd994e064a30562adfd63b9439099bd7c80819f5261c37ebe593283c9e", - "sha256:526b9e19cf6300f0891d8f427eac1048091912332bb781eb4957a4994bfbe608", - "sha256:5f0c7d34fa9953706c2a1a6d2760a91dc5a68cab3df16af61609894c6c8586f1", - "sha256:657cdd9fbbbbb7e898ae3d9b0649b367d0e440d429c714104d069c5612d578bb", - "sha256:65c9ea57be5ebcaed009b3fee14fe59f3b6aa1e573fe08c5039ff057ad593c53", - "sha256:777fb596e04331f73ef5b0c1faa4d33348f29ca58216d9286355c16f5489c939", - "sha256:7c75680ffdf32d7044415a215d1fc60dbec14a7f2f0b59a85f0f74ef5efcb6ad", - "sha256:924aa4c34ead0b817309f42291dc526b2a7755476afe3009d1e275fc3090d92d", - "sha256:96d1da6ad061d8f3509668d398d14dd8265e529d6407f87eb26e7f4ddf043cc0", - "sha256:9aa04a491c534531029fbac61da961fae0bf4abb1786eed9c91befde4ca7bd81", - "sha256:a322d4df14c3a5f19701a023b3da91e9b8af8653b0d2ee0c50b0b341212f7343", - "sha256:aa562c520f46283423ba8fac29099458e42deab697a9abd0491622e421c5c454", - "sha256:ba2f0bd46e9534f29969e39f7895cbea9764173102b0c04b7818b8a9910d66e4", - "sha256:d16825755e7b5a6d8737f93e116670229e1510199e0af9213004e187ae0dbcc5", - "sha256:e3027e0d1b71b68b5cfe1ea9c627e323dff71112c854ba19805258d8fc6c630e", - "sha256:e61c8df818d79c6bd62a5a18a9b3bfdf389502798e3c22f95c67f8dcd09ce97b", - "sha256:f2011ecb3980bbed283d17d43e0f1221bac88c0cac1a6fb59a056544de2df2f7", - "sha256:f5e40a06116460ef2fd2d1c24be3b65f8bfb5fcdfe433f3fc01bcb4c2eb485bf", - "sha256:f7f8e4f7c63c8e95eb210f4cba88d0069a0a964d6335d7a35b07f0d0baa13558", - "sha256:fe02a943b1a28b505e954fbce24e867119a3eb4351f93adad55c6cfe81a70484" + "sha256:0935bf569e2edc1af8bc38f00813e37b1ba63e4b2eb0dc48651f405068b8b36d", + "sha256:0e8080dc5baf6855b663634d9c7a45732e2999bbe44aa5e4a14c15f3631998e4", + "sha256:0ef768d119a00eb6be0ba7953ab875218084d078db564af89af2b3891b6d7a3b", + "sha256:0efe30ce934ec2ab7272143d17f68885910a715c6aeffd02973c04b3d8b99ca6", + "sha256:1a00d01d8d7250d6b87c63eb7b2df1e541fdb10919c22940cc53884bb12057c9", + "sha256:3308d22efec8a69932709fc76456ef7e0d1cd9eb2272d94b7e5df0674d3e324f", + "sha256:3599939dc61ab32c91cb2dbfd80a8199b53bf8a08cbb636ee9c2f27d74fe4f8f", + "sha256:39eaae19d5fa906c7ec2db76447c0ff9df6305633493f1b86e4c0080ed1cb036", + "sha256:3b1aa94894872e17a5fa07794562d8c44add78dd4c90a12fa1d946789e088784", + "sha256:465b157480902ecd6b2c4c46d547e4cfdec7595177bcc612c6f4c351966a5a1e", + "sha256:90686bd1292972ad47298154c8ad37543080b2fc0417b68a350d30232b77f670", + "sha256:b224afffbc4ea21fe100824ff08a79ad1fae3088c1280b8ff815067b827e5240", + "sha256:b563106e93525bd30072a27448cc2b5c11e45bf6025e74e1d40efd5349a31736", + "sha256:bfe8989ed4af5e14ef1480c89fe259f5d4bc66c5ae0e7d56d7c778ef6ebbcdf4", + "sha256:c54ad51bfc4623de287bd68f14f481f72300bd16a2d9e14850c1ef1a2dfc7dca", + "sha256:c7a1d365f2f8ce413847479a2d39bee2be6d5027119b08c4c5f6594f1f57673d", + "sha256:d4c784339c366d27d0b4503a073c97a7cd78657937b3755fdbc9168a002f6409", + "sha256:d70f3a44fb25f555da36e6c242cd1dffac6c4637eb900584f2a70a46e8e3cf05", + "sha256:e19ba664cae0bc6ad39e2fbf3b3f16c93009132f792dcb7ca0105f0cc15997ed", + "sha256:ebfeaff7e0bb1c293008a49b2b2c033a7cec3805006a8284fa1a44ad8c30446b", + "sha256:ecd84834f58daf94d3a4874a11997180380ef9af11b1fd5f81dbb8599b3c3446", + "sha256:f1082f4f15a681b435686ed3fc0232128a222fed6792a47fae5cf081eb751ccf", + "sha256:fc9fa0fa88423c0e74db52fe13939aa506789f924a2a879e31f1d415e39a0db2", + "sha256:fd0f0629e27d97eec2f51500afbe0845876fdcd89d412035f4d7beb618c51066", + "sha256:ff4de00d4f19d622561dee58a5121045ca265df3665751d14433149bbd748f55" ], "markers": "python_version >= '3.6'", - "version": "==4.5.1.48" + "version": "==4.5.3.56" }, "openflexure-microscope-server": { "editable": true, @@ -325,11 +391,11 @@ }, "packaging": { "hashes": [ - "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", - "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" + "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", + "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.9" + "markers": "python_version >= '3.6'", + "version": "==21.0" }, "picamerax": { "hashes": [ @@ -356,6 +422,7 @@ "sha256:25930fadde8019f374400f7986e8404c8b781ce519da27792cbe46eabec00c4d", "sha256:39b8830992872e00605ec6eb0d83efad488553f10d79066b8b47c8be38e126b3", "sha256:431b15cffbf949e89df2f7b48528be18b78bfa5177cb3036284a5508159492b5", + "sha256:4a2ef919795ad6fc28c47be3f16ec8add4e8c6daa01871e25788cda1b244b162", "sha256:52125833b070791fcb5710fabc640fc1df07d087fc0c0f02d3661f76c23c5b8b", "sha256:5e51ee2b8114def244384eda1c82b10e307ad9778dac5c83fb0943775a653cd8", "sha256:612cfda94e9c8346f239bf1a4b082fdd5c8143cf82d685ba2dba76e7adeeb233", @@ -390,6 +457,16 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, + "prance": { + "extras": [ + "osv" + ], + "hashes": [ + "sha256:03374b020a4f643c71fc941bb7d1e4852331732714b4b6ebbbd53dd5f75adacf", + "sha256:43ebe3a5b38f0c65c428427004c4d8ce8d7155ddad50610276c89c192680f138" + ], + "version": "==0.21.2" + }, "psutil": { "hashes": [ "sha256:094f899ac3ef72422b7e00411b4ed174e3c5a2e04c267db6643937ddba67a05b", @@ -398,6 +475,7 @@ "sha256:21231ef1c1a89728e29b98a885b8e0a8e00d09018f6da5cdc1f43f988471a995", "sha256:28f771129bfee9fc6b63d83a15d857663bbdcae3828e1cb926e91320a9b5b5cd", "sha256:70387772f84fa5c3bb6a106915a2445e20ac8f9821c5914d7cbde148f4d7ff73", + "sha256:7d5e1be385287e2334f708ec261b8ee364ac2bcb07eadcbd7a254f9cd476b65f", "sha256:891e24ec41be15a99c00b0f3a378d3d439876fe18793f874a4f8e796fec77458", "sha256:a9340f6b9ff887d77aed72a51d101ca0f21b8d82edc6c85105d1d9f027544365", "sha256:b560f5cd86cf8df7bcd258a851ca1ad98f0d5b8b98748e877a0aec4e9032b465", @@ -425,6 +503,34 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, + "pyrsistent": { + "hashes": [ + "sha256:097b96f129dd36a8c9e33594e7ebb151b1515eb52cceb08474c10a5479e799f2", + "sha256:2aaf19dc8ce517a8653746d98e962ef480ff34b6bc563fc067be6401ffb457c7", + "sha256:404e1f1d254d314d55adb8d87f4f465c8693d6f902f67eb6ef5b4526dc58e6ea", + "sha256:48578680353f41dca1ca3dc48629fb77dfc745128b56fc01096b2530c13fd426", + "sha256:4916c10896721e472ee12c95cdc2891ce5890898d2f9907b1b4ae0f53588b710", + "sha256:527be2bfa8dc80f6f8ddd65242ba476a6c4fb4e3aedbf281dfbac1b1ed4165b1", + "sha256:58a70d93fb79dc585b21f9d72487b929a6fe58da0754fa4cb9f279bb92369396", + "sha256:5e4395bbf841693eaebaa5bb5c8f5cdbb1d139e07c975c682ec4e4f8126e03d2", + "sha256:6b5eed00e597b5b5773b4ca30bd48a5774ef1e96f2a45d105db5b4ebb4bca680", + "sha256:73ff61b1411e3fb0ba144b8f08d6749749775fe89688093e1efef9839d2dcc35", + "sha256:772e94c2c6864f2cd2ffbe58bb3bdefbe2a32afa0acb1a77e472aac831f83427", + "sha256:773c781216f8c2900b42a7b638d5b517bb134ae1acbebe4d1e8f1f41ea60eb4b", + "sha256:8c065dcc8ed1c7412be6f0cfa69e49434c61bf73953ee30ddf35420795408e47", + "sha256:a0c772d791c38bbc77be659af29bb14c38ced151433592e326361610250c605b", + "sha256:b29b869cf58412ca5738d23691e96d8aff535e17390128a1a52717c9a109da4f", + "sha256:c1a9ff320fa699337e05edcaae79ef8c2880b52720bc031b219e5b5008ebbdef", + "sha256:cd3caef37a415fd0dae6148a1b6957a8c5f275a62cca02e18474608cb263640c", + "sha256:d5ec194c9c573aafaceebf05fc400656722793dac57f254cd4741f3c27ae57b4", + "sha256:da6e5e818d18459fa46fac0a4a4e543507fe1110e808101277c5a2b5bab0cd2d", + "sha256:e79d94ca58fcafef6395f6352383fa1a76922268fa02caa2272fff501c2fdc78", + "sha256:f3ef98d7b76da5eb19c37fda834d50262ff9167c65658d1d8f974d2e4d90676b", + "sha256:f4c8cabb46ff8e5d61f56a037974228e978f26bfefce4f61a4b1ac0ba7a2ab72" + ], + "markers": "python_version >= '3.6'", + "version": "==0.18.0" + }, "pyserial": { "hashes": [ "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", @@ -450,11 +556,11 @@ }, "python-dateutil": { "hashes": [ - "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", - "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.1" + "version": "==2.8.2" }, "pyyaml": { "hashes": [ @@ -491,15 +597,13 @@ ], "version": "==5.4.1" }, - "rpi.gpio": { + "requests": { "hashes": [ - "sha256:5cd2f3c88d0b1e2f4780c905702c0474768c2bc113fb793121c2e117b401ab16", - "sha256:6a4791f41cafc2ee6e4cb70e5bd31fadc66a0cfab29b38df8723a98f6f73ad5a", - "sha256:7424bc6c205466764f30f666c18187a0824077daf20b295c42f08aea2cb87d3f", - "sha256:7da5235aeba20da39ad4fb97f3ba3a2c6b2d60bba8958b00a4df42f608c6d42e" + "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", + "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" ], - "markers": "platform_machine == 'armv7l'", - "version": "==0.7.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==2.26.0" }, "sangaboard": { "hashes": [ @@ -534,6 +638,14 @@ "markers": "python_version < '3.10' and python_version >= '3.7'", "version": "==1.6.3" }, + "semver": { + "hashes": [ + "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4", + "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.13.0" + }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", @@ -559,6 +671,14 @@ "markers": "python_version < '3.8'", "version": "==3.7.4.3" }, + "urllib3": { + "hashes": [ + "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", + "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.6" + }, "webargs": { "hashes": [ "sha256:2f3d883ce9f348fa884889440fcc1b207e7c67b04be5e90be00a5be73e2cd91d", @@ -584,11 +704,11 @@ }, "zipp": { "hashes": [ - "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76", - "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098" + "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3", + "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4" ], "markers": "python_version >= '3.6'", - "version": "==3.4.1" + "version": "==3.5.0" } }, "develop": { @@ -601,14 +721,15 @@ }, "apispec": { "extras": [ + "validation", "yaml" ], "hashes": [ - "sha256:aaded4ae451752c58b70e579923d8db7daf0b71f62def8c8fe7a2a52bd7c06a2", - "sha256:f49c49e0dee3fa545462d4536856458aae61b972727b3c32eabc7d6db8c074f8" + "sha256:6613dbc39f41cd58942a697f11c8762ba18422bd173fe0bdfc1535b83d3f84f0", + "sha256:79029486d36a0d7f3c659dbf6ae50a91fbed0c22dcd5376f592e076c130bc7f9" ], "markers": "python_version >= '3.6'", - "version": "==4.4.1" + "version": "==4.7.1" }, "apispec-webframeworks": { "hashes": [ @@ -627,11 +748,11 @@ }, "astroid": { "hashes": [ - "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e", - "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975" + "sha256:38b95085e9d92e2ca06cf8b35c12a74fa81da395a6f9e65803742e6509c05892", + "sha256:606b2911d10c3dcf35e58d2ee5c97360e8477d7b9f3efc3f24811c93e6fc2cd9" ], "markers": "python_version ~= '3.6'", - "version": "==2.5.6" + "version": "==2.6.2" }, "attrs": { "hashes": [ @@ -662,15 +783,15 @@ "sha256:02a409bd2cb193b20d9eb015ca31400bd3e1721ec5d702e1b96de54608ff5a8f", "sha256:d39c3afebb59ec865fb1247dfd951d7090c159ab0cf3e4291e0869d3be33776c" ], - "markers": "python_version >= '3.6' and python_version < '4.0'", + "markers": "python_version >= '3.6' and python_version < '4'", "version": "==0.1.4" }, "certifi": { "hashes": [ - "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", - "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" + "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", + "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" ], - "version": "==2020.12.5" + "version": "==2021.5.30" }, "chardet": { "hashes": [ @@ -680,6 +801,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.0.0" }, + "charset-normalizer": { + "hashes": [ + "sha256:88fce3fa5b1a84fdcb3f603d889f723d1dd89b26059d0123ca435570e848d5e1", + "sha256:c46c3ace2d744cfbdebceaa3c19ae691f53ae621b39fd7570f59d14fb7f2fd12" + ], + "markers": "python_version >= '3'", + "version": "==2.0.3" + }, "click": { "hashes": [ "sha256:a3747c864f8e400a3664f5f4fd6dae11b4605bf6b727dae7b6f22ba9bd0a194a", @@ -751,16 +880,16 @@ "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==5.5" }, "docutils": { "hashes": [ - "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", - "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61" + "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", + "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==0.17.1" + "version": "==0.16" }, "expiringdict": { "hashes": [ @@ -802,12 +931,11 @@ }, "idna": { "hashes": [ - "sha256:4a57a6379512ade94fa99e2fa46d3cd0f2f553040548d0e2958c6ed90ee48226", - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", + "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.10" + "markers": "python_version >= '3'", + "version": "==3.2" }, "ifaddr": { "hashes": [ @@ -826,11 +954,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581", - "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d" + "sha256:079ada16b7fc30dfbb5d13399a5113110dab1aa7c2bc62f66af75f0b717c8cac", + "sha256:9f55f560e116f8643ecf2922d9cd3e1c7e8d52e683178fecd9d08f6aa357e11e" ], "markers": "python_version < '3.8'", - "version": "==4.0.1" + "version": "==4.6.1" }, "iniconfig": { "hashes": [ @@ -840,13 +968,20 @@ ], "version": "==1.1.1" }, + "isodate": { + "hashes": [ + "sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8", + "sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81" + ], + "version": "==0.6.0" + }, "isort": { "hashes": [ - "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6", - "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d" + "sha256:eed17b53c3e7912425579853d078a0832820f023191561fcee9d7cae424e0813", + "sha256:f65ce5bd4cbc6abdfbe29afc2f0245538ab358c14590912df638033f157d555e" ], - "markers": "python_version >= '3.6' and python_version < '4'", - "version": "==5.8.0" + "markers": "python_version < '4.0' and python_full_version >= '3.6.1'", + "version": "==5.9.2" }, "itsdangerous": { "hashes": [ @@ -864,12 +999,19 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.3" }, + "jsonschema": { + "hashes": [ + "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163", + "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a" + ], + "version": "==3.2.0" + }, "labthings": { "hashes": [ "sha256:4f286ea585e596aeb67a02d5bd822b2c89828693026085829e15e415a2d2d287", "sha256:970bb6a81f0e007b55e575e6eafe6bba10974d671f2767b91631f001ff53cf0e" ], - "markers": "python_version >= '3.6' and python_version < '4.0'", + "markers": "python_version >= '3.6' and python_version < '4'", "version": "==1.2.4" }, "lazy-object-proxy": { @@ -955,6 +1097,13 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.6.3" }, + "m2r": { + "hashes": [ + "sha256:4c72974da78f787a6b0b83973177479b17d311e2d04f6a38998c677e8f394129", + "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99" + ], + "version": "==0.2.1" + }, "markupsafe": { "hashes": [ "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", @@ -998,11 +1147,11 @@ }, "marshmallow": { "hashes": [ - "sha256:8050475b70470cc58f4441ee92375db611792ba39ca1ad41d39cad193ea9e040", - "sha256:b45cde981d1835145257b4a3c5cb7b80786dcf5f50dd2990749a50c16cb48e01" + "sha256:77368dfedad93c3a041cbbdbce0b33fac1d8608c9e2e2288408a43ce3493d2ff", + "sha256:d4090ca9a36cd129126ad8b10c3982c47d4644a6e3ccb20534b512badce95f35" ], "markers": "python_version >= '3.5'", - "version": "==3.12.1" + "version": "==3.12.2" }, "mccabe": { "hashes": [ @@ -1011,33 +1160,41 @@ ], "version": "==0.6.1" }, + "mistune": { + "hashes": [ + "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e", + "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4" + ], + "version": "==0.8.4" + }, "mypy": { "hashes": [ - "sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e", - "sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064", - "sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c", - "sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4", - "sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97", - "sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df", - "sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8", - "sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a", - "sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56", - "sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7", - "sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6", - "sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5", - "sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a", - "sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521", - "sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564", - "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49", - "sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66", - "sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a", - "sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119", - "sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506", - "sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c", - "sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb" + "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9", + "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a", + "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9", + "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e", + "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2", + "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212", + "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b", + "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885", + "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150", + "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703", + "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072", + "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457", + "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e", + "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0", + "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb", + "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97", + "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8", + "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811", + "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6", + "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de", + "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504", + "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921", + "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d" ], "markers": "python_version >= '3.5'", - "version": "==0.812" + "version": "==0.910" }, "mypy-extensions": { "hashes": [ @@ -1048,66 +1205,86 @@ }, "numpy": { "hashes": [ - "sha256:1676b0a292dd3c99e49305a16d7a9f42a4ab60ec522eac0d3dd20cdf362ac010", - "sha256:16f221035e8bd19b9dc9a57159e38d2dd060b48e93e1d843c49cb370b0f415fd", - "sha256:43909c8bb289c382170e0282158a38cf306a8ad2ff6dfadc447e90f9961bef43", - "sha256:4e465afc3b96dbc80cf4a5273e5e2b1e3451286361b4af70ce1adb2984d392f9", - "sha256:55b745fca0a5ab738647d0e4db099bd0a23279c32b31a783ad2ccea729e632df", - "sha256:5d050e1e4bc9ddb8656d7b4f414557720ddcca23a5b88dd7cff65e847864c400", - "sha256:637d827248f447e63585ca3f4a7d2dfaa882e094df6cfa177cc9cf9cd6cdf6d2", - "sha256:6690080810f77485667bfbff4f69d717c3be25e5b11bb2073e76bb3f578d99b4", - "sha256:66fbc6fed94a13b9801fb70b96ff30605ab0a123e775a5e7a26938b717c5d71a", - "sha256:67d44acb72c31a97a3d5d33d103ab06d8ac20770e1c5ad81bdb3f0c086a56cf6", - "sha256:6ca2b85a5997dabc38301a22ee43c82adcb53ff660b89ee88dded6b33687e1d8", - "sha256:6e51534e78d14b4a009a062641f465cfaba4fdcb046c3ac0b1f61dd97c861b1b", - "sha256:70eb5808127284c4e5c9e836208e09d685a7978b6a216db85960b1a112eeace8", - "sha256:830b044f4e64a76ba71448fce6e604c0fc47a0e54d8f6467be23749ac2cbd2fb", - "sha256:8b7bb4b9280da3b2856cb1fc425932f46fba609819ee1c62256f61799e6a51d2", - "sha256:a9c65473ebc342715cb2d7926ff1e202c26376c0dcaaee85a1fd4b8d8c1d3b2f", - "sha256:c1c09247ccea742525bdb5f4b5ceeacb34f95731647fe55774aa36557dbb5fa4", - "sha256:c5bf0e132acf7557fc9bb8ded8b53bbbbea8892f3c9a1738205878ca9434206a", - "sha256:d840d80623c96696fab0d15489f49425bec1c0f47f3bf0b3362ce0c75dbad993", - "sha256:db250fd3e90117e0312b611574cd1b3f78bec046783195075cbd7ba9c3d73f16", - "sha256:e515c9a93aebe27166ec9593411c58494fa98e5fcc219e47260d9ab8a1cc7f9f", - "sha256:e55185e51b18d788e49fe8305fd73ef4470596b33fc2c1ceb304566b99c71a69", - "sha256:ea9cff01e75a956dbee133fa8e5b68f2f92175233de2f88de3a682dd94deda65", - "sha256:f1452578d0516283c87608a5a5548b0cdde15b99650efdfd85182102ef7a7c17", - "sha256:f39a995e47cb8649673cfa0579fbdd1cdd33ea497d1728a6cb194d6252268e48" + "sha256:01721eefe70544d548425a07c80be8377096a54118070b8a62476866d5208e33", + "sha256:0318c465786c1f63ac05d7c4dbcecd4d2d7e13f0959b01b534ea1e92202235c5", + "sha256:05a0f648eb28bae4bcb204e6fd14603de2908de982e761a2fc78efe0f19e96e1", + "sha256:1412aa0aec3e00bc23fbb8664d76552b4efde98fb71f60737c83efbac24112f1", + "sha256:25b40b98ebdd272bc3020935427a4530b7d60dfbe1ab9381a39147834e985eac", + "sha256:2c6413051a74c317d36f4bac04f61e8d1a6708969cc910d876664c76e36817a7", + "sha256:2d4d1de6e6fb3d28781c73fbde702ac97f03d79e4ffd6598b880b2d95d62ead4", + "sha256:38e8648f9449a549a7dfe8d8755a5979b45b3538520d1e735637ef28e8c2dc50", + "sha256:4a3d5fb89bfe21be2ef47c0614b9c9c707b7362386c9a3ff1feae63e0267ccb6", + "sha256:635e6bd31c9fb3d475c8f44a089569070d10a9ef18ed13738b03049280281267", + "sha256:73101b2a1fef16602696d133db402a7e7586654682244344b8329cdcbbb82172", + "sha256:791492091744b0fe390a6ce85cc1bf5149968ac7d5f0477288f78c89b385d9af", + "sha256:7a708a79c9a9d26904d1cca8d383bf869edf6f8e7650d85dbc77b041e8c5a0f8", + "sha256:88c0b89ad1cc24a5efbb99ff9ab5db0f9a86e9cc50240177a571fbe9c2860ac2", + "sha256:8a326af80e86d0e9ce92bcc1e65c8ff88297de4fa14ee936cb2293d414c9ec63", + "sha256:8a92c5aea763d14ba9d6475803fc7904bda7decc2a0a68153f587ad82941fec1", + "sha256:91c6f5fc58df1e0a3cc0c3a717bb3308ff850abdaa6d2d802573ee2b11f674a8", + "sha256:95b995d0c413f5d0428b3f880e8fe1660ff9396dcd1f9eedbc311f37b5652e16", + "sha256:9749a40a5b22333467f02fe11edc98f022133ee1bfa8ab99bda5e5437b831214", + "sha256:978010b68e17150db8765355d1ccdd450f9fc916824e8c4e35ee620590e234cd", + "sha256:9a513bd9c1551894ee3d31369f9b07460ef223694098cf27d399513415855b68", + "sha256:a75b4498b1e93d8b700282dc8e655b8bd559c0904b3910b144646dbbbc03e062", + "sha256:c6a2324085dd52f96498419ba95b5777e40b6bcbc20088fddb9e8cbb58885e8e", + "sha256:d7a4aeac3b94af92a9373d6e77b37691b86411f9745190d2c351f410ab3a791f", + "sha256:d9e7912a56108aba9b31df688a4c4f5cb0d9d3787386b87d504762b6754fbb1b", + "sha256:dff4af63638afcc57a3dfb9e4b26d434a7a602d225b42d746ea7fe2edf1342fd", + "sha256:e46ceaff65609b5399163de5893d8f2a82d3c77d5e56d976c8b5fb01faa6b671", + "sha256:f01f28075a92eede918b965e86e8f0ba7b7797a95aa8d35e1cc8821f5fc3ad6a", + "sha256:fd7d7409fa643a91d0a05c7554dd68aa9c9bb16e186f6ccfe40d6e003156e33a" ], "markers": "python_version >= '3.7'", - "version": "==1.20.3" + "version": "==1.21.1" + }, + "openapi-schema-validator": { + "hashes": [ + "sha256:215b516d0942f4e8e2446cf3f7d4ff2ed71d102ebddcc30526d8a3f706ab1df6", + "sha256:a4b2712020284cee880b4c55faa513fbc2f8f07f365deda6098f8ab943c9f0df", + "sha256:b65d6c2242620bfe76d4c749b61cd9657e4528895a8f4fb6f916085b508ebd24" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.1.5" + }, + "openapi-spec-validator": { + "hashes": [ + "sha256:0a7da925bad4576f4518f77302c0b1990adb2fbcbe7d63fb4ed0de894cad8bdd", + "sha256:3d70e6592754799f7e77a45b98c6a91706bdd309a425169d17d8e92173e198a2", + "sha256:ba28b06e63274f2bc6de995a07fb572c657e534425b5baf68d9f7911efe6929f" + ], + "version": "==0.3.1" }, "opencv-python-headless": { "hashes": [ - "sha256:0e02809db2968e54f3c23340be6ff9a1428b3f03f5dca7cd5aceda66e319ce86", - "sha256:1f7c14f5d4e5af4dc4669fc6b4a983b36072a934c439ac11266b930496da8255", - "sha256:243aea91cc1e36a47c46da4cc408071af39444a48df1fe1539ea8d7990500fd2", - "sha256:2560dcf3c1158226b066302f777bfe0f65282410b8d90871dd872306c967d1f1", - "sha256:2e6a9a88617a0ef7219cff24ba78a58416670a77e6ac63975f9009af3319ab63", - "sha256:372149d007e20bf556b7687591d22b58b56b3c225f492da051d81587e5dc7411", - "sha256:522f12dd994e064a30562adfd63b9439099bd7c80819f5261c37ebe593283c9e", - "sha256:526b9e19cf6300f0891d8f427eac1048091912332bb781eb4957a4994bfbe608", - "sha256:5f0c7d34fa9953706c2a1a6d2760a91dc5a68cab3df16af61609894c6c8586f1", - "sha256:657cdd9fbbbbb7e898ae3d9b0649b367d0e440d429c714104d069c5612d578bb", - "sha256:65c9ea57be5ebcaed009b3fee14fe59f3b6aa1e573fe08c5039ff057ad593c53", - "sha256:777fb596e04331f73ef5b0c1faa4d33348f29ca58216d9286355c16f5489c939", - "sha256:7c75680ffdf32d7044415a215d1fc60dbec14a7f2f0b59a85f0f74ef5efcb6ad", - "sha256:924aa4c34ead0b817309f42291dc526b2a7755476afe3009d1e275fc3090d92d", - "sha256:96d1da6ad061d8f3509668d398d14dd8265e529d6407f87eb26e7f4ddf043cc0", - "sha256:9aa04a491c534531029fbac61da961fae0bf4abb1786eed9c91befde4ca7bd81", - "sha256:a322d4df14c3a5f19701a023b3da91e9b8af8653b0d2ee0c50b0b341212f7343", - "sha256:aa562c520f46283423ba8fac29099458e42deab697a9abd0491622e421c5c454", - "sha256:ba2f0bd46e9534f29969e39f7895cbea9764173102b0c04b7818b8a9910d66e4", - "sha256:d16825755e7b5a6d8737f93e116670229e1510199e0af9213004e187ae0dbcc5", - "sha256:e3027e0d1b71b68b5cfe1ea9c627e323dff71112c854ba19805258d8fc6c630e", - "sha256:e61c8df818d79c6bd62a5a18a9b3bfdf389502798e3c22f95c67f8dcd09ce97b", - "sha256:f2011ecb3980bbed283d17d43e0f1221bac88c0cac1a6fb59a056544de2df2f7", - "sha256:f5e40a06116460ef2fd2d1c24be3b65f8bfb5fcdfe433f3fc01bcb4c2eb485bf", - "sha256:f7f8e4f7c63c8e95eb210f4cba88d0069a0a964d6335d7a35b07f0d0baa13558", - "sha256:fe02a943b1a28b505e954fbce24e867119a3eb4351f93adad55c6cfe81a70484" + "sha256:0935bf569e2edc1af8bc38f00813e37b1ba63e4b2eb0dc48651f405068b8b36d", + "sha256:0e8080dc5baf6855b663634d9c7a45732e2999bbe44aa5e4a14c15f3631998e4", + "sha256:0ef768d119a00eb6be0ba7953ab875218084d078db564af89af2b3891b6d7a3b", + "sha256:0efe30ce934ec2ab7272143d17f68885910a715c6aeffd02973c04b3d8b99ca6", + "sha256:1a00d01d8d7250d6b87c63eb7b2df1e541fdb10919c22940cc53884bb12057c9", + "sha256:3308d22efec8a69932709fc76456ef7e0d1cd9eb2272d94b7e5df0674d3e324f", + "sha256:3599939dc61ab32c91cb2dbfd80a8199b53bf8a08cbb636ee9c2f27d74fe4f8f", + "sha256:39eaae19d5fa906c7ec2db76447c0ff9df6305633493f1b86e4c0080ed1cb036", + "sha256:3b1aa94894872e17a5fa07794562d8c44add78dd4c90a12fa1d946789e088784", + "sha256:465b157480902ecd6b2c4c46d547e4cfdec7595177bcc612c6f4c351966a5a1e", + "sha256:90686bd1292972ad47298154c8ad37543080b2fc0417b68a350d30232b77f670", + "sha256:b224afffbc4ea21fe100824ff08a79ad1fae3088c1280b8ff815067b827e5240", + "sha256:b563106e93525bd30072a27448cc2b5c11e45bf6025e74e1d40efd5349a31736", + "sha256:bfe8989ed4af5e14ef1480c89fe259f5d4bc66c5ae0e7d56d7c778ef6ebbcdf4", + "sha256:c54ad51bfc4623de287bd68f14f481f72300bd16a2d9e14850c1ef1a2dfc7dca", + "sha256:c7a1d365f2f8ce413847479a2d39bee2be6d5027119b08c4c5f6594f1f57673d", + "sha256:d4c784339c366d27d0b4503a073c97a7cd78657937b3755fdbc9168a002f6409", + "sha256:d70f3a44fb25f555da36e6c242cd1dffac6c4637eb900584f2a70a46e8e3cf05", + "sha256:e19ba664cae0bc6ad39e2fbf3b3f16c93009132f792dcb7ca0105f0cc15997ed", + "sha256:ebfeaff7e0bb1c293008a49b2b2c033a7cec3805006a8284fa1a44ad8c30446b", + "sha256:ecd84834f58daf94d3a4874a11997180380ef9af11b1fd5f81dbb8599b3c3446", + "sha256:f1082f4f15a681b435686ed3fc0232128a222fed6792a47fae5cf081eb751ccf", + "sha256:fc9fa0fa88423c0e74db52fe13939aa506789f924a2a879e31f1d415e39a0db2", + "sha256:fd0f0629e27d97eec2f51500afbe0845876fdcd89d412035f4d7beb618c51066", + "sha256:ff4de00d4f19d622561dee58a5121045ca265df3665751d14433149bbd748f55" ], "markers": "python_version >= '3.6'", - "version": "==4.5.1.48" + "version": "==4.5.3.56" }, "openflexure-microscope-server": { "editable": true, @@ -1115,11 +1292,11 @@ }, "packaging": { "hashes": [ - "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", - "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" + "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", + "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.9" + "markers": "python_version >= '3.6'", + "version": "==21.0" }, "pastel": { "hashes": [ @@ -1154,6 +1331,7 @@ "sha256:25930fadde8019f374400f7986e8404c8b781ce519da27792cbe46eabec00c4d", "sha256:39b8830992872e00605ec6eb0d83efad488553f10d79066b8b47c8be38e126b3", "sha256:431b15cffbf949e89df2f7b48528be18b78bfa5177cb3036284a5508159492b5", + "sha256:4a2ef919795ad6fc28c47be3f16ec8add4e8c6daa01871e25788cda1b244b162", "sha256:52125833b070791fcb5710fabc640fc1df07d087fc0c0f02d3661f76c23c5b8b", "sha256:5e51ee2b8114def244384eda1c82b10e307ad9778dac5c83fb0943775a653cd8", "sha256:612cfda94e9c8346f239bf1a4b082fdd5c8143cf82d685ba2dba76e7adeeb233", @@ -1193,9 +1371,19 @@ "sha256:6fb3021603d4421c6fcc40072bbcf150a6c52ef70ff4d3be089b8b04e015ef5a", "sha256:70b97cb194b978dc464c70793e85e6f746cddf82b84a38bfb135946ad71ae19c" ], - "markers": "python_version >= '3.6' and python_version < '4'", + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==0.10.0" }, + "prance": { + "extras": [ + "osv" + ], + "hashes": [ + "sha256:03374b020a4f643c71fc941bb7d1e4852331732714b4b6ebbbd53dd5f75adacf", + "sha256:43ebe3a5b38f0c65c428427004c4d8ce8d7155ddad50610276c89c192680f138" + ], + "version": "==0.21.2" + }, "psutil": { "hashes": [ "sha256:094f899ac3ef72422b7e00411b4ed174e3c5a2e04c267db6643937ddba67a05b", @@ -1204,6 +1392,7 @@ "sha256:21231ef1c1a89728e29b98a885b8e0a8e00d09018f6da5cdc1f43f988471a995", "sha256:28f771129bfee9fc6b63d83a15d857663bbdcae3828e1cb926e91320a9b5b5cd", "sha256:70387772f84fa5c3bb6a106915a2445e20ac8f9821c5914d7cbde148f4d7ff73", + "sha256:7d5e1be385287e2334f708ec261b8ee364ac2bcb07eadcbd7a254f9cd476b65f", "sha256:891e24ec41be15a99c00b0f3a378d3d439876fe18793f874a4f8e796fec77458", "sha256:a9340f6b9ff887d77aed72a51d101ca0f21b8d82edc6c85105d1d9f027544365", "sha256:b560f5cd86cf8df7bcd258a851ca1ad98f0d5b8b98748e877a0aec4e9032b465", @@ -1233,11 +1422,11 @@ }, "pylint": { "hashes": [ - "sha256:586d8fa9b1891f4b725f587ef267abe2a1bad89d6b184520c7f07a253dd6e217", - "sha256:f7e2072654a6b6afdf5e2fb38147d3e2d2d43c89f648637baab63e026481279b" + "sha256:23a1dc8b30459d78e9ff25942c61bb936108ccbe29dd9e71c01dc8274961709a", + "sha256:5d46330e6b8886c31b5e3aba5ff48c10f4aa5e76cbf9002c6544306221e63fbc" ], "markers": "python_version ~= '3.6'", - "version": "==2.8.2" + "version": "==2.9.3" }, "pyparsing": { "hashes": [ @@ -1247,6 +1436,34 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, + "pyrsistent": { + "hashes": [ + "sha256:097b96f129dd36a8c9e33594e7ebb151b1515eb52cceb08474c10a5479e799f2", + "sha256:2aaf19dc8ce517a8653746d98e962ef480ff34b6bc563fc067be6401ffb457c7", + "sha256:404e1f1d254d314d55adb8d87f4f465c8693d6f902f67eb6ef5b4526dc58e6ea", + "sha256:48578680353f41dca1ca3dc48629fb77dfc745128b56fc01096b2530c13fd426", + "sha256:4916c10896721e472ee12c95cdc2891ce5890898d2f9907b1b4ae0f53588b710", + "sha256:527be2bfa8dc80f6f8ddd65242ba476a6c4fb4e3aedbf281dfbac1b1ed4165b1", + "sha256:58a70d93fb79dc585b21f9d72487b929a6fe58da0754fa4cb9f279bb92369396", + "sha256:5e4395bbf841693eaebaa5bb5c8f5cdbb1d139e07c975c682ec4e4f8126e03d2", + "sha256:6b5eed00e597b5b5773b4ca30bd48a5774ef1e96f2a45d105db5b4ebb4bca680", + "sha256:73ff61b1411e3fb0ba144b8f08d6749749775fe89688093e1efef9839d2dcc35", + "sha256:772e94c2c6864f2cd2ffbe58bb3bdefbe2a32afa0acb1a77e472aac831f83427", + "sha256:773c781216f8c2900b42a7b638d5b517bb134ae1acbebe4d1e8f1f41ea60eb4b", + "sha256:8c065dcc8ed1c7412be6f0cfa69e49434c61bf73953ee30ddf35420795408e47", + "sha256:a0c772d791c38bbc77be659af29bb14c38ced151433592e326361610250c605b", + "sha256:b29b869cf58412ca5738d23691e96d8aff535e17390128a1a52717c9a109da4f", + "sha256:c1a9ff320fa699337e05edcaae79ef8c2880b52720bc031b219e5b5008ebbdef", + "sha256:cd3caef37a415fd0dae6148a1b6957a8c5f275a62cca02e18474608cb263640c", + "sha256:d5ec194c9c573aafaceebf05fc400656722793dac57f254cd4741f3c27ae57b4", + "sha256:da6e5e818d18459fa46fac0a4a4e543507fe1110e808101277c5a2b5bab0cd2d", + "sha256:e79d94ca58fcafef6395f6352383fa1a76922268fa02caa2272fff501c2fdc78", + "sha256:f3ef98d7b76da5eb19c37fda834d50262ff9167c65658d1d8f974d2e4d90676b", + "sha256:f4c8cabb46ff8e5d61f56a037974228e978f26bfefce4f61a4b1ac0ba7a2ab72" + ], + "markers": "python_version >= '3.6'", + "version": "==0.18.0" + }, "pyserial": { "hashes": [ "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", @@ -1272,11 +1489,11 @@ }, "python-dateutil": { "hashes": [ - "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", - "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.1" + "version": "==2.8.2" }, "pytz": { "hashes": [ @@ -1322,11 +1539,11 @@ }, "requests": { "hashes": [ - "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", - "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" + "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", + "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.25.1" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==2.26.0" }, "rope": { "hashes": [ @@ -1336,16 +1553,6 @@ ], "version": "==0.14.0" }, - "rpi.gpio": { - "hashes": [ - "sha256:5cd2f3c88d0b1e2f4780c905702c0474768c2bc113fb793121c2e117b401ab16", - "sha256:6a4791f41cafc2ee6e4cb70e5bd31fadc66a0cfab29b38df8723a98f6f73ad5a", - "sha256:7424bc6c205466764f30f666c18187a0824077daf20b295c42f08aea2cb87d3f", - "sha256:7da5235aeba20da39ad4fb97f3ba3a2c6b2d60bba8958b00a4df42f608c6d42e" - ], - "markers": "platform_machine == 'armv7l'", - "version": "==0.7.0" - }, "sangaboard": { "hashes": [ "sha256:3791159d57a749571f89acdce70aa9c1de82afef71f5186d5e2845c7be44698c", @@ -1379,6 +1586,14 @@ "markers": "python_version < '3.10' and python_version >= '3.7'", "version": "==1.6.3" }, + "semver": { + "hashes": [ + "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4", + "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.13.0" + }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", @@ -1396,11 +1611,18 @@ }, "sphinx": { "hashes": [ - "sha256:b5c2ae4120bf00c799ba9b3699bc895816d272d120080fbc967292f29b52b48c", - "sha256:d1cb10bee9c4231f1700ec2e24a91be3f3a3aba066ea4ca9f3bbe47e59d5a1d4" + "sha256:19010b7b9fa0dc7756a6e105b2aacd3a80f798af3c25c273be64d7beeb482cb1", + "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8" ], - "markers": "python_version >= '3.6'", - "version": "==4.0.2" + "markers": "python_version >= '3.5'", + "version": "==3.5.4" + }, + "sphinx-rtd-theme": { + "hashes": [ + "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a", + "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f" + ], + "version": "==0.5.2" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -1420,11 +1642,11 @@ }, "sphinxcontrib-htmlhelp": { "hashes": [ - "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", - "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" + "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07", + "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2" ], - "markers": "python_version >= '3.5'", - "version": "==1.0.3" + "markers": "python_version >= '3.6'", + "version": "==2.0.0" }, "sphinxcontrib-httpdomain": { "hashes": [ @@ -1441,6 +1663,14 @@ "markers": "python_version >= '3.5'", "version": "==1.0.1" }, + "sphinxcontrib-openapi": { + "hashes": [ + "sha256:1c1bd10d7653912c59a42f727c62cbb7b75f7905ddd9ccc477ebfd1bc69f0cf3", + "sha256:3c2a33c1d24733e6f22a0091b8172d08c723a7f192699f0dbfac99bb380dcb31" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, "sphinxcontrib-qthelp": { "hashes": [ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", @@ -1451,11 +1681,11 @@ }, "sphinxcontrib-serializinghtml": { "hashes": [ - "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", - "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" + "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", + "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" ], "markers": "python_version >= '3.5'", - "version": "==1.1.4" + "version": "==1.1.5" }, "toml": { "hashes": [ @@ -1521,11 +1751,11 @@ }, "urllib3": { "hashes": [ - "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", - "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937" + "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", + "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.4" + "version": "==1.26.6" }, "webargs": { "hashes": [ @@ -1546,6 +1776,7 @@ "wrapt": { "hashes": [ "sha256:1ba3a6bcb5014ed4d9c98afe8aa47b5ed320f84292b4f1b9f6afa0f4b2b91351", + "sha256:548a5381002654b72c8b8d1e167722e2a11169535d017d79d9cdee76502c1bc6", "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7", "sha256:c16fd7cad3d27a5db392ae0e6215533a912a7d3d83a40b8cf275744276989780", "sha256:d3294c918c5529e50b5454d39b589648d9baf6152e380067606d5566d71369a0" @@ -1561,11 +1792,11 @@ }, "zipp": { "hashes": [ - "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76", - "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098" + "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3", + "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4" ], "markers": "python_version >= '3.6'", - "version": "==3.4.1" + "version": "==3.5.0" } } } diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 6e3c4e0e..5384b828 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -258,7 +258,16 @@ def generate_openapi(): 'Use .yml or .yaml for YAML (which is the default' ) ) + parser.add_argument( + "--validate", + action="store_true", + help="Validate the API spec, returning an error code if it does not pass." + ) args = parser.parse_args() + if args.validate: + import apispec.utils + if apispec.utils.validate_spec(labthing.spec): + print("OpenAPI specification validated OK.") fname = args.output if fname.endswith(".json"): import json @@ -268,7 +277,6 @@ def generate_openapi(): with open(fname, 'w') as fd: fd.write(labthing.spec.to_yaml()) - # Start the app if the module is run directly if __name__ == "__main__": ofm_serve() diff --git a/setup.py b/setup.py index ed4cfd36..614961d5 100644 --- a/setup.py +++ b/setup.py @@ -44,6 +44,7 @@ setup( # set up the project for development, you use those specific packages, rather than # the looser specifications given here. install_requires=[ + "apispec[validation]", # We need the extra to validate the spec "Flask ~= 1.0", "Pillow ~= 7.2.0", "numpy ~= 1.20", From 8eba9d7981940af1677d638671220a2ef0f9b9ad Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 19 Jul 2021 10:51:42 +0000 Subject: [PATCH 14/36] Build and validate OpenAPI YAML description in CI --- .gitlab-ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8758cabe..1f2e1ce0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -102,6 +102,27 @@ pytest: - tags - web +openapi: + stage: testing + image: python:3.7 + retry: 1 + + <<: *python-install + + script: + - ofm-generate-openapi -o docs/build/swagger.yaml --validate + artifacts: + name: "openapi" + expire_in: 1 week + paths: + - "./docs/build/swagger.yaml" + + only: + - branches + - merge_requests + - tags + - web + # JavaScript linting with ESLint (via Vue CLI) eslint: stage: analysis @@ -146,6 +167,7 @@ package: stage: package dependencies: - build + - openapi image: ubuntu:latest @@ -154,9 +176,11 @@ package: - mkdir -p dist - tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz . - tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz ./openflexure_microscope/api/static/dist/ + - cp docs/build/swagger.yaml dist/openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml - cd dist/ - sha256sum openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz.sha256 - sha256sum openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz.sha256 + - sha256sum openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml > openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml.sha256 artifacts: name: "dist" From 38fbe530d2c8a8628ba5cd442312e00090c419bc Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 19 Jul 2021 11:56:40 +0100 Subject: [PATCH 15/36] Fix externalDocs entries My externalDocs entries in the "tags" metadata section of the OpenAPI description were not valid. This is now fixed. --- openflexure_microscope/api/openapi.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/openflexure_microscope/api/openapi.py b/openflexure_microscope/api/openapi.py index d96aa491..9996a57b 100644 --- a/openflexure_microscope/api/openapi.py +++ b/openflexure_microscope/api/openapi.py @@ -9,7 +9,10 @@ API_TAGS = [ "as `Action` objects and their status can be queried using the links " "embedded in the JSON action description." ), - "externalDocs": "https://iot.mozilla.org/wot/#action-resource", + "externalDocs": { + "url": "https://iot.mozilla.org/wot/#action-resource", + "description": "Mozilla's description of Web of Things 'Action' resources." + } }, { "name": "properties", @@ -17,22 +20,22 @@ API_TAGS = [ "Properties can be read and/or written to, and affect the " "state of the microscope." ), - "externalDocs": "https://iot.mozilla.org/wot/#property-resource", + "externalDocs": { + "url": "https://iot.mozilla.org/wot/#property-resource", + "description": "Mozilla's description of Web of Things 'Property' resources." + } }, { "name": "captures", "description": "", - "externalDocs": "" }, { "name": "extensions", "description": "", - "externalDocs": "" }, { "name": "events", "description": "", - "externalDocs": "" } ] From b7c9d3b73bd6ccb2c90cec207588f52045ae10ac Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 19 Jul 2021 20:09:09 +0100 Subject: [PATCH 16/36] Sorted remaining content_type issues and blackened --- docs/source/conf.py | 22 ++--- .../example_extension/05_actions.py | 2 +- docs/test_swagger_yaml.py | 1 + openflexure_microscope/api/app.py | 25 +++--- .../api/default_extensions/autofocus.py | 81 +++++++++++-------- openflexure_microscope/api/openapi.py | 26 ++---- .../api/v2/views/__init__.py | 2 +- .../api/v2/views/actions/camera.py | 4 +- openflexure_microscope/api/v2/views/camera.py | 2 +- .../api/v2/views/captures.py | 20 ++++- openflexure_microscope/api/v2/views/docs.py | 4 +- .../api/v2/views/streams.py | 11 +-- setup.py | 6 +- 13 files changed, 114 insertions(+), 92 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index b1a2f862..23203631 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -44,7 +44,7 @@ project = "OpenFlexure Microscope Software" copyright = "2018, Bath Open Instrumentation Group" # pylint: disable=redefined-builtin author = "Bath Open Instrumentation Group" -#TODO: extract version from ../setup.py +# TODO: extract version from ../setup.py # The short X.Y version version = "" # The full version, including alpha/beta/rc tags @@ -68,11 +68,11 @@ extensions = [ "sphinx.ext.viewcode", "sphinx.ext.githubpages", "sphinx.ext.ifconfig", - #"sphinxcontrib.httpdomain", - #"sphinxcontrib.autohttp.flask", - #"sphinxcontrib.autohttp.flaskqref", + # "sphinxcontrib.httpdomain", + # "sphinxcontrib.autohttp.flask", + # "sphinxcontrib.autohttp.flaskqref", "sphinxcontrib.openapi", - "sphinxcontrib.redoc" + "sphinxcontrib.redoc", ] # Override ordering @@ -228,7 +228,7 @@ epub_exclude_files = ["search.html"] # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. -#TODO: update with pysangaboard? +# TODO: update with pysangaboard? intersphinx_mapping = { "openflexure_stage": ("https://openflexure-stage.readthedocs.io/en/latest/", None), "picamerax": ("https://picamerax.readthedocs.io/en/latest//", None), @@ -244,9 +244,9 @@ todo_include_todos = True # -- Options for redoc extension --------------------------------------------- redoc = [ { - 'name': 'OpenFlexure Microscope HTTP API', - 'page': 'api_redoc', - 'spec': '../build/swagger.yaml', - 'embed': True, + "name": "OpenFlexure Microscope HTTP API", + "page": "api_redoc", + "spec": "../build/swagger.yaml", + "embed": True, } -] \ No newline at end of file +] diff --git a/docs/source/extensions/example_extension/05_actions.py b/docs/source/extensions/example_extension/05_actions.py index 0ac21880..ca2578db 100644 --- a/docs/source/extensions/example_extension/05_actions.py +++ b/docs/source/extensions/example_extension/05_actions.py @@ -94,7 +94,7 @@ class QuickCaptureAPI(ActionView): args = {"use_video_port": fields.Boolean(missing=True)} # Our success response (200) returns an image (image/jpeg mimetype) - responses = {200: {"content_type": "image/jpeg"}} + responses = {200: {"content": {"image/jpeg": {}}}} def post(self, args): """ diff --git a/docs/test_swagger_yaml.py b/docs/test_swagger_yaml.py index d37fcbd7..560a76bc 100644 --- a/docs/test_swagger_yaml.py +++ b/docs/test_swagger_yaml.py @@ -1,4 +1,5 @@ import yaml + with open("./build/swagger.yaml", "r") as f: api = yaml.load(f) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 5384b828..ee56a86d 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -210,6 +210,7 @@ def routes(): def api_v1_catch_all(path): # pylint: disable=W0613 abort(410, "API v1 is no longer in use. Please upgrade your client.") + add_spec_extras(labthing.spec) @@ -247,36 +248,40 @@ def ofm_serve(): server: Server = Server(app) server.run(host="0.0.0.0", port=5000, debug=debug_app, zeroconf=True) + def generate_openapi(): parser = argparse.ArgumentParser("Generate an OpenAPI specification document") parser.add_argument( - '-o', - dest='output', + "-o", + dest="output", default="openapi.yaml", help=( - 'Specify the output filename. If it ends in .json, we output JSON.' - 'Use .yml or .yaml for YAML (which is the default' - ) + "Specify the output filename. If it ends in .json, we output JSON." + "Use .yml or .yaml for YAML (which is the default" + ), ) parser.add_argument( - "--validate", - action="store_true", - help="Validate the API spec, returning an error code if it does not pass." + "--validate", + action="store_true", + help="Validate the API spec, returning an error code if it does not pass.", ) args = parser.parse_args() if args.validate: import apispec.utils + if apispec.utils.validate_spec(labthing.spec): print("OpenAPI specification validated OK.") fname = args.output if fname.endswith(".json"): import json - with open(fname, 'w') as fd: + + with open(fname, "w") as fd: json.dump(labthing.spec.to_dict(), fd) else: - with open(fname, 'w') as fd: + with open(fname, "w") as fd: fd.write(labthing.spec.to_yaml()) + # Start the app if the module is run directly if __name__ == "__main__": ofm_serve() diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index 3efd7973..90027d48 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -24,6 +24,7 @@ class JPEGSharpnessMonitor: """Monitor JPEG frame size in a background thread This class starts a background thread """ + def __init__(self, microscope: Microscope): self.microscope: Microscope = microscope self.camera: BaseCamera = microscope.camera @@ -164,6 +165,7 @@ def find_microscope() -> Microscope: return microscope + def find_microscope_with_real_stage() -> Microscope: """Find the microscope and ensure it has a real stage. @@ -199,6 +201,7 @@ def extension_action(args=None): responsible for collating and adding the views in its `__init__` method. """ supplied_args = args + def decorator(func): class_docstring = f"""Manage actions for {func.__name__}. @@ -206,6 +209,7 @@ def extension_action(args=None): each time {func.__name__} has been run in response to a `GET` request, and will start a new `Action` in response to a `POST` request. """ + class ActionViewWrapper(ActionView): __doc__ = class_docstring args = supplied_args @@ -222,9 +226,9 @@ def extension_action(args=None): # aren't guaranteed to have the same leading whitespace, we just make sure # both docstrings are stripped of leading indent, using `inspect.cleandoc()` ActionViewWrapper.post.description = ( - get_docstring(func, remove_newlines=False) + - "\n\n" + - inspect.cleandoc( + get_docstring(func, remove_newlines=False) + + "\n\n" + + inspect.cleandoc( """ This `POST` request starts an Action, i.e. the hardware will do something that may continue after the HTTP request has been responded to. The @@ -242,11 +246,13 @@ def extension_action(args=None): ActionViewWrapper.post.summary = get_summary(func) ActionViewWrapper.post.__doc__ = ActionViewWrapper.post.description ActionViewWrapper.__name__ = func.__name__ - ActionViewWrapper.get.summary = f"List running and completed `{func.__name__}` actions." + ActionViewWrapper.get.summary = ( + f"List running and completed `{func.__name__}` actions." + ) ActionViewWrapper.get.description = ( - ActionViewWrapper.get.summary + - "\n\n" + - inspect.cleandoc( + ActionViewWrapper.get.summary + + "\n\n" + + inspect.cleandoc( f""" This `GET` request will return a list of `Action` objects corresponding to the `{func.__name__}` action. It will include all the times it has @@ -258,8 +264,8 @@ def extension_action(args=None): func.flask_view = ActionViewWrapper return func - return decorator + return decorator ### Autofocus extension @@ -291,7 +297,9 @@ class AutofocusExtension(BaseExtension): self.add_view(obj.flask_view, f"/{name}", name) def measure_sharpness( - self, microscope: Optional[Microscope] = None, metric_fn: Callable = sharpness_sum_lap2 + self, + microscope: Optional[Microscope] = None, + metric_fn: Callable = sharpness_sum_lap2, ) -> float: """Measure the sharpness from the MJPEG stream @@ -309,13 +317,13 @@ class AutofocusExtension(BaseExtension): raise RuntimeError(f"Object {microscope.camera} has no method `array`") @extension_action( - args = { + args={ "dz": fields.List( - fields.Int(), + fields.Int(), description="An ascending list of relative z positions", - example = [int(x) for x in np.linspace(-300, 300, 7)], + example=[int(x) for x in np.linspace(-300, 300, 7)], ) - }, + } ) def autofocus( self, @@ -360,16 +368,20 @@ class AutofocusExtension(BaseExtension): return positions, sharpnesses - def move_and_find_focus(self, microscope: Optional[Microscope] = None, dz: int = 0) -> int: + def move_and_find_focus( + self, microscope: Optional[Microscope] = None, dz: int = 0 + ) -> int: """Make a relative Z move and return the peak sharpness position""" if not microscope: microscope = find_microscope_with_real_stage() with monitor_sharpness(microscope) as m: m.focus_rel(dz) return m.sharpest_z_on_move(0) - + @extension_action( - args={"dz": fields.Int(required=True, description="The relative Z move to make")}, + args={ + "dz": fields.Int(required=True, description="The relative Z move to make") + } ) def move_and_measure( self, microscope: Optional[Microscope] = None, dz: int = 0 @@ -390,11 +402,11 @@ class AutofocusExtension(BaseExtension): @extension_action( args={ "dz": fields.Int( - missing=2000, - example=2000, - description="Total Z range to search over (in stage steps)" + missing=2000, + example=2000, + description="Total Z range to search over (in stage steps)", ) - }, + } ) def fast_autofocus( self, microscope: Optional[Microscope] = None, dz: int = 2000 @@ -484,27 +496,27 @@ class AutofocusExtension(BaseExtension): return m.data_dict() @extension_action( - args = { + args={ "dz": fields.Int( - missing = 2000, - example = 2000, - description = "Total Z range to search over (in stage steps)" + missing=2000, + example=2000, + description="Total Z range to search over (in stage steps)", ), "target_z": fields.Int( - missing = 0, - example = -100, - description = "Target finishing position, relative to the focus." + missing=0, + example=-100, + description="Target finishing position, relative to the focus.", ), "initial_move_up": fields.Bool( - missing = True, - description = "Set to Flase to disable the initial move upwards" + missing=True, + description="Set to Flase to disable the initial move upwards", ), "backlash": fields.Int( - missing=25, + missing=25, minimum=0, - description="Distance to undershoot, before correction move." + description="Distance to undershoot, before correction move.", ), - }, + } ) def fast_up_down_up_autofocus( self, @@ -563,7 +575,7 @@ class AutofocusExtension(BaseExtension): """ if not microscope: microscope = find_microscope_with_real_stage() - if not mini_backlash: # I renamed the argument, + if not mini_backlash: # I renamed the argument, mini_backlash = backlash or 25 with microscope.lock(timeout=1), microscope.camera.lock, microscope.stage.lock: with monitor_sharpness(microscope) as m: @@ -617,5 +629,6 @@ class AutofocusExtension(BaseExtension): class MeasureSharpnessAPI(View): __doc__ = AutofocusExtension.measure_sharpness.__doc__ + def post(self): - return {"sharpness": self.extension.measure_sharpness()} \ No newline at end of file + return {"sharpness": self.extension.measure_sharpness()} diff --git a/openflexure_microscope/api/openapi.py b/openflexure_microscope/api/openapi.py index 9996a57b..f0605954 100644 --- a/openflexure_microscope/api/openapi.py +++ b/openflexure_microscope/api/openapi.py @@ -1,5 +1,3 @@ - - API_TAGS = [ { "name": "actions", @@ -11,8 +9,8 @@ API_TAGS = [ ), "externalDocs": { "url": "https://iot.mozilla.org/wot/#action-resource", - "description": "Mozilla's description of Web of Things 'Action' resources." - } + "description": "Mozilla's description of Web of Things 'Action' resources.", + }, }, { "name": "properties", @@ -22,23 +20,15 @@ API_TAGS = [ ), "externalDocs": { "url": "https://iot.mozilla.org/wot/#property-resource", - "description": "Mozilla's description of Web of Things 'Property' resources." - } + "description": "Mozilla's description of Web of Things 'Property' resources.", + }, }, - { - "name": "captures", - "description": "", - }, - { - "name": "extensions", - "description": "", - }, - { - "name": "events", - "description": "", - } + {"name": "captures", "description": ""}, + {"name": "extensions", "description": ""}, + {"name": "events", "description": ""}, ] + def add_spec_extras(spec): """Add extra documentation and features to the OpenAPI spec""" # Add a list of tags, so we can control ordering and add descriptions diff --git a/openflexure_microscope/api/v2/views/__init__.py b/openflexure_microscope/api/v2/views/__init__.py index c5f59242..9cb10e41 100644 --- a/openflexure_microscope/api/v2/views/__init__.py +++ b/openflexure_microscope/api/v2/views/__init__.py @@ -4,4 +4,4 @@ from .captures import * from .instrument import * from .stage import * from .streams import * -from .docs import * \ No newline at end of file +from .docs import * diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 65b94eee..a6ef3b12 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -71,8 +71,8 @@ class RAMCaptureAPI(ActionView): args = BasicCaptureArgs() responses = { 200: { - "content": { "image/jpeg": {} }, - "description": "A JPEG image, representing the capture" + "content": {"image/jpeg": {}}, + "description": "A JPEG image, representing the capture", } } diff --git a/openflexure_microscope/api/v2/views/camera.py b/openflexure_microscope/api/v2/views/camera.py index abe0c32e..9179ed10 100644 --- a/openflexure_microscope/api/v2/views/camera.py +++ b/openflexure_microscope/api/v2/views/camera.py @@ -14,7 +14,7 @@ class LSTImageProperty(PropertyView): responses = { 200: { - "content": { "image/jpeg": {} }, + "content": {"image/jpeg": {}}, "description": "Lens-shading table in RGB format", } } diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index e3c1fce4..0903c35e 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -146,8 +146,19 @@ class CaptureList(PropertyView): return image_list +CAPTURE_ID_PARAMETER = { + "name": "id_", + "in": "path", + "description": "The unique ID of the capture", + "required": True, + "schema": {"type": "string"}, + "example": "eeae7ae9-0c0d-45a4-9ef2-7b84bb67a1d1", +} + + class CaptureView(View): tags = ["captures"] + parameters = [CAPTURE_ID_PARAMETER] @marshal_with(FullCaptureSchema()) def get(self, id_): @@ -162,6 +173,8 @@ class CaptureView(View): return capture_obj + get.responses = {404: {"description": "Capture object was not found"}} + def delete(self, id_): """ Delete a single image capture @@ -182,7 +195,10 @@ class CaptureView(View): class CaptureDownload(View): tags = ["captures"] - responses = {200: {"content_type": "image/jpeg"}} + responses = { + 200: {"content": {"image/jpeg": {}}, "description": "Image data in JPEG format"} + } + parameters = [CAPTURE_ID_PARAMETER] def get(self, id_, filename: Optional[str]): """ @@ -223,6 +239,7 @@ class CaptureDownload(View): class CaptureTags(View): tags = ["captures"] + parameters = [CAPTURE_ID_PARAMETER] def get(self, id_): """ @@ -270,6 +287,7 @@ class CaptureTags(View): class CaptureAnnotations(View): tags = ["captures"] + parameters = [CAPTURE_ID_PARAMETER] def get(self, id_): """ diff --git a/openflexure_microscope/api/v2/views/docs.py b/openflexure_microscope/api/v2/views/docs.py index 999668ac..d68c96f5 100644 --- a/openflexure_microscope/api/v2/views/docs.py +++ b/openflexure_microscope/api/v2/views/docs.py @@ -2,6 +2,7 @@ from labthings.views import View from labthings import current_labthing from flask import Response + class APISpecJSONView(View): """OpenAPI v3 documentation @@ -11,6 +12,7 @@ class APISpecJSONView(View): def get(self): return current_labthing().spec.to_dict() + class APISpecYAMLView(View): """OpenAPI v3 documentation @@ -18,4 +20,4 @@ class APISpecYAMLView(View): """ def get(self): - return Response(current_labthing().spec.to_yaml(), mimetype='text/yaml') \ No newline at end of file + return Response(current_labthing().spec.to_yaml(), mimetype="text/yaml") diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index b9602103..4f5ba9ff 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -19,9 +19,7 @@ class MjpegStream(PropertyView): responses = { 200: { - "content": { - "multipart/x-mixed-replace": {} - }, + "content": {"multipart/x-mixed-replace": {}}, "description": ( "An MJPEG stream of camera images.\n\n" "This endpoint will serve JPEG images sequentially, \n" @@ -62,12 +60,7 @@ class SnapshotStream(PropertyView): Single JPEG snapshot from the camera stream """ - responses = { - 200: { - "content": { "image/jpeg": {} }, - "description": "Snapshot taken" - } - } + responses = {200: {"content": {"image/jpeg": {}}, "description": "Snapshot taken"}} def get(self): """ diff --git a/setup.py b/setup.py index 614961d5..061ec442 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ setup( # set up the project for development, you use those specific packages, rather than # the looser specifications given here. install_requires=[ - "apispec[validation]", # We need the extra to validate the spec + "apispec[validation]", # We need the extra to validate the spec "Flask ~= 1.0", "Pillow ~= 7.2.0", "numpy ~= 1.20", @@ -68,7 +68,7 @@ setup( # them to specific versions to enable consistent builds and testing. extras_require={ "dev": [ - "sphinx < 4.0", # Currently httpdomain isn't ready for 4.0 + "sphinx < 4.0", # Currently httpdomain isn't ready for 4.0 "sphinxcontrib-openapi ~= 0.7", "sphinx_rtd_theme ~=0.5.2", "rope ~= 0.14.0", @@ -90,7 +90,7 @@ setup( "console_scripts": [ "ofm-serve=openflexure_microscope.api.app:ofm_serve", "ofm-rescue=openflexure_microscope.rescue.auto:main", - "ofm-generate-openapi=openflexure_microscope.api.app:generate_openapi" + "ofm-generate-openapi=openflexure_microscope.api.app:generate_openapi", ] }, project_urls={ From 72f3c11fdd3009698362cf95c7fe0e65b8552b49 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 19 Jul 2021 20:29:13 +0100 Subject: [PATCH 17/36] linting and typing fixes --- openflexure_microscope/api/app.py | 3 ++- openflexure_microscope/api/default_extensions/autofocus.py | 6 +++--- openflexure_microscope/api/default_extensions/scan.py | 2 +- openflexure_microscope/api/v2/views/__init__.py | 2 +- openflexure_microscope/api/v2/views/docs.py | 4 ++-- openflexure_microscope/microscope.py | 4 ++-- setup.py | 2 ++ 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index ee56a86d..3ad46e20 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -24,7 +24,7 @@ import os from datetime import datetime import pkg_resources -from flask import abort, send_file, jsonify +from flask import abort, jsonify, send_file from flask_cors import CORS, cross_origin from labthings import create_app from labthings.extensions import find_extensions @@ -39,6 +39,7 @@ from openflexure_microscope.paths import ( OPENFLEXURE_VAR_PATH, logs_file_path, ) + from .openapi import add_spec_extras diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index 90027d48..f249bbbd 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -7,6 +7,7 @@ from typing import Callable, Dict, List, Optional, Tuple import numpy as np from labthings import current_action, fields, find_component from labthings.extensions import BaseExtension +from labthings.utilities import get_docstring, get_summary from labthings.views import ActionView, View from scipy import ndimage @@ -15,7 +16,6 @@ from openflexure_microscope.devel import abort from openflexure_microscope.microscope import Microscope from openflexure_microscope.stage.base import BaseStage from openflexure_microscope.utilities import set_properties -from labthings.utilities import get_docstring, get_summary ### Autofocus utilities @@ -218,7 +218,7 @@ def extension_action(args=None): # Run the action return func(**arguments) - def get(self, *args, **kwargs): + def get(self, *args, **kwargs): # pylint: disable=useless-super-delegation # Explicitly wrap the `get` method to allow us to add a docstring return super().get(*args, **kwargs) @@ -346,7 +346,7 @@ class AutofocusExtension(BaseExtension): camera: BaseCamera = microscope.camera stage: BaseStage = microscope.stage if not dz: - dz = np.linspace(-300, 300, 7) + dz: List[int] = list(np.linspace(-300, 300, 7)) with set_properties(stage, backlash=256), stage.lock, camera.lock: sharpnesses: List[float] = [] diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 782ad8f4..3bb502a1 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -2,10 +2,10 @@ import datetime import logging import time import uuid -import marshmallow from functools import reduce from typing import Dict, List, Optional, Tuple +import marshmallow from labthings import ( current_action, fields, diff --git a/openflexure_microscope/api/v2/views/__init__.py b/openflexure_microscope/api/v2/views/__init__.py index 9cb10e41..5e53611f 100644 --- a/openflexure_microscope/api/v2/views/__init__.py +++ b/openflexure_microscope/api/v2/views/__init__.py @@ -1,7 +1,7 @@ from .actions import enabled_root_actions from .camera import * from .captures import * +from .docs import * from .instrument import * from .stage import * from .streams import * -from .docs import * diff --git a/openflexure_microscope/api/v2/views/docs.py b/openflexure_microscope/api/v2/views/docs.py index d68c96f5..8d2ce264 100644 --- a/openflexure_microscope/api/v2/views/docs.py +++ b/openflexure_microscope/api/v2/views/docs.py @@ -1,6 +1,6 @@ -from labthings.views import View -from labthings import current_labthing from flask import Response +from labthings import current_labthing +from labthings.views import View class APISpecJSONView(View): diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index da546b60..f97d3dc6 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -19,8 +19,8 @@ from openflexure_microscope.stage.sanga import SangaDeltaStage, SangaStage try: from openflexure_microscope.camera.pi import PiCameraStreamer -except Exception as e: # pylint: disable=W0703 - logging.error(e) +except Exception as exc: # pylint: disable=W0703 + logging.error(exc) logging.warning("Unable to import PiCameraStreamer") from labthings import CompositeLock diff --git a/setup.py b/setup.py index 061ec442..a493ebad 100644 --- a/setup.py +++ b/setup.py @@ -75,6 +75,8 @@ setup( "pylint ~= 2.3", "pytest ~= 6.1.2", "mypy ~= 0.790", + "types-python-dateutil", + "types-setuptools", "poethepoet ~= 0.10.0", "freezegun ~= 1.0.0", "lxml ~= 4.6", From ba0f78df84d981f5adadb0a58334fdf4b81b87ab Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 19 Jul 2021 21:18:25 +0100 Subject: [PATCH 18/36] fix typing in autofocus I have an Optional dz, which is then populated with a default if it's None. I've added an explicit cast() to explain this to mypy. --- Pipfile.lock | 40 +++++++++++++------ .../api/default_extensions/autofocus.py | 5 ++- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index cccc5ec5..1975c645 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -54,7 +54,7 @@ "sha256:02a409bd2cb193b20d9eb015ca31400bd3e1721ec5d702e1b96de54608ff5a8f", "sha256:d39c3afebb59ec865fb1247dfd951d7090c159ab0cf3e4291e0869d3be33776c" ], - "markers": "python_version >= '3.6' and python_version < '4'", + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==0.1.4" }, "certifi": { @@ -151,7 +151,7 @@ "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", "version": "==5.5" }, "expiringdict": { @@ -250,7 +250,7 @@ "sha256:4f286ea585e596aeb67a02d5bd822b2c89828693026085829e15e415a2d2d287", "sha256:970bb6a81f0e007b55e575e6eafe6bba10974d671f2767b91631f001ff53cf0e" ], - "markers": "python_version >= '3.6' and python_version < '4'", + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==1.2.4" }, "markupsafe": { @@ -676,7 +676,7 @@ "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", "version": "==1.26.6" }, "webargs": { @@ -748,11 +748,11 @@ }, "astroid": { "hashes": [ - "sha256:38b95085e9d92e2ca06cf8b35c12a74fa81da395a6f9e65803742e6509c05892", - "sha256:606b2911d10c3dcf35e58d2ee5c97360e8477d7b9f3efc3f24811c93e6fc2cd9" + "sha256:11e598e49e31f288ae43d13e8d0eb28454e5e2aa745cbc16cc799f147bb7e8dd", + "sha256:958c2aa6ba47e0b69773ce2b30cd6c2c99d15a5cc611e323d231f4c44db36d54" ], "markers": "python_version ~= '3.6'", - "version": "==2.6.2" + "version": "==2.6.3" }, "attrs": { "hashes": [ @@ -783,7 +783,7 @@ "sha256:02a409bd2cb193b20d9eb015ca31400bd3e1721ec5d702e1b96de54608ff5a8f", "sha256:d39c3afebb59ec865fb1247dfd951d7090c159ab0cf3e4291e0869d3be33776c" ], - "markers": "python_version >= '3.6' and python_version < '4'", + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==0.1.4" }, "certifi": { @@ -880,7 +880,7 @@ "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", "version": "==5.5" }, "docutils": { @@ -980,7 +980,7 @@ "sha256:eed17b53c3e7912425579853d078a0832820f023191561fcee9d7cae424e0813", "sha256:f65ce5bd4cbc6abdfbe29afc2f0245538ab358c14590912df638033f157d555e" ], - "markers": "python_version < '4.0' and python_full_version >= '3.6.1'", + "markers": "python_version < '4' and python_full_version >= '3.6.1'", "version": "==5.9.2" }, "itsdangerous": { @@ -1011,7 +1011,7 @@ "sha256:4f286ea585e596aeb67a02d5bd822b2c89828693026085829e15e415a2d2d287", "sha256:970bb6a81f0e007b55e575e6eafe6bba10974d671f2767b91631f001ff53cf0e" ], - "markers": "python_version >= '3.6' and python_version < '4'", + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==1.2.4" }, "lazy-object-proxy": { @@ -1371,7 +1371,7 @@ "sha256:6fb3021603d4421c6fcc40072bbcf150a6c52ef70ff4d3be089b8b04e015ef5a", "sha256:70b97cb194b978dc464c70793e85e6f746cddf82b84a38bfb135946ad71ae19c" ], - "markers": "python_version >= '3.6' and python_version < '4.0'", + "markers": "python_version >= '3.6' and python_version < '4'", "version": "==0.10.0" }, "prance": { @@ -1740,6 +1740,20 @@ "markers": "python_version < '3.8' and implementation_name == 'cpython'", "version": "==1.4.3" }, + "types-python-dateutil": { + "hashes": [ + "sha256:39bfe0bde61fc673b8fa28167bd78622d976210f791971b9f3e10877cbf119a4", + "sha256:e6486ca27b6dde73e0ec079a9e1b03e208766e6bc7f1e08964a7e9104a5c7d7a" + ], + "version": "==0.1.4" + }, + "types-setuptools": { + "hashes": [ + "sha256:71ed0f4c71d8fb5f3026a90ae82d163c13749b110e157d82126725ac8f714360", + "sha256:b3ada82b21dcb8e0cafd7658d8a16018a000e55bdb7f6f3cec033223360563ce" + ], + "version": "==57.0.0" + }, "typing-extensions": { "hashes": [ "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", @@ -1754,7 +1768,7 @@ "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", "version": "==1.26.6" }, "webargs": { diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index f249bbbd..241ea9cd 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -2,7 +2,7 @@ import inspect import logging import time from contextlib import contextmanager -from typing import Callable, Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple, cast import numpy as np from labthings import current_action, fields, find_component @@ -346,7 +346,8 @@ class AutofocusExtension(BaseExtension): camera: BaseCamera = microscope.camera stage: BaseStage = microscope.stage if not dz: - dz: List[int] = list(np.linspace(-300, 300, 7)) + dz = list(np.linspace(-300, 300, 7)) + dz = cast(List[int], dz) # dz can't now be None, so fix its type. with set_properties(stage, backlash=256), stage.lock, camera.lock: sharpnesses: List[float] = [] From 36be07b276a675414b7d85835e79d49f175e2458 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 19 Jul 2021 21:32:08 +0100 Subject: [PATCH 19/36] Responses and parameters for zipbuilder --- .../api/default_extensions/zip_builder.py | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 74444a07..50103a2f 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -149,15 +149,23 @@ class ZipBuilderAPIView(ActionView): args = fields.List(fields.String(), required=True) def post(self, args): + """Build a zip file of some captures + + Given a list of capture IDs as its argument, this action will + create a zip file that can be downloaded once the action has + completed. + """ microscope = find_component("org.openflexure.microscope") - # Return a handle on the autofocus task + # Build a zip file from the supplied IDs return self.extension.manager.marshaled_build_zip_from_capture_ids( microscope, args ) class ZipListAPIView(PropertyView): + """List all the zip files currently available for download. + """ schema = ZipObjectSchema(many=True) def get(self): @@ -165,9 +173,22 @@ class ZipListAPIView(PropertyView): class ZipGetterAPIView(View): + """Download or delete a particular capture collection ZIP file """ - Download or delete a particular capture collection ZIP file - """ + parameters = [ + { + "name": "session_id", + "in": "path", + "description": "The unique ID of the zip builder session", + "required": True, + "schema": { "type": "string" }, + } + ] + responses = { + 404: { + "description": "The session ID could not be found", + } + } def get(self, session_id): """ @@ -176,7 +197,7 @@ class ZipGetterAPIView(View): if not session_id in self.extension.manager.session_zips: return abort(404) # 404 Not Found - logging.info("Session ID: %s", session_id) + logging.info("Retrieving zip (session ID: %s)", session_id) return send_file( self.extension.manager.zip_fp_from_id(session_id).name, @@ -184,6 +205,12 @@ class ZipGetterAPIView(View): as_attachment=True, attachment_filename=f"{session_id}.zip", ) + get.responses = { + 200: { + "content": {"application/zip": {}}, + "description": "A zip archive containing the selected captures", + }, + } def delete(self, session_id): """ @@ -198,3 +225,8 @@ class ZipGetterAPIView(View): del self.extension.manager.session_zips[session_id] return {"return": session_id} + delete.responses = { + 200: { + "description": "The zip file was deleted", + }, + } From ec992c083f67d98788d54091dc0f6c857ed86b8d Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 19 Jul 2021 21:40:28 +0100 Subject: [PATCH 20/36] Describe filename param in capture download --- openflexure_microscope/api/v2/views/captures.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 0903c35e..e7def4f7 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -198,7 +198,17 @@ class CaptureDownload(View): responses = { 200: {"content": {"image/jpeg": {}}, "description": "Image data in JPEG format"} } - parameters = [CAPTURE_ID_PARAMETER] + parameters = [ + CAPTURE_ID_PARAMETER, + { + "name": "filename", + "in": "path", + "description": "The filename of the downloaded image.", + "required": False, + "schema": {"type": "string"}, + "example": "myimage.jpeg", + } + ] def get(self, id_, filename: Optional[str]): """ From 34af06b1d113df539732400a349e10a6a6a5c100 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 19 Jul 2021 22:21:39 +0100 Subject: [PATCH 21/36] Document route parameter in nested dictionaries --- .../api/v2/views/instrument.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/openflexure_microscope/api/v2/views/instrument.py b/openflexure_microscope/api/v2/views/instrument.py index 28d9ea8a..8bb2e37d 100644 --- a/openflexure_microscope/api/v2/views/instrument.py +++ b/openflexure_microscope/api/v2/views/instrument.py @@ -32,9 +32,24 @@ class SettingsProperty(PropertyView): return self.get() +ROUTE_PARAMETER = { + "name": "route", + "in": "path", + "description": ( + "The location of a key or sub-dictionary. " + + "This is formatted like a path, i.e. forward " + + "slashes delimit components of the path." + ), + "required": True, + "schema": {"type": "string"}, + "example": "camera/exposure_time", +} + + class NestedSettingsProperty(View): tags = ["properties"] responses = {404: {"description": "Settings key cannot be found"}} + parameters = [ROUTE_PARAMETER] def get(self, route: str): """ @@ -79,6 +94,7 @@ class StateProperty(PropertyView): class NestedStateProperty(View): tags = ["properties"] responses = {404: {"description": "Status key cannot be found"}} + parameters = [ROUTE_PARAMETER] def get(self, route): """ @@ -107,6 +123,7 @@ class ConfigurationProperty(PropertyView): class NestedConfigurationProperty(View): tags = ["properties"] responses = {404: {"description": "Status key cannot be found"}} + parameters = [ROUTE_PARAMETER] def get(self, route): """ From 7b54abc210ba783d493de5e8bcaab9991d4f3099 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 19 Jul 2021 22:22:58 +0100 Subject: [PATCH 22/36] blackened --- .../api/default_extensions/zip_builder.py | 22 ++++++++----------- .../api/v2/views/captures.py | 2 +- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 50103a2f..0885fc41 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -166,6 +166,7 @@ class ZipBuilderAPIView(ActionView): class ZipListAPIView(PropertyView): """List all the zip files currently available for download. """ + schema = ZipObjectSchema(many=True) def get(self): @@ -175,20 +176,17 @@ class ZipListAPIView(PropertyView): class ZipGetterAPIView(View): """Download or delete a particular capture collection ZIP file """ + parameters = [ { "name": "session_id", "in": "path", "description": "The unique ID of the zip builder session", "required": True, - "schema": { "type": "string" }, + "schema": {"type": "string"}, } ] - responses = { - 404: { - "description": "The session ID could not be found", - } - } + responses = {404: {"description": "The session ID could not be found"}} def get(self, session_id): """ @@ -205,11 +203,12 @@ class ZipGetterAPIView(View): as_attachment=True, attachment_filename=f"{session_id}.zip", ) + get.responses = { 200: { - "content": {"application/zip": {}}, + "content": {"application/zip": {}}, "description": "A zip archive containing the selected captures", - }, + } } def delete(self, session_id): @@ -225,8 +224,5 @@ class ZipGetterAPIView(View): del self.extension.manager.session_zips[session_id] return {"return": session_id} - delete.responses = { - 200: { - "description": "The zip file was deleted", - }, - } + + delete.responses = {200: {"description": "The zip file was deleted"}} diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index e7def4f7..44f056ca 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -207,7 +207,7 @@ class CaptureDownload(View): "required": False, "schema": {"type": "string"}, "example": "myimage.jpeg", - } + }, ] def get(self, id_, filename: Optional[str]): From e2990b5b387682666bfd5abca3b03d650acf0884 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 19 Jul 2021 21:31:45 +0000 Subject: [PATCH 23/36] Bump pylint version and fix to 2.8 to avoid bug pylint 2.9 crashes - I can't figure out why, but it seems like it's a bug in pylint rather than an error in our code. We should try to track it down and get it fixed, I've seen some similar bug reports on their github, but all of them should be resolved in 2.9... --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a493ebad..5c46083b 100644 --- a/setup.py +++ b/setup.py @@ -72,7 +72,7 @@ setup( "sphinxcontrib-openapi ~= 0.7", "sphinx_rtd_theme ~=0.5.2", "rope ~= 0.14.0", - "pylint ~= 2.3", + "pylint ~= 2.8.0", # 2.9.2 crashes and I've not yet figured out why. "pytest ~= 6.1.2", "mypy ~= 0.790", "types-python-dateutil", From a8b1efb7cd8163b8372b64ecb9da048945d79166 Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 25 Jul 2021 01:23:44 +0100 Subject: [PATCH 24/36] Removed openapi endpoints (now in LabThings) --- openflexure_microscope/api/app.py | 2 -- .../api/v2/views/__init__.py | 1 - openflexure_microscope/api/v2/views/docs.py | 23 ------------------- 3 files changed, 26 deletions(-) delete mode 100644 openflexure_microscope/api/v2/views/docs.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 3ad46e20..444fc7fc 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -156,8 +156,6 @@ labthing.add_view( views.NestedConfigurationProperty, "/instrument/configuration/" ) labthing.add_root_link(views.ConfigurationProperty, "instrumentConfiguration") -labthing.add_view(views.APISpecJSONView, "/docs/swagger.json") -labthing.add_view(views.APISpecYAMLView, "/docs/swagger.yaml") # Attach stage resources labthing.add_view(views.StageTypeProperty, "/instrument/stage/type") diff --git a/openflexure_microscope/api/v2/views/__init__.py b/openflexure_microscope/api/v2/views/__init__.py index 5e53611f..5771e204 100644 --- a/openflexure_microscope/api/v2/views/__init__.py +++ b/openflexure_microscope/api/v2/views/__init__.py @@ -1,7 +1,6 @@ from .actions import enabled_root_actions from .camera import * from .captures import * -from .docs import * from .instrument import * from .stage import * from .streams import * diff --git a/openflexure_microscope/api/v2/views/docs.py b/openflexure_microscope/api/v2/views/docs.py deleted file mode 100644 index 8d2ce264..00000000 --- a/openflexure_microscope/api/v2/views/docs.py +++ /dev/null @@ -1,23 +0,0 @@ -from flask import Response -from labthings import current_labthing -from labthings.views import View - - -class APISpecJSONView(View): - """OpenAPI v3 documentation - - A JSON document containing an API description in OpenAPI format - """ - - def get(self): - return current_labthing().spec.to_dict() - - -class APISpecYAMLView(View): - """OpenAPI v3 documentation - - A YAML document containing an API description in OpenAPI format - """ - - def get(self): - return Response(current_labthing().spec.to_yaml(), mimetype="text/yaml") From 9fa545f3fa956b5ba58e49af1323990c69bcbf3b Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 25 Jul 2021 03:38:20 +0100 Subject: [PATCH 25/36] Typing fix mypy doesn't like adding attributes to functions, so I have used the described_operation decorator to add type hints to the functions. --- openflexure_microscope/api/default_extensions/zip_builder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 0885fc41..077988fc 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -10,7 +10,7 @@ from labthings import fields, find_component, update_action_progress from labthings.extensions import BaseExtension from labthings.schema import Schema, pre_dump from labthings.utilities import description_from_view -from labthings.views import ActionView, PropertyView, View +from labthings.views import ActionView, PropertyView, View, described_operation from openflexure_microscope.captures import CaptureObject from openflexure_microscope.microscope import Microscope @@ -188,6 +188,7 @@ class ZipGetterAPIView(View): ] responses = {404: {"description": "The session ID could not be found"}} + @described_operation def get(self, session_id): """ Download a particular capture collection ZIP file @@ -211,6 +212,7 @@ class ZipGetterAPIView(View): } } + @described_operation def delete(self, session_id): """ Close and delete a particular capture collection ZIP file From cfba9d5c094d3fd5f33d9d32edabd1c319058487 Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 25 Jul 2021 03:41:06 +0100 Subject: [PATCH 26/36] downgrade pylint pylint 2.9 seems to die on this code, no idea why. It's a crash in pylint rather than an error in the code I think. --- Pipfile.lock | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 1975c645..d861f087 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -54,7 +54,7 @@ "sha256:02a409bd2cb193b20d9eb015ca31400bd3e1721ec5d702e1b96de54608ff5a8f", "sha256:d39c3afebb59ec865fb1247dfd951d7090c159ab0cf3e4291e0869d3be33776c" ], - "markers": "python_version >= '3.6' and python_version < '4.0'", + "markers": "python_version >= '3.6' and python_version < '4'", "version": "==0.1.4" }, "certifi": { @@ -151,7 +151,7 @@ "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==5.5" }, "expiringdict": { @@ -250,7 +250,7 @@ "sha256:4f286ea585e596aeb67a02d5bd822b2c89828693026085829e15e415a2d2d287", "sha256:970bb6a81f0e007b55e575e6eafe6bba10974d671f2767b91631f001ff53cf0e" ], - "markers": "python_version >= '3.6' and python_version < '4.0'", + "markers": "python_version >= '3.6' and python_version < '4'", "version": "==1.2.4" }, "markupsafe": { @@ -595,6 +595,7 @@ "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==5.4.1" }, "requests": { @@ -676,7 +677,7 @@ "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.26.6" }, "webargs": { @@ -748,11 +749,11 @@ }, "astroid": { "hashes": [ - "sha256:11e598e49e31f288ae43d13e8d0eb28454e5e2aa745cbc16cc799f147bb7e8dd", - "sha256:958c2aa6ba47e0b69773ce2b30cd6c2c99d15a5cc611e323d231f4c44db36d54" + "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e", + "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975" ], "markers": "python_version ~= '3.6'", - "version": "==2.6.3" + "version": "==2.5.6" }, "attrs": { "hashes": [ @@ -783,7 +784,7 @@ "sha256:02a409bd2cb193b20d9eb015ca31400bd3e1721ec5d702e1b96de54608ff5a8f", "sha256:d39c3afebb59ec865fb1247dfd951d7090c159ab0cf3e4291e0869d3be33776c" ], - "markers": "python_version >= '3.6' and python_version < '4.0'", + "markers": "python_version >= '3.6' and python_version < '4'", "version": "==0.1.4" }, "certifi": { @@ -880,7 +881,7 @@ "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==5.5" }, "docutils": { @@ -1011,7 +1012,7 @@ "sha256:4f286ea585e596aeb67a02d5bd822b2c89828693026085829e15e415a2d2d287", "sha256:970bb6a81f0e007b55e575e6eafe6bba10974d671f2767b91631f001ff53cf0e" ], - "markers": "python_version >= '3.6' and python_version < '4.0'", + "markers": "python_version >= '3.6' and python_version < '4'", "version": "==1.2.4" }, "lazy-object-proxy": { @@ -1422,11 +1423,11 @@ }, "pylint": { "hashes": [ - "sha256:23a1dc8b30459d78e9ff25942c61bb936108ccbe29dd9e71c01dc8274961709a", - "sha256:5d46330e6b8886c31b5e3aba5ff48c10f4aa5e76cbf9002c6544306221e63fbc" + "sha256:0a049c5d47b629d9070c3932d13bff482b12119b6a241a93bc460b0be16953c8", + "sha256:792b38ff30903884e4a9eab814ee3523731abd3c463f3ba48d7b627e87013484" ], "markers": "python_version ~= '3.6'", - "version": "==2.9.3" + "version": "==2.8.3" }, "pyparsing": { "hashes": [ @@ -1535,6 +1536,7 @@ "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==5.4.1" }, "requests": { @@ -1768,7 +1770,7 @@ "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.26.6" }, "webargs": { From b6228ba2d5f50c55a038c3866be7cd156126e935 Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 25 Jul 2021 03:44:26 +0100 Subject: [PATCH 27/36] Removed unnecessary node junk I'd briefly installed some node utilities in docs/ These are not needed, so I'm removing the associated .gitignore --- docs/.gitignore | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 docs/.gitignore diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 504afef8..00000000 --- a/docs/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -package-lock.json From 1d1679268188f0bd96a4072cd397a5aa8964084e Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 25 Jul 2021 03:45:16 +0100 Subject: [PATCH 28/36] Removing unnecessary validation code I wrote test_swagger_yaml before I realised I could validate the API with apispec directly. --- docs/test_swagger_yaml.py | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 docs/test_swagger_yaml.py diff --git a/docs/test_swagger_yaml.py b/docs/test_swagger_yaml.py deleted file mode 100644 index 560a76bc..00000000 --- a/docs/test_swagger_yaml.py +++ /dev/null @@ -1,16 +0,0 @@ -import yaml - -with open("./build/swagger.yaml", "r") as f: - api = yaml.load(f) - -for path, methods in api["paths"].items(): - for method, properties in methods.items(): - try: - for k in ["responses", "description"]: - current_key = k - _ = properties[k] - for code, r in properties["responses"].items(): - current_key = f"responses/{code}/description" - _ = r["description"] - except KeyError as e: - print(f"❌ {method} {path} has a problem: {e} from {current_key}") From fe3fb5c66b4ca7f6d0e2b7c4cfe8a0ff6eea1a71 Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 25 Jul 2021 22:23:54 +0100 Subject: [PATCH 29/36] Incorporate LabThings 1.3 The necessary changes to produce valid OpenAPI docs are now in the latest release of LabThings. This change updates it. Also, isort was trying to sort my virtual environment, hence the change to pyproject.toml. I don't know why that happened - maybe an update to isort? --- Pipfile.lock | 56 +++++++++++++++++++++++++++----------------------- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index d861f087..04c8a27a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -247,11 +247,11 @@ }, "labthings": { "hashes": [ - "sha256:4f286ea585e596aeb67a02d5bd822b2c89828693026085829e15e415a2d2d287", - "sha256:970bb6a81f0e007b55e575e6eafe6bba10974d671f2767b91631f001ff53cf0e" + "sha256:2a167d512fe8f01283a46753c070a75778f8d573c8291ce6398c236029b85891", + "sha256:a63f410dd39273af8b774434b20457c367548990f19340360397bba26dfd4ca7" ], "markers": "python_version >= '3.6' and python_version < '4'", - "version": "==1.2.4" + "version": "==1.3.0" }, "markupsafe": { "hashes": [ @@ -296,11 +296,12 @@ }, "marshmallow": { "hashes": [ - "sha256:77368dfedad93c3a041cbbdbce0b33fac1d8608c9e2e2288408a43ce3493d2ff", - "sha256:d4090ca9a36cd129126ad8b10c3982c47d4644a6e3ccb20534b512badce95f35" + "sha256:c67929438fd73a2be92128caa0325b1b5ed8b626d91a094d2f7f2771bf1f1c0e", + "sha256:dd4724335d3c2b870b641ffe4a2f8728a1380cd2e7e2312756715ffeaa82b842", + "sha256:f9b83845790eae045b4cfa220f9118b1b7db166d02db90e7a82d28eed401da11" ], "markers": "python_version >= '3.5'", - "version": "==3.12.2" + "version": "==3.13.0" }, "numpy": { "hashes": [ @@ -682,11 +683,11 @@ }, "webargs": { "hashes": [ - "sha256:2f3d883ce9f348fa884889440fcc1b207e7c67b04be5e90be00a5be73e2cd91d", - "sha256:ce8c565789ece1584be7edba41617319eb75de59b2987958bee3f3fdb3669e60" + "sha256:565666bafd08dd375fae995a9d4187604741efc27b8ec7c5b5bcb0ae90b2c677", + "sha256:7b06eebf23d05ebec1aa97e9c373deadaa8194866fae79c56e0453804db7a756" ], "markers": "python_version >= '3.6'", - "version": "==7.0.1" + "version": "==8.0.0" }, "werkzeug": { "hashes": [ @@ -698,10 +699,11 @@ }, "zeroconf": { "hashes": [ - "sha256:3608be2db58f6f0dc70665e02ab420fb8bf428016f2c78403d879e066ecc9bff", - "sha256:4be24a10aa9c73406f48d42a8b3b077c217b0e8d7ed1e57639630da520c25959" + "sha256:46992d0786129c769c652c875221d394a63bdbef12832d443ac9e521005d46e1", + "sha256:ac88738639669f924859a4e01d9742f3783e619e710103f4a0d321a47f260fd2", + "sha256:de14268be7a449691f74463cce5ecbeeb7e5f572878a28285b11236b0171d407" ], - "version": "==0.28.8" + "version": "==0.33.1" }, "zipp": { "hashes": [ @@ -981,7 +983,7 @@ "sha256:eed17b53c3e7912425579853d078a0832820f023191561fcee9d7cae424e0813", "sha256:f65ce5bd4cbc6abdfbe29afc2f0245538ab358c14590912df638033f157d555e" ], - "markers": "python_version < '4' and python_full_version >= '3.6.1'", + "markers": "python_version < '4.0' and python_full_version >= '3.6.1'", "version": "==5.9.2" }, "itsdangerous": { @@ -1009,11 +1011,11 @@ }, "labthings": { "hashes": [ - "sha256:4f286ea585e596aeb67a02d5bd822b2c89828693026085829e15e415a2d2d287", - "sha256:970bb6a81f0e007b55e575e6eafe6bba10974d671f2767b91631f001ff53cf0e" + "sha256:2a167d512fe8f01283a46753c070a75778f8d573c8291ce6398c236029b85891", + "sha256:a63f410dd39273af8b774434b20457c367548990f19340360397bba26dfd4ca7" ], "markers": "python_version >= '3.6' and python_version < '4'", - "version": "==1.2.4" + "version": "==1.3.0" }, "lazy-object-proxy": { "hashes": [ @@ -1148,11 +1150,12 @@ }, "marshmallow": { "hashes": [ - "sha256:77368dfedad93c3a041cbbdbce0b33fac1d8608c9e2e2288408a43ce3493d2ff", - "sha256:d4090ca9a36cd129126ad8b10c3982c47d4644a6e3ccb20534b512badce95f35" + "sha256:c67929438fd73a2be92128caa0325b1b5ed8b626d91a094d2f7f2771bf1f1c0e", + "sha256:dd4724335d3c2b870b641ffe4a2f8728a1380cd2e7e2312756715ffeaa82b842", + "sha256:f9b83845790eae045b4cfa220f9118b1b7db166d02db90e7a82d28eed401da11" ], "markers": "python_version >= '3.5'", - "version": "==3.12.2" + "version": "==3.13.0" }, "mccabe": { "hashes": [ @@ -1372,7 +1375,7 @@ "sha256:6fb3021603d4421c6fcc40072bbcf150a6c52ef70ff4d3be089b8b04e015ef5a", "sha256:70b97cb194b978dc464c70793e85e6f746cddf82b84a38bfb135946ad71ae19c" ], - "markers": "python_version >= '3.6' and python_version < '4'", + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==0.10.0" }, "prance": { @@ -1775,11 +1778,11 @@ }, "webargs": { "hashes": [ - "sha256:2f3d883ce9f348fa884889440fcc1b207e7c67b04be5e90be00a5be73e2cd91d", - "sha256:ce8c565789ece1584be7edba41617319eb75de59b2987958bee3f3fdb3669e60" + "sha256:565666bafd08dd375fae995a9d4187604741efc27b8ec7c5b5bcb0ae90b2c677", + "sha256:7b06eebf23d05ebec1aa97e9c373deadaa8194866fae79c56e0453804db7a756" ], "markers": "python_version >= '3.6'", - "version": "==7.0.1" + "version": "==8.0.0" }, "werkzeug": { "hashes": [ @@ -1801,10 +1804,11 @@ }, "zeroconf": { "hashes": [ - "sha256:3608be2db58f6f0dc70665e02ab420fb8bf428016f2c78403d879e066ecc9bff", - "sha256:4be24a10aa9c73406f48d42a8b3b077c217b0e8d7ed1e57639630da520c25959" + "sha256:46992d0786129c769c652c875221d394a63bdbef12832d443ac9e521005d46e1", + "sha256:ac88738639669f924859a4e01d9742f3783e619e710103f4a0d321a47f260fd2", + "sha256:de14268be7a449691f74463cce5ecbeeb7e5f572878a28285b11236b0171d407" ], - "version": "==0.28.8" + "version": "==0.33.1" }, "zipp": { "hashes": [ diff --git a/pyproject.toml b/pyproject.toml index 1465fb29..27543614 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ location = ".venv" [tool.poe.tasks] black = "black ." black_check = "black --check ." -isort = "isort ." +isort = "isort openflexure_microscope" pylint = "pylint openflexure_microscope" mypy = "mypy --cobertura-xml-report openflexure_microscope openflexure_microscope" test = "pytest ." diff --git a/setup.py b/setup.py index 5c46083b..036a7b97 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,7 @@ setup( "pyyaml ~= 5.4.0", "pytest-cov ~= 2.10.1", "piexif ~= 1.1.3", - "labthings ~= 1.2.2", + "labthings ~= 1.3.0", "typing-extensions ~= 3.7.4", # Needed for some type-hints in Python < 3.8 (e.g. Literal) "RPi.GPIO ~= 0.7.0; platform_machine == 'armv7l'", ], From 80cebe3ebcac08e70eceede4f2268c2a3e83ec8c Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 25 Jul 2021 22:26:52 +0100 Subject: [PATCH 30/36] Add test for valid OpenAPI --- tests/test_app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_app.py b/tests/test_app.py index c1ef2d0e..ad47e326 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,3 +1,4 @@ +from apispec.utils import validate_spec from openflexure_microscope.api.app import api_microscope, app, labthing from openflexure_microscope.camera.base import BaseCamera from openflexure_microscope.microscope import Microscope @@ -12,3 +13,6 @@ def test_microscope_creation(): assert isinstance(api_microscope, Microscope) assert isinstance(api_microscope.camera, BaseCamera) assert isinstance(api_microscope.stage, BaseStage) + +def test_openapi_valid(): + assert validate_spec(labthing.spec) \ No newline at end of file From 89f350cf7ef8d8c2dbe927e53269f45a68a9906e Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 25 Jul 2021 22:56:47 +0100 Subject: [PATCH 31/36] Create docs/build directory in CI --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1f2e1ce0..0d84b9a1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -110,6 +110,7 @@ openapi: <<: *python-install script: + - mkdir -p docs/build/ - ofm-generate-openapi -o docs/build/swagger.yaml --validate artifacts: name: "openapi" From 5e36837e5f62e1467a8ea84cc2fce42c1e9e6e35 Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 25 Jul 2021 22:59:59 +0100 Subject: [PATCH 32/36] Blackened tests --- tests/test_app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_app.py b/tests/test_app.py index ad47e326..18b8ef45 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -14,5 +14,6 @@ def test_microscope_creation(): assert isinstance(api_microscope.camera, BaseCamera) assert isinstance(api_microscope.stage, BaseStage) + def test_openapi_valid(): - assert validate_spec(labthing.spec) \ No newline at end of file + assert validate_spec(labthing.spec) From c831937b053f4db35248c1ea07cb4ed58cb60a08 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 26 Jul 2021 02:48:45 +0100 Subject: [PATCH 33/36] Validate thing description in tests --- tests/test_app.py | 26 + tests/w3c_td_schema.json | 1035 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 1061 insertions(+) create mode 100644 tests/w3c_td_schema.json diff --git a/tests/test_app.py b/tests/test_app.py index 18b8ef45..c3f5b2e8 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,8 +1,13 @@ from apispec.utils import validate_spec +import json +import jsonschema +import os + from openflexure_microscope.api.app import api_microscope, app, labthing from openflexure_microscope.camera.base import BaseCamera from openflexure_microscope.microscope import Microscope from openflexure_microscope.stage.base import BaseStage +from labthings.json import encode_json def test_app_creation(): @@ -17,3 +22,24 @@ def test_microscope_creation(): def test_openapi_valid(): assert validate_spec(labthing.spec) + +def test_thing_description_valid(): + # TODO: it would be nice to put this into LabThings + # First load the schema from file and check it validates + schema_fname = os.path.join( + os.path.dirname(__file__), + "w3c_td_schema.json" + ) + schema = json.load(open(schema_fname, "r")) + jsonschema.Draft7Validator.check_schema(schema) + + # Build a TD dictionary + with labthing.app.test_request_context(): + td_dict = labthing.thing_description.to_dict() + + # Allow our LabThingsJSONEncoder to encode the RD + td_json = encode_json(td_dict) + # Decode the JSON back into a primitive dictionary + td_json_dict = json.loads(td_json) + # Validate + jsonschema.validate(instance=td_json_dict, schema=schema) \ No newline at end of file diff --git a/tests/w3c_td_schema.json b/tests/w3c_td_schema.json new file mode 100644 index 00000000..62194083 --- /dev/null +++ b/tests/w3c_td_schema.json @@ -0,0 +1,1035 @@ +{ + "title": "WoT TD Schema - 16 October 2019", + "description": "JSON Schema for validating TD instances against the TD model. TD instances can be with or without terms that have default values", + "$schema ": "http://json-schema.org/draft-07/schema#", + "definitions": { + "anyUri": { + "type": "string", + "format": "iri-reference" + }, + "description": { + "type": "string" + }, + "descriptions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "titles": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "security": { + "oneOf": [{ + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "scopes": { + "oneOf": [{ + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "subprotocol": { + "type": "string", + "enum": [ + "longpoll", + "websub", + "sse" + ] + }, + "thing-context-w3c-uri": { + "type": "string", + "enum": [ + "https://www.w3.org/2019/wot/td/v1" + ] + }, + "thing-context": { + "oneOf": [{ + "type": "array", + "items": [{ + "$ref": "#/definitions/thing-context-w3c-uri" + }], + "additionalItems": { + "anyOf": [{ + "$ref": "#/definitions/anyUri" + }, + { + "type": "object" + } + ] + } + }, + { + "$ref": "#/definitions/thing-context-w3c-uri" + } + ] + }, + "type_declaration": { + "oneOf": [{ + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "dataSchema": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "title": { + "$ref": "#/definitions/title" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "writeOnly": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "format": { + "type": "string" + }, + "const": {}, + "type": { + "type": "string", + "enum": [ + "boolean", + "integer", + "number", + "string", + "object", + "array", + "null" + ] + }, + "items": { + "oneOf": [{ + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "form_element_property": { + "type": "object", + "properties": { + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + } + } + ] + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "contentType": { + "type": "string" + }, + "contentCoding": { + "type": "string" + }, + "subprotocol": { + "$ref": "#/definitions/subprotocol" + }, + "security": { + "$ref": "#/definitions/security" + }, + "scopes": { + "$ref": "#/definitions/scopes" + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "form_element_action": { + "type": "object", + "properties": { + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "invokeaction" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "invokeaction" + ] + } + } + ] + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "contentType": { + "type": "string" + }, + "contentCoding": { + "type": "string" + }, + "subprotocol": { + "$ref": "#/definitions/subprotocol" + }, + "security": { + "$ref": "#/definitions/security" + }, + "scopes": { + "$ref": "#/definitions/scopes" + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "form_element_event": { + "type": "object", + "properties": { + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + } + } + ] + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "contentType": { + "type": "string" + }, + "contentCoding": { + "type": "string" + }, + "subprotocol": { + "$ref": "#/definitions/subprotocol" + }, + "security": { + "$ref": "#/definitions/security" + }, + "scopes": { + "$ref": "#/definitions/scopes" + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "form_element_root": { + "type": "object", + "properties": { + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties" + ] + } + } + ] + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "contentType": { + "type": "string" + }, + "contentCoding": { + "type": "string" + }, + "subprotocol": { + "$ref": "#/definitions/subprotocol" + }, + "security": { + "$ref": "#/definitions/security" + }, + "scopes": { + "$ref": "#/definitions/scopes" + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "property_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_property" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "observable": { + "type": "boolean" + }, + "writeOnly": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "format": { + "type": "string" + }, + "const": {}, + "type": { + "type": "string", + "enum": [ + "boolean", + "integer", + "number", + "string", + "object", + "array", + "null" + ] + }, + "items": { + "oneOf": [{ + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "action_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_action" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "input": { + "$ref": "#/definitions/dataSchema" + }, + "output": { + "$ref": "#/definitions/dataSchema" + }, + "safe": { + "type": "boolean" + }, + "idempotent": { + "type": "boolean" + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "event_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_event" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "subscription": { + "$ref": "#/definitions/dataSchema" + }, + "data": { + "$ref": "#/definitions/dataSchema" + }, + "cancellation": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "link_element": { + "type": "object", + "properties": { + "href": { + "$ref": "#/definitions/anyUri" + }, + "type": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "anchor": { + "$ref": "#/definitions/anyUri" + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "securityScheme": { + "oneOf": [{ + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "nosec" + ] + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "basic" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "digest" + ] + }, + "qop": { + "type": "string", + "enum": [ + "auth", + "auth-int" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "apikey" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "bearer" + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "alg": { + "type": "string" + }, + "format": { + "type": "string" + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "psk" + ] + }, + "identity": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "token": { + "$ref": "#/definitions/anyUri" + }, + "refresh": { + "$ref": "#/definitions/anyUri" + }, + "scopes": { + "oneOf": [{ + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "flow": { + "type": "string", + "enum": [ + "code" + ] + } + }, + "required": [ + "scheme" + ] + } + ] + } + }, + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uri" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/property_element" + } + }, + "actions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/action_element" + } + }, + "events": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/event_element" + } + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "version": { + "type": "object", + "properties": { + "instance": { + "type": "string" + } + }, + "required": [ + "instance" + ] + }, + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/link_element" + } + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_root" + } + }, + "base": { + "$ref": "#/definitions/anyUri" + }, + "securityDefinitions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/securityScheme" + } + }, + "support": { + "$ref": "#/definitions/anyUri" + }, + "created": { + "type": "string", + "format": "date-time" + }, + "modified": { + "type": "string", + "format": "date-time" + }, + "security": { + "oneOf": [{ + "type": "string" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "@context": { + "$ref": "#/definitions/thing-context" + } + }, + "required": [ + "title", + "security", + "securityDefinitions", + "@context" + ], + "additionalProperties": true +} \ No newline at end of file From d7d7a8cc8d18549fc1dd874a734b77b85f0ac7ef Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 9 Aug 2021 22:01:14 +0100 Subject: [PATCH 34/36] Blackened test_app.py --- tests/test_app.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index c3f5b2e8..0e16c58b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -23,23 +23,21 @@ def test_microscope_creation(): def test_openapi_valid(): assert validate_spec(labthing.spec) + def test_thing_description_valid(): # TODO: it would be nice to put this into LabThings # First load the schema from file and check it validates - schema_fname = os.path.join( - os.path.dirname(__file__), - "w3c_td_schema.json" - ) + schema_fname = os.path.join(os.path.dirname(__file__), "w3c_td_schema.json") schema = json.load(open(schema_fname, "r")) jsonschema.Draft7Validator.check_schema(schema) # Build a TD dictionary with labthing.app.test_request_context(): - td_dict = labthing.thing_description.to_dict() + td_dict = labthing.thing_description.to_dict() # Allow our LabThingsJSONEncoder to encode the RD td_json = encode_json(td_dict) # Decode the JSON back into a primitive dictionary td_json_dict = json.loads(td_json) # Validate - jsonschema.validate(instance=td_json_dict, schema=schema) \ No newline at end of file + jsonschema.validate(instance=td_json_dict, schema=schema) From 040d7d4ba6852ec888a3c04938d41af3812f5749 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 17 Aug 2021 12:11:20 +0100 Subject: [PATCH 35/36] Fix action wrapper The action wrapper was using an unbound function - it now passes the extension object as self. We were incorrectly passing endpoint names as positional arguments - this is fixed and I now use a keyword argument. --- openflexure_microscope/api/default_extensions/autofocus.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index 241ea9cd..5f736121 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -216,7 +216,7 @@ def extension_action(args=None): def post(self, arguments): # Run the action - return func(**arguments) + return func(self.extension, **arguments) def get(self, *args, **kwargs): # pylint: disable=useless-super-delegation # Explicitly wrap the `get` method to allow us to add a docstring @@ -294,7 +294,7 @@ class AutofocusExtension(BaseExtension): obj = getattr(self, k) if hasattr(obj, "flask_view"): name = obj.__name__ - self.add_view(obj.flask_view, f"/{name}", name) + self.add_view(obj.flask_view, f"/{name}", endpoint=name) def measure_sharpness( self, From 7860b6eda7b28528dddde12ed5c18fb5b21b6c32 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 17 Aug 2021 12:18:10 +0100 Subject: [PATCH 36/36] deleted some commented-out extensions --- docs/source/conf.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 23203631..e3550325 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -68,9 +68,6 @@ extensions = [ "sphinx.ext.viewcode", "sphinx.ext.githubpages", "sphinx.ext.ifconfig", - # "sphinxcontrib.httpdomain", - # "sphinxcontrib.autohttp.flask", - # "sphinxcontrib.autohttp.flaskqref", "sphinxcontrib.openapi", "sphinxcontrib.redoc", ]