From 644fb3d972a29d914d9a4a3cab76776f62c565a9 Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Thu, 4 Dec 2025 15:12:32 +0000 Subject: [PATCH 01/13] Rename package namespace from odin to odin-control (#68) * Rename package: odin -> odin_control (package namespace) * Add migrate_adapter utility to allow easy adapter migration to new package name --- .gitignore | 2 +- docs/server_ref/odin.adapters.rst | 12 +- docs/server_ref/odin.config.rst | 8 +- docs/server_ref/odin.http.routes.rst | 16 +- docs/server_ref/odin.http.rst | 10 +- docs/server_ref/odin.rst | 10 +- pyproject.toml | 10 +- src/odin/adapters/__init__.py | 1 - src/odin/config/__init__.py | 1 - src/odin/http/__init__.py | 1 - src/odin/http/routes/__init__.py | 1 - src/{odin => odin_control}/__init__.py | 0 src/odin_control/adapters/__init__.py | 1 + .../adapters/adapter.py | 4 +- .../adapters/async_adapter.py | 4 +- .../adapters/async_dummy.py | 10 +- .../adapters/async_parameter_tree.py | 2 +- .../adapters/async_proxy.py | 8 +- .../adapters/base_parameter_tree.py | 0 .../adapters/base_proxy.py | 2 +- src/{odin => odin_control}/adapters/dummy.py | 4 +- .../adapters/parameter_tree.py | 2 +- src/{odin => odin_control}/adapters/proxy.py | 6 +- .../adapters/system_info.py | 6 +- .../adapters/system_status.py | 6 +- src/{odin => odin_control}/async_util.py | 0 src/odin_control/config/__init__.py | 1 + src/{odin => odin_control}/config/parser.py | 4 +- src/odin_control/http/__init__.py | 1 + .../http/handlers/__init__.py | 0 .../http/handlers/api.py | 2 +- .../http/handlers/async_api.py | 2 +- .../http/handlers/base.py | 4 +- src/odin_control/http/routes/__init__.py | 1 + src/{odin => odin_control}/http/routes/api.py | 12 +- .../http/routes/default.py | 2 +- .../http/routes/route.py | 0 src/{odin => odin_control}/http/server.py | 8 +- src/{odin => odin_control}/logconfig.py | 0 src/{odin => odin_control}/main.py | 6 +- src/{odin => odin_control}/util.py | 2 +- tests/adapters/test_adapter.py | 2 +- tests/adapters/test_async_adapter_py3.py | 2 +- tests/adapters/test_async_dummy_py3.py | 2 +- .../adapters/test_async_parameter_tree_py3.py | 2 +- tests/adapters/test_async_proxy_py3.py | 4 +- tests/adapters/test_dummy.py | 4 +- tests/adapters/test_parameter_tree.py | 2 +- tests/adapters/test_proxy.py | 12 +- tests/adapters/test_system_info.py | 2 +- tests/adapters/test_system_status.py | 4 +- tests/config/test.cfg | 2 +- tests/config/test_adapter_communication.cfg | 6 +- tests/config/test_async.cfg | 4 +- tests/config/test_async_proxy.cfg | 2 +- tests/config/test_config.py | 6 +- tests/config/test_https.cfg | 2 +- tests/config/test_proxy.cfg | 4 +- tests/config/test_system_info.cfg | 2 +- tests/config/test_system_status.cfg | 2 +- tests/handlers/fixtures.py | 10 +- tests/handlers/test_api_py2.py | 4 +- tests/handlers/test_api_py3.py | 2 +- tests/handlers/test_base.py | 4 +- tests/routes/test_api.py | 14 +- tests/routes/test_async_api_py3.py | 6 +- tests/routes/test_default.py | 2 +- tests/routes/test_route.py | 2 +- tests/test_server.py | 6 +- tests/test_util.py | 4 +- tests/test_util_py3.py | 4 +- tests/utils.py | 2 +- utils/migrate_adapter | 140 ++++++++++++++++++ 73 files changed, 289 insertions(+), 149 deletions(-) delete mode 100644 src/odin/adapters/__init__.py delete mode 100644 src/odin/config/__init__.py delete mode 100644 src/odin/http/__init__.py delete mode 100644 src/odin/http/routes/__init__.py rename src/{odin => odin_control}/__init__.py (100%) create mode 100644 src/odin_control/adapters/__init__.py rename src/{odin => odin_control}/adapters/adapter.py (98%) rename src/{odin => odin_control}/adapters/async_adapter.py (96%) rename src/{odin => odin_control}/adapters/async_dummy.py (95%) rename src/{odin => odin_control}/adapters/async_parameter_tree.py (99%) rename src/{odin => odin_control}/adapters/async_proxy.py (97%) rename src/{odin => odin_control}/adapters/base_parameter_tree.py (100%) rename src/{odin => odin_control}/adapters/base_proxy.py (99%) rename src/{odin => odin_control}/adapters/dummy.py (98%) rename src/{odin => odin_control}/adapters/parameter_tree.py (98%) rename src/{odin => odin_control}/adapters/proxy.py (97%) rename src/{odin => odin_control}/adapters/system_info.py (97%) rename src/{odin => odin_control}/adapters/system_status.py (98%) rename src/{odin => odin_control}/async_util.py (100%) create mode 100644 src/odin_control/config/__init__.py rename src/{odin => odin_control}/config/parser.py (99%) create mode 100644 src/odin_control/http/__init__.py rename src/{odin => odin_control}/http/handlers/__init__.py (100%) rename src/{odin => odin_control}/http/handlers/api.py (95%) rename src/{odin => odin_control}/http/handlers/async_api.py (96%) rename src/{odin => odin_control}/http/handlers/base.py (98%) create mode 100644 src/odin_control/http/routes/__init__.py rename src/{odin => odin_control}/http/routes/api.py (95%) rename src/{odin => odin_control}/http/routes/default.py (96%) rename src/{odin => odin_control}/http/routes/route.py (100%) rename src/{odin => odin_control}/http/server.py (96%) rename src/{odin => odin_control}/logconfig.py (100%) rename src/{odin => odin_control}/main.py (97%) rename src/{odin => odin_control}/util.py (98%) create mode 100755 utils/migrate_adapter diff --git a/.gitignore b/.gitignore index 4e08a57f..92d6d608 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ var/ *.egg-info/ .installed.cfg *.egg -src/odin/_version.py +src/odin_control/_version.py # PyInstaller # Usually these files are written by a python script from a template diff --git a/docs/server_ref/odin.adapters.rst b/docs/server_ref/odin.adapters.rst index 3f55d336..6eb5ce7f 100644 --- a/docs/server_ref/odin.adapters.rst +++ b/docs/server_ref/odin.adapters.rst @@ -1,21 +1,21 @@ -odin.adapters package +odin_control.adapters package ===================== Submodules ---------- -odin.adapters.adapter module +odin_control.adapters.adapter module ---------------------------- -.. automodule:: odin.adapters.adapter +.. automodule:: odin_control.adapters.adapter :members: :undoc-members: :show-inheritance: -odin.adapters.dummy module +odin_control.adapters.dummy module -------------------------- -.. automodule:: odin.adapters.dummy +.. automodule:: odin_control.adapters.dummy :members: :undoc-members: :show-inheritance: @@ -24,7 +24,7 @@ odin.adapters.dummy module Module contents --------------- -.. automodule:: odin.adapters +.. automodule:: odin_control.adapters :members: :undoc-members: :show-inheritance: diff --git a/docs/server_ref/odin.config.rst b/docs/server_ref/odin.config.rst index c774c499..b863ee3d 100644 --- a/docs/server_ref/odin.config.rst +++ b/docs/server_ref/odin.config.rst @@ -1,13 +1,13 @@ -odin.config package +odin_control.config package =================== Submodules ---------- -odin.config.parser module +odin_control.config.parser module ------------------------- -.. automodule:: odin.config.parser +.. automodule:: odin_control.config.parser :members: :undoc-members: :show-inheritance: @@ -16,7 +16,7 @@ odin.config.parser module Module contents --------------- -.. automodule:: odin.config +.. automodule:: odin_control.config :members: :undoc-members: :show-inheritance: diff --git a/docs/server_ref/odin.http.routes.rst b/docs/server_ref/odin.http.routes.rst index 1fc6c1da..d4e54236 100644 --- a/docs/server_ref/odin.http.routes.rst +++ b/docs/server_ref/odin.http.routes.rst @@ -1,29 +1,29 @@ -odin.http.routes package +odin_control.http.routes package ======================== Submodules ---------- -odin.http.routes.api module +odin_control.http.routes.api module --------------------------- -.. automodule:: odin.http.routes.api +.. automodule:: odin_control.http.routes.api :members: :undoc-members: :show-inheritance: -odin.http.routes.default module +odin_control.http.routes.default module ------------------------------- -.. automodule:: odin.http.routes.default +.. automodule:: odin_control.http.routes.default :members: :undoc-members: :show-inheritance: -odin.http.routes.route module +odin_control.http.routes.route module ----------------------------- -.. automodule:: odin.http.routes.route +.. automodule:: odin_control.http.routes.route :members: :undoc-members: :show-inheritance: @@ -32,7 +32,7 @@ odin.http.routes.route module Module contents --------------- -.. automodule:: odin.http.routes +.. automodule:: odin_control.http.routes :members: :undoc-members: :show-inheritance: diff --git a/docs/server_ref/odin.http.rst b/docs/server_ref/odin.http.rst index c00d3b72..a2ee7d07 100644 --- a/docs/server_ref/odin.http.rst +++ b/docs/server_ref/odin.http.rst @@ -1,4 +1,4 @@ -odin.http package +odin_control.http package ================= Subpackages @@ -6,15 +6,15 @@ Subpackages .. toctree:: - odin.http.routes + odin_control.http.routes Submodules ---------- -odin.http.server module +odin_control.http.server module ----------------------- -.. automodule:: odin.http.server +.. automodule:: odin_control.http.server :members: :undoc-members: :show-inheritance: @@ -23,7 +23,7 @@ odin.http.server module Module contents --------------- -.. automodule:: odin.http +.. automodule:: odin_control.http :members: :undoc-members: :show-inheritance: diff --git a/docs/server_ref/odin.rst b/docs/server_ref/odin.rst index fa43af37..d9bcd2cb 100644 --- a/docs/server_ref/odin.rst +++ b/docs/server_ref/odin.rst @@ -6,17 +6,17 @@ Subpackages .. toctree:: - odin.adapters - odin.config - odin.http + odin_control.adapters + odin_control.config + odin_control.http Submodules ---------- -odin.server module +odin_control.server module ------------------ -.. automodule:: odin.server +.. automodule:: odin_control.server :members: :undoc-members: :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml index fe4a652d..32b28d93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,20 +43,20 @@ graylog = [ ] [project.scripts] -odin_control = "odin.main:main" -odin_server = "odin.main:main_deprecate" +odin_control = "odin_control.main:main" +odin_server = "odin_control.main:main_deprecate" [project.urls] GitHub = "https://github.com/odin-detector/odin-control" [tool.setuptools_scm] -version_file = "src/odin/_version.py" +version_file = "src/odin_control/_version.py" [tool.coverage.paths] source = ["src", "**/site-packages/"] [tool.pytest.ini_options] -addopts = "-vv --cov=odin --cov-report=term-missing --asyncio-mode=strict" +addopts = "-vv --cov=odin_control --cov-report=term-missing --asyncio-mode=strict" [tool.tox] legacy_tox_ini = """ @@ -83,7 +83,7 @@ deps = setenv = py{38,39,310,311,312}: COVERAGE_FILE=.coverage.{envname} commands = - pytest --cov=odin --cov-report=term-missing --asyncio-mode=strict {posargs:-vv} + pytest --cov=odin_control --cov-report=term-missing --asyncio-mode=strict {posargs:-vv} depends = py{38,39,310,311,312}: clean report: py{38,39,310,311,312} diff --git a/src/odin/adapters/__init__.py b/src/odin/adapters/__init__.py deleted file mode 100644 index 01869d88..00000000 --- a/src/odin/adapters/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""odin.adapters package __init__.py.""" diff --git a/src/odin/config/__init__.py b/src/odin/config/__init__.py deleted file mode 100644 index 4bdc5bdf..00000000 --- a/src/odin/config/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""odin.config package __init__.py.""" diff --git a/src/odin/http/__init__.py b/src/odin/http/__init__.py deleted file mode 100644 index dba5177f..00000000 --- a/src/odin/http/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""odin.http package __init__.py.""" diff --git a/src/odin/http/routes/__init__.py b/src/odin/http/routes/__init__.py deleted file mode 100644 index f00ab58c..00000000 --- a/src/odin/http/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""odin.http.routes package __init__.py.""" diff --git a/src/odin/__init__.py b/src/odin_control/__init__.py similarity index 100% rename from src/odin/__init__.py rename to src/odin_control/__init__.py diff --git a/src/odin_control/adapters/__init__.py b/src/odin_control/adapters/__init__.py new file mode 100644 index 00000000..7bc2bea5 --- /dev/null +++ b/src/odin_control/adapters/__init__.py @@ -0,0 +1 @@ +"""odin_control.adapters package __init__.py.""" diff --git a/src/odin/adapters/adapter.py b/src/odin_control/adapters/adapter.py similarity index 98% rename from src/odin/adapters/adapter.py rename to src/odin_control/adapters/adapter.py index da9d3e0c..b1a9ebd8 100644 --- a/src/odin/adapters/adapter.py +++ b/src/odin_control/adapters/adapter.py @@ -1,12 +1,12 @@ """ -odin.adapters.adapter.py - base API adapter implmentation for the ODIN server. +odin_control.adapters.adapter.py - base API adapter implmentation for the ODIN server. Tim Nicholls, STFC Application Engineering Group """ import logging -from odin.util import wrap_result +from odin_control.util import wrap_result class ApiAdapter(object): """ diff --git a/src/odin/adapters/async_adapter.py b/src/odin_control/adapters/async_adapter.py similarity index 96% rename from src/odin/adapters/async_adapter.py rename to src/odin_control/adapters/async_adapter.py index 25a53f4f..d198cbae 100644 --- a/src/odin/adapters/async_adapter.py +++ b/src/odin_control/adapters/async_adapter.py @@ -1,5 +1,5 @@ """ -odin.adapters.adapter.py - base asynchronous API adapter implmentation for the ODIN server. +odin_control.adapters.adapter.py - base asynchronous API adapter implmentation for the ODIN server. Tim Nicholls, STFC Detector Systems Software Group """ @@ -8,7 +8,7 @@ import logging import inspect -from odin.adapters.adapter import ApiAdapter, ApiAdapterResponse +from odin_control.adapters.adapter import ApiAdapter, ApiAdapterResponse class AsyncApiAdapter(ApiAdapter): diff --git a/src/odin/adapters/async_dummy.py b/src/odin_control/adapters/async_dummy.py similarity index 95% rename from src/odin/adapters/async_dummy.py rename to src/odin_control/adapters/async_dummy.py index 753145e0..5ae91c7e 100644 --- a/src/odin/adapters/async_dummy.py +++ b/src/odin_control/adapters/async_dummy.py @@ -11,11 +11,11 @@ import time import concurrent.futures -from odin.adapters.adapter import ApiAdapterResponse, request_types, response_types -from odin.adapters.async_adapter import AsyncApiAdapter -from odin.adapters.async_parameter_tree import AsyncParameterTree -from odin.adapters.base_parameter_tree import ParameterTreeError -from odin.util import decode_request_body, run_in_executor +from odin_control.adapters.adapter import ApiAdapterResponse, request_types, response_types +from odin_control.adapters.async_adapter import AsyncApiAdapter +from odin_control.adapters.async_parameter_tree import AsyncParameterTree +from odin_control.adapters.base_parameter_tree import ParameterTreeError +from odin_control.util import decode_request_body, run_in_executor class AsyncDummyAdapter(AsyncApiAdapter): diff --git a/src/odin/adapters/async_parameter_tree.py b/src/odin_control/adapters/async_parameter_tree.py similarity index 99% rename from src/odin/adapters/async_parameter_tree.py rename to src/odin_control/adapters/async_parameter_tree.py index e2130dab..d93302d9 100644 --- a/src/odin/adapters/async_parameter_tree.py +++ b/src/odin_control/adapters/async_parameter_tree.py @@ -9,7 +9,7 @@ import asyncio -from odin.adapters.base_parameter_tree import ( +from odin_control.adapters.base_parameter_tree import ( BaseParameterAccessor, BaseParameterTree, ParameterTreeError ) diff --git a/src/odin/adapters/async_proxy.py b/src/odin_control/adapters/async_proxy.py similarity index 97% rename from src/odin/adapters/async_proxy.py rename to src/odin_control/adapters/async_proxy.py index 97ab6a34..1ec3078d 100644 --- a/src/odin/adapters/async_proxy.py +++ b/src/odin_control/adapters/async_proxy.py @@ -13,20 +13,20 @@ import tornado from tornado.httpclient import AsyncHTTPClient, HTTPRequest -from odin.adapters.adapter import ( +from odin_control.adapters.adapter import ( ApiAdapterResponse, request_types, response_types, wants_metadata ) -from odin.adapters.async_adapter import AsyncApiAdapter -from odin.adapters.base_proxy import ( +from odin_control.adapters.async_adapter import AsyncApiAdapter +from odin_control.adapters.base_proxy import ( BaseProxyAdapter, BaseProxyTarget, ProxyError, ProxyResponse ) -from odin.util import decode_request_body +from odin_control.util import decode_request_body class AsyncProxyTarget(BaseProxyTarget): diff --git a/src/odin/adapters/base_parameter_tree.py b/src/odin_control/adapters/base_parameter_tree.py similarity index 100% rename from src/odin/adapters/base_parameter_tree.py rename to src/odin_control/adapters/base_parameter_tree.py diff --git a/src/odin/adapters/base_proxy.py b/src/odin_control/adapters/base_proxy.py similarity index 99% rename from src/odin/adapters/base_proxy.py rename to src/odin_control/adapters/base_proxy.py index f8663c0c..1f86afd7 100644 --- a/src/odin/adapters/base_proxy.py +++ b/src/odin_control/adapters/base_proxy.py @@ -15,7 +15,7 @@ import tornado.httpclient from tornado.escape import json_decode, json_encode -from odin.adapters.parameter_tree import ParameterTree, ParameterTreeError +from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError @dataclass diff --git a/src/odin/adapters/dummy.py b/src/odin_control/adapters/dummy.py similarity index 98% rename from src/odin/adapters/dummy.py rename to src/odin_control/adapters/dummy.py index 9da4dbd4..02af5a30 100644 --- a/src/odin/adapters/dummy.py +++ b/src/odin_control/adapters/dummy.py @@ -12,9 +12,9 @@ import logging from tornado.ioloop import PeriodicCallback -from odin.adapters.adapter import (ApiAdapter, ApiAdapterRequest, +from odin_control.adapters.adapter import (ApiAdapter, ApiAdapterRequest, ApiAdapterResponse, request_types, response_types) -from odin.util import decode_request_body +from odin_control.util import decode_request_body class DummyAdapter(ApiAdapter): diff --git a/src/odin/adapters/parameter_tree.py b/src/odin_control/adapters/parameter_tree.py similarity index 98% rename from src/odin/adapters/parameter_tree.py rename to src/odin_control/adapters/parameter_tree.py index ecd20619..c754e9fb 100644 --- a/src/odin/adapters/parameter_tree.py +++ b/src/odin_control/adapters/parameter_tree.py @@ -6,7 +6,7 @@ Tim Nicholls, STFC Detector Systems Software Group. """ -from odin.adapters.base_parameter_tree import ( +from odin_control.adapters.base_parameter_tree import ( BaseParameterAccessor, BaseParameterTree, ParameterTreeError ) diff --git a/src/odin/adapters/proxy.py b/src/odin_control/adapters/proxy.py similarity index 97% rename from src/odin/adapters/proxy.py rename to src/odin_control/adapters/proxy.py index 720efe80..9539efe7 100644 --- a/src/odin/adapters/proxy.py +++ b/src/odin_control/adapters/proxy.py @@ -16,20 +16,20 @@ "Cannot create a ProxyAdapter instance as requests package not installed" ) -from odin.adapters.adapter import ( +from odin_control.adapters.adapter import ( ApiAdapter, ApiAdapterResponse, request_types, response_types, wants_metadata, ) -from odin.adapters.base_proxy import ( +from odin_control.adapters.base_proxy import ( BaseProxyAdapter, BaseProxyTarget, ProxyError, ProxyResponse, ) -from odin.util import decode_request_body +from odin_control.util import decode_request_body class ProxyTarget(BaseProxyTarget): diff --git a/src/odin/adapters/system_info.py b/src/odin_control/adapters/system_info.py similarity index 97% rename from src/odin/adapters/system_info.py rename to src/odin_control/adapters/system_info.py index 3beb0d3b..23267333 100644 --- a/src/odin/adapters/system_info.py +++ b/src/odin_control/adapters/system_info.py @@ -11,10 +11,10 @@ import tornado from future.utils import with_metaclass -from odin.adapters.adapter import (ApiAdapter, ApiAdapterResponse, +from odin_control.adapters.adapter import (ApiAdapter, ApiAdapterResponse, request_types, response_types, wants_metadata) -from odin.adapters.parameter_tree import ParameterTree, ParameterTreeError -from odin._version import __version__ +from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError +from odin_control._version import __version__ class SystemInfoAdapter(ApiAdapter): diff --git a/src/odin/adapters/system_status.py b/src/odin_control/adapters/system_status.py similarity index 98% rename from src/odin/adapters/system_status.py rename to src/odin_control/adapters/system_status.py index e731f118..8c3a8764 100644 --- a/src/odin/adapters/system_status.py +++ b/src/odin_control/adapters/system_status.py @@ -3,7 +3,7 @@ Example config file section for odin-control: [adapter.status] -module = odin.adapters.system_status.SystemStatusAdapter +module = odin_control.adapters.system_status.SystemStatusAdapter disks = /home/gnx91527 interfaces = p3p1, p3p2 processes = stFrameProcessor1.sh, stFrameProcessor3.sh @@ -26,8 +26,8 @@ import psutil from future.utils import with_metaclass from tornado.ioloop import IOLoop -from odin.adapters.adapter import ApiAdapter, ApiAdapterResponse, request_types, response_types -from odin.adapters.parameter_tree import ParameterTree, ParameterTreeError +from odin_control.adapters.adapter import ApiAdapter, ApiAdapterResponse, request_types, response_types +from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError class SystemStatusAdapter(ApiAdapter): diff --git a/src/odin/async_util.py b/src/odin_control/async_util.py similarity index 100% rename from src/odin/async_util.py rename to src/odin_control/async_util.py diff --git a/src/odin_control/config/__init__.py b/src/odin_control/config/__init__.py new file mode 100644 index 00000000..4d751ce0 --- /dev/null +++ b/src/odin_control/config/__init__.py @@ -0,0 +1 @@ +"""odin_control.config package __init__.py.""" diff --git a/src/odin/config/parser.py b/src/odin_control/config/parser.py similarity index 99% rename from src/odin/config/parser.py rename to src/odin_control/config/parser.py index 4f719ef2..6f8fa508 100644 --- a/src/odin/config/parser.py +++ b/src/odin_control/config/parser.py @@ -1,8 +1,8 @@ -"""odin.config.parser - configuration parsing for the ODIN server. +"""odin_control.config.parser - configuration parsing for the ODIN server. Tim Nicholls, STFC Application Engineering Group """ -from odin._version import __version__ +from odin_control._version import __version__ import sys from argparse import ArgumentParser diff --git a/src/odin_control/http/__init__.py b/src/odin_control/http/__init__.py new file mode 100644 index 00000000..2e9f9966 --- /dev/null +++ b/src/odin_control/http/__init__.py @@ -0,0 +1 @@ +"""odin_control.http package __init__.py.""" diff --git a/src/odin/http/handlers/__init__.py b/src/odin_control/http/handlers/__init__.py similarity index 100% rename from src/odin/http/handlers/__init__.py rename to src/odin_control/http/handlers/__init__.py diff --git a/src/odin/http/handlers/api.py b/src/odin_control/http/handlers/api.py similarity index 95% rename from src/odin/http/handlers/api.py rename to src/odin_control/http/handlers/api.py index cb17c3ab..1ff8e2c2 100644 --- a/src/odin/http/handlers/api.py +++ b/src/odin_control/http/handlers/api.py @@ -6,7 +6,7 @@ Tim Nicholls, STFC Detector Systems Software Group. """ -from odin.http.handlers.base import BaseApiHandler, validate_api_request, API_VERSION +from odin_control.http.handlers.base import BaseApiHandler, validate_api_request, API_VERSION class ApiHandler(BaseApiHandler): diff --git a/src/odin/http/handlers/async_api.py b/src/odin_control/http/handlers/async_api.py similarity index 96% rename from src/odin/http/handlers/async_api.py rename to src/odin_control/http/handlers/async_api.py index 852aca99..6faff865 100644 --- a/src/odin/http/handlers/async_api.py +++ b/src/odin_control/http/handlers/async_api.py @@ -6,7 +6,7 @@ Tim Nicholls, STFC Detector Systems Software Group. """ -from odin.http.handlers.base import BaseApiHandler, validate_api_request, API_VERSION +from odin_control.http.handlers.base import BaseApiHandler, validate_api_request, API_VERSION class AsyncApiHandler(BaseApiHandler): diff --git a/src/odin/http/handlers/base.py b/src/odin_control/http/handlers/base.py similarity index 98% rename from src/odin/http/handlers/base.py rename to src/odin_control/http/handlers/base.py index 5ec76f92..b98705a3 100644 --- a/src/odin/http/handlers/base.py +++ b/src/odin_control/http/handlers/base.py @@ -8,8 +8,8 @@ import tornado.web -from odin.adapters.adapter import ApiAdapterResponse -from odin.util import wrap_result +from odin_control.adapters.adapter import ApiAdapterResponse +from odin_control.util import wrap_result API_VERSION = 0.1 diff --git a/src/odin_control/http/routes/__init__.py b/src/odin_control/http/routes/__init__.py new file mode 100644 index 00000000..8b297916 --- /dev/null +++ b/src/odin_control/http/routes/__init__.py @@ -0,0 +1 @@ +"""odin_control.http.routes package __init__.py.""" diff --git a/src/odin/http/routes/api.py b/src/odin_control/http/routes/api.py similarity index 95% rename from src/odin/http/routes/api.py rename to src/odin_control/http/routes/api.py index ef7097ba..40e8c496 100644 --- a/src/odin/http/routes/api.py +++ b/src/odin_control/http/routes/api.py @@ -12,14 +12,14 @@ import tornado.web -from odin.http.routes.route import Route -from odin.util import PY3 -from odin.http.handlers.base import ApiError, API_VERSION +from odin_control.http.routes.route import Route +from odin_control.util import PY3 +from odin_control.http.handlers.base import ApiError, API_VERSION if PY3: - from odin.http.handlers.async_api import AsyncApiHandler as ApiHandler - from odin.async_util import run_async + from odin_control.http.handlers.async_api import AsyncApiHandler as ApiHandler + from odin_control.async_util import run_async else: - from odin.http.handlers.api import ApiHandler + from odin_control.http.handlers.api import ApiHandler class ApiVersionHandler(tornado.web.RequestHandler): diff --git a/src/odin/http/routes/default.py b/src/odin_control/http/routes/default.py similarity index 96% rename from src/odin/http/routes/default.py rename to src/odin_control/http/routes/default.py index 44fcd5e4..8c698706 100644 --- a/src/odin/http/routes/default.py +++ b/src/odin_control/http/routes/default.py @@ -8,7 +8,7 @@ import logging import os import tornado.web -from odin.http.routes.route import Route +from odin_control.http.routes.route import Route class DefaultHandler(tornado.web.StaticFileHandler): diff --git a/src/odin/http/routes/route.py b/src/odin_control/http/routes/route.py similarity index 100% rename from src/odin/http/routes/route.py rename to src/odin_control/http/routes/route.py diff --git a/src/odin/http/server.py b/src/odin_control/http/server.py similarity index 96% rename from src/odin/http/server.py rename to src/odin_control/http/server.py index 6d457f9f..c40e5cc7 100644 --- a/src/odin/http/server.py +++ b/src/odin_control/http/server.py @@ -1,4 +1,4 @@ -"""odin.http.server - ODIN HTTP Server class. +"""odin_control.http.server - ODIN HTTP Server class. This module provides the core HTTP server class used in ODIN, which handles all client requests, handing off API requests to the appropriate API route and adapter plugins, and defining the @@ -14,9 +14,9 @@ import tornado.web from tornado.log import access_log -from odin.config.parser import ConfigError -from odin.http.routes.api import ApiRoute -from odin.http.routes.default import DefaultRoute +from odin_control.config.parser import ConfigError +from odin_control.http.routes.api import ApiRoute +from odin_control.http.routes.default import DefaultRoute class HttpServer(object): diff --git a/src/odin/logconfig.py b/src/odin_control/logconfig.py similarity index 100% rename from src/odin/logconfig.py rename to src/odin_control/logconfig.py diff --git a/src/odin/main.py b/src/odin_control/main.py similarity index 97% rename from src/odin/main.py rename to src/odin_control/main.py index 0b7b1603..9d594f50 100644 --- a/src/odin/main.py +++ b/src/odin_control/main.py @@ -13,9 +13,9 @@ import tornado.ioloop from tornado.autoreload import add_reload_hook -from odin.http.server import HttpServer -from odin.config.parser import ConfigParser, ConfigError -from odin.logconfig import add_graylog_handler +from odin_control.http.server import HttpServer +from odin_control.config.parser import ConfigParser, ConfigError +from odin_control.logconfig import add_graylog_handler _stop_ioloop = False # Global variable to indicate ioloop should be shut down diff --git a/src/odin/util.py b/src/odin_control/util.py similarity index 98% rename from src/odin/util.py rename to src/odin_control/util.py index 2ab32dad..5a9c5fb0 100644 --- a/src/odin/util.py +++ b/src/odin_control/util.py @@ -11,7 +11,7 @@ PY3 = sys.version_info >= (3,) if PY3: - from odin.async_util import get_async_event_loop, wrap_async + from odin_control.async_util import get_async_event_loop, wrap_async unicode = str diff --git a/tests/adapters/test_adapter.py b/tests/adapters/test_adapter.py index a553b23a..499e0f35 100644 --- a/tests/adapters/test_adapter.py +++ b/tests/adapters/test_adapter.py @@ -7,7 +7,7 @@ else: # pragma: no cover from mock import Mock -from odin.adapters.adapter import (ApiAdapter, ApiAdapterResponse, ApiAdapterRequest, +from odin_control.adapters.adapter import (ApiAdapter, ApiAdapterResponse, ApiAdapterRequest, request_types, response_types, wants_metadata) class ApiAdapterTestFixture(object): diff --git a/tests/adapters/test_async_adapter_py3.py b/tests/adapters/test_async_adapter_py3.py index aae0c9c8..253b32fb 100644 --- a/tests/adapters/test_async_adapter_py3.py +++ b/tests/adapters/test_async_adapter_py3.py @@ -5,7 +5,7 @@ if sys.version_info[0] < 3: pytest.skip("Skipping async tests", allow_module_level=True) else: - from odin.adapters.async_adapter import AsyncApiAdapter + from odin_control.adapters.async_adapter import AsyncApiAdapter from unittest.mock import Mock class AsyncApiAdapterTestFixture(object): diff --git a/tests/adapters/test_async_dummy_py3.py b/tests/adapters/test_async_dummy_py3.py index 96f6d1e9..56831ae0 100644 --- a/tests/adapters/test_async_dummy_py3.py +++ b/tests/adapters/test_async_dummy_py3.py @@ -6,7 +6,7 @@ pytest.skip("Skipping async tests", allow_module_level=True) else: import asyncio - from odin.adapters.async_dummy import AsyncDummyAdapter + from odin_control.adapters.async_dummy import AsyncDummyAdapter from unittest.mock import Mock from tests.async_utils import AwaitableTestFixture, asyncio_fixture_decorator diff --git a/tests/adapters/test_async_parameter_tree_py3.py b/tests/adapters/test_async_parameter_tree_py3.py index 998e94b1..b0774496 100644 --- a/tests/adapters/test_async_parameter_tree_py3.py +++ b/tests/adapters/test_async_parameter_tree_py3.py @@ -19,7 +19,7 @@ else: from tests.async_utils import AwaitableTestFixture, asyncio_fixture_decorator - from odin.adapters.async_parameter_tree import ( + from odin_control.adapters.async_parameter_tree import ( AsyncParameterAccessor, AsyncParameterTree, ParameterTreeError ) diff --git a/tests/adapters/test_async_proxy_py3.py b/tests/adapters/test_async_proxy_py3.py index 9e95c7dc..7b4b8017 100644 --- a/tests/adapters/test_async_proxy_py3.py +++ b/tests/adapters/test_async_proxy_py3.py @@ -15,10 +15,10 @@ else: from tornado.ioloop import TimeoutError from tornado.httpclient import HTTPResponse - from odin.adapters.async_proxy import AsyncProxyTarget, AsyncProxyAdapter + from odin_control.adapters.async_proxy import AsyncProxyTarget, AsyncProxyAdapter from unittest.mock import Mock from tests.adapters.test_proxy import ProxyTestHandler, ProxyTargetTestFixture, ProxyTestServer - from odin.util import convert_unicode_to_string + from odin_control.util import convert_unicode_to_string from tests.utils import log_message_seen from tests.async_utils import AwaitableTestFixture, asyncio_fixture_decorator try: diff --git a/tests/adapters/test_dummy.py b/tests/adapters/test_dummy.py index 096143e0..fbc7d4cf 100644 --- a/tests/adapters/test_dummy.py +++ b/tests/adapters/test_dummy.py @@ -2,8 +2,8 @@ import pytest -from odin.adapters.dummy import DummyAdapter, IacDummyAdapter -from odin.adapters.adapter import (ApiAdapter, ApiAdapterRequest, +from odin_control.adapters.dummy import DummyAdapter, IacDummyAdapter +from odin_control.adapters.adapter import (ApiAdapter, ApiAdapterRequest, ApiAdapterResponse, request_types, response_types) if sys.version_info[0] == 3: # pragma: no cover diff --git a/tests/adapters/test_parameter_tree.py b/tests/adapters/test_parameter_tree.py index 346f8394..efe48b5b 100644 --- a/tests/adapters/test_parameter_tree.py +++ b/tests/adapters/test_parameter_tree.py @@ -7,7 +7,7 @@ import pytest -from odin.adapters.parameter_tree import ParameterAccessor, ParameterTree, ParameterTreeError +from odin_control.adapters.parameter_tree import ParameterAccessor, ParameterTree, ParameterTreeError class ParameterAccessorTestFixture(object): diff --git a/tests/adapters/test_proxy.py b/tests/adapters/test_proxy.py index 9e67b7b2..047843a3 100644 --- a/tests/adapters/test_proxy.py +++ b/tests/adapters/test_proxy.py @@ -21,10 +21,10 @@ from tornado.httpserver import HTTPServer import tornado.gen -from odin.adapters.proxy import ProxyTarget, ProxyAdapter -from odin.adapters.parameter_tree import ParameterTree, ParameterTreeError -from odin.adapters.adapter import wants_metadata -from odin.util import convert_unicode_to_string +from odin_control.adapters.proxy import ProxyTarget, ProxyAdapter +from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError +from odin_control.adapters.adapter import wants_metadata +from odin_control.util import convert_unicode_to_string from tests.utils import log_message_seen if sys.version_info[0] == 3: # pragma: no cover @@ -318,11 +318,11 @@ def test_requests_missing(self, monkeypatch): requests package is not installed. """ monkeypatch.delitem(sys.modules, 'requests', raising=False) - monkeypatch.delitem(sys.modules, 'odin.adapters.proxy') + monkeypatch.delitem(sys.modules, 'odin_control.adapters.proxy') monkeypatch.setattr(builtins, '__import__', monkey_import_importerror) with pytest.raises(ImportError) as excinfo: - from odin.adapters.proxy import ProxyAdapter + from odin_control.adapters.proxy import ProxyAdapter assert("requests package not installed" in str(excinfo.value)) diff --git a/tests/adapters/test_system_info.py b/tests/adapters/test_system_info.py index 0e82a592..049d238d 100644 --- a/tests/adapters/test_system_info.py +++ b/tests/adapters/test_system_info.py @@ -12,7 +12,7 @@ else: # pragma: no cover from mock import Mock -from odin.adapters.system_info import SystemInfoAdapter, SystemInfo +from odin_control.adapters.system_info import SystemInfoAdapter, SystemInfo @pytest.fixture(scope="class") def test_system_info(): diff --git a/tests/adapters/test_system_status.py b/tests/adapters/test_system_status.py index 553b1bb0..e574a3c8 100644 --- a/tests/adapters/test_system_status.py +++ b/tests/adapters/test_system_status.py @@ -15,8 +15,8 @@ else: # pragma: no cover from mock import Mock, patch -from odin.adapters.system_status import SystemStatusAdapter, SystemStatus, Singleton -from odin.adapters.parameter_tree import ParameterTreeError +from odin_control.adapters.system_status import SystemStatusAdapter, SystemStatus, Singleton +from odin_control.adapters.parameter_tree import ParameterTreeError from tests.utils import log_message_seen diff --git a/tests/config/test.cfg b/tests/config/test.cfg index bf5afc72..5bf6ab49 100644 --- a/tests/config/test.cfg +++ b/tests/config/test.cfg @@ -9,6 +9,6 @@ adapters = dummy logging = debug [adapter.dummy] -module = odin.adapters.dummy.DummyAdapter +module = odin_control.adapters.dummy.DummyAdapter background_task_enable = 1 background_task_interval = 1.0 diff --git a/tests/config/test_adapter_communication.cfg b/tests/config/test_adapter_communication.cfg index 5c3579c5..e4751d05 100644 --- a/tests/config/test_adapter_communication.cfg +++ b/tests/config/test_adapter_communication.cfg @@ -9,12 +9,12 @@ adapters = iacDummy, iacTarget1, system_info logging = debug [adapter.iacDummy] -module = odin.adapters.dummy.IacDummyAdapter +module = odin_control.adapters.dummy.IacDummyAdapter [adapter.iacTarget1] -module = odin.adapters.dummy.DummyAdapter +module = odin_control.adapters.dummy.DummyAdapter background_task_enable = 0 background_task_interval = 1.0 [adapter.system_info] -module = odin.adapters.system_info.SystemInfoAdapter +module = odin_control.adapters.system_info.SystemInfoAdapter diff --git a/tests/config/test_async.cfg b/tests/config/test_async.cfg index 0afb0a96..c52eacf4 100644 --- a/tests/config/test_async.cfg +++ b/tests/config/test_async.cfg @@ -9,11 +9,11 @@ adapters = async, dummy logging = debug [adapter.async] -module = odin.adapters.async_dummy.AsyncDummyAdapter +module = odin_control.adapters.async_dummy.AsyncDummyAdapter async_sleep_duration = 1.5 wrap_sync_sleep = 1 [adapter.dummy] -module = odin.adapters.dummy.DummyAdapter +module = odin_control.adapters.dummy.DummyAdapter background_task_enable = 1 background_task_interval = 1.0 diff --git a/tests/config/test_async_proxy.cfg b/tests/config/test_async_proxy.cfg index 2b2abeae..b1c6623b 100644 --- a/tests/config/test_async_proxy.cfg +++ b/tests/config/test_async_proxy.cfg @@ -9,7 +9,7 @@ adapters = proxy logging = debug [adapter.proxy] -module = odin.adapters.async_proxy.AsyncProxyAdapter +module = odin_control.adapters.async_proxy.AsyncProxyAdapter targets = node_1 = http://127.0.0.1:8888/api/0.1/system_info/, node_2 = http://127.0.0.1:8887/api/0.1/system_info/ diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 78771c2a..9247e294 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -14,7 +14,7 @@ from StringIO import StringIO from ConfigParser import SafeConfigParser as NativeConfigParser -from odin.config.parser import ConfigParser, ConfigOption, ConfigError, AdapterConfig, _parse_multiple_arg +from odin_control.config.parser import ConfigParser, ConfigOption, ConfigError, AdapterConfig, _parse_multiple_arg class TestConfigOption(): @@ -122,11 +122,11 @@ def __init__(self): self.adapters = ['dummy', 'dummy2'] self.options = { 'dummy' : { - 'module' : 'odin.adapters.dummy.DummyAdapter', + 'module' : 'odin_control.adapters.dummy.DummyAdapter', 'test_param' : '13.46', }, 'dummy2' : { - 'module' : 'odin.adapters.dummy.DummyAdapter', + 'module' : 'odin_control.adapters.dummy.DummyAdapter', 'other_param' : 'wibble', }, } diff --git a/tests/config/test_https.cfg b/tests/config/test_https.cfg index 9df0a371..353e9607 100644 --- a/tests/config/test_https.cfg +++ b/tests/config/test_https.cfg @@ -13,6 +13,6 @@ adapters = dummy logging = debug [adapter.dummy] -module = odin.adapters.dummy.DummyAdapter +module = odin_control.adapters.dummy.DummyAdapter background_task_enable = 1 background_task_interval = 1.0 diff --git a/tests/config/test_proxy.cfg b/tests/config/test_proxy.cfg index f70c3919..b10a07e0 100644 --- a/tests/config/test_proxy.cfg +++ b/tests/config/test_proxy.cfg @@ -9,8 +9,8 @@ adapters = proxy logging = debug [adapter.proxy] -module = odin.adapters.proxy.ProxyAdapter -targets = +module = odin_control.adapters.proxy.ProxyAdapter +targets = node_1 = http://127.0.0.1:8888/api/0.1/system_info/, node_2 = http://127.0.0.1:8887/api/0.1/system_info/ request_timeout = 2.0 diff --git a/tests/config/test_system_info.cfg b/tests/config/test_system_info.cfg index b99b6114..6f728560 100644 --- a/tests/config/test_system_info.cfg +++ b/tests/config/test_system_info.cfg @@ -9,4 +9,4 @@ adapters = system_info logging = debug [adapter.system_info] -module = odin.adapters.system_info.SystemInfoAdapter +module = odin_control.adapters.system_info.SystemInfoAdapter diff --git a/tests/config/test_system_status.cfg b/tests/config/test_system_status.cfg index 4004f3f7..bea1e36f 100644 --- a/tests/config/test_system_status.cfg +++ b/tests/config/test_system_status.cfg @@ -9,7 +9,7 @@ adapters = status logging = debug [adapter.status] -module = odin.adapters.system_status.SystemStatusAdapter +module = odin_control.adapters.system_status.SystemStatusAdapter disks = /home interfaces = p3p1, p3p2 processes = stFrameReceiver1.sh, stFrameProcessor1.sh, stFrameReceiver3.sh, stFrameProcessor3.sh diff --git a/tests/handlers/fixtures.py b/tests/handlers/fixtures.py index f2ec7a57..5ec7d305 100644 --- a/tests/handlers/fixtures.py +++ b/tests/handlers/fixtures.py @@ -11,11 +11,11 @@ from mock import Mock async_allowed = False -from odin.http.handlers.base import BaseApiHandler, API_VERSION, ApiError, validate_api_request -from odin.http.routes.api import ApiHandler -from odin.adapters.adapter import ApiAdapterResponse -from odin.config.parser import AdapterConfig -from odin.util import wrap_result +from odin_control.http.handlers.base import BaseApiHandler, API_VERSION, ApiError, validate_api_request +from odin_control.http.routes.api import ApiHandler +from odin_control.adapters.adapter import ApiAdapterResponse +from odin_control.config.parser import AdapterConfig +from odin_control.util import wrap_result class TestHandler(object): diff --git a/tests/handlers/test_api_py2.py b/tests/handlers/test_api_py2.py index 4fdff926..85fea126 100644 --- a/tests/handlers/test_api_py2.py +++ b/tests/handlers/test_api_py2.py @@ -8,8 +8,8 @@ else: # pragma: no cover from mock import Mock -from odin.http.routes.api import ApiRoute, ApiHandler, ApiError, API_VERSION -from odin.config.parser import AdapterConfig +from odin_control.http.routes.api import ApiRoute, ApiHandler, ApiError, API_VERSION +from odin_control.config.parser import AdapterConfig from tests.handlers.fixtures import test_api_handler class TestApiHandler(object): diff --git a/tests/handlers/test_api_py3.py b/tests/handlers/test_api_py3.py index 073ca101..12a7bb34 100644 --- a/tests/handlers/test_api_py3.py +++ b/tests/handlers/test_api_py3.py @@ -9,7 +9,7 @@ from mock import Mock pytest.skip("Skipping async tests", allow_module_level=True) -from odin.http.handlers.base import BaseApiHandler, API_VERSION +from odin_control.http.handlers.base import BaseApiHandler, API_VERSION from tests.handlers.fixtures import test_api_handler class TestApiHandler(object): diff --git a/tests/handlers/test_base.py b/tests/handlers/test_base.py index c4f64fe6..f8b015f7 100644 --- a/tests/handlers/test_base.py +++ b/tests/handlers/test_base.py @@ -9,8 +9,8 @@ from mock import Mock -from odin.http.handlers.base import BaseApiHandler, API_VERSION, ApiError, validate_api_request -from odin.adapters.adapter import ApiAdapterResponse +from odin_control.http.handlers.base import BaseApiHandler, API_VERSION, ApiError, validate_api_request +from odin_control.adapters.adapter import ApiAdapterResponse from tests.handlers.fixtures import test_base_handler, test_base_handler_cors diff --git a/tests/routes/test_api.py b/tests/routes/test_api.py index e8deff3b..c3d1c808 100644 --- a/tests/routes/test_api.py +++ b/tests/routes/test_api.py @@ -8,8 +8,8 @@ else: # pragma: no cover from mock import Mock -from odin.http.routes.api import ApiRoute, ApiHandler, ApiError, API_VERSION -from odin.config.parser import AdapterConfig +from odin_control.http.routes.api import ApiRoute, ApiHandler, ApiError, API_VERSION +from odin_control.config.parser import AdapterConfig @pytest.fixture(scope="class") def test_api_route(): @@ -26,7 +26,7 @@ def test_api_route_has_handlers(self, test_api_route): def test_register_adapter(self, test_api_route): """Test that it is possible to register an adapter with the API route object.""" - adapter_config = AdapterConfig('dummy', 'odin.adapters.dummy.DummyAdapter') + adapter_config = AdapterConfig('dummy', 'odin_control.adapters.dummy.DummyAdapter') test_api_route.register_adapter(adapter_config) assert test_api_route.has_adapter('dummy') @@ -34,7 +34,7 @@ def test_register_adapter(self, test_api_route): def test_register_adapter_badmodule(self, test_api_route): """Test that registering an adapter with a bad module name raises an error.""" adapter_name = 'dummy' - adapter_config = AdapterConfig(adapter_name, 'odin.adapters.bad_dummy.DummyAdapter') + adapter_config = AdapterConfig(adapter_name, 'odin_control.adapters.bad_dummy.DummyAdapter') with pytest.raises(ApiError) as excinfo: test_api_route.register_adapter(adapter_config, fail_ok=False) @@ -43,7 +43,7 @@ def test_register_adapter_badmodule(self, test_api_route): def test_register_adapter_badclass(self, test_api_route): """Test that registering an adapter with a bad class name raises an error.""" - adapter_config = AdapterConfig('dummy', 'odin.adapters.dummy.BadAdapter') + adapter_config = AdapterConfig('dummy', 'odin_control.adapters.dummy.BadAdapter') with pytest.raises(ApiError) as excinfo: test_api_route.register_adapter(adapter_config, fail_ok=False) @@ -56,7 +56,7 @@ def test_register_adapter_no_cleanup(self, test_api_route): cause an error """ adapter_name = 'dummy_no_clean' - adapter_config = AdapterConfig(adapter_name, 'odin.adapters.dummy.DummyAdapter') + adapter_config = AdapterConfig(adapter_name, 'odin_control.adapters.dummy.DummyAdapter') test_api_route.register_adapter(adapter_config) test_api_route.adapters[adapter_name].cleanup = Mock(side_effect=AttributeError()) @@ -74,7 +74,7 @@ def test_register_adapter_no_initialize(self, test_api_route): raise an error. """ adapter_name = 'dummy_no_clean' - adapter_config = AdapterConfig(adapter_name, 'odin.adapters.dummy.DummyAdapter') + adapter_config = AdapterConfig(adapter_name, 'odin_control.adapters.dummy.DummyAdapter') test_api_route.register_adapter(adapter_config) test_api_route.adapters[adapter_name].initialize = Mock(side_effect=AttributeError()) diff --git a/tests/routes/test_async_api_py3.py b/tests/routes/test_async_api_py3.py index 066e40c3..58fea263 100644 --- a/tests/routes/test_async_api_py3.py +++ b/tests/routes/test_async_api_py3.py @@ -11,8 +11,8 @@ from tests.async_utils import AsyncMock -from odin.http.routes.api import ApiRoute -from odin.config.parser import AdapterConfig +from odin_control.http.routes.api import ApiRoute +from odin_control.config.parser import AdapterConfig class ApiRouteAsyncTestFixture(object): @@ -20,7 +20,7 @@ def __init__(self): self.route = ApiRoute() self.adapter_name = 'async_dummy' - self.adapter_module = 'odin.adapters.async_dummy.AsyncDummyAdapter' + self.adapter_module = 'odin_control.adapters.async_dummy.AsyncDummyAdapter' self.adapter_config = AdapterConfig(self.adapter_name, self.adapter_module) self.route.register_adapter(self.adapter_config) diff --git a/tests/routes/test_default.py b/tests/routes/test_default.py index fe76a198..347ff7bf 100644 --- a/tests/routes/test_default.py +++ b/tests/routes/test_default.py @@ -8,7 +8,7 @@ import pytest -from odin.http.routes.default import DefaultRoute +from odin_control.http.routes.default import DefaultRoute from tests.utils import log_message_seen class TestDefaultRoute(): diff --git a/tests/routes/test_route.py b/tests/routes/test_route.py index 94128657..7d6a2783 100644 --- a/tests/routes/test_route.py +++ b/tests/routes/test_route.py @@ -1,6 +1,6 @@ #from nose.tools import * -from odin.http.routes.route import Route +from odin_control.http.routes.route import Route class DummyHandler(object): pass diff --git a/tests/test_server.py b/tests/test_server.py index fa668278..b7eca0fc 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -9,8 +9,8 @@ else: # pragma: no cover import mock -from odin.http.server import HttpServer -from odin import main +from odin_control.http.server import HttpServer +from odin_control import main from tests.utils import OdinTestServer, log_message_seen from tests.ssl_utils import SslTestCert @@ -20,7 +20,7 @@ def odin_test_server(): """Test fixture for starting an odin test server instance with a dummy adapter loaded.""" adapter_config = { 'dummy': { - 'module': 'odin.adapters.dummy.DummyAdapter', + 'module': 'odin_control.adapters.dummy.DummyAdapter', 'background_task_enable': 1, 'background_task_interval': 0.1, } diff --git a/tests/test_util.py b/tests/test_util.py index 41f396f0..da41dabe 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -11,10 +11,10 @@ else: # pragma: no cover from mock import Mock -from odin import util +from odin_control import util class TestUtil(): - """Class to test utility functions in odin.util""" + """Class to test utility functions in odin_control.util""" def test_decode_request_body(self): """Test that the body a a request is correctly decoded.""" diff --git a/tests/test_util_py3.py b/tests/test_util_py3.py index b98fb5e3..2c2e7eb7 100644 --- a/tests/test_util_py3.py +++ b/tests/test_util_py3.py @@ -5,8 +5,8 @@ import pytest_asyncio -from odin import util -from odin import async_util +from odin_control import util +from odin_control import async_util if sys.version_info[0] < 3: pytest.skip("Skipping async tests", allow_module_level=True) diff --git a/tests/utils.py b/tests/utils.py index ea44e43f..6de03e2f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -20,7 +20,7 @@ from tornado.ioloop import IOLoop -from odin import main +from odin_control import main def log_message_seen(caplog, level, message, when="call"): diff --git a/utils/migrate_adapter b/utils/migrate_adapter new file mode 100755 index 00000000..26d180e9 --- /dev/null +++ b/utils/migrate_adapter @@ -0,0 +1,140 @@ +#!/usr/bin/env python +"""Utility to migrate odin-control adapters to use the odin_control package name. + +Tim Nicholls, STFC Detector Systems Software Group +""" +import argparse +import re +from pathlib import Path + + +class AdapterMigrator: + """Class to handle migration of odin-control adapters to odin_control package name.""" + + def __init__(self): + """Initialize the migrator with patterns and arguments.""" + self.old = "odin" + self.new = "odin_control" + + # Replacement rules: + # - 'from odin' and 'import odin' (avoid matching odin- i.e. odin-control) + self.pat_from = re.compile(r'(^\s*from\s+)'+re.escape(self.old)+r'(?!-)\b', re.M) + self.pat_import = re.compile(r'(^\s*import\s+)'+re.escape(self.old)+r'(?!-)\b', re.M) + # - explicit module strings in toml/ini like module = odin.adapters... + self.pat_module_eq = re.compile(r'(\bmodule\s*=\s*)(?:["\']?)'+re.escape(self.old)+r'(\.)') + # - dotted usages odin.<...> + self.pat_dotted = re.compile(r'\b'+re.escape(self.old)+r'(?=\.)') + + self.file_globs = [ + "**/*.py", "**/*.pyi", "**/*.rst", "**/*.md", "**/*.toml", + "**/*.yml", "**/*.yaml", "**/*.ini", "**/*.cfg" + ] + + self.skip_dirs = {".git", "dist", "build", "__pycache__"} + + self.parse_args() + + def parse_args(self): + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description='Migrate an odin-control adapter to support the odin_control package name' + ) + parser.add_argument( + 'root', nargs='?', type=Path, default=Path.cwd(), + help='Root directory to search for files (default: current working directory)' + ) + parser.add_argument( + '--dry-run', '-n', action='store_true', + help='Show which files would be changed without making modifications' + ) + parser.add_argument( + '-i', '--backup', metavar='SUFFIX', type=str, + help='Create backup files with specified suffix (e.g., -i.bak)' + ) + self.args = parser.parse_args() + + def should_skip(self, p: Path) -> bool: + """Determine if a path should be skipped based on its parts.""" + parts = set(p.parts) + return bool(self.skip_dirs & parts) + + def generate_paths(self): + """Generate files to process based on the defined globs.""" + for g in self.file_globs: + for p in self.args.root.glob(g): + if self.should_skip(p): + continue + yield p + + def apply_replacements(self, text: str) -> str: + """Apply all replacement patterns to the given text.""" + text = self.pat_from.sub(r'\1'+self.new, text) + text = self.pat_import.sub(r'\1'+self.new, text) + text = self.pat_module_eq.sub(r'\1'+self.new+r'\2', text) + text = self.pat_dotted.sub(self.new + '.', text) + return text + + def run(self): + """Run the migration process.""" + changed_files = [] + errors = 0 + + # Process each valid path + for p in self.generate_paths(): + + # Read original file content + try: + orig_content = p.read_text(encoding="utf-8") + except Exception as e: + print(f"Error reading {p}: {e}") + errors += 1 + continue + + # Apply replacements + new_content = self.apply_replacements(orig_content) + if new_content == orig_content: + continue + + # Record changed file + changed_files.append(str(p.relative_to(self.args.root))) + + # Skip writing changes if in dry run mode + if self.args.dry_run: + continue + + # Create backup if backup suffix is specified + if self.args.backup: + backup_path = Path(str(p) + self.args.backup) + try: + backup_path.write_text(orig_content, encoding="utf-8") + except Exception as e: + print(f"Error creating backup for {p}: {e}") + errors += 1 + + # Write updated content back to file + try: + p.write_text(new_content, encoding="utf-8") + except Exception as e: + print(f"Error updating {p}: {e}") + errors += 1 + + cond_text = "would be " if self.args.dry_run else "" + + if not changed_files: + print(f"No files {cond_text}changed.") + return + + print(f"Files that {cond_text}changed:") + for f in sorted(set(changed_files)): + print(" -", f) + + if errors: + print(f"\nCompleted with {errors} error(s) encountered - you should review the changes") + +def main(): + """Create and run the adapter migrator.""" + migrator = AdapterMigrator() + migrator.run() + +if __name__ == "__main__": + main() From 99ca627c70ad3081f3a68ece015cc2f832d93c9c Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Thu, 4 Dec 2025 15:35:40 +0000 Subject: [PATCH 02/13] Remove support for legacy python versions (#69) * Remove legacy asyncio.ensure_future support from AsyncParameterTree * Refactor API handlers to remove python 2.7 support This commit refactors the API handlers to remove python 2.7 support by no longer requiring separate sync/async implementations. The underlying BaseApiHandler class is also merged into a single ApiHandler that provides async verb methods compatible with both sync and async adapters. Test cases are also refactored and simplified to match. * Refactor routes/api.py to separate adapter list and API version handlers * Remove redundant python 2.7 code from utils.py and refactor * Rename remaining py3 test modules * Remove singleton behaviour from system status and info adapters Allows the legacy future package to be dropped from dependencies. * Remove version conditonal import from config/parser.py * Remove remaining python version checks from test suite * Fix remaining missing test coverage on BaseParameterTree Test branch replace fails on immutable tree (and remove excessive caps from exception. Tsk, tsk Alan ;-) ) * Extend support and test envs to include python 3.13 --- .coveragerc | 4 +- .coveragerc-py27 | 14 -- .github/workflows/test_odin_control.yml | 2 +- pyproject.toml | 21 +-- .../adapters/async_parameter_tree.py | 12 +- .../adapters/base_parameter_tree.py | 2 +- src/odin_control/adapters/system_info.py | 15 +- src/odin_control/adapters/system_status.py | 17 +- src/odin_control/async_util.py | 56 ------- src/odin_control/config/parser.py | 8 +- src/odin_control/http/handlers/api.py | 141 +++++++++++++++-- .../http/handlers/api_adapter_list.py | 39 +++++ src/odin_control/http/handlers/api_version.py | 22 +++ src/odin_control/http/handlers/async_api.py | 75 --------- src/odin_control/http/handlers/base.py | 148 ------------------ src/odin_control/http/routes/api.py | 103 +++--------- src/odin_control/util.py | 103 ++++++------ tests/adapters/test_adapter.py | 7 +- ...c_adapter_py3.py => test_async_adapter.py} | 9 +- ...async_dummy_py3.py => test_async_dummy.py} | 13 +- ...ee_py3.py => test_async_parameter_tree.py} | 13 +- ...async_proxy_py3.py => test_async_proxy.py} | 31 ++-- tests/adapters/test_dummy.py | 9 +- tests/adapters/test_parameter_tree.py | 12 ++ tests/adapters/test_proxy.py | 19 +-- tests/adapters/test_system_info.py | 12 +- tests/adapters/test_system_status.py | 17 +- tests/config/test_config.py | 9 +- tests/conftest.py | 9 +- tests/handlers/fixtures.py | 69 ++++---- tests/handlers/test_api.py | 116 ++++++++++++++ tests/handlers/test_api_adapter_list.py | 36 +++++ tests/handlers/test_api_py2.py | 59 ------- tests/handlers/test_api_py3.py | 65 -------- tests/handlers/test_api_version.py | 23 +++ tests/handlers/test_base.py | 147 ----------------- tests/routes/test_api.py | 10 +- ...est_async_api_py3.py => test_async_api.py} | 15 +- tests/test_server.py | 9 +- tests/test_util.py | 109 +++++++------ tests/test_util_py3.py | 76 --------- tests/utils.py | 12 +- 42 files changed, 624 insertions(+), 1064 deletions(-) delete mode 100644 .coveragerc-py27 delete mode 100644 src/odin_control/async_util.py create mode 100644 src/odin_control/http/handlers/api_adapter_list.py create mode 100644 src/odin_control/http/handlers/api_version.py delete mode 100644 src/odin_control/http/handlers/async_api.py delete mode 100644 src/odin_control/http/handlers/base.py rename tests/adapters/{test_async_adapter_py3.py => test_async_adapter.py} (94%) rename tests/adapters/{test_async_dummy_py3.py => test_async_dummy.py} (91%) rename tests/adapters/{test_async_parameter_tree_py3.py => test_async_parameter_tree.py} (99%) rename tests/adapters/{test_async_proxy_py3.py => test_async_proxy.py} (94%) create mode 100644 tests/handlers/test_api.py create mode 100644 tests/handlers/test_api_adapter_list.py delete mode 100644 tests/handlers/test_api_py2.py delete mode 100644 tests/handlers/test_api_py3.py create mode 100644 tests/handlers/test_api_version.py delete mode 100644 tests/handlers/test_base.py rename tests/routes/{test_async_api_py3.py => test_async_api.py} (86%) delete mode 100644 tests/test_util_py3.py diff --git a/.coveragerc b/.coveragerc index 4872ed05..b79da85e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,5 @@ [run] -omit = *_version* +omit = */_version.py [paths] source= @@ -7,4 +7,4 @@ source= .tox/py*/lib/python*/site-packages/ [report] -omit = *_version* \ No newline at end of file +omit = */_version.py \ No newline at end of file diff --git a/.coveragerc-py27 b/.coveragerc-py27 deleted file mode 100644 index 09d6ae1d..00000000 --- a/.coveragerc-py27 +++ /dev/null @@ -1,14 +0,0 @@ -[run] -omit = - *_version* - src/odin/adapters/async_adapter.py - -[paths] -source= - src/ - .tox/py*/lib/python*/site-packages/ - -[report] -omit = - *_version* - */async_*.py diff --git a/.github/workflows/test_odin_control.yml b/.github/workflows/test_odin_control.yml index ed3223b2..5973ef54 100644 --- a/.github/workflows/test_odin_control.yml +++ b/.github/workflows/test_odin_control.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index 32b28d93..61362907 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] description = "ODIN detector control system" dynamic = ["version"] @@ -22,9 +23,7 @@ authors = [ ] dependencies = [ - "tornado>=4.3", - "future", - "pyzmq>=17.1.0", + "tornado>=6.0", "psutil>=5.0" ] @@ -32,8 +31,10 @@ dependencies = [ dev = [ "requests", "tox", - "pytest-asyncio<0.23", + "pytest", + "pytest-asyncio", "pytest-cov", + "ruff" ] sync_proxy = [ "requests" @@ -63,7 +64,7 @@ legacy_tox_ini = """ # tox test configuration for odin-control [tox] -envlist = clean,py{38,39,310,311,312},py{38}-pygelf,report +envlist = clean,py{38,39,310,311,312,313},py{38}-pygelf,report [gh-actions] python = @@ -72,21 +73,23 @@ python = 3.10: py310 3.11: py311 3.12: py312 + 3.13: py313 [testenv] deps = pytest pytest-cov - pytest-asyncio<0.23 + py{38,38-pygelf}: pytest-asyncio<0.23 + py{39,310,311,312,313}: pytest-asyncio requests py38: pygelf setenv = - py{38,39,310,311,312}: COVERAGE_FILE=.coverage.{envname} + py{38,39,310,311,312,313}: COVERAGE_FILE=.coverage.{envname} commands = pytest --cov=odin_control --cov-report=term-missing --asyncio-mode=strict {posargs:-vv} depends = - py{38,39,310,311,312}: clean - report: py{38,39,310,311,312} + py{38,39,310,311,312,313}: clean + report: py{38,39,310,311,312,313} [testenv:clean] skip_install = true diff --git a/src/odin_control/adapters/async_parameter_tree.py b/src/odin_control/adapters/async_parameter_tree.py index d93302d9..d06c3f4a 100644 --- a/src/odin_control/adapters/async_parameter_tree.py +++ b/src/odin_control/adapters/async_parameter_tree.py @@ -15,15 +15,7 @@ __all__ = ['AsyncParameterAccessor', 'AsyncParameterTree', 'ParameterTreeError'] -# if sys.version_info < (3,7): -# async_create_task = asyncio.ensure_future -# else: -# async_create_task = asyncio.create_task -try: - async_create_task = asyncio.create_task -except AttributeError: - async_create_task = asyncio.ensure_future - + class AsyncParameterAccessor(BaseParameterAccessor): """Asynchronous container class representing accessor methods for a parameter. @@ -238,4 +230,4 @@ def _set_node(self, node, data): """ response = node.set(data) if asyncio.iscoroutine(response): - self.awaitable_params.append(async_create_task(response)) + self.awaitable_params.append(asyncio.create_task(response)) diff --git a/src/odin_control/adapters/base_parameter_tree.py b/src/odin_control/adapters/base_parameter_tree.py index b8263fd5..18555b42 100644 --- a/src/odin_control/adapters/base_parameter_tree.py +++ b/src/odin_control/adapters/base_parameter_tree.py @@ -307,7 +307,7 @@ def set(self, path, data, replace=False): # Merge data with tree if replace: if not self.mutable: - raise ParameterTreeError("Invalid Replace Attempt: Tree Not Mutable") + raise ParameterTreeError("Invalid replace attempt: tree not mutable") merged = data else: merged = self._merge_tree(merge_child, data, path) diff --git a/src/odin_control/adapters/system_info.py b/src/odin_control/adapters/system_info.py index 23267333..9ee08214 100644 --- a/src/odin_control/adapters/system_info.py +++ b/src/odin_control/adapters/system_info.py @@ -9,7 +9,6 @@ import platform import time import tornado -from future.utils import with_metaclass from odin_control.adapters.adapter import (ApiAdapter, ApiAdapterResponse, request_types, response_types, wants_metadata) @@ -96,19 +95,7 @@ def delete(self, path, request): return ApiAdapterResponse(response, status_code=status_code) -class Singleton(type): - """Singleton metaclass for use with SystemInfo to ensure only one instance is created.""" - - _instances = {} - - def __call__(cls, *args, **kwargs): - """Ensure only a single instance of any class is created.""" - if cls not in cls._instances: - cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) - return cls._instances[cls] - - -class SystemInfo(with_metaclass(Singleton, object)): +class SystemInfo(): """SystemInfo - class that extracts and stores information about system-level parameters.""" # __metaclass__ = Singleton diff --git a/src/odin_control/adapters/system_status.py b/src/odin_control/adapters/system_status.py index 8c3a8764..5fda18e2 100644 --- a/src/odin_control/adapters/system_status.py +++ b/src/odin_control/adapters/system_status.py @@ -19,12 +19,9 @@ Alan Greer, OSL """ -from __future__ import print_function - import logging import os import psutil -from future.utils import with_metaclass from tornado.ioloop import IOLoop from odin_control.adapters.adapter import ApiAdapter, ApiAdapterResponse, request_types, response_types from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError @@ -110,19 +107,7 @@ def delete(self, path, request): return ApiAdapterResponse(response, status_code=status_code) -class Singleton(type): - """Singleton metaclass for use with SystemMonitor to ensure only one instance is created.""" - - _instances = {} - - def __call__(cls, *args, **kwargs): - """Ensure only a single instance of any class is created.""" - if cls not in cls._instances: - cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) - return cls._instances[cls] - - -class SystemStatus(with_metaclass(Singleton, object)): +class SystemStatus(): """Class to monitor disks, network and processes running on a server.""" def __init__(self, *args, **kwargs): """Initalise the Server Monitor. diff --git a/src/odin_control/async_util.py b/src/odin_control/async_util.py deleted file mode 100644 index bd50b03c..00000000 --- a/src/odin_control/async_util.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Odin server asyncio utility functions. - -This module implements asyncio-based utility functions needed in odin-control when using -asynchronous adapters. - -Tim Nicholls, STFC Detector System Software Group. -""" -import asyncio - - -def wrap_async(object): - """Wrap an object in an async future. - - This function wraps an object in an async future and is called from wrap_result when - async objects are wrapped in python 3. A future is created, its result set to the - object passed in, and returned to the caller. - - :param object: object to wrap in a future - :return: a Future with object as its result - """ - future = asyncio.Future() - future.set_result(object) - return future - - -def get_async_event_loop(): - """Get the asyncio event loop. - - This function obtains and returns the current asyncio event loop. If no loop is present, a new - one is created and set as the event loop. - - :return: an asyncio event loop - """ - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - return loop - - -def run_async(func, *args, **kwargs): - """Run an async function synchronously in an event loop. - - This function can be used to run an async function synchronously, i.e. without the need for an - await() call. The function is run on an asyncio event loop and the result is returned. - - :param func: async function to run - :param args: positional arguments to function - :param kwargs:: keyword arguments to function - :return: result of function - """ - loop = get_async_event_loop() - result = loop.run_until_complete(func(*args, **kwargs)) - return result diff --git a/src/odin_control/config/parser.py b/src/odin_control/config/parser.py index 6f8fa508..0960621a 100644 --- a/src/odin_control/config/parser.py +++ b/src/odin_control/config/parser.py @@ -9,11 +9,7 @@ from functools import partial import tornado.options -if sys.version_info[0] == 3: # pragma: no cover - from configparser import ConfigParser as NativeConfigParser -else: # pragma: no cover - from ConfigParser import SafeConfigParser as NativeConfigParser - NativeConfigParser.read_file = NativeConfigParser.readfp +from configparser import ConfigParser as NativeConfigParser class ConfigError(Exception): @@ -25,7 +21,7 @@ class ConfigError(Exception): pass -class ConfigParser(object): +class ConfigParser(): """Parses configuration options from the command-line and from a file. Provides parsing of program configuration options from both command-line arguments and diff --git a/src/odin_control/http/handlers/api.py b/src/odin_control/http/handlers/api.py index 1ff8e2c2..67463529 100644 --- a/src/odin_control/http/handlers/api.py +++ b/src/odin_control/http/handlers/api.py @@ -1,57 +1,166 @@ -"""Synchronous API handler for the ODIN server. +"""API request handler for odin-control. -This module implements the synchronous API handler used by the ODIN server to pass -API calls to synchronous adapters. +This module implements the API request handler used by odin-control to pass API calls to adapters. Tim Nicholls, STFC Detector Systems Software Group. """ +from tornado.web import RequestHandler -from odin_control.http.handlers.base import BaseApiHandler, validate_api_request, API_VERSION +from odin_control.adapters.adapter import ApiAdapterResponse +from odin_control.util import wrap_result +API_VERSION = 0.1 -class ApiHandler(BaseApiHandler): - """Class for handling synchrounous API requests. +class ApiError(Exception): + """Simple exception class for API-related errors.""" - This class handles synchronous API requests, that is when the ODIN server is being - used with Tornado and python versions incompatible with native async behaviour. + +def validate_api_request(required_version): + """Validate an API request to the ApiHandler. + + This decorator checks that API version in the URI of a request is correct and that the subsystem + is registered with the application dispatcher; responds with a 400 error if not """ + def decorator(func): + def wrapper(_self, *args, **kwargs): + # Extract version as first argument + version = args[0] + subsystem = args[1] + rem_args = args[2:] + if version != str(required_version): + _self.respond(ApiAdapterResponse( + "API version {} is not supported".format(version), + status_code=400)) + return wrap_result(None) + if not _self.route.has_adapter(subsystem): + _self.respond(ApiAdapterResponse( + "No API adapter registered for subsystem {}".format(subsystem), + status_code=400)) + return wrap_result(None) + return func(_self, subsystem, *rem_args, **kwargs) + return wrapper + return decorator + + +class ApiHandler(RequestHandler): + """API handler to transform requests into appropriate adapter calls. + + This handler maps incoming API requests onto the appropriate calls to methods in registered + adapters. HTTP GET, PUT, POST, DELETE and OPTIONS verbs are supported. The handler also enforces + a uniform response with the appropriate Content-Type header. + """ + + def __init__(self, *args, **kwargs): + """Construct the ApiHandler object. + + This method constructs the ApiHandler object, calling the superclass constructor and setting + the route object to None. + """ + self.route = None + super().__init__(*args, **kwargs) + + def initialize(self, route, enable_cors, cors_origin): + """Initialize the API handler. + + :param route: ApiRoute object calling the handler (allows adapters to be resolved) + :param enable_cors: enable CORS support by setting appropriate headers + :param cors_origin: allowed origin for CORS requests + """ + self.route = route + if enable_cors: + self.set_header("Access-Control-Allow-Origin", cors_origin) + self.set_header("Access-Control-Allow-Headers", "x-requested-with,content-type") + self.set_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + + def respond(self, response): + """Respond to an API request. + + This method transforms an ApiAdapterResponse object into the appropriate request handler + response, setting the HTTP status code and content type for a response to an API request + and validating the content of the response against the appropriate type. + + :param response: ApiAdapterResponse object containing response + """ + self.set_status(response.status_code) + self.set_header('Content-Type', response.content_type) + + data = response.data + + if response.content_type == 'application/json': + if not isinstance(response.data, (str, dict)): + raise ApiError( + 'A response with content type application/json must have str or dict data' + ) + + self.write(data) + + def options(self, *_): + """Handle an API OPTIONS request. + + This method handles an OPTION request to an API handler and is provided to allow + browser clients to employ CORS preflight requests to determine if non-simple requests + are allowed. + + :param _: unused arguments passed to the method by the URI matching in the handler + """ + # Set status to indicate successful request with no content returned + self.set_status(204) @validate_api_request(API_VERSION) - def get(self, subsystem, path=''): + async def get(self, subsystem, path=''): """Handle an API GET request. :param subsystem: subsystem element of URI, defining adapter to be called :param path: remaining URI path to be passed to adapter method """ - response = self.route.adapter(subsystem).get(path, self.request) + adapter = self.route.adapter(subsystem) + if adapter.is_async: + response = await adapter.get(path, self.request) + else: + response = adapter.get(path, self.request) + self.respond(response) @validate_api_request(API_VERSION) - def post(self, subsystem, path=''): + async def post(self, subsystem, path=''): """Handle an API POST request. :param subsystem: subsystem element of URI, defining adapter to be called :param path: remaining URI path to be passed to adapter method """ - response = self.route.adapter(subsystem).post(path, self.request) + adapter = self.route.adapter(subsystem) + if adapter.is_async: + response = await adapter.post(path, self.request) + else: + response = adapter.post(path, self.request) + self.respond(response) @validate_api_request(API_VERSION) - def put(self, subsystem, path=''): + async def put(self, subsystem, path=''): """Handle an API PUT request. :param subsystem: subsystem element of URI, defining adapter to be called :param path: remaining URI path to be passed to adapter method """ - response = self.route.adapter(subsystem).put(path, self.request) + adapter = self.route.adapter(subsystem) + if adapter.is_async: + response = await adapter.put(path, self.request) + else: + response = adapter.put(path, self.request) self.respond(response) @validate_api_request(API_VERSION) - def delete(self, subsystem, path=''): + async def delete(self, subsystem, path=''): """Handle an API DELETE request. :param subsystem: subsystem element of URI, defining adapter to be called :param path: remaining URI path to be passed to adapter method """ - response = self.route.adapter(subsystem).delete(path, self.request) + adapter = self.route.adapter(subsystem) + if adapter.is_async: + response = await adapter.delete(path, self.request) + else: + response = adapter.delete(path, self.request) self.respond(response) + diff --git a/src/odin_control/http/handlers/api_adapter_list.py b/src/odin_control/http/handlers/api_adapter_list.py new file mode 100644 index 00000000..dbc894eb --- /dev/null +++ b/src/odin_control/http/handlers/api_adapter_list.py @@ -0,0 +1,39 @@ +from tornado.web import RequestHandler + +from .api import API_VERSION + +class ApiAdapterListHandler(RequestHandler): + """API adapter list handler to return a list of loaded adapters. + + This request hander implements the GET verb to allow a call to the appropriate URI to return + a JSON-encoded list of adapters loaded by the server. + """ + + def initialize(self, route): + """Initialize the API adapter list handler. + + :param route: ApiRoute object calling the handler (allows adapters to be resolved) + """ + self.route = route + + def get(self, version): + """Handle API adapter list GET requests. + + This handler returns a JSON-encoded list of adapters loaded into the server. + + :param version: API version + """ + # Validate the API version explicity - can't use the validate_api_request decorator here + if version != str(API_VERSION): + self.set_status(400) + self.write("API version {} is not supported".format(version)) + return + + # Validate the accept type requested is appropriate + accept_types = self.request.headers.get('Accept', 'application/json').split(',') + if '*/*' not in accept_types and 'application/json' not in accept_types: + self.set_status(406) + self.write('Request content types not supported') + return + + self.write({'adapters': [adapter for adapter in self.route.adapters]}) diff --git a/src/odin_control/http/handlers/api_version.py b/src/odin_control/http/handlers/api_version.py new file mode 100644 index 00000000..eb49ad17 --- /dev/null +++ b/src/odin_control/http/handlers/api_version.py @@ -0,0 +1,22 @@ +import json + +from tornado.web import RequestHandler + +from .api import API_VERSION + +class ApiVersionHandler(RequestHandler): + """API version handler to allow client to resolve supported version. + + This request handler implements the GET verb to allow a call to the appropriate URI to return + the supported API version as JSON. + """ + + def get(self): + """Handle API version GET requests.""" + accept_types = self.request.headers.get('Accept', 'application/json').split(',') + if "*/*" not in accept_types and 'application/json' not in accept_types: + self.set_status(406) + self.write('Requested content types not supported') + return + + self.write(json.dumps({'api': API_VERSION})) \ No newline at end of file diff --git a/src/odin_control/http/handlers/async_api.py b/src/odin_control/http/handlers/async_api.py deleted file mode 100644 index 6faff865..00000000 --- a/src/odin_control/http/handlers/async_api.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Asynchronous API handler for the ODIN server. - -This module implements the asynchronous API handler used by the ODIN server to pass -API calls to asynchronous adapters. - -Tim Nicholls, STFC Detector Systems Software Group. -""" - -from odin_control.http.handlers.base import BaseApiHandler, validate_api_request, API_VERSION - - -class AsyncApiHandler(BaseApiHandler): - """Class for handling asynchrounous API requests. - - This class handles asynchronous API requests, that is when the ODIN server is being - used with Tornado and python versions the implement native async behaviour. - """ - - @validate_api_request(API_VERSION) - async def get(self, subsystem, path=''): - """Handle an API GET request. - - :param subsystem: subsystem element of URI, defining adapter to be called - :param path: remaining URI path to be passed to adapter method - """ - adapter = self.route.adapter(subsystem) - if adapter.is_async: - response = await adapter.get(path, self.request) - else: - response = adapter.get(path, self.request) - - self.respond(response) - - @validate_api_request(API_VERSION) - async def post(self, subsystem, path=''): - """Handle an API POST request. - - :param subsystem: subsystem element of URI, defining adapter to be called - :param path: remaining URI path to be passed to adapter method - """ - adapter = self.route.adapter(subsystem) - if adapter.is_async: - response = await adapter.post(path, self.request) - else: - response = adapter.post(path, self.request) - - self.respond(response) - - @validate_api_request(API_VERSION) - async def put(self, subsystem, path=''): - """Handle an API PUT request. - - :param subsystem: subsystem element of URI, defining adapter to be called - :param path: remaining URI path to be passed to adapter method - """ - adapter = self.route.adapter(subsystem) - if adapter.is_async: - response = await adapter.put(path, self.request) - else: - response = adapter.put(path, self.request) - self.respond(response) - - @validate_api_request(API_VERSION) - async def delete(self, subsystem, path=''): - """Handle an API DELETE request. - - :param subsystem: subsystem element of URI, defining adapter to be called - :param path: remaining URI path to be passed to adapter method - """ - adapter = self.route.adapter(subsystem) - if adapter.is_async: - response = await adapter.delete(path, self.request) - else: - response = adapter.delete(path, self.request) - self.respond(response) diff --git a/src/odin_control/http/handlers/base.py b/src/odin_control/http/handlers/base.py deleted file mode 100644 index b98705a3..00000000 --- a/src/odin_control/http/handlers/base.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Base API handler for the ODIN server. - -This module implements the base API handler functionality from which both the concrete -synchronous and asynchronous API handler implementations inherit. - -Tim Nicholls, STFC Detector Systems Software Group. -""" - -import tornado.web - -from odin_control.adapters.adapter import ApiAdapterResponse -from odin_control.util import wrap_result -API_VERSION = 0.1 - - -class ApiError(Exception): - """Simple exception class for API-related errors.""" - - -def validate_api_request(required_version): - """Validate an API request to the ApiHandler. - - This decorator checks that API version in the URI of a requst is correct and that the subsystem - is registered with the application dispatcher; responds with a 400 error if not - """ - def decorator(func): - def wrapper(_self, *args, **kwargs): - # Extract version as first argument - version = args[0] - subsystem = args[1] - rem_args = args[2:] - if version != str(required_version): - _self.respond(ApiAdapterResponse( - "API version {} is not supported".format(version), - status_code=400)) - return wrap_result(None) - if not _self.route.has_adapter(subsystem): - _self.respond(ApiAdapterResponse( - "No API adapter registered for subsystem {}".format(subsystem), - status_code=400)) - return wrap_result(None) - return func(_self, subsystem, *rem_args, **kwargs) - return wrapper - return decorator - - -class BaseApiHandler(tornado.web.RequestHandler): - """API handler to transform requests into appropriate adapter calls. - - This handler maps incoming API requests into the appropriate calls to methods - in registered adapters. HTTP GET, PUT and DELETE verbs are supported. The class - also enforces a uniform response with the appropriate Content-Type header. - """ - - def __init__(self, *args, **kwargs): - """Construct the BaseApiHandler object. - - This method just calls the base class constructor and sets the route object to None. - """ - self.route = None - super(BaseApiHandler, self).__init__(*args, **kwargs) - - def initialize(self, route, enable_cors, cors_origin): - """Initialize the API handler. - - :param route: ApiRoute object calling the handler (allows adapters to be resolved) - :param enable_cors: enable CORS support by setting appropriate headers - :param cors_origin: allowed origin for CORS requests - """ - self.route = route - if enable_cors: - self.set_header("Access-Control-Allow-Origin", cors_origin) - self.set_header("Access-Control-Allow-Headers", "x-requested-with,content-type") - self.set_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') - - def respond(self, response): - """Respond to an API request. - - This method transforms an ApiAdapterResponse object into the appropriate request handler - response, setting the HTTP status code and content type for a response to an API request - and validating the content of the response against the appropriate type. - - :param response: ApiAdapterResponse object containing response - """ - self.set_status(response.status_code) - self.set_header('Content-Type', response.content_type) - - data = response.data - - if response.content_type == 'application/json': - if not isinstance(response.data, (str, dict)): - raise ApiError( - 'A response with content type application/json must have str or dict data' - ) - - self.write(data) - - def options(self, *_): - """Handle an API OPTIONS request. - - This method handles an OPTION request to an API handler and is provided to allow - browser clients to employ CORS preflight requests to determine if non-simple requests - are allowed. - - :param _: unused arguments passed to the method by the URI matching in the handler - """ - # Set status to indicate successful request with no content returned - self.set_status(204) - - def get(self, subsystem, path=''): - """Handle an API GET request. - - This is an abstract method which must be implemented by derived classes. - - :param subsystem: subsystem element of URI, defining adapter to be called - :param path: remaining URI path to be passed to adapter method - """ - raise NotImplementedError() - - def post(self, subsystem, path=''): - """Handle an API POST request. - - This is an abstract method which must be implemented by derived classes. - - :param subsystem: subsystem element of URI, defining adapter to be called - :param path: remaining URI path to be passed to adapter method - """ - raise NotImplementedError() - - def put(self, subsystem, path=''): - """Handle an API PUT request. - - This is an abstract method which must be implemented by derived classes. - - :param subsystem: subsystem element of URI, defining adapter to be called - :param path: remaining URI path to be passed to adapter method - """ - raise NotImplementedError() - - def delete(self, subsystem, path=''): - """Handle an API DELETE request. - - This is an abstract method which must be implemented by derived classes. - - :param subsystem: subsystem element of URI, defining adapter to be called - :param path: remaining URI path to be passed to adapter method - """ - raise NotImplementedError() diff --git a/src/odin_control/http/routes/api.py b/src/odin_control/http/routes/api.py index 40e8c496..3542031d 100644 --- a/src/odin_control/http/routes/api.py +++ b/src/odin_control/http/routes/api.py @@ -1,80 +1,18 @@ -"""API-related classes for the ODIN server. +"""API route implementation for odin-control. -This module implements a number of classes used by the ODIN server for routing and handling -API calls. +This module implements an API route object used by odin-control to handle API-related requests. Tim Nicholls, STFC Application Engineering Group """ import importlib import logging -import json - -import tornado.web +from odin_control.http.handlers.api import ApiError, ApiHandler +from odin_control.http.handlers.api_adapter_list import ApiAdapterListHandler +from odin_control.http.handlers.api_version import ApiVersionHandler from odin_control.http.routes.route import Route -from odin_control.util import PY3 -from odin_control.http.handlers.base import ApiError, API_VERSION -if PY3: - from odin_control.http.handlers.async_api import AsyncApiHandler as ApiHandler - from odin_control.async_util import run_async -else: - from odin_control.http.handlers.api import ApiHandler - - -class ApiVersionHandler(tornado.web.RequestHandler): - """API version handler to allow client to resolve supported version. - - This request handler implements the GET verb to allow a call to the appropriate URI to return - the supported API version as JSON. - """ - - def get(self): - """Handle API version GET requests.""" - accept_types = self.request.headers.get('Accept', 'application/json').split(',') - if "*/*" not in accept_types and 'application/json' not in accept_types: - self.set_status(406) - self.write('Requested content types not supported') - return - - self.write(json.dumps({'api': API_VERSION})) - - -class ApiAdapterListHandler(tornado.web.RequestHandler): - """API adapter list handler to return a list of loaded adapters. - - This request hander implements the GET verb to allow a call to the appropriate URI to return - a JSON-encoded list of adapters loaded by the server. - """ - - def initialize(self, route): - """Initialize the API adapter list handler. - - :param route: ApiRoute object calling the handler (allows adapters to be resolved) - """ - self.route = route - - def get(self, version): - """Handle API adapter list GET requests. - - This handler returns a JSON-encoded list of adapters loaded into the server. - - :param version: API version - """ - # Validate the API version explicity - can't use the validate_api_request decorator here - if version != str(API_VERSION): - self.set_status(400) - self.write("API version {} is not supported".format(version)) - return - - # Validate the accept type requested is appropriate - accept_types = self.request.headers.get('Accept', 'application/json').split(',') - if '*/*' not in accept_types and 'application/json' not in accept_types: - self.set_status(406) - self.write('Request content types not supported') - return - - self.write({'adapters': [adapter for adapter in self.route.adapters]}) +from odin_control.util import run_async class ApiRoute(Route): @@ -95,10 +33,14 @@ def __init__(self, enable_cors=False, cors_origin="*"): self.add_handler((r"/api/?", ApiVersionHandler)) # Define a handler which can return a list of loaded adapters - self.add_handler((r'/api/(.*?)/adapters/?', ApiAdapterListHandler, dict(route=self))) + self.add_handler( + (r"/api/(.*?)/adapters/?", ApiAdapterListHandler, dict(route=self)) + ) # Build a dict of params to be passed to API handler initialisation calls - handler_params = dict(route=self, enable_cors=enable_cors, cors_origin=cors_origin) + handler_params = dict( + route=self, enable_cors=enable_cors, cors_origin=cors_origin + ) # Define the handler for API calls. The expected URI syntax, which is # enforced by the validate_api_request decorator, is the following: @@ -123,13 +65,13 @@ def register_adapter(self, adapter_config, fail_ok=True): :param fail_ok: Allow the adapter import and registration to fail without raising an error """ # Resolve the adapter module and class name from the dotted module path in the config object - (module_name, class_name) = adapter_config.module.rsplit('.', 1) + (module_name, class_name) = adapter_config.module.rsplit(".", 1) # Try to import the module, resolve the class in the module and create an instance of it try: adapter_module = importlib.import_module(module_name) adapter_class = getattr(adapter_module, class_name) - if PY3 and adapter_class.is_async: + if adapter_class.is_async: adapter = run_async(adapter_class, **adapter_config.options()) else: adapter = adapter_class(**adapter_config.options()) @@ -138,13 +80,18 @@ def register_adapter(self, adapter_config, fail_ok=True): except (ImportError, AttributeError) as e: logging.error( "Failed to register API adapter %s for path %s with dispatcher: %s", - adapter_config.module, adapter_config.name, e) + adapter_config.module, + adapter_config.name, + e, + ) if not fail_ok: raise ApiError(e) else: logging.debug( "Registered API adapter class %s from module %s for path %s", - class_name, module_name, adapter_config.name + class_name, + module_name, + adapter_config.name, ) def has_adapter(self, subsystem): @@ -171,8 +118,8 @@ def cleanup_adapters(self): """ for adapter_name, adapter in self.adapters.items(): try: - cleanup_method = getattr(adapter, 'cleanup') - if PY3 and adapter.is_async: + cleanup_method = getattr(adapter, "cleanup") + if adapter.is_async: run_async(cleanup_method) else: cleanup_method() @@ -187,8 +134,8 @@ def initialize_adapters(self): """ for adapter_name, adapter in self.adapters.items(): try: - initialize_method = getattr(adapter, 'initialize') - if PY3 and adapter.is_async: + initialize_method = getattr(adapter, "initialize") + if adapter.is_async: run_async(initialize_method, self.adapters) else: initialize_method(self.adapters) diff --git a/src/odin_control/util.py b/src/odin_control/util.py index 5a9c5fb0..bfc19519 100644 --- a/src/odin_control/util.py +++ b/src/odin_control/util.py @@ -1,19 +1,12 @@ -"""Odin Server Utility Functions +"""Utility functions for odin-control. -This module implements utility methods for Odin Server. +This module implements utility methods for odin-control. """ -import sys +import asyncio -from tornado import version_info from tornado.escape import json_decode from tornado.ioloop import IOLoop -PY3 = sys.version_info >= (3,) - -if PY3: - from odin_control.async_util import get_async_event_loop, wrap_async - unicode = str - def decode_request_body(request): """Extract the body from a request. @@ -33,36 +26,6 @@ def decode_request_body(request): return body -def convert_unicode_to_string(obj): - """ - Convert all unicode parts of a dictionary or list to standard strings. - - This method will not handle special characters well due to the difference between uft-8 - and unicode. - - It is recursive, so if the object passed is a collection (dict or list) it will call - itself for each object in the collection - - :param obj: the dictionary, list, or unicode string - - :return: the same data type as obj, but with unicode strings converted to python strings. - """ - if PY3: - # Python 3 strings ARE unicode, so no need to encode them - return obj # pragma: no cover - if isinstance(obj, dict): - # Obj is a dictionary. We need to recurse this method over each key and value - return {convert_unicode_to_string(key): convert_unicode_to_string(value) - for key, value in obj.items()} - elif isinstance(obj, list): - # Obj is a list. We need to recurse over each object in the list - return [convert_unicode_to_string(element) for element in obj] - elif isinstance(obj, unicode): - return obj.encode("utf-8") - # Obj is none of the above, just return it - return obj - - def wrap_result(result, is_async=True): """ Conditionally wrap a result in an aysncio Future if being used in async code on python 3. @@ -74,7 +37,7 @@ def wrap_result(result, is_async=True): :return: either the result or a Future wrapping the result """ - if is_async and PY3: + if is_async: return wrap_async(result) else: return result @@ -96,15 +59,59 @@ def run_in_executor(executor, func, *args): :return: a Future wrapping the task """ - # In python 3, try to get the current asyncio event loop, otherwise create a new one - if PY3: - get_async_event_loop() + # Try to get the current asyncio event loop, otherwise create a new one + get_async_event_loop() # Run the function in the specified executor, handling tornado version 4 where there was no # run_in_executor implementation - if version_info[0] <= 4: - future = executor.submit(func, *args) - else: - future = IOLoop.current().run_in_executor(executor, func, *args) + future = IOLoop.current().run_in_executor(executor, func, *args) return future + +def wrap_async(object): + """Wrap an object in an async future. + + This function wraps an object in an async future and is called from wrap_result when + async objects are wrapped in python 3. A future is created, its result set to the + object passed in, and returned to the caller. + + :param object: object to wrap in a future + :return: a Future with object as its result + """ + future = asyncio.Future() + future.set_result(object) + return future + + +def get_async_event_loop(): + """Get the asyncio event loop. + + This function obtains and returns the current asyncio event loop. If no loop is present, a new + one is created and set as the event loop. + + :return: an asyncio event loop + """ + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + return loop + + +def run_async(func, *args, **kwargs): + """Run an async function synchronously in an event loop. + + This function can be used to run an async function synchronously, i.e. without the need for an + await() call. The function is run on an asyncio event loop and the result is returned. + + :param func: async function to run + :param args: positional arguments to function + :param kwargs:: keyword arguments to function + :return: result of function + """ + loop = get_async_event_loop() + result = loop.run_until_complete(func(*args, **kwargs)) + return result + diff --git a/tests/adapters/test_adapter.py b/tests/adapters/test_adapter.py index 499e0f35..3f6a9a34 100644 --- a/tests/adapters/test_adapter.py +++ b/tests/adapters/test_adapter.py @@ -1,11 +1,6 @@ -import sys - import pytest -if sys.version_info[0] == 3: # pragma: no cover - from unittest.mock import Mock -else: # pragma: no cover - from mock import Mock +from unittest.mock import Mock from odin_control.adapters.adapter import (ApiAdapter, ApiAdapterResponse, ApiAdapterRequest, request_types, response_types, wants_metadata) diff --git a/tests/adapters/test_async_adapter_py3.py b/tests/adapters/test_async_adapter.py similarity index 94% rename from tests/adapters/test_async_adapter_py3.py rename to tests/adapters/test_async_adapter.py index 253b32fb..11ec1678 100644 --- a/tests/adapters/test_async_adapter_py3.py +++ b/tests/adapters/test_async_adapter.py @@ -1,12 +1,7 @@ -import sys - import pytest -if sys.version_info[0] < 3: - pytest.skip("Skipping async tests", allow_module_level=True) -else: - from odin_control.adapters.async_adapter import AsyncApiAdapter - from unittest.mock import Mock +from odin_control.adapters.async_adapter import AsyncApiAdapter +from unittest.mock import Mock class AsyncApiAdapterTestFixture(object): diff --git a/tests/adapters/test_async_dummy_py3.py b/tests/adapters/test_async_dummy.py similarity index 91% rename from tests/adapters/test_async_dummy_py3.py rename to tests/adapters/test_async_dummy.py index 56831ae0..714980d9 100644 --- a/tests/adapters/test_async_dummy_py3.py +++ b/tests/adapters/test_async_dummy.py @@ -1,14 +1,9 @@ -import sys - import pytest -if sys.version_info[0] < 3: - pytest.skip("Skipping async tests", allow_module_level=True) -else: - import asyncio - from odin_control.adapters.async_dummy import AsyncDummyAdapter - from unittest.mock import Mock - from tests.async_utils import AwaitableTestFixture, asyncio_fixture_decorator +import asyncio +from odin_control.adapters.async_dummy import AsyncDummyAdapter +from unittest.mock import Mock +from tests.async_utils import AwaitableTestFixture, asyncio_fixture_decorator class AsyncDummyAdapterTestFixture(AwaitableTestFixture): diff --git a/tests/adapters/test_async_parameter_tree_py3.py b/tests/adapters/test_async_parameter_tree.py similarity index 99% rename from tests/adapters/test_async_parameter_tree_py3.py rename to tests/adapters/test_async_parameter_tree.py index b0774496..7008cbb0 100644 --- a/tests/adapters/test_async_parameter_tree_py3.py +++ b/tests/adapters/test_async_parameter_tree.py @@ -8,20 +8,15 @@ import asyncio import math -import sys from copy import deepcopy import pytest -if sys.version_info[0] < 3: - pytest.skip("Skipping async tests", allow_module_level=True) -else: - - from tests.async_utils import AwaitableTestFixture, asyncio_fixture_decorator - from odin_control.adapters.async_parameter_tree import ( - AsyncParameterAccessor, AsyncParameterTree, ParameterTreeError - ) +from tests.async_utils import AwaitableTestFixture, asyncio_fixture_decorator +from odin_control.adapters.async_parameter_tree import ( + AsyncParameterAccessor, AsyncParameterTree, ParameterTreeError +) class AsyncParameterAccessorTestFixture(AwaitableTestFixture): diff --git a/tests/adapters/test_async_proxy_py3.py b/tests/adapters/test_async_proxy.py similarity index 94% rename from tests/adapters/test_async_proxy_py3.py rename to tests/adapters/test_async_proxy.py index 7b4b8017..ff314a7c 100644 --- a/tests/adapters/test_async_proxy_py3.py +++ b/tests/adapters/test_async_proxy.py @@ -5,26 +5,21 @@ """ import logging -import sys from io import StringIO import pytest -if sys.version_info[0] < 3: - pytest.skip("Skipping async tests", allow_module_level=True) -else: - from tornado.ioloop import TimeoutError - from tornado.httpclient import HTTPResponse - from odin_control.adapters.async_proxy import AsyncProxyTarget, AsyncProxyAdapter - from unittest.mock import Mock - from tests.adapters.test_proxy import ProxyTestHandler, ProxyTargetTestFixture, ProxyTestServer - from odin_control.util import convert_unicode_to_string - from tests.utils import log_message_seen - from tests.async_utils import AwaitableTestFixture, asyncio_fixture_decorator - try: - from unittest.mock import AsyncMock - except ImportError: - from tests.async_utils import AsyncMock +from tornado.ioloop import TimeoutError +from tornado.httpclient import HTTPResponse +from odin_control.adapters.async_proxy import AsyncProxyTarget, AsyncProxyAdapter +from unittest.mock import Mock +from tests.adapters.test_proxy import ProxyTestHandler, ProxyTargetTestFixture, ProxyTestServer +from tests.utils import log_message_seen +from tests.async_utils import AwaitableTestFixture, asyncio_fixture_decorator +try: + from unittest.mock import AsyncMock +except ImportError: + from tests.async_utils import AsyncMock @pytest.fixture(scope="class") def test_proxy_target(): @@ -252,7 +247,7 @@ async def test_adapter_put(self, async_proxy_adapter_fixture): for tgt in range(async_proxy_adapter_fixture.num_targets): node_str = 'node_{}'.format(tgt) assert node_str in response.data - assert convert_unicode_to_string(response.data[node_str]) == ProxyTestHandler.param_tree.get("") + assert response.data[node_str] == ProxyTestHandler.param_tree.get("") @pytest.mark.asyncio async def test_adapter_get_proxy_path(self, async_proxy_adapter_fixture): @@ -292,7 +287,7 @@ async def test_adapter_put_proxy_path(self, async_proxy_adapter_fixture): "{}/{}".format(node, path), async_proxy_adapter_fixture.request) assert async_proxy_adapter_fixture.adapter.param_tree.get('')['status'][node]['status_code'] == 200 - assert convert_unicode_to_string(response.data["more"]["replace"]) == "been replaced" + assert response.data["more"]["replace"] == "been replaced" @pytest.mark.asyncio async def test_adapter_get_bad_path(self, async_proxy_adapter_fixture): diff --git a/tests/adapters/test_dummy.py b/tests/adapters/test_dummy.py index fbc7d4cf..ae147729 100644 --- a/tests/adapters/test_dummy.py +++ b/tests/adapters/test_dummy.py @@ -1,15 +1,10 @@ -import sys - import pytest from odin_control.adapters.dummy import DummyAdapter, IacDummyAdapter from odin_control.adapters.adapter import (ApiAdapter, ApiAdapterRequest, ApiAdapterResponse, request_types, response_types) -if sys.version_info[0] == 3: # pragma: no cover - from unittest.mock import Mock -else: # pragma: no cover - from mock import Mock +from unittest.mock import Mock class DummyAdapterTestFixture(object): @@ -35,7 +30,7 @@ def test_adapter_get(self, test_dummy_adapter): expected_response = { 'response': 'DummyAdapter: GET on path {}'.format(test_dummy_adapter.path) } - response = test_dummy_adapter.adapter.get(test_dummy_adapter.path, + response = test_dummy_adapter.adapter.get(test_dummy_adapter.path, test_dummy_adapter.request) assert response.data == expected_response assert response.status_code == 200 diff --git a/tests/adapters/test_parameter_tree.py b/tests/adapters/test_parameter_tree.py index efe48b5b..dfc4856c 100644 --- a/tests/adapters/test_parameter_tree.py +++ b/tests/adapters/test_parameter_tree.py @@ -900,6 +900,18 @@ def test_mutable_replace_branch(self, test_tree_mutable): val = test_tree_mutable.param_tree.get(path) assert val[path] == new_node + def test_immutable_replace_branch_raises_error(self, test_tree_mutable): + + test_tree_mutable.param_tree.mutable = False + new_node = {"double_nest": 294} + path = 'nest' + + with pytest.raises(ParameterTreeError) as excinfo: + test_tree_mutable.param_tree.replace(path, new_node) + + test_tree_mutable.param_tree.mutable = True + assert "Invalid replace attempt" in str(excinfo.value) + def test_mutable_put_replace_nested_path(self, test_tree_mutable): new_node = {"double_nest": 294} diff --git a/tests/adapters/test_proxy.py b/tests/adapters/test_proxy.py index 047843a3..936b0275 100644 --- a/tests/adapters/test_proxy.py +++ b/tests/adapters/test_proxy.py @@ -8,7 +8,6 @@ import threading import logging import time -from io import StringIO import pytest @@ -16,7 +15,6 @@ from tornado.testing import bind_unused_port from tornado.ioloop import IOLoop -from tornado.httpclient import HTTPResponse from tornado.web import Application, RequestHandler from tornado.httpserver import HTTPServer import tornado.gen @@ -24,14 +22,10 @@ from odin_control.adapters.proxy import ProxyTarget, ProxyAdapter from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError from odin_control.adapters.adapter import wants_metadata -from odin_control.util import convert_unicode_to_string from tests.utils import log_message_seen -if sys.version_info[0] == 3: # pragma: no cover - from unittest.mock import Mock, patch - import asyncio -else: # pragma: no cover - from mock import Mock, patch +from unittest.mock import Mock, patch +import asyncio class ProxyTestHandler(RequestHandler): @@ -71,7 +65,7 @@ def get(self, path=''): def put(self, path): """Handle PUT requests to the test server.""" - response_body = convert_unicode_to_string(tornado.escape.json_decode(self.request.body)) + response_body = tornado.escape.json_decode(self.request.body) try: self.param_tree.set(path, response_body) data_ref = self.param_tree.get(path) @@ -98,8 +92,7 @@ def __init__(self,): def _run_server(self): - if sys.version_info[0] == 3: - asyncio.set_event_loop(asyncio.new_event_loop()) + asyncio.set_event_loop(asyncio.new_event_loop()) self.server_event_loop = IOLoop() @@ -383,7 +376,7 @@ def test_adapter_put(self, proxy_adapter_test): for tgt in range(proxy_adapter_test.num_targets): node_str = 'node_{}'.format(tgt) assert node_str in response.data - assert convert_unicode_to_string(response.data[node_str]) == ProxyTestHandler.param_tree.get("") + assert response.data[node_str] == ProxyTestHandler.param_tree.get("") def test_adapter_get_proxy_path(self, proxy_adapter_test): """Test that a GET to a sub-path within a targer succeeds and return the correct data.""" @@ -421,7 +414,7 @@ def test_adapter_put_proxy_path(self, proxy_adapter_test): logging.debug("Response: %s", response.data) assert proxy_adapter_test.adapter.param_tree.get('')['status'][node]['status_code'] == 200 - assert convert_unicode_to_string(response.data["more"]["replace"]) == "been replaced" + assert response.data["more"]["replace"] == "been replaced" def test_adapter_get_bad_path(self, proxy_adapter_test): """Test that a GET to a bad path within a target returns the appropriate error.""" diff --git a/tests/adapters/test_system_info.py b/tests/adapters/test_system_info.py index 049d238d..6d517262 100644 --- a/tests/adapters/test_system_info.py +++ b/tests/adapters/test_system_info.py @@ -3,14 +3,9 @@ Tim Nicholls, STFC Application Engineering Group. """ -import sys - import pytest -if sys.version_info[0] == 3: # pragma: no cover - from unittest.mock import Mock -else: # pragma: no cover - from mock import Mock +from unittest.mock import Mock from odin_control.adapters.system_info import SystemInfoAdapter, SystemInfo @@ -24,11 +19,6 @@ def test_system_info(): class TestSystemInfo(): """Test cases for the SystemInfo class.""" - def test_system_info_single_instance(self, test_system_info): - """Test that the SystemInfo class exhibits singleton behaviour.""" - new_instance = SystemInfo() - assert test_system_info == new_instance - def test_system_info_get(self, test_system_info): """Test the calling the GET method of system info returns a dict.""" result = test_system_info.get('') diff --git a/tests/adapters/test_system_status.py b/tests/adapters/test_system_status.py index e574a3c8..217e96ea 100644 --- a/tests/adapters/test_system_status.py +++ b/tests/adapters/test_system_status.py @@ -3,19 +3,15 @@ Tim Nicholls, STFC Application Engineering Group. """ -import sys import platform import psutil import logging import pytest -if sys.version_info[0] == 3: # pragma: no cover - from unittest.mock import Mock, patch -else: # pragma: no cover - from mock import Mock, patch +from unittest.mock import Mock, patch -from odin_control.adapters.system_status import SystemStatusAdapter, SystemStatus, Singleton +from odin_control.adapters.system_status import SystemStatusAdapter, SystemStatus from odin_control.adapters.parameter_tree import ParameterTreeError from tests.utils import log_message_seen @@ -101,11 +97,6 @@ def test_system_status(): class TestSystemStatus(): """Test cases for the SystemStatus class.""" - def test_system_status_single_instance(self, test_system_status): - """Test that the SystemStatus class exhibits singleton behaviour.""" - new_instance = SystemStatus() - assert test_system_status.system_status == new_instance - def test_system_status_rate(self, test_system_status): """Test that the status update interval is calculated from the rate correctly. """ update_interval = 1.0 / test_system_status.rate @@ -188,16 +179,12 @@ def test_update_loop_exception(self, test_system_status): def test_default_rate_argument(self, test_system_status): """Test that that the default monitoring rate argument is applied correctly.""" - stash_singleton = dict(Singleton._instances) - Singleton._instances = {} temp_system_status = SystemStatus( interfaces=test_system_status.interfaces, disks=test_system_status.disks, processes=test_system_status.processes, ) assert pytest.approx(1.0) == temp_system_status._update_interval - Singleton._instances = {} - Singleton._instances = dict(stash_singleton) def test_num_processes_change(self, test_system_status, caplog): """Test that monitoring processes correctly detects a change in the number of processes.""" diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 9247e294..294aa9ca 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -1,18 +1,11 @@ -import sys import os -from contextlib import contextmanager from tempfile import NamedTemporaryFile import pytest import tornado.options -if sys.version_info[0] == 3: # pragma: no cover - from io import StringIO - from configparser import ConfigParser as NativeConfigParser -else: # pragma: no cover - from StringIO import StringIO - from ConfigParser import SafeConfigParser as NativeConfigParser +from configparser import ConfigParser as NativeConfigParser from odin_control.config.parser import ConfigParser, ConfigOption, ConfigError, AdapterConfig, _parse_multiple_arg diff --git a/tests/conftest.py b/tests/conftest.py index 995f60a1..da73fb24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1 @@ -import sys - -collect_ignore = [] - -if sys.version_info[0] < 3: - collect_ignore_glob = ["*_py3.py"] -else: - collect_ignore_glob = ["*_py2.py"] +collect_ignore = [] \ No newline at end of file diff --git a/tests/handlers/fixtures.py b/tests/handlers/fixtures.py index 5ec7d305..f598842b 100644 --- a/tests/handlers/fixtures.py +++ b/tests/handlers/fixtures.py @@ -1,29 +1,22 @@ -import sys import json import pytest -if sys.version_info[0] == 3: # pragma: no cover - from unittest.mock import Mock - import asyncio - async_allowed = True -else: # pragma: no cover - from mock import Mock - async_allowed = False +from unittest.mock import Mock -from odin_control.http.handlers.base import BaseApiHandler, API_VERSION, ApiError, validate_api_request -from odin_control.http.routes.api import ApiHandler +from odin_control.http.handlers.api import ApiHandler, API_VERSION, validate_api_request +from odin_control.http.handlers.api_adapter_list import ApiAdapterListHandler +from odin_control.http.handlers.api_version import ApiVersionHandler from odin_control.adapters.adapter import ApiAdapterResponse -from odin_control.config.parser import AdapterConfig from odin_control.util import wrap_result class TestHandler(object): """Class to create appropriate mocked objects to allow the ApiHandler to be tested.""" - def __init__(self, handler_cls, async_adapter=async_allowed, enable_cors=False): + def __init__(self, handler_cls, async_adapter=True, with_route=True, **handler_kwargs): """Initialise the TestHandler.""" - self.enable_cors = enable_cors + self.handler_kwargs = handler_kwargs # Initialise attribute to receive output of patched write() method self.write_data = None @@ -51,6 +44,8 @@ def __init__(self, handler_cls, async_adapter=async_allowed, enable_cors=False): self.route.adapters = {} self.route.adapter = lambda subsystem: self.route.adapters[subsystem] self.route.has_adapter = lambda subsystem: subsystem in self.route.adapters + if with_route: + handler_kwargs['route'] = self.route # Create a mock API adapter that returns appropriate responses api_adapter_mock = Mock() @@ -62,13 +57,12 @@ def __init__(self, handler_cls, async_adapter=async_allowed, enable_cors=False): self.route.adapters[self.subsystem] = api_adapter_mock # Create the handler and mock its write method with the local version - self.handler = handler_cls(self.app, self.request, - route=self.route, enable_cors=self.enable_cors, cors_origin="*" - ) + self.handler = handler_cls(self.app, self.request, **handler_kwargs) + self.handler.write = self.mock_write self.handler.dummy_get = self.dummy_get - self.respond = self.handler.respond + # self.respond = self.handler.respond self.headers = lambda: self.handler._headers def mock_write(self, chunk): @@ -88,14 +82,7 @@ def dummy_get(self, subsystem, path=''): ) self.respond(response) -if async_allowed: - fixture_params = [True, False] - fixture_ids = ["async", "sync"] -else: - fixture_params = [False] - fixture_ids = ["sync"] - -@pytest.fixture(scope="class", params=fixture_params, ids=fixture_ids) +@pytest.fixture(scope="class", params=[True, False], ids=["async", "sync"]) def test_api_handler(request): """ Parameterised test fixture for testing the APIHandler class. @@ -103,17 +90,29 @@ def test_api_handler(request): The fixture parameters and id lists are set depending on whether async code is allowed on the current platform (e.g. python 2 vs 3). """ - test_api_handler = TestHandler(ApiHandler, async_adapter=request.param) + test_api_handler = TestHandler( + ApiHandler, async_adapter=request.param, enable_cors=False, cors_origin="*" + ) + yield test_api_handler + +@pytest.fixture(scope="class", params=[True, False], ids=["CORS enabled", "CORS disabled"]) +def test_api_handler_cors(request): + """Test fixture for testing the ApiHandler class CORS support.""" + test_api_handler = TestHandler( + ApiHandler, async_adapter=False,enable_cors=request.param, cors_origin="*" + ) yield test_api_handler @pytest.fixture(scope="class") -def test_base_handler(): - """Test fixture for testing the BaseHandler class.""" - test_base_handler = TestHandler(BaseApiHandler) - yield test_base_handler +def test_api_adapter_list_handler(): + """Test fixture for testing the ApiAdapterListHandler class.""" + test_api_adapter_list_handler = TestHandler(ApiAdapterListHandler, async_adapter=False) + test_api_adapter_list_handler.request.headers = {'Accept': 'application/json'} + yield test_api_adapter_list_handler -@pytest.fixture(scope="class", params=[True, False], ids=["CORS enabled", "CORS disabled"]) -def test_base_handler_cors(request): - """Test fixture for testing the BaseHandler class.""" - test_base_handler = TestHandler(BaseApiHandler, enable_cors=request.param) - yield test_base_handler +@pytest.fixture(scope="class") +def test_api_version_handler(): + """Test fixture for testing the ApiVersionHandler class.""" + test_api_version_handler = TestHandler(ApiVersionHandler, async_adapter=False, with_route=False) + test_api_version_handler.request.headers = {'Accept': 'application/json'} + yield test_api_version_handler diff --git a/tests/handlers/test_api.py b/tests/handlers/test_api.py new file mode 100644 index 00000000..50252755 --- /dev/null +++ b/tests/handlers/test_api.py @@ -0,0 +1,116 @@ +import json + +import pytest + +from odin_control.http.handlers.api import API_VERSION, ApiError +from odin_control.adapters.adapter import ApiAdapterResponse +from tests.handlers.fixtures import test_api_handler, test_api_handler_cors + +class TestApiHandler(): + """Test cases for the ApiHandler class.""" + + @pytest.mark.asyncio + async def test_handler_initializes_route(self, test_api_handler): + """ + Test that the handler route has been set, i.e that that handler has its + initialize method called. + """ + assert test_api_handler.handler.route == test_api_handler.route + + def test_handler_response_json_str(self, test_api_handler): + """Test that the handler respond correctly deals with a string response.""" + test_api_handler.handler.respond(test_api_handler.json_str_response) + assert test_api_handler.write_data == test_api_handler.json_str_response.data + + def test_handler_response_json_dict(self, test_api_handler): + """Test that the handler respond correctly deals with a dict response.""" + test_api_handler.handler.respond(test_api_handler.json_dict_response) + assert test_api_handler.write_data == test_api_handler.json_str_response.data + + def test_handler_respond_valid_json(self, test_api_handler): + """Test that the base handler respond method handles a valid JSON ApiAdapterResponse.""" + data = {'valid': 'json', 'value': 1.234} + valid_response = ApiAdapterResponse(data, content_type="application/json") + test_api_handler.handler.respond(valid_response) + assert test_api_handler.handler.get_status() == 200 + assert json.loads(test_api_handler.write_data) == data + + def test_handler_respond_invalid_json(self, test_api_handler): + """ + Test that the base handler respond method raises an ApiError when passed + an invalid response. + """ + invalid_response = ApiAdapterResponse(1234, content_type="application/json") + with pytest.raises(ApiError) as excinfo: + test_api_handler.handler.respond(invalid_response) + + assert 'A response with content type application/json must have str or dict data' \ + in str(excinfo.value) + + @pytest.mark.asyncio + async def test_handler_valid_get(self, test_api_handler): + """Test that the handler creates a valid status and response to a GET request.""" + await test_api_handler.handler.get(str(API_VERSION), + test_api_handler.subsystem, test_api_handler.path) + assert test_api_handler.handler.get_status() == 200 + assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data + + @pytest.mark.asyncio + async def test_handler_valid_post(self, test_api_handler): + """Test that the handler creates a valid status and response to a POST request.""" + await test_api_handler.handler.post(str(API_VERSION), + test_api_handler.subsystem, test_api_handler.path) + assert test_api_handler.handler.get_status() == 200 + assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data + + @pytest.mark.asyncio + async def test_handler_valid_put(self, test_api_handler): + """Test that the handler creates a valid status and response to a PUT request.""" + await test_api_handler.handler.put(str(API_VERSION), + test_api_handler.subsystem, test_api_handler.path) + assert test_api_handler.handler.get_status() == 200 + assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data + + @pytest.mark.asyncio + async def test_handler_valid_delete(self, test_api_handler): + """Test that the handler creates a valid status and response to a DELETE request.""" + await test_api_handler.handler.delete(str(API_VERSION), + test_api_handler.subsystem, test_api_handler.path) + assert test_api_handler.handler.get_status() == 200 + assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data + + @pytest.mark.asyncio + async def test_bad_api_version(self, test_api_handler): + """Test that a bad API version in a GET call to the handler yields an error.""" + bad_version = 0.1234 + await test_api_handler.handler.get(str(bad_version), + test_api_handler.subsystem, test_api_handler.path) + assert test_api_handler.handler.get_status() == 400 + assert "API version {} is not supported".format(bad_version) in test_api_handler.write_data + + @pytest.mark.asyncio + async def test_bad_subsystem(self, test_api_handler): + """Test that a bad subsystem in a GET call to the handler yields an error.""" + bad_subsystem = 'missing' + await test_api_handler.handler.get(str(API_VERSION), bad_subsystem, test_api_handler.path) + assert test_api_handler.handler.get_status() == 400 + assert "No API adapter registered for subsystem {}".format(bad_subsystem) \ + in test_api_handler.write_data + +class TestApiHandlerCorsSupport(): + + def test_cors_headers(self, test_api_handler_cors): + test_api_handler_cors.handler.options( + test_api_handler_cors.subsystem, test_api_handler_cors.path) + cors_headers = [ + "Access-Control-Allow-Origin", + "Access-Control-Allow-Headers", + "Access-Control-Allow-Methods" + ] + + headers = test_api_handler_cors.headers() + for cors_header in cors_headers: + if test_api_handler_cors.handler_kwargs.get('enable_cors', False): + assert cors_header in headers + else: + assert cors_header not in headers \ No newline at end of file diff --git a/tests/handlers/test_api_adapter_list.py b/tests/handlers/test_api_adapter_list.py new file mode 100644 index 00000000..f744e401 --- /dev/null +++ b/tests/handlers/test_api_adapter_list.py @@ -0,0 +1,36 @@ +import json + +from odin_control.http.handlers.api_adapter_list import API_VERSION +from tests.handlers.fixtures import test_api_adapter_list_handler + +class TestApiAdapterListHandler(): + """Test cases for the ApiAdapterListHandler class.""" + + def test_handler_initializes_route(self, test_api_adapter_list_handler): + + assert test_api_adapter_list_handler.handler.route == test_api_adapter_list_handler.route + + def test_api_adapter_list_handler_get_adapters(self, test_api_adapter_list_handler): + + test_api_adapter_list_handler.handler.get(str(API_VERSION)) + + adapter_list = json.loads(test_api_adapter_list_handler.write_data) + assert 'adapters' in adapter_list + assert isinstance(adapter_list['adapters'], list) + assert test_api_adapter_list_handler.subsystem in adapter_list['adapters'] + + def test_api_adapter_list_handler_invalid_version(self, test_api_adapter_list_handler): + + invalid_version = str(API_VERSION + 1) + test_api_adapter_list_handler.handler.get(invalid_version) + + assert test_api_adapter_list_handler.handler.get_status() == 400 + assert "is not supported" in test_api_adapter_list_handler.write_data + + def test_api_adapter_list_handler_invalid_accept(self, test_api_adapter_list_handler): + + test_api_adapter_list_handler.request.headers = {'Accept': 'text/plain'} + test_api_adapter_list_handler.handler.get(str(API_VERSION)) + + assert test_api_adapter_list_handler.handler.get_status() == 406 + assert "Request content types not supported" in test_api_adapter_list_handler.write_data \ No newline at end of file diff --git a/tests/handlers/test_api_py2.py b/tests/handlers/test_api_py2.py deleted file mode 100644 index 85fea126..00000000 --- a/tests/handlers/test_api_py2.py +++ /dev/null @@ -1,59 +0,0 @@ -import sys -import json - -import pytest - -if sys.version_info[0] == 3: # pragma: no cover - from unittest.mock import Mock -else: # pragma: no cover - from mock import Mock - -from odin_control.http.routes.api import ApiRoute, ApiHandler, ApiError, API_VERSION -from odin_control.config.parser import AdapterConfig -from tests.handlers.fixtures import test_api_handler - -class TestApiHandler(object): - - def test_handler_valid_get(self, test_api_handler): - """Test that the handler creates a valid status and response to a GET request.""" - test_api_handler.handler.get(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 200 - assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data - - def test_handler_valid_post(self, test_api_handler): - """Test that the handler creates a valid status and response to a POST request.""" - test_api_handler.handler.post(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 200 - assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data - - def test_handler_valid_put(self, test_api_handler): - """Test that the handler creates a valid status and response to a PUT request.""" - test_api_handler.handler.put(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 200 - assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data - - def test_handler_valid_delete(self, test_api_handler): - """Test that the handler creates a valid status and response to a PUT request.""" - test_api_handler.handler.delete(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 200 - assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data - - def test_bad_api_version(self, test_api_handler): - """Test that a bad API version in a GET call to the handler yields an error.""" - bad_version = 0.1234 - test_api_handler.handler.get(str(bad_version), - test_api_handler.subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 400 - assert "API version {} is not supported".format(bad_version) in test_api_handler.write_data - - def test_bad_subsystem(self, test_api_handler): - """Test that a bad subsystem in a GET call to the handler yields an error.""" - bad_subsystem = 'missing' - test_api_handler.handler.get(str(API_VERSION), bad_subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 400 - assert "No API adapter registered for subsystem {}".format(bad_subsystem) \ - in test_api_handler.write_data diff --git a/tests/handlers/test_api_py3.py b/tests/handlers/test_api_py3.py deleted file mode 100644 index 12a7bb34..00000000 --- a/tests/handlers/test_api_py3.py +++ /dev/null @@ -1,65 +0,0 @@ -import sys -import json - -import pytest - -if sys.version_info[0] == 3: # pragma: no cover - from unittest.mock import Mock -else: # pragma: no cover - from mock import Mock - pytest.skip("Skipping async tests", allow_module_level=True) - -from odin_control.http.handlers.base import BaseApiHandler, API_VERSION -from tests.handlers.fixtures import test_api_handler - -class TestApiHandler(object): - - @pytest.mark.asyncio - async def test_handler_valid_get(self, test_api_handler): - """Test that the handler creates a valid status and response to a GET request.""" - await test_api_handler.handler.get(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 200 - assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data - - @pytest.mark.asyncio - async def test_handler_valid_post(self, test_api_handler): - """Test that the handler creates a valid status and response to a POST request.""" - await test_api_handler.handler.post(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 200 - assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data - - @pytest.mark.asyncio - async def test_handler_valid_put(self, test_api_handler): - """Test that the handler creates a valid status and response to a PUT request.""" - await test_api_handler.handler.put(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 200 - assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data - - @pytest.mark.asyncio - async def test_handler_valid_delete(self, test_api_handler): - """Test that the handler creates a valid status and response to a DELETE request.""" - await test_api_handler.handler.delete(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 200 - assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data - - @pytest.mark.asyncio - async def test_bad_api_version(self, test_api_handler): - """Test that a bad API version in a GET call to the handler yields an error.""" - bad_version = 0.1234 - await test_api_handler.handler.get(str(bad_version), - test_api_handler.subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 400 - assert "API version {} is not supported".format(bad_version) in test_api_handler.write_data - - @pytest.mark.asyncio - async def test_bad_subsystem(self, test_api_handler): - """Test that a bad subsystem in a GET call to the handler yields an error.""" - bad_subsystem = 'missing' - await test_api_handler.handler.get(str(API_VERSION), bad_subsystem, test_api_handler.path) - assert test_api_handler.handler.get_status() == 400 - assert "No API adapter registered for subsystem {}".format(bad_subsystem) \ - in test_api_handler.write_data diff --git a/tests/handlers/test_api_version.py b/tests/handlers/test_api_version.py new file mode 100644 index 00000000..faec12ce --- /dev/null +++ b/tests/handlers/test_api_version.py @@ -0,0 +1,23 @@ +import json + +from odin_control.http.handlers.api_adapter_list import API_VERSION +from tests.handlers.fixtures import test_api_version_handler + +class TestApiVersionHandler(): + """Test cases for the ApiVersionHandler class.""" + + def test_handler_valid_get(self, test_api_version_handler): + """Test that the handler creates a valid status and response to a GET request.""" + test_api_version_handler.handler.get() + assert test_api_version_handler.handler.get_status() == 200 + response_data = json.loads(test_api_version_handler.write_data) + assert 'api' in response_data + assert response_data['api'] == API_VERSION + + def test_handler_invalid_accept(self, test_api_version_handler): + + test_api_version_handler.request.headers = {'Accept': 'text/plain'} + test_api_version_handler.handler.get() + + assert test_api_version_handler.handler.get_status() == 406 + assert "Requested content types not supported" in test_api_version_handler.write_data diff --git a/tests/handlers/test_base.py b/tests/handlers/test_base.py deleted file mode 100644 index f8b015f7..00000000 --- a/tests/handlers/test_base.py +++ /dev/null @@ -1,147 +0,0 @@ -import sys -import json - -import pytest - -if sys.version_info[0] == 3: # pragma: no cover - from unittest.mock import Mock -else: # pragma: no cover - from mock import Mock - - -from odin_control.http.handlers.base import BaseApiHandler, API_VERSION, ApiError, validate_api_request -from odin_control.adapters.adapter import ApiAdapterResponse -from tests.handlers.fixtures import test_base_handler, test_base_handler_cors - - -class TestBaseApiHandler(object): - """Test cases for the BaseApiHandler class.""" - - def test_handler_initializes_route(self, test_base_handler): - """ - Check that the handler route has been set, i.e that that handler has its - initialize method called. - """ - assert test_base_handler.handler.route == test_base_handler.route - - def test_handler_response_json_str(self, test_base_handler): - """Test that the handler respond correctly deals with a string response.""" - test_base_handler.handler.respond(test_base_handler.json_str_response) - assert test_base_handler.write_data == test_base_handler.json_str_response.data - - def test_handler_response_json_dict(self, test_base_handler): - """Test that the handler respond correctly deals with a dict response.""" - test_base_handler.handler.respond(test_base_handler.json_dict_response) - assert test_base_handler.write_data == test_base_handler.json_str_response.data - - def test_handler_respond_valid_json(self, test_base_handler): - """Test that the base handler respond method handles a valid JSON ApiAdapterResponse.""" - data = {'valid': 'json', 'value': 1.234} - valid_response = ApiAdapterResponse(data, content_type="application/json") - test_base_handler.handler.respond(valid_response) - assert test_base_handler.handler.get_status() == 200 - assert json.loads(test_base_handler.write_data) == data - - def test_handler_respond_invalid_json(self, test_base_handler): - """ - Test that the base handler respond method raises an ApiError when passed - an invalid response. - """ - invalid_response = ApiAdapterResponse(1234, content_type="application/json") - with pytest.raises(ApiError) as excinfo: - test_base_handler.handler.respond(invalid_response) - - assert 'A response with content type application/json must have str or dict data' \ - in str(excinfo.value) - - def test_handler_get(self, test_base_handler): - """Test that the base handler get method raises a not implemented error.""" - with pytest.raises(NotImplementedError): - test_base_handler.handler.get( - test_base_handler.subsystem, test_base_handler.path) - - def test_handler_post(self, test_base_handler): - """Test that the base handler post method raises a not implemented error.""" - with pytest.raises(NotImplementedError): - test_base_handler.handler.post( - test_base_handler.subsystem, test_base_handler.path) - - def test_handler_put(self, test_base_handler): - """Test that the base handler put method raises a not implemented error.""" - with pytest.raises(NotImplementedError): - test_base_handler.handler.put( - test_base_handler.subsystem, test_base_handler.path) - - def test_handler_delete(self, test_base_handler): - """Test that the base handler delete method raises a not implemented error.""" - with pytest.raises(NotImplementedError): - test_base_handler.handler.delete( - test_base_handler.subsystem, test_base_handler.path) - - def test_handler_options(self, test_base_handler): - """Test that the base handler options method returns a 204 success status code.""" - test_base_handler.handler.options(test_base_handler.subsystem, test_base_handler.path) - assert test_base_handler.handler.get_status() == 204 - - -class TestBaseHandlerCorsSupport(): - - def test_cors_headers(self, test_base_handler_cors): - test_base_handler_cors.handler.options( - test_base_handler_cors.subsystem, test_base_handler_cors.path) - - cors_headers = [ - "Access-Control-Allow-Origin", - "Access-Control-Allow-Headers", - "Access-Control-Allow-Methods" - ] - - headers = test_base_handler_cors.headers() - for cors_header in cors_headers: - if test_base_handler_cors.enable_cors: - assert cors_header in headers - else: - assert cors_header not in headers - -class TestHandlerRequestValidation(): - """Test cases for the validate_api_request decorator.""" - - def test_invalid_api_request_version(self, test_base_handler): - """ - Check that a request with an invalid API version is intercepted by the decorator - and returns an appropriate HTTP response. - """ - bad_version = 0.1234 - test_base_handler.handler.dummy_get( - str(bad_version), test_base_handler.subsystem, test_base_handler.path - ) - assert test_base_handler.handler.get_status() == 400 - assert "API version {} is not supported".format(bad_version) in test_base_handler.write_data - - def test_invalid_subsystem_request(self, test_base_handler): - """ - Check that a request with an invalid subsystem, i.e. one which does not have an - adapter registered, is intercepted by the decorator and returns an appropriate - HTTP response. - """ - bad_subsystem = 'bad_subsys' - test_base_handler.handler.dummy_get( - str(API_VERSION), bad_subsystem, test_base_handler.path - ) - assert test_base_handler.handler.get_status() == 400 - assert "No API adapter registered for subsystem {}".format(bad_subsystem) \ - in test_base_handler.write_data - - def test_valid_request(self, test_base_handler): - """ - Check that a request with a valid API version and subsystem is not intercepted by - the decorator and calls the verb method correctly. - """ - test_base_handler.handler.dummy_get( - str(API_VERSION), test_base_handler.subsystem, test_base_handler.path - ) - assert test_base_handler.handler.get_status() == 200 - - - - diff --git a/tests/routes/test_api.py b/tests/routes/test_api.py index c3d1c808..dfc8419a 100644 --- a/tests/routes/test_api.py +++ b/tests/routes/test_api.py @@ -1,14 +1,8 @@ -import sys -import json - import pytest -if sys.version_info[0] == 3: # pragma: no cover - from unittest.mock import Mock -else: # pragma: no cover - from mock import Mock +from unittest.mock import Mock -from odin_control.http.routes.api import ApiRoute, ApiHandler, ApiError, API_VERSION +from odin_control.http.routes.api import ApiRoute, ApiError from odin_control.config.parser import AdapterConfig @pytest.fixture(scope="class") diff --git a/tests/routes/test_async_api_py3.py b/tests/routes/test_async_api.py similarity index 86% rename from tests/routes/test_async_api_py3.py rename to tests/routes/test_async_api.py index 58fea263..9facacd5 100644 --- a/tests/routes/test_async_api_py3.py +++ b/tests/routes/test_async_api.py @@ -1,14 +1,9 @@ -import sys - import pytest -if sys.version_info[0] < 3: - pytest.skip("Skipping async tests", allow_module_level=True) -else: - try: - from unittest.mock import AsyncMock - except ImportError: - from tests.async_utils import AsyncMock +try: + from unittest.mock import AsyncMock +except ImportError: + from tests.async_utils import AsyncMock from odin_control.http.routes.api import ApiRoute @@ -24,7 +19,7 @@ def __init__(self): self.adapter_config = AdapterConfig(self.adapter_name, self.adapter_module) self.route.register_adapter(self.adapter_config) - + self.initialize_mock = AsyncMock() self.route.adapters[self.adapter_name].initialize = self.initialize_mock diff --git a/tests/test_server.py b/tests/test_server.py index b7eca0fc..f8a75eb4 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4,10 +4,7 @@ import sys import pytest -if sys.version_info[0] == 3: # pragma: no cover - from unittest import mock -else: # pragma: no cover - import mock +from unittest import mock from odin_control.http.server import HttpServer from odin_control import main @@ -365,8 +362,8 @@ class TestOdinHttpsServer(): def test_https_valid_config(self, server_config, ssl_test_cert, caplog): server_config.enable_https = True - server_config.ssl_cert_file = ssl_test_cert.cert_file - server_config.ssl_key_file = ssl_test_cert.key_file + server_config.ssl_cert_file = ssl_test_cert.cert_file + server_config.ssl_key_file = ssl_test_cert.key_file server = HttpServer(server_config) server.stop() diff --git a/tests/test_util.py b/tests/test_util.py index da41dabe..dfba9cd9 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,15 +1,10 @@ -import sys import pytest import time import concurrent.futures import tornado.concurrent -from tornado import version_info -if sys.version_info[0] == 3: # pragma: no cover - from unittest.mock import Mock - import asyncio -else: # pragma: no cover - from mock import Mock +from unittest.mock import Mock +import asyncio from odin_control import util @@ -40,42 +35,12 @@ def test_decode_request_body_type_error(self): response = util.decode_request_body(request) assert response == request.body - def test_convert_unicode_to_string(self): - """Test conversion of unicode to string.""" - u_string = u'test string' - result = util.convert_unicode_to_string(u_string) - assert result == "test string" - - def test_convert_unicode_to_string_list(self): - """Test conversion of a list of unicode strings to strings.""" - u_list = [u'first string', u'second string'] - result = util.convert_unicode_to_string(u_list) - assert result == ["first string", "second string"] - - def test_convert_unicode_to_string_dict(self): - """Test conversion of a unicode dict to native string dict.""" - u_dict = {u'key': u'value'} - result = util.convert_unicode_to_string(u_dict) - assert result == {"key": "value"} - - def test_convert_unicode_to_string_mixed_recursion(self): - """Test recursion through a deeper object with mixed types.""" - u_object = {u'string': u'test string', - u'list': [u'unicode string', "normal string"] - } - result = util.convert_unicode_to_string(u_object) - expected_result = { - 'string': 'test string', - 'list': ['unicode string', "normal string"] - } - assert result == expected_result - @pytest.mark.parametrize("is_async", [True, False], ids=["async", "sync"]) def test_wrap_result(self, is_async): """Test that the wrap_result utility correctly wraps results in a future when needed.""" result = 321 wrapped_result = util.wrap_result(result, is_async) - if sys.version_info[0] == 3 and is_async: + if is_async: assert isinstance(wrapped_result, asyncio.Future) assert wrapped_result.result() == result else: @@ -112,12 +77,68 @@ def outer_task(num_loops): time.sleep(0.01) wait_count += 1 - if version_info[0] <= 4: - future_type = concurrent.futures.Future - else: - future_type = tornado.concurrent.Future - - assert isinstance(future, future_type) + assert isinstance(future, tornado.concurrent.Future) assert task_result['inner_completed'] is True assert task_result['count'] == num_loops assert task_result['outer_completed'] is True + +class TestUtilAsync(): + + @pytest.mark.asyncio + async def test_wrap_result(self): + """Test that the wrap_result utility correctly wraps results in a future when needed.""" + result = 321 + wrapped = util.wrap_result(result, True) + await wrapped + assert isinstance(wrapped, asyncio.Future) + assert wrapped.result() == result + + @pytest.mark.asyncio + async def test_wrap_async(self): + """Test that the wrap_async fuction correctly wraps results in a future.""" + result = 987 + wrapped = util.wrap_async(result) + await wrapped + assert isinstance(wrapped, asyncio.Future) + assert wrapped.result() == result + + @pytest.mark.asyncio + async def test_run_in_executor(self): + """ + Test that the run_in_executor utility runs a background task asynchronously and returns + an awaitable future. + """ + task_result = { + 'count': 0, + 'completed': False + } + + def task_func(num_loops): + """Simple task that loops and increments a counter before completing.""" + for _ in range(num_loops): + time.sleep(0.01) + task_result['count'] += 1 + task_result['completed'] = True + + executor = concurrent.futures.ThreadPoolExecutor() + + num_loops = 10 + await util.run_in_executor(executor, task_func, num_loops) + + wait_count = 0 + while not task_result['completed'] and wait_count < 100: + asyncio.sleep(0.01) + wait_count += 1 + + assert task_result['completed'] + assert task_result['count'] == num_loops + + def test_run_async(self): + + async def async_increment(value): + await asyncio.sleep(0) + return value + 1 + + value = 5 + result = util.run_async(async_increment, value) + assert result == value + 1 \ No newline at end of file diff --git a/tests/test_util_py3.py b/tests/test_util_py3.py deleted file mode 100644 index 2c2e7eb7..00000000 --- a/tests/test_util_py3.py +++ /dev/null @@ -1,76 +0,0 @@ -import sys -import pytest -import time -import concurrent.futures - -import pytest_asyncio - -from odin_control import util -from odin_control import async_util - -if sys.version_info[0] < 3: - pytest.skip("Skipping async tests", allow_module_level=True) - -import asyncio - - -class TestUtilAsync(): - - @pytest.mark.asyncio - async def test_wrap_result(self): - """Test that the wrap_result utility correctly wraps results in a future when needed.""" - result = 321 - wrapped = util.wrap_result(result, True) - await wrapped - assert isinstance(wrapped, asyncio.Future) - assert wrapped.result() == result - - @pytest.mark.asyncio - async def test_wrap_async(self): - """Test that the wrap_async fuction correctly wraps results in a future.""" - result = 987 - wrapped = async_util.wrap_async(result) - await wrapped - assert isinstance(wrapped, asyncio.Future) - assert wrapped.result() == result - - @pytest.mark.asyncio - async def test_run_in_executor(self): - """ - Test that the run_in_executor utility runs a background task asynchronously and returns - an awaitable future. - """ - task_result = { - 'count': 0, - 'completed': False - } - - def task_func(num_loops): - """Simple task that loops and increments a counter before completing.""" - for _ in range(num_loops): - time.sleep(0.01) - task_result['count'] += 1 - task_result['completed'] = True - - executor = concurrent.futures.ThreadPoolExecutor() - - num_loops = 10 - await util.run_in_executor(executor, task_func, num_loops) - - wait_count = 0 - while not task_result['completed'] and wait_count < 100: - asyncio.sleep(0.01) - wait_count += 1 - - assert task_result['completed'] == True - assert task_result['count'] == num_loops - - def test_run_async(self): - - async def async_increment(value): - await asyncio.sleep(0) - return value + 1 - - value = 5 - result = async_util.run_async(async_increment, value) - assert result == value + 1 \ No newline at end of file diff --git a/tests/utils.py b/tests/utils.py index 6de03e2f..251f8100 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,19 +4,14 @@ Tim Nicholls, STFC Application Engineering Group """ -import sys import time import threading -import logging import os from tempfile import NamedTemporaryFile -if sys.version_info[0] == 3: # pragma: no cover - from configparser import ConfigParser - import asyncio -else: # pragma: no cover - from ConfigParser import SafeConfigParser as ConfigParser +from configparser import ConfigParser +import asyncio from tornado.ioloop import IOLoop @@ -98,8 +93,7 @@ def __del__(self): self.stop() def _run_server(self, server_args): - if sys.version_info[0] == 3: # pragma: no cover - asyncio.set_event_loop(asyncio.new_event_loop()) + asyncio.set_event_loop(asyncio.new_event_loop()) self.server_event_loop = IOLoop.current() main.main(server_args) From 8d89e2ab959b91520577f45b0bd112c9ea803a25 Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Fri, 5 Dec 2025 07:55:02 +0000 Subject: [PATCH 03/13] Remove redundant API versioning from URLs (#70) * Remove global API versioning by default. Removes the global API version e.g. /api/0.1/adapter_name from API-related routes. The original behaviour can be restored by setting the api_version configuration option accordingly. * Refactor ApiAdapterListHandler to ApiAdapterInfoHandler Handler now returns a structured response containing module and version information for each loaded adapter. Update base ApiAdapter classes and core adapters to add a version field which can return per-adapter version information. --- pyproject.toml | 17 ++++ src/odin_control/adapters/adapter.py | 1 + src/odin_control/adapters/async_dummy.py | 3 + src/odin_control/adapters/async_proxy.py | 3 + src/odin_control/adapters/dummy.py | 5 + src/odin_control/adapters/proxy.py | 3 + src/odin_control/adapters/system_info.py | 14 ++- src/odin_control/adapters/system_status.py | 3 + src/odin_control/http/handlers/api.py | 49 +++++----- .../http/handlers/api_adapter_info.py | 55 +++++++++++ .../http/handlers/api_adapter_list.py | 39 -------- src/odin_control/http/handlers/api_version.py | 17 +++- src/odin_control/http/routes/api.py | 39 +++++--- src/odin_control/http/server.py | 5 +- src/odin_control/main.py | 82 +++++++++------- tests/handlers/fixtures.py | 64 ++++++++----- tests/handlers/test_api.py | 95 ++++++++++++++++--- tests/handlers/test_api_adapter_info.py | 36 +++++++ tests/handlers/test_api_adapter_list.py | 36 ------- tests/handlers/test_api_version.py | 5 +- tests/test_server.py | 9 +- tests/utils.py | 6 +- 22 files changed, 387 insertions(+), 199 deletions(-) create mode 100644 src/odin_control/http/handlers/api_adapter_info.py delete mode 100644 src/odin_control/http/handlers/api_adapter_list.py create mode 100644 tests/handlers/test_api_adapter_info.py delete mode 100644 tests/handlers/test_api_adapter_list.py diff --git a/pyproject.toml b/pyproject.toml index 61362907..63af7490 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,3 +103,20 @@ commands = coverage combine coverage report -m """ + +[tool.ruff] +src = ["src", "tests"] +line-length = 100 +lint.select = [ + "D", # docstrings + "D401", # docstrings require imperative mood + "C4", # flake8-comprehensions - https://beta.ruff.rs/docs/rules/#flake8-comprehensions-c4 + "E", # pycodestyle errors - https://beta.ruff.rs/docs/rules/#error-e + "F", # pyflakes rules - https://beta.ruff.rs/docs/rules/#pyflakes-f + "F401", + "W", # pycodestyle warnings - https://beta.ruff.rs/docs/rules/#warning-w + "I001", # isort +] + +[tool.ruff.lint.pydocstyle] +convention = "google" diff --git a/src/odin_control/adapters/adapter.py b/src/odin_control/adapters/adapter.py index b1a9ebd8..3f6482fe 100644 --- a/src/odin_control/adapters/adapter.py +++ b/src/odin_control/adapters/adapter.py @@ -18,6 +18,7 @@ class ApiAdapter(object): """ is_async = False + version = "unknown" def __init__(self, **kwargs): """Initialise the ApiAdapter object. diff --git a/src/odin_control/adapters/async_dummy.py b/src/odin_control/adapters/async_dummy.py index 5ae91c7e..45d773c8 100644 --- a/src/odin_control/adapters/async_dummy.py +++ b/src/odin_control/adapters/async_dummy.py @@ -11,6 +11,7 @@ import time import concurrent.futures +from odin_control._version import __version__ from odin_control.adapters.adapter import ApiAdapterResponse, request_types, response_types from odin_control.adapters.async_adapter import AsyncApiAdapter from odin_control.adapters.async_parameter_tree import AsyncParameterTree @@ -28,6 +29,8 @@ class AsyncDummyAdapter(AsyncApiAdapter): during long-running async tasks. """ + version = __version__ + def __init__(self, **kwargs): """Intialize the AsyncDummy Adapter object. diff --git a/src/odin_control/adapters/async_proxy.py b/src/odin_control/adapters/async_proxy.py index 1ec3078d..8e47d1e8 100644 --- a/src/odin_control/adapters/async_proxy.py +++ b/src/odin_control/adapters/async_proxy.py @@ -13,6 +13,7 @@ import tornado from tornado.httpclient import AsyncHTTPClient, HTTPRequest +from odin_control._version import __version__ from odin_control.adapters.adapter import ( ApiAdapterResponse, request_types, @@ -147,6 +148,8 @@ class AsyncProxyAdapter(AsyncApiAdapter, BaseProxyAdapter): other HTTP services. """ + version = __version__ + def __init__(self, **kwargs): """ Initialise the AsyncProxyAdapter. diff --git a/src/odin_control/adapters/dummy.py b/src/odin_control/adapters/dummy.py index 02af5a30..a578fec3 100644 --- a/src/odin_control/adapters/dummy.py +++ b/src/odin_control/adapters/dummy.py @@ -12,6 +12,7 @@ import logging from tornado.ioloop import PeriodicCallback +from odin_control._version import __version__ from odin_control.adapters.adapter import (ApiAdapter, ApiAdapterRequest, ApiAdapterResponse, request_types, response_types) from odin_control.util import decode_request_body @@ -24,6 +25,8 @@ class DummyAdapter(ApiAdapter): and HTTP verb methods (GET, PUT, DELETE) with various request and response types allowed. """ + version = __version__ + def __init__(self, **kwargs): """Initialize the DummyAdapter object. @@ -149,6 +152,8 @@ class IacDummyAdapter(ApiAdapter): and allows another adapter to interact with it via these methods. """ + version = __version__ + def __init__(self, **kwargs): """Initialize the dummy target adapter. diff --git a/src/odin_control/adapters/proxy.py b/src/odin_control/adapters/proxy.py index 9539efe7..a0502db3 100644 --- a/src/odin_control/adapters/proxy.py +++ b/src/odin_control/adapters/proxy.py @@ -16,6 +16,7 @@ "Cannot create a ProxyAdapter instance as requests package not installed" ) +from odin_control._version import __version__ from odin_control.adapters.adapter import ( ApiAdapter, ApiAdapterResponse, @@ -122,6 +123,8 @@ class ProxyAdapter(ApiAdapter, BaseProxyAdapter): other HTTP services. """ + version = __version__ + def __init__(self, **kwargs): """ Initialise the ProxyAdapter. diff --git a/src/odin_control/adapters/system_info.py b/src/odin_control/adapters/system_info.py index 9ee08214..3c93cd50 100644 --- a/src/odin_control/adapters/system_info.py +++ b/src/odin_control/adapters/system_info.py @@ -8,12 +8,18 @@ import logging import platform import time + import tornado -from odin_control.adapters.adapter import (ApiAdapter, ApiAdapterResponse, - request_types, response_types, wants_metadata) -from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError from odin_control._version import __version__ +from odin_control.adapters.adapter import ( + ApiAdapter, + ApiAdapterResponse, + request_types, + response_types, + wants_metadata, +) +from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError class SystemInfoAdapter(ApiAdapter): @@ -23,6 +29,8 @@ class SystemInfoAdapter(ApiAdapter): running on. """ + version = __version__ + def __init__(self, **kwargs): """Initialize the SystemInfoAdapter object. diff --git a/src/odin_control/adapters/system_status.py b/src/odin_control/adapters/system_status.py index 5fda18e2..2030dd1f 100644 --- a/src/odin_control/adapters/system_status.py +++ b/src/odin_control/adapters/system_status.py @@ -23,6 +23,7 @@ import os import psutil from tornado.ioloop import IOLoop +from odin_control._version import __version__ from odin_control.adapters.adapter import ApiAdapter, ApiAdapterResponse, request_types, response_types from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError @@ -34,6 +35,8 @@ class SystemStatusAdapter(ApiAdapter): and processes. """ + version = __version__ + def __init__(self, **kwargs): """Initialize the SystemInfoAdapter object. diff --git a/src/odin_control/http/handlers/api.py b/src/odin_control/http/handlers/api.py index 67463529..69783faa 100644 --- a/src/odin_control/http/handlers/api.py +++ b/src/odin_control/http/handlers/api.py @@ -8,38 +8,43 @@ from odin_control.adapters.adapter import ApiAdapterResponse from odin_control.util import wrap_result -API_VERSION = 0.1 class ApiError(Exception): """Simple exception class for API-related errors.""" -def validate_api_request(required_version): +def validate_api_request(func): """Validate an API request to the ApiHandler. - This decorator checks that API version in the URI of a request is correct and that the subsystem - is registered with the application dispatcher; responds with a 400 error if not + This decorator checks that, if API versioning is enabled, the version element of the URI of a + request is correct and that the subsystem is registered with the application dispatcher; it + responds with a 400 error if not. """ - def decorator(func): - def wrapper(_self, *args, **kwargs): - # Extract version as first argument + def wrapper(_self, *args, **kwargs): + + # If API versioning is enabled, extract the version as first argument and validate it + if _self.route.api_version: version = args[0] - subsystem = args[1] - rem_args = args[2:] - if version != str(required_version): + args = args[1:] + if version != _self.route.api_version: _self.respond(ApiAdapterResponse( "API version {} is not supported".format(version), status_code=400)) return wrap_result(None) - if not _self.route.has_adapter(subsystem): - _self.respond(ApiAdapterResponse( - "No API adapter registered for subsystem {}".format(subsystem), - status_code=400)) - return wrap_result(None) - return func(_self, subsystem, *rem_args, **kwargs) - return wrapper - return decorator + + # Extract the subsystem and remaining arguments + subsystem = args[0] + rem_args = args[1:] + if not _self.route.has_adapter(subsystem): + _self.respond(ApiAdapterResponse( + "No API adapter registered for subsystem {}".format(subsystem), + status_code=400)) + return wrap_result(None) + + return func(_self, subsystem, *rem_args, **kwargs) + + return wrapper class ApiHandler(RequestHandler): @@ -106,7 +111,7 @@ def options(self, *_): # Set status to indicate successful request with no content returned self.set_status(204) - @validate_api_request(API_VERSION) + @validate_api_request async def get(self, subsystem, path=''): """Handle an API GET request. @@ -121,7 +126,7 @@ async def get(self, subsystem, path=''): self.respond(response) - @validate_api_request(API_VERSION) + @validate_api_request async def post(self, subsystem, path=''): """Handle an API POST request. @@ -136,7 +141,7 @@ async def post(self, subsystem, path=''): self.respond(response) - @validate_api_request(API_VERSION) + @validate_api_request async def put(self, subsystem, path=''): """Handle an API PUT request. @@ -150,7 +155,7 @@ async def put(self, subsystem, path=''): response = adapter.put(path, self.request) self.respond(response) - @validate_api_request(API_VERSION) + @validate_api_request async def delete(self, subsystem, path=''): """Handle an API DELETE request. diff --git a/src/odin_control/http/handlers/api_adapter_info.py b/src/odin_control/http/handlers/api_adapter_info.py new file mode 100644 index 00000000..7d2914fd --- /dev/null +++ b/src/odin_control/http/handlers/api_adapter_info.py @@ -0,0 +1,55 @@ +"""API adapter info handler module for odin-control. + +This module implements the API adapter info handler. It allows clients to obtain information about +loaded API adapters through HTTP GET requests, returning the adapter information as JSON. + +Tim Nicholls, STFC Detector Systems Software Group. +""" +from tornado.web import RequestHandler + + +class ApiAdapterInfoHandler(RequestHandler): + """API adapter info handler to return information about loaded adapters. + + This request hander implements the GET verb to allow a call to the appropriate URI to return + a JSON-encoded dictionary of information about the adapters loaded by the server. + """ + + def initialize(self, route): + """Initialize the API adapter info handler. + + :param route: ApiRoute object calling the handler (allows adapters to be resolved) + """ + self.route = route + + def get(self, version=None): + """Handle API adapter info GET requests. + + This handler returns a JSON-encoded dictionary of information about adapters loaded into the + server. + + :param version: API version (or None if versioning not enabled) + """ + # Validate the API version explicity - can't use the validate_api_request decorator here + if version != self.route.api_version: + self.set_status(400) + self.write("API version {} is not supported".format(version)) + return + + # Validate the accept type requested is appropriate + accept_types = self.request.headers.get('Accept', 'application/json').split(',') + if '*/*' not in accept_types and 'application/json' not in accept_types: + self.set_status(406) + self.write('Request content types not supported') + return + + # Build a dictionary of loaded adapter information + adapter_info = { + adapter_name: { + 'version': getattr(adapter, 'version', 'unknown'), + "module": f"{adapter.__class__.__module__}.{adapter.__class__.__name__}", + } for adapter_name, adapter in self.route.adapters.items() + } + + # Return the loaded adapter information + self.write({'adapters': adapter_info}) diff --git a/src/odin_control/http/handlers/api_adapter_list.py b/src/odin_control/http/handlers/api_adapter_list.py deleted file mode 100644 index dbc894eb..00000000 --- a/src/odin_control/http/handlers/api_adapter_list.py +++ /dev/null @@ -1,39 +0,0 @@ -from tornado.web import RequestHandler - -from .api import API_VERSION - -class ApiAdapterListHandler(RequestHandler): - """API adapter list handler to return a list of loaded adapters. - - This request hander implements the GET verb to allow a call to the appropriate URI to return - a JSON-encoded list of adapters loaded by the server. - """ - - def initialize(self, route): - """Initialize the API adapter list handler. - - :param route: ApiRoute object calling the handler (allows adapters to be resolved) - """ - self.route = route - - def get(self, version): - """Handle API adapter list GET requests. - - This handler returns a JSON-encoded list of adapters loaded into the server. - - :param version: API version - """ - # Validate the API version explicity - can't use the validate_api_request decorator here - if version != str(API_VERSION): - self.set_status(400) - self.write("API version {} is not supported".format(version)) - return - - # Validate the accept type requested is appropriate - accept_types = self.request.headers.get('Accept', 'application/json').split(',') - if '*/*' not in accept_types and 'application/json' not in accept_types: - self.set_status(406) - self.write('Request content types not supported') - return - - self.write({'adapters': [adapter for adapter in self.route.adapters]}) diff --git a/src/odin_control/http/handlers/api_version.py b/src/odin_control/http/handlers/api_version.py index eb49ad17..205616bf 100644 --- a/src/odin_control/http/handlers/api_version.py +++ b/src/odin_control/http/handlers/api_version.py @@ -1,8 +1,14 @@ +"""API version handler module for odin-control. + +This module implements the API version handler. It allows clients to query the supported API version +through HTTP GET requests, returning the version information as JSON. + +Tim Nicholls, STFC Detector Systems Software Group. +""" import json from tornado.web import RequestHandler -from .api import API_VERSION class ApiVersionHandler(RequestHandler): """API version handler to allow client to resolve supported version. @@ -11,6 +17,13 @@ class ApiVersionHandler(RequestHandler): the supported API version as JSON. """ + def initialize(self, route): + """Initialise the ApiVersionHandler. + + :param route: ApiRoute object calling the handler (allows API version to be resolved) + """ + self.route = route + def get(self): """Handle API version GET requests.""" accept_types = self.request.headers.get('Accept', 'application/json').split(',') @@ -19,4 +32,4 @@ def get(self): self.write('Requested content types not supported') return - self.write(json.dumps({'api': API_VERSION})) \ No newline at end of file + self.write(json.dumps({'version': self.route.api_version})) diff --git a/src/odin_control/http/routes/api.py b/src/odin_control/http/routes/api.py index 3542031d..cb16bf7a 100644 --- a/src/odin_control/http/routes/api.py +++ b/src/odin_control/http/routes/api.py @@ -9,7 +9,7 @@ import logging from odin_control.http.handlers.api import ApiError, ApiHandler -from odin_control.http.handlers.api_adapter_list import ApiAdapterListHandler +from odin_control.http.handlers.api_adapter_info import ApiAdapterInfoHandler from odin_control.http.handlers.api_version import ApiVersionHandler from odin_control.http.routes.route import Route from odin_control.util import run_async @@ -18,7 +18,7 @@ class ApiRoute(Route): """ApiRoute - API route object used to map handlers onto adapter for API calls.""" - def __init__(self, enable_cors=False, cors_origin="*"): + def __init__(self, enable_cors=False, cors_origin="*", api_version=None): """Initialize the ApiRoute object. This constructor initialises the API route object, defining handlers for version, adapter @@ -26,31 +26,44 @@ def __init__(self, enable_cors=False, cors_origin="*"): :param enable_cors: flag to enable CORS request support :param cors_origin: CORS allowed origins + :param api_version: API version string """ super(ApiRoute, self).__init__() + # Store the API version string so it can be used by handlers + self.api_version = api_version + # Define a default handler which can return the supported API version - self.add_handler((r"/api/?", ApiVersionHandler)) + self.add_handler((r"/api/?", ApiVersionHandler, {"route": self})) + + # Define the API handler URL specs depending on whether versioning is enabled + if self.api_version: + adapter_info_spec = r"/api/(.*?)/adapters/?" + api_specs = [r"/api/(.*?)/(.*?)/(.*)", r"/api/(.*?)/(.*?)/?"] + else: + adapter_info_spec = r"/api/adapters/?" + api_specs = [r"/api/(.*?)/(.*)", r"/api/(.*?)/?"] # Define a handler which can return a list of loaded adapters self.add_handler( - (r"/api/(.*?)/adapters/?", ApiAdapterListHandler, dict(route=self)) + (adapter_info_spec, ApiAdapterInfoHandler, {"route": self}) ) # Build a dict of params to be passed to API handler initialisation calls - handler_params = dict( - route=self, enable_cors=enable_cors, cors_origin=cors_origin - ) + handler_params = { + "route": self, "enable_cors": enable_cors, "cors_origin": cors_origin + } - # Define the handler for API calls. The expected URI syntax, which is - # enforced by the validate_api_request decorator, is the following: + # Define the handler for API calls. The expected URI syntax, which is enforced by the + # validate_api_request decorator, is the following: # # /api///.... # - # The second pattern allows an API adapter to be accessed with or without - # a trailing slash for maximum compatibility - self.add_handler((r"/api/(.*?)/(.*?)/(.*)", ApiHandler, handler_params)) - self.add_handler((r"/api/(.*?)/(.*?)/?", ApiHandler, handler_params)) + # where the part is optional depending on whether API versioning is enabled or + # not. The second pattern allows an API adapter to be accessed with or without a trailing + # slash for maximum compatibility + for api_spec in api_specs: + self.add_handler((api_spec, ApiHandler, handler_params)) self.adapters = {} diff --git a/src/odin_control/http/server.py b/src/odin_control/http/server.py index c40e5cc7..725f703e 100644 --- a/src/odin_control/http/server.py +++ b/src/odin_control/http/server.py @@ -43,7 +43,10 @@ def __init__(self, config): ) # Create an API route - self.api_route = ApiRoute(enable_cors=config.enable_cors, cors_origin=config.cors_origin) + self.api_route = ApiRoute( + enable_cors=config.enable_cors, cors_origin=config.cors_origin, + api_version=config.api_version + ) # Resolve the list of adapters specified try: diff --git a/src/odin_control/main.py b/src/odin_control/main.py index 9d594f50..e4bb2138 100644 --- a/src/odin_control/main.py +++ b/src/odin_control/main.py @@ -5,20 +5,22 @@ Tim Nicholls, STFC Application Engineering Group """ -import sys + import logging import signal +import sys import threading import tornado.ioloop from tornado.autoreload import add_reload_hook +from odin_control.config.parser import ConfigError, ConfigParser from odin_control.http.server import HttpServer -from odin_control.config.parser import ConfigParser, ConfigError from odin_control.logconfig import add_graylog_handler _stop_ioloop = False # Global variable to indicate ioloop should be shut down + def main(argv=None): """Run the odin-control server. @@ -31,40 +33,51 @@ def main(argv=None): config = ConfigParser() # Define configuration options and add to the configuration parser - config.define('http_addr', default='0.0.0.0', option_help='Set HTTP/S server address') - config.define('http_port', default=8888, option_help='Set HTTP server port') - config.define('enable_http', default=True, option_help='Enable HTTP') - config.define('https_port', default=8443, option_help='Set HTTPS server port') - config.define('enable_https', default=False, option_help='Enable HTTPS') - config.define('ssl_cert_file', default='cert.pem', option_help='Set SSL certificate file for HTTPS') - config.define('ssl_key_file', default='key.pem', option_help='Set SSL key file for HTTPS') - config.define('debug_mode', default=False, option_help='Enable tornado debug mode') - config.define('access_logging', default=None, option_help="Set the tornado access log level", - metavar="debug|info|warning|error|none") - config.define('static_path', default='./static', option_help='Set path for static file content') - config.define('enable_cors', default=False, - option_help='Enable cross-origin resource sharing (CORS)') - config.define('cors_origin', default='*', option_help='Specify allowed CORS origin') - config.define('graylog_server', default=None, option_help="Graylog server address and :port") - config.define('graylog_logging_level', default=logging.INFO, option_help="Graylog logging level") - config.define('graylog_static_fields', default=None, - option_help="Comma separated list of key=value pairs to add to every log message metadata") + config.define("http_addr", default="0.0.0.0", option_help="Set HTTP/S server address") + config.define("http_port", default=8888, option_help="Set HTTP server port") + config.define("enable_http", default=True, option_help="Enable HTTP") + config.define("https_port", default=8443, option_help="Set HTTPS server port") + config.define("enable_https", default=False, option_help="Enable HTTPS") + config.define( + "ssl_cert_file", default="cert.pem", option_help="Set SSL certificate file for HTTPS" + ) + config.define("ssl_key_file", default="key.pem", option_help="Set SSL key file for HTTPS") + config.define("debug_mode", default=False, option_help="Enable tornado debug mode") + config.define( + "access_logging", + default=None, + option_help="Set the tornado access log level", + metavar="debug|info|warning|error|none", + ) + config.define("static_path", default="./static", option_help="Set path for static file content") + config.define( + "enable_cors", default=False, option_help="Enable cross-origin resource sharing (CORS)" + ) + config.define("cors_origin", default="*", option_help="Specify allowed CORS origin") + config.define("api_version", default=None, option_help="Set the API version string in URLs") + config.define("graylog_server", default=None, option_help="Graylog server address and :port") + config.define( + "graylog_logging_level", default=logging.INFO, option_help="Graylog logging level" + ) + config.define( + "graylog_static_fields", + default=None, + option_help="Comma separated list of key=value pairs to add to every log message metadata", + ) # Parse configuration options and any configuration file specified try: config.parse(argv) except ConfigError as e: - logging.error('Failed to parse configuration: %s', e) + logging.error("Failed to parse configuration: %s", e) return 2 if config.graylog_server is not None: add_graylog_handler( - config.graylog_server, - config.graylog_logging_level, - config.graylog_static_fields + config.graylog_server, config.graylog_logging_level, config.graylog_static_fields ) - # Get the Tornado ioloop instance + # Get the Tornado ioloop instance ioloop = tornado.ioloop.IOLoop.instance() # Launch the HTTP server with the parsed configuration @@ -87,7 +100,7 @@ def shutdown_handler(sig_name): # pragma: no cover :param _: unused stack frame """ global _stop_ioloop - logging.info('%s signal received, shutting down', sig_name) + logging.info("%s signal received, shutting down", sig_name) # Stop the HTTP server http_server.stop() @@ -105,16 +118,16 @@ def stop_ioloop(): # pragma: no cover """ global _stop_ioloop if _stop_ioloop: - logging.debug("Stopping ioloop") + logging.debug("Stopping ioloop") - # Stop the ioloop - ioloop.stop() + # Stop the ioloop + ioloop.stop() # Register a shutdown signal handler and start an ioloop stop callback only if this is the # main thread if isinstance(threading.current_thread(), threading._MainThread): # pragma: no cover - signal.signal(signal.SIGINT, lambda signum, frame: shutdown_handler('Interrupt')) - signal.signal(signal.SIGTERM, lambda signum, frame: shutdown_handler('Terminate')) + signal.signal(signal.SIGINT, lambda signum, frame: shutdown_handler("Interrupt")) + signal.signal(signal.SIGTERM, lambda signum, frame: shutdown_handler("Terminate")) tornado.ioloop.PeriodicCallback(stop_ioloop, 1000).start() # Start the ioloop @@ -128,7 +141,7 @@ def stop_ioloop(): # pragma: no cover # At shutdown, clean up the state of the loaded adapters http_server.cleanup_adapters() - logging.info('ODIN server shutdown') + logging.info("ODIN server shutdown") return 0 @@ -141,8 +154,9 @@ def main_deprecate(argv=None): # pragma: no cover printing a deprecation warning. """ import warnings + with warnings.catch_warnings(): - warnings.simplefilter('always', DeprecationWarning) + warnings.simplefilter("always", DeprecationWarning) message = """ The odin_server script entry point is deprecated and will be removed in future releases. Consider @@ -154,5 +168,5 @@ def main_deprecate(argv=None): # pragma: no cover main(argv) -if __name__ == '__main__': # pragma: no cover +if __name__ == "__main__": # pragma: no cover sys.exit(main()) diff --git a/tests/handlers/fixtures.py b/tests/handlers/fixtures.py index f598842b..d9e2a500 100644 --- a/tests/handlers/fixtures.py +++ b/tests/handlers/fixtures.py @@ -4,17 +4,18 @@ from unittest.mock import Mock -from odin_control.http.handlers.api import ApiHandler, API_VERSION, validate_api_request -from odin_control.http.handlers.api_adapter_list import ApiAdapterListHandler +from odin_control.http.handlers.api import ApiHandler, validate_api_request +from odin_control.http.handlers.api_adapter_info import ApiAdapterInfoHandler from odin_control.http.handlers.api_version import ApiVersionHandler from odin_control.adapters.adapter import ApiAdapterResponse from odin_control.util import wrap_result +TEST_API_VERSION = "0.1" class TestHandler(object): """Class to create appropriate mocked objects to allow the ApiHandler to be tested.""" - def __init__(self, handler_cls, async_adapter=True, with_route=True, **handler_kwargs): + def __init__(self, handler_cls, async_adapter=True, api_version=None, **handler_kwargs): """Initialise the TestHandler.""" self.handler_kwargs = handler_kwargs @@ -44,12 +45,15 @@ def __init__(self, handler_cls, async_adapter=True, with_route=True, **handler_k self.route.adapters = {} self.route.adapter = lambda subsystem: self.route.adapters[subsystem] self.route.has_adapter = lambda subsystem: subsystem in self.route.adapters - if with_route: - handler_kwargs['route'] = self.route + self.route.api_version = api_version + handler_kwargs['route'] = self.route # Create a mock API adapter that returns appropriate responses api_adapter_mock = Mock() api_adapter_mock.is_async = async_adapter + api_adapter_mock.version = TEST_API_VERSION + api_adapter_mock.__class__.__module__ = self.__class__.__module__ + api_adapter_mock.__class__.__name__ = 'TestApiAdapter' api_adapter_mock.get.return_value = wrap_result(self.json_dict_response, async_adapter) api_adapter_mock.post.return_value = wrap_result(self.json_dict_response, async_adapter) api_adapter_mock.put.return_value = wrap_result(self.json_dict_response, async_adapter) @@ -72,7 +76,7 @@ def mock_write(self, chunk): else: self.write_data = chunk - @validate_api_request(API_VERSION) + @validate_api_request def dummy_get(self, subsystem, path=''): """Dummy HTTP GET verb method to allow the request validation decorator to be tested.""" response = ApiAdapterResponse( @@ -84,35 +88,43 @@ def dummy_get(self, subsystem, path=''): @pytest.fixture(scope="class", params=[True, False], ids=["async", "sync"]) def test_api_handler(request): - """ - Parameterised test fixture for testing the APIHandler class. - - The fixture parameters and id lists are set depending on whether async code is - allowed on the current platform (e.g. python 2 vs 3). - """ + """Parameterised test fixture for testing the ApiHandler class sync/async support.""" test_api_handler = TestHandler( - ApiHandler, async_adapter=request.param, enable_cors=False, cors_origin="*" + ApiHandler, async_adapter=request.param, api_version=TEST_API_VERSION, + enable_cors=False, cors_origin="*" ) yield test_api_handler +@pytest.fixture(scope="class") +def test_api_handler_no_versioning(): + """Test fixture for testing the ApiHandler class with no API versioning.""" + test_api_handler_no_versioning = TestHandler( + ApiHandler, async_adapter=False, api_version=None, enable_cors=False, cors_origin="*" + ) + yield test_api_handler_no_versioning + @pytest.fixture(scope="class", params=[True, False], ids=["CORS enabled", "CORS disabled"]) def test_api_handler_cors(request): - """Test fixture for testing the ApiHandler class CORS support.""" - test_api_handler = TestHandler( - ApiHandler, async_adapter=False,enable_cors=request.param, cors_origin="*" + """Parameterised test fixture for testing the ApiHandler class CORS support.""" + test_api_handler_cors = TestHandler( + ApiHandler, async_adapter=False, enable_cors=request.param, cors_origin="*" ) - yield test_api_handler + yield test_api_handler_cors -@pytest.fixture(scope="class") -def test_api_adapter_list_handler(): - """Test fixture for testing the ApiAdapterListHandler class.""" - test_api_adapter_list_handler = TestHandler(ApiAdapterListHandler, async_adapter=False) - test_api_adapter_list_handler.request.headers = {'Accept': 'application/json'} - yield test_api_adapter_list_handler +@pytest.fixture(scope="class", params=[None, TEST_API_VERSION], ids=["no versioning", "versioned"]) +def test_api_adapter_info_handler(request): + """Parameterised test fixture for testing the ApiAdapterInfoHandler class.""" + test_api_adapter_info_handler = TestHandler( + ApiAdapterInfoHandler, async_adapter=False, api_version=request.param + ) + test_api_adapter_info_handler.request.headers = {'Accept': 'application/json'} + yield test_api_adapter_info_handler -@pytest.fixture(scope="class") -def test_api_version_handler(): +@pytest.fixture(scope="class", params=[None, TEST_API_VERSION], ids=["no versioning", "versioned"]) +def test_api_version_handler(request): """Test fixture for testing the ApiVersionHandler class.""" - test_api_version_handler = TestHandler(ApiVersionHandler, async_adapter=False, with_route=False) + test_api_version_handler = TestHandler( + ApiVersionHandler, async_adapter=False, api_version=request.param + ) test_api_version_handler.request.headers = {'Accept': 'application/json'} yield test_api_version_handler diff --git a/tests/handlers/test_api.py b/tests/handlers/test_api.py index 50252755..78854b2b 100644 --- a/tests/handlers/test_api.py +++ b/tests/handlers/test_api.py @@ -2,9 +2,14 @@ import pytest -from odin_control.http.handlers.api import API_VERSION, ApiError from odin_control.adapters.adapter import ApiAdapterResponse -from tests.handlers.fixtures import test_api_handler, test_api_handler_cors +from odin_control.http.handlers.api import ApiError +from tests.handlers.fixtures import ( + test_api_handler, + test_api_handler_cors, + test_api_handler_no_versioning, +) + class TestApiHandler(): """Test cases for the ApiHandler class.""" @@ -50,32 +55,36 @@ def test_handler_respond_invalid_json(self, test_api_handler): @pytest.mark.asyncio async def test_handler_valid_get(self, test_api_handler): """Test that the handler creates a valid status and response to a GET request.""" - await test_api_handler.handler.get(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) + await test_api_handler.handler.get( + test_api_handler.route.api_version, test_api_handler.subsystem, test_api_handler.path + ) assert test_api_handler.handler.get_status() == 200 assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data @pytest.mark.asyncio async def test_handler_valid_post(self, test_api_handler): """Test that the handler creates a valid status and response to a POST request.""" - await test_api_handler.handler.post(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) + await test_api_handler.handler.post( + test_api_handler.route.api_version, test_api_handler.subsystem, test_api_handler.path + ) assert test_api_handler.handler.get_status() == 200 assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data @pytest.mark.asyncio async def test_handler_valid_put(self, test_api_handler): """Test that the handler creates a valid status and response to a PUT request.""" - await test_api_handler.handler.put(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) + await test_api_handler.handler.put( + test_api_handler.route.api_version, test_api_handler.subsystem, test_api_handler.path + ) assert test_api_handler.handler.get_status() == 200 assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data @pytest.mark.asyncio async def test_handler_valid_delete(self, test_api_handler): """Test that the handler creates a valid status and response to a DELETE request.""" - await test_api_handler.handler.delete(str(API_VERSION), - test_api_handler.subsystem, test_api_handler.path) + await test_api_handler.handler.delete( + test_api_handler.route.api_version, test_api_handler.subsystem, test_api_handler.path + ) assert test_api_handler.handler.get_status() == 200 assert json.loads(test_api_handler.write_data) == test_api_handler.json_dict_response.data @@ -83,8 +92,9 @@ async def test_handler_valid_delete(self, test_api_handler): async def test_bad_api_version(self, test_api_handler): """Test that a bad API version in a GET call to the handler yields an error.""" bad_version = 0.1234 - await test_api_handler.handler.get(str(bad_version), - test_api_handler.subsystem, test_api_handler.path) + await test_api_handler.handler.get( + str(bad_version), test_api_handler.subsystem, test_api_handler.path + ) assert test_api_handler.handler.get_status() == 400 assert "API version {} is not supported".format(bad_version) in test_api_handler.write_data @@ -92,11 +102,68 @@ async def test_bad_api_version(self, test_api_handler): async def test_bad_subsystem(self, test_api_handler): """Test that a bad subsystem in a GET call to the handler yields an error.""" bad_subsystem = 'missing' - await test_api_handler.handler.get(str(API_VERSION), bad_subsystem, test_api_handler.path) + await test_api_handler.handler.get( + test_api_handler.route.api_version, bad_subsystem, test_api_handler.path + ) assert test_api_handler.handler.get_status() == 400 assert "No API adapter registered for subsystem {}".format(bad_subsystem) \ in test_api_handler.write_data + +class TestApiHandlerNoVersioning(): + """Test cases for the ApiHandler class with no API versioning.""" + + @pytest.mark.asyncio + async def test_handler_valid_get(self, test_api_handler_no_versioning): + """Test that the handler creates a valid status and response to a GET request.""" + await test_api_handler_no_versioning.handler.get( + test_api_handler_no_versioning.subsystem, test_api_handler_no_versioning.path + ) + assert test_api_handler_no_versioning.handler.get_status() == 200 + assert json.loads(test_api_handler_no_versioning.write_data) == \ + test_api_handler_no_versioning.json_dict_response.data + + @pytest.mark.asyncio + async def test_handler_valid_post(self, test_api_handler_no_versioning): + """Test that the handler creates a valid status and response to a POST request.""" + await test_api_handler_no_versioning.handler.post( + test_api_handler_no_versioning.subsystem, test_api_handler_no_versioning.path + ) + assert test_api_handler_no_versioning.handler.get_status() == 200 + assert json.loads(test_api_handler_no_versioning.write_data) == \ + test_api_handler_no_versioning.json_dict_response.data + + @pytest.mark.asyncio + async def test_handler_valid_put(self, test_api_handler_no_versioning): + """Test that the handler creates a valid status and response to a PUT request.""" + await test_api_handler_no_versioning.handler.put( + test_api_handler_no_versioning.subsystem, test_api_handler_no_versioning.path + ) + assert test_api_handler_no_versioning.handler.get_status() == 200 + assert json.loads(test_api_handler_no_versioning.write_data) == \ + test_api_handler_no_versioning.json_dict_response.data + + @pytest.mark.asyncio + async def test_handler_valid_delete(self, test_api_handler_no_versioning): + """Test that the handler creates a valid status and response to a DELETE request.""" + await test_api_handler_no_versioning.handler.delete( + test_api_handler_no_versioning.subsystem, test_api_handler_no_versioning.path + ) + assert test_api_handler_no_versioning.handler.get_status() == 200 + assert json.loads(test_api_handler_no_versioning.write_data) == \ + test_api_handler_no_versioning.json_dict_response.data + + @pytest.mark.asyncio + async def test_bad_subsystem(self, test_api_handler_no_versioning): + """Test that a bad subsystem in a GET call to the handler yields an error.""" + bad_subsystem = 'missing' + await test_api_handler_no_versioning.handler.get( + bad_subsystem, test_api_handler_no_versioning.path + ) + assert test_api_handler_no_versioning.handler.get_status() == 400 + assert "No API adapter registered for subsystem {}".format(bad_subsystem) \ + in test_api_handler_no_versioning.write_data + class TestApiHandlerCorsSupport(): def test_cors_headers(self, test_api_handler_cors): @@ -113,4 +180,4 @@ def test_cors_headers(self, test_api_handler_cors): if test_api_handler_cors.handler_kwargs.get('enable_cors', False): assert cors_header in headers else: - assert cors_header not in headers \ No newline at end of file + assert cors_header not in headers diff --git a/tests/handlers/test_api_adapter_info.py b/tests/handlers/test_api_adapter_info.py new file mode 100644 index 00000000..6ff42144 --- /dev/null +++ b/tests/handlers/test_api_adapter_info.py @@ -0,0 +1,36 @@ +import json + +from tests.handlers.fixtures import test_api_adapter_info_handler + +class TestApiAdapterInfoHandler(): + """Test cases for the ApiAdapterListHandler class.""" + + def test_handler_initializes_route(self, test_api_adapter_info_handler): + + assert test_api_adapter_info_handler.handler.route == test_api_adapter_info_handler.route + + def test_api_adapter_info_handler_get_adapters(self, test_api_adapter_info_handler): + + test_api_adapter_info_handler.handler.get(test_api_adapter_info_handler.route.api_version) + + adapter_list = json.loads(test_api_adapter_info_handler.write_data) + print(adapter_list) + assert 'adapters' in adapter_list + assert isinstance(adapter_list['adapters'], dict) + assert test_api_adapter_info_handler.subsystem in adapter_list['adapters'] + + def test_api_adapter_info_handler_invalid_version(self, test_api_adapter_info_handler): + + invalid_version = "9.9.9" + test_api_adapter_info_handler.handler.get(invalid_version) + + assert test_api_adapter_info_handler.handler.get_status() == 400 + assert "is not supported" in test_api_adapter_info_handler.write_data + + def test_api_adapter_info_handler_invalid_accept(self, test_api_adapter_info_handler): + + test_api_adapter_info_handler.request.headers = {'Accept': 'text/plain'} + test_api_adapter_info_handler.handler.get(test_api_adapter_info_handler.route.api_version) + + assert test_api_adapter_info_handler.handler.get_status() == 406 + assert "Request content types not supported" in test_api_adapter_info_handler.write_data diff --git a/tests/handlers/test_api_adapter_list.py b/tests/handlers/test_api_adapter_list.py deleted file mode 100644 index f744e401..00000000 --- a/tests/handlers/test_api_adapter_list.py +++ /dev/null @@ -1,36 +0,0 @@ -import json - -from odin_control.http.handlers.api_adapter_list import API_VERSION -from tests.handlers.fixtures import test_api_adapter_list_handler - -class TestApiAdapterListHandler(): - """Test cases for the ApiAdapterListHandler class.""" - - def test_handler_initializes_route(self, test_api_adapter_list_handler): - - assert test_api_adapter_list_handler.handler.route == test_api_adapter_list_handler.route - - def test_api_adapter_list_handler_get_adapters(self, test_api_adapter_list_handler): - - test_api_adapter_list_handler.handler.get(str(API_VERSION)) - - adapter_list = json.loads(test_api_adapter_list_handler.write_data) - assert 'adapters' in adapter_list - assert isinstance(adapter_list['adapters'], list) - assert test_api_adapter_list_handler.subsystem in adapter_list['adapters'] - - def test_api_adapter_list_handler_invalid_version(self, test_api_adapter_list_handler): - - invalid_version = str(API_VERSION + 1) - test_api_adapter_list_handler.handler.get(invalid_version) - - assert test_api_adapter_list_handler.handler.get_status() == 400 - assert "is not supported" in test_api_adapter_list_handler.write_data - - def test_api_adapter_list_handler_invalid_accept(self, test_api_adapter_list_handler): - - test_api_adapter_list_handler.request.headers = {'Accept': 'text/plain'} - test_api_adapter_list_handler.handler.get(str(API_VERSION)) - - assert test_api_adapter_list_handler.handler.get_status() == 406 - assert "Request content types not supported" in test_api_adapter_list_handler.write_data \ No newline at end of file diff --git a/tests/handlers/test_api_version.py b/tests/handlers/test_api_version.py index faec12ce..fd31a4c6 100644 --- a/tests/handlers/test_api_version.py +++ b/tests/handlers/test_api_version.py @@ -1,6 +1,5 @@ import json -from odin_control.http.handlers.api_adapter_list import API_VERSION from tests.handlers.fixtures import test_api_version_handler class TestApiVersionHandler(): @@ -11,8 +10,8 @@ def test_handler_valid_get(self, test_api_version_handler): test_api_version_handler.handler.get() assert test_api_version_handler.handler.get_status() == 200 response_data = json.loads(test_api_version_handler.write_data) - assert 'api' in response_data - assert response_data['api'] == API_VERSION + assert 'version' in response_data + assert response_data['version'] == test_api_version_handler.route.api_version def test_handler_invalid_accept(self, test_api_version_handler): diff --git a/tests/test_server.py b/tests/test_server.py index f8a75eb4..c78430c8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -25,7 +25,7 @@ def odin_test_server(): access_logging='debug' test_server = OdinTestServer( - adapter_config=adapter_config, access_logging=access_logging + adapter_config=adapter_config, access_logging=access_logging, server_api_version=0.1 ) yield test_server test_server.stop() @@ -88,7 +88,7 @@ def test_api_version(self, odin_test_server): headers=headers ) assert result.status_code == 200 - assert result.json()['api'] == odin_test_server.server_api_version + assert result.json()['version'] == odin_test_server.server_api_version def test_api_version_bad_accept(self, odin_test_server): """Test that bad accept heeader content type returns an error and message.""" @@ -105,7 +105,7 @@ def test_api_adapter_list(self, odin_test_server): headers = {'Accept': 'application/json'} result = requests.get(odin_test_server.build_url('adapters/'), headers=headers) assert result.status_code == 200 - assert result.json()['adapters'] == ['dummy'] + assert 'dummy' in result.json()['adapters'] def test_api_adapter_list_bad_version(self, odin_test_server): """Test that the API route rejects an adapter list GET with a bad API version.""" @@ -196,6 +196,7 @@ def __init__(self): self.access_logging = None self.enable_cors = True self.cors_origin = "*" + self.api_version = "0.1" def resolve_adapters(self): return [] @@ -397,4 +398,4 @@ def test_https_listen_error(self, server_config, ssl_test_cert, caplog): "Failed to create HTTPS server on {}:{}: [Errno 13] Permission denied".format( server_config.http_addr, server_config.https_port, ) - ) \ No newline at end of file + ) diff --git a/tests/utils.py b/tests/utils.py index 251f8100..efe9d159 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -30,7 +30,7 @@ class OdinTestServer(object): server_port = 8888 server_addr = '127.0.0.1' - server_api_version = 0.1 + server_api_version = '0.1' def __init__( self, @@ -39,6 +39,7 @@ def __init__( access_logging=None, graylog_server=None, graylog_static_fields=None, + server_api_version=server_api_version, ): self.server_thread = None @@ -57,9 +58,10 @@ def __init__( parser.set('server', 'http_addr', self.server_addr) parser.set('server', 'enable_https', 'false') parser.set('server', 'static_path', static_path) + parser.set('server', 'api_version', str(server_api_version)) if adapter_config is not None: - adapters = ', '.join([adapter for adapter in adapter_config]) + adapters = ', '.join(list(adapter_config)) parser.set('server', 'adapters', adapters) if access_logging is not None: From 1fd1f864258d5d1a3eeb01497ff184ef431d4399 Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Fri, 5 Dec 2025 08:15:58 +0000 Subject: [PATCH 04/13] Remove the deprecated odin_server entry point (#72) --- pyproject.toml | 1 - src/odin_control/main.py | 22 ---------------------- 2 files changed, 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 63af7490..7d2e3217 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,6 @@ graylog = [ [project.scripts] odin_control = "odin_control.main:main" -odin_server = "odin_control.main:main_deprecate" [project.urls] GitHub = "https://github.com/odin-detector/odin-control" diff --git a/src/odin_control/main.py b/src/odin_control/main.py index e4bb2138..ea873bdb 100644 --- a/src/odin_control/main.py +++ b/src/odin_control/main.py @@ -146,27 +146,5 @@ def stop_ioloop(): # pragma: no cover return 0 -def main_deprecate(argv=None): # pragma: no cover - """Deprecated main entry point for running the odin control server. - - This method adds an entry point for running odin control server that is run by the - deprecated odin_server command. It simply runs the main entry point as normal having - printing a deprecation warning. - """ - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always", DeprecationWarning) - message = """ - -The odin_server script entry point is deprecated and will be removed in future releases. Consider -using \'odin_control\' instead - - """ - warnings.warn(message, DeprecationWarning, stacklevel=1) - - main(argv) - - if __name__ == "__main__": # pragma: no cover sys.exit(main()) From ef63b4bcf8ea6a2dffb08ba1b6e99494cb214535 Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Thu, 11 Dec 2025 10:12:08 +0000 Subject: [PATCH 05/13] Make ParameterTree leaf node responses symmetric (#73) * Make ParameterTree leaf node responses symmetric with and without metadata. This change makes the parameter tree treatment of leaf nodes symmetric with and without metadata, i.e. a get() call to a leaf node will return a dict with a single 'value' entry. The behaviour for higher subtrees is also modified so that the last level of the subtree path is not repeated for the key of the response. The set() behaviour is also improved, allowing single leaf node values to be set with a payload that is a dict with a single 'value' entry. * Remove reserved tree-level metadata fields. This commit removes the reserved tree-level metadata fields 'name' and 'description', allowing those to be used as parameter keys and preventing issues with explicit gets. * Remove stray print() statements and replace commented asserts in tests * Remove redundant level check when populating tree Closes #71 Closes #58 --- .../adapters/base_parameter_tree.py | 54 ++----- src/odin_control/adapters/base_proxy.py | 2 +- tests/adapters/test_async_dummy.py | 4 +- tests/adapters/test_async_parameter_tree.py | 140 +++++++--------- tests/adapters/test_async_proxy.py | 8 +- tests/adapters/test_parameter_tree.py | 150 ++++++++---------- tests/adapters/test_proxy.py | 10 +- tests/handlers/test_api_adapter_info.py | 1 - 8 files changed, 155 insertions(+), 214 deletions(-) diff --git a/src/odin_control/adapters/base_parameter_tree.py b/src/odin_control/adapters/base_parameter_tree.py index 18555b42..6b56804c 100644 --- a/src/odin_control/adapters/base_parameter_tree.py +++ b/src/odin_control/adapters/base_parameter_tree.py @@ -176,8 +176,6 @@ class BaseParameterTree(object): interfacing of those to the underlying device or object. """ - METADATA_FIELDS = ["name", "description"] - def __init__(self, tree, mutable=False): """Initialise the BaseParameterTree object. @@ -243,15 +241,9 @@ def get(self, path, with_metadata=False): # Initialise the subtree before descent subtree = self._tree - # If this is single level path, return the populated tree at the top level - if not levels: - return self._populate_tree(subtree, with_metadata) - # Descend the specified levels in the path, checking for a valid subtree of the appropriate - # type + # type, A single level path returns the populated tree at the top level. for level in levels: - if level in self.METADATA_FIELDS and not with_metadata: - raise ParameterTreeError("Invalid path: {}".format(path)) try: if isinstance(subtree, dict): subtree = subtree[level] @@ -263,7 +255,14 @@ def get(self, path, with_metadata=False): raise ParameterTreeError("Invalid path: {}".format(path)) # Return the populated tree at the appropriate path - return self._populate_tree({levels[-1]: subtree}, with_metadata) + values = self._populate_tree(subtree, with_metadata) + + # If this is a request for a single leaf node (i.e. depth is 1 and only one value + # returned) without metadata, return a value dict rather than just the value itself + if not with_metadata and not isinstance(values, dict): + values = {'value': values} + + return values def set(self, path, data, replace=False): """Set the values of the parameters in a tree. @@ -289,8 +288,6 @@ def set(self, path, data, replace=False): # Descend the tree and validate each element of the path for level in levels: - if level in self.METADATA_FIELDS: - raise ParameterTreeError("Invalid path: {}".format(path)) try: merge_parent = merge_child if isinstance(merge_child, dict): @@ -335,8 +332,7 @@ def replace(self, path, data): self.set(path, data, replace=True) def delete(self, path=''): - """ - Remove Parameters from a Mutable Tree. + """Delete parameters from a mutable tree. This method deletes selected parameters from a tree, if that tree has been flagged as Mutable. Deletion of Branch Nodes means all child nodes of that Branch Node are also deleted @@ -427,18 +423,6 @@ def _build_tree(self, node, path=''): return node - def __remove_metadata(self, node): - """Remove metadata fields from a node. - - Used internally to return a parameter tree without metadata fields - - :param node: tree node to return without metadata fields - :returns: generator yeilding items in node minus metadata - """ - for key, val in node.items(): - if key not in self.METADATA_FIELDS: - yield key, val - def _populate_tree(self, node, with_metadata=False): """Recursively populate a tree with values. @@ -452,17 +436,7 @@ def _populate_tree(self, node, with_metadata=False): """ # If this is a branch node recurse down the tree if isinstance(node, dict): - if with_metadata: - branch = { - k: self._populate_tree(v, with_metadata) for k, v - in node.items() - } - else: - branch = { - k: self._populate_tree(v, with_metadata) for k, v - in self.__remove_metadata(node) - } - return branch + return {k: self._populate_tree(v, with_metadata) for k, v in node.items()} if isinstance(node, list): return [self._populate_tree(item, with_metadata) for item in node] @@ -487,11 +461,15 @@ def _merge_tree(self, node, new_data, cur_path): :param cur_path: current path in the tree :returns: the update node at this point in the tree """ + # If new data is a dict with a single 'value' field, extract that value for updating + if isinstance(new_data, dict) and len(new_data) == 1 and 'value' in new_data: + new_data = new_data['value'] + # Recurse down tree if this is a branch node if isinstance(node, dict) and isinstance(new_data, dict): try: update = {} - for k, v in self.__remove_metadata(new_data): + for k, v in new_data.items(): mutable = self.mutable or any( cur_path.startswith(part) for part in self.mutable_paths ) diff --git a/src/odin_control/adapters/base_proxy.py b/src/odin_control/adapters/base_proxy.py index 1f86afd7..e86328c5 100644 --- a/src/odin_control/adapters/base_proxy.py +++ b/src/odin_control/adapters/base_proxy.py @@ -219,7 +219,7 @@ def _process_response(self, response, path, get_metadata): del path_elems[-1] # Traverse down the data tree for each element - for elem in path_elems[:-1]: + for elem in path_elems: data_ref = data_ref[elem] # Update the data or metadata with the body of the response diff --git a/tests/adapters/test_async_dummy.py b/tests/adapters/test_async_dummy.py index 714980d9..ffec4f4e 100644 --- a/tests/adapters/test_async_dummy.py +++ b/tests/adapters/test_async_dummy.py @@ -82,7 +82,7 @@ async def test_adapter_put(self, test_dummy_adapter): test_dummy_adapter.rw_path, test_dummy_adapter.request) assert isinstance(response.data, dict) - assert response.data[test_dummy_adapter.rw_path] == rw_request.body + assert response.data['value'] == rw_request.body assert response.status_code == 200 async def test_adapter_put_bad_path(self, test_dummy_adapter): @@ -92,4 +92,4 @@ async def test_adapter_put_bad_path(self, test_dummy_adapter): test_dummy_adapter.bad_path, test_dummy_adapter.request ) assert response.data == expected_response - assert response.status_code == 400 \ No newline at end of file + assert response.status_code == 400 diff --git a/tests/adapters/test_async_parameter_tree.py b/tests/adapters/test_async_parameter_tree.py index 7008cbb0..487156b0 100644 --- a/tests/adapters/test_async_parameter_tree.py +++ b/tests/adapters/test_async_parameter_tree.py @@ -137,7 +137,7 @@ class TestAsyncParameterAccessor(): async def test_static_rw_accessor_get(self, test_param_accessor): """Test that a static RW accessor get call returns the correct value.""" - value = await test_param_accessor.static_rw_accessor.get() + value = await test_param_accessor.static_rw_accessor.get() assert value == test_param_accessor.static_rw_value async def test_static_rw_accessor_set(self, test_param_accessor): @@ -145,7 +145,7 @@ async def test_static_rw_accessor_set(self, test_param_accessor): old_val = test_param_accessor.static_rw_value new_val = 1.234 await test_param_accessor.static_rw_accessor.set(new_val) - value = await test_param_accessor.static_rw_accessor.get() + value = await test_param_accessor.static_rw_accessor.get() assert value == new_val await test_param_accessor.static_rw_accessor.set(old_val) @@ -284,7 +284,7 @@ async def test_param_accessor_set_type_mismatch(self, test_param_accessor): await test_param_accessor.async_rw_accessor.set(bad_value) assert "Type mismatch setting {}: got {} expected {}".format( - test_param_accessor.async_rw_path, bad_value_type, + test_param_accessor.async_rw_path, bad_value_type, type(test_param_accessor.async_rw_value).__name__ ) in str(excinfo.value) @@ -327,8 +327,8 @@ async def test_param_accessor_value_above_max(self, test_param_accessor): bad_value, test_param_accessor.md_minmax_metadata['max'], test_param_accessor.md_minmax_path ) in str(excinfo.value) - - + + class AsyncParameterTreeTestFixture(AwaitableTestFixture): """Container class for use in fixtures testing AsyncParameterTree.""" @@ -371,7 +371,7 @@ def __init__(self): 'listParam': self.list_values, 'branch': AsyncParameterTree(deepcopy(self.nested_dict)), }) - + self.list_tree = AsyncParameterTree({ 'main' : [ self.simple_dict.copy(), @@ -415,16 +415,16 @@ async def test_simple_tree_returns_dict(self, test_param_tree): async def test_simple_tree_single_values(self, test_param_tree): """Test that getting single values from a simple tree returns the correct values.""" dt_int_val = await test_param_tree.simple_tree.get('intParam') - assert dt_int_val['intParam'] == test_param_tree.int_value + assert dt_int_val['value'] == test_param_tree.int_value dt_float_val = await test_param_tree.simple_tree.get('floatParam') - assert dt_float_val['floatParam'] == test_param_tree.float_value + assert dt_float_val['value'] == test_param_tree.float_value dt_bool_val = await test_param_tree.simple_tree.get('boolParam') - assert dt_bool_val['boolParam'] == test_param_tree.bool_value + assert dt_bool_val['value'] == test_param_tree.bool_value dt_str_val = await test_param_tree.simple_tree.get('strParam') - assert dt_str_val['strParam'] == test_param_tree.str_value + assert dt_str_val['value'] == test_param_tree.str_value async def test_simple_tree_missing_value(self, test_param_tree): """Test that getting a missing value from a simple tree raises an error.""" @@ -441,12 +441,12 @@ async def test_nested_tree_returns_nested_dict(self, test_param_tree): async def test_nested_tree_branch_returns_dict(self, test_param_tree): """Test that getting a tree from within a nested tree returns a dict.""" branch_vals = await test_param_tree.nested_tree.get('branch') - assert branch_vals['branch'] == test_param_tree.nested_dict['branch'] + assert branch_vals == test_param_tree.nested_dict['branch'] async def test_nested_tree_trailing_slash(self, test_param_tree): """Test that getting a tree with trailing slash returns the correct dict.""" branch_vals = await test_param_tree.nested_tree.get('branch/') - assert branch_vals['branch'] == test_param_tree.nested_dict['branch'] + assert branch_vals == test_param_tree.nested_dict['branch'] async def test_set_with_extra_branch_paths(self, test_param_tree): """ @@ -462,7 +462,7 @@ async def test_set_with_extra_branch_paths(self, test_param_tree): async def test_complex_tree_calls_leaf_nodes(self, test_param_tree): """ - Test that accessing valyus in a complex tree returns the correct values for + Test that accessing valyus in a complex tree returns the correct values for static and callable parameters. """ complex_vals = await test_param_tree.complex_tree.get('') @@ -472,8 +472,8 @@ async def test_complex_tree_calls_leaf_nodes(self, test_param_tree): async def test_complex_tree_access_list_param(self, test_param_tree): """Test that getting a list parameter from a complex tree returns the appropriate values.""" list_param_vals = await test_param_tree.complex_tree.get('listParam') - assert list_param_vals['listParam'] == test_param_tree.list_values - + assert list_param_vals['value'] == test_param_tree.list_values + async def test_complex_tree_callable_readonly(self, test_param_tree): """ Test that attempting to set the value of a RO callable parameter in a tree raises an @@ -524,14 +524,14 @@ async def test_list_tree_get_indexed(self, test_param_tree): Test that it is possible to get a value by index from a list parameter. """ ret = await test_param_tree.list_tree.get("main/1") - assert ret == {'1':test_param_tree.list_values} + assert ret['value'] == test_param_tree.list_values async def test_list_tree_set_indexed(self, test_param_tree): """ Test that it is possible to set a value by index on a list parameter. """ await test_param_tree.list_tree.set("main/1/2", 7) - assert await test_param_tree.list_tree.get("main/1/2") == {'2': 7} + assert await test_param_tree.list_tree.get("main/1/2") == {'value': 7} async def test_list_tree_set_from_root(self, test_param_tree): """Test that it is possible to set a list tree from its root.""" @@ -548,14 +548,15 @@ async def test_list_tree_set_from_root(self, test_param_tree): } await test_param_tree.list_tree.set("",tree_data) - assert await test_param_tree.list_tree.get("main") == tree_data + result = await test_param_tree.list_tree.get("main") + assert result['value'] == tree_data['main'] async def test_list_tree_from_dict(self, test_param_tree): - """TEet that a list tree can be set with a dict of index/values.""" + """Test that a list tree can be set with a dict of index/values.""" new_list_param = {0: 0, 1: 1, 2: 2, 3: 3} await test_param_tree.simple_list_tree.set('list_param', new_list_param) result = await test_param_tree.simple_list_tree.get('list_param') - assert result['list_param']== list(new_list_param.values()) + assert result['value']== list(new_list_param.values()) async def test_list_tree_from_dict_bad_index(self, test_param_tree): @@ -581,7 +582,7 @@ async def test_bad_tuple_node_raises_error(self, test_param_tree): assert "not a valid leaf node" in str(excinfo.value) - + class AsyncRwParameterTreeTestFixture(AwaitableTestFixture): """Container class for use in async read-write parameter tree test fixtures.""" @@ -655,13 +656,13 @@ class TestAsyncRwParameterTree(): async def test_rw_tree_simple_get_values(self, test_rw_tree): """Test getting simple values from a RW tree returns the correct values.""" dt_rw_int_param = await test_rw_tree.rw_callable_tree.get('intCallableRwParam') - assert dt_rw_int_param['intCallableRwParam'] == test_rw_tree.int_rw_param + assert dt_rw_int_param['value'] == test_rw_tree.int_rw_param dt_ro_int_param = await test_rw_tree.rw_callable_tree.get('intCallableRoParam') - assert dt_ro_int_param['intCallableRoParam'] == test_rw_tree.int_ro_param + assert dt_ro_int_param['value'] == test_rw_tree.int_ro_param dt_rw_int_value = await test_rw_tree.rw_callable_tree.get('intCallableRwValue') - assert dt_rw_int_value['intCallableRwValue'] == test_rw_tree.int_rw_value + assert dt_rw_int_value['value'] == test_rw_tree.int_rw_value async def test_rw_tree_simple_set_value(self, test_rw_tree): """Test that setting a value in a RW tree updates and returns the correct value.""" @@ -669,7 +670,7 @@ async def test_rw_tree_simple_set_value(self, test_rw_tree): await test_rw_tree.rw_callable_tree.set('intCallableRwParam', new_int_value) dt_rw_int_param = await test_rw_tree.rw_callable_tree.get('intCallableRwParam') - assert dt_rw_int_param['intCallableRwParam'] == new_int_value + assert dt_rw_int_param['value'] == new_int_value async def test_rw_tree_set_ro_param(self, test_rw_tree): """Test that attempting to set a RO parameter raises an error.""" @@ -693,7 +694,7 @@ async def test_rw_callable_tree_set_rw_value(self, test_rw_tree): async def test_rw_callable_nested_param_get(self, test_rw_tree): """Test the getting a nested callable RW parameter returns the correct value.""" dt_nested_param = await test_rw_tree.rw_callable_tree.get('branch/nestedRwParam') - assert dt_nested_param['nestedRwParam'] == test_rw_tree.nested_rw_param + assert dt_nested_param['value'] == test_rw_tree.nested_rw_param async def test_rw_callable_nested_param_set(self, test_rw_tree): """Test that setting a nested callable RW parameter sets the correct value.""" @@ -703,26 +704,24 @@ async def test_rw_callable_nested_param_set(self, test_rw_tree): async def test_rw_callable_nested_tree_set(self, test_rw_tree): """Test the setting a value within a callable nested tree updated the value correctly.""" - result = await test_rw_tree.rw_callable_tree.get('branch') - nested_branch = result['branch'] + nested_branch = await test_rw_tree.rw_callable_tree.get('branch') new_rw_param_val = 45.876 nested_branch['nestedRwParam'] = new_rw_param_val await test_rw_tree.rw_callable_tree.set('branch', nested_branch) result = await test_rw_tree.rw_callable_tree.get('branch') - assert result['branch']['nestedRwParam'], new_rw_param_val + assert result['nestedRwParam'], new_rw_param_val async def test_rw_callable_nested_tree_set_trailing_slash(self, test_rw_tree): """ Test that setting a callable nested tree with a trailing slash in the path sets the value correctly. """ - result = await test_rw_tree.rw_callable_tree.get('branch/') - nested_branch = result['branch'] + nested_branch = await test_rw_tree.rw_callable_tree.get('branch/') new_rw_param_val = 24.601 nested_branch['nestedRwParam'] = new_rw_param_val await test_rw_tree.rw_callable_tree.set('branch/', nested_branch) result = await test_rw_tree.rw_callable_tree.get('branch/') - assert result['branch']['nestedRwParam'] == new_rw_param_val + assert result['nestedRwParam'] == new_rw_param_val class AsyncParameterTreeMetadataTestFixture(AwaitableTestFixture): @@ -787,45 +786,23 @@ async def test_callable_rw_param_metadata(self, test_tree_metadata): int_param_with_metadata = await test_tree_metadata.metadata_tree.get( "intCallableRwParam",with_metadata=True) result = await test_tree_metadata.metadata_tree.get("intCallableRwParam") - int_param = result["intCallableRwParam"] + int_param = result["value"] expected_metadata = test_tree_metadata.int_rw_param_metadata expected_metadata["value"] = int_param expected_metadata["type"] = 'int' expected_metadata["writeable"] = True - expected_param = {"intCallableRwParam" : expected_metadata} - - assert int_param_with_metadata == expected_param - async def test_get_filters_tree_metadata(self, test_tree_metadata): - """ - Test that attempting to get a metadata field for a parameter as if it was path itself - raises an error. - """ - metadata_path = "name" - with pytest.raises(ParameterTreeError) as excinfo: - await test_tree_metadata.metadata_tree.get(metadata_path) - - assert "Invalid path: {}".format(metadata_path) in str(excinfo.value) - - async def test_set_tree_rejects_metadata(self, test_tree_metadata): - """ - Test that attampeting to set a metadata field as if it was a parameter raises an error. - """ - metadata_path = "name" - with pytest.raises(ParameterTreeError) as excinfo: - await test_tree_metadata.metadata_tree.set(metadata_path, "invalid") - - assert "Invalid path: {}".format(metadata_path) in str(excinfo.value) + assert await int_param_with_metadata == expected_metadata async def test_enum_param_allowed_values(self, test_tree_metadata): """Test that setting an enumerated parameter with an allowed value succeeds.""" for value in test_tree_metadata.int_enum_param_allowed_values: await test_tree_metadata.metadata_tree.set("intEnumParam", value) result = await test_tree_metadata.metadata_tree.get("intEnumParam") - set_value = result["intEnumParam"] + set_value = result["value"] assert value == set_value - + async def test_enum_param_bad_value(self, test_tree_metadata): """ Test that attempting to set a disallowed value for an enumerated parameter raises an error. @@ -839,7 +816,8 @@ async def test_enum_param_bad_value(self, test_tree_metadata): async def test_ro_param_has_writeable_metadata_field(self, test_tree_metadata): """Test that a RO parameter has the writeable metadata field set to false.""" ro_param = await test_tree_metadata.metadata_tree.get("floatRoParam", with_metadata=True) - assert ro_param["floatRoParam"]["writeable"] == False + result = await ro_param + assert result["writeable"] == False async def test_ro_param_not_writeable(self, test_tree_metadata): """Test that attempting to write to a RO parameter with metadata raises an error.""" @@ -851,19 +829,19 @@ async def test_value_param_writeable(self, test_tree_metadata): """Test that a value parameter is writeable and has the correct metadata flag.""" new_value = 90210 await test_tree_metadata.metadata_tree.set("valueParam", new_value) - result = await test_tree_metadata.metadata_tree.get("valueParam", with_metadata=True) - set_param = result["valueParam"] - assert set_param["value"] == new_value - assert set_param["writeable"] == True + param = await test_tree_metadata.metadata_tree.get("valueParam", with_metadata=True) + result = await param + assert result["value"] == new_value + assert result["writeable"] == True async def test_rw_param_min_no_max(self, test_tree_metadata): """Test that a parameter with a minimum but no maximum works as expected.""" new_value = 2 await test_tree_metadata.metadata_tree.set("minNoMaxParam", new_value) - result = await test_tree_metadata.metadata_tree.get("minNoMaxParam", with_metadata=True) - set_param = result["minNoMaxParam"] - assert set_param["value"] == new_value - assert set_param["writeable"] == True + param = await test_tree_metadata.metadata_tree.get("minNoMaxParam", with_metadata=True) + result = await param + assert result["value"] == new_value + assert result["writeable"] == True async def test_rw_param_below_min_value(self, test_tree_metadata): """ @@ -875,7 +853,7 @@ async def test_rw_param_below_min_value(self, test_tree_metadata): await test_tree_metadata.metadata_tree.set("intCallableRwParam", low_value) assert "{} is below the minimum value {} for {}".format( - low_value, test_tree_metadata.int_rw_param_metadata["min"], + low_value, test_tree_metadata.int_rw_param_metadata["min"], "intCallableRwParam") in str(excinfo.value) async def test_rw_param_above_max_value(self, test_tree_metadata): @@ -888,7 +866,7 @@ async def test_rw_param_above_max_value(self, test_tree_metadata): await test_tree_metadata.metadata_tree.set("intCallableRwParam", high_value) assert "{} is above the maximum value {} for {}".format( - high_value, test_tree_metadata.int_rw_param_metadata["max"], + high_value, test_tree_metadata.int_rw_param_metadata["max"], "intCallableRwParam") in str(excinfo.value) @@ -943,7 +921,7 @@ async def test_mutable_put_differnt_data_type(self, test_tree_mutable): new_data = 75 await test_tree_mutable.param_tree.set('bonus', new_data) val = await test_tree_mutable.param_tree.get('bonus') - assert val['bonus'] == new_data + assert val['value'] == new_data async def test_mutable_put_new_branch_node(self, test_tree_mutable): @@ -951,7 +929,7 @@ async def test_mutable_put_new_branch_node(self, test_tree_mutable): await test_tree_mutable.param_tree.set('extra', new_node) val = await test_tree_mutable.param_tree.get('extra') - assert val['extra'] == new_node + assert val == new_node async def test_mutable_put_new_sibling_node(self, test_tree_mutable): @@ -960,14 +938,14 @@ async def test_mutable_put_new_sibling_node(self, test_tree_mutable): await test_tree_mutable.param_tree.set(path, new_node) val = await test_tree_mutable.param_tree.get(path) - assert 'new' in val[path] + assert 'new' in val async def test_mutable_put_overwrite_param_accessor_read_only(self, test_tree_mutable): new_node = {"Node": "Broke Accessor"} with pytest.raises(ParameterTreeError) as excinfo: await test_tree_mutable.param_tree.set('read', new_node) - + assert "is read-only" in str(excinfo.value) async def test_mutable_put_overwrite_param_accessor_read_write(self, test_tree_mutable): @@ -987,7 +965,7 @@ async def test_mutable_put_replace_nested_path(self, test_tree_mutable): await test_tree_mutable.param_tree.set(path, new_node) val = await test_tree_mutable.param_tree.get(path) - assert val[path]['double_nest'] == new_node['double_nest'] + assert val['double_nest'] == new_node['double_nest'] async def test_mutable_put_merge_nested_path(self, test_tree_mutable): @@ -1003,8 +981,8 @@ async def test_mutable_put_merge_nested_path(self, test_tree_mutable): await test_tree_mutable.param_tree.set(path, new_node) val = await test_tree_mutable.param_tree.get(path) - assert val[path]['double_nest']['nested_val'] == new_node['double_nest']['nested_val'] - assert 'dont_touch' in val[path]['double_nest'] + assert val['double_nest']['nested_val'] == new_node['double_nest']['nested_val'] + assert 'dont_touch' in val['double_nest'] async def test_mutable_delete_method(self, test_tree_mutable): @@ -1051,14 +1029,14 @@ async def test_mutable_delete_from_list(self, test_tree_mutable): test_tree_mutable.param_tree.delete(path) val = await test_tree_mutable.param_tree.get('nest/list') - assert '3' not in val['list'] + assert '3' not in val['value'] async def test_mutable_delete_from_dict_in_list(self, test_tree_mutable): path = 'nest/list/2/list_test' test_tree_mutable.param_tree.delete(path) val = await test_tree_mutable.param_tree.get('nest/list') - assert {'list_test': "test"} not in val['list'] + assert {'list_test': "test"} not in val['value'] async def test_mutable_nested_tree_in_immutable_tree(self, test_tree_mutable): @@ -1073,7 +1051,7 @@ async def test_mutable_nested_tree_in_immutable_tree(self, test_tree_mutable): path = 'nest/tree/extra' await new_tree.set(path, new_node) val = await new_tree.get(path) - assert val['extra'] == new_node + assert val == new_node async def test_mutable_nested_tree_external_change(self, test_tree_mutable): @@ -1086,7 +1064,7 @@ async def test_mutable_nested_tree_external_change(self, test_tree_mutable): path = 'tree/extra' await test_tree_mutable.param_tree.set('extra', new_node) val = await new_tree.get(path) - assert val['extra'] == new_node + assert val == new_node async def test_mutable_nested_tree_delete(self, test_tree_mutable): @@ -1130,4 +1108,4 @@ async def test_mutable_add_to_empty_dict(self, test_tree_mutable): path = 'empty' await test_tree_mutable.param_tree.set(path, new_node) val = await test_tree_mutable.param_tree.get(path) - assert val[path] == new_node \ No newline at end of file + assert val== new_node diff --git a/tests/adapters/test_async_proxy.py b/tests/adapters/test_async_proxy.py index ff314a7c..7f7b0eed 100644 --- a/tests/adapters/test_async_proxy.py +++ b/tests/adapters/test_async_proxy.py @@ -257,7 +257,7 @@ async def test_adapter_get_proxy_path(self, async_proxy_adapter_fixture): response = await async_proxy_adapter_fixture.adapter.get( "{}/{}".format(node, path), async_proxy_adapter_fixture.request) - assert response.data["even_more"] == ProxyTestHandler.data["more"]["even_more"] + assert response.data == ProxyTestHandler.data["more"]["even_more"] assert async_proxy_adapter_fixture.adapter.param_tree.get('')['status'][node]['status_code'] == 200 @pytest.mark.asyncio @@ -271,7 +271,7 @@ async def test_adapter_get_proxy_path_trailing_slash(self, async_proxy_adapter_f response = await async_proxy_adapter_fixture.adapter.get( "{}/{}".format(node, path), async_proxy_adapter_fixture.request) - assert response.data["even_more"] == ProxyTestHandler.data["more"]["even_more"] + assert response.data == ProxyTestHandler.data["more"]["even_more"] assert async_proxy_adapter_fixture.adapter.param_tree.get('')['status'][node]['status_code'] == 200 @pytest.mark.asyncio @@ -287,7 +287,7 @@ async def test_adapter_put_proxy_path(self, async_proxy_adapter_fixture): "{}/{}".format(node, path), async_proxy_adapter_fixture.request) assert async_proxy_adapter_fixture.adapter.param_tree.get('')['status'][node]['status_code'] == 200 - assert response.data["more"]["replace"] == "been replaced" + assert response.data["replace"] == "been replaced" @pytest.mark.asyncio async def test_adapter_get_bad_path(self, async_proxy_adapter_fixture): @@ -376,5 +376,5 @@ async def test_adapter_counter_get_single_node(self, async_proxy_adapter_fixture response = await async_proxy_adapter_fixture.adapter.get(path, async_proxy_adapter_fixture.request) access_counts = [server.get_access_count() for server in async_proxy_adapter_fixture.test_servers] - assert path in response.data + assert all(key in response.data for key in ProxyTestHandler.data.keys()) assert sum(access_counts) == 1 diff --git a/tests/adapters/test_parameter_tree.py b/tests/adapters/test_parameter_tree.py index dfc4856c..19352ac0 100644 --- a/tests/adapters/test_parameter_tree.py +++ b/tests/adapters/test_parameter_tree.py @@ -157,7 +157,7 @@ def test_param_accessor_bad_metadata_arg(self, test_param_accessor): assert "Invalid metadata argument: {}".format(bad_metadata_argument) \ in str(excinfo.value) - + def test_param_accessor_set_type_mismatch(self, test_param_accessor): """ Test that setting the value of a parameter accessor with the incorrected type raises @@ -170,7 +170,7 @@ def test_param_accessor_set_type_mismatch(self, test_param_accessor): test_param_accessor.callable_rw_accessor.set(bad_value) assert "Type mismatch setting {}: got {} expected {}".format( - test_param_accessor.callable_rw_path, bad_value_type, + test_param_accessor.callable_rw_path, bad_value_type, type(test_param_accessor.callable_rw_value).__name__ ) in str(excinfo.value) @@ -292,16 +292,16 @@ def test_simple_tree_returns_dict(self, test_param_tree): def test_simple_tree_single_values(self, test_param_tree): """Test that getting single values from a simple tree returns the correct values.""" dt_int_val = test_param_tree.simple_tree.get('intParam') - assert dt_int_val['intParam'] == test_param_tree.int_value + assert dt_int_val['value'] == test_param_tree.int_value dt_float_val = test_param_tree.simple_tree.get('floatParam') - assert dt_float_val['floatParam'] == test_param_tree.float_value + assert dt_float_val['value'] == test_param_tree.float_value dt_bool_val = test_param_tree.simple_tree.get('boolParam') - assert dt_bool_val['boolParam'] == test_param_tree.bool_value + assert dt_bool_val['value'] == test_param_tree.bool_value dt_str_val = test_param_tree.simple_tree.get('strParam') - assert dt_str_val['strParam'] == test_param_tree.str_value + assert dt_str_val['value'] == test_param_tree.str_value def test_simple_tree_missing_value(self, test_param_tree): """Test that getting a missing value from a simple tree raises an error.""" @@ -318,12 +318,14 @@ def test_nested_tree_returns_nested_dict(self, test_param_tree): def test_nested_tree_branch_returns_dict(self, test_param_tree): """Test that getting a tree from within a nested tree returns a dict.""" branch_vals = test_param_tree.nested_tree.get('branch') - assert branch_vals['branch'] == test_param_tree.nested_dict['branch'] + for key, val in test_param_tree.nested_dict['branch'].items(): + assert branch_vals[key] == val def test_nested_tree_trailing_slash(self, test_param_tree): """Test that getting a tree with trailing slash returns the correct dict.""" branch_vals = test_param_tree.nested_tree.get('branch/') - assert branch_vals['branch'] == test_param_tree.nested_dict['branch'] + for key, val in test_param_tree.nested_dict['branch'].items(): + assert branch_vals[key] == val def test_set_with_extra_branch_paths(self, test_param_tree): """ @@ -339,7 +341,7 @@ def test_set_with_extra_branch_paths(self, test_param_tree): def test_complex_tree_calls_leaf_nodes(self, test_param_tree): """ - Test that accessing valyus in a complex tree returns the correct values for + Test that accessing values in a complex tree returns the correct values for static and callable parameters. """ complex_vals = test_param_tree.complex_tree.get('') @@ -349,13 +351,13 @@ def test_complex_tree_calls_leaf_nodes(self, test_param_tree): def test_complex_tree_access_list_param(self, test_param_tree): """Test that getting a list parameter from a complex tree returns the appropriate values.""" list_param_vals = test_param_tree.complex_tree.get('listParam') - assert list_param_vals['listParam'] == test_param_tree.list_values + assert list_param_vals['value'] == test_param_tree.list_values def test_complex_tree_access_list_param_element(self, test_param_tree): """Test that getting single values from a list element returns the correct values""" for elem in test_param_tree.list_values: list_param_elem = test_param_tree.complex_tree.get('listParam/{}'.format(elem)) - assert list_param_elem['{}'.format(elem)] == elem + assert list_param_elem['value'] == elem def test_complex_tree_accessor(self, test_param_tree): """ @@ -363,7 +365,7 @@ def test_complex_tree_accessor(self, test_param_tree): the correct value. """ accessor_val = test_param_tree.complex_tree.get('callableAccessorParam/one') - assert accessor_val['one']== test_param_tree.accessor_params['one'] + assert accessor_val['value']== test_param_tree.accessor_params['one'] def test_complex_tree_callable_readonly(self, test_param_tree): """ @@ -415,14 +417,14 @@ def test_list_tree_get_indexed(self, test_param_tree): Test that it is possible to get a value by index from a list parameter. """ ret = test_param_tree.list_tree.get("main/1") - assert ret == {'1':test_param_tree.list_values} + assert ret['value'] == test_param_tree.list_values def test_list_tree_set_indexed(self, test_param_tree): """ Test that it is possible to set a value by index on a list parameter. """ test_param_tree.list_tree.set("main/1/2", 7) - assert test_param_tree.list_tree.get("main/1/2") == {'2': 7} + assert test_param_tree.list_tree.get("main/1/2") == {'value': 7} def test_list_tree_set_from_root(self, test_param_tree): """Test that it is possible to set a list tree from its root.""" @@ -439,7 +441,7 @@ def test_list_tree_set_from_root(self, test_param_tree): } test_param_tree.list_tree.set("",tree_data) - assert test_param_tree.list_tree.get("main") == tree_data + assert test_param_tree.list_tree.get("main")['value'] == tree_data['main'] def test_list_tree_set_partial_from_root(self, test_param_tree): """Test that it is possible to set part of a list tree from its root.""" @@ -455,11 +457,11 @@ def test_list_tree_set_partial_from_root(self, test_param_tree): ] } test_param_tree.list_tree.set("",tree_data) - assert test_param_tree.list_tree.get("main/0/intParam") == {'intParam': 3} - assert test_param_tree.list_tree.get("main/0/floatParam") == {'floatParam': 4.56} - assert test_param_tree.list_tree.get("main/0/boolParam") == {'boolParam': True} - assert test_param_tree.list_tree.get("main/0/strParam") == {'strParam': "test1"} - assert test_param_tree.list_tree.get("main/1") == {'1': [1,2,3,4]} + assert test_param_tree.list_tree.get("main/0/intParam") == {'value': 3} + assert test_param_tree.list_tree.get("main/0/floatParam") == {'value': 4.56} + assert test_param_tree.list_tree.get("main/0/boolParam") == {'value': True} + assert test_param_tree.list_tree.get("main/0/strParam") == {'value': "test1"} + assert test_param_tree.list_tree.get("main/1") == {'value': [1,2,3,4]} tree_data = { 'main' : [ @@ -469,18 +471,18 @@ def test_list_tree_set_partial_from_root(self, test_param_tree): ] } test_param_tree.list_tree.set("",tree_data) - assert test_param_tree.list_tree.get("main/0/intParam") == {'intParam': 6} - assert test_param_tree.list_tree.get("main/0/floatParam") == {'floatParam': 4.56} - assert test_param_tree.list_tree.get("main/0/boolParam") == {'boolParam': True} - assert test_param_tree.list_tree.get("main/0/strParam") == {'strParam': "test1"} - assert test_param_tree.list_tree.get("main/1") == {'1': [1,2,3,4]} + assert test_param_tree.list_tree.get("main/0/intParam") == {'value': 6} + assert test_param_tree.list_tree.get("main/0/floatParam") == {'value': 4.56} + assert test_param_tree.list_tree.get("main/0/boolParam") == {'value': True} + assert test_param_tree.list_tree.get("main/0/strParam") == {'value': "test1"} + assert test_param_tree.list_tree.get("main/1") == {'value': [1,2,3,4]} def test_list_tree_from_dict(self, test_param_tree): - """TEet that a list tree can be set with a dict of index/values.""" + """Test that a list tree can be set with a dict of index/values.""" new_list_param = {0: 0, 1: 1, 2: 2, 3: 3} test_param_tree.simple_list_tree.set('list_param', new_list_param) assert test_param_tree.simple_list_tree.get( - 'list_param')['list_param']== list(new_list_param.values()) + 'list_param')['value']== list(new_list_param.values()) def test_list_tree_from_dict_bad_index(self, test_param_tree): @@ -569,13 +571,13 @@ class TestRwParameterTree(): def test_rw_tree_simple_get_values(self, test_rw_tree): """Test getting simple values from a RW tree returns the correct values.""" dt_rw_int_param = test_rw_tree.rw_callable_tree.get('intCallableRwParam') - assert dt_rw_int_param['intCallableRwParam'] == test_rw_tree.int_rw_param + assert dt_rw_int_param['value'] == test_rw_tree.int_rw_param dt_ro_int_param = test_rw_tree.rw_callable_tree.get('intCallableRoParam') - assert dt_ro_int_param['intCallableRoParam'] == test_rw_tree.int_ro_param + assert dt_ro_int_param['value'] == test_rw_tree.int_ro_param dt_rw_int_value = test_rw_tree.rw_callable_tree.get('intCallableRwValue') - assert dt_rw_int_value['intCallableRwValue'] == test_rw_tree.int_rw_value + assert dt_rw_int_value['value'] == test_rw_tree.int_rw_value def test_rw_tree_simple_set_value(self, test_rw_tree): """Test that setting a value in a RW tree updates and returns the correct value.""" @@ -583,7 +585,15 @@ def test_rw_tree_simple_set_value(self, test_rw_tree): test_rw_tree.rw_callable_tree.set('intCallableRwParam', new_int_value) dt_rw_int_param = test_rw_tree.rw_callable_tree.get('intCallableRwParam') - assert dt_rw_int_param['intCallableRwParam'] == new_int_value + assert dt_rw_int_param['value'] == new_int_value + + def test_rw_tree_set_value_with_dict(self, test_rw_tree): + """Test that setting a value in a RW tree with a value dict updates correctly.""" + new_int_value = 31415 + test_rw_tree.rw_callable_tree.set('intCallableRwParam', {'value': new_int_value}) + + dt_rw_int_param = test_rw_tree.rw_callable_tree.get('intCallableRwParam') + assert dt_rw_int_param['value'] == new_int_value def test_rw_tree_set_ro_param(self, test_rw_tree): """Test that attempting to set a RO parameter raises an error.""" @@ -607,7 +617,7 @@ def test_rw_callable_tree_set_rw_value(self, test_rw_tree): def test_rw_callable_nested_param_get(self, test_rw_tree): """Test the getting a nested callable RW parameter returns the correct value.""" dt_nested_param = test_rw_tree.rw_callable_tree.get('branch/nestedRwParam') - assert dt_nested_param['nestedRwParam'] == test_rw_tree.nested_rw_param + assert dt_nested_param['value'] == test_rw_tree.nested_rw_param def test_rw_callable_nested_param_set(self, test_rw_tree): """Test that setting a nested callable RW parameter sets the correct value.""" @@ -617,23 +627,23 @@ def test_rw_callable_nested_param_set(self, test_rw_tree): def test_rw_callable_nested_tree_set(self, test_rw_tree): """Test the setting a value within a callable nested tree updated the value correctly.""" - nested_branch = test_rw_tree.rw_callable_tree.get('branch')['branch'] + nested_branch = test_rw_tree.rw_callable_tree.get('branch') new_rw_param_val = 45.876 nested_branch['nestedRwParam'] = new_rw_param_val test_rw_tree.rw_callable_tree.set('branch', nested_branch) - new_branch = test_rw_tree.rw_callable_tree.get('branch')['branch'] - assert new_branch['nestedRwParam'], new_rw_param_val + new_branch = test_rw_tree.rw_callable_tree.get('branch') + assert new_branch['nestedRwParam'] == new_rw_param_val def test_rw_callable_nested_tree_set_trailing_slash(self, test_rw_tree): """ Test that setting a callable nested tree with a trailing slash in the path sets the value correctly. """ - nested_branch = test_rw_tree.rw_callable_tree.get('branch/')['branch'] + nested_branch = test_rw_tree.rw_callable_tree.get('branch/') new_rw_param_val = 24.601 nested_branch['nestedRwParam'] = new_rw_param_val test_rw_tree.rw_callable_tree.set('branch/', nested_branch) - new_branch = test_rw_tree.rw_callable_tree.get('branch/')['branch'] + new_branch = test_rw_tree.rw_callable_tree.get('branch/') assert new_branch['nestedRwParam'] == new_rw_param_val @@ -695,44 +705,22 @@ def test_callable_rw_param_metadata(self, test_tree_metadata): """Test that a getting RW parameter with metadata returns the appropriate metadata.""" int_param_with_metadata = test_tree_metadata.metadata_tree.get( "intCallableRwParam",with_metadata=True) - int_param = test_tree_metadata.metadata_tree.get("intCallableRwParam")["intCallableRwParam"] + int_param = test_tree_metadata.metadata_tree.get("intCallableRwParam")["value"] expected_metadata = test_tree_metadata.int_rw_param_metadata expected_metadata["value"] = int_param expected_metadata["type"] = 'int' expected_metadata["writeable"] = True - expected_param = {"intCallableRwParam" : expected_metadata} - - assert int_param_with_metadata == expected_param - def test_get_filters_tree_metadata(self, test_tree_metadata): - """ - Test that attempting to get a metadata field for a parameter as if it was path itself - raises an error. - """ - metadata_path = "name" - with pytest.raises(ParameterTreeError) as excinfo: - test_tree_metadata.metadata_tree.get(metadata_path) - - assert "Invalid path: {}".format(metadata_path) in str(excinfo.value) - - def test_set_tree_rejects_metadata(self, test_tree_metadata): - """ - Test that attampeting to set a metadata field as if it was a parameter raises an error. - """ - metadata_path = "name" - with pytest.raises(ParameterTreeError) as excinfo: - test_tree_metadata.metadata_tree.set(metadata_path, "invalid") - - assert "Invalid path: {}".format(metadata_path) in str(excinfo.value) + assert int_param_with_metadata == expected_metadata def test_enum_param_allowed_values(self, test_tree_metadata): """Test that setting an enumerated parameter with an allowed value succeeds.""" for value in test_tree_metadata.int_enum_param_allowed_values: test_tree_metadata.metadata_tree.set("intEnumParam", value) - set_value = test_tree_metadata.metadata_tree.get("intEnumParam")["intEnumParam"] + set_value = test_tree_metadata.metadata_tree.get("intEnumParam")["value"] assert value == set_value - + def test_enum_param_bad_value(self, test_tree_metadata): """ Test that attempting to set a disallowed value for an enumerated parameter raises an error. @@ -746,7 +734,7 @@ def test_enum_param_bad_value(self, test_tree_metadata): def test_ro_param_has_writeable_metadata_field(self, test_tree_metadata): """Test that a RO parameter has the writeable metadata field set to false.""" ro_param = test_tree_metadata.metadata_tree.get("floatRoParam", with_metadata=True) - assert ro_param["floatRoParam"]["writeable"] == False + assert ro_param["writeable"] == False def test_ro_param_not_writeable(self, test_tree_metadata): """Test that attempting to write to a RO parameter with metadata raises an error.""" @@ -759,7 +747,7 @@ def test_value_param_writeable(self, test_tree_metadata): new_value = 90210 test_tree_metadata.metadata_tree.set("valueParam", new_value) set_param = test_tree_metadata.metadata_tree.get( - "valueParam", with_metadata=True)["valueParam"] + "valueParam", with_metadata=True) assert set_param["value"] == new_value assert set_param["writeable"] == True @@ -768,7 +756,7 @@ def test_rw_param_min_no_max(self, test_tree_metadata): new_value = 2 test_tree_metadata.metadata_tree.set("minNoMaxParam", new_value) set_param = test_tree_metadata.metadata_tree.get( - "minNoMaxParam", with_metadata=True)["minNoMaxParam"] + "minNoMaxParam", with_metadata=True) assert set_param["value"] == new_value assert set_param["writeable"] == True @@ -782,7 +770,7 @@ def test_rw_param_below_min_value(self, test_tree_metadata): test_tree_metadata.metadata_tree.set("intCallableRwParam", low_value) assert "{} is below the minimum value {} for {}".format( - low_value, test_tree_metadata.int_rw_param_metadata["min"], + low_value, test_tree_metadata.int_rw_param_metadata["min"], "intCallableRwParam") in str(excinfo.value) def test_rw_param_above_max_value(self, test_tree_metadata): @@ -795,7 +783,7 @@ def test_rw_param_above_max_value(self, test_tree_metadata): test_tree_metadata.metadata_tree.set("intCallableRwParam", high_value) assert "{} is above the maximum value {} for {}".format( - high_value, test_tree_metadata.int_rw_param_metadata["max"], + high_value, test_tree_metadata.int_rw_param_metadata["max"], "intCallableRwParam") in str(excinfo.value) @@ -849,7 +837,7 @@ def test_mutable_put_differnt_data_type(self, test_tree_mutable): new_data = 75 test_tree_mutable.param_tree.set('bonus', new_data) val = test_tree_mutable.param_tree.get('bonus') - assert val['bonus'] == new_data + assert val['value'] == new_data def test_mutable_put_new_branch_node(self, test_tree_mutable): @@ -857,7 +845,7 @@ def test_mutable_put_new_branch_node(self, test_tree_mutable): test_tree_mutable.param_tree.set('extra', new_node) val = test_tree_mutable.param_tree.get('extra') - assert val['extra'] == new_node + assert val == new_node def test_mutable_put_new_sibling_node(self, test_tree_mutable): @@ -866,14 +854,14 @@ def test_mutable_put_new_sibling_node(self, test_tree_mutable): test_tree_mutable.param_tree.set(path, new_node) val = test_tree_mutable.param_tree.get(path) - assert 'new' in val[path] + assert 'new' in val def test_mutable_put_overwrite_param_accessor_read_only(self, test_tree_mutable): new_node = {"Node": "Broke Accessor"} with pytest.raises(ParameterTreeError) as excinfo: test_tree_mutable.param_tree.set('read', new_node) - + assert "is read-only" in str(excinfo.value) def test_mutable_put_overwrite_param_accessor_read_write(self, test_tree_mutable): @@ -898,7 +886,7 @@ def test_mutable_replace_branch(self, test_tree_mutable): test_tree_mutable.param_tree.replace(path, new_node) val = test_tree_mutable.param_tree.get(path) - assert val[path] == new_node + assert val == new_node def test_immutable_replace_branch_raises_error(self, test_tree_mutable): @@ -919,7 +907,7 @@ def test_mutable_put_replace_nested_path(self, test_tree_mutable): test_tree_mutable.param_tree.set(path, new_node) val = test_tree_mutable.param_tree.get(path) - assert val[path]['double_nest'] == new_node['double_nest'] + assert val['double_nest'] == new_node['double_nest'] def test_mutable_put_merge_nested_path(self, test_tree_mutable): @@ -935,8 +923,8 @@ def test_mutable_put_merge_nested_path(self, test_tree_mutable): test_tree_mutable.param_tree.set(path, new_node) val = test_tree_mutable.param_tree.get(path) - assert val[path]['double_nest']['nested_val'] == new_node['double_nest']['nested_val'] - assert 'dont_touch' in val[path]['double_nest'] + assert val['double_nest']['nested_val'] == new_node['double_nest']['nested_val'] + assert 'dont_touch' in val['double_nest'] def test_mutable_delete_method(self, test_tree_mutable): @@ -983,14 +971,14 @@ def test_mutable_delete_from_list(self, test_tree_mutable): test_tree_mutable.param_tree.delete(path) val = test_tree_mutable.param_tree.get('nest/list') - assert '3' not in val['list'] + assert '3' not in val def test_mutable_delete_from_dict_in_list(self, test_tree_mutable): path = 'nest/list/2/list_test' test_tree_mutable.param_tree.delete(path) val = test_tree_mutable.param_tree.get('nest/list') - assert {'list_test': "test"} not in val['list'] + assert 'list_test' not in val def test_mutable_nested_tree_in_immutable_tree(self, test_tree_mutable): @@ -1005,7 +993,7 @@ def test_mutable_nested_tree_in_immutable_tree(self, test_tree_mutable): path = 'nest/tree/extra' new_tree.set(path, new_node) val = new_tree.get(path) - assert val['extra'] == new_node + assert val == new_node def test_mutable_nested_tree_external_change(self, test_tree_mutable): @@ -1018,7 +1006,7 @@ def test_mutable_nested_tree_external_change(self, test_tree_mutable): path = 'tree/extra' test_tree_mutable.param_tree.set('extra', new_node) val = new_tree.get(path) - assert val['extra'] == new_node + assert val == new_node def test_mutable_nested_tree_delete(self, test_tree_mutable): @@ -1062,4 +1050,4 @@ def test_mutable_add_to_empty_dict(self, test_tree_mutable): path = 'empty' test_tree_mutable.param_tree.set(path, new_node) val = test_tree_mutable.param_tree.get(path) - assert val[path] == new_node + assert val == new_node diff --git a/tests/adapters/test_proxy.py b/tests/adapters/test_proxy.py index 936b0275..d7be0cc6 100644 --- a/tests/adapters/test_proxy.py +++ b/tests/adapters/test_proxy.py @@ -69,7 +69,6 @@ def put(self, path): try: self.param_tree.set(path, response_body) data_ref = self.param_tree.get(path) - self.write(data_ref) except ParameterTreeError: self.set_status(404) @@ -385,7 +384,7 @@ def test_adapter_get_proxy_path(self, proxy_adapter_test): response = proxy_adapter_test.adapter.get( "{}/{}".format(node, path), proxy_adapter_test.request) - assert response.data["even_more"] == ProxyTestHandler.data["more"]["even_more"] + assert response.data == ProxyTestHandler.data["more"]["even_more"] assert proxy_adapter_test.adapter.param_tree.get('')['status'][node]['status_code'] == 200 def test_adapter_get_proxy_path_trailing_slash(self, proxy_adapter_test): @@ -398,7 +397,7 @@ def test_adapter_get_proxy_path_trailing_slash(self, proxy_adapter_test): response = proxy_adapter_test.adapter.get( "{}/{}".format(node, path), proxy_adapter_test.request) - assert response.data["even_more"] == ProxyTestHandler.data["more"]["even_more"] + assert response.data == ProxyTestHandler.data["more"]["even_more"] assert proxy_adapter_test.adapter.param_tree.get('')['status'][node]['status_code'] == 200 def test_adapter_put_proxy_path(self, proxy_adapter_test): @@ -412,9 +411,8 @@ def test_adapter_put_proxy_path(self, proxy_adapter_test): response = proxy_adapter_test.adapter.put( "{}/{}".format(node, path), proxy_adapter_test.request) - logging.debug("Response: %s", response.data) assert proxy_adapter_test.adapter.param_tree.get('')['status'][node]['status_code'] == 200 - assert response.data["more"]["replace"] == "been replaced" + assert response.data["replace"] == "been replaced" def test_adapter_get_bad_path(self, proxy_adapter_test): """Test that a GET to a bad path within a target returns the appropriate error.""" @@ -493,5 +491,5 @@ def test_adapter_counter_get_single_node(self, proxy_adapter_test): response = proxy_adapter_test.adapter.get(path, proxy_adapter_test.request) access_counts = [server.get_access_count() for server in proxy_adapter_test.test_servers] - assert path in response.data + assert all(key in response.data for key in ProxyTestHandler.data.keys()) assert sum(access_counts) == 1 diff --git a/tests/handlers/test_api_adapter_info.py b/tests/handlers/test_api_adapter_info.py index 6ff42144..9a601650 100644 --- a/tests/handlers/test_api_adapter_info.py +++ b/tests/handlers/test_api_adapter_info.py @@ -14,7 +14,6 @@ def test_api_adapter_info_handler_get_adapters(self, test_api_adapter_info_handl test_api_adapter_info_handler.handler.get(test_api_adapter_info_handler.route.api_version) adapter_list = json.loads(test_api_adapter_info_handler.write_data) - print(adapter_list) assert 'adapters' in adapter_list assert isinstance(adapter_list['adapters'], dict) assert test_api_adapter_info_handler.subsystem in adapter_list['adapters'] From e3ea8178280740d3c3ae1b932a502dc480cbead2 Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Fri, 16 Jan 2026 07:40:55 +0000 Subject: [PATCH 06/13] Provide support for the adapter-controller pattern (#74) * Refactor the ApiAdapter class to support the adapter-controller pattern. This commit refactors the ApiAdapter class to support the adapter-controller pattern while retaining backward-compatiblity with adapters that implement their own HTTP verb handler methods. The adapter-controller pattern allows a derived adapter to simply define a controller class and error class and exploit the default behaviour of ApiAdapter to handle all requests and call the appropriate methods in the controller. The BaseController provides an abstract base class that defines the interface a derived controller must implement. Also refactored the structure of support classes and decorator methods, along with associated test classes and cases. * Refactor system info and status adapters to use adapter-controller pattern * Fix typing in BaseController for 3.8 and tweak comments of class vars in ApiAdapter * Add tests for base controller * Refactor AsyncApiAdapter to support the adapter-controller pattern * Fix docstrings and typos --- src/odin_control/adapters/adapter.py | 406 +++++------ src/odin_control/adapters/async_adapter.py | 206 ++++-- .../adapters/async_base_controller.py | 104 +++ src/odin_control/adapters/base_controller.py | 109 +++ src/odin_control/adapters/request.py | 56 ++ src/odin_control/adapters/response.py | 41 ++ src/odin_control/adapters/system_info.py | 110 +-- src/odin_control/adapters/system_status.py | 117 +--- src/odin_control/adapters/util.py | 167 +++++ src/odin_control/http/handlers/api.py | 2 +- src/odin_control/util.py | 32 - tests/adapters/test_adapter.py | 647 +++++++++--------- tests/adapters/test_async_adapter.py | 421 ++++++++++-- tests/adapters/test_async_base_controller.py | 108 +++ tests/adapters/test_async_proxy.py | 4 +- tests/adapters/test_base_controller.py | 78 +++ tests/adapters/test_dummy.py | 2 +- tests/adapters/test_request.py | 53 ++ tests/adapters/test_response.py | 47 ++ tests/adapters/test_system_info.py | 38 +- tests/adapters/test_system_status.py | 32 +- tests/adapters/test_util.py | 290 ++++++++ tests/handlers/fixtures.py | 2 +- tests/test_util.py | 31 +- 24 files changed, 2183 insertions(+), 920 deletions(-) create mode 100644 src/odin_control/adapters/async_base_controller.py create mode 100644 src/odin_control/adapters/base_controller.py create mode 100644 src/odin_control/adapters/request.py create mode 100644 src/odin_control/adapters/response.py create mode 100644 src/odin_control/adapters/util.py create mode 100644 tests/adapters/test_async_base_controller.py create mode 100644 tests/adapters/test_base_controller.py create mode 100644 tests/adapters/test_request.py create mode 100644 tests/adapters/test_response.py create mode 100644 tests/adapters/test_util.py diff --git a/src/odin_control/adapters/adapter.py b/src/odin_control/adapters/adapter.py index 3f6482fe..b45167a3 100644 --- a/src/odin_control/adapters/adapter.py +++ b/src/odin_control/adapters/adapter.py @@ -1,31 +1,51 @@ -""" -odin_control.adapters.adapter.py - base API adapter implmentation for the ODIN server. +"""Base API adapter implementation for odin-control. -Tim Nicholls, STFC Application Engineering Group +Tim Nicholls, STFC Detector System Software Group """ import logging -from odin_control.util import wrap_result +from tornado.escape import json_decode + +from odin_control.adapters.base_controller import BaseError +from odin_control.adapters.request import ApiAdapterRequest # noqa: F401 +from odin_control.adapters.response import ApiAdapterResponse +from odin_control.adapters.util import ( + request_types, + require_controller, + response_types, + wants_metadata, +) -class ApiAdapter(object): - """ - API adapter base class. - This class defines the basis for all API adapters and provides default - methods for the required HTTP verbs in case the derived classes fail to - implement them, returning an error message and 400 code. +class ApiAdapter(): + """API adapter base class. + + This class defines the basis for all API adapters and provides default methods for supported + HTTP verbs and for lifecycle management. Dervied adapters can either override these methods + explciitly to provide custom behavior, or adopt the adapter-controller pattern by specifying + controller and error clases, and allowing default methods in this class to interface requests + to the controller. """ - is_async = False + # Derived classes can specify controller and error classes to use + controller_cls = None + error_cls = BaseError + + # Derived classes should override this with a relevant version string version = "unknown" + # Flag to indicate if adapter is async; default to False + is_async = False + def __init__(self, **kwargs): """Initialise the ApiAdapter object. + This method initialises the adapter, loading any keyword arguments into the options + dictionary, and instantiating the controller if a controller class has been specified + :param kwargs: keyword argument list that is copied into options dictionary """ - super(ApiAdapter, self).__init__() self.name = type(self).__name__ # Load any keyword arguments into the adapter options dictionary @@ -33,280 +53,178 @@ def __init__(self, **kwargs): for kw in kwargs: self.options[kw] = kwargs[kw] + # Instantiate the controller if a controller class has been specified + if self.controller_cls: + self.controller = self.controller_cls(self.options) + else: + self.controller = None + + logging.debug("%s loaded", self.name) + def initialize(self, adapters): """Initialize the ApiAdapter after it has been registered by the API Route. - This is an abstract implementation of the initialize mechinism that allows - an adapter to receive a list of loaded adapters, for Inter-adapter communication. + This method allows the adapter to perform any initialization that requires access to other + loaded adapters. It receives a dictionary of loaded adapters from the API route and passes + them to the controller's initialize method, if a controller has been configured. + :param adapters: a dictionary of the adapters loaded by the API route. """ - pass + logging.debug("%s initialize called with %d adapters", self.name, len(adapters)) + + # Build a dictionary of other adapters excluding self + adapters = {name: adapter for name, adapter in adapters.items() if adapter is not self} + + # Initialize the controller with access to other adapters + if self.controller: + try: + self.controller.initialize(adapters) + except AttributeError: + logging.warning("%s controller has no initialize method", self.name) + else: + logging.warning("%s has no controller configured", self.name) + + @response_types("application/json", default="application/json") + @require_controller def get(self, path, request): """Handle an HTTP GET request. - This method is an abstract implementation of the GET request handler for ApiAdapter. + This method is a default implementation of the GET request handler for adapters. It calls + the get method of the controller and returns the result. Error handling is provided to + catch controller errors and return appropriate error responses. :param path: URI path of resource :param request: HTTP request object passed from handler :return: ApiAdapterResponse container of data, content-type and status_code """ - logging.debug('GET on path %s from %s: method not implemented by %s', - path, request.remote_ip, self.name) - response = "GET method not implemented by {}".format(self.name) - return ApiAdapterResponse(response, status_code=400) + content_type = "application/json" - def post(self, path, request): - """Handle an HTTP POST request. + try: + response = self.controller.get(path, wants_metadata(request)) + status_code = 200 + except self.error_cls as error: + response = {"error": str(error)} + status_code = 400 + + return ApiAdapterResponse(response, content_type=content_type, status_code=status_code) - This method is an abstract implementation of the POST request handler for ApiAdapter. + @request_types("application/json", "application/vnd.odin-native") + @response_types("application/json", default="application/json") + @require_controller + def put(self, path, request): + """Handle an HTTP PUT request. + + This method is a default implementation of the PUT request handler for adapters. It calls + the set method of the controller with the specified data and returns the result of a + subsequent get call. Error handling is provided to catch controller errors and return + appropriate error responses. :param path: URI path of resource :param request: HTTP request object passed from handler :return: ApiAdapterResponse container of data, content-type and status_code """ - logging.debug('POST on path %s from %s: method not implemented by %s', - path, request.remote_ip, self.name) - response = "POST method not implemented by {}".format(self.name) - return ApiAdapterResponse(response, status_code=400) - - def put(self, path, request): - """Handle an HTTP PUT request. + content_type = "application/json" + + try: + data = json_decode(request.body) + self.controller.set(path, data) + response = self.controller.get(path) + status_code = 200 + except self.error_cls as error: + response = {"error": str(error)} + status_code = 400 + except NotImplementedError: + response = { + "error": f"{self.name} does not support PUT requests" + } + status_code = 405 + except (TypeError, ValueError) as error: + response = {"error": f"Failed to decode PUT request body: {str(error)}"} + status_code = 400 + + return ApiAdapterResponse(response, content_type=content_type, status_code=status_code) + + + @request_types("application/json", "application/vnd.odin-native") + @response_types("application/json", default="application/json") + @require_controller + def post(self, path, request): + """Handle an HTTP POST request. - This method is an abstract implementation of the PUT request handler for ApiAdapter. + This method is a default implementation of the POST request handler for adapters. It calls + the create method of the controller and returns the result. Error handling is provided to + catch controller errors and return appropriate error responses. :param path: URI path of resource :param request: HTTP request object passed from handler :return: ApiAdapterResponse container of data, content-type and status_code """ - logging.debug('PUT on path %s from %s: method not implemented by %s', - path, request.remote_ip, self.name) - response = "PUT method not implemented by {}".format(self.name) - return ApiAdapterResponse(response, status_code=400) - + content_type = "application/json" + + try: + data = json_decode(request.body) + response = self.controller.create(path, data) + status_code = 200 + except self.error_cls as error: + response = {"error": str(error)} + status_code = 400 + except NotImplementedError: + response = { + "error": f"{self.name} does not support POST requests" + } + status_code = 405 + except (TypeError, ValueError) as error: + response = {"error": f"Failed to decode POST request body: {str(error)}"} + status_code = 400 + + return ApiAdapterResponse(response, content_type=content_type, status_code=status_code) + + @response_types("application/json", default="application/json") + @require_controller def delete(self, path, request): """Handle an HTTP DELETE request. - This method is an abstract implementation of the DELETE request handler for ApiAdapter. + This method is a default implementation of the DELETE request handler for adapters. It calls + the delete method of the controller and returns the result. Error handling is provided to + catch controller errors and return appropriate error responses. :param path: URI path of resource :param request: HTTP request object passed from handler :return: ApiAdapterResponse container of data, content-type and status_code """ - logging.debug('DELETE on path %s from %s: method not implemented by %s', - path, request.remote_ip, self.name) - response = "DELETE method not implemented by {}".format(self.name) - return ApiAdapterResponse(response, status_code=400) + content_type = "application/json" + + try: + response = self.controller.delete(path) + status_code = 200 + except self.error_cls as error: + response = {"error": str(error)} + status_code = 400 + except NotImplementedError: + response = { + "error": f"{self.name} does not support DELETE requests" + } + status_code = 405 + + return ApiAdapterResponse(response, content_type=content_type, status_code=status_code) def cleanup(self): """Clean up adapter state. - This is an abstract implementation of the cleanup mechanism provided to allow adapters + This method is a default implementation of the cleanup mechanism provided to allow adapters to clean up their state (e.g. disconnect cleanly from the device being controlled, set some status message). """ - pass - - -class ApiAdapterRequest(object): - """API Adapter Request object. - - Emulate the HTTPServerRequest class passed as an argument to adapter HTTP - verb methods (GET, PUT etc), for internal communication between adapters. - """ - def __init__(self, data, content_type="application/vnd.odin-native", - accept="application/json", remote_ip="LOCAL"): - """Initialize the Adapter Request body and headers. - - Create the header and body in the same way as in a HTTP Request. - This means we can still use it in adapter HTTP verb methods - """ - self.body = data - self.content_type = content_type - self.response_type = accept - self.remote_ip = remote_ip - self.headers = { - "Content-Type": self.content_type, - "Accept": self.response_type - } - - def set_content_type(self, content_type): - """Set the content type header for the request - - The content type is filtered by the decorator "request_types". If - it does not match the server will return a 415 error code. - """ - self.content_type = content_type - self.headers["Content-Type"] = content_type - - def set_response_type(self, response_type): - """Set the type of response accepted by the request - - The response type is filtered by the decorator "response_types". If - it does not match the server will return a 406 error code. - """ - self.response_type = response_type - self.headers["Accept"] = response_type - - def set_remote_ip(self, ip): - """Set the Remote IP of the request - - This is only used in the event that an adapter has not implemented - a GET or PUT request and is still using the base adapter class version - of that method. - """ - self.remote_ip = ip - - -class ApiAdapterResponse(object): - """ - API adapter response object. - - This is a container class for responses returned by ApiAdapter method calls. - It encapsulates the required attributes for all responses; data, content type and - status code. - """ - - def __init__(self, data, content_type="text/plain", status_code=200): - """Initialise the APiAdapterResponse object. - - :param data: data to return from data - :param content_type: content type of response - :param status_code: HTTP status code to return - """ - self.data = data - self.content_type = content_type - self.status_code = status_code - - def set_content_type(self, content_type): - """Set the content type for the adapter response. - - :param content_type: response content type - """ - self.content_type = content_type - - def set_status_code(self, status_code): - """Set the HTTP status code for the adapter response. - - :param status_code: HTTP status code - """ - self.status_code = status_code + logging.debug("%s cleanup called", self.name) + # If a controller is configured, call its cleanup method + if self.controller: + try: + self.controller.cleanup() + except AttributeError: + logging.warning("%s controller has no cleanup method", self.name) + else: + logging.warning("%s has no controller configured", self.name) -def request_types(*oargs): - """ - Decorator method to define legal content types that adapter method will accept. - - This method compares the HTTP Content-Type header with a list of acceptable - type. If there is a match, the adapter method is called accordingly, otherwise an - HTTP 415 error response is returned. - - Typical usage would be, in an adapter, to decorate a verb method as follows: - - @request_types('application/json') - def get(self, path, request) - - Note that both the request_types and response_types decorators can be applied to a - method. - - :param oargs: a variable length list of acceptable content types - :return: decorator context - """ - def decorator(func): - """Function decorator.""" - def wrapper(_self, path, request): - """Inner method wrapper.""" - # Validate the Content-Type header in the request against allowed types - if 'Content-Type' in request.headers: - if request.headers['Content-Type'] not in oargs: - response = ApiAdapterResponse( - 'Request content type ({}) not supported'.format( - request.headers['Content-Type']), status_code=415) - return wrap_result(response, _self.is_async) - return func(_self, path, request) - return wrapper - return decorator - - -def response_types(*oargs, **okwargs): - """ - Decorator method to define legal response types and a default for an adapter method. - - This method compares the HTTP request Accept header with a list of acceptable - response types. If there is a match, the response type is set accordingly, otherwise - an HTTP 406 error code is returned. A default type is also allowable, so if the request - fails to specify a type (e.g. '*/*') then this will be used. - Typical usage for this would be, in an adapter, to decorate a verb method as follows: - - @response_type('application/json', 'text/html', default='text/html') - def get(self, path, request): - - - to specify that the method has acceptable resonse types of JSON, HTML, defaulting to HTML - - :param oargs: a variable length list of acceptable response types - :param okwargs: keyword argument(s), allowing default type to be specified. - :return: decorator context - """ - def decorator(func): - """Function decorator.""" - def wrapper(_self, path, request): - """Inner function wrapper.""" - response_type = None - - # If Accept header is present, resolve the response type appropriately, otherwise - # coerce to the default before calling the decorated function - if 'Accept' in request.headers: - - if request.headers['Accept'] == '*/*': - if 'default' in okwargs: - response_type = okwargs['default'] - else: - response_type = 'text/plain' - else: - for accept_type in request.headers['Accept'].split(','): - accept_type = accept_type.split(';')[0] - if accept_type in oargs: - response_type = accept_type - break - - # If it was not possible to resolve a response type or there was not default - # given, return an error code 406 - if response_type is None: - response = ApiAdapterResponse( - "Requested content types not supported", status_code=406 - ) - return wrap_result(response, _self.is_async) - else: - response_type = okwargs['default'] if 'default' in okwargs else 'text/plain' - request.headers['Accept'] = response_type - - # Call the decorated function - return func(_self, path, request) - return wrapper - return decorator - - -def wants_metadata(request): - """ - Determine if a client request wants metadata to be included in the response. - - This method checks to see if an incoming request has an Accept header with - the 'metadata=true' qualified attached to the MIME-type. - - :param request: HTTPServerRequest or equivalent from client - :return boolean, True if metadata is requested. - """ - wants_metadata = False - - if "Accept" in request.headers: - accept_elems = request.headers["Accept"].split(';') - if len(accept_elems) > 1: - for elem in accept_elems[1:]: - if '=' in elem: - elem = elem.split('=') - if elem[0].strip() == "metadata": - wants_metadata = str(elem[1]).strip().lower() == 'true' - - return wants_metadata diff --git a/src/odin_control/adapters/async_adapter.py b/src/odin_control/adapters/async_adapter.py index d198cbae..0d2a60b0 100644 --- a/src/odin_control/adapters/async_adapter.py +++ b/src/odin_control/adapters/async_adapter.py @@ -1,25 +1,35 @@ -""" -odin_control.adapters.adapter.py - base asynchronous API adapter implmentation for the ODIN server. +"""Base asynchronous API adapter implmentation for the odin-control. Tim Nicholls, STFC Detector Systems Software Group """ import asyncio -import logging import inspect +import logging -from odin_control.adapters.adapter import ApiAdapter, ApiAdapterResponse +from odin_control.adapters.adapter import ( + ApiAdapter, + ApiAdapterRequest, # noqa: F401 + ApiAdapterResponse, + json_decode, + request_types, + require_controller, + response_types, + wants_metadata, +) class AsyncApiAdapter(ApiAdapter): - """ - Asynchronous API adapter base class. + """Asynchronous API adapter base class. - This class defines the basis for all async API adapters and provides default - methods for the required HTTP verbs in case the derived classes fail to - implement them, returning an error message and 400 code. + This class defines the basis for all async API adapters and provides default methods for + supported HTTP verbs and for lifecycle management. Derived adapters can override these + methods explicitly to provide custom behavior. Through the parent ApiAdapter class, this + class also supports the adapter-controller pattern; the controller class should be derived from + AsyncBaseController to provide async behavior during controller initialization. """ + # Set flag to indicate that this is an async adapter is_async = True def __init__(self, **kwargs): @@ -33,8 +43,9 @@ def __await__(self): """Make AsyncApiAdapter objects awaitable. This magic method makes the instantiation of AsyncApiAdapter objects awaitable. This allows - any underlying async and awaitable attributes, e.g. an AsyncParameterTree, to be correctly - awaited when the adapter is loaded.""" + any underlying async and awaitable attributes, e.g. an AsyncBaseController, to be correctly + awaited when the adapter is loaded. + """ async def closure(): """Await all async attributes of the adapter.""" awaitable_attrs = [attr for attr in self.__dict__.values() if inspect.isawaitable(attr)] @@ -46,78 +57,163 @@ async def closure(): async def initialize(self, adapters): """Initialize the AsyncApiAdapter after it has been registered by the API Route. - This is an abstract implementation of the initialize mechinism that allows - an adapter to receive a list of loaded adapters, for Inter-adapter communication. - :param adapters: a dictionary of the adapters loaded by the API route. - """ - - pass + This method allows the adapter to perform any initialization that requires access to other + loaded adapters. It receives a dictionary of loaded adapters from the API route and passes + them to the controller's initialize method, if a controller has been configured. - async def cleanup(self): - """Clean up adapter state. - - This is an abstract implementation of the cleanup mechanism provided to allow adapters - to clean up their state (e.g. disconnect cleanly from the device being controlled, set - some status message). """ - pass - + logging.debug("%s initialize called with %d adapters", self.name, len(adapters)) + + # Build a dictionary of other adapters excluding self + adapters = {name: adapter for name, adapter in adapters.items() if adapter is not self} + + # Initialize the controller with access to other adapters + if self.controller: + try: + await self.controller.initialize(adapters) + except AttributeError: + logging.warning("%s controller has no initialize method", self.name) + else: + logging.warning("%s has no controller configured", self.name) + + @response_types("application/json", default="application/json") + @require_controller async def get(self, path, request): """Handle an HTTP GET request. - This method is an abstract implementation of the GET request handler for AsyncApiAdapter. + This method is a default implementation of the async GET request handler for adapters. It + calls the get method of the controller and returns the result. Error handling is provided to + catch controller errors and return appropriate error responses. :param path: URI path of resource :param request: HTTP request object passed from handler :return: ApiAdapterResponse container of data, content-type and status_code """ - logging.debug('GET on path %s from %s: method not implemented by %s', - path, request.remote_ip, self.name) - await asyncio.sleep(0) - response = "GET method not implemented by {}".format(self.name) - return ApiAdapterResponse(response, status_code=400) + content_type = "application/json" - async def post(self, path, request): - """Handle an HTTP POST request. + try: + response = await self.controller.get(path, wants_metadata(request)) + status_code = 200 + except self.error_cls as error: + response = {"error": str(error)} + status_code = 400 + + return ApiAdapterResponse(response, content_type=content_type, status_code=status_code) + + @request_types("application/json", "application/vnd.odin-native") + @response_types("application/json", default="application/json") + @require_controller + async def put(self, path, request): + """Handle an HTTP PUT request. - This method is an abstract implementation of the POST request handler for AsyncApiAdapter. + This method is a default implementation of the async PUT request handler for adapters. It + calls the set method of the controller with the specified data and returns the result of a + subsequent get call. Error handling is provided to catch controller errors and return + appropriate error responses. :param path: URI path of resource :param request: HTTP request object passed from handler :return: ApiAdapterResponse container of data, content-type and status_code """ - logging.debug('POST on path %s from %s: method not implemented by %s', - path, request.remote_ip, self.name) - await asyncio.sleep(0) - response = "POST method not implemented by {}".format(self.name) - return ApiAdapterResponse(response, status_code=400) - - async def put(self, path, request): - """Handle an HTTP PUT request. + content_type = "application/json" + + try: + data = json_decode(request.body) + await self.controller.set(path, data) + response = await self.controller.get(path) + status_code = 200 + except self.error_cls as error: + response = {"error": str(error)} + status_code = 400 + except NotImplementedError: + response = { + "error": f"{self.name} does not support PUT requests" + } + status_code = 405 + except (TypeError, ValueError) as error: + response = {"error": f"Failed to decode PUT request body: {str(error)}"} + status_code = 400 + + return ApiAdapterResponse(response, content_type=content_type, status_code=status_code) + + @request_types("application/json", "application/vnd.odin-native") + @response_types("application/json", default="application/json") + @require_controller + async def post(self, path, request): + """Handle an HTTP POST request. - This method is an abstract implementation of the PUT request handler for AsyncApiAdapter. + This method is a default implementation of the async POST request handler for adapters. It + calls the create method of the controller and returns the result. Error handling is provided + to catch controller errors and return appropriate error responses. :param path: URI path of resource :param request: HTTP request object passed from handler :return: ApiAdapterResponse container of data, content-type and status_code """ - logging.debug('PUT on path %s from %s: method not implemented by %s', - path, request.remote_ip, self.name) - await asyncio.sleep(0) - response = "PUT method not implemented by {}".format(self.name) - return ApiAdapterResponse(response, status_code=400) - + content_type = "application/json" + + try: + data = json_decode(request.body) + response = await self.controller.create(path, data) + status_code = 200 + except self.error_cls as error: + response = {"error": str(error)} + status_code = 400 + except NotImplementedError: + response = { + "error": f"{self.name} does not support POST requests" + } + status_code = 405 + except (TypeError, ValueError) as error: + response = {"error": f"Failed to decode POST request body: {str(error)}"} + status_code = 400 + + return ApiAdapterResponse(response, content_type=content_type, status_code=status_code) + + @response_types("application/json", default="application/json") + @require_controller async def delete(self, path, request): """Handle an HTTP DELETE request. - This method is an abstract implementation of the DELETE request handler for ApiAdapter. + This method is a default implementation of the async DELETE request handler for adapters. It + calls the delete method of the controller and returns the result. Error handling is provided + to catch controller errors and return appropriate error responses. :param path: URI path of resource :param request: HTTP request object passed from handler :return: ApiAdapterResponse container of data, content-type and status_code """ - logging.debug('DELETE on path %s from %s: method not implemented by %s', - path, request.remote_ip, self.name) - await asyncio.sleep(0) - response = "DELETE method not implemented by {}".format(self.name) - return ApiAdapterResponse(response, status_code=400) + content_type = "application/json" + + try: + response = await self.controller.delete(path) + status_code = 200 + except self.error_cls as error: + response = {"error": str(error)} + status_code = 400 + except NotImplementedError: + response = { + "error": f"{self.name} does not support DELETE requests" + } + status_code = 405 + + return ApiAdapterResponse(response, content_type=content_type, status_code=status_code) + + async def cleanup(self): + """Clean up adapter state. + + This method is a default implementation of the async cleanup mechanism provided to allow + adapters to clean up their state (e.g. disconnect cleanly from the device being controlled, + set some status message). + """ + logging.debug("%s cleanup called", self.name) + + # If a controller is configured, call its cleanup method + if self.controller: + try: + await self.controller.cleanup() + except AttributeError: + logging.warning("%s controller has no cleanup method", self.name) + else: + logging.warning("%s has no controller configured", self.name) + diff --git a/src/odin_control/adapters/async_base_controller.py b/src/odin_control/adapters/async_base_controller.py new file mode 100644 index 00000000..c4355f61 --- /dev/null +++ b/src/odin_control/adapters/async_base_controller.py @@ -0,0 +1,104 @@ +"""Async base controller class for odin-control adapters. + +This module defines the base error class and abstract controller interface for odin control system +adapters. + +Tim Nicholls, STFC Detector Systems Software Group +""" +import asyncio +import inspect +from abc import ABC, abstractmethod +from typing import Any, Dict + +from .base_controller import BaseError # noqa: F401 + + +class AsyncBaseController(ABC): + """Abstract base class for asynchronous controllers. + + This class defines the interface that all async adapter controllers must implement to provide + consistent behavior across different adapter types. Controllers handle the core logic and device + communication for adapters. + + At a minimum, derived controller classes must implement the following methods: + - __init__(options) + - get(path, with_metadata=False) + + The following methods can be optionally implemented to provide additional functionality: + - initialize(adapters) + - cleanup() + - set(path, data) + - create(path, data) + - delete(path) + """ + + @abstractmethod + def __init__(self, options): + """Initialize the asynchronous controller with configuration options. + + :param options: Dictionary containing configuration options for the controller. + """ + ... # pragma: no cover + + def __await__(self): + """Make the controller awaitable to resolve async attributes.""" + async def closure(): + """Await all async attributes of the controller.""" + awaitable_attrs = [attr for attr in self.__dict__.values() if inspect.isawaitable(attr)] + await asyncio.gather(*awaitable_attrs) + return self + + return closure().__await__() + + async def initialize(self, adapters: Dict[str, object]) -> None: + """Initialize the asynchronous controller with access to other adapters. + + This method is called after all adapters have been loaded and allows + the controller to establish inter-adapter communication if needed. + + :param adapters: Dictionary mapping adapter names to adapter instances. + """ + self.adapters = adapters + + async def cleanup(self) -> None: + """Clean up controller resources and state. + + This method should be implemented to properly clean up any resources, + close connections, or perform other shutdown tasks when the controller + is being stopped or destroyed. + """ + ... + + @abstractmethod + async def get(self, path: str, with_metadata: bool = False) -> Dict[str, Any]: + """Asynchronously get data from the controller at the specified path. + + :param path: The path string identifying the resource to retrieve. + :param with_metadata: Whether to include metadata in the response. + :return: A dictionary containing the requested data. + """ + ... # pragma: no cover + + async def set(self, path: str, data: Dict[str, Any]) -> None: + """Asynchronously set data in the controller at the specified path. + + :param path: The path string identifying the resource to modify. + :param data: A dictionary containing the data to set. + """ + raise NotImplementedError() + + async def create(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]: + """Asynchronously create a new resource at the specified path. + + :param path: The path string identifying where to create the resource. + :param data: A dictionary containing the data for the new resource. + :return: A dictionary containing the created resource's data. + """ + raise NotImplementedError() + + async def delete(self, path: str) -> None: + """Asynchronously delete a resource at the specified path. + + :param path: The path string identifying the resource to delete. + """ + raise NotImplementedError() diff --git a/src/odin_control/adapters/base_controller.py b/src/odin_control/adapters/base_controller.py new file mode 100644 index 00000000..31607dca --- /dev/null +++ b/src/odin_control/adapters/base_controller.py @@ -0,0 +1,109 @@ +"""Base controller classes for odin-control adapters. + +This module defines the base error class and abstract controller interface for odin control system +adapters. + +Tim Nicholls, STFC Detector Systems Software Group +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict + + +class BaseError(Exception): + """Base exception class for adapter controllers. + + This exception class provides a common base for all adapter controller specific exceptions that + may be raised during operation. + """ + + pass + + +class BaseController(ABC): + """Abstract base class for adapter controllers. + + This class defines the interface that all adapter controllers must implement to provide + consistent behavior across different adapter types. Controllers handle the core logic and device + communication for adapters. + + At a minimum, derived controller classes must implement the following methods: + - __init__(options) + - get(path, with_metadata=False) + + The following methods can be optionally implemented to provide additional functionality: + - initialize(adapters) + - cleanup() + - set(path, data) + - create(path, data) + - delete(path) + """ + + @abstractmethod + def __init__(self, options): + """Initialize the controller with configuration options. + + :param options: Dictionary containing configuration options for the controller. + """ + ... # pragma: no cover + + def initialize(self, adapters: Dict[str, object]) -> None: + """Initialize the controller with access to other adapters. + + This method is called after all adapters have been loaded and allows + the controller to establish inter-adapter communication if needed. + + :param adapters: Dictionary mapping adapter names to adapter instances. + """ + self.adapters = adapters + + def cleanup(self) -> None: + """Clean up controller resources and state. + + This method should be implemented to properly clean up any resources, + close connections, or perform other shutdown tasks when the controller + is being stopped or destroyed. + """ + ... + + @abstractmethod + def get(self, path: str, with_metadata: bool = False) -> Dict[str, Any]: + """Get data from the controller at the specified path. + + :param path: The path string identifying the resource to retrieve. + :param with_metadata: Whether to include metadata in the response. + + :return: The requested data, format depends on controller implementation. + """ + ... # pragma: no cover + + def set(self, path: str, data: Dict[str, Any]) -> None: + """Set data in the controller at the specified path. + + :param path: The path string identifying the resource to modify. + :param data: The data to set at the specified path. + """ + raise NotImplementedError() + + def create(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]: + """Create a new resource at the specified path. + + :param path: The path string identifying where to create the resource. + :param data: The data to use for creating the new resource. + + :return: Dictionary containing the created resource data. + + Raises: + NotImplementedError: This method must be implemented by subclasses. + """ + raise NotImplementedError() + + def delete(self, path: str) -> None: + """Delete a resource at the specified path. + + :param path: The path string identifying the resource to delete. + + Raises: + NotImplementedError: This method must be implemented by subclasses. + """ + raise NotImplementedError() diff --git a/src/odin_control/adapters/request.py b/src/odin_control/adapters/request.py new file mode 100644 index 00000000..a6ac7b23 --- /dev/null +++ b/src/odin_control/adapters/request.py @@ -0,0 +1,56 @@ +"""API adapter request classes for odin-control adapters. + +This module defines the request container class used for internalcommunication between API adapters. + +Tim Nicholls, STFC Detector Systems Software Group +""" + + +class ApiAdapterRequest(object): + """API Adapter Request object. + + Emulate the HTTPServerRequest class passed as an argument to adapter HTTP + verb methods (GET, PUT etc), for internal communication between adapters. + """ + def __init__(self, data, content_type="application/vnd.odin-native", + accept="application/json", remote_ip="LOCAL"): + """Initialize the Adapter Request body and headers. + + Create the header and body in the same way as in a HTTP Request. + This means we can still use it in adapter HTTP verb methods + """ + self.body = data + self.content_type = content_type + self.response_type = accept + self.remote_ip = remote_ip + self.headers = { + "Content-Type": self.content_type, + "Accept": self.response_type + } + + def set_content_type(self, content_type): + """Set the content type header for the request. + + The content type is filtered by the decorator "request_types". If + it does not match the server will return a 415 error code. + """ + self.content_type = content_type + self.headers["Content-Type"] = content_type + + def set_response_type(self, response_type): + """Set the type of response accepted by the request. + + The response type is filtered by the decorator "response_types". If + it does not match the server will return a 406 error code. + """ + self.response_type = response_type + self.headers["Accept"] = response_type + + def set_remote_ip(self, ip): + """Set the Remote IP of the request. + + This is only used in the event that an adapter has not implemented + a GET or PUT request and is still using the base adapter class version + of that method. + """ + self.remote_ip = ip diff --git a/src/odin_control/adapters/response.py b/src/odin_control/adapters/response.py new file mode 100644 index 00000000..2f88d6d0 --- /dev/null +++ b/src/odin_control/adapters/response.py @@ -0,0 +1,41 @@ +"""API adapter response classes for odin-control adapters. + +This module defines the response container class used by API adapters to return data, content type +and status code information. + +Tim Nicholls, STFC Detector Systems Software Group +""" + + +class ApiAdapterResponse(object): + """API adapter response object. + + This is a container class for responses returned by ApiAdapter method calls. + It encapsulates the required attributes for all responses; data, content type and + status code. + """ + + def __init__(self, data, content_type="text/plain", status_code=200): + """Initialise the APiAdapterResponse object. + + :param data: data to return from data + :param content_type: content type of response + :param status_code: HTTP status code to return + """ + self.data = data + self.content_type = content_type + self.status_code = status_code + + def set_content_type(self, content_type): + """Set the content type for the adapter response. + + :param content_type: response content type + """ + self.content_type = content_type + + def set_status_code(self, status_code): + """Set the HTTP status code for the adapter response. + + :param status_code: HTTP status code + """ + self.status_code = status_code diff --git a/src/odin_control/adapters/system_info.py b/src/odin_control/adapters/system_info.py index 3c93cd50..af5a8707 100644 --- a/src/odin_control/adapters/system_info.py +++ b/src/odin_control/adapters/system_info.py @@ -5,110 +5,21 @@ Tim Nicholls, STFC Application Engineering """ -import logging import platform import time import tornado from odin_control._version import __version__ -from odin_control.adapters.adapter import ( - ApiAdapter, - ApiAdapterResponse, - request_types, - response_types, - wants_metadata, -) +from odin_control.adapters.adapter import ApiAdapter +from odin_control.adapters.base_controller import BaseController from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError -class SystemInfoAdapter(ApiAdapter): - """System info adapter class for the ODIN server. - - This adapter provides ODIN clients with information about the server and the system that it is - running on. - """ - - version = __version__ - - def __init__(self, **kwargs): - """Initialize the SystemInfoAdapter object. - - This constructor initializes the SystemInfoAdapter object, including resolving any - system-level information that the adapter can provide to clients subsequently. - - :param kwargs: keyword arguments specifying options - """ - super(SystemInfoAdapter, self).__init__(**kwargs) - self.system_info = SystemInfo() - logging.debug('SystemInfoAdapter loaded') - - @response_types('application/json', default='application/json') - def get(self, path, request): - """Handle an HTTP GET request. - - This method handles an HTTP GET request, returning a JSON response. - - :param path: URI path of request - :param request: HTTP request object - :return: an ApiAdapterResponse object containing the appropriate response - """ - try: - response = self.system_info.get(path, wants_metadata(request)) - status_code = 200 - except ParameterTreeError as param_error: - response = {'error': str(param_error)} - status_code = 400 - - logging.debug(response) - content_type = 'application/json' - - return ApiAdapterResponse(response, content_type=content_type, - status_code=status_code) - - @request_types("application/json", "application/vnd.odin-native") - @response_types('application/json', default='application/json') - def put(self, path, request): - """Handle an HTTP PUT request. - - This method handles an HTTP PUT request, returning a JSON response. - - :param path: URI path of request - :param request: HTTP request object - :return: an ApiAdapterResponse object containing the appropriate response - """ - response = {'response': 'SystemInfoAdapter: PUT on path {}'.format(path)} - content_type = 'application/json' - status_code = 200 - - logging.debug(response) - - return ApiAdapterResponse(response, content_type=content_type, - status_code=status_code) - - def delete(self, path, request): - """Handle an HTTP DELETE request. - - This method handles an HTTP DELETE request, returning a JSON response. - - :param path: URI path of request - :param request: HTTP request object - :return: an ApiAdapterResponse object containing the appropriate response - """ - response = 'SystemInfoAdapter: DELETE on path {}'.format(path) - status_code = 200 - - logging.debug(response) - - return ApiAdapterResponse(response, status_code=status_code) - - -class SystemInfo(): - """SystemInfo - class that extracts and stores information about system-level parameters.""" +class SystemInfoController(BaseController): + """SystemInfoController class that extracts and stores system-level parameters.""" - # __metaclass__ = Singleton - - def __init__(self): + def __init__(self, options=None): """Initialise the SystemInfo object. This constructor initlialises the SystemInfo object, extracting various system-level @@ -184,3 +95,14 @@ def get(self, path, with_metadata=False): :param path: path to retrieve from tree """ return self.param_tree.get(path, with_metadata) + +class SystemInfoAdapter(ApiAdapter): + """System info adapter class for the ODIN server. + + This adapter provides ODIN clients with information about the server and the system that it is + running on. + """ + + version = __version__ + controller_cls = SystemInfoController + error_cls = ParameterTreeError diff --git a/src/odin_control/adapters/system_status.py b/src/odin_control/adapters/system_status.py index 2030dd1f..8672f0fb 100644 --- a/src/odin_control/adapters/system_status.py +++ b/src/odin_control/adapters/system_status.py @@ -21,96 +21,17 @@ """ import logging import os + import psutil from tornado.ioloop import IOLoop + from odin_control._version import __version__ -from odin_control.adapters.adapter import ApiAdapter, ApiAdapterResponse, request_types, response_types +from odin_control.adapters.adapter import ApiAdapter +from odin_control.adapters.base_controller import BaseController from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError -class SystemStatusAdapter(ApiAdapter): - """System status adapter class for the ODIN server. - - This adapter provides ODIN clients with information about the server disks, network - and processes. - """ - - version = __version__ - - def __init__(self, **kwargs): - """Initialize the SystemInfoAdapter object. - - This constructor initializes the SystemInfoAdapter object, including resolving any - system-level information that the adapter can provide to clients subsequently. - - :param kwargs: keyword arguments specifying options - """ - super(SystemStatusAdapter, self).__init__(**kwargs) - self.system_status = SystemStatus(None, **kwargs) - logging.debug('SystemStatusAdapter loaded') - - @response_types('application/json', default='application/json') - def get(self, path, request): - """Handle an HTTP GET request. - - This method handles an HTTP GET request, returning a JSON response. - - :param path: URI path of request - :param request: HTTP request object - :return: an ApiAdapterResponse object containing the appropriate response - """ - response = {} - try: - response = self.system_status.get(path) - status_code = 200 - except ParameterTreeError as param_err: - response = {'error': str(param_err)} - status_code = 400 - - logging.debug(response) - content_type = 'application/json' - - return ApiAdapterResponse(response, content_type=content_type, - status_code=status_code) - - @request_types("application/json", "application/vnd.odin-native") - @response_types('application/json', default='application/json') - def put(self, path, request): - """Handle an HTTP PUT request. - - This method handles an HTTP PUT request, returning a JSON response. - - :param path: URI path of request - :param request: HTTP request object - :return: an ApiAdapterResponse object containing the appropriate response - """ - response = {'response': 'SystemStatusAdapter: PUT on path {}'.format(path)} - content_type = 'application/json' - status_code = 200 - - logging.debug(response) - - return ApiAdapterResponse(response, content_type=content_type, - status_code=status_code) - - def delete(self, path, request): - """Handle an HTTP DELETE request. - - This method handles an HTTP DELETE request, returning a JSON response. - - :param path: URI path of request - :param request: HTTP request object - :return: an ApiAdapterResponse object containing the appropriate response - """ - response = 'SystemStatusAdapter: DELETE on path {}'.format(path) - status_code = 200 - - logging.debug(response) - - return ApiAdapterResponse(response, status_code=status_code) - - -class SystemStatus(): +class SystemStatusController(BaseController): """Class to monitor disks, network and processes running on a server.""" def __init__(self, *args, **kwargs): """Initalise the Server Monitor. @@ -200,12 +121,13 @@ def get_process_status(self): """Return process status information.""" return self._process_status - def get(self, path): - """Return the requested path value""" - return self._status.get(path) + def get(self, path, with_metadata=False): + """Return the requested path value.""" + return self._status.get(path, with_metadata=with_metadata) def update_loop(self): """Handle update loop tasks. + This method handles background update tasks executed periodically in the tornado IOLoop instance. This includes monitoring all status from the server. """ @@ -274,8 +196,7 @@ def monitor_network(self): self._log.exception(exc) def monitor_processes(self): - """Loops over active processes and retrieves the statistics from them.""" - + """Loop over active processes and retrieves the statistics from them.""" for process_name in self._processes: self._process_status[process_name] = {} @@ -343,8 +264,11 @@ def find_processes(self, process_name): return processes def find_processes_by_name(self, name): - """Return a list of process matching 'name' that is not this process (in the case - where the process name was passed as an argument to this process).""" + """Find processes by name. + + Returns a list of process matching 'name' that is not this process (in the case + where the process name was passed as an argument to this process). + """ processes = [] for proc in psutil.process_iter(): process = None @@ -369,3 +293,14 @@ def find_processes_by_name(self, name): processes.append(process) return processes + +class SystemStatusAdapter(ApiAdapter): + """System status adapter class for the ODIN server. + + This adapter provides ODIN clients with information about the server disks, network + and processes. + """ + + version = __version__ + controller_cls = SystemStatusController + error_cls = ParameterTreeError diff --git a/src/odin_control/adapters/util.py b/src/odin_control/adapters/util.py new file mode 100644 index 00000000..89e8f384 --- /dev/null +++ b/src/odin_control/adapters/util.py @@ -0,0 +1,167 @@ +"""Utility functions and decorators for odin-control API adapters. + +This module provides common decorators and utility functions used by adapters for request/response +type validation and controller management. + +Tim Nicholls, STFC Detector System Software Group +""" +import asyncio + +from odin_control.adapters.response import ApiAdapterResponse + + +def wrap_result(result, is_async=True): + """Conditionally wrap a result in an aysncio Future if being used in async code. + + This method allows common functions for e.g. request validation, to be used in both + async and sync adapters. + + :param result: the result to potentially wrap + :param is_async: optional flag for if desired outcome is a result wrapped in a future + + :return: either the result or a Future wrapping the result + """ + if is_async: + future = asyncio.Future() + future.set_result(result) + return future + else: + return result + + +def request_types(*oargs): + """Ensure that a request has a legal content types that an adapter method will accept. + + This decorator method compares the HTTP Content-Type header with a list of acceptable + types. If there is a match, the adapter method is called accordingly, otherwise an + HTTP 415 error response is returned. + + Typical usage would be, in an adapter, to decorate a verb method as follows: + + @request_types('application/json') + def get(self, path, request) + + Note that both the request_types and response_types decorators can be applied to a + method. + + :param oargs: a variable length list of acceptable content types + :return: decorator context + """ + def decorator(func): + """Function decorator.""" + def wrapper(_self, path, request): + """Inner method wrapper.""" + # Validate the Content-Type header in the request against allowed types + if 'Content-Type' in request.headers: + if request.headers['Content-Type'] not in oargs: + response = ApiAdapterResponse( + f'Request content type ({request.headers["Content-Type"]}) not supported', + status_code=415) + return wrap_result(response, _self.is_async) + return func(_self, path, request) + return wrapper + return decorator + + +def response_types(*oargs, **okwargs): + """Ensure that a request wants legal response types for an adapter method. + + This decorator method compares the HTTP Accept header with a list of acceptable + response types. If there is a match, the response type is set accordingly, otherwise + an HTTP 406 error code is returned. A default type is also allowable, so if the request + fails to specify a type (e.g. '*/*') then this will be used. + + Typical usage for this would be, in an adapter, to decorate a verb method as follows: + + @response_type('application/json', 'text/html', default='text/html') + def get(self, path, request): + + + to specify that the method has acceptable resonse types of JSON, HTML, defaulting to HTML + + :param oargs: a variable length list of acceptable response types + :param okwargs: keyword argument(s), allowing default type to be specified. + :return: decorator context + """ + def decorator(func): + """Function decorator.""" + def wrapper(_self, path, request): + """Inner function wrapper.""" + response_type = None + + # If Accept header is present, resolve the response type appropriately, otherwise + # coerce to the default before calling the decorated function + if 'Accept' in request.headers: + + if request.headers['Accept'] == '*/*': + if 'default' in okwargs: + response_type = okwargs['default'] + else: + response_type = 'text/plain' + else: + for accept_type in request.headers['Accept'].split(','): + accept_type = accept_type.split(';')[0] + if accept_type in oargs: + response_type = accept_type + break + + # If it was not possible to resolve a response type or there was not default + # given, return an error code 406 + if response_type is None: + response = ApiAdapterResponse( + "Requested content types not supported", status_code=406 + ) + return wrap_result(response, _self.is_async) + else: + response_type = okwargs['default'] if 'default' in okwargs else 'text/plain' + request.headers['Accept'] = response_type + + # Call the decorated function + return func(_self, path, request) + return wrapper + return decorator + + +def require_controller(func): + """Ensure the adapter has a valid controller before executing HTTP methods. + + This decorator checks if the adapter instance has a valid controller object + in self.controller. If not, it returns a JSON error response with status 405. + + :param func: The HTTP method function to decorate + :return: Decorated function that validates controller presence + """ + def wrapper(_self, path, request): + """Wrapper function that validates controller presence.""" + if not _self.controller: + response = ApiAdapterResponse( + { "error": f"Adapter {_self.name} has no controller configured" }, + content_type="application/json", + status_code=405 + ) + return wrap_result(response, _self.is_async) + return func(_self, path, request) + return wrapper + + +def wants_metadata(request): + """Determine if a client request wants metadata to be included in the response. + + This method checks to see if an incoming request has an Accept header with + the 'metadata=true' qualifier attached to the MIME-type. + + :param request: HTTPServerRequest or equivalent from client + :return boolean, True if metadata is requested. + """ + wants_metadata = False + + if "Accept" in request.headers: + accept_elems = request.headers["Accept"].split(';') + if len(accept_elems) > 1: + for elem in accept_elems[1:]: + if '=' in elem: + elem = elem.split('=') + if elem[0].strip() == "metadata": + wants_metadata = str(elem[1]).strip().lower() == 'true' + + return wants_metadata diff --git a/src/odin_control/http/handlers/api.py b/src/odin_control/http/handlers/api.py index 69783faa..674507f3 100644 --- a/src/odin_control/http/handlers/api.py +++ b/src/odin_control/http/handlers/api.py @@ -7,7 +7,7 @@ from tornado.web import RequestHandler from odin_control.adapters.adapter import ApiAdapterResponse -from odin_control.util import wrap_result +from odin_control.adapters.util import wrap_result class ApiError(Exception): diff --git a/src/odin_control/util.py b/src/odin_control/util.py index bfc19519..563fdd62 100644 --- a/src/odin_control/util.py +++ b/src/odin_control/util.py @@ -26,23 +26,6 @@ def decode_request_body(request): return body -def wrap_result(result, is_async=True): - """ - Conditionally wrap a result in an aysncio Future if being used in async code on python 3. - - This is to allow common functions for e.g. request validation, to be used in both - async and sync code across python variants. - - :param is_async: optional flag for if desired outcome is a result wrapped in a future - - :return: either the result or a Future wrapping the result - """ - if is_async: - return wrap_async(result) - else: - return result - - def run_in_executor(executor, func, *args): """ Run a function asynchronously in an executor. @@ -68,21 +51,6 @@ def run_in_executor(executor, func, *args): return future -def wrap_async(object): - """Wrap an object in an async future. - - This function wraps an object in an async future and is called from wrap_result when - async objects are wrapped in python 3. A future is created, its result set to the - object passed in, and returned to the caller. - - :param object: object to wrap in a future - :return: a Future with object as its result - """ - future = asyncio.Future() - future.set_result(object) - return future - - def get_async_event_loop(): """Get the asyncio event loop. diff --git a/tests/adapters/test_adapter.py b/tests/adapters/test_adapter.py index 3f6a9a34..e7b0af76 100644 --- a/tests/adapters/test_adapter.py +++ b/tests/adapters/test_adapter.py @@ -1,375 +1,402 @@ +from unittest.mock import Mock + import pytest -from unittest.mock import Mock +from odin_control._version import __version__ +from odin_control.adapters.adapter import ApiAdapter +from odin_control.adapters.base_controller import BaseController, BaseError -from odin_control.adapters.adapter import (ApiAdapter, ApiAdapterResponse, ApiAdapterRequest, - request_types, response_types, wants_metadata) -class ApiAdapterTestFixture(object): +class ApiAdapterTestFixture(): """ Container class used in fixtures for testing ApiAdapter behaviour.""" - def __init__(self): + def __init__(self, adapter_cls): self.adapter_options = { 'test_option_float' : 1.234, 'test_option_str' : 'value', 'test_option_int' : 4567. } - self.adapter = ApiAdapter(**self.adapter_options) + self.adapter = adapter_cls(**self.adapter_options) self.path = '/api/path' self.request = Mock() - self.request.headers = {'Accept': '*/*', 'Content-Type': 'text/plain'} + self.request.headers = {'Accept': '*/*', 'Content-Type': 'application/json'} + self.request.body = b'{}' @pytest.fixture(scope="class") def test_api_adapter(): """Simple test fixture used for testing ApiAdapter.""" - test_api_adapter = ApiAdapterTestFixture() + test_api_adapter = ApiAdapterTestFixture(ApiAdapter) yield test_api_adapter class TestApiAdapter(): """Class to test the ApiAdapter object.""" def test_adapter_get(self, test_api_adapter): - """ - Test the the adapter responds to a GET request correctly by returning a 400 code and - appropriate message. This is due to the base adapter not implementing the methods. + """Test that the adapter responds to a GET request correctly by returning a 405 code and + appropriate message due to the lack of a controller. """ response = test_api_adapter.adapter.get(test_api_adapter.path, test_api_adapter.request) - assert response.data == 'GET method not implemented by ApiAdapter' - assert response.status_code == 400 + assert response.data == {'error': 'Adapter ApiAdapter has no controller configured'} + assert response.status_code == 405 def test_adapter_post(self, test_api_adapter): + """Test that the adapter responds to a POST request correctly by returning a 405 code and + appropriate message due to the lack of a controller. """ - Test the the adapter responds to a GET request correctly by returning a 400 code and - appropriate message. This is due to the base adapter not implementing the methods. - """ + response = test_api_adapter.adapter.post(test_api_adapter.path, test_api_adapter.request) - assert response.data == 'POST method not implemented by ApiAdapter' - assert response.status_code == 400 + assert response.data == {'error': 'Adapter ApiAdapter has no controller configured'} + assert response.status_code == 405 def test_adapter_put(self, test_api_adapter): - """ - Test the the adapter responds to a PUT request correctly by returning a 400 code and - appropriate message. This is due to the base adapter not implementing the methods. + """Test that the adapter responds to a PUT request correctly by returning a 405 code and + appropriate message due to the lack of a controller. """ response = test_api_adapter.adapter.put(test_api_adapter.path, test_api_adapter.request) - assert response.data == 'PUT method not implemented by ApiAdapter' - assert response.status_code == 400 + assert response.data == {'error': 'Adapter ApiAdapter has no controller configured'} + assert response.status_code == 405 def test_adapter_delete(self, test_api_adapter): - """ - Test the the adapter responds to a DELETE request correctly by returning a 400 code and - appropriate message. This is due to the base adapter not implementing the methods. + """Test that the adapter responds to a DELETE request correctly by returning a 405 code and + appropriate message due to the lack of a controller. """ response = test_api_adapter.adapter.delete(test_api_adapter.path, test_api_adapter.request) - assert response.data == 'DELETE method not implemented by ApiAdapter' - assert response.status_code == 400 + assert response.data == {'error': 'Adapter ApiAdapter has no controller configured'} + assert response.status_code == 405 def test_api_adapter_has_options(self, test_api_adapter): """Test that the adapter loads the options correctly.""" opts = test_api_adapter.adapter.options assert opts == test_api_adapter.adapter_options - def test_api_adapter_cleanup(self, test_api_adapter): - """Test the the adapter cleanup function runs without error.""" - raised = False - try: + def test_api_adapter_initialize(self, test_api_adapter, caplog): + """Test that the initialize method logs the correct message when no controller is set.""" + with caplog.at_level('DEBUG'): + test_api_adapter.adapter.initialize({}) + assert any(record.levelname == 'WARNING' for record in caplog.records) + assert f"{test_api_adapter.adapter.name} initialize called" in caplog.text + assert f"{test_api_adapter.adapter.name} has no controller configured" in caplog.text + + def test_api_adapter_cleanup(self, test_api_adapter, caplog): + """Test that the cleanup function logs the correct message when no controller is set.""" + with caplog.at_level('DEBUG'): test_api_adapter.adapter.cleanup() - except: - raised = True - assert not raised - - def test_api_adapter_initialize(self, test_api_adapter): - """Test the the adapter initialize function runs without error.""" - raised = False - try: - test_api_adapter.adapter.initialize(None) - except: - raised = True - assert not raised - - -class TestAdapterRequest(): - """Class to test behaviour of the AdapterRequest object.""" - - def test_simple_request(self): - """Test that a simple request is populated with the correct fields.""" - data = "This is some simple request data" - request = ApiAdapterRequest(data) - assert request.body == data - assert request.content_type == 'application/vnd.odin-native' - assert request.response_type == "application/json" - expected_headers = { - "Content-Type": 'application/vnd.odin-native', - "Accept": "application/json" + assert any(record.levelname == 'WARNING' for record in caplog.records) + assert f"{test_api_adapter.adapter.name} cleanup called" in caplog.text + assert f"{test_api_adapter.adapter.name} has no controller configured" in caplog.text + + +@pytest.fixture(scope="class") +def test_api_adapter_with_incomplete_controller(): + """Simple test fixture used for testing ApiAdapter with an incomplete controller.""" + test_api_adapter = ApiAdapterTestFixture(ApiAdapter) + test_api_adapter.adapter.controller = Mock() + test_api_adapter.adapter.controller.initialize.side_effect = AttributeError + test_api_adapter.adapter.controller.cleanup.side_effect = AttributeError + test_api_adapter.adapter.controller.set.side_effect = NotImplementedError + test_api_adapter.adapter.controller.create.side_effect = NotImplementedError + test_api_adapter.adapter.controller.delete.side_effect = NotImplementedError + + yield test_api_adapter + +class TestApiAdapterWithIncompleteController(): + """Class to test the ApiAdapter object with a controller.""" + + def test_adapter_with_controller_initialize( + self, test_api_adapter_with_incomplete_controller, caplog + ): + """Test that an adapter with a controller catched a missing controller initialize method.""" + with caplog.at_level('DEBUG'): + test_api_adapter_with_incomplete_controller.adapter.initialize({}) + assert any(record.levelname == 'WARNING' for record in caplog.records) + assert (f"{test_api_adapter_with_incomplete_controller.adapter.name} controller has no " + f"initialize method" in caplog.text) + + def test_adapter_with_controller_cleanup( + self, test_api_adapter_with_incomplete_controller, caplog + ): + """Test that an adapter with a controller catched a missing controller cleanup method.""" + with caplog.at_level('DEBUG'): + test_api_adapter_with_incomplete_controller.adapter.cleanup() + assert any(record.levelname == 'WARNING' for record in caplog.records) + assert (f"{test_api_adapter_with_incomplete_controller.adapter.name} controller has no " + f"cleanup method" in caplog.text) + + def test_adapter_with_controller_get( + self, test_api_adapter_with_incomplete_controller + ): + """Test that an adapter with a controller calls the controller get method.""" + test_api_adapter_with_incomplete_controller.adapter.get( + test_api_adapter_with_incomplete_controller.path, + test_api_adapter_with_incomplete_controller.request + ) + assert test_api_adapter_with_incomplete_controller.adapter.controller.get.called + + def test_adapter_with_controller_put( + self, test_api_adapter_with_incomplete_controller + ): + """Test that a PUT to an adapter with an incomplete controller (no set method) handles + NotImplementedError. + """ + response = test_api_adapter_with_incomplete_controller.adapter.put( + test_api_adapter_with_incomplete_controller.path, + test_api_adapter_with_incomplete_controller.request + ) + assert response.data == { + "error": f"{test_api_adapter_with_incomplete_controller.adapter.name} does not support " + "PUT requests" } - assert request.headers == expected_headers - - def test_request_with_types(self): - """Test that a request with the correct types is correctly populated.""" - data = '{\'some_json_value\' : 1.234}' - content_type = 'application/json' - request_type = "application/vnd.odin-native" - request = ApiAdapterRequest(data, content_type=content_type, accept=request_type) - assert request.body == data - assert request.content_type == content_type - assert request.response_type == request_type - assert request.headers == { - "Content-Type": content_type, - "Accept": request_type} - - - def test_set_content(self): - """Test that explicitly setting fields on the request works correctly.""" - data = '{\'some_json_value\' : 1.234}' - content_type = 'application/json' - request_type = "application/vnd.odin-native" - remote_ip = "127.0.0.1" - - request = ApiAdapterRequest(data) - request.set_content_type(content_type) - request.set_response_type(request_type) - request.set_remote_ip(remote_ip) - - assert request.body == data - assert request.content_type == content_type - assert request.response_type == request_type - assert request.remote_ip == remote_ip - assert request.headers == { - "Content-Type": content_type, - "Accept": request_type} - - def test_wants_metadata(self): - """Test that the wants_metadata fields on the rqeuest object works correctly.""" - request = Mock() - for metadata_state in (True, False): - request.headers = { - 'Accept': 'application/json;metadata={}'.format(str(metadata_state)) - } - assert wants_metadata(request) == metadata_state - - request.headers = { - 'Accept': 'application/json;metadata={}'.format(str(metadata_state).lower()) - } - assert wants_metadata(request) == metadata_state - - request.headers = {'Accept:' 'application/json;metadata=wibble'} - assert not wants_metadata(request) - -class TestApiAdapterResponse(): - """Class to test behaviour of the ApiAdapterResponse object.""" - - def test_simple_response(self): - """Test that a simple rewponse contains the correct default values in fields.""" - data = 'This is a simple response' - response = ApiAdapterResponse(data) - - assert response.data == data - assert response.content_type == 'text/plain' - assert response.status_code == 200 + assert response.status_code == 405 - def test_response_with_type_and_code(self): + def test_adapter_with_controller_post( + self, test_api_adapter_with_incomplete_controller + ): + """Test that a POST to an adapter with an incomplete controller (no create method) handles + NotImplementedError. """ - Test that a response with explicit content type and status codes - correctly populates the fields. + response = test_api_adapter_with_incomplete_controller.adapter.post( + test_api_adapter_with_incomplete_controller.path, + test_api_adapter_with_incomplete_controller.request + ) + assert response.data == { + "error": f"{test_api_adapter_with_incomplete_controller.adapter.name} does not support " + "POST requests" + } + assert response.status_code == 405 + + def test_adapter_with_controller_delete( + self, test_api_adapter_with_incomplete_controller + ): + """Test that a DELETE to an adapter with an incomplete controller (no delete method) + handles NotImplementedError. """ - data = '{\'some_json_value\' : 1.234}' - content_type = 'application/json' - status_code = 400 + response = test_api_adapter_with_incomplete_controller.adapter.delete( + test_api_adapter_with_incomplete_controller.path, + test_api_adapter_with_incomplete_controller.request + ) + assert response.data == { + "error": f"{test_api_adapter_with_incomplete_controller.adapter.name} does not support " + "DELETE requests" + } + assert response.status_code == 405 - response = ApiAdapterResponse(data, content_type=content_type, status_code=status_code) - assert response.data == data - assert response.content_type == content_type - assert response.status_code == status_code - def test_response_with_set_calls(self): - """ - Test the creating a default response and then explicitly setting the type and code - correctly populates the fields. - """ - data = '{\'some_json_value\' : 1.234}' - content_type = 'application/json' - status_code = 400 +class ApiAdapterTestController(BaseController): + """Simple test controller for testing ApiAdapter with a controller.""" + def __init__(self, options): + self.options = options - response = ApiAdapterResponse(data) - response.set_content_type(content_type) - response.set_status_code(status_code) + def get(self, path, with_metadata=False): + """Mock get method.""" + if 'error' in path: + raise BaseError("Test error raised from controller get method") - assert response.data == data - assert response.content_type == content_type - assert response.status_code == status_code + return {"response": f"get called on path {path} with_metadata={with_metadata}"} + def set(self, path, data): + """Mock set method.""" + if 'error' in path: + raise BaseError("Test error raised from controller set method") -class ApiMethodDecoratorsTestFixture(object): - """Container class used in fixtures for testing adapter method decorators.""" + return {"response": f"set called on path {path} with data={data}"} - def __init__(self): - """Initialise request and responses for testing method decorators.""" - self.path = '/api/path' - self.response_code = 200 - self.response_type_plain = 'text/plain' - self.response_data_plain = 'Plain text response' - - self.response_type_json = 'application/json' - self.response_data_json = {'response': 'JSON response'} - - self.is_async = False - - @request_types('application/json', 'text/plain') - @response_types('application/json', 'text/plain', default='application/json') - def decorated_method(self, path, request): - """Decorated method having defined request, response and default types.""" - if request.headers['Accept'] == self.response_type_plain: - response = ApiAdapterResponse( - self.response_data_plain, - content_type=self.response_type_plain, status_code=self.response_code) - else: - response = ApiAdapterResponse( - self.response_data_json, - content_type=self.response_type_json, status_code=self.response_code) - - return response - - @request_types('application/json', 'text/plain') - @response_types('application/json') - def decorated_method_without_default(self, path, request): - """Decorated method having defined request, response but no default type.""" - if request.headers['Accept'] == self.response_type_plain: - response = ApiAdapterResponse( - self.response_data_plain, - content_type=self.response_type_plain, status_code=self.response_code) - elif request.headers['Accept'] == '*/*': - response = ApiAdapterResponse( - self.response_data_plain, - content_type=self.response_type_plain, status_code=self.response_code) - elif request.headers['Accept'] == self.response_type_json: - response = ApiAdapterResponse( - self.response_data_json, - content_type=self.response_type_json, status_code=self.response_code) - else: # pragma: no cover - response = None - assert ("Request type decorator failed to trap unknown content type") - - return response + def create(self, path, data): + """Mock create method.""" + if 'error' in path: + raise BaseError("Test error raised from controller create method") + + return data + + def delete(self, path): + """Mock delete method.""" + if 'error' in path: + raise BaseError("Test error raised from controller delete method") + + return {"response": f"delete called on path {path}"} + +class ApiAdapterTestAdapter(ApiAdapter): + """Simple test adapter for testing ApiAdapter with a controller.""" + version = __version__ + controller_cls = ApiAdapterTestController + error_cls = BaseError @pytest.fixture(scope="class") -def test_api_decorator(): - """Test fixture for testing method decorators.""" - test_api_decorator = ApiMethodDecoratorsTestFixture() - yield test_api_decorator - -class TestApiMethodDecorators(object): - """Class to test API method decorators.""" - - def test_decorated_method_plaintext(self, test_api_decorator): - """ Test that a method being passed a plaintext request responds correctly.""" - plain_request = Mock() - plain_request.data = 'Simple plain text request' - plain_request.headers = {'Accept': 'text/plain', 'Content-Type': 'text/plain'} - - response = test_api_decorator.decorated_method(test_api_decorator.path, plain_request) - assert response.status_code == test_api_decorator.response_code - assert response.content_type == test_api_decorator.response_type_plain - assert response.data == test_api_decorator.response_data_plain - - def test_decorated_method_default(self, test_api_decorator): - """ - Test that a decorated method being passed a JSON object responds correctly when - the accepted type is default. - """ - json_request = Mock() - json_request.data = '{\'request\': 1234}' - json_request.headers = {'Accept': '*/*', 'Content-Type': 'application/json'} +def test_api_adapter_with_controller(): + """Simple test fixture used for testing ApiAdapter with a controller.""" + test_api_adapter = ApiAdapterTestFixture(ApiAdapterTestAdapter) - response = test_api_decorator.decorated_method(test_api_decorator.path, json_request) - assert response.status_code == test_api_decorator.response_code - assert response.content_type == test_api_decorator.response_type_json - assert response.data == test_api_decorator.response_data_json + yield test_api_adapter - def test_decorated_method_json(self, test_api_decorator): - """ - Test that a decorated method being passed a JSON object responds correctly when - the accepted type is also JSON. - """ - json_request = Mock() - json_request.data = '{\'request\' : 1234}' - json_request.headers = {'Accept': 'application/json', 'Content-Type': 'application/json'} - - response = test_api_decorator.decorated_method(test_api_decorator.path, json_request) - assert response.status_code == test_api_decorator.response_code - assert response.content_type == test_api_decorator.response_type_json - assert response.data == test_api_decorator.response_data_json - - def test_decorated_method_bad_content(self, test_api_decorator): - """Test that a decorated method passed an unsupported content type returns an error.""" - json_request = Mock() - json_request.data = 'wibble' - json_request.headers = {'Accept': 'application/json', 'Content-Type': 'application/hdf'} - - response = test_api_decorator.decorated_method(test_api_decorator.path, json_request) - assert response.status_code == 415 - assert response.data == 'Request content type (application/hdf) not supported' - - def test_decorated_method_bad_accept(self, test_api_decorator): - """Test that a decorated method passed an unsupported accept type returns an error.""" - request = Mock() - request.data = 'Some text' - request.headers = {'Accept': 'application/hdf', 'Content-Type': 'text/plain'} - - response = test_api_decorator.decorated_method(test_api_decorator.path, request) - assert response.status_code == 406 - assert response.data == 'Requested content types not supported' - - def test_decorated_method_no_default(self, test_api_decorator): - """ - Test that a decorated method with no default defined returns a response matching the - request. - """ - request = Mock() - request.data = 'Some text' - request.headers = {'Accept': '*/*', 'Content-Type': 'text/plain'} +class TestApiAdapterWithController(): + """Class to test the ApiAdapter object with a controller.""" + + def test_api_adapter_with_controller_initialize( + self, test_api_adapter_with_controller, caplog + ): + """Test that an adapter with a controller calls the controller initialize method.""" + with caplog.at_level('DEBUG'): + try: + test_api_adapter_with_controller.adapter.initialize({}) + except Exception as exc: + pytest.fail(f"Adapter initialize raised an exception: {exc}") + assert any(record.levelname == 'DEBUG' for record in caplog.records) + assert (f"{test_api_adapter_with_controller.adapter.name} initialize called" in + caplog.text) + + def test_api_adapter_with_controller_cleanup( + self, test_api_adapter_with_controller, caplog + ): + """Test that an adapter with a controller calls the controller cleanup method.""" + with caplog.at_level('DEBUG'): + try: + test_api_adapter_with_controller.adapter.cleanup() + except Exception as exc: + pytest.fail(f"Adapter cleanup raised an exception: {exc}") + assert any(record.levelname == 'DEBUG' for record in caplog.records) + assert (f"{test_api_adapter_with_controller.adapter.name} cleanup called" in + caplog.text) + + @pytest.mark.parametrize("with_metadata", [False, True]) + def test_api_adapter_with_controller_get( + self, test_api_adapter_with_controller, with_metadata + ): + """Test that the controller get method is called and returns the correct response.""" + request = test_api_adapter_with_controller.request + request.headers['Accept'] = f'application/json; metadata={str(with_metadata).lower()}' + + response = test_api_adapter_with_controller.adapter.get( + test_api_adapter_with_controller.path, request + ) + assert response.data == { + "response": ( + f"get called on path {test_api_adapter_with_controller.path} " + f"with_metadata={with_metadata}" + ) + } + assert response.status_code == 200 - response = test_api_decorator.decorated_method_without_default(test_api_decorator.path, request) - assert response.status_code == test_api_decorator.response_code - assert response.content_type == test_api_decorator.response_type_plain - assert response.data == test_api_decorator.response_data_plain + def test_api_adapter_with_controller_get_error( + self, test_api_adapter_with_controller + ): + """Test that the controller get method raises an error and is handled correctly.""" + path = test_api_adapter_with_controller.path + '/error' + response = test_api_adapter_with_controller.adapter.get( + path, test_api_adapter_with_controller.request + ) + assert response.data == {"error": "Test error raised from controller get method"} + assert response.status_code == 400 - def test_decorated_method_no_default_json(self, test_api_decorator): - """ - Test that a decorated method with no default defined returns a JSON repsonse to a JSON - request. - """ - request = Mock() - request.data = '{\'request\' : 1234}' - request.headers = {'Accept': 'application/json', 'Content-Type': 'application/json'} + def test_api_adapter_with_controller_put( + self, test_api_adapter_with_controller + ): + """Test that the controller set method is called and returns the correct response.""" + request = test_api_adapter_with_controller.request + request.data = b'{"key": "value"}' + + response = test_api_adapter_with_controller.adapter.put( + test_api_adapter_with_controller.path, + test_api_adapter_with_controller.request + ) + assert response.data == { + "response": ( + f"get called on path {test_api_adapter_with_controller.path} with_metadata=False" + ) + } + assert response.status_code == 200 + + def test_api_adapter_with_controller_put_error( + self, test_api_adapter_with_controller + ): + """Test that the controller set method raises an error and is handled correctly.""" + path = test_api_adapter_with_controller.path + '/error' + request = test_api_adapter_with_controller.request + request.data = b'{"key": "value"}' + + response = test_api_adapter_with_controller.adapter.put( + path, test_api_adapter_with_controller.request + ) + assert response.data == {"error": "Test error raised from controller set method"} + assert response.status_code == 400 - response = test_api_decorator.decorated_method_without_default(test_api_decorator.path, request) - assert response.status_code == test_api_decorator.response_code - assert response.content_type == test_api_decorator.response_type_json - assert response.data == test_api_decorator.response_data_json + def test_api_adapter_with_controller_put_decode_error( + self, test_api_adapter_with_controller + ): + """Test that a decode error in the PUT request body is handled correctly.""" + request = test_api_adapter_with_controller.request + request.body = b'{"key": "value"' # Malformed JSON + + response = test_api_adapter_with_controller.adapter.put( + test_api_adapter_with_controller.path, + test_api_adapter_with_controller.request + ) + assert "error" in response.data + assert "Failed to decode PUT request body" in response.data["error"] + assert response.status_code == 400 - def test_decorated_method_no_accept(self, test_api_decorator): - """ - Test that a decorated method responds to a request with no Accept header with the - appropriate default response type. - """ - request = Mock() - request.data = 'Some text' - request.headers = {'Content-Type': 'text/plain'} + def test_api_adapter_with_controller_post( + self, test_api_adapter_with_controller + ): + """Test that the controller create method is called and returns the correct response.""" + request = test_api_adapter_with_controller.request + request.body = b'{"key": "value"}' + + response = test_api_adapter_with_controller.adapter.post( + test_api_adapter_with_controller.path, + test_api_adapter_with_controller.request + ) + assert response.data == {"key": "value"} + assert response.status_code == 200 - response = test_api_decorator.decorated_method(test_api_decorator.path, request) - assert response.status_code == test_api_decorator.response_code - assert response.content_type == test_api_decorator.response_type_json - assert response.data == test_api_decorator.response_data_json + def test_api_adapter_with_controller_post_error( + self, test_api_adapter_with_controller + ): + """Test that the controller create method raises an error and is handled correctly.""" + path = test_api_adapter_with_controller.path + '/error' + request = test_api_adapter_with_controller.request + request.body = b'{"key": "value"}' + + response = test_api_adapter_with_controller.adapter.post( + path, test_api_adapter_with_controller.request + ) + assert response.data == {"error": "Test error raised from controller create method"} + assert response.status_code == 400 - def test_decorated_method_no_accept_no_default(self, test_api_decorator): - """ - Test that a decorated method with no default responsds - """ - request = Mock() - request.data = 'Some text' - request.headers = {'Content-Type': 'text/plain'} - - response = test_api_decorator.decorated_method_without_default(test_api_decorator.path, request) - assert response.status_code == test_api_decorator.response_code - assert response.content_type == test_api_decorator.response_type_plain - assert response.data == test_api_decorator.response_data_plain + def test_api_adapter_with_controller_post_decode_error( + self, test_api_adapter_with_controller + ): + """Test that a decode error in the POST request body is handled correctly.""" + request = test_api_adapter_with_controller.request + request.body = b'{"key": "value"' # Malformed JSON + + response = test_api_adapter_with_controller.adapter.post( + test_api_adapter_with_controller.path, + test_api_adapter_with_controller.request + ) + assert "error" in response.data + assert "Failed to decode POST request body" in response.data["error"] + assert response.status_code == 400 + + def test_api_adapter_with_controller_delete( + self, test_api_adapter_with_controller + ): + """Test that the controller delete method is called and returns the correct response.""" + response = test_api_adapter_with_controller.adapter.delete( + test_api_adapter_with_controller.path, + test_api_adapter_with_controller.request + ) + assert response.data == { + "response": f"delete called on path {test_api_adapter_with_controller.path}" + } + assert response.status_code == 200 + + def test_api_adapter_with_controller_delete_error( + self, test_api_adapter_with_controller + ): + """Test that the controller delete method raises an error and is handled correctly.""" + path = test_api_adapter_with_controller.path + '/error' + + response = test_api_adapter_with_controller.adapter.delete( + path, test_api_adapter_with_controller.request + ) + assert response.data == {"error": "Test error raised from controller delete method"} + assert response.status_code == 400 diff --git a/tests/adapters/test_async_adapter.py b/tests/adapters/test_async_adapter.py index 11ec1678..04cb4ca4 100644 --- a/tests/adapters/test_async_adapter.py +++ b/tests/adapters/test_async_adapter.py @@ -1,24 +1,29 @@ +from unittest.mock import AsyncMock, Mock + import pytest +from odin_control._version import __version__ from odin_control.adapters.async_adapter import AsyncApiAdapter -from unittest.mock import Mock +from odin_control.adapters.async_base_controller import AsyncBaseController, BaseError + class AsyncApiAdapterTestFixture(object): - def __init__(self): + def __init__(self,adapter_cls): self.adapter_options = { 'test_option_float' : 1.234, 'test_option_str' : 'value', 'test_option_int' : 4567. } - self.adapter = AsyncApiAdapter(**self.adapter_options) + self.adapter = adapter_cls(**self.adapter_options) self.path = '/api/async_path' self.request = Mock() - self.request.headers = {'Accept': '*/*', 'Content-Type': 'text/plain'} + self.request.headers = {'Accept': '*/*', 'Content-Type': 'application/json'} + self.request.body = b'{}' @pytest.fixture(scope="class") def test_async_api_adapter(): - test_async_api_adapter = AsyncApiAdapterTestFixture() + test_async_api_adapter = AsyncApiAdapterTestFixture(AsyncApiAdapter) yield test_async_api_adapter class TestAsyncApiAdapter(): @@ -26,64 +31,400 @@ class TestAsyncApiAdapter(): @pytest.mark.asyncio async def test_async_adapter_get(self, test_async_api_adapter): - """ - Test the the adapter responds to a GET request correctly by returning a 400 code and - appropriate message. This is due to the base adapter not implementing the methods. + """Test that the adapter responds to a GET request correctly by returning a 405 code and + appropriate message due to the lack of a controller. """ response = await test_async_api_adapter.adapter.get( test_async_api_adapter.path, test_async_api_adapter.request) - assert response.data == 'GET method not implemented by AsyncApiAdapter' - assert response.status_code == 400 + assert response.data == {'error': 'Adapter AsyncApiAdapter has no controller configured'} + assert response.status_code == 405 @pytest.mark.asyncio async def test_async_adapter_post(self, test_async_api_adapter): - """ - Test the the adapter responds to a POST request correctly by returning a 400 code and - appropriate message. This is due to the base adapter not implementing the methods. + """Test that the adapter responds to a POST request correctly by returning a 405 code and + appropriate message due to the lack of a controller. """ response = await test_async_api_adapter.adapter.post( test_async_api_adapter.path, test_async_api_adapter.request) - assert response.data == 'POST method not implemented by AsyncApiAdapter' - assert response.status_code == 400 + assert response.data == {'error': 'Adapter AsyncApiAdapter has no controller configured'} + assert response.status_code == 405 @pytest.mark.asyncio async def test_async_adapter_put(self, test_async_api_adapter): - """ - Test the the adapter responds to a PUT request correctly by returning a 400 code and - appropriate message. This is due to the base adapter not implementing the methods. + """Test that the adapter responds to a PUT request correctly by returning a 405 code and + appropriate message due to the lack of a controller. """ response = await test_async_api_adapter.adapter.put( test_async_api_adapter.path, test_async_api_adapter.request) - assert response.data == 'PUT method not implemented by AsyncApiAdapter' - assert response.status_code == 400 + assert response.data == {'error': 'Adapter AsyncApiAdapter has no controller configured'} + assert response.status_code == 405 @pytest.mark.asyncio async def test_adapter_delete(self, test_async_api_adapter): - """ - Test the the adapter responds to a DELETE request correctly by returning a 400 code and - appropriate message. This is due to the base adapter not implementing the methods. + """Test that the adapter responds to a DELETE request correctly by returning a 405 code and + appropriate message due to the lack of a controller. """ response = await test_async_api_adapter.adapter.delete( test_async_api_adapter.path, test_async_api_adapter.request) - assert response.data == 'DELETE method not implemented by AsyncApiAdapter' - assert response.status_code == 400 + assert response.data == {'error': 'Adapter AsyncApiAdapter has no controller configured'} + assert response.status_code == 405 @pytest.mark.asyncio - async def test_adapter_initialize(self, test_async_api_adapter): - """Test the the adapter initialize function runs without error.""" - raised = False - try: - await test_async_api_adapter.adapter.initialize(None) - except: - raised = True - assert not raised + async def test_adapter_initialize(self, test_async_api_adapter, caplog): + """"Test that the initialize method logs the correct message when no controller is set.""" + with caplog.at_level('DEBUG'): + await test_async_api_adapter.adapter.initialize({}) + assert any(record.levelname == 'WARNING' for record in caplog.records) + assert f"{test_async_api_adapter.adapter.name} initialize called" in caplog.text + assert f"{test_async_api_adapter.adapter.name} " + "has no controller configured" in caplog.text @pytest.mark.asyncio - async def test_adapter_cleanup(self, test_async_api_adapter): - """Test the the adapter cleanup function runs without error.""" - raised = False - try: + async def test_adapter_cleanup(self, test_async_api_adapter, caplog): + """Test that the cleanup function logs the correct message when no controller is set.""" + with caplog.at_level('DEBUG'): await test_async_api_adapter.adapter.cleanup() - except: - raised = True - assert not raised + assert any(record.levelname == 'WARNING' for record in caplog.records) + assert f"{test_async_api_adapter.adapter.name} cleanup called" in caplog.text + assert f"{test_async_api_adapter.adapter.name} " + "has no controller configured" in caplog.text + +@pytest.fixture(scope="class") +def test_async_api_adapter_with_incomplete_controller(): + """Simple test fixture used for testing ApiAdapter with an incomplete controller.""" + test_async_api_adapter = AsyncApiAdapterTestFixture(AsyncApiAdapter) + test_async_api_adapter.adapter.controller = AsyncMock() + test_async_api_adapter.adapter.controller.initialize.side_effect = AttributeError + test_async_api_adapter.adapter.controller.cleanup.side_effect = AttributeError + test_async_api_adapter.adapter.controller.set.side_effect = NotImplementedError + test_async_api_adapter.adapter.controller.create.side_effect = NotImplementedError + test_async_api_adapter.adapter.controller.delete.side_effect = NotImplementedError + + yield test_async_api_adapter + +class TestAsyncApiAdapterWithIncompleteController(): + """Class to test the ApiAdapter object with a controller.""" + + @pytest.mark.asyncio + async def test_adapter_with_controller_initialize( + self, test_async_api_adapter_with_incomplete_controller, caplog + ): + """Test that an adapter with a controller catched a missing controller initialize method.""" + with caplog.at_level('DEBUG'): + await test_async_api_adapter_with_incomplete_controller.adapter.initialize({}) + assert any(record.levelname == 'WARNING' for record in caplog.records) + assert (f"{test_async_api_adapter_with_incomplete_controller.adapter.name} " + f"controller has no initialize method" in caplog.text) + + @pytest.mark.asyncio + async def test_adapter_with_controller_cleanup( + self, test_async_api_adapter_with_incomplete_controller, caplog + ): + """Test that an adapter with a controller catched a missing controller cleanup method.""" + with caplog.at_level('DEBUG'): + await test_async_api_adapter_with_incomplete_controller.adapter.cleanup() + assert any(record.levelname == 'WARNING' for record in caplog.records) + assert (f"{test_async_api_adapter_with_incomplete_controller.adapter.name} " + f"controller has no cleanup method" in caplog.text) + + @pytest.mark.asyncio + async def test_adapter_with_controller_get( + self, test_async_api_adapter_with_incomplete_controller + ): + """Test that an adapter with a controller calls the controller get method.""" + await test_async_api_adapter_with_incomplete_controller.adapter.get( + test_async_api_adapter_with_incomplete_controller.path, + test_async_api_adapter_with_incomplete_controller.request + ) + assert test_async_api_adapter_with_incomplete_controller.adapter.controller.get.called + + @pytest.mark.asyncio + async def test_adapter_with_controller_put( + self, test_async_api_adapter_with_incomplete_controller + ): + """Test that a PUT to an adapter with an incomplete controller (no set method) handles + NotImplementedError. + """ + response = await test_async_api_adapter_with_incomplete_controller.adapter.put( + test_async_api_adapter_with_incomplete_controller.path, + test_async_api_adapter_with_incomplete_controller.request + ) + assert response.data == { + "error": f"{test_async_api_adapter_with_incomplete_controller.adapter.name} " + "does not support PUT requests" + } + assert response.status_code == 405 + + @pytest.mark.asyncio + async def test_adapter_with_controller_post( + self, test_async_api_adapter_with_incomplete_controller + ): + """Test that a POST to an adapter with an incomplete controller (no create method) handles + NotImplementedError. + """ + response = await test_async_api_adapter_with_incomplete_controller.adapter.post( + test_async_api_adapter_with_incomplete_controller.path, + test_async_api_adapter_with_incomplete_controller.request + ) + assert response.data == { + "error": f"{test_async_api_adapter_with_incomplete_controller.adapter.name} " + "does not support POST requests" + } + assert response.status_code == 405 + + @pytest.mark.asyncio + async def test_adapter_with_controller_delete( + self, test_async_api_adapter_with_incomplete_controller + ): + """Test that a DELETE to an adapter with an incomplete controller (no delete method) + handles NotImplementedError. + """ + response = await test_async_api_adapter_with_incomplete_controller.adapter.delete( + test_async_api_adapter_with_incomplete_controller.path, + test_async_api_adapter_with_incomplete_controller.request + ) + assert response.data == { + "error": f"{test_async_api_adapter_with_incomplete_controller.adapter.name} " + "does not support DELETE requests" + } + assert response.status_code == 405 + +class AsyncApiAdapterTestController(AsyncBaseController): + """Simple test controller for testing ApiAdapter with a controller.""" + def __init__(self, options): + self.options = options + + async def get(self, path, with_metadata=False): + """Mock get method.""" + if 'error' in path: + raise BaseError("Test error raised from controller get method") + + return {"response": f"get called on path {path} with_metadata={with_metadata}"} + + async def set(self, path, data): + """Mock set method.""" + if 'error' in path: + raise BaseError("Test error raised from controller set method") + + return {"response": f"set called on path {path} with data={data}"} + + async def create(self, path, data): + """Mock create method.""" + if 'error' in path: + raise BaseError("Test error raised from controller create method") + + return data + + async def delete(self, path): + """Mock delete method.""" + if 'error' in path: + raise BaseError("Test error raised from controller delete method") + + return {"response": f"delete called on path {path}"} + +class AsyncApiAdapterTestAdapter(AsyncApiAdapter): + + version = __version__ + controller_cls = AsyncApiAdapterTestController + error_cls = BaseError + +@pytest.fixture(scope="class") +def test_async_api_adapter_with_controller(): + """Simple test fixture used for testing ApiAdapter with a controller.""" + test_async_api_adapter = AsyncApiAdapterTestFixture(AsyncApiAdapterTestAdapter) + yield test_async_api_adapter + +class TestAsyncApiAdapterWithController(): + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_init(self): + """Test that an adapter with a controller is awaited correctly on initialization.""" + adapter = await AsyncApiAdapterTestAdapter(option1='value1', option2='value2') + assert isinstance(adapter.controller, AsyncApiAdapterTestController) + assert adapter.controller.options['option1'] == 'value1' + assert adapter.controller.options['option2'] == 'value2' + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_initialize( + self, test_async_api_adapter_with_controller, caplog + ): + """Test that an adapter with a controller calls the controller initialize method.""" + with caplog.at_level('DEBUG'): + try: + await test_async_api_adapter_with_controller.adapter.initialize({}) + except Exception as exc: + pytest.fail(f"Adapter initialize raised an exception: {exc}") + assert any(record.levelname == 'DEBUG' for record in caplog.records) + assert (f"{test_async_api_adapter_with_controller.adapter.name} initialize called" in + caplog.text) + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_cleanup( + self, test_async_api_adapter_with_controller, caplog + ): + """Test that an adapter with a controller calls the controller cleanup method.""" + with caplog.at_level('DEBUG'): + try: + await test_async_api_adapter_with_controller.adapter.cleanup() + except Exception as exc: + pytest.fail(f"Adapter cleanup raised an exception: {exc}") + assert any(record.levelname == 'DEBUG' for record in caplog.records) + assert (f"{test_async_api_adapter_with_controller.adapter.name} cleanup called" in + caplog.text) + + @pytest.mark.parametrize("with_metadata", [False, True]) + @pytest.mark.asyncio + async def test_async_adapter_with_controller_get( + self, test_async_api_adapter_with_controller, with_metadata + ): + """Test that the controller get method is called and returns the correct response.""" + request = test_async_api_adapter_with_controller.request + request.headers['Accept'] = f'application/json; metadata={str(with_metadata).lower()}' + + response = await test_async_api_adapter_with_controller.adapter.get( + test_async_api_adapter_with_controller.path, request + ) + assert response.data == { + "response": ( + f"get called on path {test_async_api_adapter_with_controller.path} " + f"with_metadata={with_metadata}" + ) + } + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_get_error( + self, test_async_api_adapter_with_controller + ): + """Test that the controller get method raises an error and is handled correctly.""" + path = test_async_api_adapter_with_controller.path + '/error' + response = await test_async_api_adapter_with_controller.adapter.get( + path, test_async_api_adapter_with_controller.request + ) + assert response.data == {"error": "Test error raised from controller get method"} + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_put( + self, test_async_api_adapter_with_controller + ): + """Test that the controller set method is called and returns the correct response.""" + request = test_async_api_adapter_with_controller.request + request.data = b'{"key": "value"}' + + response = await test_async_api_adapter_with_controller.adapter.put( + test_async_api_adapter_with_controller.path, + test_async_api_adapter_with_controller.request + ) + assert response.data == { + "response": ( + f"get called on path {test_async_api_adapter_with_controller.path} with_metadata=False" + ) + } + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_put_error( + self, test_async_api_adapter_with_controller + ): + """Test that the controller set method raises an error and is handled correctly.""" + path = test_async_api_adapter_with_controller.path + '/error' + request = test_async_api_adapter_with_controller.request + request.data = b'{"key": "value"}' + + response = await test_async_api_adapter_with_controller.adapter.put( + path, test_async_api_adapter_with_controller.request + ) + assert response.data == {"error": "Test error raised from controller set method"} + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_put_decode_error( + self, test_async_api_adapter_with_controller + ): + """Test that a decode error in the PUT request body is handled correctly.""" + request = test_async_api_adapter_with_controller.request + request.body = b'{"key": "value"' # Malformed JSON + + response = await test_async_api_adapter_with_controller.adapter.put( + test_async_api_adapter_with_controller.path, + test_async_api_adapter_with_controller.request + ) + assert "error" in response.data + assert "Failed to decode PUT request body" in response.data["error"] + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_post( + self, test_async_api_adapter_with_controller + ): + """Test that the controller create method is called and returns the correct response.""" + request = test_async_api_adapter_with_controller.request + request.body = b'{"key": "value"}' + + response = await test_async_api_adapter_with_controller.adapter.post( + test_async_api_adapter_with_controller.path, + test_async_api_adapter_with_controller.request + ) + assert response.data == {"key": "value"} + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_post_error( + self, test_async_api_adapter_with_controller + ): + """Test that the controller create method raises an error and is handled correctly.""" + path = test_async_api_adapter_with_controller.path + '/error' + request = test_async_api_adapter_with_controller.request + request.body = b'{"key": "value"}' + + response = await test_async_api_adapter_with_controller.adapter.post( + path, test_async_api_adapter_with_controller.request + ) + assert response.data == {"error": "Test error raised from controller create method"} + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_post_decode_error( + self, test_async_api_adapter_with_controller + ): + """Test that a decode error in the POST request body is handled correctly.""" + request = test_async_api_adapter_with_controller.request + request.body = b'{"key": "value"' # Malformed JSON + + response = await test_async_api_adapter_with_controller.adapter.post( + test_async_api_adapter_with_controller.path, + test_async_api_adapter_with_controller.request + ) + assert "error" in response.data + assert "Failed to decode POST request body" in response.data["error"] + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_delete( + self, test_async_api_adapter_with_controller + ): + """Test that the controller delete method is called and returns the correct response.""" + response = await test_async_api_adapter_with_controller.adapter.delete( + test_async_api_adapter_with_controller.path, + test_async_api_adapter_with_controller.request + ) + assert response.data == { + "response": f"delete called on path {test_async_api_adapter_with_controller.path}" + } + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_async_adapter_with_controller_delete_error( + self, test_async_api_adapter_with_controller + ): + """Test that the controller delete method raises an error and is handled correctly.""" + path = test_async_api_adapter_with_controller.path + '/error' + + response = await test_async_api_adapter_with_controller.adapter.delete( + path, test_async_api_adapter_with_controller.request + ) + assert response.data == {"error": "Test error raised from controller delete method"} + assert response.status_code == 400 + + diff --git a/tests/adapters/test_async_base_controller.py b/tests/adapters/test_async_base_controller.py new file mode 100644 index 00000000..cdb84da3 --- /dev/null +++ b/tests/adapters/test_async_base_controller.py @@ -0,0 +1,108 @@ +import pytest + +from odin_control.adapters.async_base_controller import AsyncBaseController + + +class DerivedAsyncTestController(AsyncBaseController): + def __init__(self, options): + super().__init__(options) + self.options = options + self.awaited = False + + async def awaitable_attribute(): + self.awaited = True + return "awaited value" + + self.awaited_value = awaitable_attribute() + + async def initialize(self, adapters): + await super().initialize(adapters) + + async def get(self, path, with_metadata = False): + + return {"path": path, "with_metadata": with_metadata, "value": self.awaited_value} + + async def cleanup(self): + await super().cleanup() + self.options = None + self.adapters = None + +@pytest.fixture +def derived_async_test_controller(): + + return DerivedAsyncTestController(options={}) + +class TestAsyncBaseController: + + def test_async_base_controller_is_abstract(self): + """Test that AsyncBaseController cannot be instantiated directly.""" + from odin_control.adapters.async_base_controller import AsyncBaseController + + with pytest.raises(TypeError) as excinfo: + AsyncBaseController({}) + + assert "Can't instantiate abstract class AsyncBaseController" in str(excinfo.value) + + @pytest.mark.asyncio + async def test_async_controller_awaitable_init(self): + """Test that AsyncBaseController derived class can be awaited to resolve async attributes.""" + controller = await DerivedAsyncTestController(options={}) + assert controller.awaited + + @pytest.mark.asyncio + async def test_derived_async_controller_initialization(self, derived_async_test_controller): + """Test that DerivedAsyncTestController initializes adapter info correctly.""" + adapters = {"test_adapter": object(), "another_adapter": object()} + derived_async_test_controller = await derived_async_test_controller + await derived_async_test_controller.initialize(adapters) + assert "test_adapter" in derived_async_test_controller.adapters + assert "another_adapter" in derived_async_test_controller.adapters + + @pytest.mark.asyncio + async def test_derived_async_controller_get_method(self, derived_async_test_controller): + """Test that DerivedAsyncTestController get method works as expected.""" + derived_async_test_controller = await derived_async_test_controller + + path = "/test/path" + result = await derived_async_test_controller.get(path, with_metadata=True) + + assert result["path"] == path + assert result["with_metadata"] is True + + @pytest.mark.asyncio + async def test_derived_async_controller_cleanup(self, derived_async_test_controller): + """Test that DerivedAsyncTestController cleanup method resets state.""" + derived_async_test_controller = await derived_async_test_controller + + adapters = {"test_adapter": object()} + await derived_async_test_controller.initialize(adapters) + await derived_async_test_controller.cleanup() + + assert derived_async_test_controller.options is None + assert derived_async_test_controller.adapters is None + + @pytest.mark.asyncio + async def test_derived_controller_set_not_implemented(self, derived_async_test_controller): + """Test that DerivedAsyncTestController set method raises NotImplementedError.""" + derived_async_test_controller = await derived_async_test_controller + + with pytest.raises(NotImplementedError): + await derived_async_test_controller.set("/test/path", data={}) + + @pytest.mark.asyncio + async def test_derived_controller_create_not_implemented(self, derived_async_test_controller): + """Test that DerivedAsyncTestController create method raises NotImplementedError.""" + derived_async_test_controller = await derived_async_test_controller + + with pytest.raises(NotImplementedError): + await derived_async_test_controller.create("/test/path", data={}) + + @pytest.mark.asyncio + async def test_derived_controller_delete_not_implemented(self, derived_async_test_controller): + """Test that DerivedAsyncTestController delete method raises NotImplementedError.""" + derived_async_test_controller = await derived_async_test_controller + + with pytest.raises(NotImplementedError): + await derived_async_test_controller.delete("/test/path") + + diff --git a/tests/adapters/test_async_proxy.py b/tests/adapters/test_async_proxy.py index 7f7b0eed..8abc9108 100644 --- a/tests/adapters/test_async_proxy.py +++ b/tests/adapters/test_async_proxy.py @@ -180,8 +180,8 @@ def clear_access_counts(self): @asyncio_fixture_decorator async def async_proxy_adapter_fixture(): async_proxy_adapter_test = await AsyncProxyAdapterTestFixture() - adapters = [async_proxy_adapter_test] - await async_proxy_adapter_test.adapter.initialize([adapters]) + adapters = {"async_proxy": async_proxy_adapter_test.adapter } + await async_proxy_adapter_test.adapter.initialize(adapters) yield async_proxy_adapter_test await async_proxy_adapter_test.adapter.cleanup() diff --git a/tests/adapters/test_base_controller.py b/tests/adapters/test_base_controller.py new file mode 100644 index 00000000..72122869 --- /dev/null +++ b/tests/adapters/test_base_controller.py @@ -0,0 +1,78 @@ +import pytest + +from odin_control.adapters.base_controller import BaseController + +class DerivedTestController(BaseController): + def __init__(self, options): + super().__init__(options) + self.options = options + + def initialize(self, adapters): + super().initialize(adapters) + + def get(self, path, with_metadata = False): + + return {"path": path, "with_metadata": with_metadata} + + def cleanup(self): + super().cleanup() + self.options = None + self.adapters = None + +@pytest.fixture +def derived_test_controller(): + + test_controller = DerivedTestController(options={}) + yield test_controller + +class TestBaseController: + + def test_base_controller_is_abstract(self): + """Test that BaseController cannot be instantiated directly.""" + from odin_control.adapters.base_controller import BaseController + + with pytest.raises(TypeError) as excinfo: + BaseController({}) + + assert "Can't instantiate abstract class BaseController" in str(excinfo.value) + + def test_derived_controller_initizialization(self, derived_test_controller): + """Test that DerivedTestController initializes adapter info correctly.""" + adapters = {"test_adapter": object(), "another_adapter": object()} + derived_test_controller.initialize(adapters) + + assert "test_adapter" in derived_test_controller.adapters + assert "another_adapter" in derived_test_controller.adapters + + def test_derived_controller_get_method(self, derived_test_controller): + """Test that DerivedTestController get method works as expected.""" + path = "/test/path" + result = derived_test_controller.get(path, with_metadata=True) + + assert result["path"] == path + assert result["with_metadata"] is True + + def test_derived_controller_cleanup(self, derived_test_controller): + """Test that DerivedTestController cleanup method resets state.""" + adapters = {"test_adapter": object()} + derived_test_controller.initialize(adapters) + + derived_test_controller.cleanup() + + assert derived_test_controller.options is None + assert derived_test_controller.adapters is None + + def test_derived_controller_set_not_implemented(self, derived_test_controller): + """Test that DerivedTestController set method raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + derived_test_controller.set("/test/path", {"key": "value"}) + + def test_derived_controller_create_not_implemented(self, derived_test_controller): + """Test that DerivedTestController create method raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + derived_test_controller.create("/test/path", {"key": "value"}) + + def test_derived_controller_delete_not_implemented(self, derived_test_controller): + """Test that DerivedTestController delete method raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + derived_test_controller.delete("/test/path") diff --git a/tests/adapters/test_dummy.py b/tests/adapters/test_dummy.py index ae147729..0230a432 100644 --- a/tests/adapters/test_dummy.py +++ b/tests/adapters/test_dummy.py @@ -47,7 +47,7 @@ def test_adapter_put(self, test_dummy_adapter): def test_adapter_delete(self, test_dummy_adapter): """Test that a call to the DELETE method of the dummy adapter returns the correct response.""" - response = test_dummy_adapter.adapter.delete(test_dummy_adapter.path, + response = test_dummy_adapter.adapter.delete(test_dummy_adapter.path, test_dummy_adapter.request) assert response.data == 'DummyAdapter: DELETE on path {}'.format(test_dummy_adapter.path) assert response.status_code == 200 diff --git a/tests/adapters/test_request.py b/tests/adapters/test_request.py new file mode 100644 index 00000000..04354171 --- /dev/null +++ b/tests/adapters/test_request.py @@ -0,0 +1,53 @@ +"""Test cases for ODIN API adapter request classes.""" + +from odin_control.adapters.request import ApiAdapterRequest + + +class TestAdapterRequest(): + """Class to test behaviour of the AdapterRequest object.""" + + def test_simple_request(self): + """Test that a simple request is populated with the correct fields.""" + data = "This is some simple request data" + request = ApiAdapterRequest(data) + assert request.body == data + assert request.content_type == 'application/vnd.odin-native' + assert request.response_type == "application/json" + expected_headers = { + "Content-Type": 'application/vnd.odin-native', + "Accept": "application/json" + } + assert request.headers == expected_headers + + def test_request_with_types(self): + """Test that a request with the correct types is correctly populated.""" + data = '{\'some_json_value\' : 1.234}' + content_type = 'application/json' + request_type = "application/vnd.odin-native" + request = ApiAdapterRequest(data, content_type=content_type, accept=request_type) + assert request.body == data + assert request.content_type == content_type + assert request.response_type == request_type + assert request.headers == { + "Content-Type": content_type, + "Accept": request_type} + + def test_set_content(self): + """Test that explicitly setting fields on the request works correctly.""" + data = '{\'some_json_value\' : 1.234}' + content_type = 'application/json' + request_type = "application/vnd.odin-native" + remote_ip = "127.0.0.1" + + request = ApiAdapterRequest(data) + request.set_content_type(content_type) + request.set_response_type(request_type) + request.set_remote_ip(remote_ip) + + assert request.body == data + assert request.content_type == content_type + assert request.response_type == request_type + assert request.remote_ip == remote_ip + assert request.headers == { + "Content-Type": content_type, + "Accept": request_type} diff --git a/tests/adapters/test_response.py b/tests/adapters/test_response.py new file mode 100644 index 00000000..1b9e3d47 --- /dev/null +++ b/tests/adapters/test_response.py @@ -0,0 +1,47 @@ +"""Test cases for ODIN API adapter response classes.""" + +from odin_control.adapters.response import ApiAdapterResponse + + +class TestApiAdapterResponse(): + """Class to test behaviour of the ApiAdapterResponse object.""" + + def test_simple_response(self): + """Test that a simple rewponse contains the correct default values in fields.""" + data = 'This is a simple response' + response = ApiAdapterResponse(data) + + assert response.data == data + assert response.content_type == 'text/plain' + assert response.status_code == 200 + + def test_response_with_type_and_code(self): + """ + Test that a response with explicit content type and status codes + correctly populates the fields. + """ + data = '{\'some_json_value\' : 1.234}' + content_type = 'application/json' + status_code = 400 + + response = ApiAdapterResponse(data, content_type=content_type, status_code=status_code) + assert response.data == data + assert response.content_type == content_type + assert response.status_code == status_code + + def test_response_with_set_calls(self): + """ + Test the creating a default response and then explicitly setting the type and code + correctly populates the fields. + """ + data = '{\'some_json_value\' : 1.234}' + content_type = 'application/json' + status_code = 400 + + response = ApiAdapterResponse(data) + response.set_content_type(content_type) + response.set_status_code(status_code) + + assert response.data == data + assert response.content_type == content_type + assert response.status_code == status_code diff --git a/tests/adapters/test_system_info.py b/tests/adapters/test_system_info.py index 6d517262..963a9daa 100644 --- a/tests/adapters/test_system_info.py +++ b/tests/adapters/test_system_info.py @@ -7,12 +7,12 @@ from unittest.mock import Mock -from odin_control.adapters.system_info import SystemInfoAdapter, SystemInfo +from odin_control.adapters.system_info import SystemInfoAdapter, SystemInfoController @pytest.fixture(scope="class") def test_system_info(): """Fixture for use in testing the SystemInfo class.""" - test_system_info = SystemInfo() + test_system_info = SystemInfoController() yield test_system_info @@ -33,6 +33,7 @@ def __init__(self): self.path = '' self.request = Mock() self.request.headers = {'Accept': 'application/json', 'Content-Type': 'application/json'} + self.request.body = b'{}' @pytest.fixture(scope="class") @@ -68,23 +69,38 @@ def test_adapter_get_bad_path(self, test_sysinfo_adapter): def test_adapter_put(self, test_sysinfo_adapter): """Test that a PUT call to the adapter returns the appropriate response.""" expected_response = { - 'response': 'SystemInfoAdapter: PUT on path {}'.format(test_sysinfo_adapter.path) + 'error': 'SystemInfoAdapter does not support PUT requests' } response = test_sysinfo_adapter.adapter.put( test_sysinfo_adapter.path, test_sysinfo_adapter.request) assert response.data == expected_response - assert response.status_code == 200 + assert response.status_code == 405 + + def test_adapter_post(self, test_sysinfo_adapter): + """Test that a POST call to the adapter returns the appropriate response.""" + expected_response = { + 'error': 'SystemInfoAdapter does not support POST requests' + } + + response = test_sysinfo_adapter.adapter.post( + test_sysinfo_adapter.path, test_sysinfo_adapter.request) + + assert response.data == expected_response + assert response.status_code == 405 def test_adapter_delete(self, test_sysinfo_adapter): """Test that a DELETE call to the adapter returns an appropriate response.""" + expected_response = { + 'error': 'SystemInfoAdapter does not support DELETE requests' + } + response = test_sysinfo_adapter.adapter.delete( test_sysinfo_adapter.path, test_sysinfo_adapter.request) - assert response.data == 'SystemInfoAdapter: DELETE on path {}'.format( - test_sysinfo_adapter.path) - assert response.status_code == 200 + assert response.data == expected_response + assert response.status_code == 405 def test_adapter_put_bad_content_type(self, test_sysinfo_adapter): """Test that PUT call with a bad content type returns the appropriate error.""" @@ -116,8 +132,8 @@ def __init__(self): self.path = '' self.request = Mock() self.request.headers = { - 'Accept': 'application/json;metadata=True', - 'Content-Type': 'application/json' + 'Accept': 'application/json;metadata=True', + 'Content-Type': 'application/json' } self.response = self.adapter.get(self.path, self.request) self.top_level_metadata = ('name', 'description') @@ -147,7 +163,7 @@ def test_adapter_params_have_toplevel_metadata(self, test_sysinfo_metadata): def test_adapter_params_have_metadata(self, test_sysinfo_metadata): """ - Test that all parameters exposed by the adapter have value, type and writeable + Test that all parameters exposed by the adapter have value, type and writeable metadata fields. """ for (param, val) in test_sysinfo_metadata.response.data.items(): @@ -169,4 +185,4 @@ def test_subtree_has_metadata(self, test_sysinfo_metadata): if param not in test_sysinfo_metadata.top_level_metadata: assert 'value' in val assert 'type' in val - assert 'writeable' in val \ No newline at end of file + assert 'writeable' in val diff --git a/tests/adapters/test_system_status.py b/tests/adapters/test_system_status.py index 217e96ea..6bfac953 100644 --- a/tests/adapters/test_system_status.py +++ b/tests/adapters/test_system_status.py @@ -11,7 +11,7 @@ from unittest.mock import Mock, patch -from odin_control.adapters.system_status import SystemStatusAdapter, SystemStatus +from odin_control.adapters.system_status import SystemStatusAdapter, SystemStatusController from odin_control.adapters.parameter_tree import ParameterTreeError from tests.utils import log_message_seen @@ -74,7 +74,7 @@ def mock_process_iter(attrs=None, ad_value=None): scoped_patcher.setattr(psutil, "process_iter", mock_process_iter) - self.system_status = SystemStatus( + self.system_status = SystemStatusController( interfaces=self.interfaces, disks=self.disks, processes=self.processes, rate=self.rate) @@ -179,7 +179,7 @@ def test_update_loop_exception(self, test_system_status): def test_default_rate_argument(self, test_system_status): """Test that that the default monitoring rate argument is applied correctly.""" - temp_system_status = SystemStatus( + temp_system_status = SystemStatusController( interfaces=test_system_status.interfaces, disks=test_system_status.disks, processes=test_system_status.processes, @@ -285,6 +285,7 @@ def __init__(self): self.path = '' self.request = Mock() self.request.headers = {'Accept': 'application/json', 'Content-Type': 'application/json'} + self.request.body = b'{}' @pytest.fixture(scope="class") @@ -319,23 +320,38 @@ def test_adapter_get_bad_path(self, test_sysstatus_adapter): def test_adapter_put(self, test_sysstatus_adapter): """Test that a PUT call to the adapter returns the appropriate response.""" expected_response = { - 'response': 'SystemStatusAdapter: PUT on path {}'.format(test_sysstatus_adapter.path) + 'error': 'SystemStatusAdapter does not support PUT requests' } response = test_sysstatus_adapter.adapter.put( test_sysstatus_adapter.path, test_sysstatus_adapter.request) assert response.data == expected_response - assert response.status_code == 200 + assert response.status_code == 405 + + def test_adapter_post(self, test_sysstatus_adapter): + """Test that a PUT call to the adapter returns the appropriate response.""" + expected_response = { + 'error': 'SystemStatusAdapter does not support POST requests' + } + + response = test_sysstatus_adapter.adapter.post( + test_sysstatus_adapter.path, test_sysstatus_adapter.request) + + assert response.data == expected_response + assert response.status_code == 405 def test_adapter_delete(self, test_sysstatus_adapter): """Test that a DELETE call to the adapter returns the appropriate response.""" + expected_response = { + 'error': 'SystemStatusAdapter does not support DELETE requests' + } + response = test_sysstatus_adapter.adapter.delete( test_sysstatus_adapter.path, test_sysstatus_adapter.request) - assert response.data == 'SystemStatusAdapter: DELETE on path {}'.format( - test_sysstatus_adapter.path) - assert response.status_code == 200 + assert response.data == expected_response + assert response.status_code == 405 def test_adapter_put_bad_content_type(self, test_sysstatus_adapter): """Test that a PUT call with a bad content type returns the appropriate 415 error.""" diff --git a/tests/adapters/test_util.py b/tests/adapters/test_util.py new file mode 100644 index 00000000..79ee6679 --- /dev/null +++ b/tests/adapters/test_util.py @@ -0,0 +1,290 @@ +from unittest.mock import Mock + +import pytest +from tornado.concurrent import Future + +from odin_control.adapters.response import ApiAdapterResponse +from odin_control.adapters.util import ( + request_types, + require_controller, + response_types, + wants_metadata, + wrap_result, +) + + +class TestWrapResult(): + """Class to test the wrap_result utility function.""" + + def test_wrap_result_sync(self): + """Test that wrap_result returns the result directly when async is False.""" + result = 123 + wrapped = wrap_result(result, False) + assert wrapped == result + + def test_wrap_result_async(self): + """Test that wrap_result correctly wraps results in a future when async is True.""" + result = 123 + wrapped = wrap_result(result, True) + assert isinstance(wrapped, Future) + assert wrapped.result() == result + + @pytest.mark.asyncio + async def test_awaited_wrap_result(self): + """Test that wrap_result returns an awaitable future when async is True.""" + result = 123 + wrapped = wrap_result(result, True) + assert isinstance(wrapped, Future) + awaited_result = await wrapped + assert awaited_result == result + + +class ApiMethodDecoratorsTestFixture(): + """Container class used in fixtures for testing adapter method decorators.""" + + def __init__(self, with_controller=False): + """Initialise request and responses for testing method decorators.""" + + self.name = "test" + self.path = '/api/path' + self.response_code = 200 + self.response_type_plain = 'text/plain' + self.response_data_plain = 'Plain text response' + + self.response_type_json = 'application/json' + self.response_data_json = {'response': 'JSON response'} + + self.is_async = False + + if with_controller: + self.controller = Mock() + else: + self.controller = None + + @request_types('application/json', 'text/plain') + @response_types('application/json', 'text/plain', default='application/json') + def decorated_method(self, path, request): + """Decorated method having defined request, response and default types.""" + if request.headers['Accept'] == self.response_type_plain: + response = ApiAdapterResponse( + self.response_data_plain, + content_type=self.response_type_plain, status_code=self.response_code) + else: + response = ApiAdapterResponse( + self.response_data_json, + content_type=self.response_type_json, status_code=self.response_code) + + return response + + @request_types('application/json', 'text/plain') + @response_types('application/json') + def decorated_method_without_default(self, path, request): + """Decorated method having defined request, response but no default type.""" + if request.headers['Accept'] == self.response_type_plain: + response = ApiAdapterResponse( + self.response_data_plain, + content_type=self.response_type_plain, status_code=self.response_code) + elif request.headers['Accept'] == '*/*': + response = ApiAdapterResponse( + self.response_data_plain, + content_type=self.response_type_plain, status_code=self.response_code) + elif request.headers['Accept'] == self.response_type_json: + response = ApiAdapterResponse( + self.response_data_json, + content_type=self.response_type_json, status_code=self.response_code) + else: # pragma: no cover + response = None + assert ("Request type decorator failed to trap unknown content type") + + return response + + @require_controller + def decorated_method_for_controller(self, path, request): + """Method decorated to require a controller.""" + return ApiAdapterResponse( + self.response_data_json, content_type=self.response_type_json, + status_code=self.response_code + ) + +@pytest.fixture(scope="class") +def test_api_decorator(): + """Test fixture for testing method decorators.""" + test_api_decorator = ApiMethodDecoratorsTestFixture() + yield test_api_decorator + +@pytest.fixture(scope="class") +def test_api_decorator_with_controller(): + """Test fixture for testing method decorators with a controller.""" + test_api_decorator = ApiMethodDecoratorsTestFixture(with_controller=True) + yield test_api_decorator + +class TestApiMethodDecorators(): + """Class to test API method decorators.""" + + def test_decorated_method_plaintext(self, test_api_decorator): + """Test that a method being passed a plaintext request responds correctly.""" + plain_request = Mock() + plain_request.data = 'Simple plain text request' + plain_request.headers = {'Accept': 'text/plain', 'Content-Type': 'text/plain'} + + response = test_api_decorator.decorated_method(test_api_decorator.path, plain_request) + assert response.status_code == test_api_decorator.response_code + assert response.content_type == test_api_decorator.response_type_plain + assert response.data == test_api_decorator.response_data_plain + + def test_decorated_method_default(self, test_api_decorator): + """Test that a decorated method being passed a JSON object responds correctly when + the accepted type is default. + """ + json_request = Mock() + json_request.data = '{\'request\': 1234}' + json_request.headers = {'Accept': '*/*', 'Content-Type': 'application/json'} + + response = test_api_decorator.decorated_method(test_api_decorator.path, json_request) + assert response.status_code == test_api_decorator.response_code + assert response.content_type == test_api_decorator.response_type_json + assert response.data == test_api_decorator.response_data_json + + def test_decorated_method_json(self, test_api_decorator): + """Test that a decorated method being passed a JSON object responds correctly when + the accepted type is also JSON. + """ + json_request = Mock() + json_request.data = '{\'request\' : 1234}' + json_request.headers = {'Accept': 'application/json', 'Content-Type': 'application/json'} + + response = test_api_decorator.decorated_method(test_api_decorator.path, json_request) + assert response.status_code == test_api_decorator.response_code + assert response.content_type == test_api_decorator.response_type_json + assert response.data == test_api_decorator.response_data_json + + def test_decorated_method_bad_content(self, test_api_decorator): + """Test that a decorated method passed an unsupported content type returns an error.""" + json_request = Mock() + json_request.data = 'wibble' + json_request.headers = {'Accept': 'application/json', 'Content-Type': 'application/hdf'} + + response = test_api_decorator.decorated_method(test_api_decorator.path, json_request) + assert response.status_code == 415 + assert response.data == 'Request content type (application/hdf) not supported' + + def test_decorated_method_bad_accept(self, test_api_decorator): + """Test that a decorated method passed an unsupported accept type returns an error.""" + request = Mock() + request.data = 'Some text' + request.headers = {'Accept': 'application/hdf', 'Content-Type': 'text/plain'} + + response = test_api_decorator.decorated_method(test_api_decorator.path, request) + assert response.status_code == 406 + assert response.data == 'Requested content types not supported' + + def test_decorated_method_no_default(self, test_api_decorator): + """Test that a decorated method with no default defined returns a response matching the + request. + """ + request = Mock() + request.data = 'Some text' + request.headers = {'Accept': '*/*', 'Content-Type': 'text/plain'} + + response = test_api_decorator.decorated_method_without_default(test_api_decorator.path, request) + assert response.status_code == test_api_decorator.response_code + assert response.content_type == test_api_decorator.response_type_plain + assert response.data == test_api_decorator.response_data_plain + + def test_decorated_method_no_default_json(self, test_api_decorator): + """Test that a decorated method with no default defined returns a JSON repsonse to a JSON + request. + """ + request = Mock() + request.data = '{\'request\' : 1234}' + request.headers = {'Accept': 'application/json', 'Content-Type': 'application/json'} + + response = test_api_decorator.decorated_method_without_default(test_api_decorator.path, request) + assert response.status_code == test_api_decorator.response_code + assert response.content_type == test_api_decorator.response_type_json + assert response.data == test_api_decorator.response_data_json + + def test_decorated_method_no_accept(self, test_api_decorator): + """Test that a decorated method responds to a request with no Accept header with the + appropriate default response type. + """ + request = Mock() + request.data = 'Some text' + request.headers = {'Content-Type': 'text/plain'} + + response = test_api_decorator.decorated_method(test_api_decorator.path, request) + assert response.status_code == test_api_decorator.response_code + assert response.content_type == test_api_decorator.response_type_json + assert response.data == test_api_decorator.response_data_json + + def test_decorated_method_no_accept_no_default(self, test_api_decorator): + """Test that a decorated method with no default responsds + """ + request = Mock() + request.data = 'Some text' + request.headers = {'Content-Type': 'text/plain'} + + response = test_api_decorator.decorated_method_without_default(test_api_decorator.path, request) + assert response.status_code == test_api_decorator.response_code + assert response.content_type == test_api_decorator.response_type_plain + assert response.data == test_api_decorator.response_data_plain + + def test_method_require_controller_no_controller(self, test_api_decorator): + """Test that a method decorated with require_controller returns an error response + when no controller is present. + """ + request = Mock() + request.data = '{\'request\' : 1234}' + request.headers = {'Accept': 'application/json', 'Content-Type': 'application/json'} + + response = test_api_decorator.decorated_method_for_controller( + test_api_decorator.path, request) + assert response.status_code == 405 + assert response.content_type == 'application/json' + assert response.data == { + "error": f"Adapter {test_api_decorator.name} has no controller configured" + } + + def test_method_require_controller_with_controller(self, test_api_decorator_with_controller): + """Test that a method decorated with require_controller returns a valid response + when a controller is present. + """ + request = Mock() + request.data = '{\'request\' : 1234}' + request.headers = {'Accept': 'application/json', 'Content-Type': 'application/json'} + + response = test_api_decorator_with_controller.decorated_method_for_controller( + test_api_decorator_with_controller.path, request) + assert response.status_code == test_api_decorator_with_controller.response_code + assert response.content_type == test_api_decorator_with_controller.response_type_json + assert response.data == test_api_decorator_with_controller.response_data_json + +class TestWantsMetadata(): + """Class to test the wants_metadata utility function.""" + + @pytest.mark.parametrize("metadata", [True, False]) + def test_wants_metadata(self, metadata): + """Test that wants_metadata returns the appropriate value according to the header.""" + request = Mock() + + for metadata_state in [f(str(metadata)) for f in (str.upper, str.lower, str.capitalize)]: + request.headers = {'Accept': f'application/json; metadata={metadata_state}'} + assert wants_metadata(request) == metadata + + def test_wants_metadata_invalid(self): + """Test that wants_metadata returns False when an invalid metadata value is given.""" + request = Mock() + request.headers = {'Accept': 'application/json;metadata=wibble'} + assert not wants_metadata(request) + + def test_wants_metadata_missing(self): + """Test that wants_metadata returns False when no metadata parameter is given.""" + request = Mock() + request.headers = {'Accept': 'application/json'} + assert not wants_metadata(request) + + def test_wants_metadata_no_accept(self): + """Test that wants_metadata returns False when no Accept header is given.""" + request = Mock() + request.headers = {} + assert not wants_metadata(request) diff --git a/tests/handlers/fixtures.py b/tests/handlers/fixtures.py index d9e2a500..ce84fd49 100644 --- a/tests/handlers/fixtures.py +++ b/tests/handlers/fixtures.py @@ -8,7 +8,7 @@ from odin_control.http.handlers.api_adapter_info import ApiAdapterInfoHandler from odin_control.http.handlers.api_version import ApiVersionHandler from odin_control.adapters.adapter import ApiAdapterResponse -from odin_control.util import wrap_result +from odin_control.adapters.util import wrap_result TEST_API_VERSION = "0.1" diff --git a/tests/test_util.py b/tests/test_util.py index dfba9cd9..a2523545 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -35,17 +35,6 @@ def test_decode_request_body_type_error(self): response = util.decode_request_body(request) assert response == request.body - @pytest.mark.parametrize("is_async", [True, False], ids=["async", "sync"]) - def test_wrap_result(self, is_async): - """Test that the wrap_result utility correctly wraps results in a future when needed.""" - result = 321 - wrapped_result = util.wrap_result(result, is_async) - if is_async: - assert isinstance(wrapped_result, asyncio.Future) - assert wrapped_result.result() == result - else: - assert wrapped_result == result - def test_run_in_executor(self): """Test that the run_in_executor utility can correctly nest asynchronous tasks.""" # Container for task results modified by inner functions @@ -84,24 +73,6 @@ def outer_task(num_loops): class TestUtilAsync(): - @pytest.mark.asyncio - async def test_wrap_result(self): - """Test that the wrap_result utility correctly wraps results in a future when needed.""" - result = 321 - wrapped = util.wrap_result(result, True) - await wrapped - assert isinstance(wrapped, asyncio.Future) - assert wrapped.result() == result - - @pytest.mark.asyncio - async def test_wrap_async(self): - """Test that the wrap_async fuction correctly wraps results in a future.""" - result = 987 - wrapped = util.wrap_async(result) - await wrapped - assert isinstance(wrapped, asyncio.Future) - assert wrapped.result() == result - @pytest.mark.asyncio async def test_run_in_executor(self): """ @@ -141,4 +112,4 @@ async def async_increment(value): value = 5 result = util.run_async(async_increment, value) - assert result == value + 1 \ No newline at end of file + assert result == value + 1 From a5598bb737f1c8b1bf2b6597f7fbad7bdc09a7b2 Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Mon, 9 Feb 2026 11:08:58 +0000 Subject: [PATCH 07/13] Update system status controller to make parameter tree fully traversable (#76) --- src/odin_control/adapters/system_status.py | 264 ++++++++++++--------- tests/adapters/test_system_status.py | 46 ++-- 2 files changed, 175 insertions(+), 135 deletions(-) diff --git a/src/odin_control/adapters/system_status.py b/src/odin_control/adapters/system_status.py index 8672f0fb..e5b9913f 100644 --- a/src/odin_control/adapters/system_status.py +++ b/src/odin_control/adapters/system_status.py @@ -21,6 +21,7 @@ """ import logging import os +from dataclasses import dataclass, fields import psutil from tornado.ioloop import IOLoop @@ -31,99 +32,101 @@ from odin_control.adapters.parameter_tree import ParameterTree, ParameterTreeError +class ParameterTreeMixin: + """Mixin class to provide ParameterTree functionality to dataclasses.""" + + def __iter__(self): + """Generate an iterator over the field names in the dataclass.""" + for field in fields(self): + yield field.name + + def as_tree(self): + """Return a ParameterTree representation of the dataclass.""" + return ParameterTree({ + name: (lambda name=name: getattr(self, name), None) for name in self + }) + +@dataclass +class DiskStatus(ParameterTreeMixin): + """Dataclass to hold disk status information.""" + total: int = 0 + used: int = 0 + free: int = 0 + percent: float = 0.0 + +@dataclass +class InterfaceStatus(ParameterTreeMixin): + """Dataclass to hold network interface status information.""" + bytes_sent: int = 0 + bytes_recv: int = 0 + packets_sent: int = 0 + packets_recv: int = 0 + errin: int = 0 + errout: int = 0 + dropin: int = 0 + dropout: int = 0 + +@dataclass +class ProcessStatus(ParameterTreeMixin): + """Dataclass to hold process status information.""" + cpu_percent: float = 0.0 + cpu_affinity: list = None + memory_percent: float = 0.0 + memory_rss: int = 0 + memory_vms: int = 0 + memory_shared: int = 0 + + class SystemStatusController(BaseController): """Class to monitor disks, network and processes running on a server.""" - def __init__(self, *args, **kwargs): + + def __init__(self, options=None): """Initalise the Server Monitor. Creates the parameter tree for status and process monitoring. """ self._log = logging.getLogger(".".join([__name__, self.__class__.__name__])) - self._processes = {} - self._process_status = {} + self._disks = {} + self._disk_status = {} + self._disk_tree = {} self._interfaces = [] self._interface_status = {} - self._disks = [] - self._disk_status = {} - - # The parameter tree will contain general server information as well as information - # relating to each process. We need to initialise the top level tree - tree = { - 'status': { - 'disk': (self.get_disk_status, None), - 'network': (self.get_interface_status, None), - 'process': (self.get_process_status, None) - } - } + self._network_tree = {} + self._processes = {} + self._process_tree = {} # Add any disks that we need to monitor - if 'disks' in kwargs: - disks = kwargs['disks'].split(',') - for disk in disks: - if os.path.isdir(disk.strip()): - self._disks.append(disk.strip()) - - for disk in self._disks: - self._disk_status[disk.replace("/", "_")] = { - 'total': None, - 'used': None, - 'free': None, - 'percent': None - } + if 'disks' in options: + self.add_disks(options['disks'].split(',')) # Add any network interfaces that we need to monitor - available_interfaces = list(psutil.net_io_counters(pernic=True)) - if 'interfaces' in kwargs: - interfaces = kwargs['interfaces'].split(',') - for interface in interfaces: - if interface.strip() in available_interfaces: - self._interfaces.append(interface.strip()) - - for interface in self._interfaces: - self._interface_status[interface] = { - 'bytes_sent': None, - 'bytes_recv': None, - 'packets_sent': None, - 'packets_recv': None, - 'errin': None, - 'errout': None, - 'dropin': None, - 'dropout': None - } + if 'interfaces' in options: + self.add_interfaces(options['interfaces'].split(',')) # Add any processes that we need to monitor - if 'processes' in kwargs: - processes = kwargs['processes'].split(',') - for process in processes: - self.add_processes(process.strip()) - - for process in self._processes: - self._process_status[process] = {} - - self._status = ParameterTree(tree) + if 'processes' in options: + self.add_processes(options['processes'].split(',')) # Setup the time between status updates - if 'rate' in kwargs: - self._update_interval = float(1.0 / kwargs['rate']) + if 'rate' in options: + self._update_interval = float(1.0 / options['rate']) else: self._update_interval = 1.0 - self.update_loop() - def get_disk_status(self): - """Return disk status information.""" - return self._disk_status + # Build the parameter tree - this is mutable, allowing the process subtree to be replaced on + # each monitoring update as the number of monitored processes can change + self.param_tree = ParameterTree({ + 'disk': self._disk_tree, + 'network': self._network_tree, + 'process': self._process_tree, + }, mutable=True) - def get_interface_status(self): - """Return network status information.""" - return self._interface_status - - def get_process_status(self): - """Return process status information.""" - return self._process_status + # Start the update loop + self.update_loop() def get(self, path, with_metadata=False): """Return the requested path value.""" - return self._status.get(path, with_metadata=with_metadata) + return self.param_tree.get(path, with_metadata=with_metadata) def update_loop(self): """Handle update loop tasks. @@ -140,25 +143,62 @@ def update_loop(self): # Schedule the update loop to run in the IOLoop instance again after appropriate interval IOLoop.instance().call_later(self._update_interval, self.update_loop) - def add_processes(self, process_name): + def add_disks(self, disks): + """Add disks to monitor. + + :param disks the disk mount points to monitor + """ + for disk in disks: + disk = disk.strip() + if os.path.isdir(disk): + + path = disk.replace("/", "_") + + self._disks[path] = disk + self._disk_status[path] = DiskStatus() + self._disk_tree[path] = self._disk_status[path].as_tree() + + logging.debug("Adding disk %s to monitor list", disk) + + def add_interfaces(self, interfaces): + """Add a new network interface to monitor. + + :param interface the name of the network interface to monitor + """ + available_interfaces = list(psutil.net_io_counters(pernic=True)) + + for interface in interfaces: + interface = interface.strip() + if interface in available_interfaces: + self._interfaces.append(interface) + self._interface_status[interface] = InterfaceStatus() + self._network_tree[interface] = self._interface_status[interface].as_tree() + + logging.debug("Adding interface %s to monitor list", interface) + + def add_processes(self, processes): """Add a new process to monitor. :param process_name the name of the process to monitor """ - if process_name not in self._processes: - self._log.debug("Adding process %s to monitor list", process_name) - try: - self._processes[process_name] = self.find_processes(process_name) - self._log.debug( - "Found %d proceses with name %s", - len(self._processes[process_name]), process_name - ) + for process_name in processes: + process_name = process_name.strip() - except Exception as exc: - self._log.debug( - "Unable to add process %s to the monitor list: %s", - process_name, str(exc) - ) + if process_name not in self._processes: + self._log.debug("Adding process %s to monitor list", process_name) + try: + self._processes[process_name] = self.find_processes(process_name) + + self._log.debug( + "Found %d proceses with name %s", + len(self._processes[process_name]), process_name + ) + + except Exception as exc: + self._log.debug( + "Unable to add process %s to the monitor list: %s", + process_name, str(exc) + ) def monitor(self): """Executed at regular interval. Calls the specific monitoring methods.""" @@ -168,14 +208,12 @@ def monitor(self): def monitor_disks(self): """Loops over disks and retrieves the usage statistics.""" - for disk in self._disks: + for path, disk in self._disks.items(): try: usage = psutil.disk_usage(disk) - path = str(disk.replace("/", "_")) - self._disk_status[path]['total'] = usage.total - self._disk_status[path]['used'] = usage.used - self._disk_status[path]['free'] = usage.free - self._disk_status[path]['percent'] = usage.percent + for field in self._disk_status[path]: + setattr(self._disk_status[path], field, getattr(usage, field)) + except Exception as exc: self._log.exception(exc) @@ -184,25 +222,26 @@ def monitor_network(self): try: network = psutil.net_io_counters(pernic=True) for interface in self._interfaces: - self._interface_status[interface]['bytes_sent'] = network[interface].bytes_sent - self._interface_status[interface]['bytes_recv'] = network[interface].bytes_recv - self._interface_status[interface]['packets_sent'] = network[interface].packets_sent - self._interface_status[interface]['packets_recv'] = network[interface].packets_recv - self._interface_status[interface]['errin'] = network[interface].errin - self._interface_status[interface]['errout'] = network[interface].errout - self._interface_status[interface]['dropin'] = network[interface].dropin - self._interface_status[interface]['dropout'] = network[interface].dropout + for field in self._interface_status[interface]: + setattr( + self._interface_status[interface], + field, + getattr(network[interface], field) + ) except Exception as exc: self._log.exception(exc) def monitor_processes(self): """Loop over active processes and retrieves the statistics from them.""" + self._process_tree = {} + for process_name in self._processes: - self._process_status[process_name] = {} + self._process_tree[process_name] = {} num_processes_old = len(self._processes[process_name]) self._processes[process_name] = self.find_processes(process_name) + if len(self._processes[process_name]) != num_processes_old: self._log.debug( "Number of processes named %s is now %d", @@ -210,34 +249,29 @@ def monitor_processes(self): ) for process in self._processes[process_name]: - process_status = {} + process_status = ProcessStatus() try: pid = process.pid memory_info = process.memory_info() - process_status['cpu_percent'] = process.cpu_percent(interval=0.0) + process_status.cpu_percent = process.cpu_percent(interval=0.0) if hasattr(process, 'cpu_affinity'): - process_status['cpu_affinity'] = process.cpu_affinity() - else: - process_status['cpu_affinity'] = None - process_status['memory_percent'] = process.memory_percent() + process_status.cpu_affinity = process.cpu_affinity() + process_status.memory_percent = process.memory_percent() - process_status['memory_rss'] = getattr( - memory_info, 'rss', None - ) - process_status['memory_vms'] = getattr( - memory_info, 'vms', None - ) - process_status['memory_shared'] = getattr( - memory_info, 'shared', None - ) + process_status.memory_rss = getattr(memory_info, 'rss', None) + process_status.memory_vms = getattr(memory_info, 'vms', None) + process_status.memory_shared = getattr(memory_info, 'shared', None) except psutil.NoSuchProcess: self._log.error("Process %s no longer exists", process_name) except psutil.AccessDenied: self._log.error("Access to process %s denied by operating system", process_name) else: - self._process_status[process_name][pid] = process_status + self._process_tree[process_name][str(pid)] = process_status.as_tree() + + self.param_tree.replace('process', self._process_tree) + def find_processes(self, process_name): """Find processes matching a name and return a list of process objects. @@ -256,7 +290,7 @@ def find_processes(self, process_name): # Attempt to access process and remove if access denied try: _ = process.cpu_percent() - except psutil.AccessDenied: + except (psutil.AccessDenied, psutil.ZombieProcess, psutil.NoSuchProcess): pass else: processes.append(process) diff --git a/tests/adapters/test_system_status.py b/tests/adapters/test_system_status.py index 6bfac953..db17720a 100644 --- a/tests/adapters/test_system_status.py +++ b/tests/adapters/test_system_status.py @@ -74,8 +74,13 @@ def mock_process_iter(attrs=None, ad_value=None): scoped_patcher.setattr(psutil, "process_iter", mock_process_iter) - self.system_status = SystemStatusController( - interfaces=self.interfaces, disks=self.disks, processes=self.processes, rate=self.rate) + options = { + 'interfaces': self.interfaces, + 'disks': self.disks, + 'processes': self.processes, + 'rate': self.rate, + } + self.system_status = SystemStatusController(options=options) #@pytest.fixture(scope="class") @@ -109,7 +114,7 @@ def test_system_status_get(self, test_system_status): def test_system_status_add_processes(self, test_system_status): """Test that adding a process to SystemStatus works correctly.""" - test_system_status.system_status.add_processes('proc1') + test_system_status.system_status.add_processes(['proc1']) assert 'proc1' in test_system_status.system_status._processes def test_system_status_check_bad_nic(self, test_system_status): @@ -123,19 +128,19 @@ def test_system_status_monitor_network(self, test_system_status): """Test that getting the status of a network interface returns a dict.""" test_system_status.system_status.monitor_network() result = test_system_status.system_status.get( - 'status/network/{}'.format(test_system_status.lo_iface)) + 'network/{}'.format(test_system_status.lo_iface)) assert type(result) is dict def test_system_status_monitor_disks(self, test_system_status): """Test that getting the status of a system disk returns a dict.""" test_system_status.system_status.monitor_disks() - result = test_system_status.system_status.get('status/disk/_') + result = test_system_status.system_status.get('disk/_') assert type(result) is dict def test_system_status_monitor_processes(self, test_system_status): """Test that getting the status of a process returns a dict.""" test_system_status.system_status.monitor_processes() - result = test_system_status.system_status.get('status/process/proc2') + result = test_system_status.system_status.get('process/proc2') assert type(result) is dict def test_system_status_monitor(self, test_system_status): @@ -145,7 +150,7 @@ def test_system_status_monitor(self, test_system_status): def test_bad_disk_exception(self, test_system_status): """Test that trying to monitor a bad disk does not raise an exception.""" - test_system_status.system_status._disks.append("rubbish") + test_system_status.system_status._disks["rubbish"] = "rubbish" # Any exceptions caught whilst monitoring will be handled within the class test_system_status.system_status.monitor_disks() @@ -179,11 +184,12 @@ def test_update_loop_exception(self, test_system_status): def test_default_rate_argument(self, test_system_status): """Test that that the default monitoring rate argument is applied correctly.""" - temp_system_status = SystemStatusController( - interfaces=test_system_status.interfaces, - disks=test_system_status.disks, - processes=test_system_status.processes, - ) + options = { + 'interfaces': test_system_status.interfaces, + 'disks': test_system_status.disks, + 'processes': test_system_status.processes, + } + temp_system_status = SystemStatusController(options=options) assert pytest.approx(1.0) == temp_system_status._update_interval def test_num_processes_change(self, test_system_status, caplog): @@ -219,10 +225,10 @@ def test_monitor_process_cpu_affinity(self, test_system_status, monkeypatch): """Test that monitoring processes reports CPU affinity where implemented.""" test_system_status.system_status.monitor_processes() - cpu_affinity_vals = [status['cpu_affinity'] for status in - test_system_status.system_status._process_status[ + cpu_affinity_vals = [status.get('cpu_affinity')['value'] for status in + test_system_status.system_status._process_tree.get( test_system_status.mocked_proc_name - ].values() + ).values() ] assert test_system_status.cpu_affinity_vals in cpu_affinity_vals @@ -235,10 +241,10 @@ def test_monitor_process_no_cpu_affinity(self, test_system_status, monkeypatch): test_system_status.system_status.monitor_processes() test_system_status.system_status.monitor_processes() - cpu_affinity_vals = [status['cpu_affinity'] for status in - test_system_status.system_status._process_status[ + cpu_affinity_vals = [status.get('cpu_affinity')['value'] for status in + test_system_status.system_status._process_tree.get( test_system_status.mocked_proc_name - ].values() + ).values() ] assert None in cpu_affinity_vals @@ -303,8 +309,8 @@ def test_adapter_get(self, test_sysstatus_adapter): response = test_sysstatus_adapter.adapter.get( test_sysstatus_adapter.path, test_sysstatus_adapter.request) - assert type(response.data) == dict - assert 'status' in response.data + assert isinstance(response.data, dict) + assert all(key in response.data for key in ['disk', 'network', 'process']) assert response.status_code == 200 def test_adapter_get_bad_path(self, test_sysstatus_adapter): From 4118f7241623e72cfcd627bc1d9c74bd1815f70c Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Fri, 27 Feb 2026 07:33:21 +0000 Subject: [PATCH 08/13] Make parameter accessors read-write only if a callable setter is specified (#78) --- .../adapters/base_parameter_tree.py | 12 +- tests/adapters/test_async_parameter_tree.py | 91 +++++++++------ tests/adapters/test_parameter_tree.py | 107 +++++++++++------- 3 files changed, 128 insertions(+), 82 deletions(-) diff --git a/src/odin_control/adapters/base_parameter_tree.py b/src/odin_control/adapters/base_parameter_tree.py index 6b56804c..2a6ae85c 100644 --- a/src/odin_control/adapters/base_parameter_tree.py +++ b/src/odin_control/adapters/base_parameter_tree.py @@ -75,11 +75,8 @@ def __init__(self, path, getter=None, setter=None, **kwargs): # Update metadata keywords from arguments self.metadata.update(kwargs) - # Set the writeable metadata field based on specified accessors - if not callable(self._set) and callable(self._get): - self.metadata["writeable"] = False - else: - self.metadata["writeable"] = True + # Set the writeable metadata field if the setter is callable + self.metadata["writeable"] = callable(self._set) def get(self, with_metadata=False): """Get the value of the parameter. @@ -156,13 +153,10 @@ def set(self, value): ) ) - # Set the new parameter value, either by calling the setter or updating the local - # value as appropriate + # Set the new parameter value by calling the setter response = None if callable(self._set): response = self._set(value) - elif not callable(self._get): - self._get = value return response diff --git a/tests/adapters/test_async_parameter_tree.py b/tests/adapters/test_async_parameter_tree.py index 487156b0..0434d542 100644 --- a/tests/adapters/test_async_parameter_tree.py +++ b/tests/adapters/test_async_parameter_tree.py @@ -25,10 +25,10 @@ def __init__(self): super(AsyncParameterAccessorTestFixture, self).__init__(AsyncParameterAccessor) - self.static_rw_path = 'static_rw' - self.static_rw_value = 2.76923 - self.static_rw_accessor = AsyncParameterAccessor( - self.static_rw_path + '/', self.static_rw_value + self.static_ro_path = 'static_ro' + self.static_ro_value = 2.76923 + self.static_ro_accessor = AsyncParameterAccessor( + self.static_ro_path + '/', self.static_ro_value ) self.sync_ro_value = 1234 @@ -135,20 +135,19 @@ async def test_param_accessor(): class TestAsyncParameterAccessor(): """Class to test AsyncParameterAccessor behaviour""" - async def test_static_rw_accessor_get(self, test_param_accessor): - """Test that a static RW accessor get call returns the correct value.""" - value = await test_param_accessor.static_rw_accessor.get() - assert value == test_param_accessor.static_rw_value + async def test_static_ro_accessor_get(self, test_param_accessor): + """Test that a static RO accessor get call returns the correct value.""" + value = await test_param_accessor.static_ro_accessor.get() + assert value == test_param_accessor.static_ro_value - async def test_static_rw_accessor_set(self, test_param_accessor): - """Test that a static RW accessor set call sets the correct value.""" - old_val = test_param_accessor.static_rw_value + async def test_static_ro_accessor_set(self, test_param_accessor): + """Test that a static RO accessor set call raises an error.""" new_val = 1.234 - await test_param_accessor.static_rw_accessor.set(new_val) - value = await test_param_accessor.static_rw_accessor.get() - assert value == new_val + with pytest.raises(ParameterTreeError) as excinfo: + await test_param_accessor.static_ro_accessor.set(new_val) - await test_param_accessor.static_rw_accessor.set(old_val) + assert "Parameter {} is read-only".format(test_param_accessor.static_ro_path) \ + in str(excinfo.value) async def test_sync_ro_accessor_get(self, test_param_accessor): """Test that a synchronous callable RO accessor get call returns the correct value.""" @@ -206,27 +205,27 @@ async def test_async_rw_accessor_set(self, test_param_accessor): value = await test_param_accessor.async_rw_accessor.get() assert value == new_val - async def test_static_rw_accessor_default_metadata(self, test_param_accessor): + async def test_static_ro_accessor_default_metadata(self, test_param_accessor): """Test that a static RW accessor has the appropriate default metadata.""" - param = await test_param_accessor.static_rw_accessor.get(with_metadata=True) + param = await test_param_accessor.static_ro_accessor.get(with_metadata=True) assert(isinstance(param, dict)) - assert param['value'] == test_param_accessor.static_rw_value - assert param['type'] == type(test_param_accessor.static_rw_value).__name__ - assert param['writeable'] == True + assert param['value'] == test_param_accessor.static_ro_value + assert param['type'] == type(test_param_accessor.static_ro_value).__name__ + assert not param['writeable'] async def test_sync_ro_accessor_default_metadata(self, test_param_accessor): """Test that a synchronous callable RO accesor has the appropriate default metadata.""" param = await test_param_accessor.sync_ro_accessor.get(with_metadata=True) assert param['value'] == test_param_accessor.sync_ro_value assert param['type'] == type(test_param_accessor.sync_ro_value).__name__ - assert param['writeable'] == False + assert not param['writeable'] async def test_sync_rw_accessor_default_metadata(self, test_param_accessor): """Test that a synchronous callable RW accesor has the appropriate default metadata.""" param = await test_param_accessor.sync_rw_accessor.get(with_metadata=True) assert param['value'] == test_param_accessor.sync_rw_value assert param['type'] == type(test_param_accessor.sync_rw_value).__name__ - assert param['writeable'] == True + assert param['writeable'] async def test_sync_ro_accessor_default_metadata(self, test_param_accessor): """Test that a synchronous callable RO accesor has the appropriate default metadata.""" @@ -265,8 +264,8 @@ async def test_param_accessor_bad_metadata_arg(self, test_param_accessor): bad_metadata = {bad_metadata_argument: 'bar'} with pytest.raises(ParameterTreeError) as excinfo: _ = await AsyncParameterAccessor( - test_param_accessor.static_rw_path + '/', - test_param_accessor.static_rw_value, **bad_metadata + test_param_accessor.static_ro_path + '/', + test_param_accessor.static_ro_value, **bad_metadata ) assert "Invalid metadata argument: {}".format(bad_metadata_argument) \ @@ -279,7 +278,7 @@ async def test_param_accessor_set_type_mismatch(self, test_param_accessor): """ bad_value = 'bar' bad_value_type = type(bad_value).__name__ - + with pytest.raises(ParameterTreeError) as excinfo: await test_param_accessor.async_rw_accessor.set(bad_value) @@ -736,6 +735,7 @@ def __init__(self): self.int_ro_param = 1000 self.int_enum_param = 0 self.int_enum_param_allowed_values = [0, 1, 2, 3, 5, 8, 13] + self.min_no_max_param = 1 self.int_rw_param_metadata = { "min": 0, @@ -753,9 +753,12 @@ def __init__(self): 'intCallableRwParam': ( self.intCallableRwParamGet, self.intCallableRwParamSet, self.int_rw_param_metadata ), - 'intEnumParam': (0, {"allowed_values": self.int_enum_param_allowed_values}), + 'intEnumParam': ( + self.intEnumParamGet, self.intEnumParamSet, + {"allowed_values": self.int_enum_param_allowed_values} + ), 'valueParam': (24601,), - 'minNoMaxParam': (1, {'min': 0}) + 'minNoMaxParam': (self.minNoMaxParamGet, self.minNoMaxParamSet,{'min': 0}) } self.metadata_tree = AsyncParameterTree(self.metadata_tree_dict) @@ -767,10 +770,26 @@ def intCallableRwParamGet(self): def floatRoParamGet(self): return self.float_ro_param - + def intRoParamGet(self): return self.int_ro_param + async def intEnumParamGet(self): + await asyncio.sleep(0) + return self.int_enum_param + + async def intEnumParamSet(self, value): + await asyncio.sleep(0) + self.int_enum_param = value + + async def minNoMaxParamGet(self): + await asyncio.sleep(0) + return self.min_no_max_param + + async def minNoMaxParamSet(self, value): + await asyncio.sleep(0) + self.min_no_max_param = value + @asyncio_fixture_decorator(scope="class") async def test_tree_metadata(): @@ -817,7 +836,7 @@ async def test_ro_param_has_writeable_metadata_field(self, test_tree_metadata): """Test that a RO parameter has the writeable metadata field set to false.""" ro_param = await test_tree_metadata.metadata_tree.get("floatRoParam", with_metadata=True) result = await ro_param - assert result["writeable"] == False + assert not result["writeable"] async def test_ro_param_not_writeable(self, test_tree_metadata): """Test that attempting to write to a RO parameter with metadata raises an error.""" @@ -825,14 +844,16 @@ async def test_ro_param_not_writeable(self, test_tree_metadata): await test_tree_metadata.metadata_tree.set("floatRoParam", 3.141275) assert "Parameter {} is read-only".format("floatRoParam") in str(excinfo.value) - async def test_value_param_writeable(self, test_tree_metadata): - """Test that a value parameter is writeable and has the correct metadata flag.""" - new_value = 90210 - await test_tree_metadata.metadata_tree.set("valueParam", new_value) + async def test_value_param_not_writeable(self, test_tree_metadata): + """Test that a value parameter is not writeable and has the correct metadata flag.""" + with pytest.raises(ParameterTreeError) as excinfo: + await test_tree_metadata.metadata_tree.set("valueParam", 90210) + + assert "Parameter {} is read-only".format("valueParam") in str(excinfo.value) + param = await test_tree_metadata.metadata_tree.get("valueParam", with_metadata=True) result = await param - assert result["value"] == new_value - assert result["writeable"] == True + assert not result["writeable"] async def test_rw_param_min_no_max(self, test_tree_metadata): """Test that a parameter with a minimum but no maximum works as expected.""" diff --git a/tests/adapters/test_parameter_tree.py b/tests/adapters/test_parameter_tree.py index 19352ac0..7d9609e6 100644 --- a/tests/adapters/test_parameter_tree.py +++ b/tests/adapters/test_parameter_tree.py @@ -14,9 +14,9 @@ class ParameterAccessorTestFixture(object): """Container class used in fixtures for testing ParameterAccessor.""" def __init__(self): - self.static_rw_path = 'static_rw' - self.static_rw_value = 2.76923 - self.static_rw_accessor = ParameterAccessor(self.static_rw_path + '/', self.static_rw_value) + self.static_ro_path = 'static_ro' + self.static_ro_value = 2.76923 + self.static_ro_accessor = ParameterAccessor(self.static_ro_path + '/', self.static_ro_value) self.callable_ro_value = 1234 self.callable_ro_path = 'callable_ro' @@ -39,7 +39,7 @@ def __init__(self): "display_precision": 0, } self.md_accessor = ParameterAccessor( - self.md_param_path + '/', self.md_param_val, **self.md_param_metadata + self.md_param_path + '/', self.md_param_get, self.md_param_set, **self.md_param_metadata ) self.md_minmax_path = 'minmaxparam' @@ -49,7 +49,8 @@ def __init__(self): 'max': 1000 } self.md_minmax_accessor = ParameterAccessor( - self.md_minmax_path + '/', self.md_minmax_val, **self.md_minmax_metadata + self.md_minmax_path + '/', self.md_minmax_get, self.md_minmax_set, + **self.md_minmax_metadata ) def callable_ro_get(self): @@ -61,6 +62,18 @@ def callable_rw_get(self): def callable_rw_set(self, value): self.callable_rw_value = value + def md_param_get(self): + return self.md_param_val + + def md_param_set(self, value): + self.md_param_val = value + + def md_minmax_get(self): + return self.md_minmax_val + + def md_minmax_set(self, value): + self.md_minmax_val = value + @pytest.fixture(scope="class") def test_param_accessor(): @@ -72,18 +85,18 @@ def test_param_accessor(): class TestParameterAccessor(): """Class to test ParameterAccessor behaviour.""" - def test_static_rw_accessor_get(self, test_param_accessor): - """Test that a static RW accessor get call returns the correct value.""" - assert test_param_accessor.static_rw_accessor.get() == test_param_accessor.static_rw_value + def test_static_ro_accessor_get(self, test_param_accessor): + """Test that a static RO accessor get call returns the correct value.""" + assert test_param_accessor.static_ro_accessor.get() == test_param_accessor.static_ro_value - def test_static_rw_accessor_set(self, test_param_accessor): - """Test that a static RW accessor set call sets the correct value.""" - old_val = test_param_accessor.static_rw_value + def test_static_ro_accessor_set(self, test_param_accessor): + """Test that a static RO accessor set call raises an error.""" new_val = 1.234 - test_param_accessor.static_rw_accessor.set(new_val) - assert test_param_accessor.static_rw_accessor.get() == new_val + with pytest.raises(ParameterTreeError) as excinfo: + test_param_accessor.static_ro_accessor.set(new_val) - test_param_accessor.static_rw_accessor.set(old_val) + assert "Parameter {} is read-only".format(test_param_accessor.static_ro_path) \ + in str(excinfo.value) def test_callable_ro_accessor_get(self, test_param_accessor): """Test that a callable RO accessor get call returns the correct value.""" @@ -113,27 +126,27 @@ def test_callable_rw_accessor_set(self, test_param_accessor): test_param_accessor.callable_rw_accessor.set(old_val) - def test_static_rw_accessor_default_metadata(self, test_param_accessor): - """Test that a static RW accessor has the appropriate default metadata.""" - param = test_param_accessor.static_rw_accessor.get(with_metadata=True) + def test_static_ro_accessor_default_metadata(self, test_param_accessor): + """Test that a static RO accessor has the appropriate default metadata.""" + param = test_param_accessor.static_ro_accessor.get(with_metadata=True) assert(isinstance(param, dict)) - assert param['value'] == test_param_accessor.static_rw_value - assert param['type'] == type(test_param_accessor.static_rw_value).__name__ - assert param['writeable'] == True + assert param['value'] == test_param_accessor.static_ro_value + assert param['type'] == type(test_param_accessor.static_ro_value).__name__ + assert not param['writeable'] def test_callable_ro_accessor_default_metadata(self, test_param_accessor): """Test that a callable RO accesor has the appropriate default metadata.""" param = test_param_accessor.callable_ro_accessor.get(with_metadata=True) assert param['value'] == test_param_accessor.callable_ro_value assert param['type'] == type(test_param_accessor.callable_ro_value).__name__ - assert param['writeable'] == False + assert not param['writeable'] def test_callable_rw_accessor_default_metadata(self, test_param_accessor): """Test that a callable RW accesor has the appropriate default metadata.""" param = test_param_accessor.callable_rw_accessor.get(with_metadata=True) assert param['value'] == test_param_accessor.callable_rw_value assert param['type'] == type(test_param_accessor.callable_rw_value).__name__ - assert param['writeable'] == True + assert param['writeable'] def test_metadata_param_accessor_metadata(self, test_param_accessor): """Test that a parameter accessor has the correct metadata fields.""" @@ -143,7 +156,7 @@ def test_metadata_param_accessor_metadata(self, test_param_accessor): assert param[md_field] == test_param_accessor.md_param_metadata[md_field] assert param['value'] == test_param_accessor.md_param_val assert param['type'] == type(test_param_accessor.md_param_val).__name__ - assert param['writeable'] == True + assert param['writeable'] def test_param_accessor_bad_metadata_arg(self, test_param_accessor): """Test that a parameter accessor with a bad metadata argument raises an error.""" @@ -151,8 +164,8 @@ def test_param_accessor_bad_metadata_arg(self, test_param_accessor): bad_metadata = {bad_metadata_argument: 'bar'} with pytest.raises(ParameterTreeError) as excinfo: _ = ParameterAccessor( - test_param_accessor.static_rw_path + '/', - test_param_accessor.static_rw_value, **bad_metadata + test_param_accessor.static_ro_path + '/', + test_param_accessor.static_ro_value, **bad_metadata ) assert "Invalid metadata argument: {}".format(bad_metadata_argument) \ @@ -165,7 +178,7 @@ def test_param_accessor_set_type_mismatch(self, test_param_accessor): """ bad_value = 1.234 bad_value_type = type(bad_value).__name__ - + with pytest.raises(ParameterTreeError) as excinfo: test_param_accessor.callable_rw_accessor.set(bad_value) @@ -657,6 +670,7 @@ def __init__(self): self.int_ro_param = 1000 self.int_enum_param = 0 self.int_enum_param_allowed_values = [0, 1, 2, 3, 5, 8, 13] + self.min_no_max_param = 1 self.int_rw_param_metadata = { "min": 0, @@ -674,9 +688,12 @@ def __init__(self): 'intCallableRwParam': ( self.intCallableRwParamGet, self.intCallableRwParamSet, self.int_rw_param_metadata ), - 'intEnumParam': (0, {"allowed_values": self.int_enum_param_allowed_values}), + 'intEnumParam': ( + self.intEnumParamGet, self.intEnumParamSet, + {"allowed_values": self.int_enum_param_allowed_values} + ), 'valueParam': (24601,), - 'minNoMaxParam': (1, {'min': 0}) + 'minNoMaxParam': (self.minNoMaxParamGet, self.minNoMaxParamSet, {'min': 0}) } self.metadata_tree = ParameterTree(self.metadata_tree_dict) @@ -688,10 +705,21 @@ def intCallableRwParamGet(self): def floatRoParamGet(self): return self.float_ro_param - + def intRoParamGet(self): return self.int_ro_param + def intEnumParamGet(self): + return self.int_enum_param + + def intEnumParamSet(self, value): + self.int_enum_param = value + + def minNoMaxParamGet(self): + return self.min_no_max_param + + def minNoMaxParamSet(self, value): + self.min_no_max_param = value @pytest.fixture(scope="class") def test_tree_metadata(): @@ -734,7 +762,7 @@ def test_enum_param_bad_value(self, test_tree_metadata): def test_ro_param_has_writeable_metadata_field(self, test_tree_metadata): """Test that a RO parameter has the writeable metadata field set to false.""" ro_param = test_tree_metadata.metadata_tree.get("floatRoParam", with_metadata=True) - assert ro_param["writeable"] == False + assert not ro_param["writeable"] def test_ro_param_not_writeable(self, test_tree_metadata): """Test that attempting to write to a RO parameter with metadata raises an error.""" @@ -742,14 +770,17 @@ def test_ro_param_not_writeable(self, test_tree_metadata): test_tree_metadata.metadata_tree.set("floatRoParam", 3.141275) assert "Parameter {} is read-only".format("floatRoParam") in str(excinfo.value) - def test_value_param_writeable(self, test_tree_metadata): - """Test that a value parameter is writeable and has the correct metadata flag.""" - new_value = 90210 - test_tree_metadata.metadata_tree.set("valueParam", new_value) - set_param = test_tree_metadata.metadata_tree.get( + def test_value_param_not_writeable(self, test_tree_metadata): + """Test that a value parameter is not writeable and has the correct metadata flag.""" + + with pytest.raises(ParameterTreeError) as excinfo: + test_tree_metadata.metadata_tree.set("valueParam", 90201) + + assert "Parameter {} is read-only".format("valueParam") in str(excinfo.value) + + param = test_tree_metadata.metadata_tree.get( "valueParam", with_metadata=True) - assert set_param["value"] == new_value - assert set_param["writeable"] == True + assert not param["writeable"] def test_rw_param_min_no_max(self, test_tree_metadata): """Test that a parameter with a minimum but no maximum works as expected.""" @@ -758,7 +789,7 @@ def test_rw_param_min_no_max(self, test_tree_metadata): set_param = test_tree_metadata.metadata_tree.get( "minNoMaxParam", with_metadata=True) assert set_param["value"] == new_value - assert set_param["writeable"] == True + assert set_param["writeable"] def test_rw_param_below_min_value(self, test_tree_metadata): """ From 39ca82818cd3e0b83342152fca39db4c3c19913b Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Mon, 2 Mar 2026 08:58:41 +0000 Subject: [PATCH 09/13] Extend python version support to 3.14 (#80) --- .github/workflows/test_odin_control.yml | 2 +- pyproject.toml | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test_odin_control.yml b/.github/workflows/test_odin_control.yml index 5973ef54..5287fdf7 100644 --- a/.github/workflows/test_odin_control.yml +++ b/.github/workflows/test_odin_control.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index 7d2e3217..e619d401 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] description = "ODIN detector control system" dynamic = ["version"] @@ -63,7 +64,7 @@ legacy_tox_ini = """ # tox test configuration for odin-control [tox] -envlist = clean,py{38,39,310,311,312,313},py{38}-pygelf,report +envlist = clean,py{38,39,310,311,312,313,314},py{38}-pygelf,report [gh-actions] python = @@ -73,22 +74,23 @@ python = 3.11: py311 3.12: py312 3.13: py313 + 3.14: py314 [testenv] deps = pytest pytest-cov py{38,38-pygelf}: pytest-asyncio<0.23 - py{39,310,311,312,313}: pytest-asyncio + py{39,310,311,312,313,314}: pytest-asyncio requests py38: pygelf setenv = - py{38,39,310,311,312,313}: COVERAGE_FILE=.coverage.{envname} + py{38,39,310,311,312,313,314}: COVERAGE_FILE=.coverage.{envname} commands = pytest --cov=odin_control --cov-report=term-missing --asyncio-mode=strict {posargs:-vv} depends = - py{38,39,310,311,312,313}: clean - report: py{38,39,310,311,312,313} + py{38,39,310,311,312,313,314}: clean + report: py{38,39,310,311,312,313,314} [testenv:clean] skip_install = true From d4d29bdde7d5e58c8ecb7544c65b1f5e9c2db58a Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Fri, 6 Mar 2026 08:31:55 +0000 Subject: [PATCH 10/13] Add CORS support to API version and adapter info handlers (#82) --- src/odin_control/http/handlers/api.py | 39 +--------- .../http/handlers/api_adapter_info.py | 11 +-- src/odin_control/http/handlers/api_version.py | 11 +-- .../http/handlers/cors_request.py | 50 +++++++++++++ src/odin_control/http/routes/api.py | 16 ++-- tests/handlers/fixtures.py | 6 +- tests/handlers/test_cors_request.py | 75 +++++++++++++++++++ 7 files changed, 142 insertions(+), 66 deletions(-) create mode 100644 src/odin_control/http/handlers/cors_request.py create mode 100644 tests/handlers/test_cors_request.py diff --git a/src/odin_control/http/handlers/api.py b/src/odin_control/http/handlers/api.py index 674507f3..3720c5e5 100644 --- a/src/odin_control/http/handlers/api.py +++ b/src/odin_control/http/handlers/api.py @@ -4,10 +4,9 @@ Tim Nicholls, STFC Detector Systems Software Group. """ -from tornado.web import RequestHandler - from odin_control.adapters.adapter import ApiAdapterResponse from odin_control.adapters.util import wrap_result +from odin_control.http.handlers.cors_request import CorsRequestHandler class ApiError(Exception): @@ -47,7 +46,7 @@ def wrapper(_self, *args, **kwargs): return wrapper -class ApiHandler(RequestHandler): +class ApiHandler(CorsRequestHandler): """API handler to transform requests into appropriate adapter calls. This handler maps incoming API requests onto the appropriate calls to methods in registered @@ -55,28 +54,6 @@ class ApiHandler(RequestHandler): a uniform response with the appropriate Content-Type header. """ - def __init__(self, *args, **kwargs): - """Construct the ApiHandler object. - - This method constructs the ApiHandler object, calling the superclass constructor and setting - the route object to None. - """ - self.route = None - super().__init__(*args, **kwargs) - - def initialize(self, route, enable_cors, cors_origin): - """Initialize the API handler. - - :param route: ApiRoute object calling the handler (allows adapters to be resolved) - :param enable_cors: enable CORS support by setting appropriate headers - :param cors_origin: allowed origin for CORS requests - """ - self.route = route - if enable_cors: - self.set_header("Access-Control-Allow-Origin", cors_origin) - self.set_header("Access-Control-Allow-Headers", "x-requested-with,content-type") - self.set_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') - def respond(self, response): """Respond to an API request. @@ -99,18 +76,6 @@ def respond(self, response): self.write(data) - def options(self, *_): - """Handle an API OPTIONS request. - - This method handles an OPTION request to an API handler and is provided to allow - browser clients to employ CORS preflight requests to determine if non-simple requests - are allowed. - - :param _: unused arguments passed to the method by the URI matching in the handler - """ - # Set status to indicate successful request with no content returned - self.set_status(204) - @validate_api_request async def get(self, subsystem, path=''): """Handle an API GET request. diff --git a/src/odin_control/http/handlers/api_adapter_info.py b/src/odin_control/http/handlers/api_adapter_info.py index 7d2914fd..9b9e71c7 100644 --- a/src/odin_control/http/handlers/api_adapter_info.py +++ b/src/odin_control/http/handlers/api_adapter_info.py @@ -5,23 +5,16 @@ Tim Nicholls, STFC Detector Systems Software Group. """ -from tornado.web import RequestHandler +from odin_control.http.handlers.cors_request import CorsRequestHandler -class ApiAdapterInfoHandler(RequestHandler): +class ApiAdapterInfoHandler(CorsRequestHandler): """API adapter info handler to return information about loaded adapters. This request hander implements the GET verb to allow a call to the appropriate URI to return a JSON-encoded dictionary of information about the adapters loaded by the server. """ - def initialize(self, route): - """Initialize the API adapter info handler. - - :param route: ApiRoute object calling the handler (allows adapters to be resolved) - """ - self.route = route - def get(self, version=None): """Handle API adapter info GET requests. diff --git a/src/odin_control/http/handlers/api_version.py b/src/odin_control/http/handlers/api_version.py index 205616bf..944000ad 100644 --- a/src/odin_control/http/handlers/api_version.py +++ b/src/odin_control/http/handlers/api_version.py @@ -7,23 +7,16 @@ """ import json -from tornado.web import RequestHandler +from odin_control.http.handlers.cors_request import CorsRequestHandler -class ApiVersionHandler(RequestHandler): +class ApiVersionHandler(CorsRequestHandler): """API version handler to allow client to resolve supported version. This request handler implements the GET verb to allow a call to the appropriate URI to return the supported API version as JSON. """ - def initialize(self, route): - """Initialise the ApiVersionHandler. - - :param route: ApiRoute object calling the handler (allows API version to be resolved) - """ - self.route = route - def get(self): """Handle API version GET requests.""" accept_types = self.request.headers.get('Accept', 'application/json').split(',') diff --git a/src/odin_control/http/handlers/cors_request.py b/src/odin_control/http/handlers/cors_request.py new file mode 100644 index 00000000..4121d564 --- /dev/null +++ b/src/odin_control/http/handlers/cors_request.py @@ -0,0 +1,50 @@ +"""Cross-Origin Resource Sharing (CORS) handler for odin-control. + +This module implements a simple handler to respond to CORS preflight requests from clients, +allowing CORS requests to be made to API handlers. + +Tim Nicholls, STFC Detector Systems Software Group. +""" +from tornado.web import RequestHandler + + +class CorsRequestHandler(RequestHandler): + """Handler to respond to CORS requests. + + This handler responds to HTTP OPTIONS requests and sets the appropriate headers to allow CORS + requests from the specified origin. This class is intended to be used as a base class for API + handlers to allow CORS requests to be made to API endpoints. + """ + + def __init__(self, *args, **kwargs): + """Construct the CorsRequestHandler object. + + This method constructs the CorsRequestHandler object, calling the superclass constructor and + setting the route object to None. + """ + self.route = None + super().__init__(*args, **kwargs) + + def initialize(self, route, enable_cors, cors_origin): + """Initialize the CorsRequestHandler object. + + :param route: ApiRoute object calling the handler (allows adapters to be resolved) + :param enable_cors: enable CORS support by setting appropriate headers + :param cors_origin: allowed origin for CORS requests + """ + self.route = route + if enable_cors: + self.set_header("Access-Control-Allow-Origin", cors_origin) + self.set_header("Access-Control-Allow-Headers", "x-requested-with,content-type") + self.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + + def options(self, *_): + """Handle an OPTIONS request. + + This method handles an OPTION request and is provided to allow browser clients to employ + CORS preflight requests to determine if non-simple requests are allowed. + + :param _: unused arguments passed to the method by the URI matching in the handler + """ + # Set status to indicate successful request with no content returned + self.set_status(204) diff --git a/src/odin_control/http/routes/api.py b/src/odin_control/http/routes/api.py index cb16bf7a..21f6003c 100644 --- a/src/odin_control/http/routes/api.py +++ b/src/odin_control/http/routes/api.py @@ -33,8 +33,13 @@ def __init__(self, enable_cors=False, cors_origin="*", api_version=None): # Store the API version string so it can be used by handlers self.api_version = api_version + # Build a dict of params to be passed to API handler initialisation calls + handler_params = { + "route": self, "enable_cors": enable_cors, "cors_origin": cors_origin + } + # Define a default handler which can return the supported API version - self.add_handler((r"/api/?", ApiVersionHandler, {"route": self})) + self.add_handler((r"/api/?", ApiVersionHandler, handler_params)) # Define the API handler URL specs depending on whether versioning is enabled if self.api_version: @@ -45,14 +50,7 @@ def __init__(self, enable_cors=False, cors_origin="*", api_version=None): api_specs = [r"/api/(.*?)/(.*)", r"/api/(.*?)/?"] # Define a handler which can return a list of loaded adapters - self.add_handler( - (adapter_info_spec, ApiAdapterInfoHandler, {"route": self}) - ) - - # Build a dict of params to be passed to API handler initialisation calls - handler_params = { - "route": self, "enable_cors": enable_cors, "cors_origin": cors_origin - } + self.add_handler((adapter_info_spec, ApiAdapterInfoHandler, handler_params)) # Define the handler for API calls. The expected URI syntax, which is enforced by the # validate_api_request decorator, is the following: diff --git a/tests/handlers/fixtures.py b/tests/handlers/fixtures.py index ce84fd49..ae022974 100644 --- a/tests/handlers/fixtures.py +++ b/tests/handlers/fixtures.py @@ -115,7 +115,8 @@ def test_api_handler_cors(request): def test_api_adapter_info_handler(request): """Parameterised test fixture for testing the ApiAdapterInfoHandler class.""" test_api_adapter_info_handler = TestHandler( - ApiAdapterInfoHandler, async_adapter=False, api_version=request.param + ApiAdapterInfoHandler, async_adapter=False, api_version=request.param, + enable_cors=False, cors_origin="*" ) test_api_adapter_info_handler.request.headers = {'Accept': 'application/json'} yield test_api_adapter_info_handler @@ -124,7 +125,8 @@ def test_api_adapter_info_handler(request): def test_api_version_handler(request): """Test fixture for testing the ApiVersionHandler class.""" test_api_version_handler = TestHandler( - ApiVersionHandler, async_adapter=False, api_version=request.param + ApiVersionHandler, async_adapter=False, api_version=request.param, + enable_cors=False, cors_origin="*" ) test_api_version_handler.request.headers = {'Accept': 'application/json'} yield test_api_version_handler diff --git a/tests/handlers/test_cors_request.py b/tests/handlers/test_cors_request.py new file mode 100644 index 00000000..bd8f16cd --- /dev/null +++ b/tests/handlers/test_cors_request.py @@ -0,0 +1,75 @@ +"""Tests for the CorsRequestHandler class.""" + +from unittest.mock import Mock + +import pytest + +from odin_control.http.handlers.cors_request import CorsRequestHandler + + +class TestCorsRequestHandler: + """Test cases for the CorsRequestHandler class.""" + + @pytest.mark.parametrize("enable_cors,cors_origin", [ + (True, "*"), + (True, "https://example.com"), + (True, "https://localhost:8080"), + (False, "*"), + (False, "https://example.com"), + ]) + def test_cors_configuration_combinations(self, enable_cors, cors_origin): + """Test various combinations of CORS configuration parameters.""" + # Create mock objects + app = Mock() + app.ui_methods = {} + request = Mock() + route = Mock() + + # Create handler + handler = CorsRequestHandler( + app, request, route=route, enable_cors=enable_cors, cors_origin=cors_origin + ) + + # Check route is stored + assert handler.route == route + + # Define expected CORS headers + cors_headers = { + "Access-Control-Allow-Origin": cors_origin, + "Access-Control-Allow-Headers": "x-requested-with,content-type", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS" + } + + if enable_cors: + # Check that all CORS headers are set with correct values + for header_name, expected_value in cors_headers.items(): + assert handler._headers[header_name] == expected_value + else: + # Check that no CORS headers are set + for header_name in cors_headers.keys(): + assert header_name not in handler._headers + + @pytest.mark.parametrize("args", [ + (), # No arguments + ("subsystem", "path", "extra_arg"), # Multiple arguments + ], ids=["no_args", "with_args"]) + def test_options_request_handling(self, args): + """Test that OPTIONS requests are handled correctly with various argument combinations.""" + # Create mock objects + app = Mock() + app.ui_methods = {} + request = Mock() + route = Mock() + + # Create handler + handler = CorsRequestHandler(app, request, route=route, enable_cors=True, cors_origin="*") + + # Mock the set_status method to track calls + handler.set_status = Mock() + + # Call options method (simulating OPTIONS request) + handler.options(*args) + + # Check that status 204 (No Content) is set + handler.set_status.assert_called_once_with(204) + From acbcd40eaf378f31925e640ed290db4548d45cdf Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Wed, 25 Mar 2026 09:26:36 +0000 Subject: [PATCH 11/13] Migrate documentation generation to properdocs (#83) * Migrate to properdocs and mkdocs-materialx theme * Extended getting started doc * Update docs workflow for * Fix docs link in README.md * Increase size of topbar logo for visiblity --- .github/workflows/docs.yml | 53 +++ .gitignore | 3 + README.md | 15 +- docs/Makefile | 236 ----------- docs/conf.py | 293 -------------- docs/developer-guide/index.md | 5 + docs/getting-started.md | 367 ++++++++++++++++++ docs/images/odin.png | Bin 0 -> 93763 bytes docs/index.md | 2 + docs/index.rst | 29 -- docs/scripts/gen_ref_pages.py | 35 ++ docs/server_ref/modules.rst | 7 - docs/server_ref/odin.adapters.rst | 30 -- docs/server_ref/odin.config.rst | 22 -- docs/server_ref/odin.http.routes.rst | 38 -- docs/server_ref/odin.http.rst | 29 -- docs/server_ref/odin.rst | 31 -- docs/stylesheets/extra.css | 12 + docs/user-guide/configuration.md | 0 docs/user-guide/index.md | 8 + docs/user-guide/installation.md | 8 + docs/user-guide/key-concepts.md | 7 + properdocs.yml | 78 ++++ pyproject.toml | 13 +- src/odin_control/adapters/async_dummy.py | 2 +- .../adapters/async_parameter_tree.py | 2 +- .../adapters/base_parameter_tree.py | 2 +- src/odin_control/adapters/base_proxy.py | 4 +- src/odin_control/adapters/dummy.py | 5 +- src/odin_control/adapters/system_status.py | 6 +- src/odin_control/adapters/util.py | 2 +- src/odin_control/config/parser.py | 2 +- src/odin_control/util.py | 5 +- 33 files changed, 615 insertions(+), 736 deletions(-) create mode 100644 .github/workflows/docs.yml delete mode 100644 docs/Makefile delete mode 100644 docs/conf.py create mode 100644 docs/developer-guide/index.md create mode 100644 docs/getting-started.md create mode 100644 docs/images/odin.png create mode 100644 docs/index.md delete mode 100644 docs/index.rst create mode 100644 docs/scripts/gen_ref_pages.py delete mode 100644 docs/server_ref/modules.rst delete mode 100644 docs/server_ref/odin.adapters.rst delete mode 100644 docs/server_ref/odin.config.rst delete mode 100644 docs/server_ref/odin.http.routes.rst delete mode 100644 docs/server_ref/odin.http.rst delete mode 100644 docs/server_ref/odin.rst create mode 100644 docs/stylesheets/extra.css create mode 100644 docs/user-guide/configuration.md create mode 100644 docs/user-guide/index.md create mode 100644 docs/user-guide/installation.md create mode 100644 docs/user-guide/key-concepts.md create mode 100644 properdocs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..95613944 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,53 @@ +name: Deploy Documentation + +on: + push: + tags: + - '*' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + docs: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Build documentation + run: properdocs build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 92d6d608..046d96f1 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,6 @@ tags # Visual studio code metadata .vscode/ + +# Ignore mkdocs site generation output +site/ diff --git a/README.md b/README.md index 541367ea..0d31e075 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,17 @@ # odin-control [![Test odin-control](https://github.com/odin-detector/odin-control/actions/workflows/test_odin_control.yml/badge.svg)](https://github.com/odin-detector/odin-control/actions/workflows/test_odin_control.yml) -[![codecov](https://codecov.io/gh/odin-detector/odin-control/branch/master/graph/badge.svg?token=Urucx8wsTU)](https://codecov.io/gh/odin-detector/odin-control) \ No newline at end of file +[![codecov](https://codecov.io/gh/odin-detector/odin-control/branch/master/graph/badge.svg?token=Urucx8wsTU)](https://codecov.io/gh/odin-detector/odin-control) +[![PyPI](https://img.shields.io/pypi/v/odin-control.svg)](https://pypi.org/project/odin-control) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) + +Source | +:---: | :---: +PyPI | `pip install odin-control` +Documentation | +Releases | + +odin-control is a Python web application framework designed to support integration of the control +plane of scientific detector systems. Based on the [Tornado](https://tornadoweb.org) framework, +odin-control provides a REST-like API interface to a set of dynamically-loaded plugins, known as +*adapters*, which control the underlying detector system. diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 4be601eb..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,236 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXAPIDOC = sphinx-apidoc -PAPER = -BUILDDIR = _build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) - $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don\'t have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " applehelp to make an Apple Help Book" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " epub3 to make an epub3" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - @echo " coverage to run coverage check of the documentation (if enabled)" - @echo " dummy to check syntax errors of document sources" - -.PHONY: clean -clean: - rm -rf $(BUILDDIR)/* - -.PHONY: html -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -.PHONY: dirhtml -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -.PHONY: singlehtml -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -.PHONY: pickle -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -.PHONY: json -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -.PHONY: htmlhelp -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -.PHONY: qthelp -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/ODIN.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/ODIN.qhc" - -.PHONY: applehelp -applehelp: - $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp - @echo - @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." - @echo "N.B. You won't be able to view it unless you put it in" \ - "~/Library/Documentation/Help or install it in your application" \ - "bundle." - -.PHONY: devhelp -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/ODIN" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/ODIN" - @echo "# devhelp" - -.PHONY: epub -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -.PHONY: epub3 -epub3: - $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 - @echo - @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." - -.PHONY: latex -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -.PHONY: latexpdf -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: latexpdfja -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: text -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -.PHONY: man -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -.PHONY: texinfo -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -.PHONY: info -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -.PHONY: gettext -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -.PHONY: changes -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -.PHONY: linkcheck -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -.PHONY: doctest -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -.PHONY: coverage -coverage: - $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage - @echo "Testing of coverage in the sources finished, look at the " \ - "results in $(BUILDDIR)/coverage/python.txt." - -.PHONY: xml -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -.PHONY: pseudoxml -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." - -.PHONY: dummy -dummy: - $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy - @echo - @echo "Build finished. Dummy builder generates no files." - -.PHONY: apidoc -apidoc: - $(SPHINXAPIDOC) -f -o server_ref ../server/odin - diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index c9c2a152..00000000 --- a/docs/conf.py +++ /dev/null @@ -1,293 +0,0 @@ -# -*- coding: utf-8 -*- -# -# ODIN documentation build configuration file, created by -# sphinx-quickstart on Tue May 3 13:53:50 2016. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys -import os - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) -sys.path.insert(0, os.path.abspath('../server/odin')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', - 'sphinx.ext.intersphinx' -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'ODIN' -copyright = u'2016, Tim Nicholls' -author = u'Tim Nicholls' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = u'0.1' -# The full version, including alpha/beta/rc tags. -release = u'0.1' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - -intersphinx_mapping = {'python': ('https://docs.python.org/2.7', - None)} - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = 'sphinx_rtd_theme' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. -# " v documentation" by default. -#html_title = u'ODIN v0.1' - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (relative to this directory) to use as a favicon of -# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not None, a 'Last updated on:' timestamp is inserted at every page -# bottom, using the given strftime format. -# The empty string is equivalent to '%b %d, %Y'. -#html_last_updated_fmt = None - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' -#html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# 'ja' uses this config value. -# 'zh' user can custom change `jieba` dictionary path. -#html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = 'ODINdoc' - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'ODIN.tex', u'ODIN Documentation', - u'Tim Nicholls', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'odin', u'ODIN Documentation', - [author], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'ODIN', u'ODIN Documentation', - author, 'ODIN', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False diff --git a/docs/developer-guide/index.md b/docs/developer-guide/index.md new file mode 100644 index 00000000..d31c6fbc --- /dev/null +++ b/docs/developer-guide/index.md @@ -0,0 +1,5 @@ +This is the developer guide for odin-control. + +- [API Reference](../reference/index.md) + + diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..ab6fbd0e --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,367 @@ +## Installation + +odin-control can be installed into your python environment with the following command: + +```bash +pip install odin-control +``` + +For more detailed information and alternative installation methods, see the [Installation Guide]. + +## Running odin-control + +You can verify that odin-control has been correctly installed into your python environment with +the following command: + +```bash +$ odin_control --version +odin control version +``` + +odin-control requires configuration to do anything useful. You can get help with its command-line +options in the usual way: + +```bash +$ odin_control --help +usage: odin_control [-h] [--version] [--config FILE] [--adapters ADAPTERS] [--http_addr HTTP_ADDR] [--http_port HTTP_PORT] [--enable_http ENABLE_HTTP] + [--https_port HTTPS_PORT] [--enable_https ENABLE_HTTPS] [--ssl_cert_file SSL_CERT_FILE] [--ssl_key_file SSL_KEY_FILE] [--debug_mode DEBUG_MODE] + [--access_logging debug|info|warning|error|none] [--static_path STATIC_PATH] [--enable_cors ENABLE_CORS] [--cors_origin CORS_ORIGIN] + [--api_version API_VERSION] [--graylog_server GRAYLOG_SERVER] [--graylog_logging_level GRAYLOG_LOGGING_LEVEL] + [--graylog_static_fields GRAYLOG_STATIC_FIELDS] [--log_file_max_size LOG_FILE_MAX_SIZE] [--log_file_num_backups LOG_FILE_NUM_BACKUPS] + [--log_file_prefix PATH] [--log_rotate_interval LOG_ROTATE_INTERVAL] [--log_rotate_mode LOG_ROTATE_MODE] [--log_rotate_when LOG_ROTATE_WHEN] + [--log_to_stderr LOG_TO_STDERR] [--logging debug|info|warning|error|none] + +options: + -h, --help show this help message and exit + --version Show the server version information and exit + --config FILE Specify a configuration file to parse + --adapters ADAPTERS Comma-separated list of API adapters to load + +<... snip ...> +``` +See [Configuration] for details on the meaning of each options. The typical way to +configure odin-control is to specify a configuration file with the `--config` +option: + +```bash +odin_control --config +``` +where `config_file_name` points to a INI-style configuration file on your host. You can create a +simple example by pasting the follwing into a file (called, for example `test.cfg`): + +``` ini title="test.cfg" +[server] +debug_mode = 1 +http_port = 8888 +http_addr = 127.0.0.1 +static_path = static +adapters = system_info + +[tornado] +logging = debug + +[adapter.system_info] +module = odin_control.adapters.system_info.SystemInfoAdapter +``` +and running odin_control with that file: + +```bash +odin-control --config test.cfg +``` +which will result in output logged to the terminal: + +``` +[D YYMMDD hh:mm:dd selector_events:64] Using selector: KqueueSelector +[D YYMMDD hh:mm:dd adapter:62] SystemInfoAdapter loaded +[D YYMMDD hh:mm:dd api:103] Registered API adapter class SystemInfoAdapter from module odin_control.adapters.system_info for path system_info +[D YYMMDD hh:mm:dd adapter:73] SystemInfoAdapter initialize called with 1 adapters +[W YYMMDD hh:mm:dd default:38] Default handler static path does not exist: static +[I YYMMDD hh:mm:dd server:81] HTTP server listening on 127.0.0.1:8888 +``` + +!!! note + You can safely ignore the warning `"Default handler static path does not exist"` - more on that + later. + +!!! warning + In the example, the `http_addr` parameter in the configuration limits access to the local + loopback network interface (`127.0.0.1`). You can modify this to suit your own environment + but **exercise caution** if exposing your odin-control instance to the wider network. + +You can leave odin_control running to follow the next sections of this guide, or shut it down using +the usual key sequence, e.g. `Ctrl-C`. + +## Interacting with an API + +You can now interact with an API provided by odin-control with a suitable client application. +[Curl](https://curl.se/) is commonly used and widely available, but we recommend +[httpie](https://httpie.io/cli) for ease of use; it can be installed as a standalone application or +into your python virtual environment if you prefer. + +For instance, so see what [API adapters](user-guide/key-concepts.md#adapters) are loaded, enter the +following command: + +```bash +http http://127.0.0.1:8888/api/adapters +``` + +which will return output like the following: + +```json +HTTP/1.1 200 OK +Content-Length: 143 +Content-Type: application/json; charset=UTF-8 +Date: Fri, 06 Mar 2026 09:25:29 GMT +Etag: "38fdbbb91794fef1105f7ff38f1f60f92c277559" +Server: TornadoServer/6.4.1 + +{ + "adapters": { + "system_info": { + "module": "odin_control.adapters.system_info.SystemInfoAdapter", + "version": "2.0.0" + } + } +} +``` +!!! note + An equivalent command using curl would be: + + `curl -s http://127.0.0.1:8888/api/adapters | python -m json.tool`. + + Piping the output to `python -m json.tool` formats the response for better readability. + +Note that the response from odin-control is JSON-formatted; this is the common content type used to +interact with APIs in odin-control. The `./api/adapters` endpoint is bulit into odin-control and +returns information about the loaded adapters, which python package and module they are provided by, +and version information. This allows a client to interrogate a running instance to determine what is +loaded. In this case a single adapter `system_info` is loaded, which is provided by the odin-control +package itself. + +To interact with the `system_info` adapter API, enter the following command: + +```bash +http http://127.0.0.1:8888/api/system_info +``` + +which will return the following (with HTTP headers removed for clarity): + +```json +{ + "description": "Information about the system hosting this odin server instance", + "name": "system_info", + "odin_version": "2.0.0", + "platform": { + "description": "Information about the underlying platform", + "name": "platform", + "node": "hostname.example.com", + "processor": "arm", + "release": "25.3.0", + "system": "Darwin", + "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:05 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6020" + }, + "python_version": "3.12.11", + "server_uptime": 7106.5337200164795, + "tornado_version": "6.4.1" +} +``` +odin-control makes it possible to drill down into the structure provided by an adapter by extending +the requested URL path. For instance, to retrieve just the `server_uptime` value, enter the +following: + +```bash +http http://127.0.0.1:8888/api/system_info/server_uptime +``` + +which will return just the value of the `server_uptime` parameter: + +```json +{ + "value": 7646.556041955948 +} +``` +Similarly, a subtree of the API can be retrieved. For instance: + +```bash +http http://127.0.0.1:8888/api/system_info/platform +``` + +returns the `platform` subtree: + +```json +{ + "description": "Information about the underlying platform", + "name": "platform", + "node": "hostname.example.com", + "processor": "arm", + "release": "25.3.0", + "system": "Darwin", + "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:05 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6020" +} +``` + +## Modifying state + +So far, all the interaction with odin-control has been to read the state an adapter. In a REST-like +API this corresponds to making HTTP GET requests. To modify the state of one or more parameters in +an adapter, PUT requests are used with a JSON payload containing the new values. + +The `system_info` adapter used above does not expose any read-write parameters. A simple example is +available in the [odin-workshop](http://github.com/stfc-aeg/odin-workshop) repository. To use this, +first clone the repository: + +```bash +git clone https://github.com/stfc-aeg/odin-workshop +``` + +Change directory to the location of the example adapter: + +```bash +cd python +``` + +and install the adapter into your python environment (where odin-control is already installed): + +``` +pip install . +``` + +!!! note + If you wish to try modifying the adapter code, it can also be installed in + [editable mode](https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs) + by using the `-e` command option. + +The repository also contains an +[example configuration file](https://github.com/stfc-aeg/odin-workshop/blob/master/python/test/config/workshop.cfg), +or you can create it yourself: + + +```ini title="workshop.cfg" +[server] +debug_mode = 1 +http_port = 8888 +http_addr = 127.0.0.1 +static_path = test/static +adapters = workshop, system_info + +[tornado] +logging = debug + +[adapter.workshop] +module = workshop.adapter.WorkshopAdapter +background_task_enable = 1 +background_task_interval = 1.0 + +[adapter.system_info] +module = odin_control.adapters.system_info.SystemInfoAdapter +``` + +!!! Note + This configuration also loads the `system_info` adapter and shows how multiple adapters can + be run and configured. + +Run odin-control with this configuration: + +```bash +odin_control --config workshop.cfg +``` + +and note that the output shows the adapter incrementing in two background tasks: + +``` +[D YYMMDD hh:mm:ss selector_events:64] Using selector: KqueueSelector +[D YYMMDD hh:mm:ss adapter:62] WorkshopAdapter loaded +[D YYMMDD hh:mm:ss controller:120] Launching background tasks with interval 1.00 secs +[D YYMMDD hh:mm:ss adapter:41] WorkshopAdapter loaded +[D YYMMDD hh:mm:ss api:101] Registered API adapter class WorkshopAdapter from module workshop.adapter for path workshop +[D YYMMDD hh:mm:ss adapter:62] SystemInfoAdapter loaded +[D YYMMDD hh:mm:ss api:101] Registered API adapter class SystemInfoAdapter from module odin_control.adapters.system_info for path system_info +[D YYMMDD hh:mm:ss adapter:73] WorkshopAdapter initialize called with 2 adapters +[W YYMMDD hh:mm:ss adapter:83] WorkshopAdapter controller has no initialize method +[D YYMMDD hh:mm:ss adapter:73] SystemInfoAdapter initialize called with 2 adapters +[D YYMMDD hh:mm:ss default:40] Static path for default handler is test/static +[I YYMMDD hh:mm:ss server:81] HTTP server listening on 127.0.0.1:8888 +[D YYMMDD hh:mm:ss controller:148] Background IOLoop task running, count = 0 +[D YYMMDD hh:mm:ss controller:167] Background thread task running, count = 0 +[D YYMMDD hh:mm:ss controller:148] Background IOLoop task running, count = 1 +[D YYMMDD hh:mm:ss controller:167] Background thread task running, count = 1 +[D YYMMDD hh:mm:ss controller:148] Background IOLoop task running, count = 2 +[D YYMMDD hh:mm:ss controller:167] Background thread task running, count = 2 +``` + +The adapter exposes a sub-tree of parameters related to the background tasks: + +```bash +http http://128.0.0.1:8888/api/workshop/background_task +``` + +returns: + +```json +{ + "enable": true, + "interval": 1.0, + "ioloop_count": 418, + "thread_count": 417 +} +``` + +The `enable` and `interval` parameters are read-write in this instance. You can stop the background +tasks by setting `enable` to `false` using a PUT request: + +```bash +http PUT http://127.0.0.1:8888/api/workshop/background_task enable:=false +``` + +!!! note + This command is using httpie's implicit JSON support and + [JSON field syntax](https://httpie.io/docs/cli/non-string-json-fields) to create the + PUT request body. The equivalent `curl` command is (the much more verbose): + ```bash + curl -X PUT http://127.0.0.1:8888/api/workshop/background_task -H "Content-Type: application/json" -d '{"enable": false}' + ``` + +The output will show the background tasks stopping and no futher status reports: + +``` +[D YYMMDD hh:mm:ss server:141] 200 PUT /api/workshop/background_task (127.0.0.1) 1.13ms +[D YYMMDD hh:mm:ss controller:172] Background thread task stopping +``` + +You can also increase the speed of the counters by setting the interval: + +```bash +http PUT http://127.0.0.1:8888/api/workshop/background_task interval:=0.5 +``` + +and re-enabling the tasks: +```bash +http PUT http://127.0.0.1:8888/api/workshop/background_task enable:=true +``` + +which will give the following output: + +``` +[D YYMMDD hh:mm:ss controller:105] Setting background task interval to 0.500000 +[D YYMMDD hh:mm:ss adapter:91] {'ioloop_count': 535, 'thread_count': 533, 'enable': False, 'interval': 0.5} +[D YYMMDD hh:mm:ss server:141] 200 PUT /api/workshop/background_task (127.0.0.1) 2.62ms +[D YYMMDD hh:mm:ss controller:120] Launching background tasks with interval 0.50 secs +[D YYMMDD hh:mm:ss adapter:91] {'ioloop_count': 535, 'thread_count': 533, 'enable': True, 'interval': 0.5} +[D YYMMDD hh:mm:ss server:141] 200 PUT /api/workshop/background_task (127.0.0.1) 0.85ms +[D YYMMDD hh:mm:ss controller:148] Background IOLoop task running, count = 540 +[D YYMMDD hh:mm:ss controller:167] Background thread task running, count = 540 +[D YYMMDD hh:mm:ss controller:148] Background IOLoop task running, count = 560 +[D YYMMDD hh:mm:ss controller:167] Background thread task running, count = 560 +``` +Note that the task count messages appear twice as frequently. + +## Going further + +Refer to the [user guide] to learn more about installation, configuration and the key concepts of +odin-control. The [developer guide] explains more details on how to implement adapters for +controlling systems. + +[Installation Guide]: user-guide/installation.md +[Configuration]: user-guide/configuration.md +[User Guide]: user-guide/index.md +[Developer Guide]: developer-guide/index.md diff --git a/docs/images/odin.png b/docs/images/odin.png new file mode 100644 index 0000000000000000000000000000000000000000..d6f958ffc83ea97375a013b1a6bd8dabcf30ab3b GIT binary patch literal 93763 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

Oc%bv{`wQTL23M(}BlGUjZRPGiQf| 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 Date: Wed, 25 Mar 2026 09:42:06 +0000 Subject: [PATCH 12/13] Update Github action workflows for v2 (#84) * Add PyPI publisher workflow file * Migrate coverage tool config to pyproject.toml and remove legacy reference to python 2.7 in CI * Remove .codecov.yml to stop Slack notifications * Remove Slack notifications from test CI job * Remove disabled travis-ci config * Update action versions in test CI workflow * Update action versions in pypi publisher workflow --- .codecov.yml | 5 --- .coveragerc | 10 ----- .disable-travis.yml | 24 ----------- .github/workflows/pypi_publish.yml | 53 +++++++++++++++++++++++++ .github/workflows/test_odin_control.yml | 30 +++----------- pyproject.toml | 7 ++++ 6 files changed, 66 insertions(+), 63 deletions(-) delete mode 100644 .codecov.yml delete mode 100644 .coveragerc delete mode 100644 .disable-travis.yml create mode 100644 .github/workflows/pypi_publish.yml diff --git a/.codecov.yml b/.codecov.yml deleted file mode 100644 index 023c59b9..00000000 --- a/.codecov.yml +++ /dev/null @@ -1,5 +0,0 @@ -coverage: - notify: - slack: - default: - url: "secret:p86vG6xzWKglCbWuYQtBSGejyP2W0udsB8gacjwScz8BnSZTcfbIi6nkmJQU+62KZW7P2ZfJujXWYrm0OwPfSKHx4hFPm9QR+/qZBliA3ENlTiwDzNqZo6MpvClJnPksptVkM38KrjWbkXD4psXEsU34hhGca6NNCrwK6+oP98Y=" diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index b79da85e..00000000 --- a/.coveragerc +++ /dev/null @@ -1,10 +0,0 @@ -[run] -omit = */_version.py - -[paths] -source= - src/ - .tox/py*/lib/python*/site-packages/ - -[report] -omit = */_version.py \ No newline at end of file diff --git a/.disable-travis.yml b/.disable-travis.yml deleted file mode 100644 index 99886410..00000000 --- a/.disable-travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -dist: xenial -language: python -sudo: false -python: -- 2.7 -- 3.6 -- 3.7 -- 3.8 -- 3.9 -addons: - apt: - packages: - - libzmq3-dev - code_climate: - repo_token: cff0747bc9e271cfb6e363047e9119e13e09df9b4fe7a20295bbac64175d40ed -install: -- pip install tox-travis -- pip install coveralls -script: tox -after_success: -- COVERALLS_PARALLEL=true coveralls -notifications: - slack: - secure: o8qDJR9Hf3u1bzrivk/F6XLVTrdR2Pl+2qAe1Wwso3HifZt4FuhpRaJaJyZbcZEHf8kOJ6sAJ3ETsCKwBGePT52B5M715VA2XaamzADF0mQRFfP02zRZL6xMof5xCQ7BwxBmnx/PihYNbFDOvhEKxCN6umby2818W2wUAWIqU3nIKKUK9hnXXDZ+Y8HQpMw9quCoWn3Ix+fgcKjUyFZaFy50HWSayn1E+iBPxkkNzTsTkTYFrxKhAz0pI/pDiHXBIpTZhzjJTSOd/cKj4zWzq3aYMu4oL/uhews021EtiSu22jENoJf72LGnGfb3wrEqPyaY+qTv+3LRxaYajT8zhC1brpAPUISIA1qlcx3jlZTwTyAR8K3mhC1RjfzFbnt+jk33R3hQzZvg47ES5l4Pp4OyY7++ucirpGEKb2dp89FCBQsjdVajHO+o3VItawN1tngl3Rb9ll8j9t9IZfCy2yXdDnuc5Ft+LGvK85BSaYhDBEBzbGDeSxeay651vugxq6YMsZAVqc+eXEerYLySWBkl24gS+v+ASZUT7qz/PKziGrFXizvebOmIBhnhe+Y39+hvKgtCyNp8VVzaofdZf7JtUS/eMgCHSpoQuQMh/PjcYz3rsC9Lh7oxHbtOOtrgLdJ6o7Sku8gPdZ7rawRNE/WYIaAl4eKn6jsq+XQq95A= diff --git a/.github/workflows/pypi_publish.yml b/.github/workflows/pypi_publish.yml new file mode 100644 index 00000000..eca0c181 --- /dev/null +++ b/.github/workflows/pypi_publish.yml @@ -0,0 +1,53 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + build: + name: Build distribution packages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # Full history needed for setuptools_scm + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Install build dependencies + run: python -m pip install --upgrade pip build + + - name: Build distribution packages + run: python -m build + + - name: Store the distribution packages + uses: actions/upload-artifact@v7 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/') # Only publish for tagged releases + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/odin-control + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v8 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test_odin_control.yml b/.github/workflows/test_odin_control.yml index 5287fdf7..9266a10f 100644 --- a/.github/workflows/test_odin_control.yml +++ b/.github/workflows/test_odin_control.yml @@ -12,9 +12,11 @@ jobs: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -26,31 +28,11 @@ jobs: - name: Merge tox env specific coverage files run: | coverage combine - if [[ "${{ matrix.python-version }}" == 2.7* ]]; then - export COVERAGE_RC=.coveragerc-py27 - else - export COVERAGE_RC=.coveragerc - fi - coverage xml --rcfile=$COVERAGE_RC + coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: name: ${{ matrix.python-version }} fail_ci_if_error: false - - notify: - if: ${{ always() }} - runs-on: ubuntu-latest - needs: build_and_test - steps: - - name: Slack Notification on completion - uses: rtCamp/action-slack-notify@v2 - env: - SLACK_CHANNEL: odin-control-notify - SLACK_COLOR: ${{ needs.build_and_test.result }} - SLACK_ICON: https://avatars.githubusercontent.com/odin-detector?size=48 - SLACK_TITLE: "odin-control CI tests completed: ${{ needs.build_and_test.result }}" - SLACK_USERNAME: odin-detector - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/pyproject.toml b/pyproject.toml index 076dc73e..5ebb04ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,9 +60,16 @@ GitHub = "https://github.com/odin-detector/odin-control" [tool.setuptools_scm] version_file = "src/odin_control/_version.py" +[tool.coverage.run] +omit = ["*/_version.py"] + [tool.coverage.paths] source = ["src", "**/site-packages/"] +[tool.coverage.report] +omit = ["*/_version.py"] +show_missing = true + [tool.pytest.ini_options] addopts = "-vv --cov=odin_control --cov-report=term-missing --asyncio-mode=strict" From 834fa42e50e95c1d84f63bae945ab3b73f80cc0f Mon Sep 17 00:00:00 2001 From: Tim Nicholls Date: Thu, 26 Mar 2026 09:22:05 +0000 Subject: [PATCH 13/13] Update references to default branch from master to main (#85) --- README.md | 2 +- docs/getting-started.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0d31e075..4fc82bd2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # odin-control [![Test odin-control](https://github.com/odin-detector/odin-control/actions/workflows/test_odin_control.yml/badge.svg)](https://github.com/odin-detector/odin-control/actions/workflows/test_odin_control.yml) -[![codecov](https://codecov.io/gh/odin-detector/odin-control/branch/master/graph/badge.svg?token=Urucx8wsTU)](https://codecov.io/gh/odin-detector/odin-control) +[![codecov](https://codecov.io/gh/odin-detector/odin-control/branch/main/graph/badge.svg?token=Urucx8wsTU)](https://codecov.io/gh/odin-detector/odin-control) [![PyPI](https://img.shields.io/pypi/v/odin-control.svg)](https://pypi.org/project/odin-control) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) diff --git a/docs/getting-started.md b/docs/getting-started.md index ab6fbd0e..e4268e78 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -232,7 +232,7 @@ pip install . by using the `-e` command option. The repository also contains an -[example configuration file](https://github.com/stfc-aeg/odin-workshop/blob/master/python/test/config/workshop.cfg), +[example configuration file](https://github.com/stfc-aeg/odin-workshop/blob/main/python/test/config/workshop.cfg), or you can create it yourself: