├── tests
├── __init__.py
├── static
│ └── text
├── extensions
│ ├── extension_exception.py
│ ├── extension.py
│ ├── extension_package
│ │ └── __init__.py
│ └── extension_explicit_list.py
├── test_quick.py
├── test_marshalling.py
├── test_fields.py
├── test_representations.py
├── test_labthing_exceptions.py
├── test_views_op.py
├── test_json_schemas.py
├── test_tasks_pool.py
├── test_find.py
├── test_schema.py
├── test_action_api.py
├── test_default_views.py
├── test_marshalling_args.py
├── test_sync_event.py
├── test_sync_lock.py
├── test_extensions.py
├── test_openapi.py
├── test_views.py
├── test_tasks_thread.py
├── test_labthing.py
└── test_utilities.py
├── src
└── labthings
│ ├── py.typed
│ ├── default_views
│ ├── __init__.py
│ ├── docs
│ │ ├── static
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ ├── index.html
│ │ │ └── oauth2-redirect.html
│ │ ├── templates
│ │ │ └── swagger-ui.html
│ │ └── __init__.py
│ ├── events.py
│ ├── extensions.py
│ ├── root.py
│ └── actions.py
│ ├── example_components
│ ├── __init__.py
│ └── spectrometer.py
│ ├── json
│ ├── marshmallow_jsonschema
│ │ ├── exceptions.py
│ │ ├── __init__.py
│ │ ├── README
│ │ ├── LICENSE
│ │ └── validation.py
│ ├── __init__.py
│ ├── encoder.py
│ └── schemas.py
│ ├── marshalling
│ ├── __init__.py
│ ├── args.py
│ └── marshalling.py
│ ├── apispec
│ ├── __init__.py
│ └── utilities.py
│ ├── responses.py
│ ├── sync
│ ├── __init__.py
│ ├── event.py
│ └── lock.py
│ ├── monkey.py
│ ├── names.py
│ ├── actions
│ ├── __init__.py
│ └── pool.py
│ ├── logging.py
│ ├── views
│ ├── op.py
│ └── builder.py
│ ├── deque.py
│ ├── representations.py
│ ├── __init__.py
│ ├── httperrorhandler.py
│ ├── fields.py
│ ├── quick.py
│ ├── find.py
│ ├── wsgi.py
│ └── schema.py
├── examples
├── static
│ ├── index.html
│ ├── subfolder
│ │ └── index.html
│ └── example.json
├── simple_thing.py
├── nested_thing.py
└── simple_extensions.py
├── mypy.ini
├── .flake8
├── docs
├── requirements.txt
├── advanced_usage
│ ├── components.rst
│ ├── encoders.rst
│ ├── extensions.rst
│ └── index.rst
├── api.rst
├── basic_usage
│ ├── http_api_structure.rst
│ ├── index.rst
│ ├── synchronisation.rst
│ ├── app_thing_server.rst
│ ├── action_threads.rst
│ └── serialising.rst
├── index.rst
├── Makefile
├── make.bat
├── conf.py
├── plan.md
└── core_concepts.rst
├── pytest.ini
├── readthedocs.yaml
├── codecov.yml
├── .github
├── dependabot.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── test.yml
│ └── publish.yml
├── .coveragerc
├── pyproject.toml
├── .gitignore
├── changelog.config.js
├── CODE_OF_CONDUCT.md
└── README.md
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/labthings/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/static/text:
--------------------------------------------------------------------------------
1 | text
--------------------------------------------------------------------------------
/src/labthings/default_views/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/static/index.html:
--------------------------------------------------------------------------------
1 |
Hello world
--------------------------------------------------------------------------------
/mypy.ini:
--------------------------------------------------------------------------------
1 | [mypy]
2 | ignore_missing_imports = True
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | ignore=E501
3 | exclude = tests/*
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | sphinx-autoapi
2 | sphinx-rtd-theme
--------------------------------------------------------------------------------
/examples/static/subfolder/index.html:
--------------------------------------------------------------------------------
1 | Hello subfolder
--------------------------------------------------------------------------------
/tests/extensions/extension_exception.py:
--------------------------------------------------------------------------------
1 | raise Exception
2 |
--------------------------------------------------------------------------------
/docs/advanced_usage/components.rst:
--------------------------------------------------------------------------------
1 | Components
2 | ==========
3 |
4 | *Documentation to be written*
--------------------------------------------------------------------------------
/docs/api.rst:
--------------------------------------------------------------------------------
1 | API Reference
2 | =============
3 |
4 | .. automodule:: labthings
5 | :members:
--------------------------------------------------------------------------------
/examples/static/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "app": "python-labthings",
3 | "name": "squidward"
4 | }
--------------------------------------------------------------------------------
/src/labthings/example_components/__init__.py:
--------------------------------------------------------------------------------
1 | from .spectrometer import PretendSpectrometer
2 |
--------------------------------------------------------------------------------
/docs/advanced_usage/encoders.rst:
--------------------------------------------------------------------------------
1 | Data encoders
2 | =============
3 |
4 | *Documentation to be written*
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | addopts = --cov-report term-missing --cov=labthings --cov-report html --cov-report xml
--------------------------------------------------------------------------------
/docs/advanced_usage/extensions.rst:
--------------------------------------------------------------------------------
1 | LabThing Extensions
2 | ===================
3 |
4 | *Documentation to be written*
--------------------------------------------------------------------------------
/src/labthings/json/marshmallow_jsonschema/exceptions.py:
--------------------------------------------------------------------------------
1 | class UnsupportedValueError(Exception):
2 | """ """
3 |
--------------------------------------------------------------------------------
/docs/basic_usage/http_api_structure.rst:
--------------------------------------------------------------------------------
1 | HTTP API Structure
2 | ==================
3 |
4 | *Documentation to be written*
--------------------------------------------------------------------------------
/src/labthings/json/__init__.py:
--------------------------------------------------------------------------------
1 | from .encoder import LabThingsJSONEncoder, encode_json
2 |
3 | __all__ = ["LabThingsJSONEncoder", "encode_json"]
4 |
--------------------------------------------------------------------------------
/tests/extensions/extension.py:
--------------------------------------------------------------------------------
1 | from labthings.extensions import BaseExtension
2 |
3 | test_extension = BaseExtension("org.labthings.tests.extension")
4 |
--------------------------------------------------------------------------------
/src/labthings/marshalling/__init__.py:
--------------------------------------------------------------------------------
1 | from .args import use_args
2 | from .marshalling import marshal_with
3 |
4 | __all__ = ["use_args", "marshal_with"]
5 |
--------------------------------------------------------------------------------
/src/labthings/apispec/__init__.py:
--------------------------------------------------------------------------------
1 | from .plugins import FlaskLabThingsPlugin, MarshmallowPlugin
2 |
3 | __all__ = ["MarshmallowPlugin", "FlaskLabThingsPlugin"]
4 |
--------------------------------------------------------------------------------
/src/labthings/responses.py:
--------------------------------------------------------------------------------
1 | from flask import Response, abort, make_response, send_file
2 |
3 | __all__ = ["Response", "send_file", "abort", "make_response"]
4 |
--------------------------------------------------------------------------------
/src/labthings/default_views/docs/static/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/labthings/python-labthings/HEAD/src/labthings/default_views/docs/static/favicon-16x16.png
--------------------------------------------------------------------------------
/src/labthings/default_views/docs/static/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/labthings/python-labthings/HEAD/src/labthings/default_views/docs/static/favicon-32x32.png
--------------------------------------------------------------------------------
/tests/extensions/extension_package/__init__.py:
--------------------------------------------------------------------------------
1 | from labthings.extensions import BaseExtension
2 |
3 | test_extension = BaseExtension("org.labthings.tests.extension_package")
4 |
--------------------------------------------------------------------------------
/src/labthings/sync/__init__.py:
--------------------------------------------------------------------------------
1 | from .event import ClientEvent
2 | from .lock import CompositeLock, StrictLock
3 |
4 | __all__ = ["StrictLock", "CompositeLock", "ClientEvent"]
5 |
--------------------------------------------------------------------------------
/src/labthings/json/marshmallow_jsonschema/__init__.py:
--------------------------------------------------------------------------------
1 | __license__ = "MIT"
2 |
3 | from .base import JSONSchema
4 | from .exceptions import UnsupportedValueError
5 |
6 | __all__ = ("JSONSchema", "UnsupportedValueError")
7 |
--------------------------------------------------------------------------------
/docs/advanced_usage/index.rst:
--------------------------------------------------------------------------------
1 | Advanced usage
2 | ==============
3 |
4 | .. toctree::
5 | :maxdepth: 2
6 | :caption: Contents:
7 |
8 | view_class.rst
9 | components.rst
10 | encoders.rst
11 | extensions.rst
--------------------------------------------------------------------------------
/readthedocs.yaml:
--------------------------------------------------------------------------------
1 | build:
2 | image: latest
3 |
4 | sphinx:
5 | configuration: docs/conf.py
6 |
7 | python:
8 | version: 3.7
9 | pip_install: true
10 | install:
11 | - requirements: docs/requirements.txt
12 |
--------------------------------------------------------------------------------
/src/labthings/default_views/events.py:
--------------------------------------------------------------------------------
1 | from ..schema import LogRecordSchema
2 | from ..views import EventView
3 |
4 |
5 | class LoggingEventView(EventView):
6 | """List of latest logging events from the session"""
7 |
8 | schema = LogRecordSchema()
9 |
--------------------------------------------------------------------------------
/tests/extensions/extension_explicit_list.py:
--------------------------------------------------------------------------------
1 | from labthings.extensions import BaseExtension
2 |
3 | test_extension = BaseExtension("org.labthings.tests.extension")
4 | test_extension_excluded = BaseExtension("org.labthings.tests.extension_excluded")
5 |
6 | __extensions__ = ["test_extension"]
7 |
--------------------------------------------------------------------------------
/docs/basic_usage/index.rst:
--------------------------------------------------------------------------------
1 | Basic usage
2 | ===========
3 |
4 | .. toctree::
5 | :maxdepth: 2
6 | :caption: Contents:
7 |
8 | app_thing_server.rst
9 | http_api_structure.rst
10 | ws_api_structure.rst
11 | serialising.rst
12 | action_threads.rst
13 | synchronisation.rst
14 |
--------------------------------------------------------------------------------
/src/labthings/monkey.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 |
4 | def patch_all(*_):
5 | """
6 |
7 | :param *args:
8 | :param **kwargs:
9 |
10 | """
11 | logging.warning(
12 | "monkey.patch_all is deprecated and will be removed in a future version"
13 | )
14 | return
15 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | status:
3 | project:
4 | default:
5 | # basic
6 | target: auto
7 | threshold: 5%
8 | base: auto
9 | patch:
10 | default:
11 | # basic
12 | target: 80%
13 | threshold: 5%
14 | base: auto
15 |
--------------------------------------------------------------------------------
/src/labthings/names.py:
--------------------------------------------------------------------------------
1 | TASK_ENDPOINT = "labthing_task"
2 | TASK_LIST_ENDPOINT = "labthing_task_list"
3 | ACTION_ENDPOINT = "labthing_action"
4 | ACTION_LIST_ENDPOINT = "labthing_task_action"
5 | EXTENSION_LIST_ENDPOINT = "labthing_extension_list"
6 | EXTENSION_NAME = "flask-labthings"
7 | LOG_EVENT_ENDPOINT = "logging"
8 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | Welcome to Python-LabThings' documentation!
2 | ===========================================
3 |
4 | .. toctree::
5 | :maxdepth: 2
6 | :caption: Contents:
7 |
8 | core_concepts.rst
9 | quickstart.rst
10 |
11 | basic_usage/index.rst
12 | advanced_usage/index.rst
13 |
14 | api.rst
15 |
16 |
17 | Installation
18 | ------------
19 |
20 | ``pip install labthings``
--------------------------------------------------------------------------------
/src/labthings/actions/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "current_action",
3 | "update_action_progress",
4 | "update_action_data",
5 | "ActionKilledException",
6 | ]
7 |
8 | from .pool import Pool, current_action, update_action_data, update_action_progress
9 | from .thread import ActionKilledException, ActionThread
10 |
11 | __all__ = [
12 | "Pool",
13 | "current_action",
14 | "update_action_progress",
15 | "update_action_data",
16 | "ActionThread",
17 | "ActionKilledException",
18 | ]
19 |
--------------------------------------------------------------------------------
/src/labthings/logging.py:
--------------------------------------------------------------------------------
1 | from logging import StreamHandler
2 |
3 |
4 | class LabThingLogger(StreamHandler):
5 | """ """
6 |
7 | def __init__(self, labthing, *args, ignore_werkzeug=True, **kwargs):
8 | StreamHandler.__init__(self, *args, **kwargs)
9 | self.labthing = labthing
10 | self.ignore_werkzeug = ignore_werkzeug
11 |
12 | def emit(self, record):
13 | if self.ignore_werkzeug and record.name == "werkzeug":
14 | return
15 | else:
16 | self.labthing.emit("logging", record)
17 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: pip
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | open-pull-requests-limit: 10
8 | ignore:
9 | - dependency-name: pylint
10 | versions:
11 | - 2.6.2
12 | - 2.7.0
13 | - 2.7.1
14 | - 2.7.3
15 | - 2.7.4
16 | - 2.8.1
17 | - dependency-name: sphinx-autoapi
18 | versions:
19 | - 1.8.0
20 | - dependency-name: marshmallow
21 | versions:
22 | - 3.11.0
23 | - dependency-name: sphinx
24 | versions:
25 | - 3.5.0
26 | - 3.5.1
27 | - 3.5.2
28 |
--------------------------------------------------------------------------------
/tests/test_quick.py:
--------------------------------------------------------------------------------
1 | from flask import Flask
2 |
3 | from labthings import quick
4 | from labthings.labthing import LabThing
5 |
6 |
7 | def test_create_app():
8 | app, labthing = quick.create_app(__name__)
9 | assert isinstance(app, Flask)
10 | assert isinstance(labthing, LabThing)
11 |
12 |
13 | def test_create_app_options():
14 | app, labthing = quick.create_app(
15 | __name__,
16 | types=["org.labthings.tests.labthing"],
17 | flask_kwargs={"static_url_path": "/static"},
18 | handle_cors=False,
19 | )
20 | assert isinstance(app, Flask)
21 | assert isinstance(labthing, LabThing)
22 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behaviour.
15 |
16 | **Expected behaviour**
17 | A clear and concise description of what you expected to happen.
18 |
19 | **Screenshots**
20 | If applicable, add screenshots to help explain your problem.
21 |
22 | **System:**
23 | - OS: [e.g. Ubuntu 19.10]
24 | - Python version [e.g. 3.8]
25 | - Version [e.g. 0.1.0]
26 |
27 | **Additional context**
28 | Add any other context about the problem here.
29 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = .
9 | BUILDDIR = _build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/src/labthings/views/op.py:
--------------------------------------------------------------------------------
1 | import copy
2 |
3 |
4 | class _opannotation:
5 | def __init__(self, fn):
6 | self.fn = fn
7 |
8 | def __set_name__(self, owner, name):
9 | if hasattr(owner, "_opmap"):
10 | owner._opmap = copy.copy(owner._opmap)
11 | owner._opmap.update({self.__class__.__name__: name})
12 |
13 | # then replace ourself with the original method
14 | setattr(owner, name, self.fn)
15 |
16 |
17 | class readproperty(_opannotation):
18 | pass
19 |
20 |
21 | class observeproperty(_opannotation):
22 | pass
23 |
24 |
25 | class unobserveproperty(_opannotation):
26 | pass
27 |
28 |
29 | class writeproperty(_opannotation):
30 | pass
31 |
32 |
33 | class invokeaction(_opannotation):
34 | pass
35 |
--------------------------------------------------------------------------------
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = True
3 | source = ./src/labthings
4 | omit = .venv/*, ./src/labthings/wsgi.py, ./src/labthings/monkey.py, ./src/labthings/responses.py, ./src/labthings/server/*, ./src/labthings/core/*, ./src/labthings/example_components/*
5 | concurrency = thread
6 |
7 | [report]
8 | # Regexes for lines to exclude from consideration
9 | exclude_lines =
10 | # Have to re-enable the standard pragma
11 | pragma: no cover
12 |
13 | # Don't complain about missing debug-only code:
14 | def __repr__
15 | if self\.debug
16 |
17 | # Don't complain if tests don't hit defensive assertion code:
18 | raise AssertionError
19 | raise NotImplementedError
20 |
21 | # Don't complain if non-runnable code isn't run:
22 | if 0:
23 | if __name__ == .__main__.:
24 |
25 | ignore_errors = True
26 |
27 | [html]
28 | directory = coverage_html_report
--------------------------------------------------------------------------------
/src/labthings/deque.py:
--------------------------------------------------------------------------------
1 | from collections import deque as _deque
2 | from threading import Lock
3 |
4 |
5 | class Deque(_deque):
6 | """ """
7 |
8 | def __init__(self, iterable=None, maxlen=100):
9 | _deque.__init__(self, iterable or [], maxlen)
10 |
11 |
12 | class LockableDeque(Deque):
13 | def __init__(self, iterable=None, maxlen=100, timeout=-1):
14 | Deque.__init__(self, iterable, maxlen)
15 | self.lock = Lock()
16 | self.timeout = timeout
17 |
18 | def __enter__(self):
19 | self.lock.acquire(blocking=True, timeout=self.timeout)
20 | return self
21 |
22 | def __exit__(self, *args):
23 | self.lock.release()
24 |
25 |
26 | def resize_deque(iterable: _deque, newsize: int):
27 | """
28 |
29 | :param iterable: _deque:
30 | :param newsize: int:
31 |
32 | """
33 | return Deque(iterable, newsize)
34 |
--------------------------------------------------------------------------------
/tests/test_marshalling.py:
--------------------------------------------------------------------------------
1 | from labthings import fields
2 | from labthings.marshalling import marshalling as ms
3 | from labthings.schema import Schema
4 |
5 |
6 | def test_schema_to_converter_schema():
7 | class TestSchema(Schema):
8 | foo = fields.String()
9 |
10 | test_schema = TestSchema()
11 | converter = ms.schema_to_converter(test_schema)
12 | assert converter == test_schema.dump
13 | assert converter({"foo": 5}) == {"foo": "5"}
14 |
15 |
16 | def test_schema_to_converter_map():
17 | test_schema = {"foo": fields.String()}
18 | converter = ms.schema_to_converter(test_schema)
19 | assert converter({"foo": 5}) == {"foo": "5"}
20 |
21 |
22 | def test_schema_to_converter_field():
23 | field = fields.String()
24 | converter = ms.schema_to_converter(field)
25 | assert converter(5) == "5"
26 |
27 |
28 | def test_schema_to_converter_none():
29 | assert ms.schema_to_converter(object()) is None
30 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=.
11 | set BUILDDIR=_build
12 |
13 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.http://sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | project = "Python-LabThings"
2 | copyright = "2020, Joel Collins"
3 | author = "Joel Collins"
4 |
5 |
6 | extensions = [
7 | "sphinx.ext.intersphinx",
8 | "sphinx.ext.autodoc",
9 | "autoapi.extension",
10 | "sphinx_rtd_theme",
11 | ]
12 |
13 | templates_path = ["_templates"]
14 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
15 |
16 | master_doc = "index"
17 |
18 | html_theme = "sphinx_rtd_theme"
19 | html_static_path = ["_static"]
20 |
21 | autoapi_dirs = ["../src/labthings"]
22 | autoapi_ignore = [
23 | "*/server/*",
24 | "*/core/*",
25 | ]
26 |
27 | autoapi_generate_api_docs = False
28 |
29 | intersphinx_mapping = {
30 | "marshmallow": ("https://marshmallow.readthedocs.io/en/stable/", None),
31 | "webargs": ("https://webargs.readthedocs.io/en/latest/", None),
32 | "apispec": ("https://apispec.readthedocs.io/en/latest/", None),
33 | "flask": ("https://flask.palletsprojects.com/en/1.1.x/", None),
34 | }
35 |
--------------------------------------------------------------------------------
/docs/basic_usage/synchronisation.rst:
--------------------------------------------------------------------------------
1 | Synchronisation objects
2 | =======================
3 |
4 |
5 | Locks
6 | -----
7 |
8 | Locks have been implemented to solve a distinct issue, most obvious when considering action tasks. During a long task, it may be necesarry to block any completing interaction with the LabThing hardware.
9 |
10 | The :py:class:`labthings.StrictLock` class is a form of re-entrant lock. Once acquired by a thread, that thread can re-acquire the same lock. This means that other requests or actions will block, or timeout, but the action which acquired the lock is able to re-acquire it.
11 |
12 | .. autoclass:: labthings.StrictLock
13 | :members:
14 | :noindex:
15 |
16 | A CompositeLock allows grouping multiple locks to be simultaneously acquired and released.
17 |
18 | .. autoclass:: labthings.CompositeLock
19 | :members:
20 | :noindex:
21 |
22 |
23 | Per-Client events
24 | -----------------
25 |
26 | .. autoclass:: labthings.ClientEvent
27 | :members:
28 | :noindex:
--------------------------------------------------------------------------------
/src/labthings/default_views/extensions.py:
--------------------------------------------------------------------------------
1 | """Top-level representation of attached and enabled Extensions"""
2 | from ..find import registered_extensions
3 | from ..schema import ExtensionSchema
4 | from ..views import View, described_operation
5 |
6 |
7 | class ExtensionList(View):
8 | """List and basic documentation for all enabled Extensions"""
9 |
10 | tags = ["extensions"]
11 |
12 | @described_operation
13 | def get(self):
14 | """List enabled extensions.
15 |
16 | Returns a list of Extension representations, including basic documentation.
17 | Describes server methods, web views, and other relevant Lab Things metadata.
18 | """
19 | return ExtensionSchema(many=True).dump(registered_extensions().values() or [])
20 |
21 | get.responses = {
22 | "200": {
23 | "description": "A list of available extensions and their properties",
24 | "content": {"application/json": {"schema": ExtensionSchema}},
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/labthings/default_views/root.py:
--------------------------------------------------------------------------------
1 | from ..find import current_labthing
2 | from ..views import View, described_operation
3 |
4 |
5 | class RootView(View):
6 | """W3C Thing Description"""
7 |
8 | @described_operation
9 | def get(self):
10 | """Thing Description
11 | ---
12 | description: Thing Description
13 | summary: Thing Description
14 | """
15 | return current_labthing().thing_description.to_dict()
16 |
17 | get.summary = "Thing Description"
18 | get.description = (
19 | "A W3C compliant Thing Description is a JSON representation\n"
20 | "of the API, including links to different endpoints.\n"
21 | "You can browse it directly (e.g. in Firefox), though for \n"
22 | "interactive API documentation you should try the swagger-ui \n"
23 | "docs, at `docs/swagger-ui/`"
24 | )
25 | get.responses = {
26 | "200": {
27 | "description": "W3C Thing Description",
28 | "content": {"application/json": {}},
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/labthings/json/marshmallow_jsonschema/README:
--------------------------------------------------------------------------------
1 | This submodule is a modified version of fuhrysteve/marshmallow-jsonschema, with slightly differing and reduced functionality.
2 |
3 | This will convert any Marshmallow schema into a JSON schema, without any schema references or additional properties.
4 | It will create a basic, inline schema only.
5 |
6 | It has also been modified (with the addition of `.base.convert_type_list_to_oneof`) to avoid returning list values for
7 | the type of a schema (this is valid JSON schema, but is not permitted in Thing Description syntax). Instead, we expand
8 | the list, by creating a copy of the schema for each type, and combining them using `oneOf`. This means that
9 | `fields.String(allow_none=True)`, which would previously be rendered as:
10 | ```json
11 | {"type": ["string", "null"]}
12 | ```
13 | will be dumped as
14 | ```json
15 | {
16 | "oneOf": [
17 | {"type": "string"},
18 | {"type": "null"}
19 | ]
20 | }
21 | ```
22 | This is also valid JSONSchema, though clearly less elegant. However, it's required by the thing description.
23 |
24 | https://github.com/fuhrysteve/marshmallow-jsonschema
--------------------------------------------------------------------------------
/src/labthings/json/marshmallow_jsonschema/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Stephen J. Fuhry
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/src/labthings/json/encoder.py:
--------------------------------------------------------------------------------
1 | # Flask JSON encoder so we get UUID, datetime etc support
2 | import json
3 | from base64 import b64encode
4 | from collections import UserString
5 |
6 | from flask.json import JSONEncoder
7 |
8 |
9 | class LabThingsJSONEncoder(JSONEncoder):
10 | """A custom JSON encoder, with type conversions for PiCamera fractions, Numpy integers, and Numpy arrays"""
11 |
12 | def default(self, o):
13 | """
14 |
15 | :param o:
16 |
17 | """
18 | if isinstance(o, set):
19 | return list(o)
20 | if isinstance(o, bytes):
21 | try: # Try unicode
22 | return o.decode()
23 | except UnicodeDecodeError: # Otherwise, base64
24 | return b64encode(o).decode()
25 | if isinstance(o, UserString):
26 | return str(o)
27 | return JSONEncoder.default(self, o)
28 |
29 |
30 | def encode_json(data, encoder=LabThingsJSONEncoder, **settings):
31 | """Makes JSON encoded data using the LabThings JSON encoder
32 |
33 | :param data:
34 | :param encoder: (Default value = LabThingsJSONEncoder)
35 | :param **settings:
36 |
37 | """
38 | return json.dumps(data, cls=encoder, **settings) + "\n"
39 |
--------------------------------------------------------------------------------
/docs/plan.md:
--------------------------------------------------------------------------------
1 | # Documentation plan/structure
2 |
3 | ## Quickstart
4 |
5 | ## App, LabThing, and Server
6 |
7 | * create_app
8 | * LabThing class
9 | * Server class
10 | * current_labthing
11 |
12 | ### HTTP API structure
13 |
14 | * Thing Description (root)
15 | * Swagger-UI
16 | * Action queue
17 | * Extension list
18 |
19 | ### Serialising data
20 |
21 | * fields
22 | * schema
23 | * semantics
24 |
25 | ### Action tasks
26 |
27 | * Preamble (all actions are tasks, etc)
28 | * Labthing.actions TaskPool
29 | * current_task
30 | * update_task_progress
31 | * update_task_data
32 | * Stopping tasks
33 | * TaskThread.stopped event
34 | * TaskKillException
35 |
36 | ### Synchronisation
37 |
38 | * StrictLock
39 | * CompositeLock
40 | * ClientEvent
41 |
42 | ## Advanced usage
43 |
44 | ### View classes
45 |
46 | * labthings.views
47 |
48 | ### Components
49 |
50 | * Access to Python objects by name
51 | * Used to access hardware from within Views
52 | * registered_components, find_component
53 |
54 | ### Encoders
55 |
56 | * labthings.json.LabThingsJSONEncoder
57 |
58 | ### Extensions
59 |
60 | * labthings.extensions.BaseExtension
61 | * labthings.extensions.find_extensions
62 | * registered_extensions, find_extension
--------------------------------------------------------------------------------
/tests/test_fields.py:
--------------------------------------------------------------------------------
1 | import pickle
2 | from base64 import b64encode
3 |
4 | import pytest
5 | from marshmallow import ValidationError
6 |
7 | from labthings import fields, schema
8 |
9 |
10 | def test_bytes_encode():
11 | test_schema = schema.Schema.from_dict({"b": fields.Bytes()})()
12 |
13 | obj = type("obj", (object,), {"b": pickle.dumps(object())})
14 |
15 | assert test_schema.dump(obj) == {
16 | "b": obj.b,
17 | }
18 |
19 |
20 | def test_bytes_decode():
21 | test_schema = schema.Schema.from_dict({"b": fields.Bytes()})()
22 |
23 | data = {"b": pickle.dumps(object())}
24 |
25 | assert test_schema.load(data) == data
26 |
27 |
28 | def test_bytes_decode_string():
29 | test_schema = schema.Schema.from_dict({"b": fields.Bytes()})()
30 |
31 | data = {"b": pickle.dumps(object())}
32 | encoded_data = {"b": b64encode(data["b"]).decode()}
33 |
34 | assert test_schema.load(encoded_data) == data
35 |
36 |
37 | def test_bytes_validate():
38 | assert fields.Bytes()._validate(pickle.dumps(object())) is None
39 |
40 |
41 | def test_bytes_validate_wrong_type():
42 | with pytest.raises(ValidationError):
43 | fields.Bytes()._validate(object())
44 |
45 |
46 | def test_bytes_validate_bad_data():
47 | with pytest.raises(ValidationError):
48 | fields.Bytes()._validate(b"")
49 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | release:
6 | types:
7 | - created
8 |
9 | jobs:
10 | test:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | python: [3.6, 3.7, 3.8]
15 |
16 | steps:
17 | - uses: actions/checkout@v1
18 |
19 | - name: Set up Python
20 | uses: actions/setup-python@v1
21 | with:
22 | python-version: ${{ matrix.python }}
23 |
24 | - name: Install and configure Poetry
25 | uses: snok/install-poetry@v1.1.1
26 | with:
27 | version: 1.1.4
28 | virtualenvs-create: true
29 | virtualenvs-in-project: false
30 | virtualenvs-path: ~/.virtualenvs
31 |
32 | - name: Install Dependencies
33 | run: poetry install
34 |
35 | - name: Code Quality
36 | run: poetry run black . --check
37 | continue-on-error: true
38 |
39 | - name: Analyse with MyPy
40 | run: poetry run mypy src
41 |
42 | - name: Lint with PyLint
43 | run: poetry run pylint src/labthings/
44 |
45 | - name: Test with pytest
46 | run: poetry run pytest
47 |
48 | - name: Upload coverage to Codecov
49 | uses: codecov/codecov-action@v1
50 | with:
51 | file: ./coverage.xml
52 | flags: unittests
53 | name: codecov-umbrella-${{ matrix.python }}
54 | fail_ci_if_error: false
55 |
--------------------------------------------------------------------------------
/src/labthings/example_components/spectrometer.py:
--------------------------------------------------------------------------------
1 | import math
2 | import random
3 | import time
4 |
5 |
6 | class PretendSpectrometer:
7 | def __init__(self):
8 | self.x_range = range(-100, 100)
9 | self.integration_time = 200
10 | self.settings = {
11 | "voltage": 5,
12 | "mode": "spectrum",
13 | "light_on": True,
14 | "user": {"name": "Squidward", "id": 1},
15 | }
16 |
17 | def make_spectrum(self, x, mu=0.0, sigma=25.0):
18 | """
19 | Generate a noisy gaussian function (to act as some pretend data)
20 |
21 | Our noise is inversely proportional to self.integration_time
22 | """
23 | x = float(x - mu) / sigma
24 | return (
25 | math.exp(-x * x / 2.0) / math.sqrt(2.0 * math.pi) / sigma
26 | + (1 / self.integration_time) * random.random()
27 | )
28 |
29 | @property
30 | def data(self):
31 | """Return a 1D data trace."""
32 | time.sleep(self.integration_time / 1000)
33 | return [self.make_spectrum(x) for x in self.x_range]
34 |
35 | def average_data(self, n: int):
36 | """Average n-sets of data. Emulates a measurement that may take a while."""
37 | summed_data = self.data
38 |
39 | for _ in range(n):
40 | summed_data = [summed_data[i] + el for i, el in enumerate(self.data)]
41 | time.sleep(0.25)
42 |
43 | summed_data = [i / n for i in summed_data]
44 |
45 | return summed_data
46 |
--------------------------------------------------------------------------------
/src/labthings/default_views/docs/templates/swagger-ui.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Swagger UI
7 |
9 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
20 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/src/labthings/representations.py:
--------------------------------------------------------------------------------
1 | from collections import OrderedDict
2 | from typing import Any, Optional
3 |
4 | from flask import Response, current_app, make_response
5 |
6 | from .find import current_labthing
7 | from .json.encoder import JSONEncoder as FlaskJSONEncoder
8 | from .json.encoder import LabThingsJSONEncoder, encode_json
9 | from .utilities import PY3
10 |
11 | __all__ = ["LabThingsJSONEncoder", "DEFAULT_REPRESENTATIONS", "output_json"]
12 |
13 |
14 | def output_json(data: Any, code: int, headers: Optional[dict] = None) -> Response:
15 | """Makes a Flask response with a JSON encoded body, using app JSON settings
16 |
17 | :param data: Data to be serialised
18 | :param code: HTTP response code
19 | :param headers: HTTP response headers (Default value = None)
20 |
21 | """
22 |
23 | settings = current_app.config.get("LABTHINGS_JSON", {})
24 |
25 | if current_labthing():
26 | encoder = current_labthing().json_encoder
27 | else:
28 | encoder = getattr(current_app, "json_encoder", None) or FlaskJSONEncoder
29 |
30 | if current_app.debug:
31 | settings.setdefault("indent", 4)
32 | settings.setdefault("sort_keys", not PY3)
33 |
34 | dumped = encode_json(data, encoder=encoder, **settings)
35 |
36 | resp = make_response(dumped, code)
37 | resp.headers.extend(headers or {})
38 | resp.mimetype = "application/json"
39 | return resp
40 |
41 |
42 | DEFAULT_REPRESENTATIONS = OrderedDict(
43 | {
44 | "application/json": output_json,
45 | }
46 | )
47 |
--------------------------------------------------------------------------------
/tests/test_representations.py:
--------------------------------------------------------------------------------
1 | import json
2 | import pickle
3 |
4 | import pytest
5 | from flask import Response
6 |
7 | from labthings import representations
8 |
9 |
10 | @pytest.fixture
11 | def labthings_json_encoder():
12 | return representations.LabThingsJSONEncoder
13 |
14 |
15 | def test_encoder_default_exception(labthings_json_encoder):
16 | with pytest.raises(TypeError):
17 | labthings_json_encoder().default("")
18 |
19 |
20 | def test_encode_json(labthings_json_encoder):
21 | data = {
22 | "key": "value",
23 | "blob": pickle.dumps(object()),
24 | }
25 |
26 | out = representations.encode_json(data, encoder=labthings_json_encoder)
27 | out_dict = json.loads(out)
28 | assert "blob" in out_dict
29 | assert isinstance(out_dict.get("blob"), str)
30 |
31 |
32 | def test_output_json(app_ctx):
33 | data = {
34 | "key": "value",
35 | }
36 |
37 | with app_ctx.test_request_context():
38 | response = representations.output_json(data, 200)
39 | assert isinstance(response, Response)
40 | assert response.status_code == 200
41 | assert response.headers.get("Content-Type") == "application/json"
42 | assert response.data == b'{"key": "value"}\n'
43 |
44 |
45 | def test_pretty_output_json(app_ctx_debug):
46 | data = {
47 | "key": "value",
48 | }
49 |
50 | with app_ctx_debug.test_request_context():
51 | response = representations.output_json(data, 200)
52 | assert response.data == b'{\n "key": "value"\n}\n'
53 |
--------------------------------------------------------------------------------
/src/labthings/__init__.py:
--------------------------------------------------------------------------------
1 | # Main LabThing class
2 | # Submodules
3 | from . import extensions, fields, json, marshalling, views
4 |
5 | # Action threads
6 | from .actions import (
7 | ActionKilledException,
8 | current_action,
9 | update_action_data,
10 | update_action_progress,
11 | )
12 |
13 | # Functions to speed up finding global objects
14 | from .find import (
15 | current_labthing,
16 | find_component,
17 | find_extension,
18 | registered_components,
19 | registered_extensions,
20 | )
21 | from .labthing import LabThing
22 |
23 | # Quick-create app+LabThing function
24 | from .quick import create_app
25 |
26 | # Schema and field
27 | from .schema import Schema
28 |
29 | # Synchronisation classes
30 | from .sync import ClientEvent, CompositeLock, StrictLock
31 |
32 | # Views
33 | from .views import ActionView, EventView, PropertyView, op
34 |
35 | # Suggested WSGI server class
36 | from .wsgi import Server
37 |
38 | __all__ = [
39 | "LabThing",
40 | "create_app",
41 | "Server",
42 | "current_labthing",
43 | "registered_extensions",
44 | "registered_components",
45 | "find_extension",
46 | "find_component",
47 | "StrictLock",
48 | "CompositeLock",
49 | "ClientEvent",
50 | "current_action",
51 | "update_action_progress",
52 | "update_action_data",
53 | "ActionKilledException",
54 | "marshalling",
55 | "extensions",
56 | "views",
57 | "fields",
58 | "Schema",
59 | "json",
60 | "PropertyView",
61 | "ActionView",
62 | "EventView",
63 | "op",
64 | ]
65 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "labthings"
3 | version = "1.3.2"
4 | description = "Python implementation of LabThings, based on the Flask microframework"
5 | readme = "README.md"
6 | repository = "https://github.com/labthings/python-labthings/"
7 | authors = ["Joel Collins "]
8 | classifiers = [
9 | "Topic :: System :: Hardware",
10 | "Topic :: Software Development :: Libraries :: Application Frameworks",
11 | "Topic :: Internet :: WWW/HTTP :: WSGI"
12 | ]
13 | include = ["src/labthings/py.typed"]
14 |
15 | [tool.poetry.dependencies]
16 | python = "^3.6"
17 | Flask = "^1.1.1"
18 | marshmallow = "^3.4.0"
19 | webargs = ">=6,<9"
20 | apispec = {version = ">=3.2,<5.0", extras = ["yaml", "validation"]}
21 | flask-cors = "^3.0.8"
22 | zeroconf = ">=0.24.5,<0.39.0"
23 | apispec_webframeworks = "^0.5.2"
24 |
25 | [tool.poetry.dev-dependencies]
26 | pytest = "^6.2"
27 | black = {version = "^20.8b1",allow-prereleases = true}
28 | pytest-cov = "^2.11.1"
29 | jsonschema = "^3.2.0"
30 | pylint = "^2.10.2"
31 | sphinx = "^4.1.1"
32 | sphinx-autoapi = "^1.8.4"
33 | sphinx-rtd-theme = "^0.5.2"
34 | mypy = "^0.812"
35 |
36 | [tool.black]
37 | exclude = '(\.eggs|\.git|\.venv|node_modules/)'
38 |
39 | [tool.isort]
40 | multi_line_output = 3
41 | include_trailing_comma = true
42 | force_grid_wrap = 0
43 | use_parentheses = true
44 | ensure_newline_before_comments = true
45 | line_length = 88
46 |
47 | [tool.pylint.'MESSAGES CONTROL']
48 | disable = "fixme,C,R"
49 | max-line-length = 88
50 |
51 | [tool.pylint.'MASTER']
52 | ignore = "marshmallow_jsonschema"
53 |
54 | [build-system]
55 | requires = ["poetry-core>=1.0.0"]
56 | build-backend = "poetry.core.masonry.api"
57 |
--------------------------------------------------------------------------------
/src/labthings/default_views/docs/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Swagger UI
7 |
8 |
9 |
10 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/src/labthings/httperrorhandler.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from flask import escape
4 | from werkzeug.exceptions import HTTPException, default_exceptions
5 |
6 |
7 | class SerializedExceptionHandler:
8 |
9 | """A class to be registered as a Flask error handler,
10 | converts error codes into a serialized response
11 |
12 |
13 | """
14 |
15 | def __init__(self, app=None):
16 | if app:
17 | self.init_app(app)
18 |
19 | def std_handler(self, error):
20 | """
21 |
22 | :param error:
23 |
24 | """
25 | logging.error(error)
26 |
27 | if isinstance(error, HTTPException):
28 | message = error.description
29 | elif hasattr(error, "message"):
30 | message = error.message
31 | else:
32 | message = str(error)
33 |
34 | status_code = error.code if isinstance(error, HTTPException) else 500
35 |
36 | response = {
37 | "code": status_code,
38 | "message": escape(message),
39 | "name": getattr(error, "__name__", None)
40 | or getattr(getattr(error, "__class__", None), "__name__", None)
41 | or None,
42 | }
43 | return (response, status_code)
44 |
45 | def init_app(self, app):
46 | """
47 |
48 | :param app:
49 |
50 | """
51 | self.app = app
52 | self.register(HTTPException)
53 | for code, _ in default_exceptions.items():
54 | self.register(code)
55 | self.register(Exception)
56 |
57 | def register(self, exception_or_code, handler=None):
58 | """
59 |
60 | :param exception_or_code:
61 | :param handler: (Default value = None)
62 |
63 | """
64 | self.app.errorhandler(exception_or_code)(handler or self.std_handler)
65 |
--------------------------------------------------------------------------------
/src/labthings/default_views/docs/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, Response, make_response, render_template
2 |
3 | from ...find import current_labthing
4 | from ...views import View
5 |
6 |
7 | class APISpecView(View):
8 | """OpenAPI v3 documentation"""
9 |
10 | responses = {
11 | "200": {
12 | "description": "OpenAPI v3 description of this API",
13 | "content": {"application/json": {}},
14 | }
15 | }
16 |
17 | def get(self):
18 | """OpenAPI v3 documentation"""
19 | return current_labthing().spec.to_dict()
20 |
21 |
22 | class APISpecYAMLView(View):
23 | """OpenAPI v3 documentation
24 |
25 | A YAML document containing an API description in OpenAPI format
26 | """
27 |
28 | responses = {
29 | "200": {
30 | "description": "OpenAPI v3 description of this API",
31 | "content": {"text/yaml": {}},
32 | }
33 | }
34 |
35 | def get(self):
36 | return Response(current_labthing().spec.to_yaml(), mimetype="text/yaml")
37 |
38 |
39 | class SwaggerUIView(View):
40 | """Swagger UI documentation"""
41 |
42 | def get(self):
43 | """ """
44 | return make_response(render_template("swagger-ui.html"))
45 |
46 |
47 | docs_blueprint = Blueprint(
48 | "labthings_docs", __name__, static_folder="./static", template_folder="./templates"
49 | )
50 |
51 | docs_blueprint.add_url_rule("/swagger", view_func=APISpecView.as_view("swagger_json"))
52 | docs_blueprint.add_url_rule("/openapi", endpoint="swagger_json")
53 | docs_blueprint.add_url_rule("/openapi.json", endpoint="swagger_json")
54 | docs_blueprint.add_url_rule(
55 | "/openapi.yaml", view_func=APISpecYAMLView.as_view("openapi_yaml")
56 | )
57 | docs_blueprint.add_url_rule(
58 | "/swagger-ui", view_func=SwaggerUIView.as_view("swagger_ui")
59 | )
60 | SwaggerUIView.endpoint = "labthings_docs.swagger_ui"
61 |
--------------------------------------------------------------------------------
/tests/test_labthing_exceptions.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import pytest
4 | from flask import Flask
5 |
6 | from labthings.labthing import SerializedExceptionHandler
7 |
8 |
9 | @pytest.fixture
10 | def client():
11 | app = Flask(__name__)
12 | app.config["TESTING"] = True
13 |
14 | with app.test_client() as client:
15 | yield client
16 |
17 |
18 | @pytest.fixture
19 | def app():
20 | app = Flask(__name__)
21 | app.config["TESTING"] = True
22 | return app
23 |
24 |
25 | def test_registering_handler(app):
26 | error_handler = SerializedExceptionHandler()
27 | error_handler.init_app(app)
28 |
29 |
30 | def test_http_exception(app):
31 | from werkzeug.exceptions import NotFound
32 |
33 | error_handler = SerializedExceptionHandler(app)
34 |
35 | # Test a 404 HTTPException
36 | response = error_handler.std_handler(NotFound())
37 |
38 | response_json = json.dumps(response[0])
39 | assert (
40 | response_json
41 | == '{"code": 404, "message": "The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.", "name": "NotFound"}'
42 | )
43 |
44 | assert response[1] == 404
45 |
46 |
47 | def test_generic_exception(app):
48 | error_handler = SerializedExceptionHandler(app)
49 |
50 | # Test a 404 HTTPException
51 | response = error_handler.std_handler(RuntimeError("Exception message"))
52 |
53 | response_json = json.dumps(response[0])
54 | assert (
55 | response_json
56 | == '{"code": 500, "message": "Exception message", "name": "RuntimeError"}'
57 | )
58 |
59 | assert response[1] == 500
60 |
61 |
62 | def test_blank_exception(app):
63 | error_handler = SerializedExceptionHandler(app)
64 |
65 | e = Exception()
66 | e.message = None
67 |
68 | # Test an empty Exception
69 | response = error_handler.std_handler(e)
70 |
71 | response_json = json.dumps(response[0])
72 | assert response_json == '{"code": 500, "message": "None", "name": "Exception"}'
73 |
74 | assert response[1] == 500
75 |
--------------------------------------------------------------------------------
/tests/test_views_op.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from labthings.views import View, op
4 |
5 |
6 | @pytest.fixture
7 | def thing_description(thing):
8 | return thing.thing_description
9 |
10 |
11 | def test_op_readproperty(helpers, app, thing_description, app_ctx, schemas_path):
12 | class Index(View):
13 | @op.readproperty
14 | def get(self):
15 | return "GET"
16 |
17 | app.add_url_rule("/", view_func=Index.as_view("index"))
18 | rules = app.url_map._rules_by_endpoint["index"]
19 |
20 | thing_description.property(rules, Index)
21 |
22 | with app_ctx.test_request_context():
23 | assert "index" in thing_description.to_dict()["properties"]
24 | assert (
25 | thing_description.to_dict()["properties"]["index"]["forms"][0]["op"]
26 | == "readproperty"
27 | )
28 |
29 | assert (
30 | thing_description.to_dict()["properties"]["index"]["forms"][0][
31 | "htv:methodName"
32 | ]
33 | == "GET"
34 | )
35 | helpers.validate_thing_description(thing_description, app_ctx, schemas_path)
36 |
37 |
38 | def test_op_writeproperty(helpers, app, thing_description, app_ctx, schemas_path):
39 | class Index(View):
40 | @op.writeproperty
41 | def put(self):
42 | return "PUT"
43 |
44 | app.add_url_rule("/", view_func=Index.as_view("index"))
45 | rules = app.url_map._rules_by_endpoint["index"]
46 |
47 | thing_description.property(rules, Index)
48 |
49 | with app_ctx.test_request_context():
50 | assert "index" in thing_description.to_dict()["properties"]
51 | assert (
52 | thing_description.to_dict()["properties"]["index"]["forms"][0]["op"]
53 | == "writeproperty"
54 | )
55 |
56 | assert (
57 | thing_description.to_dict()["properties"]["index"]["forms"][0][
58 | "htv:methodName"
59 | ]
60 | == "PUT"
61 | )
62 | helpers.validate_thing_description(thing_description, app_ctx, schemas_path)
63 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 | strategy:
11 | matrix:
12 | python: [3.6, 3.7, 3.8]
13 |
14 | steps:
15 | - uses: actions/checkout@v1
16 |
17 | - name: Set up Python
18 | uses: actions/setup-python@v1
19 | with:
20 | python-version: ${{ matrix.python }}
21 |
22 | - name: Install and configure Poetry
23 | uses: snok/install-poetry@v1.1.1
24 | with:
25 | version: 1.1.4
26 | virtualenvs-create: true
27 | virtualenvs-in-project: false
28 | virtualenvs-path: ~/.virtualenvs
29 |
30 | - name: Install Dependencies
31 | run: poetry install
32 |
33 | - name: Code Quality
34 | run: poetry run black . --check
35 | continue-on-error: true
36 |
37 | - name: Analyse with MyPy
38 | run: poetry run mypy src
39 |
40 | - name: Lint with PyLint
41 | run: poetry run pylint src/labthings/
42 |
43 | - name: Test with pytest
44 | run: poetry run pytest
45 |
46 | publish:
47 | runs-on: ubuntu-latest
48 | needs: test
49 |
50 | steps:
51 | - uses: actions/checkout@v1
52 | with:
53 | fetch-depth: 1
54 |
55 | - name: Set up Python 3.7
56 | uses: actions/setup-python@v1
57 | with:
58 | python-version: 3.7
59 |
60 | - name: Install and configure Poetry
61 | uses: snok/install-poetry@v1.1.1
62 | with:
63 | version: 1.1.4
64 | virtualenvs-create: true
65 | virtualenvs-in-project: false
66 | virtualenvs-path: ~/.virtualenvs
67 |
68 | - name: Set Poetry config
69 | env:
70 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.POETRY_PYPI_TOKEN_PYPI }}
71 | run: |
72 | poetry config pypi-token.pypi "$POETRY_PYPI_TOKEN_PYPI"
73 |
74 | - name: Build with Poetry
75 | run: poetry build
76 |
77 | - name: Publish with Poetry
78 | run: poetry publish
79 |
--------------------------------------------------------------------------------
/src/labthings/views/builder.py:
--------------------------------------------------------------------------------
1 | import glob
2 | import os
3 | import uuid
4 | from typing import Type
5 |
6 | from flask import abort, send_file
7 |
8 | from . import View, described_operation
9 |
10 |
11 | def static_from(static_folder: str, name=None) -> Type[View]:
12 | """
13 | :param static_folder: str:
14 | :param name: (Default value = None)
15 | """
16 |
17 | # Create a class name
18 | if not name:
19 | uid = uuid.uuid4()
20 | name = f"static-{uid}"
21 |
22 | # Create inner functions
23 | @described_operation
24 | def _get(_, path=""):
25 | """
26 | :param path: (Default value = "")
27 | """
28 | full_path = os.path.join(static_folder, path)
29 | if not os.path.exists(full_path):
30 | return abort(404)
31 |
32 | if os.path.isfile(full_path):
33 | return send_file(full_path)
34 |
35 | if os.path.isdir(full_path):
36 | indexes = glob.glob(os.path.join(full_path, "index.*"))
37 | if not indexes:
38 | return abort(404)
39 | return send_file(indexes[0])
40 |
41 | _get.summary = "Serve static files"
42 | _get.description = (
43 | "Files and folders within this path will be served from a static directory."
44 | )
45 | _get.responses = {
46 | "200": {
47 | "description": "Static file",
48 | },
49 | "404": {
50 | "description": "Static file not found",
51 | },
52 | }
53 |
54 | # Generate a basic property class
55 | generated_class = type(
56 | name,
57 | (View, object),
58 | {
59 | "get": _get,
60 | "parameters": [
61 | {
62 | "name": "path",
63 | "in": "path",
64 | "description": "Path to the static file",
65 | "required": True,
66 | "schema": {"type": "string"},
67 | "example": "style.css",
68 | }
69 | ],
70 | },
71 | )
72 |
73 | return generated_class
74 |
--------------------------------------------------------------------------------
/src/labthings/marshalling/args.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from functools import update_wrapper, wraps
3 | from typing import Callable, Mapping, Union
4 |
5 | from flask import abort, request
6 | from marshmallow.exceptions import ValidationError
7 | from webargs import flaskparser
8 |
9 | from ..fields import Field
10 | from ..schema import FieldSchema, Schema
11 |
12 |
13 | def use_body(schema: Field, **_) -> Callable:
14 | def inner(f: Callable):
15 | # Wrapper function
16 | @wraps(f)
17 | def wrapper(*args, **kwargs):
18 | """
19 |
20 | :param *args:
21 | :param **kwargs:
22 |
23 | """
24 | # Get data from request
25 | data = request.get_json(silent=True) or request.data or None
26 |
27 | # If no data is there
28 | if not data:
29 | # If data is required
30 | if schema.required:
31 | # Abort
32 | return abort(400)
33 | # Otherwise, look for the schema fields 'missing' property
34 | if schema.missing:
35 | data = schema.missing
36 |
37 | # Serialize data if it exists
38 | if data:
39 | try:
40 | data = FieldSchema(schema).deserialize(data)
41 | except ValidationError as e:
42 | logging.error(e)
43 | return abort(400)
44 |
45 | # Inject argument and return wrapped function
46 | return f(*args, data, **kwargs)
47 |
48 | return wrapper
49 |
50 | return inner
51 |
52 |
53 | class use_args:
54 | """Equivalent to webargs.flask_parser.use_args"""
55 |
56 | def __init__(self, schema: Union[Schema, Field, Mapping[str, Field]], **kwargs):
57 | self.schema = schema
58 |
59 | if isinstance(schema, Field):
60 | self.wrapper = use_body(schema, **kwargs)
61 | else:
62 | self.wrapper = flaskparser.use_args(schema, **kwargs)
63 |
64 | def __call__(self, f: Callable):
65 | # Wrapper function
66 | update_wrapper(self.wrapper, f)
67 | return self.wrapper(f)
68 |
--------------------------------------------------------------------------------
/tests/test_json_schemas.py:
--------------------------------------------------------------------------------
1 | from labthings.json import schemas
2 |
3 |
4 | def make_rule(app, path, **kwargs):
5 | @app.route(path, **kwargs)
6 | def view():
7 | pass
8 |
9 | return app.url_map._rules_by_endpoint["view"][0]
10 |
11 |
12 | def make_param(in_location="path", **kwargs):
13 | ret = {"in": in_location, "required": True}
14 | ret.update(kwargs)
15 | return ret
16 |
17 |
18 | def test_rule_to_path(app):
19 | rule = make_rule(app, "/path//")
20 | assert schemas.rule_to_path(rule) == "/path/{id}/"
21 |
22 |
23 | def test_rule_to_param(app):
24 | rule = make_rule(app, "/path//")
25 | assert schemas.rule_to_params(rule) == [
26 | {"in": "path", "name": "id", "required": True, "schema": {"type": "string"}}
27 | ]
28 |
29 |
30 | def test_rule_to_param_typed(app):
31 | rule = make_rule(app, "/path//")
32 | assert schemas.rule_to_params(rule) == [
33 | {
34 | "in": "path",
35 | "name": "id",
36 | "required": True,
37 | "schema": {"type": "integer"},
38 | "format": "int32",
39 | }
40 | ]
41 |
42 |
43 | def test_rule_to_param_typed_default(app):
44 | rule = make_rule(app, "/path//", defaults={"id": 1})
45 | assert schemas.rule_to_params(rule) == [
46 | {
47 | "in": "path",
48 | "name": "id",
49 | "required": True,
50 | "default": 1,
51 | "schema": {"type": "integer"},
52 | "format": "int32",
53 | }
54 | ]
55 |
56 |
57 | def test_rule_to_param_overrides(app):
58 | rule = make_rule(app, "/path//")
59 | overrides = {"override_key": {"in": "header", "name": "header_param"}}
60 | assert schemas.rule_to_params(rule, overrides=overrides) == [
61 | {"in": "path", "name": "id", "required": True, "schema": {"type": "string"}},
62 | *overrides.values(),
63 | ]
64 |
65 |
66 | def test_rule_to_param_overrides_invalid(app):
67 | rule = make_rule(app, "/path//")
68 | overrides = {"override_key": {"in": "invalid", "name": "header_param"}}
69 | assert schemas.rule_to_params(rule, overrides=overrides) == [
70 | {"in": "path", "name": "id", "required": True, "schema": {"type": "string"}}
71 | ]
72 |
--------------------------------------------------------------------------------
/src/labthings/fields.py:
--------------------------------------------------------------------------------
1 | # Marshmallow fields
2 | from base64 import b64decode
3 |
4 | from marshmallow import ValidationError
5 | from marshmallow.fields import (
6 | URL,
7 | UUID,
8 | AwareDateTime,
9 | Bool,
10 | Boolean,
11 | Constant,
12 | Date,
13 | DateTime,
14 | Decimal,
15 | Dict,
16 | Email,
17 | Field,
18 | Float,
19 | Function,
20 | Int,
21 | Integer,
22 | List,
23 | Mapping,
24 | Method,
25 | NaiveDateTime,
26 | Nested,
27 | Number,
28 | Pluck,
29 | Raw,
30 | Str,
31 | String,
32 | Time,
33 | TimeDelta,
34 | Tuple,
35 | Url,
36 | )
37 |
38 | __all__ = [
39 | "Bytes",
40 | "Field",
41 | "Raw",
42 | "Nested",
43 | "Mapping",
44 | "Dict",
45 | "List",
46 | "Tuple",
47 | "String",
48 | "UUID",
49 | "Number",
50 | "Integer",
51 | "Decimal",
52 | "Boolean",
53 | "Float",
54 | "DateTime",
55 | "NaiveDateTime",
56 | "AwareDateTime",
57 | "Time",
58 | "Date",
59 | "TimeDelta",
60 | "Url",
61 | "URL",
62 | "Email",
63 | "Method",
64 | "Function",
65 | "Str",
66 | "Bool",
67 | "Int",
68 | "Constant",
69 | "Pluck",
70 | ]
71 |
72 |
73 | class Bytes(Field):
74 | """
75 | Marshmallow field for `bytes` objects
76 | """
77 |
78 | def _jsonschema_type_mapping(self):
79 | """ """
80 | return {"type": "string", "contentEncoding": "base64"}
81 |
82 | def _validate(self, value):
83 | """
84 |
85 | :param value:
86 |
87 | """
88 | if not isinstance(value, bytes):
89 | raise ValidationError("Invalid input type.")
90 |
91 | if value is None or value == b"":
92 | raise ValidationError("Invalid value")
93 |
94 | def _deserialize(self, value, attr, data, **kwargs):
95 | """
96 |
97 | :param value:
98 | :param attr:
99 | :param data:
100 | :param **kwargs:
101 |
102 | """
103 | if isinstance(value, bytes):
104 | return value
105 | if isinstance(value, str):
106 | return b64decode(value)
107 | else:
108 | raise self.make_error("invalid", input=value)
109 |
--------------------------------------------------------------------------------
/src/labthings/marshalling/marshalling.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Mapping
2 | from functools import wraps
3 | from typing import Callable, Dict, Optional, Tuple, Union
4 |
5 | from marshmallow import Schema as _Schema
6 | from werkzeug.wrappers import Response as ResponseBase
7 |
8 | from ..fields import Field
9 | from ..schema import FieldSchema, Schema
10 | from ..utilities import unpack
11 |
12 |
13 | def schema_to_converter(
14 | schema: Union[Schema, Field, Dict[str, Union[Field, type]]]
15 | ) -> Optional[Callable]:
16 | """Convert a schema into a converter function,
17 | which takes a value as an argument and returns
18 | marshalled data
19 |
20 | :param schema: Input schema
21 |
22 | """
23 | if isinstance(schema, Mapping):
24 | # Please ignore the pylint disable below,
25 | # GeneratedSchema definitely does have a `dump` member
26 | # pylint: disable=no-member
27 | return Schema.from_dict(schema)().dump
28 | # Case of schema as a single Field
29 | elif isinstance(schema, Field):
30 | return FieldSchema(schema).dump
31 | # Case of schema as a Schema
32 | elif isinstance(schema, _Schema):
33 | return schema.dump
34 | else:
35 | return None
36 |
37 |
38 | def marshal(response: Union[Tuple, ResponseBase], converter: Callable):
39 | """
40 |
41 | :param response:
42 | :param converter:
43 |
44 | """
45 | if isinstance(response, ResponseBase):
46 | response.data = converter(response.data)
47 | return response
48 | elif isinstance(response, tuple):
49 | response, code, headers = unpack(response)
50 | return (converter(response), code, headers)
51 | return converter(response)
52 |
53 |
54 | class marshal_with:
55 | def __init__(self, schema: Union[Schema, Field, Dict[str, Union[Field, type]]]):
56 | """Decorator to format the return of a function with a Marshmallow schema
57 |
58 | :param schema: Marshmallow schema, field, or dict of Fields, describing
59 | the format of data to be returned by a View
60 |
61 | """
62 | self.schema = schema
63 | self.converter = schema_to_converter(self.schema)
64 |
65 | def __call__(self, f: Callable):
66 | # Wrapper function
67 | @wraps(f)
68 | def wrapper(*args, **kwargs):
69 | resp = f(*args, **kwargs)
70 | return marshal(resp, self.converter)
71 |
72 | return wrapper
73 |
--------------------------------------------------------------------------------
/tests/test_tasks_pool.py:
--------------------------------------------------------------------------------
1 | import threading
2 |
3 | from labthings import actions
4 |
5 |
6 | def test_spawn_without_context(task_pool):
7 | def task_func():
8 | pass
9 |
10 | task_obj = task_pool.spawn("task_func", task_func)
11 | assert isinstance(task_obj, threading.Thread)
12 |
13 |
14 | def test_spawn_with_context(app_ctx, task_pool):
15 | def task_func():
16 | pass
17 |
18 | with app_ctx.test_request_context():
19 | task_obj = task_pool.spawn("task_func", task_func)
20 | assert isinstance(task_obj, threading.Thread)
21 |
22 |
23 | def test_update_task_data(task_pool):
24 | def task_func():
25 | actions.update_action_data({"key": "value"})
26 |
27 | task_obj = task_pool.spawn("task_func", task_func)
28 | task_obj.join()
29 | assert task_obj.data == {"key": "value"}
30 |
31 |
32 | def test_update_task_data_main_thread():
33 | # Should do nothing
34 | actions.update_action_data({"key": "value"})
35 |
36 |
37 | def test_update_task_progress(task_pool):
38 | def task_func():
39 | actions.update_action_progress(100)
40 |
41 | task_obj = task_pool.spawn("task_func", task_func)
42 | task_obj.join()
43 | assert task_obj.progress == 100
44 |
45 |
46 | def test_update_task_progress_main_thread():
47 | # Should do nothing
48 | actions.update_action_progress(100)
49 |
50 |
51 | def test_tasks_list(task_pool):
52 | assert all(isinstance(task_obj, threading.Thread) for task_obj in task_pool.threads)
53 |
54 |
55 | def test_tasks_dict(task_pool):
56 | assert all(
57 | isinstance(task_obj, threading.Thread)
58 | for task_obj in task_pool.to_dict().values()
59 | )
60 |
61 | assert all(k == str(t.id) for k, t in task_pool.to_dict().items())
62 |
63 |
64 | def test_discard_id(task_pool):
65 | def task_func():
66 | pass
67 |
68 | task_obj = task_pool.spawn("task_func", task_func)
69 | assert str(task_obj.id) in task_pool.to_dict()
70 | task_obj.join()
71 |
72 | task_pool.discard_id(task_obj.id)
73 | assert not str(task_obj.id) in task_pool.to_dict()
74 |
75 |
76 | def test_cleanup_task(task_pool):
77 | import time
78 |
79 | def task_func():
80 | pass
81 |
82 | # Make sure at least 1 actions is around
83 | task_pool.spawn("task_func", task_func)
84 |
85 | # Wait for all actions to finish
86 | task_pool.join()
87 |
88 | assert len(task_pool.threads) > 0
89 | task_pool.cleanup()
90 | assert len(task_pool.threads) == 0
91 |
--------------------------------------------------------------------------------
/docs/basic_usage/app_thing_server.rst:
--------------------------------------------------------------------------------
1 | App, LabThing, and Server
2 | =========================
3 |
4 | Python LabThings works as a Flask extension, and so we introduce two key objects: the :class:`flask.Flask` app, and the :class:`labthings.LabThing` object. The :class:`labthings.LabThing` object is our main entrypoint for the Flask application, and all LabThings functionality is added via this object.
5 |
6 | In order to enable threaded actions the app should be served using the :class:`labthings.Server` class. Other production servers such as Gevent can be used, however this will require monkey-patching and has not been comprehensively tested.
7 |
8 |
9 | Create app
10 | ----------
11 |
12 | The :meth:`labthings.create_app` function automatically creates a Flask app object, enables up cross-origin resource sharing, and initialises a :class:`labthings.LabThing` instance on the app. The function returns both in a tuple.
13 |
14 | .. autofunction:: labthings.create_app
15 | :noindex:
16 |
17 | ALternatively, the app and :class:`labthings.LabThing` objects can be initialised and attached separately, for example:
18 |
19 | .. code-block:: python
20 |
21 | from flask import Flask
22 | from labthings import LabThing
23 |
24 | app = Flask(__name__)
25 | labthing = LabThing(app)
26 |
27 |
28 | LabThing
29 | --------
30 |
31 | The LabThing object is our main entrypoint, and handles creating API views, managing background actions, tracking logs, and generating API documentation.
32 |
33 | .. autoclass:: labthings.LabThing
34 | :noindex:
35 |
36 |
37 | Views
38 | -----
39 |
40 | Thing interaction affordances are created using Views. Two main View types correspond to properties and actions.
41 |
42 | .. autoclass:: labthings.PropertyView
43 | :noindex:
44 |
45 | .. autoclass:: labthings.ActionView
46 | :noindex:
47 |
48 |
49 | Server
50 | ------
51 |
52 | The integrated server actually handles 3 distinct server functions: WSGI HTTP requests, and registering mDNS records for automatic Thing discovery. It is therefore strongly suggested you use the builtin server.
53 |
54 | **Important notes:**
55 |
56 | The integrated server will spawn a new native thread *per-connection*. This will only function well in situations where few (<50) simultaneous connections are expected, such as local Web of Things devices. Do not use this server in any public web app where many connections are expected. It is designed exclusively with low-traffic LAN access in mind.
57 |
58 | .. autoclass:: labthings.Server
59 | :members:
60 | :noindex:
--------------------------------------------------------------------------------
/tests/test_find.py:
--------------------------------------------------------------------------------
1 | from labthings import find
2 | from labthings.extensions import BaseExtension
3 |
4 |
5 | def test_current_labthing_explicit_app(thing, thing_ctx):
6 | with thing_ctx.test_request_context():
7 | assert find.current_labthing(thing.app) is thing
8 |
9 |
10 | def test_current_labthing(thing, thing_ctx):
11 | with thing_ctx.test_request_context():
12 | assert find.current_labthing() is thing
13 |
14 |
15 | def test_current_labthing_missing_app():
16 | assert find.current_labthing() is None
17 |
18 |
19 | def test_registered_extensions(thing_ctx):
20 | with thing_ctx.test_request_context():
21 | assert find.registered_extensions() == {}
22 |
23 |
24 | def test_registered_extensions_explicit_thing(thing):
25 | assert find.registered_extensions(thing) == {}
26 |
27 |
28 | def test_registered_components(thing_ctx):
29 | with thing_ctx.test_request_context():
30 | assert find.registered_components() == {}
31 |
32 |
33 | def test_registered_components_explicit_thing(thing):
34 | assert find.registered_components(thing) == {}
35 |
36 |
37 | def test_find_component(thing, thing_ctx):
38 | component = type("component", (object,), {})
39 | thing.add_component(component, "org.labthings.tests.component")
40 |
41 | with thing_ctx.test_request_context():
42 | assert find.find_component("org.labthings.tests.component") == component
43 |
44 |
45 | def test_find_component_explicit_thing(thing):
46 | component = type("component", (object,), {})
47 | thing.add_component(component, "org.labthings.tests.component")
48 |
49 | assert find.find_component("org.labthings.tests.component", thing) == component
50 |
51 |
52 | def test_find_component_missing_component(thing_ctx):
53 | with thing_ctx.test_request_context():
54 | assert find.find_component("org.labthings.tests.component") is None
55 |
56 |
57 | def test_find_extension(thing, thing_ctx):
58 | extension = BaseExtension("org.labthings.tests.extension")
59 | thing.register_extension(extension)
60 |
61 | with thing_ctx.test_request_context():
62 | assert find.find_extension("org.labthings.tests.extension") == extension
63 |
64 |
65 | def test_find_extension_explicit_thing(thing):
66 | extension = BaseExtension("org.labthings.tests.extension")
67 | thing.register_extension(extension)
68 |
69 | assert find.find_extension("org.labthings.tests.extension", thing) == extension
70 |
71 |
72 | def test_find_extension_missing_extesion(thing_ctx):
73 | with thing_ctx.test_request_context():
74 | assert find.find_extension("org.labthings.tests.extension") is None
75 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 | # We use poetry, so should ignore the generated setup.py
30 | /setup.py
31 |
32 | # PyInstaller
33 | # Usually these files are written by a python script from a template
34 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
35 | *.manifest
36 | *.spec
37 |
38 | # Installer logs
39 | pip-log.txt
40 | pip-delete-this-directory.txt
41 |
42 | # Unit test / coverage reports
43 | htmlcov/
44 | coverage_html_report/
45 | .tox/
46 | .nox/
47 | .coverage
48 | .coverage.*
49 | .cache
50 | nosetests.xml
51 | coverage.xml
52 | *.cover
53 | *.py,cover
54 | .hypothesis/
55 | .pytest_cache/
56 | coverage_html_report/
57 | prof/
58 |
59 | # Translations
60 | *.mo
61 | *.pot
62 |
63 | # Django stuff:
64 | *.log
65 | local_settings.py
66 | db.sqlite3
67 | db.sqlite3-journal
68 |
69 | # Flask stuff:
70 | instance/
71 | .webassets-cache
72 |
73 | # Scrapy stuff:
74 | .scrapy
75 |
76 | # Sphinx documentation
77 | docs/_build/
78 |
79 | # PyBuilder
80 | target/
81 |
82 | # Jupyter Notebook
83 | .ipynb_checkpoints
84 |
85 | # IPython
86 | profile_default/
87 | ipython_config.py
88 |
89 | # pyenv
90 | .python-version
91 |
92 | # pipenv
93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
96 | # install all needed dependencies.
97 | #Pipfile.lock
98 |
99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
100 | __pypackages__/
101 |
102 | # Celery stuff
103 | celerybeat-schedule
104 | celerybeat.pid
105 |
106 | # SageMath parsed files
107 | *.sage.py
108 |
109 | # Environments
110 | .env*
111 | .venv*
112 | env/
113 | venv/
114 | ENV/
115 | env.bak/
116 | venv.bak/
117 |
118 | # Spyder project settings
119 | .spyderproject
120 | .spyproject
121 |
122 | # Rope project settings
123 | .ropeproject
124 |
125 | # mkdocs documentation
126 | /site
127 |
128 | # mypy
129 | .mypy_cache/
130 | .dmypy.json
131 | dmypy.json
132 |
133 | # Pyre type checker
134 | .pyre/
135 |
136 | # IDE files
137 | .vscode/*
138 | .idea/*
--------------------------------------------------------------------------------
/changelog.config.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const mainTemplateStr = `{{> header}}
4 |
5 | {{#each commitGroups}}
6 |
7 | {{#if title}}
8 | ### {{title}}
9 |
10 | {{/if}}
11 | {{#each commits}}
12 | {{> commit root=@root}}
13 |
14 | {{/each}}
15 | {{/each}}
16 |
17 |
18 | `
19 |
20 | const headerTemplateStr = `{{#if isPatch~}}
21 | ##
22 | {{~else~}}
23 | #
24 | {{~/if}} {{#if @root.linkCompare~}}
25 | [{{version}}](
26 | {{~#if @root.repository~}}
27 | {{~#if @root.host}}
28 | {{~@root.host}}/
29 | {{~/if}}
30 | {{~#if @root.owner}}
31 | {{~@root.owner}}/
32 | {{~/if}}
33 | {{~@root.repository}}
34 | {{~else}}
35 | {{~@root.repoUrl}}
36 | {{~/if~}}
37 | /compare/{{previousTag}}...{{currentTag}})
38 | {{~else}}
39 | {{~version}}
40 | {{~/if}}
41 | {{~#if title}} "{{title}}"
42 | {{~/if}}
43 | {{~#if date}} ({{date}})
44 | {{/if}}`
45 |
46 | const commitTemplateStr = `*{{#if scope}} **{{scope}}:**
47 | {{~/if}} {{#if subject}}
48 | {{~subject}}
49 | {{~else}}
50 | {{~header}}
51 | {{~/if}}
52 |
53 | {{~!-- commit link --}} {{#if @root.linkReferences~}}
54 | ([{{hash}}](
55 | {{~#if @root.repository}}
56 | {{~#if @root.host}}
57 | {{~@root.host}}/
58 | {{~/if}}
59 | {{~#if @root.owner}}
60 | {{~@root.owner}}/
61 | {{~/if}}
62 | {{~@root.repository}}
63 | {{~else}}
64 | {{~@root.repoUrl}}
65 | {{~/if}}/
66 | {{~@root.commit}}/{{hash}}))
67 | {{~else}}
68 | {{~hash}}
69 | {{~/if}}
70 |
71 | {{~!-- commit references --}}
72 | {{~#if references~}}
73 | , closes
74 | {{~#each references}} {{#if @root.linkReferences~}}
75 | [
76 | {{~#if this.owner}}
77 | {{~this.owner}}/
78 | {{~/if}}
79 | {{~this.repository}}#{{this.issue}}](
80 | {{~#if @root.repository}}
81 | {{~#if @root.host}}
82 | {{~@root.host}}/
83 | {{~/if}}
84 | {{~#if this.repository}}
85 | {{~#if this.owner}}
86 | {{~this.owner}}/
87 | {{~/if}}
88 | {{~this.repository}}
89 | {{~else}}
90 | {{~#if @root.owner}}
91 | {{~@root.owner}}/
92 | {{~/if}}
93 | {{~@root.repository}}
94 | {{~/if}}
95 | {{~else}}
96 | {{~@root.repoUrl}}
97 | {{~/if}}/
98 | {{~@root.issue}}/{{this.issue}})
99 | {{~else}}
100 | {{~#if this.owner}}
101 | {{~this.owner}}/
102 | {{~/if}}
103 | {{~this.repository}}#{{this.issue}}
104 | {{~/if}}{{/each}}
105 | {{~/if}}`
106 |
107 | module.exports = {
108 | writerOpts: {
109 | mainTemplate: mainTemplateStr,
110 | headerPartial: headerTemplateStr,
111 | commitPartial: commitTemplateStr
112 | }
113 | }
--------------------------------------------------------------------------------
/src/labthings/quick.py:
--------------------------------------------------------------------------------
1 | from flask import Flask
2 | from flask_cors import CORS
3 |
4 | from .labthing import LabThing
5 |
6 |
7 | def create_app(
8 | import_name,
9 | prefix: str = "",
10 | title: str = "",
11 | description: str = "",
12 | types: list = None,
13 | version: str = "0.0.0",
14 | external_links: bool = True,
15 | handle_errors: bool = True,
16 | handle_cors: bool = True,
17 | flask_kwargs: dict = None,
18 | ):
19 | """Quick-create a LabThings-enabled Flask app
20 |
21 | :param import_name: Flask import name. Usually ``__name__``.
22 | :param prefix: URL prefix for all LabThings views. Defaults to "/api".
23 | :type prefix: str
24 | :param title: Title/name of the LabThings Thing.
25 | :type title: str
26 | :param description: Brief description of the LabThings Thing.
27 | :type description: str
28 | :param version: Version number/code of the Thing. Defaults to "0.0.0".
29 | :type version: str
30 | :param handle_errors: Use the LabThings error handler,
31 | to JSON format internal exceptions. Defaults to True.
32 | :type handle_errors: bool
33 | :param handle_cors: Automatically enable CORS on all LabThings views.
34 | Defaults to True.
35 | :type handle_cors: bool
36 | :param flask_kwargs: Keyword arguments to pass to the Flask instance.
37 | :type flask_kwargs: dict
38 | :param prefix: str: (Default value = "")
39 | :param title: str: (Default value = "")
40 | :param description: str: (Default value = "")
41 | :param types: list: (Default value = None)
42 | :param version: str: (Default value = "0.0.0")
43 | :param external_links: bool: Use external links in Thing Description where possible
44 | :param handle_errors: bool: (Default value = True)
45 | :param handle_cors: bool: (Default value = True)
46 | :param flask_kwargs: dict: (Default value = None)
47 | :returns: (Flask app object, LabThings object)
48 |
49 | """
50 | if types is None:
51 | types = []
52 | # Handle arguments
53 | if flask_kwargs is None:
54 | flask_kwargs = {}
55 |
56 | # Create Flask app
57 | app = Flask(import_name, **flask_kwargs)
58 | app.url_map.strict_slashes = False
59 |
60 | # Handle CORS
61 | if handle_cors:
62 | CORS(app, resources=f"{prefix}/*")
63 |
64 | # Create a LabThing
65 | labthing = LabThing(
66 | app,
67 | prefix=prefix,
68 | title=title,
69 | description=description,
70 | types=types,
71 | version=str(version),
72 | format_flask_exceptions=handle_errors,
73 | external_links=external_links,
74 | )
75 |
76 | return app, labthing
77 |
--------------------------------------------------------------------------------
/src/labthings/sync/event.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import threading
3 | import time
4 | from _thread import get_ident
5 |
6 |
7 | class ClientEvent(object):
8 | """An event-signaller object with per-client setting and waiting.
9 |
10 | A client can be any Greenlet or native Thread. This can be used, for example,
11 | to signal to clients that new data is available
12 |
13 |
14 | """
15 |
16 | def __init__(self):
17 | self.events = {}
18 | self._setting_lock = threading.Lock()
19 |
20 | def wait(self, timeout: int = 5):
21 | """Wait for the next data frame (invoked from each client's thread).
22 |
23 | :param timeout: int: (Default value = 5)
24 |
25 | """
26 | ident = get_ident()
27 | if ident not in self.events:
28 | # this is a new client
29 | # add an entry for it in the self.events dict
30 | # each entry has two elements, a threading.Event() and a timestamp
31 | self.events[ident] = [threading.Event(), time.time()]
32 |
33 | return self.events[ident][0].wait(timeout=timeout)
34 |
35 | def set(self, timeout=5):
36 | """Signal that a new frame is available.
37 |
38 | :param timeout: (Default value = 5)
39 |
40 | """
41 | with self._setting_lock:
42 | now = time.time()
43 | remove_keys = set()
44 | for event_key in list(self.events.keys()):
45 | if not self.events[event_key][0].is_set():
46 | # if this client's event is not set, then set it
47 | # also update the last set timestamp to now
48 | self.events[event_key][0].set()
49 | self.events[event_key][1] = now
50 | else:
51 | # if the client's event is already set, it means the client
52 | # did not process a previous frame
53 | # if the event stays set for more than `timeout` seconds, then
54 | # assume the client is gone and remove it
55 | if now - self.events[event_key][1] >= timeout:
56 | remove_keys.add(event_key)
57 | if remove_keys:
58 | for remove_key in remove_keys:
59 | del self.events[remove_key]
60 |
61 | def clear(self) -> bool:
62 | """Clear frame event, once processed."""
63 | ident = get_ident()
64 | if ident not in self.events:
65 | logging.error("Mismatched ident. Current: %s, available:", ident)
66 | logging.error(self.events.keys())
67 | return False
68 | self.events[get_ident()][0].clear()
69 | return True
70 |
--------------------------------------------------------------------------------
/src/labthings/default_views/docs/static/oauth2-redirect.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Swagger UI: OAuth2 Redirect
4 |
5 |
6 |
7 |
69 |
--------------------------------------------------------------------------------
/src/labthings/apispec/utilities.py:
--------------------------------------------------------------------------------
1 | from inspect import isclass
2 | from typing import Dict, Type, Union, cast
3 |
4 | from apispec import APISpec
5 | from apispec.ext.marshmallow import MarshmallowPlugin
6 | from marshmallow import Schema
7 |
8 | from .. import fields
9 |
10 |
11 | def field2property(spec: APISpec, field: fields.Field):
12 | """Convert a marshmallow Field to OpenAPI dictionary
13 |
14 | We require an initialised APISpec object to use its
15 | converter function - in particular, this will depend
16 | on the OpenAPI version defined in `spec`. We also rely
17 | on the spec having a `MarshmallowPlugin` attached.
18 | """
19 | plugin = get_marshmallow_plugin(spec)
20 | return plugin.converter.field2property(field)
21 |
22 |
23 | def ensure_schema(
24 | spec: APISpec,
25 | schema: Union[
26 | fields.Field,
27 | Type[fields.Field],
28 | Schema,
29 | Type[Schema],
30 | Dict[str, Union[fields.Field, type]],
31 | ],
32 | name: str = "GeneratedFromDict",
33 | ) -> Union[dict, Schema]:
34 | """Create a Schema object, or OpenAPI dictionary, given a Field, Schema, or Dict.
35 |
36 | The output from this function should be suitable to include in a dictionary
37 | that is passed to APISpec. Fields won't get processed by the Marshmallow
38 | plugin, and can't be converted to Schemas without adding a field name, so
39 | we convert them directly to the dictionary representation.
40 |
41 | Other Schemas are returned as Marshmallow Schema instances, which will be
42 | converted to references by the plugin.
43 |
44 | The first argument must be an initialised APISpec object, as the conversion
45 | of single fields to dictionaries is version-dependent.
46 | """
47 | if schema is None:
48 | return None
49 | if isinstance(schema, fields.Field):
50 | return field2property(spec, schema)
51 | elif isinstance(schema, dict):
52 | return Schema.from_dict(schema, name=name)()
53 | elif isinstance(schema, Schema):
54 | return schema
55 | if isclass(schema):
56 | schema = cast(Type, schema)
57 | if issubclass(schema, fields.Field):
58 | return field2property(spec, schema())
59 | elif issubclass(schema, Schema):
60 | return schema()
61 | raise TypeError(
62 | f"Invalid schema type {type(schema)}. Must be a Schema or Mapping/dict"
63 | )
64 |
65 |
66 | def get_marshmallow_plugin(spec):
67 | """Extract the marshmallow plugin object from an APISpec"""
68 | for p in spec.plugins:
69 | if isinstance(p, MarshmallowPlugin):
70 | return p
71 | raise AttributeError("The APISpec does not seem to have a Marshmallow plugin.")
72 |
--------------------------------------------------------------------------------
/tests/test_schema.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from labthings import fields, schema
4 | from labthings.actions.thread import ActionThread
5 | from labthings.extensions import BaseExtension
6 |
7 |
8 | def test_field_schema(app_ctx):
9 | test_schema = schema.FieldSchema(fields.String())
10 |
11 | assert test_schema.serialize(5) == "5"
12 | assert test_schema.dump(5) == "5"
13 | assert test_schema.deserialize("string") == "string"
14 |
15 |
16 | def test_extension_schema(app_ctx):
17 | test_schema = schema.ExtensionSchema()
18 | test_extension = BaseExtension("org.labthings.tests.extension")
19 |
20 | with app_ctx.test_request_context():
21 | d = test_schema.dump(test_extension)
22 | assert isinstance(d, dict)
23 | assert "pythonName" in d
24 | assert d.get("pythonName") == "org.labthings.tests.extension"
25 | assert "links" in d
26 | assert isinstance(d.get("links"), dict)
27 |
28 |
29 | def test_build_action_schema():
30 | input_schema = schema.Schema()
31 | output_schema = schema.Schema()
32 | action_schema = schema.build_action_schema(input_schema, output_schema)
33 |
34 | assert issubclass(action_schema, schema.ActionSchema)
35 | declared_fields = action_schema._declared_fields
36 | assert "input" in declared_fields
37 | assert "output" in declared_fields
38 |
39 |
40 | def test_build_action_schema_fields():
41 | input_schema = fields.Field()
42 | output_schema = fields.Field()
43 | action_schema = schema.build_action_schema(input_schema, output_schema)
44 |
45 | assert issubclass(action_schema, schema.ActionSchema)
46 | declared_fields = action_schema._declared_fields
47 | assert "input" in declared_fields
48 | assert "output" in declared_fields
49 |
50 |
51 | def test_build_action_schema_nones():
52 | input_schema = None
53 | output_schema = None
54 | action_schema = schema.build_action_schema(input_schema, output_schema)
55 |
56 | assert issubclass(action_schema, schema.ActionSchema)
57 | declared_fields = action_schema._declared_fields
58 | assert "input" in declared_fields
59 | assert "output" in declared_fields
60 |
61 |
62 | def test_build_action_schema_typeerror():
63 | input_schema = object()
64 | output_schema = object()
65 | with pytest.raises(TypeError):
66 | action_schema = schema.build_action_schema(input_schema, output_schema)
67 |
68 |
69 | def test_nest_if_needed():
70 | nested_schema = schema.nest_if_needed(schema.ActionSchema())
71 | assert isinstance(nested_schema, fields.Field)
72 | nested_dict = schema.nest_if_needed({"name": fields.Integer()})
73 | assert isinstance(nested_schema, fields.Field)
74 | nested_field = schema.nest_if_needed(fields.Boolean())
75 | assert isinstance(nested_schema, fields.Field)
76 |
--------------------------------------------------------------------------------
/tests/test_action_api.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | import time
4 |
5 | import pytest
6 |
7 | from labthings import LabThing
8 | from labthings.views import ActionView
9 |
10 |
11 | @pytest.mark.filterwarnings("ignore:Exception in thread")
12 | def test_action_exception_handling(thing_with_some_views, client):
13 | """Check errors in an Action are handled correctly
14 |
15 |
16 |
17 | `/FieldProperty` has a validation constraint - it
18 | should return a "bad response" error if invoked with
19 | anything other than
20 | """
21 | # `/FailAction` raises an `Exception`.
22 | # This ought to return a 201 code representing the
23 | # action that was successfully started - but should
24 | # show that it failed through the "status" field.
25 |
26 | # This is correct for the current (24/7/2021) behaviour
27 | # but may want to change for the next version, e.g.
28 | # returning a 500 code. For further discussion...
29 | r = client.post("/FailAction")
30 | assert r.status_code == 201
31 | action = r.get_json()
32 | assert action["status"] == "error"
33 |
34 |
35 | def test_action_abort(thing_with_some_views, client):
36 | """Check HTTPExceptions result in error codes.
37 |
38 | Subclasses of HTTPError should result in a non-200 return code, not
39 | just failures. This covers Marshmallow validation (400) and
40 | use of `abort()`.
41 | """
42 | # `/AbortAction` should return a 418 error code
43 | r = client.post("/AbortAction")
44 | assert r.status_code == 418
45 |
46 |
47 | @pytest.mark.filterwarnings("ignore:Exception in thread")
48 | def test_action_abort_late(thing_with_some_views, client, caplog):
49 | """Check HTTPExceptions raised late are just regular errors."""
50 | caplog.set_level(logging.ERROR)
51 | caplog.clear()
52 | r = client.post("/AbortAction", data=json.dumps({"abort_after": 0.2}))
53 | assert r.status_code == 201 # Should have started OK
54 | time.sleep(0.3)
55 | # Now check the status - should be error
56 | r2 = client.get(r.get_json()["links"]["self"]["href"])
57 | assert r2.get_json()["status"] == "error"
58 | # Check it was logged as well
59 | error_was_raised = False
60 | for r in caplog.records:
61 | if r.levelname == "ERROR" and "HTTPException" in r.message:
62 | error_was_raised = True
63 | assert error_was_raised
64 |
65 |
66 | def test_action_validate(thing_with_some_views, client):
67 | """Validation errors should result in 422 return codes."""
68 | # `/ActionWithValidation` should fail with a 400 error
69 | # if `test_arg` is not either `one` or `two`
70 | r = client.post("/ActionWithValidation", data=json.dumps({"test_arg": "one"}))
71 | assert r.status_code in [200, 201]
72 | assert r.get_json()["status"] == "completed"
73 | r = client.post("/ActionWithValidation", data=json.dumps({"test_arg": "three"}))
74 | assert r.status_code in [422]
75 |
--------------------------------------------------------------------------------
/tests/test_default_views.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from labthings.actions import current_action
4 | from labthings.find import current_labthing
5 |
6 |
7 | def test_docs(thing, thing_client, schemas_path):
8 |
9 | with thing_client as c:
10 | json_out = c.get("/docs/swagger").json
11 | assert "openapi" in json_out
12 | assert "paths" in json_out
13 | assert "info" in json_out
14 | assert c.get("/docs/swagger-ui").status_code == 200
15 |
16 |
17 | def test_extensions(thing_client):
18 | with thing_client as c:
19 | assert c.get("/extensions").json == []
20 |
21 |
22 | def test_actions_list(thing_client):
23 | def task_func():
24 | pass
25 |
26 | task_obj = current_labthing().actions.spawn("task_func", task_func)
27 |
28 | with thing_client as c:
29 | response = c.get("/actions").json
30 | ids = [task.get("id") for task in response]
31 | assert str(task_obj.id) in ids
32 |
33 |
34 | def test_action_representation(thing_client):
35 | def task_func():
36 | pass
37 |
38 | task_obj = current_labthing().actions.spawn("task_func", task_func)
39 | task_id = str(task_obj.id)
40 |
41 | with thing_client as c:
42 | response = c.get(f"/actions/{task_id}").json
43 | assert response
44 |
45 |
46 | def test_action_representation_missing(thing_client):
47 | with thing_client as c:
48 | assert c.get("/actions/missing_id").status_code == 404
49 |
50 |
51 | def test_action_stop(thing_client):
52 | def task_func():
53 | while not current_action().stopping:
54 | time.sleep(0)
55 |
56 | task_obj = current_labthing().actions.spawn("task_func", task_func)
57 | task_id = str(task_obj.id)
58 |
59 | # Wait for task to start
60 | task_obj.started.wait()
61 | assert task_id in current_labthing().actions.to_dict()
62 |
63 | # Send a DELETE request to terminate the task
64 | with thing_client as c:
65 | response = c.delete(f"/actions/{task_id}")
66 | assert response.status_code == 200
67 | # Test task was stopped
68 | assert task_obj._status == "cancelled"
69 |
70 |
71 | def test_action_terminate(thing_client):
72 | def task_func():
73 | while True:
74 | time.sleep(0)
75 |
76 | task_obj = current_labthing().actions.spawn("task_func", task_func)
77 | task_id = str(task_obj.id)
78 |
79 | # Wait for task to start
80 | task_obj.started.wait()
81 | assert task_id in current_labthing().actions.to_dict()
82 |
83 | # Send a DELETE request to terminate the task
84 | with thing_client as c:
85 | response = c.delete(f"/actions/{task_id}", json={"timeout": "0"})
86 | assert response.status_code == 200
87 | # Test task was stopped
88 | assert task_obj._status == "cancelled"
89 |
90 |
91 | def test_action_kill_missing(thing_client):
92 | with thing_client as c:
93 | assert c.delete("/actions/missing_id").status_code == 404
94 |
--------------------------------------------------------------------------------
/tests/test_marshalling_args.py:
--------------------------------------------------------------------------------
1 | from labthings import fields, views
2 | from labthings.marshalling.args import use_args, use_body
3 | from labthings.schema import Schema
4 |
5 |
6 | def test_use_body_string(app, client):
7 | class Index(views.MethodView):
8 | @use_body(fields.String(required=True))
9 | def post(self, args):
10 | return args
11 |
12 | app.add_url_rule("/", view_func=Index.as_view("index"))
13 |
14 | with client:
15 | res = client.post("/", data="string", content_type="application/json")
16 | assert res.data.decode() == "string"
17 |
18 |
19 | def test_use_body_no_data_error(app, client):
20 | class Index(views.MethodView):
21 | @use_body(fields.String(required=True))
22 | def post(self, args):
23 | return args
24 |
25 | app.add_url_rule("/", view_func=Index.as_view("index"))
26 |
27 | with client:
28 | res = client.post("/", content_type="application/json")
29 | assert res.status_code == 400
30 |
31 |
32 | def test_use_body_no_data_missing(app, client):
33 | class Index(views.MethodView):
34 | @use_body(fields.String(missing="default"))
35 | def post(self, args):
36 | return args
37 |
38 | app.add_url_rule("/", view_func=Index.as_view("index"))
39 |
40 | with client:
41 | res = client.post("/", content_type="application/json")
42 | assert res.data.decode() == "default"
43 |
44 |
45 | def test_use_body_validation_error(app, client):
46 | class Index(views.MethodView):
47 | @use_body(fields.String(required=True))
48 | def post(self, args):
49 | return args
50 |
51 | app.add_url_rule("/", view_func=Index.as_view("index"))
52 |
53 | with client:
54 | res = client.post("/", json={"foo": "bar"}, content_type="application/json")
55 | assert res.status_code == 400
56 |
57 |
58 | def test_use_args_field(app, client):
59 | class Index(views.MethodView):
60 | @use_args(fields.String(required=True))
61 | def post(self, args):
62 | return args
63 |
64 | app.add_url_rule("/", view_func=Index.as_view("index"))
65 |
66 | with client:
67 | res = client.post("/", data="string", content_type="application/json")
68 | assert res.data.decode() == "string"
69 |
70 |
71 | def test_use_args_map(app, client):
72 | class Index(views.MethodView):
73 | @use_args({"foo": fields.String(required=True)})
74 | def post(self, args):
75 | return args
76 |
77 | app.add_url_rule("/", view_func=Index.as_view("index"))
78 |
79 | with client:
80 | res = client.post("/", json={"foo": "bar"}, content_type="application/json")
81 | assert res.json == {"foo": "bar"}
82 |
83 |
84 | def test_use_args_schema(app, client):
85 | class TestSchema(Schema):
86 | foo = fields.String(required=True)
87 |
88 | class Index(views.MethodView):
89 | @use_args(TestSchema())
90 | def post(self, args):
91 | return args
92 |
93 | app.add_url_rule("/", view_func=Index.as_view("index"))
94 |
95 | with client:
96 | res = client.post("/", json={"foo": "bar"}, content_type="application/json")
97 | assert res.json == {"foo": "bar"}
98 |
--------------------------------------------------------------------------------
/src/labthings/default_views/actions.py:
--------------------------------------------------------------------------------
1 | from flask import abort
2 |
3 | from .. import fields
4 | from ..find import current_labthing
5 | from ..marshalling import use_args
6 | from ..schema import ActionSchema
7 | from ..views import View, described_operation
8 |
9 |
10 | class ActionQueueView(View):
11 | """List of all actions from the session"""
12 |
13 | @described_operation
14 | def get(self):
15 | """Action queue
16 |
17 | This endpoint returns a list of all actions that have run since
18 | the server was started, including ones that have completed and
19 | actions that are still running. Each entry includes links to
20 | manage and inspect that action.
21 | """
22 | return ActionSchema(many=True).dump(current_labthing().actions.threads)
23 |
24 | get.responses = {
25 | "200": {
26 | "description": "List of Action objects",
27 | "content": {"application/json": {"schema": ActionSchema(many=True)}},
28 | }
29 | }
30 |
31 |
32 | TASK_ID_PARAMETER = {
33 | "name": "task_id",
34 | "in": "path",
35 | "description": "The unique ID of the action",
36 | "required": True,
37 | "schema": {"type": "string"},
38 | "example": "eeae7ae9-0c0d-45a4-9ef2-7b84bb67a1d1",
39 | }
40 |
41 |
42 | class ActionObjectView(View):
43 | """Manage a particular action.
44 |
45 | GET will safely return the current action progress.
46 | DELETE will cancel the action, if pending or running.
47 |
48 |
49 | """
50 |
51 | parameters = [TASK_ID_PARAMETER]
52 |
53 | @described_operation
54 | def get(self, task_id):
55 | """Show the status of an Action
56 |
57 | A `GET` request will return the current status
58 | of an action, including logs. For completed
59 | actions, it will include the return value.
60 | """
61 | task_dict = current_labthing().actions.to_dict()
62 |
63 | if task_id not in task_dict:
64 | return abort(404) # 404 Not Found
65 |
66 | task = task_dict.get(task_id)
67 |
68 | return ActionSchema().dump(task)
69 |
70 | get.responses = {
71 | "200": {
72 | "description": "Action object",
73 | "content": {"application/json": {"schema": ActionSchema}},
74 | },
75 | "404": {"description": "Action not found"},
76 | }
77 |
78 | @described_operation
79 | @use_args({"timeout": fields.Int()})
80 | def delete(self, args, task_id):
81 | """Cancel a running Action
82 |
83 | A `DELETE` request will stop a running action.
84 | """
85 | timeout = args.get("timeout", None)
86 | task_dict = current_labthing().actions.to_dict()
87 |
88 | if task_id not in task_dict:
89 | return abort(404) # 404 Not Found
90 |
91 | task = task_dict.get(task_id)
92 | task.stop(timeout=timeout)
93 | return ActionSchema().dump(task)
94 |
95 | delete.responses = {
96 | "200": {
97 | "description": "Action object that was cancelled",
98 | "content": {"application/json": {"schema": ActionSchema}},
99 | },
100 | "404": {"description": "Action not found"},
101 | }
102 |
--------------------------------------------------------------------------------
/docs/basic_usage/action_threads.rst:
--------------------------------------------------------------------------------
1 | Action threads
2 | ==============
3 |
4 | Many actions in your LabThing may perform tasks that take a long time (compared to the expected response time of a web request). For example, if you were to implement a timelapse action, this inherently runs over a long time.
5 |
6 | This introduces a couple of problems. Firstly, a request that triggers a long function will, by default, block the Python interpreter for the duration of the function. This usually causes the connection to timeout, and the response will never be revieved.
7 |
8 | Action threads are introduced to manage long-running functions in a way that does not block HTTP requests. Any API Action will automatically run as a background thread.
9 |
10 | Internally, the :class:`labthings.LabThing` object stores a list of all requested actions, and their states. This state stores the running status of the action (if itis idle, running, error, or success), information about the start and end times, a unique ID, and, upon completion, the return value of the long-running function.
11 |
12 | By using threads, a function can be started in the background, and it's return value fetched at a later time once it has reported success. If a long-running action is started by some client, it should note the ID returned in the action state JSON, and use this to periodically check on the status of that particular action.
13 |
14 | API routes have been created to allow checking the state of all actions (GET ``/actions``), a particular action by ID (GET ``/actions/``), and terminating or removing individual actions (DELETE ``/actions/``).
15 |
16 | All actions will return a serialized representation of the action state when your POST request returns. If the action completes within a default timeout period (usually 1 second) then the completed action representation will be returned. If the action is still running after this timeout period, the "in-progress" action representation will be returned. The final output value can then be retrieved at a later time.
17 |
18 | Most users will not need to create instances of this class. Instead, they will be created automatically when a function is started by an API Action view.
19 |
20 | .. autoclass:: labthings.actions.ActionThread
21 | :noindex:
22 | :members:
23 |
24 | Accessing the current action thread
25 | +++++++++++++++++++++++++++++++++++
26 |
27 | A function running inside a :class:`labthings.actions.ActionThread` is able to access the instance it is running in using the :meth:`labthings.current_action` function. This allows the state of the Action to be modified freely.
28 |
29 | .. autofunction:: labthings.current_action
30 | :noindex:
31 |
32 |
33 | Updating action progress
34 | ++++++++++++++++++++++++
35 |
36 | Some client applications may be able to display progress bars showing the progress of an action. Implementing progress updates in your actions is made easy with the :py:meth:`labthings.update_action_progress` function. This function takes a single argument, which is the action progress as an integer percent (0 - 100).
37 |
38 | If your long running function was started within a :class:`labthings.actions.ActionThread`, this function will update the state of the corresponding :class:`labthings.actions.ActionThread` instance. If your function is called outside of an :class:`labthings.actions.ActionThread` (e.g. by some internal code, not the web API), then this function will silently do nothing.
39 |
40 | .. autofunction:: labthings.update_action_progress
41 | :noindex:
42 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at contact@labthings.org. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/tests/test_sync_event.py:
--------------------------------------------------------------------------------
1 | import threading
2 | import time
3 |
4 | from labthings.sync import event
5 |
6 |
7 | def test_clientevent_init():
8 | assert event.ClientEvent()
9 |
10 |
11 | def test_clientevent_greenlet_wait():
12 | e = event.ClientEvent()
13 |
14 | def g():
15 | # Wait for e.set()
16 | return e.wait()
17 |
18 | # Spawn thread
19 | thread = threading.Thread(target=g)
20 | thread.start()
21 | # Wait for e to notice greenlet is waiting for it
22 | while e.events == {}:
23 | time.sleep(0)
24 |
25 | # Assert greenlet is in the list of threads waiting for e
26 | assert thread.ident in e.events
27 |
28 | # Set e from main thread
29 | # Should cause greenlet to exit due to wait ending as event is set
30 | e.set()
31 | # Wait for greenlet to finish
32 | thread.join()
33 | ## Ensure greenlet successfully waited without timing out
34 | # assert greenlet.value == True
35 |
36 |
37 | def test_clientevent_greenlet_wait_timeout():
38 | e = event.ClientEvent()
39 |
40 | def g():
41 | # Wait for e.set(), but timeout immediately
42 | result = e.wait(timeout=0)
43 | return result
44 |
45 | # Spawn thread
46 | thread = threading.Thread(target=g)
47 | thread.start()
48 | # Wait for greenlet to finish without ever setting e
49 | thread.join()
50 |
51 |
52 | def test_clientevent_greenlet_wait_clear():
53 | e = event.ClientEvent()
54 |
55 | def g():
56 | # Wait for e.set()
57 | e.wait()
58 | # Clear e for this greenlet
59 | # This informs e that the greenlet is alive
60 | # and waiting for e to be set again
61 | return e.clear()
62 |
63 | # Spawn thread
64 | thread = threading.Thread(target=g)
65 | thread.start()
66 | # Wait for e to notice greenlet is waiting for it
67 | while e.events == {}:
68 | time.sleep(0)
69 |
70 | # Set e from main thread
71 | e.set()
72 | # Wait for greenlet to finish
73 | thread.join()
74 |
75 |
76 | def test_clientevent_greenlet_wait_clear_wrong_greenlet():
77 | e = event.ClientEvent()
78 |
79 | def g():
80 | return e.wait()
81 |
82 | # Spawn thread
83 | thread = threading.Thread(target=g)
84 | thread.start()
85 | # Wait for e to notice greenlet is waiting for it
86 | while e.events == {}:
87 | time.sleep(0)
88 |
89 | # Set e from main thread
90 | e.set()
91 | # Wait for greenlet to finish
92 | thread.join()
93 | # Try to clear() e from main thread
94 | # Should return False since main thread isn't registered as waiting for e
95 | assert e.clear() == False
96 |
97 |
98 | def test_clientevent_drop_client():
99 | e = event.ClientEvent()
100 |
101 | def g():
102 | # Wait for e.set()
103 | e.wait()
104 | # Exit without clearing
105 |
106 | # Spawn thread
107 | thread = threading.Thread(target=g)
108 | thread.start()
109 | # Wait for e to notice greenlet is waiting for it
110 | while e.events == {}:
111 | time.sleep(0)
112 |
113 | # Set e from main thread, causing the greenlet to exit
114 | e.set()
115 | # Wait for greenlet to finish
116 | thread.join()
117 | # Set e from main thread again, with immediate timeout
118 | # This means that if the client greenlet hasn't cleared the event
119 | # within 0 seconds, it will be assumed to have exited and dropped
120 | # from the internal event list
121 | e.set(timeout=0)
122 |
123 | # Assert that the exited greenlet was dropped from e
124 | assert thread.ident not in e.events
125 |
--------------------------------------------------------------------------------
/src/labthings/find.py:
--------------------------------------------------------------------------------
1 | import weakref
2 |
3 | from flask import current_app, url_for
4 |
5 | from .names import EXTENSION_NAME
6 |
7 | __all__ = [
8 | "current_app",
9 | "url_for",
10 | "current_labthing",
11 | "registered_extensions",
12 | "registered_components",
13 | "find_component",
14 | "find_extension",
15 | ]
16 |
17 |
18 | def current_labthing(app=None):
19 | """The LabThing instance handling current requests.
20 |
21 | Searches for a valid LabThing extension attached to the current Flask context.
22 |
23 | :param app: (Default value = None)
24 |
25 | """
26 | # We use _get_current_object so that Task threads can still
27 | # reach the Flask app object. Just using current_app returns
28 | # a wrapper, which breaks it's use in Task threads
29 | try:
30 | app = current_app._get_current_object() # pylint: disable=protected-access
31 | except RuntimeError:
32 | return None
33 | ext = app.extensions.get(EXTENSION_NAME, None)
34 | if isinstance(ext, weakref.ref):
35 | return ext()
36 | else:
37 | return ext
38 |
39 |
40 | def registered_extensions(labthing_instance=None):
41 | """Find all LabThings Extensions registered to a LabThing instance
42 |
43 | :param labthing_instance: LabThing instance to search for extensions.
44 | Defaults to current_labthing.
45 | :type labthing_instance: optional
46 | :returns: LabThing Extension objects
47 | :rtype: list
48 |
49 | """
50 | if not labthing_instance:
51 | labthing_instance = current_labthing()
52 |
53 | return getattr(labthing_instance, "extensions", {})
54 |
55 |
56 | def registered_components(labthing_instance=None):
57 | """Find all LabThings Components registered to a LabThing instance
58 |
59 | :param labthing_instance: LabThing instance to search for extensions.
60 | Defaults to current_labthing.
61 | :type labthing_instance: optional
62 | :returns: Python objects registered as LabThings components
63 | :rtype: list
64 |
65 | """
66 | if not labthing_instance:
67 | labthing_instance = current_labthing()
68 | return labthing_instance.components
69 |
70 |
71 | def find_component(component_name: str, labthing_instance=None):
72 | """Find a particular LabThings Component registered to a LabThing instance
73 |
74 | :param component_name: Fully qualified name of the component
75 | :type component_name: str
76 | :param labthing_instance: LabThing instance to search for the component.
77 | Defaults to current_labthing.
78 | :type labthing_instance: optional
79 | :returns: Python object registered as a component, or `None` if not found
80 |
81 | """
82 | if not labthing_instance:
83 | labthing_instance = current_labthing()
84 |
85 | if component_name in labthing_instance.components:
86 | return labthing_instance.components[component_name]
87 | else:
88 | return None
89 |
90 |
91 | def find_extension(extension_name: str, labthing_instance=None):
92 | """Find a particular LabThings Extension registered to a LabThing instance
93 |
94 | :param extension_name: Fully qualified name of the extension
95 | :type extension_name: str
96 | :param labthing_instance: LabThing instance to search for the extension.
97 | Defaults to current_labthing.
98 | :type labthing_instance: optional
99 | :returns: LabThings Extension object, or `None` if not found
100 |
101 | """
102 | if not labthing_instance:
103 | labthing_instance = current_labthing()
104 |
105 | if extension_name in labthing_instance.extensions:
106 | return labthing_instance.extensions[extension_name]
107 | else:
108 | return None
109 |
--------------------------------------------------------------------------------
/docs/core_concepts.rst:
--------------------------------------------------------------------------------
1 | Core Concepts
2 | =============
3 |
4 | LabThings is rooted in the `W3C Web of Things standards `_. Using IP networking in labs is not itself new, though perhaps under-used. However lack of proper standardisation has stiffled widespread adoption. LabThings, rather than try to introduce new competing standards, uses the architecture and terminology introduced by the W3C Web of Things. A full description of the core architecture can be found in the `Web of Things (WoT) Architecture `_ document. However, a brief outline of core terminology is given below.
5 |
6 | Web Thing
7 | ---------
8 |
9 | A Web Thing is defined as "an abstraction of a physical or a virtual entity whose metadata and interfaces are described by a WoT Thing description." For LabThings this corresponds to an individual lab instrument that exposes functionality available to the user via a web API, and that functionality is described by a Thing Description.
10 |
11 | LabThings automatically generates a Thing Description as you add functionality. The functionality you add falls into one of three categories of "interaction affordance": Properties, Actions, and Events.
12 |
13 | Properties
14 | ----------
15 |
16 | A Property is defined as "an Interaction Affordance that exposes the state of the Thing. The state exposed by a Property MUST be retrievable (readable). Optionally, the state exposed by a Property MAY be updated (writeable)."
17 |
18 | As a rule of thumb, any attribute of your device that can be quickly read, or optionally written, should be a Property. For example, simple device settings, or one-shot measurements such as temperature.
19 |
20 | Actions
21 | -------
22 |
23 | An Action is defined as "an Interaction Affordance that allows to invoke a function of the Thing. An Action MAY manipulate state that is not directly exposed (cf. Properties), manipulate multiple Properties at a time, or manipulate Properties based on internal logic (e.g., toggle). Invoking an Action MAY also trigger a process on the Thing that manipulates state (including physical state through actuators) over time."
24 |
25 | The key point here is that Actions are typically more complex in functionality than simply setting or getting a property. For example, they can set multiple properties simultaneously (for example, auto-exposing a camera), or they can manipulate the state of the Thing over time, for example starting a long-running data acquisition.
26 |
27 | Python-LabThings automatically handles offloading Actions into background threads where appropriate. The Action has a short timeout to complete quickly and respond to the API request, after which time a ``201 - Started`` response is sent to the client with information on how to check the state of the Action at a later time. This is particularly useful for long-running data acquisitions, as it allows users to start an acquisition and get immediate confirmation that it has started, after which point they can poll for changes in status.
28 |
29 | Events
30 | ------
31 |
32 | An event "describes an event source that pushes data asynchronously from the Thing to the Consumer. Here not state, but state transitions (i.e., events) are communicated. Events MAY be triggered through conditions that are not exposed as Properties."
33 |
34 | Common examples are notifying clients when a Property is changed, or when an Action starts or finishes. However, Thing developers can introduce new Events such as warnings, status messages, and logs. For example, a device may emit an events when the internal temperature gets too high, or when an interlock is tripped. This Event can then be pushed to both users AND other Things, allowing automtic response to external conditions.
35 |
36 | A good example of this might be having Things automatically pause data-acquisition Actions upon detection of an overheat or interlock Event from another Thing.
--------------------------------------------------------------------------------
/src/labthings/json/schemas.py:
--------------------------------------------------------------------------------
1 | import re
2 | from typing import Any, Dict, List, Optional, Union
3 |
4 | import werkzeug.routing
5 | from marshmallow import Schema, fields
6 |
7 | from .marshmallow_jsonschema import JSONSchema
8 |
9 | PATH_RE = re.compile(r"<(?:[^:<>]+:)?([^<>]+)>")
10 | # Conversion map of werkzeug rule converters to Javascript schema types
11 | CONVERTER_MAPPING = {
12 | werkzeug.routing.UnicodeConverter: ("string", None),
13 | werkzeug.routing.IntegerConverter: ("integer", "int32"),
14 | werkzeug.routing.FloatConverter: ("number", "float"),
15 | }
16 |
17 | DEFAULT_TYPE = ("string", None)
18 |
19 |
20 | def rule_to_path(rule) -> str:
21 | """Convert a Flask rule into an JSON schema formatted URL path
22 |
23 | :param rule: Flask rule object
24 | :returns: URL path
25 | :rtype: str
26 |
27 | """
28 | return PATH_RE.sub(r"{\1}", rule.rule)
29 |
30 |
31 | def rule_to_params(rule: werkzeug.routing.Rule, overrides=None) -> List[Dict[str, Any]]:
32 | """Convert a Flask rule into JSON schema URL parameters description
33 |
34 | :param rule: Flask rule object
35 | :param overrides: Optional dictionary to override params with (Default value = None)
36 | :type overrides: dict
37 | :returns: Dictionary of URL parameters
38 | :rtype: dict
39 |
40 | """
41 | overrides = overrides or {}
42 | result = [
43 | argument_to_param(argument, rule, overrides.get(argument, {}))
44 | for argument in rule.arguments
45 | ]
46 | for key in overrides.keys():
47 | if overrides[key].get("in") in ("header", "query"):
48 | overrides[key]["name"] = overrides[key].get("name", key)
49 | result.append(overrides[key])
50 | return result
51 |
52 |
53 | def argument_to_param(
54 | argument: str,
55 | rule: werkzeug.routing.Rule,
56 | override: Optional[Dict[str, str]] = None,
57 | ) -> Dict[str, Any]:
58 | """Convert a Flask rule into APISpec URL parameters description
59 |
60 | :param argument: URL argument
61 | :type argument: str
62 | :param rule: Flask rule object
63 | :param override: Optional dictionary to override params with (Default value = None)
64 | :type override: dict
65 | :returns: Dictionary of URL parameter description
66 | :rtype: dict
67 |
68 | """
69 | param: Dict[str, Any] = {"in": "path", "name": argument, "required": True}
70 | type_, format_ = CONVERTER_MAPPING.get(
71 | # pylint: disable=protected-access
72 | type(rule._converters[argument]), # type: ignore
73 | DEFAULT_TYPE,
74 | )
75 | param["schema"] = {}
76 | param["schema"]["type"] = type_
77 | if format_ is not None:
78 | param["format"] = format_
79 | if rule.defaults and argument in rule.defaults:
80 | param["default"] = rule.defaults[argument]
81 | param.update(override or {})
82 | return param
83 |
84 |
85 | def field_to_property(field: fields.Field):
86 | """
87 |
88 | :param field:
89 |
90 | """
91 | # pylint: disable=protected-access
92 | return JSONSchema()._get_schema_for_field(Schema(), field)
93 |
94 |
95 | def schema_to_json(
96 | schema: Union[fields.Field, Schema, Dict[str, Union[fields.Field, type]]]
97 | ) -> dict:
98 | """
99 |
100 | :param schema:
101 |
102 | """
103 | if schema is None:
104 | return None
105 | if isinstance(schema, fields.Field):
106 | return field_to_property(schema)
107 | elif isinstance(schema, dict):
108 | return JSONSchema().dump(Schema.from_dict(schema)())
109 | elif isinstance(schema, Schema):
110 | return JSONSchema().dump(schema)
111 | else:
112 | raise TypeError(
113 | f"Invalid schema type {type(schema)}. Must be a Schema or Mapping/dict"
114 | )
115 |
--------------------------------------------------------------------------------
/tests/test_sync_lock.py:
--------------------------------------------------------------------------------
1 | import threading
2 |
3 | import pytest
4 |
5 | from labthings.sync import lock
6 |
7 | # Fixtures
8 |
9 |
10 | @pytest.fixture(
11 | params=["StrictLock", "CompositeLock"],
12 | )
13 | def this_lock(request):
14 | # Create a fresh lock for each test
15 | if request.param == "StrictLock":
16 | return lock.StrictLock()
17 | elif request.param == "CompositeLock":
18 | return lock.CompositeLock([lock.StrictLock(), lock.StrictLock()])
19 | return request.param
20 |
21 |
22 | # RLock
23 |
24 |
25 | def test_rlock_acquire(this_lock):
26 | # Assert no owner
27 | assert not this_lock.locked()
28 |
29 | # Acquire lock
30 | assert this_lock.acquire()
31 | # Assert owner
32 | assert this_lock._is_owned()
33 |
34 | # Release lock
35 | this_lock.release()
36 |
37 | # Release lock, assert not held
38 | assert not this_lock.locked()
39 |
40 |
41 | def test_rlock_entry(this_lock):
42 | # Acquire lock
43 | with this_lock:
44 | # Assert owner
45 | assert this_lock._is_owned()
46 |
47 | # Release lock, assert no owner
48 | assert not this_lock.locked()
49 |
50 |
51 | def test_rlock_reentry(this_lock):
52 | # Acquire lock
53 | with this_lock:
54 | # Assert owner
55 | assert this_lock._is_owned()
56 | # Assert acquirable
57 | with this_lock as acquired_return:
58 | assert acquired_return
59 | # Assert still owned
60 | assert this_lock._is_owned()
61 |
62 | # Release lock, assert no owner
63 | assert not this_lock.locked()
64 |
65 |
66 | def test_rlock_block(this_lock):
67 | def g():
68 | this_lock.acquire()
69 |
70 | # Spawn thread
71 | thread = threading.Thread(target=g)
72 | thread.start()
73 |
74 | # Assert not owner
75 | assert not this_lock._is_owned()
76 |
77 | # Assert acquisition fails
78 | with pytest.raises(lock.LockError):
79 | this_lock.acquire(blocking=True, timeout=0)
80 |
81 | # Ensure an unheld lock cannot be released
82 | with pytest.raises(RuntimeError):
83 | this_lock.release()
84 |
85 |
86 | def test_rlock_acquire_timeout_pass(this_lock):
87 | assert not this_lock.locked()
88 |
89 | # Assert acquisition fails using context manager
90 | with this_lock(timeout=-1) as result:
91 | assert result is True
92 |
93 | assert not this_lock.locked()
94 |
95 |
96 | def test_rlock_acquire_timeout_fail(this_lock):
97 | def g():
98 | this_lock.acquire()
99 |
100 | # Spawn thread
101 | thread = threading.Thread(target=g)
102 | thread.start()
103 |
104 | # Assert not owner
105 | assert not this_lock._is_owned()
106 |
107 | # Assert acquisition fails using context manager
108 | with pytest.raises(lock.LockError):
109 | with this_lock(timeout=0.01):
110 | pass
111 |
112 | class DummyException(Exception):
113 | pass
114 |
115 | def test_rlock_released_after_error_args(this_lock):
116 | """If an exception occurs in a with block, the lock should release.
117 |
118 | NB there are two sets of code that do this - one if arguments are
119 | given (i.e. the __call__ method of the lock class) and one without
120 | arguments (i.e. the __enter__ and __exit__ methods).
121 |
122 | See the following function for the no-arguments version.
123 | """
124 | try:
125 | with this_lock():
126 | assert this_lock.locked()
127 | raise DummyException()
128 | except DummyException:
129 | pass
130 | assert not this_lock.locked()
131 |
132 | def test_rlock_released_after_error_noargs(this_lock):
133 | """If an exception occurs in a with block, the lock should release."""
134 | try:
135 | with this_lock:
136 | assert this_lock.locked()
137 | raise DummyException()
138 | except DummyException:
139 | pass
140 | assert not this_lock.locked()
--------------------------------------------------------------------------------
/examples/simple_thing.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import logging
4 |
5 | from labthings import ActionView, PropertyView, create_app, fields, find_component, op
6 | from labthings.example_components import PretendSpectrometer
7 |
8 | """
9 | Class for our lab component functionality. This could include serial communication,
10 | equipment API calls, network requests, or a "virtual" device as seen here.
11 | """
12 |
13 |
14 | """
15 | Create a view to view and change our integration_time value,
16 | and register is as a Thing property
17 | """
18 |
19 |
20 | # Wrap in a semantic annotation to autmatically set schema and args
21 | class DenoiseProperty(PropertyView):
22 | """Value of integration_time"""
23 |
24 | schema = fields.Int(required=True, minimum=100, maximum=500)
25 | semtype = "LevelProperty"
26 |
27 | @op.readproperty
28 | def get(self):
29 | # When a GET request is made, we'll find our attached component
30 | my_component = find_component("org.labthings.example.mycomponent")
31 | return my_component.integration_time
32 |
33 | @op.writeproperty
34 | def put(self, new_property_value):
35 | # Find our attached component
36 | my_component = find_component("org.labthings.example.mycomponent")
37 |
38 | # Apply the new value
39 | my_component.integration_time = new_property_value
40 |
41 | return my_component.integration_time
42 |
43 |
44 | """
45 | Create a view to quickly get some noisy data, and register is as a Thing property
46 | """
47 |
48 |
49 | class QuickDataProperty(PropertyView):
50 | """Show the current data value"""
51 |
52 | # Marshal the response as a list of floats
53 | schema = fields.List(fields.Float())
54 |
55 | @op.readproperty
56 | def get(self):
57 | # Find our attached component
58 | my_component = find_component("org.labthings.example.mycomponent")
59 | return my_component.data
60 |
61 |
62 | """
63 | Create a view to start an averaged measurement, and register is as a Thing action
64 | """
65 |
66 |
67 | class MeasurementAction(ActionView):
68 | # Expect JSON parameters in the request body.
69 | # Pass to post function as dictionary argument.
70 | args = {
71 | "averages": fields.Integer(
72 | missing=20,
73 | example=20,
74 | description="Number of data sets to average over",
75 | )
76 | }
77 | # Marshal the response as a list of numbers
78 | schema = fields.List(fields.Number)
79 |
80 | # Main function to handle POST requests
81 | @op.invokeaction
82 | def post(self, args):
83 | """Start an averaged measurement"""
84 | logging.warning("Starting a measurement")
85 |
86 | # Find our attached component
87 | my_component = find_component("org.labthings.example.mycomponent")
88 |
89 | # Get arguments and start a background task
90 | n_averages = args.get("averages")
91 |
92 | logging.warning("Finished a measurement")
93 |
94 | # Return the task information
95 | return my_component.average_data(n_averages)
96 |
97 |
98 | # Create LabThings Flask app
99 | app, labthing = create_app(
100 | __name__,
101 | title="My Lab Device API",
102 | description="Test LabThing-based API",
103 | version="0.1.0",
104 | )
105 |
106 | # Attach an instance of our component
107 | # Usually a Python object controlling some piece of hardware
108 | my_spectrometer = PretendSpectrometer()
109 | labthing.add_component(my_spectrometer, "org.labthings.example.mycomponent")
110 |
111 |
112 | # Add routes for the API views we created
113 | labthing.add_view(DenoiseProperty, "/integration_time")
114 | labthing.add_view(QuickDataProperty, "/quick-data")
115 | labthing.add_view(MeasurementAction, "/actions/measure")
116 |
117 |
118 | # Start the app
119 | if __name__ == "__main__":
120 | from labthings import Server
121 |
122 | Server(app).run()
123 |
--------------------------------------------------------------------------------
/tests/test_extensions.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import pytest
4 |
5 | from labthings import extensions
6 |
7 |
8 | @pytest.fixture
9 | def lt_extension():
10 | return extensions.BaseExtension("org.labthings.tests.extension")
11 |
12 |
13 | def test_extension_init(lt_extension):
14 | assert lt_extension
15 | assert lt_extension.name
16 |
17 |
18 | def test_add_view(lt_extension, app, view_cls):
19 | lt_extension.add_view(view_cls, "/index", endpoint="index")
20 |
21 | assert "index" in lt_extension.views
22 | assert lt_extension.views.get("index") == {
23 | "urls": ["/org.labthings.tests.extension/index"],
24 | "view": view_cls,
25 | "kwargs": {},
26 | }
27 |
28 |
29 | def test_on_register(lt_extension):
30 | def f(arg, kwarg=1):
31 | pass
32 |
33 | lt_extension.on_register(f, args=(1,), kwargs={"kwarg": 0})
34 | assert {
35 | "function": f,
36 | "args": (1,),
37 | "kwargs": {"kwarg": 0},
38 | } in lt_extension._on_registers
39 |
40 |
41 | def test_on_register_non_callable(lt_extension):
42 | with pytest.raises(TypeError):
43 | lt_extension.on_register(object())
44 |
45 |
46 | def test_on_component(lt_extension):
47 | def f():
48 | pass
49 |
50 | lt_extension.on_component("org.labthings.tests.component", f)
51 | assert {
52 | "component": "org.labthings.tests.component",
53 | "function": f,
54 | "args": (),
55 | "kwargs": {},
56 | } in lt_extension._on_components
57 |
58 |
59 | def test_on_component_non_callable(lt_extension):
60 | with pytest.raises(TypeError):
61 | lt_extension.on_component("org.labthings.tests.component", object())
62 |
63 |
64 | def test_meta_simple(lt_extension):
65 | lt_extension.add_meta("key", "value")
66 | assert lt_extension.meta.get("key") == "value"
67 |
68 |
69 | def test_meta_callable(lt_extension):
70 | def f():
71 | return "callable value"
72 |
73 | lt_extension.add_meta("key", f)
74 | assert lt_extension.meta.get("key") == "callable value"
75 |
76 |
77 | def test_add_method(lt_extension):
78 | def f():
79 | pass
80 |
81 | lt_extension.add_method(
82 | f,
83 | "method_name",
84 | )
85 | assert lt_extension.method_name == f
86 |
87 |
88 | def test_add_method_name_clash(lt_extension):
89 | def f():
90 | pass
91 |
92 | lt_extension.add_method(
93 | f,
94 | "method_name",
95 | )
96 | assert lt_extension.method_name == f
97 |
98 | with pytest.raises(NameError):
99 | lt_extension.add_method(
100 | f,
101 | "method_name",
102 | )
103 |
104 |
105 | def test_find_instances_in_module(lt_extension):
106 | mod = type(
107 | "mod",
108 | (object,),
109 | {"extension_instance": lt_extension, "another_object": object()},
110 | )
111 | assert extensions.find_instances_in_module(mod, extensions.BaseExtension) == [
112 | lt_extension
113 | ]
114 |
115 |
116 | def test_find_extensions_in_file(extensions_path):
117 | test_file = os.path.join(extensions_path, "extension.py")
118 |
119 | found_extensions = extensions.find_extensions_in_file(test_file)
120 | assert len(found_extensions) == 1
121 | assert found_extensions[0].name == "org.labthings.tests.extension"
122 |
123 |
124 | def test_find_extensions_in_file_explicit_list(extensions_path):
125 | test_file = os.path.join(extensions_path, "extension_explicit_list.py")
126 |
127 | found_extensions = extensions.find_extensions_in_file(test_file)
128 | assert len(found_extensions) == 1
129 | assert found_extensions[0].name == "org.labthings.tests.extension"
130 |
131 |
132 | def test_find_extensions_in_file_exception(extensions_path):
133 | test_file = os.path.join(extensions_path, "extension_exception.py")
134 |
135 | found_extensions = extensions.find_extensions_in_file(test_file)
136 | assert found_extensions == []
137 |
138 |
139 | def test_find_extensions(extensions_path):
140 | found_extensions = extensions.find_extensions(extensions_path)
141 | assert len(found_extensions) == 3
142 |
--------------------------------------------------------------------------------
/docs/basic_usage/serialising.rst:
--------------------------------------------------------------------------------
1 | Marshalling and Serialising views
2 | =================================
3 |
4 | Introduction
5 | ------------
6 |
7 | LabThings makes use of the `Marshmallow library `_ for both response and argument marshaling. From the Marshmallow documentation:
8 |
9 | **marshmallow** is an ORM/ODM/framework-agnostic library for converting complex datatypes, such as objects, to and from native Python datatypes.
10 |
11 | In short, marshmallow schemas can be used to:
12 |
13 | - **Validate** input data.
14 | - **Deserialize** input data to app-level objects.
15 | - **Serialize** app-level objects to primitive Python types. The serialized objects can then be rendered to standard formats such as JSON for use in an HTTP API.
16 |
17 | Marshalling schemas are used by LabThings to document the data types of properties, as well as the structure and types of Action arguments and return values. They allow arbitrary Python objects to be returned as serialized JSON, and ensure that input arguments are properly formated before being passed to your Python functions.
18 |
19 | From our quickstart example, we use schemas for our `integration_time` property to inform LabThings that both responses *and* requests to the API should be integer formatted. Additional information about range, example values, and units can be added to the schema field.
20 |
21 | .. code-block:: python
22 |
23 | labthing.build_property(
24 | my_spectrometer, # Python object
25 | "integration_time", # Objects attribute name
26 | description="Single-shot integration time",
27 | schema=fields.Int(min=100, max=500, example=200, unit="microsecond")
28 | )
29 |
30 | Actions require separate schemas for input and output, since the action return data is likely in a different format to the input arguments. In our quickstart example, our `schema` argument informs LabThings that the action return value should be a list of numbers. Meanwhile, our `args` argument informs LabThings that requests to start the action should include an attribute called `n`, which should be an integer. Human-readable descriptions, examples, and default values can be added to the args field.
31 |
32 | .. code-block:: python
33 |
34 | labthing.build_action(
35 | my_spectrometer, # Python object
36 | "average_data", # Objects method name
37 | description="Take an averaged measurement",
38 | schema=fields.List(fields.Number()),
39 | args={ # How do we convert from the request input to function arguments?
40 | "n": fields.Int(description="Number of averages to take", example=5, default=5)
41 | },
42 | )
43 |
44 |
45 | Schemas
46 | -------
47 |
48 | A schema is a collection of keys and fields describing how an object should be serialized/deserialized. Schemas can be created in several ways, either by creating a :class:`labthings.Schema` class, or by passing a dictionary of key-field pairs.
49 |
50 | Note that the :class:`labthings.Schema` class is an alias of :class:`marshmallow.Schema`, and the two can be used interchangeably.
51 |
52 | Schemas are required for argument parsing. While Property views can be marshalled with a single field, arguments must be passed to the server as a JSON object, which gets mapped to a Python dictionary and passed to the Action method.
53 |
54 | For example, a Python function of the form:
55 |
56 | .. code-block:: python
57 |
58 | from typing import List
59 |
60 | def my_function(quantity: int, name: str, organizations: List(str)):
61 | return quantity * len(organizations)
62 |
63 | would require `args` of the form:
64 |
65 | .. code-block:: python
66 |
67 | args = {
68 | "quantity": fields.Int()
69 | "name": fields.String()
70 | "organisation": fields.List(fields.String())
71 | }
72 |
73 | and a `schema` of :class:`labthings.fields.Int`.
74 |
75 |
76 | Fields
77 | ------
78 |
79 | Most data types are represented by fields in the Marshmallow library. All Marshmallow fields are imported and available from the :mod:`labthings.fields` submodule, however any field can be imported from Marshmallow and used in LabThings schemas.
80 |
81 | .. automodule:: labthings.fields
82 | :members:
83 | :undoc-members:
84 |
--------------------------------------------------------------------------------
/examples/nested_thing.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import math
3 | import random
4 | import uuid
5 |
6 | from labthings.server import fields
7 | from labthings.server.find import find_component
8 | from labthings.server.quick import create_app
9 | from labthings.server.schema import Schema
10 | from labthings.server.view import PropertyView
11 |
12 | """
13 | Class for our lab component functionality. This could include serial communication,
14 | equipment API calls, network requests, or a "virtual" device as seen here.
15 | """
16 |
17 |
18 | class DataSet:
19 | def __init__(self, x_values, y_values):
20 | self.xs = x_values
21 | self.ys = y_values
22 |
23 |
24 | class DataSetSchema(Schema):
25 | xs = fields.List(fields.Number())
26 | ys = fields.List(fields.Number())
27 |
28 |
29 | class MyComponent:
30 | def __init__(self):
31 | self.id = uuid.uuid4() # skipcq: PYL-C0103
32 | self.x_range = range(-100, 100)
33 | self.magic_denoise = 200
34 |
35 | def noisy_pdf(self, x_value, mu=0.0, sigma=25.0):
36 | """
37 | Generate a noisy gaussian function (to act as some pretend data)
38 |
39 | Our noise is inversely proportional to self.magic_denoise
40 | """
41 | x_value = float(x_value - mu) / sigma
42 | return (
43 | math.exp(-x_value * x_value / 2.0) / math.sqrt(2.0 * math.pi) / sigma
44 | + (1 / self.magic_denoise) * random.random()
45 | )
46 |
47 | @property
48 | def data(self):
49 | """Return a 1D data trace."""
50 | return DataSet(self.x_range, [self.noisy_pdf(x) for x in self.x_range])
51 |
52 |
53 | class MyComponentSchema(Schema):
54 | id = fields.UUID()
55 | magic_denoise = fields.Int()
56 | data = fields.Nested(DataSetSchema())
57 |
58 |
59 | """
60 | Create a view to view and change our magic_denoise value,
61 | and register is as a Thing property
62 | """
63 |
64 |
65 | class DenoiseProperty(PropertyView):
66 |
67 | schema = fields.Integer(
68 | required=True,
69 | example=200,
70 | minimum=100,
71 | maximum=500,
72 | description="Value of magic_denoise",
73 | )
74 |
75 | # Main function to handle GET requests (read)
76 | def get(self):
77 | """Show the current magic_denoise value"""
78 |
79 | # When a GET request is made, we'll find our attached component
80 | my_component = find_component("org.labthings.example.mycomponent")
81 | return my_component.magic_denoise
82 |
83 | # Main function to handle POST requests (write)
84 | def post(self, new_property_value):
85 | """Change the current magic_denoise value"""
86 |
87 | # Find our attached component
88 | my_component = find_component("org.labthings.example.mycomponent")
89 |
90 | # Apply the new value
91 | my_component.magic_denoise = new_property_value
92 |
93 | return my_component.magic_denoise
94 |
95 |
96 | """
97 | Create a view to quickly get some noisy data, and register is as a Thing property
98 | """
99 |
100 |
101 | class MyComponentProperty(PropertyView):
102 | # Main function to handle GET requests
103 |
104 | schema = MyComponentSchema()
105 |
106 | def get(self):
107 | """Show the current data value"""
108 |
109 | # Find our attached component
110 | return find_component("org.labthings.example.mycomponent")
111 |
112 |
113 | # Create LabThings Flask app
114 | app, labthing = create_app(
115 | __name__,
116 | prefix="/api",
117 | title="My Lab Device API",
118 | description="Test LabThing-based API",
119 | version="0.1.0",
120 | )
121 |
122 | # Attach an instance of our component
123 | labthing.add_component(MyComponent(), "org.labthings.example.mycomponent")
124 |
125 | # Add routes for the API views we created
126 | labthing.add_view(DenoiseProperty, "/denoise")
127 | labthing.add_view(MyComponentProperty, "/component")
128 |
129 |
130 | # Start the app
131 | if __name__ == "__main__":
132 | from labthings.server.wsgi import Server
133 |
134 | logger = logging.getLogger()
135 | logger.setLevel(logging.DEBUG)
136 |
137 | server = Server(app)
138 | server.run()
139 |
--------------------------------------------------------------------------------
/tests/test_openapi.py:
--------------------------------------------------------------------------------
1 | """Test OpenAPI spec generation
2 |
3 | This file tests the OpenAPI spec generated by LabThings.
4 | NB in order to avoid duplicating the examples, OpenAPI spec validation is also
5 | done in test_td.py.
6 | """
7 |
8 | import apispec
9 | import pytest
10 | import yaml
11 | from apispec.ext.marshmallow import MarshmallowPlugin
12 | from apispec.utils import validate_spec
13 |
14 | from labthings import fields, schema
15 | from labthings.actions.thread import ActionThread
16 | from labthings.apispec import utilities
17 | from labthings.extensions import BaseExtension
18 | from labthings.schema import LogRecordSchema, Schema
19 | from labthings.utilities import get_by_path
20 | from labthings.views import ActionView, EventView, PropertyView
21 |
22 |
23 | def test_openapi(thing_with_some_views):
24 | """Make an example Thing and check its openapi description validates"""
25 |
26 | thing_with_some_views.spec.to_yaml()
27 | thing_with_some_views.spec.to_dict()
28 | validate_spec(thing_with_some_views.spec)
29 |
30 |
31 | def test_duplicate_action_name(thing_with_some_views):
32 | """Check that name clashes don't overwrite schemas"""
33 | t = thing_with_some_views
34 |
35 | class TestAction(ActionView):
36 | args = {"m": fields.Integer()}
37 |
38 | def post(self):
39 | return "POST"
40 |
41 | with pytest.warns(UserWarning):
42 | t.add_view(TestAction, "TestActionM", endpoint="TestActionM")
43 |
44 | # We should have two actions with the same name
45 | actions_named_testaction = 0
46 | for v in t._action_views.values():
47 | if v.__name__ == "TestAction":
48 | actions_named_testaction += 1
49 | assert actions_named_testaction >= 2
50 |
51 | api = t.spec.to_dict()
52 | original_input_schema = get_by_path(api, ["paths", "/TestAction", "post"])
53 | modified_input_schema = get_by_path(api, ["paths", "/TestActionM", "post"])
54 | assert original_input_schema != modified_input_schema
55 |
56 |
57 | def test_ensure_schema_field_instance(spec):
58 | ret = utilities.ensure_schema(spec, fields.Integer())
59 | assert ret == {"type": "integer"}
60 |
61 |
62 | def test_ensure_schema_nullable_field_instance(spec):
63 | ret = utilities.ensure_schema(spec, fields.Integer(allow_none=True))
64 | assert ret == {"type": "integer", "nullable": True}
65 |
66 |
67 | def test_ensure_schema_field_class(spec):
68 | ret = utilities.ensure_schema(spec, fields.Integer)
69 | assert ret == {"type": "integer"}
70 |
71 |
72 | def test_ensure_schema_class(spec):
73 | ret = utilities.ensure_schema(spec, LogRecordSchema)
74 | assert isinstance(ret, Schema)
75 |
76 |
77 | def test_ensure_schema_instance(spec):
78 | ret = utilities.ensure_schema(spec, LogRecordSchema())
79 | assert isinstance(ret, Schema)
80 |
81 |
82 | def test_ensure_schema_dict(spec):
83 | ret = utilities.ensure_schema(
84 | spec,
85 | {
86 | "count": fields.Integer(),
87 | "name": fields.String(),
88 | },
89 | )
90 | assert isinstance(ret, Schema)
91 |
92 |
93 | def test_ensure_schema_none(spec):
94 | assert utilities.ensure_schema(spec, None) is None
95 |
96 |
97 | def test_ensure_schema_error(spec):
98 | with pytest.raises(TypeError):
99 | utilities.ensure_schema(spec, Exception)
100 |
101 |
102 | def test_get_marshmallow_plugin(spec):
103 | p = utilities.get_marshmallow_plugin(spec)
104 | assert isinstance(p, MarshmallowPlugin)
105 |
106 |
107 | def test_get_marchmallow_plugin_empty():
108 | spec = apispec.APISpec("test", "0", "3.0")
109 | with pytest.raises(Exception):
110 | utilities.get_marshmallow_plugin(spec)
111 |
112 |
113 | def dict_is_openapi(d):
114 | for k in ["paths", "components"]:
115 | assert k in d.keys()
116 | assert d["openapi"].startswith("3.0")
117 | return True
118 |
119 |
120 | def test_openapi_json_endpoint(thing, client):
121 | r = client.get("/docs/openapi")
122 | assert r.status_code == 200
123 | assert dict_is_openapi(r.get_json())
124 |
125 |
126 | def test_openapi_yaml_endpoint(thing, client):
127 | r = client.get("/docs/openapi.yaml")
128 | assert r.status_code == 200
129 | assert dict_is_openapi(yaml.safe_load(r.data))
130 |
--------------------------------------------------------------------------------
/src/labthings/actions/pool.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import threading
3 | from typing import Dict
4 |
5 | from ..deque import Deque
6 | from .thread import ActionThread
7 |
8 |
9 | class Pool:
10 | """ """
11 |
12 | def __init__(self, maxlen: int = 100):
13 | self.threads = Deque(maxlen=maxlen)
14 |
15 | def add(self, thread: ActionThread):
16 | """
17 |
18 | :param thread: ActionThread:
19 |
20 | """
21 | self.threads.append(thread)
22 |
23 | def start(self, thread: ActionThread):
24 | """
25 |
26 | :param thread: ActionThread:
27 |
28 | """
29 | self.add(thread)
30 | thread.start()
31 |
32 | def spawn(self, action: str, function, *args, http_error_lock=None, **kwargs):
33 | """
34 |
35 | :param function:
36 | :param *args:
37 | :param **kwargs:
38 |
39 | """
40 | thread = ActionThread(
41 | action,
42 | target=function,
43 | http_error_lock=http_error_lock,
44 | args=args,
45 | kwargs=kwargs,
46 | )
47 | self.start(thread)
48 | return thread
49 |
50 | def kill(self, timeout: int = 5):
51 | """
52 |
53 | :param timeout: (Default value = 5)
54 |
55 | """
56 | for thread in self.threads:
57 | if thread.is_alive():
58 | thread.stop(timeout=timeout)
59 |
60 | def tasks(self):
61 | """
62 |
63 |
64 | :returns: List of ActionThread objects.
65 |
66 | :rtype: list
67 |
68 | """
69 | return list(self.threads)
70 |
71 | def states(self):
72 | """
73 |
74 |
75 | :returns: Dictionary of ActionThread.state dictionaries. Key is ActionThread ID.
76 |
77 | :rtype: dict
78 |
79 | """
80 | return {str(t.id): t.state for t in self.threads}
81 |
82 | def to_dict(self) -> Dict[str, ActionThread]:
83 | """
84 |
85 |
86 | :returns: Dictionary of ActionThread objects. Key is ActionThread ID.
87 |
88 | :rtype: dict
89 |
90 | """
91 | return {str(t.id): t for t in self.threads}
92 |
93 | def get(self, task_id: str):
94 | return self.to_dict().get(task_id, None)
95 |
96 | def discard_id(self, task_id):
97 | """
98 |
99 | :param task_id:
100 |
101 | """
102 | marked_for_discard = set()
103 | for task in self.threads:
104 | if (str(task.id) == str(task_id)) and task.dead:
105 | marked_for_discard.add(task)
106 |
107 | for thread in marked_for_discard:
108 | self.threads.remove(thread)
109 |
110 | def cleanup(self):
111 | """ """
112 | marked_for_discard = set()
113 | for task in self.threads:
114 | if task.dead:
115 | marked_for_discard.add(task)
116 |
117 | for thread in marked_for_discard:
118 | self.threads.remove(thread)
119 |
120 | def join(self):
121 | """ """
122 | for thread in self.threads:
123 | thread.join()
124 |
125 |
126 | # Operations on the current task
127 |
128 |
129 | def current_action():
130 | """Return the ActionThread instance in which the caller is currently running.
131 |
132 | If this function is called from outside an ActionThread, it will return None.
133 |
134 |
135 | :returns: :class:`labthings.actions.ActionThread` -- Currently running ActionThread.
136 |
137 | """
138 | current_action_thread = threading.current_thread()
139 | if not isinstance(current_action_thread, ActionThread):
140 | return None
141 | return current_action_thread
142 |
143 |
144 | def update_action_progress(progress: int):
145 | """Update the progress of the ActionThread in which the caller is currently running.
146 |
147 | If this function is called from outside an ActionThread, it will do nothing.
148 |
149 | :param progress: int: Action progress, in percent (0-100)
150 |
151 | """
152 | if current_action():
153 | current_action().update_progress(progress)
154 | else:
155 | logging.info("Cannot update task progress of __main__ thread. Skipping.")
156 |
157 |
158 | def update_action_data(data: dict):
159 | """Update the data of the ActionThread in which the caller is currently running.
160 |
161 | If this function is called from outside an ActionThread, it will do nothing.
162 |
163 | :param data: dict: Action data dictionary
164 |
165 | """
166 | if current_action():
167 | current_action().update_data(data)
168 | else:
169 | logging.info("Cannot update task data of __main__ thread. Skipping.")
170 |
--------------------------------------------------------------------------------
/src/labthings/wsgi.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import socket
3 |
4 | from werkzeug.serving import run_simple
5 | from zeroconf import IPVersion, ServiceInfo, Zeroconf, get_all_addresses
6 |
7 | from .find import current_labthing
8 |
9 | sentinel = object()
10 |
11 |
12 | class Server:
13 | """Combined WSGI+mDNS server.
14 |
15 | :param host: Host IP address. Defaults to 0.0.0.0.
16 | :type host: string
17 | :param port: Host port. Defaults to 7485.
18 | :type port: int
19 | :param debug: Enable server debug mode. Defaults to False.
20 | :type debug: bool
21 | :param zeroconf: Enable the zeroconf (mDNS) server. Defaults to True.
22 | :type zeroconf: bool
23 | """
24 |
25 | def __init__(self, app, host="0.0.0.0", port=7485, debug=False, zeroconf=True):
26 | self.app = app
27 | # Find LabThing attached to app
28 | with app.app_context():
29 | self.labthing = current_labthing(app)
30 |
31 | # Server properties
32 | self.host = host
33 | self.port = port
34 | self.debug = debug
35 | self.zeroconf = zeroconf
36 |
37 | # Servers
38 | self.zeroconf_server = None
39 | self.service_info = None
40 | self.service_infos = []
41 |
42 | def _register_zeroconf(self):
43 | if self.labthing:
44 | host = f"{self.labthing.safe_title}._labthing._tcp.local."
45 | if len(host) > 63:
46 | host = (
47 | f"{hashlib.sha1(host.encode()).hexdigest()}._labthing._tcp.local."
48 | )
49 | print(f"Registering zeroconf {host}")
50 | # Get list of host addresses
51 | mdns_addresses = {
52 | socket.inet_aton(i)
53 | for i in get_all_addresses()
54 | if i not in ("127.0.0.1", "0.0.0.0")
55 | }
56 | # LabThing service
57 | self.service_infos.append(
58 | ServiceInfo(
59 | "_labthing._tcp.local.",
60 | host,
61 | port=self.port,
62 | properties={
63 | "path": self.labthing.url_prefix,
64 | "id": self.labthing.id,
65 | },
66 | addresses=mdns_addresses,
67 | )
68 | )
69 | self.zeroconf_server = Zeroconf(ip_version=IPVersion.V4Only)
70 | for service in self.service_infos:
71 | self.zeroconf_server.register_service(service)
72 |
73 | def start(self):
74 | """Start the server and register mDNS records"""
75 | # Handle zeroconf
76 | if self.zeroconf:
77 | self._register_zeroconf()
78 |
79 | # Slightly more useful logger output
80 | friendlyhost = "localhost" if self.host == "0.0.0.0" else self.host
81 | print("Starting LabThings WSGI Server")
82 | print(f"Debug mode: {self.debug}")
83 | print(f"Running on http://{friendlyhost}:{self.port} (Press CTRL+C to quit)")
84 |
85 | # Create WSGIServer
86 | try:
87 | run_simple(
88 | self.host,
89 | self.port,
90 | self.app,
91 | use_debugger=self.debug,
92 | threaded=True,
93 | processes=1,
94 | )
95 | finally:
96 | # When server stops
97 | if self.zeroconf_server:
98 | print("Unregistering zeroconf services...")
99 | for service in self.service_infos:
100 | self.zeroconf_server.unregister_service(service)
101 | self.zeroconf_server.close()
102 | print("Server stopped")
103 |
104 | def run(self, host=None, port=None, debug=None, zeroconf=None):
105 | """Starts the server allowing for runtime parameters. Designed to immitate
106 | the old Flask app.run style of starting an app
107 |
108 | :param host: Host IP address. Defaults to 0.0.0.0.
109 | :type host: string
110 | :param port: Host port. Defaults to 7485.
111 | :type port: int
112 | :param debug: Enable server debug mode. Defaults to False.
113 | :type debug: bool
114 | :param zeroconf: Enable the zeroconf (mDNS) server. Defaults to True.
115 | :type zeroconf: bool
116 | """
117 | if port is not None:
118 | self.port = int(port)
119 |
120 | if host is not None:
121 | self.host = str(host)
122 |
123 | if debug is not None:
124 | self.debug = debug
125 |
126 | if zeroconf is not None:
127 | self.zeroconf = zeroconf
128 |
129 | self.start()
130 |
--------------------------------------------------------------------------------
/tests/test_views.py:
--------------------------------------------------------------------------------
1 | import json
2 | import time
3 |
4 | import pytest
5 | from flask import make_response
6 | from werkzeug.http import parse_set_header
7 | from werkzeug.wrappers import Response as ResponseBase
8 |
9 | from labthings import views
10 |
11 |
12 | def common_test(app):
13 | c = app.test_client()
14 |
15 | assert c.get("/").data == b"GET"
16 | assert c.post("/").data == b"POST"
17 | assert c.put("/").status_code == 405
18 | assert c.delete("/").status_code == 405
19 | meths = parse_set_header(c.open("/", method="OPTIONS").headers["Allow"])
20 | assert sorted(meths) == ["GET", "HEAD", "OPTIONS", "POST"]
21 |
22 |
23 | def test_method_based_view(app):
24 | class Index(views.View):
25 | def get(self):
26 | return "GET"
27 |
28 | def post(self):
29 | return "POST"
30 |
31 | app.add_url_rule("/", view_func=Index.as_view("index"))
32 | common_test(app)
33 |
34 |
35 | def test_view_patching(app):
36 | class Index(views.View):
37 | def get(self):
38 | 1 // 0
39 |
40 | def post(self):
41 | 1 // 0
42 |
43 | class Other(Index):
44 | def get(self):
45 | return "GET"
46 |
47 | def post(self):
48 | return "POST"
49 |
50 | view_obj = Index.as_view("index")
51 | view_obj.view_class = Other
52 | app.add_url_rule("/", view_func=view_obj)
53 | common_test(app)
54 |
55 |
56 | def test_accept_default_application_json(app, client):
57 | class Index(views.View):
58 | def get(self):
59 | return {"key": "value"}
60 |
61 | app.add_url_rule("/", view_func=Index.as_view("index"))
62 |
63 | with client:
64 | res = client.get("/")
65 | assert res.status_code == 200
66 | assert res.content_type == "application/json"
67 | assert json.loads(res.data) == {"key": "value"}
68 |
69 |
70 | def test_return_response(app, client):
71 | class Index(views.View):
72 | def get(self):
73 | return make_response("GET", 200)
74 |
75 | app.add_url_rule("/", view_func=Index.as_view("index"))
76 |
77 | with client:
78 | res = client.get("/")
79 | assert res.status_code == 200
80 | assert res.data == b"GET"
81 |
82 |
83 | def test_missing_method(app, client):
84 | class Index(views.View):
85 | def get(self):
86 | return "GET"
87 |
88 | app.add_url_rule("/", view_func=Index.as_view("index"))
89 |
90 | with client:
91 | res = client.post("/")
92 | assert res.status_code == 405
93 |
94 |
95 | def test_missing_head_method(app, client):
96 | class Index(views.View):
97 | def get(self):
98 | return "GET"
99 |
100 | app.add_url_rule("/", view_func=Index.as_view("index"))
101 |
102 | with client:
103 | res = client.head("/")
104 | assert res.status_code == 200
105 |
106 |
107 | def test_get_value_text():
108 | class Index(views.View):
109 | def get(self):
110 | return "GET"
111 |
112 | # Main test
113 | assert Index().get_value() == "GET"
114 |
115 |
116 | def test_get_value_missing():
117 | class Index(views.View):
118 | def post(self):
119 | return "POST"
120 |
121 | # Main test
122 | assert Index().get_value() is None
123 |
124 |
125 | def test_get_value_raise_if_not_callable():
126 | class Index(views.View):
127 | def post(self):
128 | return "POST"
129 |
130 | Index.get = "GET"
131 |
132 | with pytest.raises(TypeError):
133 | # Main test
134 | Index().get_value()
135 |
136 |
137 | def test_get_value_response_text(app_ctx):
138 | class Index(views.View):
139 | def get(self):
140 | return make_response("GET", 200)
141 |
142 | with app_ctx.test_request_context():
143 | assert isinstance(Index().get(), ResponseBase)
144 | assert Index().get().headers.get("Content-Type") == "text/html; charset=utf-8"
145 | # Main test
146 | assert Index().get_value() == b"GET"
147 |
148 |
149 | def test_get_value_response_json(app_ctx):
150 | class Index(views.View):
151 | def get(self):
152 | return make_response({"json": "body"}, 200)
153 |
154 | with app_ctx.test_request_context():
155 | assert isinstance(Index().get(), ResponseBase)
156 | assert Index().get().headers.get("Content-Type") == "application/json"
157 | # Main test
158 | assert Index().get_value() == {"json": "body"}
159 |
160 |
161 | def test_action_view_stop(app):
162 | class Index(views.ActionView):
163 | default_stop_timeout = 0
164 |
165 | def post(self):
166 | while True:
167 | time.sleep(1)
168 |
169 | app.add_url_rule("/", view_func=Index.as_view("index"))
170 | c = app.test_client()
171 |
172 | response = c.post("/")
173 | assert response.status_code == 201
174 | assert response.json.get("status") == "running"
175 | # Assert we only have a single running Action thread
176 | assert len(Index._deque) == 1
177 | action_thread = Index._deque[0]
178 | assert action_thread.default_stop_timeout == 0
179 | action_thread.stop()
180 | assert action_thread.status == "cancelled"
181 |
--------------------------------------------------------------------------------
/examples/simple_extensions.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import math
3 | import random
4 | import time
5 |
6 | from labthings import ActionView, PropertyView, create_app, fields, find_component
7 | from labthings.extensions import BaseExtension
8 | from labthings.utilities import path_relative_to
9 |
10 | logging.basicConfig(level=logging.DEBUG)
11 |
12 | """
13 | Make our extension
14 | """
15 |
16 |
17 | class ExtensionMeasurementAction(ActionView):
18 | """Start an averaged measurement"""
19 |
20 | args = {
21 | "averages": fields.Integer(
22 | missing=10,
23 | example=10,
24 | description="Number of data sets to average over",
25 | )
26 | }
27 |
28 | def post(self, args):
29 | # Find our attached component
30 | my_component = find_component("org.labthings.example.mycomponent")
31 |
32 | # Get arguments and start a background task
33 | n_averages = args.get("averages")
34 | return my_component.average_data(n_averages)
35 |
36 |
37 | def ext_on_register():
38 | logging.info("Extension registered")
39 |
40 |
41 | def ext_on_my_component(component):
42 | logging.info(f"{component} registered and noticed by extension")
43 |
44 |
45 | static_folder = path_relative_to(__file__, "static")
46 |
47 | example_extension = BaseExtension(
48 | "org.labthings.examples.extension", static_folder=static_folder
49 | )
50 |
51 | example_extension.add_view(ExtensionMeasurementAction, "/measure", endpoint="measure")
52 |
53 | example_extension.on_register(ext_on_register)
54 | example_extension.on_component("org.labthings.example.mycomponent", ext_on_my_component)
55 |
56 |
57 | """
58 | Class for our lab component functionality. This could include serial communication,
59 | equipment API calls, network requests, or a "virtual" device as seen here.
60 | """
61 |
62 |
63 | class MyComponent:
64 | def __init__(self):
65 | self.x_range = range(-100, 100)
66 | self.magic_denoise = 200
67 |
68 | def noisy_pdf(self, x, mu=0.0, sigma=25.0):
69 | """
70 | Generate a noisy gaussian function (to act as some pretend data)
71 |
72 | Our noise is inversely proportional to self.magic_denoise
73 | """
74 | x = float(x - mu) / sigma
75 | return (
76 | math.exp(-x * x / 2.0) / math.sqrt(2.0 * math.pi) / sigma
77 | + (1 / self.magic_denoise) * random.random()
78 | )
79 |
80 | @property
81 | def data(self):
82 | """
83 | Return a 1D data trace.
84 | """
85 | return [self.noisy_pdf(x) for x in self.x_range]
86 |
87 | def average_data(self, n: int):
88 | """Average n-sets of data. Emulates a measurement that may take a while."""
89 | summed_data = self.data
90 |
91 | for _ in range(n):
92 | summed_data = [summed_data[i] + el for i, el in enumerate(self.data)]
93 | time.sleep(0.25)
94 |
95 | summed_data = [i / n for i in summed_data]
96 |
97 | return summed_data
98 |
99 |
100 | """
101 | Create a view to view and change our magic_denoise value, and register is as a Thing property
102 | """
103 |
104 |
105 | class DenoiseProperty(PropertyView):
106 |
107 | schema = fields.Integer(
108 | required=True,
109 | example=200,
110 | minimum=100,
111 | maximum=500,
112 | description="Value of magic_denoise",
113 | )
114 |
115 | # Main function to handle GET requests (read)
116 | def get(self):
117 | """Show the current magic_denoise value"""
118 |
119 | # When a GET request is made, we'll find our attached component
120 | my_component = find_component("org.labthings.example.mycomponent")
121 | return my_component.magic_denoise
122 |
123 | # Main function to handle POST requests (write)
124 | def post(self, new_property_value):
125 | """Change the current magic_denoise value"""
126 |
127 | # Find our attached component
128 | my_component = find_component("org.labthings.example.mycomponent")
129 |
130 | # Apply the new value
131 | my_component.magic_denoise = new_property_value
132 |
133 | return my_component.magic_denoise
134 |
135 |
136 | """
137 | Create a view to quickly get some noisy data, and register is as a Thing property
138 | """
139 |
140 |
141 | class QuickDataProperty(PropertyView):
142 |
143 | schema = fields.List(fields.Float())
144 |
145 | # Main function to handle GET requests
146 | def get(self):
147 | """Show the current data value"""
148 |
149 | # Find our attached component
150 | my_component = find_component("org.labthings.example.mycomponent")
151 | return my_component.data
152 |
153 |
154 | # Create LabThings Flask app
155 | app, labthing = create_app(
156 | __name__,
157 | title="My Lab Device API",
158 | description="Test LabThing-based API",
159 | version="0.1.0",
160 | )
161 |
162 | # Register extensions
163 | labthing.register_extension(example_extension)
164 |
165 | # Attach an instance of our component
166 | labthing.add_component(MyComponent(), "org.labthings.example.mycomponent")
167 |
168 | # Add routes for the API views we created
169 | labthing.add_view(DenoiseProperty, "/denoise")
170 | labthing.add_view(QuickDataProperty, "/quick-data")
171 |
172 |
173 | # Start the app
174 | if __name__ == "__main__":
175 | from labthings.server.wsgi import Server
176 |
177 | logger = logging.getLogger()
178 | logger.setLevel(logging.DEBUG)
179 |
180 | server = Server(app)
181 | server.run()
182 |
--------------------------------------------------------------------------------
/tests/test_tasks_thread.py:
--------------------------------------------------------------------------------
1 | import threading
2 | import time
3 |
4 | import pytest
5 |
6 | from labthings.actions import pool, thread
7 |
8 |
9 | def test_task_with_args():
10 | def task_func(arg, kwarg=False):
11 | pass
12 |
13 | task_obj = thread.ActionThread(
14 | "task_func", target=task_func, args=("String arg",), kwargs={"kwarg": True}
15 | )
16 | assert isinstance(task_obj, threading.Thread)
17 | assert task_obj._target == task_func
18 | assert task_obj._args == ("String arg",)
19 | assert task_obj._kwargs == {"kwarg": True}
20 |
21 |
22 | def test_task_without_args():
23 | def task_func():
24 | pass
25 |
26 | task_obj = thread.ActionThread("task_func", target=task_func)
27 |
28 | assert isinstance(task_obj, threading.Thread)
29 | assert task_obj._target == task_func
30 | assert task_obj._args == ()
31 | assert task_obj._kwargs == {}
32 |
33 |
34 | def test_task_properties():
35 | def task_func(arg, kwarg=False):
36 | pass
37 |
38 | task_obj = thread.ActionThread(
39 | "task_func", target=task_func, args=("String arg",), kwargs={"kwarg": True}
40 | )
41 | assert task_obj.status == task_obj._status
42 | assert task_obj.id == task_obj._ID
43 | assert task_obj.status == task_obj._status
44 | assert task_obj.output == task_obj._return_value
45 |
46 |
47 | def test_task_update_progress():
48 | def task_func():
49 | pool.current_action().update_progress(100)
50 | return
51 |
52 | task_obj = thread.ActionThread("task_func", target=task_func)
53 |
54 | task_obj.start()
55 | task_obj.join()
56 | assert task_obj.progress == 100
57 |
58 |
59 | def test_task_update_data():
60 | def task_func():
61 | pool.current_action().update_data({"key": "value"})
62 | return
63 |
64 | task_obj = thread.ActionThread("task_func", target=task_func)
65 |
66 | task_obj.start()
67 | task_obj.join()
68 | assert task_obj.data == {"key": "value"}
69 |
70 |
71 | def test_task_start():
72 | def task_func():
73 | return "Return value"
74 |
75 | task_obj = thread.ActionThread("task_func", target=task_func)
76 |
77 | assert task_obj._status == "pending"
78 | assert task_obj._return_value is None
79 |
80 | task_obj.start()
81 | task_obj.join()
82 | assert task_obj._return_value == "Return value"
83 | assert task_obj._status == "completed"
84 |
85 |
86 | def test_task_get():
87 | def task_func():
88 | time.sleep(0.1)
89 | return "Return value"
90 |
91 | task_obj = thread.ActionThread("task_func", target=task_func)
92 | task_obj.start()
93 | assert task_obj.get() == "Return value"
94 |
95 |
96 | def test_task_get_noblock():
97 | def task_func():
98 | time.sleep(0.1)
99 | return "Return value"
100 |
101 | task_obj = thread.ActionThread("task_func", target=task_func)
102 | task_obj.start()
103 | task_obj.join()
104 | assert task_obj.get(block=False, timeout=0) == "Return value"
105 |
106 |
107 | def test_task_get_noblock_timeout():
108 | def task_func():
109 | time.sleep(0.1)
110 | return "Return value"
111 |
112 | task_obj = thread.ActionThread("task_func", target=task_func)
113 | task_obj.start()
114 | with pytest.raises(TimeoutError):
115 | assert task_obj.get(block=False, timeout=0)
116 |
117 |
118 | @pytest.mark.filterwarnings("ignore:Exception in thread")
119 | def test_task_exception():
120 | exc_to_raise = Exception("Exception message")
121 |
122 | def task_func():
123 | raise exc_to_raise
124 |
125 | task_obj = thread.ActionThread("task_func", target=task_func)
126 | task_obj.start()
127 | task_obj.join()
128 |
129 | assert task_obj._status == "error"
130 | assert task_obj._return_value == str(exc_to_raise)
131 |
132 |
133 | def test_task_stop():
134 | def task_func():
135 | while not pool.current_action().stopped:
136 | time.sleep(0)
137 |
138 | task_obj = thread.ActionThread("task_func", target=task_func)
139 | task_obj.start()
140 | task_obj.started.wait()
141 | assert task_obj._status == "running"
142 | task_obj.stop()
143 | task_obj.join()
144 | assert task_obj._status == "cancelled"
145 | assert task_obj._return_value is None
146 |
147 |
148 | def test_task_stop_timeout():
149 | def task_func():
150 | while True:
151 | time.sleep(0)
152 |
153 | task_obj = thread.ActionThread("task_func", target=task_func)
154 | task_obj.start()
155 | task_obj.started.wait()
156 | assert task_obj._status == "running"
157 | task_obj.stop(timeout=0)
158 | task_obj.join()
159 | assert task_obj._status == "cancelled"
160 | assert task_obj._return_value is None
161 |
162 |
163 | def test_task_terminate():
164 | def task_func():
165 | while True:
166 | time.sleep(0.5)
167 |
168 | task_obj = thread.ActionThread("task_func", target=task_func)
169 | task_obj.start()
170 | task_obj.started.wait()
171 | assert task_obj._status == "running"
172 | task_obj.terminate()
173 | task_obj.join()
174 | assert task_obj._status == "cancelled"
175 | assert task_obj._return_value is None
176 |
177 |
178 | def test_task_terminate_not_running():
179 | def task_func():
180 | return
181 |
182 | task_obj = thread.ActionThread("task_func", target=task_func)
183 | task_obj.start()
184 | task_obj.join()
185 | assert task_obj.terminate() is False
186 |
187 |
188 | def test_task_log_with_incorrect_thread():
189 |
190 | task_obj = thread.ActionThread(None)
191 | task_log_handler = thread.ThreadLogHandler(task_obj, task_obj._log)
192 |
193 | # Should always return False if called from outside the log handlers thread
194 | assert task_log_handler.thread == task_obj
195 | assert not task_log_handler.check_thread()
196 |
--------------------------------------------------------------------------------
/src/labthings/sync/lock.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from contextlib import contextmanager
3 | from threading import _RLock, current_thread
4 | from typing import Optional
5 |
6 | sentinel = object()
7 |
8 |
9 | class LockError(RuntimeError):
10 | """ """
11 |
12 | ERROR_CODES = {
13 | "ACQUIRE_ERROR": "Unable to acquire. Lock in use by another thread.",
14 | "IN_USE_ERROR": "Lock in use by another thread.",
15 | }
16 |
17 | def __init__(self, code, lock):
18 | self.code = code
19 | if code in LockError.ERROR_CODES:
20 | self.message = LockError.ERROR_CODES[code]
21 | else:
22 | self.message = "Unknown error."
23 |
24 | self.string = f"{self.code}: LOCK {lock}: {self.message}"
25 | print(self.string)
26 |
27 | RuntimeError.__init__(self)
28 |
29 | def __str__(self):
30 | return self.string
31 |
32 |
33 | class StrictLock:
34 | """Class that behaves like a Python RLock,
35 | but with stricter timeout conditions and custom exceptions.
36 |
37 | :param timeout: Time in seconds acquisition will wait before raising an exception
38 | :type timeout: int
39 |
40 | """
41 |
42 | def __init__(self, timeout: int = -1, name: Optional[str] = None):
43 | self._lock = _RLock()
44 | self.timeout = timeout
45 | self.name = name
46 |
47 | @property
48 | def _owner(self):
49 | """ """
50 | # pylint: disable=protected-access
51 | return self._lock._owner
52 |
53 | @contextmanager
54 | def __call__(self, timeout=sentinel, blocking: bool = True):
55 | result = self.acquire(timeout=timeout, blocking=blocking)
56 | try:
57 | yield result
58 | finally:
59 | if result:
60 | self.release()
61 |
62 | def locked(self):
63 | """ """
64 | # pylint: disable=protected-access
65 | return bool(self._lock._count)
66 |
67 | def acquire(self, blocking: bool = True, timeout=sentinel, _strict: bool = True):
68 | """
69 |
70 | :param blocking: (Default value = True)
71 | :param timeout: (Default value = sentinel)
72 | :param _strict: (Default value = True)
73 |
74 | """
75 | # If no timeout is given, use object level timeout
76 | if timeout is sentinel:
77 | timeout = self.timeout
78 | # Convert from Gevent-style timeout to threading style
79 | if timeout is None:
80 | timeout = -1
81 | result = self._lock.acquire(blocking, timeout=timeout)
82 | if _strict and not result:
83 | raise LockError("ACQUIRE_ERROR", self)
84 | else:
85 | return result
86 |
87 | def __enter__(self):
88 | return self.acquire(blocking=True, timeout=self.timeout)
89 |
90 | def __exit__(self, *args):
91 | self.release()
92 |
93 | def release(self):
94 | """ """
95 | self._lock.release()
96 |
97 | def _is_owned(self):
98 | """ """
99 | # pylint: disable=protected-access
100 | return self._lock._is_owned()
101 |
102 |
103 | class CompositeLock:
104 | """Class that behaves like a :py:class:`labthings.core.lock.StrictLock`,
105 | but allows multiple locks to be acquired and released.
106 |
107 | :param locks: List of parent RLock objects
108 | :type locks: list
109 | :param timeout: Time in seconds acquisition will wait before raising an exception
110 | :type timeout: int
111 |
112 | """
113 |
114 | def __init__(self, locks, timeout: int = -1):
115 | self.locks = locks
116 | self.timeout = timeout
117 |
118 | @property
119 | def _owner(self):
120 | """ """
121 | # pylint: disable=protected-access
122 | return [lock._owner for lock in self.locks]
123 |
124 | @contextmanager
125 | def __call__(self, timeout=sentinel, blocking: bool = True):
126 | result = self.acquire(timeout=timeout, blocking=blocking)
127 | try:
128 | yield result
129 | finally:
130 | if result:
131 | self.release()
132 |
133 | def acquire(self, blocking: bool = True, timeout=sentinel):
134 | """
135 |
136 | :param blocking: (Default value = True)
137 | :param timeout: (Default value = sentinel)
138 |
139 | """
140 | # If no timeout is given, use object level timeout
141 | if timeout is sentinel:
142 | timeout = self.timeout
143 | # Convert from Gevent-style timeout to threading style
144 | if timeout is None:
145 | timeout = -1
146 |
147 | lock_all = all(
148 | lock.acquire(blocking=blocking, timeout=timeout, _strict=False)
149 | for lock in self.locks
150 | )
151 |
152 | if not lock_all:
153 | self._emergency_release()
154 | logging.error("Unable to acquire %s within %s seconds", self, timeout)
155 | raise LockError("ACQUIRE_ERROR", self)
156 |
157 | return True
158 |
159 | def __enter__(self):
160 | return self.acquire(blocking=True, timeout=self.timeout)
161 |
162 | def __exit__(self, *args):
163 | return self.release()
164 |
165 | def release(self):
166 | """ """
167 | # If not all child locks are owner by caller
168 | if not all(owner == current_thread().ident for owner in self._owner):
169 | raise RuntimeError("cannot release un-acquired lock")
170 | for lock in self.locks:
171 | if lock.locked():
172 | lock.release()
173 |
174 | def _emergency_release(self):
175 | """ """
176 | for lock in self.locks:
177 | # pylint: disable=protected-access
178 | if lock.locked() and lock._is_owned():
179 | lock.release()
180 |
181 | def locked(self):
182 | """ """
183 | return any(lock.locked() for lock in self.locks)
184 |
185 | def _is_owned(self):
186 | """ """
187 | # pylint: disable=protected-access
188 | return all(lock._is_owned() for lock in self.locks)
189 |
--------------------------------------------------------------------------------
/src/labthings/json/marshmallow_jsonschema/validation.py:
--------------------------------------------------------------------------------
1 | from marshmallow import fields
2 |
3 | from .exceptions import UnsupportedValueError
4 |
5 |
6 | def handle_length(schema, field, validator, parent_schema):
7 | """Adds validation logic for ``marshmallow.validate.Length``, setting the
8 | values appropriately for ``fields.List``, ``fields.Nested``, and
9 | ``fields.String``.
10 |
11 | :param schema: The original JSON schema we generated. This is what we
12 | want to post-process.
13 | :type schema: dict
14 | :param field: The field that generated the original schema and
15 | who this post-processor belongs to.
16 | :type field: fields.Field
17 | :param validator: The validator attached to the
18 | passed in field.
19 | :type validator: marshmallow.validate.Length
20 | :param parent_schema: The Schema instance that the field
21 | belongs to.
22 | :type parent_schema: marshmallow.Schema
23 | :returns: A, possibly, new JSON Schema that has been post processed and
24 | altered.
25 | :rtype: dict
26 | :raises UnsupportedValueError: Raised if the `field` is something other than
27 | `fields.List`, `fields.Nested`, or `fields.String`
28 |
29 | """
30 | if isinstance(field, fields.String):
31 | minKey = "minLength"
32 | maxKey = "maxLength"
33 | elif isinstance(field, (fields.List, fields.Nested)):
34 | minKey = "minItems"
35 | maxKey = "maxItems"
36 | else:
37 | raise UnsupportedValueError(
38 | "In order to set the Length validator for JSON "
39 | "schema, the field must be either a List, Nested or a String"
40 | )
41 |
42 | if validator.min:
43 | schema[minKey] = validator.min
44 |
45 | if validator.max:
46 | schema[maxKey] = validator.max
47 |
48 | if validator.equal:
49 | schema[minKey] = validator.equal
50 | schema[maxKey] = validator.equal
51 |
52 | return schema
53 |
54 |
55 | def handle_one_of(schema, field, validator, parent_schema):
56 | """Adds the validation logic for ``marshmallow.validate.OneOf`` by setting
57 | the JSONSchema `enum` property to the allowed choices in the validator.
58 |
59 | :param schema: The original JSON schema we generated. This is what we
60 | want to post-process.
61 | :type schema: dict
62 | :param field: The field that generated the original schema and
63 | who this post-processor belongs to.
64 | :type field: fields.Field
65 | :param validator: The validator attached to the
66 | passed in field.
67 | :type validator: marshmallow.validate.OneOf
68 | :param parent_schema: The Schema instance that the field
69 | belongs to.
70 | :type parent_schema: marshmallow.Schema
71 | :returns: New JSON Schema that has been post processed and
72 | altered.
73 | :rtype: dict
74 |
75 | """
76 | schema["enum"] = list(validator.choices)
77 | schema["enumNames"] = list(validator.labels)
78 |
79 | return schema
80 |
81 |
82 | def handle_range(schema, field, validator, parent_schema):
83 | """Adds validation logic for ``marshmallow.validate.Range``, setting the
84 | values appropriately ``fields.Number`` and it's subclasses.
85 |
86 | :param schema: The original JSON schema we generated. This is what we
87 | want to post-process.
88 | :type schema: dict
89 | :param field: The field that generated the original schema and
90 | who this post-processor belongs to.
91 | :type field: fields.Field
92 | :param validator: The validator attached to the
93 | passed in field.
94 | :type validator: marshmallow.validate.Range
95 | :param parent_schema: The Schema instance that the field
96 | belongs to.
97 | :type parent_schema: marshmallow.Schema
98 | :returns: New JSON Schema that has been post processed and
99 | altered.
100 | :rtype: dict
101 | :raises UnsupportedValueError: Raised if the `field` is not an instance of
102 | `fields.Number`.
103 |
104 | """
105 | if not isinstance(field, fields.Number):
106 | raise UnsupportedValueError(
107 | "'Range' validator for non-number fields is not supported"
108 | )
109 |
110 | if validator.min is not None:
111 | # marshmallow 2 includes minimum by default
112 | # marshmallow 3 supports "min_inclusive"
113 | min_inclusive = getattr(validator, "min_inclusive", True)
114 | if min_inclusive:
115 | schema["minimum"] = validator.min
116 | else:
117 | schema["exclusiveMinimum"] = validator.min
118 |
119 | if validator.max is not None:
120 | # marshmallow 2 includes maximum by default
121 | # marshmallow 3 supports "max_inclusive"
122 | max_inclusive = getattr(validator, "max_inclusive", True)
123 | if max_inclusive:
124 | schema["maximum"] = validator.max
125 | else:
126 | schema["exclusiveMaximum"] = validator.max
127 | return schema
128 |
129 |
130 | def handle_regexp(schema, field, validator, parent_schema):
131 | """Adds validation logic for ``marshmallow.validate.Regexp``, setting the
132 | values appropriately ``fields.String`` and it's subclasses.
133 |
134 | :param schema: The original JSON schema we generated. This is what we
135 | want to post-process.
136 | :type schema: dict
137 | :param field: The field that generated the original schema and
138 | who this post-processor belongs to.
139 | :type field: fields.Field
140 | :param validator: The validator attached to the
141 | passed in field.
142 | :type validator: marshmallow.validate.Regexp
143 | :param parent_schema: The Schema instance that the field
144 | belongs to.
145 | :type parent_schema: marshmallow.Schema
146 | :returns: New JSON Schema that has been post processed and
147 | altered.
148 | :rtype: dict
149 | :raises UnsupportedValueError: Raised if the `field` is not an instance of
150 | `fields.String`.
151 |
152 | """
153 | if not isinstance(field, fields.String):
154 | raise UnsupportedValueError(
155 | "'Regexp' validator for non-string fields is not supported"
156 | )
157 |
158 | schema["pattern"] = validator.regex.pattern
159 |
160 | return schema
161 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Python LabThings (for Flask)
2 |
3 | [](https://github.com/labthings/)
4 | [](https://python-labthings.readthedocs.io/en/latest/)
5 | [](https://pypi.org/project/labthings/)
6 | [](https://github.com/psf/black)
7 | [](https://codecov.io/gh/labthings/python-labthings)
8 | [](https://riot.im/app/#/room/#labthings:matrix.org)
9 |
10 | A thread-based Python implementation of the LabThings API structure, based on the Flask microframework.
11 |
12 | ## Installation
13 |
14 | `pip install labthings`
15 |
16 | ## Quickstart example
17 |
18 | This example assumes a `PretendSpectrometer` class, which already has `data` and `integration_time` attributes, as well as an `average_data(n)` method. LabThings allows you to easily convert this existing instrument control code into a fully documented, standardised web API complete with auto-discovery and automatic background task threading.
19 |
20 | ```python
21 | #!/usr/bin/env python
22 | import time
23 |
24 | from labthings import ActionView, PropertyView, create_app, fields, find_component, op
25 | from labthings.example_components import PretendSpectrometer
26 | from labthings.json import encode_json
27 |
28 | """
29 | Class for our lab component functionality. This could include serial communication,
30 | equipment API calls, network requests, or a "virtual" device as seen here.
31 | """
32 |
33 |
34 | """
35 | Create a view to view and change our integration_time value,
36 | and register is as a Thing property
37 | """
38 |
39 |
40 | # Wrap in a semantic annotation to automatically set schema and args
41 | class DenoiseProperty(PropertyView):
42 | """Value of integration_time"""
43 |
44 | schema = fields.Int(required=True, minimum=100, maximum=500)
45 | semtype = "LevelProperty"
46 |
47 | @op.readproperty
48 | def get(self):
49 | # When a GET request is made, we'll find our attached component
50 | my_component = find_component("org.labthings.example.mycomponent")
51 | return my_component.integration_time
52 |
53 | @op.writeproperty
54 | def put(self, new_property_value):
55 | # Find our attached component
56 | my_component = find_component("org.labthings.example.mycomponent")
57 |
58 | # Apply the new value
59 | my_component.integration_time = new_property_value
60 |
61 | return my_component.integration_time
62 |
63 |
64 | """
65 | Create a view to quickly get some noisy data, and register is as a Thing property
66 | """
67 |
68 |
69 | class QuickDataProperty(PropertyView):
70 | """Show the current data value"""
71 |
72 | # Marshal the response as a list of floats
73 | schema = fields.List(fields.Float())
74 |
75 | @op.readproperty
76 | def get(self):
77 | # Find our attached component
78 | my_component = find_component("org.labthings.example.mycomponent")
79 | return my_component.data
80 |
81 |
82 |
83 | """
84 | Create a view to start an averaged measurement, and register is as a Thing action
85 | """
86 |
87 |
88 | class MeasurementAction(ActionView):
89 | # Expect JSON parameters in the request body.
90 | # Pass to post function as dictionary argument.
91 | args = {
92 | "averages": fields.Integer(
93 | missing=20, example=20, description="Number of data sets to average over",
94 | )
95 | }
96 | # Marshal the response as a list of numbers
97 | schema = fields.List(fields.Number)
98 |
99 | # Main function to handle POST requests
100 | @op.invokeaction
101 | def post(self, args):
102 | """Start an averaged measurement"""
103 |
104 | # Find our attached component
105 | my_component = find_component("org.labthings.example.mycomponent")
106 |
107 | # Get arguments and start a background task
108 | n_averages = args.get("averages")
109 |
110 | # Return the task information
111 | return my_component.average_data(n_averages)
112 |
113 |
114 | # Create LabThings Flask app
115 | app, labthing = create_app(
116 | __name__,
117 | title="My Lab Device API",
118 | description="Test LabThing-based API",
119 | version="0.1.0",
120 | )
121 |
122 | # Attach an instance of our component
123 | # Usually a Python object controlling some piece of hardware
124 | my_spectrometer = PretendSpectrometer()
125 | labthing.add_component(my_spectrometer, "org.labthings.example.mycomponent")
126 |
127 |
128 | # Add routes for the API views we created
129 | labthing.add_view(DenoiseProperty, "/integration_time")
130 | labthing.add_view(QuickDataProperty, "/quick-data")
131 | labthing.add_view(MeasurementAction, "/actions/measure")
132 |
133 |
134 | # Start the app
135 | if __name__ == "__main__":
136 | from labthings import Server
137 |
138 | Server(app).run()
139 | ```
140 |
141 | ## Acknowledgements
142 |
143 | Much of the code surrounding default response formatting has been liberally taken from [Flask-RESTful](https://github.com/flask-restful/flask-restful). The integrated [Marshmallow](https://github.com/marshmallow-code/marshmallow) support was inspired by [Flask-Marshmallow](https://github.com/marshmallow-code/flask-marshmallow) and [Flask-ApiSpec](https://github.com/jmcarp/flask-apispec).
144 |
145 | ## Developer notes
146 |
147 | ### Changelog generation
148 |
149 | * `npm install -g conventional-changelog-cli`
150 | * `npx conventional-changelog -r 1 --config ./changelog.config.js -i CHANGELOG.md -s`
151 |
--------------------------------------------------------------------------------
/tests/test_labthing.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from labthings import LabThing
4 | from labthings.extensions import BaseExtension
5 | from labthings.names import EXTENSION_NAME
6 | from labthings.representations import LabThingsJSONEncoder
7 | from labthings.views import View
8 |
9 |
10 | def test_init_types():
11 | types = ["org.labthings.test"]
12 | thing = LabThing(types=types)
13 | assert thing.types == types
14 |
15 |
16 | def test_init_app(app):
17 | thing = LabThing()
18 | thing.init_app(app)
19 |
20 | # Check weakref
21 | assert app.extensions.get(EXTENSION_NAME)() == thing
22 |
23 | assert app.json_encoder == LabThingsJSONEncoder
24 | assert 400 in app.error_handler_spec.get(None)
25 |
26 |
27 | def test_init_app_no_error_formatter(app):
28 | thing = LabThing(format_flask_exceptions=False)
29 | thing.init_app(app)
30 | assert app.error_handler_spec == {}
31 |
32 |
33 | def test_add_view(thing, view_cls, client):
34 | thing.add_view(view_cls, "/index", endpoint="index")
35 |
36 | with client as c:
37 | assert c.get("/index").data == b'"GET"\n'
38 |
39 |
40 | def test_add_view_endpoint_clash(thing, view_cls, client):
41 | thing.add_view(view_cls, "/index", endpoint="index")
42 | with pytest.raises(AssertionError):
43 | thing.add_view(view_cls, "/index2", endpoint="index")
44 |
45 |
46 | def test_view_decorator(thing, client):
47 | @thing.view("/index")
48 | class ViewClass(View):
49 | def get(self):
50 | return "GET"
51 |
52 | with client as c:
53 | assert c.get("/index").data == b'"GET"\n'
54 |
55 |
56 | def test_add_view_action(thing, action_view_cls, client):
57 | action_view_cls.tags = ["actions"]
58 | thing.add_view(action_view_cls, "/index", endpoint="index")
59 | assert action_view_cls in thing._action_views.values()
60 |
61 |
62 | def test_add_view_property(thing, property_view_cls, client):
63 | property_view_cls.tags = ["properties"]
64 | thing.add_view(property_view_cls, "/index", endpoint="index")
65 | assert property_view_cls in thing._property_views.values()
66 |
67 |
68 | def test_init_app_early_views(app, view_cls, client):
69 | thing = LabThing()
70 | thing.add_view(view_cls, "/index", endpoint="index")
71 |
72 | thing.init_app(app)
73 |
74 | with client as c:
75 | assert c.get("/index").data == b'"GET"\n'
76 |
77 |
78 | def test_register_extension(thing):
79 | extension = BaseExtension("org.labthings.tests.extension")
80 | thing.register_extension(extension)
81 | assert thing.extensions.get("org.labthings.tests.extension") == extension
82 |
83 |
84 | def test_register_extension_type_error(thing):
85 | extension = object()
86 | with pytest.raises(TypeError):
87 | thing.register_extension(extension)
88 |
89 |
90 | def test_add_component(thing):
91 | component = type("component", (object,), {})
92 |
93 | thing.add_component(component, "org.labthings.tests.component")
94 | assert "org.labthings.tests.component" in thing.components
95 |
96 |
97 | def test_on_component_callback(thing):
98 | # Build extension
99 | def f(component):
100 | component.callback_called = True
101 |
102 | extension = BaseExtension("org.labthings.tests.extension")
103 | extension.on_component("org.labthings.tests.component", f)
104 | # Add extension
105 | thing.register_extension(extension)
106 |
107 | # Build component
108 | component = type("component", (object,), {"callback_called": False})
109 |
110 | # Add component
111 | thing.add_component(component, "org.labthings.tests.component")
112 | # Check callback
113 | assert component.callback_called
114 |
115 |
116 | def test_on_component_callback_component_already_added(thing):
117 | # Build component
118 | component = type("component", (object,), {"callback_called": False})
119 | # Add component
120 | thing.add_component(component, "org.labthings.tests.component")
121 |
122 | # Build extension
123 | def f(component):
124 | component.callback_called = True
125 |
126 | extension = BaseExtension("org.labthings.tests.extension")
127 | extension.on_component("org.labthings.tests.component", f)
128 | # Add extension
129 | thing.register_extension(extension)
130 |
131 | # Check callback
132 | assert component.callback_called
133 |
134 |
135 | def test_on_component_callback_wrong_component(thing):
136 | def f(component):
137 | component.callback_called = True
138 |
139 | extension = BaseExtension("org.labthings.tests.extension")
140 | extension.on_component("org.labthings.tests.component", f)
141 | thing.register_extension(extension)
142 |
143 | component = type("component", (object,), {"callback_called": False})
144 | thing.add_component(component, "org.labthings.tests.wrong_component")
145 | assert not component.callback_called
146 |
147 |
148 | def test_on_register_callback(thing):
149 | # Build extension
150 | def f(extension):
151 | extension.callback_called = True
152 |
153 | extension = BaseExtension("org.labthings.tests.extension")
154 | extension.callback_called = False
155 | extension.on_register(f, args=(extension,))
156 | # Add extension
157 | thing.register_extension(extension)
158 |
159 | # Check callback
160 | assert extension.callback_called
161 |
162 |
163 | def test_complete_url(thing):
164 | thing.url_prefix = ""
165 | assert thing._complete_url("", "") == "/"
166 | assert thing._complete_url("", "api") == "/api"
167 | assert thing._complete_url("/method", "api") == "/api/method"
168 |
169 | thing.url_prefix = "prefix"
170 | assert thing._complete_url("", "") == "/prefix"
171 | assert thing._complete_url("", "api") == "/prefix/api"
172 | assert thing._complete_url("/method", "api") == "/prefix/api/method"
173 |
174 |
175 | def test_url_for(thing, view_cls, app_ctx):
176 | with app_ctx.test_request_context():
177 | # Before added, should return no URL
178 | assert thing.url_for(view_cls) == ""
179 | # Add view
180 | thing.add_view(view_cls, "/index", endpoint="index")
181 | # Check URLs
182 | assert thing.url_for(view_cls, _external=False) == "/index"
183 | assert all(
184 | substring in thing.url_for(view_cls) for substring in ["http://", "/index"]
185 | )
186 |
187 |
188 | def test_add_root_link(thing, view_cls, app_ctx, schemas_path):
189 | thing.add_root_link(view_cls, "rel")
190 | assert {
191 | "rel": "rel",
192 | "view": view_cls,
193 | "params": {},
194 | "kwargs": {},
195 | } in thing.thing_description._links
196 |
197 |
198 | def test_td_add_link_options(thing, view_cls):
199 | thing.add_root_link(
200 | view_cls, "rel", kwargs={"kwarg": "kvalue"}, params={"param": "pvalue"}
201 | )
202 | assert {
203 | "rel": "rel",
204 | "view": view_cls,
205 | "params": {"param": "pvalue"},
206 | "kwargs": {"kwarg": "kvalue"},
207 | } in thing.thing_description._links
208 |
209 |
210 | def test_description(thing):
211 | assert thing.description == ""
212 | thing.description = "description"
213 | assert thing.description == "description"
214 | assert thing.spec.description == "description"
215 |
216 |
217 | def test_title(thing):
218 | assert thing.title == ""
219 | thing.title = "title"
220 | assert thing.title == "title"
221 | assert thing.spec.title == "title"
222 |
223 |
224 | def test_safe_title(thing):
225 | assert thing.title == ""
226 | assert thing.safe_title == "unknown"
227 | thing.title = "Example LabThing 001"
228 | assert thing.safe_title == "examplelabthing001"
229 |
230 |
231 | def test_version(thing):
232 | assert thing.version == "0.0.0"
233 | thing.version = "x.x.x"
234 | assert thing.version == "x.x.x"
235 | assert thing.spec.version == "x.x.x"
236 |
--------------------------------------------------------------------------------
/src/labthings/schema.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import logging
3 | from datetime import datetime
4 | from typing import Any, Dict, Optional, Union
5 |
6 | from flask import url_for
7 | from marshmallow import Schema, pre_dump, pre_load, validate
8 | from werkzeug.routing import BuildError
9 |
10 | from . import fields
11 | from .names import ACTION_ENDPOINT, EXTENSION_LIST_ENDPOINT
12 | from .utilities import description_from_view, view_class_from_endpoint
13 |
14 | __all__ = ["Schema", "pre_load", "pre_dump", "validate", "FuzzySchemaType"]
15 |
16 | # Type alias for a Schema, Field, or Dict of Fields or Types
17 | FuzzySchemaType = Union[Schema, fields.Field, Dict[str, Union[fields.Field, type]]]
18 |
19 |
20 | class FieldSchema(Schema):
21 | """ "Virtual schema" for handling individual fields treated as schemas.
22 |
23 | For example, when serializing/deserializing individual values that are not
24 | attributes of an object, like passing a single number as the request/response body
25 |
26 |
27 | """
28 |
29 | def __init__(self, field: fields.Field):
30 | """Create a converter for data of the field type
31 |
32 | Args:
33 | field (Field): Marshmallow Field type of data
34 | """
35 | Schema.__init__(self)
36 | self.field = field
37 |
38 | def deserialize(self, value):
39 | """
40 |
41 | :param value:
42 |
43 | """
44 | return self.field.deserialize(value)
45 |
46 | def serialize(self, value):
47 | """Serialize a value to Field type
48 |
49 | :param value: Data to serialize
50 | :returns: Serialized data
51 |
52 | """
53 | obj = type("obj", (object,), {"value": value})
54 |
55 | return self.field.serialize("value", obj)
56 |
57 | # We disable pylint unused-argument so we can keep the same signature as the base class
58 | # pylint: disable=unused-argument
59 | def dump(self, obj: Any, *, many: Optional[bool] = None):
60 | """
61 | :param value:
62 | """
63 | return self.serialize(obj)
64 |
65 |
66 | class LogRecordSchema(Schema):
67 | name = fields.String()
68 | message = fields.String()
69 | levelname = fields.String()
70 | levelno = fields.Integer()
71 | lineno = fields.Integer()
72 | filename = fields.String()
73 | created = fields.DateTime()
74 |
75 | @pre_dump
76 | def preprocess(self, data, **_):
77 | if isinstance(data, logging.LogRecord):
78 | data.message = data.getMessage()
79 | if not isinstance(data.created, datetime):
80 | data.created = datetime.fromtimestamp(data.created)
81 | return data
82 |
83 |
84 | class ActionSchema(Schema):
85 | """Represents a running or completed Action
86 |
87 | Actions can run in the background, started by one request
88 | and subsequently polled for updates. This schema represents
89 | one Action."""
90 |
91 | action = fields.String()
92 | _ID = fields.String(data_key="id")
93 | _status = fields.String(
94 | data_key="status",
95 | validate=validate.OneOf(
96 | ["pending", "running", "completed", "cancelled", "error"]
97 | ),
98 | )
99 | progress = fields.Integer()
100 | data = fields.Raw()
101 | _request_time = fields.DateTime(data_key="timeRequested")
102 | _end_time = fields.DateTime(data_key="timeCompleted")
103 | log = fields.List(fields.Nested(LogRecordSchema()))
104 |
105 | input = fields.Raw()
106 | output = fields.Raw()
107 |
108 | href = fields.String()
109 | links = fields.Dict()
110 |
111 | @pre_dump
112 | def generate_links(self, data, **_):
113 | """
114 |
115 | :param data:
116 | :param **kwargs:
117 |
118 | """
119 | # Add Mozilla format href
120 | try:
121 | url = url_for(ACTION_ENDPOINT, task_id=data.id, _external=True)
122 | except BuildError:
123 | url = None
124 | data.href = url
125 |
126 | # Add full link description
127 | data.links = {
128 | "self": {
129 | "href": url,
130 | "mimetype": "application/json",
131 | **description_from_view(view_class_from_endpoint(ACTION_ENDPOINT)),
132 | }
133 | }
134 |
135 | return data
136 |
137 |
138 | def nest_if_needed(schema):
139 | """Convert a schema, dict, or field into a field."""
140 | # If we have a real schema, nest it
141 | if isinstance(schema, Schema):
142 | return fields.Nested(schema)
143 | # If a dictionary schema, build a real schema then nest it
144 | if isinstance(schema, dict):
145 | return fields.Nested(Schema.from_dict(schema))
146 | # If a single field, set it as the output Field, and override its data_key
147 | if isinstance(schema, fields.Field):
148 | return schema
149 |
150 | raise TypeError(
151 | f"Unsupported schema type {schema}. "
152 | "Ensure schema is a Schema object, Field object, "
153 | "or dictionary of Field objects"
154 | )
155 |
156 |
157 | def build_action_schema(
158 | output_schema: Optional[FuzzySchemaType],
159 | input_schema: Optional[FuzzySchemaType],
160 | name: Optional[str] = None,
161 | base_class: type = ActionSchema,
162 | ):
163 | """Builds a complete schema for a given ActionView.
164 |
165 | This method combines input and output schemas for a particular
166 | Action with the generic ActionSchema to give a specific ActionSchema
167 | subclass for that Action.
168 |
169 | This is used in the Thing Description (where it is serialised to
170 | JSON in-place) but not in the OpenAPI description (where the input,
171 | output, and ActionSchema schemas are combined using `allOf`.)
172 |
173 | :param output_schema: Schema:
174 | :param input_schema: Schema:
175 | :param name: str: (Default value = None)
176 |
177 | """
178 | # Create a name for the generated schema
179 | if not name:
180 | name = str(id(output_schema))
181 | if not name.endswith("Action"):
182 | name = f"{name}Action"
183 |
184 | class_attrs: Dict[str, Union[fields.Nested, fields.Field, str]] = {}
185 |
186 | class_attrs[
187 | "__doc__"
188 | ] = f"Description of an action, with specific parameters for `{name}`"
189 | if input_schema:
190 | class_attrs["input"] = nest_if_needed(input_schema)
191 | if output_schema:
192 | class_attrs["output"] = nest_if_needed(output_schema)
193 |
194 | return type(name, (base_class,), class_attrs)
195 |
196 |
197 | class EventSchema(Schema):
198 | event = fields.String()
199 | timestamp = fields.DateTime()
200 | data = fields.Raw()
201 |
202 |
203 | class ExtensionSchema(Schema):
204 | """ """
205 |
206 | name = fields.String(data_key="title")
207 | _name_python_safe = fields.String(data_key="pythonName")
208 | _cls = fields.String(data_key="pythonObject")
209 | meta = fields.Dict()
210 | description = fields.String()
211 |
212 | links = fields.Dict()
213 |
214 | @pre_dump
215 | def generate_links(self, data, **_):
216 | """
217 |
218 | :param data:
219 | :param **kwargs:
220 |
221 | """
222 | d = {}
223 | for view_id, view_data in data.views.items():
224 | view_cls = view_data.get("view")
225 | view_urls = view_data.get("urls")
226 | # Try to build a URL
227 | try:
228 | urls = [
229 | url_for(EXTENSION_LIST_ENDPOINT, _external=True) + url
230 | for url in view_urls
231 | ]
232 | except BuildError:
233 | urls = []
234 | # If URL list is empty
235 | if len(urls) == 0:
236 | urls = None
237 | # If only 1 URL is given
238 | elif len(urls) == 1:
239 | urls = urls[0]
240 | # Make links dictionary if it doesn't yet exist
241 | d[view_id] = {"href": urls, **description_from_view(view_cls)}
242 |
243 | data.links = d
244 |
245 | return data
246 |
--------------------------------------------------------------------------------
/tests/test_utilities.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from labthings import utilities
4 | from labthings.views import View
5 |
6 |
7 | @pytest.fixture
8 | def example_class():
9 | class ExampleClass:
10 | """First line of class docstring.
11 |
12 | Third line of class docstring.
13 | """
14 |
15 | def class_method(self):
16 | """First line of class method docstring.
17 |
18 | Third line of class method docstring.
19 | """
20 | return self
21 |
22 | def class_method_oneline(self):
23 | """One line docstring."""
24 | return self
25 |
26 | def class_method_no_docstring(self):
27 | return self
28 |
29 | return ExampleClass
30 |
31 |
32 | def test_get_docstring(example_class):
33 | assert (
34 | utilities.get_docstring(example_class)
35 | == "First line of class docstring. Third line of class docstring. "
36 | )
37 | assert (
38 | utilities.get_docstring(example_class, remove_newlines=False)
39 | == "First line of class docstring.\n\nThird line of class docstring."
40 | )
41 | assert (
42 | utilities.get_docstring(example_class.class_method)
43 | == "First line of class method docstring. Third line of class method docstring. "
44 | )
45 | assert (
46 | utilities.get_docstring(example_class.class_method, remove_summary=True)
47 | == "Third line of class method docstring. "
48 | )
49 | assert (
50 | utilities.get_docstring(example_class.class_method_oneline, remove_summary=True)
51 | == "One line docstring."
52 | )
53 | assert (
54 | utilities.get_docstring(
55 | example_class.class_method, remove_newlines=False, remove_summary=True
56 | ).strip()
57 | == "Third line of class method docstring."
58 | )
59 | assert utilities.get_docstring(example_class.class_method_no_docstring) == ""
60 |
61 |
62 | def test_get_summary(example_class):
63 | assert utilities.get_summary(example_class) == "First line of class docstring."
64 |
65 | assert (
66 | utilities.get_summary(example_class.class_method)
67 | == "First line of class method docstring."
68 | )
69 |
70 | assert utilities.get_summary(example_class.class_method_no_docstring) == ""
71 |
72 |
73 | def test_merge_granular():
74 | # Update string value
75 | s1 = {"a": "String"}
76 | s2 = {"a": "String 2"}
77 | assert utilities.merge(s1, s2) == s2
78 |
79 | # Update int value
80 | i1 = {"b": 5}
81 | i2 = {"b": 50}
82 | assert utilities.merge(i1, i2) == i2
83 |
84 | # Update list elements
85 | l1 = {"c": []}
86 | l2 = {"c": [1, 2, 3, 4]}
87 | assert utilities.merge(l1, l2) == l2
88 |
89 | # Extend list elements
90 | l1 = {"c": [1, 2, 3]}
91 | l2 = {"c": [4, 5, 6]}
92 | assert utilities.merge(l1, l2)["c"] == [1, 2, 3, 4, 5, 6]
93 |
94 | # Merge dictionaries
95 | d1 = {"d": {"a": "String", "b": 5, "c": []}}
96 | d2 = {"d": {"a": "String 2", "b": 50, "c": [1, 2, 3, 4, 5]}}
97 | assert utilities.merge(d1, d2) == d2
98 |
99 | # Replace value with list
100 | ml1 = {"k": True}
101 | ml2 = {"k": [1, 2, 3]}
102 | assert utilities.merge(ml1, ml2) == ml2
103 |
104 | # Create missing value
105 | ms1 = {}
106 | ms2 = {"k": "v"}
107 | assert utilities.merge(ms1, ms2) == ms2
108 |
109 | # Create missing list
110 | ml1 = {}
111 | ml2 = {"k": [1, 2, 3]}
112 | assert utilities.merge(ml1, ml2) == ml2
113 |
114 | # Create missing dictionary
115 | md1 = {}
116 | md2 = {"d": {"a": "String 2", "b": 50, "c": [1, 2, 3, 4, 5]}}
117 | assert utilities.merge(md1, md2) == md2
118 |
119 |
120 | def test_rapply():
121 | d1 = {
122 | "a": "String",
123 | "b": 5,
124 | "c": [10, 20, 30, 40, 50],
125 | "d": {"a": "String", "b": 5, "c": [10, 20, 30, 40, 50]},
126 | }
127 |
128 | def as_str(v):
129 | return str(v)
130 |
131 | d2 = {
132 | "a": "String",
133 | "b": "5",
134 | "c": ["10", "20", "30", "40", "50"],
135 | "d": {"a": "String", "b": "5", "c": ["10", "20", "30", "40", "50"]},
136 | }
137 |
138 | assert utilities.rapply(d1, as_str) == d2
139 |
140 | d2_no_iter = {
141 | "a": "String",
142 | "b": "5",
143 | "c": "[10, 20, 30, 40, 50]",
144 | "d": {"a": "String", "b": "5", "c": "[10, 20, 30, 40, 50]"},
145 | }
146 |
147 | assert utilities.rapply(d1, as_str, apply_to_iterables=False) == d2_no_iter
148 |
149 |
150 | def test_get_by_path():
151 | d1 = {"a": {"b": "String"}}
152 |
153 | assert utilities.get_by_path(d1, ("a", "b")) == "String"
154 |
155 |
156 | def test_set_by_path():
157 | d1 = {"a": {"b": "String"}}
158 |
159 | utilities.set_by_path(d1, ("a", "b"), "Set")
160 |
161 | assert d1["a"]["b"] == "Set"
162 |
163 |
164 | def test_create_from_path():
165 | assert utilities.create_from_path(["a", "b", "c"]) == {"a": {"b": {"c": {}}}}
166 |
167 |
168 | def test_camel_to_snake():
169 | assert utilities.camel_to_snake("someCamelString") == "some_camel_string"
170 |
171 |
172 | def test_camel_to_spine():
173 | assert utilities.camel_to_spine("someCamelString") == "some-camel-string"
174 |
175 |
176 | def test_snake_to_spine():
177 | assert utilities.snake_to_spine("some_snake_string") == "some-snake-string"
178 |
179 |
180 | def test_snake_to_camel():
181 | assert utilities.snake_to_camel("some_snake_string") == "someSnakeString"
182 |
183 |
184 | def test_path_relative_to():
185 | import os
186 |
187 | assert utilities.path_relative_to(
188 | "/path/to/file.extension", "joinpath", "joinfile.extension"
189 | ) == os.path.abspath("/path/to/joinpath/joinfile.extension")
190 |
191 |
192 | def test_http_status_message():
193 | assert utilities.http_status_message(404) == "Not Found"
194 |
195 |
196 | def test_http_status_missing():
197 | # Totally invalid HTTP code
198 | assert utilities.http_status_message(0) == ""
199 |
200 |
201 | def test_description_from_view(app):
202 | class Index(View):
203 | """Class summary"""
204 |
205 | def get(self):
206 | """GET summary"""
207 | return "GET"
208 |
209 | def post(self):
210 | """POST summary"""
211 | return "POST"
212 |
213 | description = utilities.description_from_view(Index)
214 | assert "POST" in description["methods"]
215 | assert "GET" in description["methods"]
216 | assert description["description"] == "Class summary"
217 |
218 |
219 | def test_description_from_view_summary_from_method(app):
220 | class Index(View):
221 | def get(self):
222 | """GET summary"""
223 | return "GET"
224 |
225 | def post(self):
226 | """POST summary"""
227 | return "POST"
228 |
229 | description = utilities.description_from_view(Index)
230 | assert "POST" in description["methods"]
231 | assert "GET" in description["methods"]
232 | assert description["description"] == "GET summary"
233 |
234 |
235 | def test_view_class_from_endpoint(app):
236 | class Index(View):
237 | def get(self):
238 | return "GET"
239 |
240 | def post(self):
241 | return "POST"
242 |
243 | app.add_url_rule("/", view_func=Index.as_view("index"))
244 | assert utilities.view_class_from_endpoint("index") == Index
245 |
246 |
247 | def test_unpack_data():
248 | assert utilities.unpack("value") == ("value", 200, {})
249 |
250 |
251 | def test_unpack_data_tuple():
252 | assert utilities.unpack(("value",)) == (("value",), 200, {})
253 |
254 |
255 | def test_unpack_data_code():
256 | assert utilities.unpack(("value", 201)) == ("value", 201, {})
257 |
258 |
259 | def test_unpack_data_code_headers():
260 | assert utilities.unpack(("value", 201, {"header": "header_value"})) == (
261 | "value",
262 | 201,
263 | {"header": "header_value"},
264 | )
265 |
266 |
267 | def test_clean_url_string():
268 | assert utilities.clean_url_string(None) == "/"
269 | assert utilities.clean_url_string("path") == "/path"
270 | assert utilities.clean_url_string("/path") == "/path"
271 | assert utilities.clean_url_string("//path") == "//path"
272 |
--------------------------------------------------------------------------------