├── 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 | [![LabThings](https://img.shields.io/badge/-LabThings-8E00FF?style=flat&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjwhRE9DVFlQRSBzdmcgIFBVQkxJQyAnLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4nICAnaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkJz4NCjxzdmcgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIyIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAxNjMgMTYzIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0xMjIuMjQgMTYyLjk5aDQwLjc0OHYtMTYyLjk5aC0xMDEuODd2NDAuNzQ4aDYxLjEyMnYxMjIuMjR6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0ibTAgMTIuMjI0di0xMi4yMjRoNDAuNzQ4djEyMi4yNGg2MS4xMjJ2NDAuNzQ4aC0xMDEuODd2LTEyLjIyNGgyMC4zNzR2LTguMTVoLTIwLjM3NHYtOC4xNDloOC4wMTl2LTguMTVoLTguMDE5di04LjE1aDIwLjM3NHYtOC4xNDloLTIwLjM3NHYtOC4xNWg4LjAxOXYtOC4xNWgtOC4wMTl2LTguMTQ5aDIwLjM3NHYtOC4xNWgtMjAuMzc0di04LjE0OWg4LjAxOXYtOC4xNWgtOC4wMTl2LTguMTVoMjAuMzc0di04LjE0OWgtMjAuMzc0di04LjE1aDguMDE5di04LjE0OWgtOC4wMTl2LTguMTVoMjAuMzc0di04LjE1aC0yMC4zNzR6IiBmaWxsPSIjZmZmIi8+PC9zdmc+DQo=)](https://github.com/labthings/) 4 | [![ReadTheDocs](https://readthedocs.org/projects/python-labthings/badge/?version=latest&style=flat)](https://python-labthings.readthedocs.io/en/latest/) 5 | [![PyPI](https://img.shields.io/pypi/v/labthings)](https://pypi.org/project/labthings/) 6 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 7 | [![codecov](https://codecov.io/gh/labthings/python-labthings/branch/master/graph/badge.svg)](https://codecov.io/gh/labthings/python-labthings) 8 | [![Riot.im](https://img.shields.io/badge/chat-on%20riot.im-368BD6)](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 | --------------------------------------------------------------------------------