diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 9c3a746b..b6e6d4eb 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -26,9 +26,12 @@ jobs: - name: install ruff run: pip install ruff - - name: run ruff linter + - name: run ruff linter src directory run: ruff check hololinked + - name: run ruff linter tests directory + run: ruff check tests/*.py tests/things/*.py tests/helper-scripts/*.py + scan: name: security scan (${{ matrix.tool }}) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index c68d663f..ad23c9ff 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ tests/run-unittest.bat # vs-code .vscode/launch.json .vs/ +todo # zmq *.ipc diff --git a/.gitmodules b/.gitmodules index 62657cd1..afc8e87d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "examples"] path = examples url = https://github.com/hololinked-dev/examples.git -[submodule "doc"] - path = doc - url = https://github.com/hololinked-dev/docs-v2.git +[submodule "docs"] + path = docs + url = https://github.com/hololinked-dev/docs.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..db56ca62 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.7 + hooks: + - id: ruff-check + files: ^hololinked/ + + - repo: https://github.com/PyCQA/bandit + rev: "1.9.2" + hooks: + - id: bandit + # needs to be fixed + args: ["pyproject.toml -r hololinked/ -b .bandit-baseline.json"] + pass_filenames: false + + - repo: https://github.com/gitleaks/gitleaks + rev: v8.26.0 + hooks: + - id: gitleaks + entry: gitleaks git --pre-commit --redact --staged --verbose + pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md index f835cfe7..d104814f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - supports structlog for logging, with colored logs and updated log statements - SAST with bandit & gitleaks integrated into CI/CD pipelines - uses pytest instead of unittests -- code improvements with isort, dependency refactoring etc. +- code improvements with isort, refactors & optimizations etc. +- fixes minor bugs & cleans up HTTP headers ## [v0.3.7] - 2025-10-30 diff --git a/doc b/doc deleted file mode 160000 index d4e965b5..00000000 --- a/doc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d4e965b5ad5b8c0b88f807d031b72e76acf9cde9 diff --git a/docs b/docs new file mode 160000 index 00000000..e8ec16a0 --- /dev/null +++ b/docs @@ -0,0 +1 @@ +Subproject commit e8ec16a0f5ff9a74c38a8a13fab26baf615e73cb diff --git a/hololinked/client/factory.py b/hololinked/client/factory.py index b2b371a1..f1f03eef 100644 --- a/hololinked/client/factory.py +++ b/hololinked/client/factory.py @@ -1,7 +1,5 @@ -import base64 import ssl import threading -import uuid import warnings from typing import Any @@ -22,11 +20,12 @@ EventAffordance, PropertyAffordance, ) -from ..utils import set_global_event_loop_policy +from ..utils import set_global_event_loop_policy, uuid_hex from .abstractions import ConsumedThingAction, ConsumedThingEvent, ConsumedThingProperty from .http.consumed_interactions import HTTPAction, HTTPEvent, HTTPProperty from .mqtt.consumed_interactions import MQTTConsumer # only one type for now from .proxy import ObjectProxy +from .security import BasicSecurity from .zmq.consumed_interactions import ( ReadMultipleProperties, WriteMultipleProperties, @@ -71,8 +70,6 @@ def zmq( - `logger`: `logging.Logger`, optional. A custom logger instance to use for logging - - `log_level`: `int`, default `logging.INFO`. - The logging level to use for the client (e.g., logging.DEBUG, logging.INFO) - `ignore_TD_errors`: `bool`, default `False`. Whether to ignore errors while fetching the Thing Description (TD) - `skip_interaction_affordances`: `list[str]`, default `[]`. @@ -87,7 +84,7 @@ def zmq( ObjectProxy An ObjectProxy instance representing the remote Thing """ - id = f"{server_id}|{thing_id}|{access_point}|{uuid.uuid4()}" + id = kwargs.get("id", f"{server_id}|{thing_id}|{access_point}|{uuid_hex()}") # configs ignore_TD_errors = kwargs.get("ignore_TD_errors", False) @@ -131,6 +128,7 @@ def zmq( logger=logger, invokation_timeout=invokation_timeout, execution_timeout=execution_timeout, + security=kwargs.get("security", None), ) # add properties @@ -278,24 +276,26 @@ def http(self, url: str, **kwargs) -> ObjectProxy: # fetch TD headers = {"Content-Type": "application/json"} - username = kwargs.get("username") - password = kwargs.get("password") - if username and password: - token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") - headers["Authorization"] = f"Basic {token}" + security = kwargs.pop("security", None) + username = kwargs.pop("username", None) + password = kwargs.pop("password", None) + if not security and username and password: + security = BasicSecurity(username=username, password=password) + if isinstance(security, BasicSecurity): + headers["Authorization"] = security.http_header response = req_rep_sync_client.get(url, headers=headers) # type: httpx.Response response.raise_for_status() TD = Serializers.json.loads(response.content) - id = f"client|{TD['id']}|HTTP|{uuid.uuid4().hex[:8]}" + id = kwargs.get("id", f"client|{TD['id']}|HTTP|{uuid_hex()}") logger = kwargs.get("logger", structlog.get_logger()).bind( component="client", client_id=id, protocol="http", thing_id=TD["id"], ) - object_proxy = ObjectProxy(id, td=TD, logger=logger, **kwargs) + object_proxy = ObjectProxy(id, td=TD, logger=logger, security=security, **kwargs) for name in TD.get("properties", []): affordance = PropertyAffordance.from_TD(name, TD) @@ -383,7 +383,7 @@ def mqtt( - `log_level`: `int`, default `logging.INFO`. The logging level to use for the client (e.g., logging.DEBUG, logging.INFO """ - id = f"mqtt-client|{hostname}:{port}|{uuid.uuid4().hex[:8]}" + id = kwargs.get("id", f"mqtt-client|{hostname}:{port}|{uuid_hex()}") logger = kwargs.get("logger", structlog.get_logger()).bind( component="client", client_id=id, diff --git a/hololinked/client/http/consumed_interactions.py b/hololinked/client/http/consumed_interactions.py index 479dc7f5..efbd56cc 100644 --- a/hololinked/client/http/consumed_interactions.py +++ b/hololinked/client/http/consumed_interactions.py @@ -66,32 +66,13 @@ def get_body_from_response( return body response.raise_for_status() - def _merge_auth_headers(self, base: dict | None = None): - headers = dict(base or {}) - - # Avoid truthiness on ObjectProxy - owner = getattr(self, "_owner_inst", None) - if owner is None: - owner = getattr(self, "owner", None) - - auth = getattr(owner, "_auth_header", None) if owner is not None else None - - # Normalize present header names (case-insensitive) - present = {k.lower() for k in headers} - - if auth: - if isinstance(auth, dict): - # Merge key-by-key if caller stored a header dict - for k, v in auth.items(): - if k.lower() not in present: - headers[k] = v - elif isinstance(auth, str): - # Caller stored just the value: "Basic abcd==" - if "authorization" not in present: - headers["Authorization"] = auth - else: - # Ignore unexpected types instead of crashing - pass + def _merge_auth_headers(self, base: dict[str, str]) -> dict[str, str]: + headers = base or {} + + if not self.owner_inst or self.owner_inst._security is None: + return headers + if not any(key.lower() == "authorization" for key in headers.keys()): + headers["Authorization"] = self.owner_inst._security.http_header return headers @@ -347,7 +328,9 @@ def listen(self, form: Form, callbacks: list[Callable], concurrent: bool = False try: with self._sync_http_client.stream( - method="GET", url=form.href, headers=self._merge_auth_headers({"Accept": "text/event-stream"}) + method="GET", + url=form.href, + headers=self._merge_auth_headers({"Accept": "text/event-stream"}), ) as resp: resp.raise_for_status() interrupting_event = threading.Event() @@ -383,7 +366,9 @@ async def async_listen( try: async with self._async_http_client.stream( - method="GET", url=form.href, headers=self._merge_auth_headers({"Accept": "text/event-stream"}) + method="GET", + url=form.href, + headers=self._merge_auth_headers({"Accept": "text/event-stream"}), ) as resp: resp.raise_for_status() interrupting_event = asyncio.Event() diff --git a/hololinked/client/proxy.py b/hololinked/client/proxy.py index 1c487c9b..49a3a64e 100644 --- a/hololinked/client/proxy.py +++ b/hololinked/client/proxy.py @@ -1,5 +1,3 @@ -import base64 - from typing import Any, Callable import structlog @@ -29,7 +27,7 @@ class ObjectProxy: "_events", "_noblock_messages", "_schema_validator", - "_auth_header", + "_security", ] ) @@ -59,16 +57,10 @@ def __init__(self, id: str, **kwargs) -> None: self._allow_foreign_attributes = kwargs.get("allow_foreign_attributes", False) self._noblock_messages = dict() # type: dict[str, ConsumedThingAction | ConsumedThingProperty] self._schema_validator = kwargs.get("schema_validator", None) + self._security = kwargs.get("security", None) self.logger = kwargs.pop("logger", structlog.get_logger()) self.td = kwargs.get("td", dict()) # type: dict[str, Any] - self._auth_header = None - username = kwargs.get("username") - password = kwargs.get("password") - if username and password: - token = f"{username}:{password}".encode("utf-8") - self._auth_header = {"Authorization": f"Basic {base64.b64encode(token).decode('utf-8')}"} - def __getattribute__(self, __name: str) -> Any: obj = super().__getattribute__(__name) if isinstance(obj, ConsumedThingProperty): diff --git a/hololinked/client/security.py b/hololinked/client/security.py new file mode 100644 index 00000000..205cb6e2 --- /dev/null +++ b/hololinked/client/security.py @@ -0,0 +1,19 @@ +import base64 + +from pydantic import BaseModel + + +class BasicSecurity(BaseModel): + """Basic Security Scheme with username and password""" + + credentials: str + + def __init__(self, username: str, password: str, use_base64: bool = True): + credentials = f"{username}:{password}" + if use_base64: + credentials = base64.b64encode(credentials.encode("utf-8")).decode("utf-8") + self._credentials = f"Basic {credentials}" + + @property + def http_header(self) -> str: + return self._credentials diff --git a/hololinked/core/actions.py b/hololinked/core/actions.py index 42077dd3..72fd66ac 100644 --- a/hololinked/core/actions.py +++ b/hololinked/core/actions.py @@ -159,7 +159,7 @@ def validate_call(self, args, kwargs: dict[str, Any]) -> None: else: raise StateMachineError( "Thing '{}' is in '{}' state, however action can be executed only in '{}' state".format( - f"{self.owner.__class__}.{self.owner_inst.id}", + self.owner_inst, self.owner_inst.state, self.execution_info.state, ) diff --git a/hololinked/core/state_machine.py b/hololinked/core/state_machine.py index 0ba7bba5..3b6e6c62 100644 --- a/hololinked/core/state_machine.py +++ b/hololinked/core/state_machine.py @@ -281,8 +281,10 @@ def set_state(self, value: str | StrEnum | Enum, push_event: bool = True, skip_c ValueError: if the state is not found in the allowed states """ - if value in self.states: + given_state = self.descriptor._get_machine_compliant_state(value) + if given_state == self.current_state: + return previous_state = self.current_state next_state = self.descriptor._get_machine_compliant_state(value) self.owner._state_machine_state = next_state @@ -366,6 +368,6 @@ def prepare_object_FSM(instance: Thing) -> None: if cls.state_machine and isinstance(cls.state_machine, StateMachine): cls.state_machine.validate(instance) instance.logger.info( - f"setup state machine, states={[state.name for state in cls.state_machine.states]}, " + f"setup state machine, states={[state.name if hasattr(state, 'name') else state for state in cls.state_machine.states]}, " + f"initial_state={cls.state_machine.initial_state}" ) diff --git a/hololinked/core/thing.py b/hololinked/core/thing.py index d61b5f27..63d536dc 100644 --- a/hololinked/core/thing.py +++ b/hololinked/core/thing.py @@ -266,7 +266,7 @@ def run_with_zmq_server( def run_with_http_server( self, port: int = 8080, - address: str = "0.0.0.0", + address: str = "0.0.0.0", # SAST(id='hololinked.core.thing.Thing.run_with_http_server.address', description='B104:hardcoded_bind_all_interfaces', tool='bandit') # host: str = None, allowed_clients: str | list[str] | None = None, ssl_context: ssl.SSLContext | None = None, diff --git a/hololinked/core/zmq/rpc_server.py b/hololinked/core/zmq/rpc_server.py index dcbf339e..0ef11896 100644 --- a/hololinked/core/zmq/rpc_server.py +++ b/hololinked/core/zmq/rpc_server.py @@ -682,13 +682,17 @@ def get_thing_description( "This server cannot generate TD for TCP protocol, consider using thing model directly." ) req_rep_socket_address = self.tcp_server.socket_address # type: str - req_rep_socket_address = req_rep_socket_address.replace("*", socket.gethostname()).replace( + req_rep_socket_address = req_rep_socket_address.replace( + "*", socket.gethostname() + ).replace( "0.0.0.0", socket.gethostname() - ) + ) # SAST(id='hololinked.core.zmq.rpc_server.RPCServer.get_thing_description.req_rep_socket_address', description='B104:hardcoded_bind_all_interfaces', tool='bandit') pub_sub_socket_address = self.tcp_event_publisher.socket_address # type: str - pub_sub_socket_address = pub_sub_socket_address.replace("*", socket.gethostname()).replace( + pub_sub_socket_address = pub_sub_socket_address.replace( + "*", socket.gethostname() + ).replace( "0.0.0.0", socket.gethostname() - ) + ) # SAST(id='hololinked.core.zmq.rpc_server.RPCServer.get_thing_description.pub_sub_socket_address', description='B104:hardcoded_bind_all_interfaces', tool='bandit') else: raise ValueError(f"Unsupported protocol '{protocol}' for ZMQ.") diff --git a/hololinked/serializers/serializers.py b/hololinked/serializers/serializers.py index 97f73f79..1b7e615f 100644 --- a/hololinked/serializers/serializers.py +++ b/hololinked/serializers/serializers.py @@ -30,7 +30,7 @@ import inspect import io import json as pythonjson -import pickle +import pickle # SAST(id='hololinked.serializers.serializers.pickle_import', description='B403:blacklist', tool='bandit') import uuid import warnings @@ -207,6 +207,7 @@ def dumps(self, data) -> bytes: if global_config.ALLOW_PICKLE: return pickle.dumps(data) + # SAST(id='hololinked.serializers.serializers.PickleSerializer.dumps', description='B301:blacklist', tool='bandit') raise RuntimeError("Pickle serialization is not allowed by the global configuration") def loads(self, data) -> Any: @@ -215,6 +216,7 @@ def loads(self, data) -> Any: if global_config.ALLOW_PICKLE: return pickle.loads(self.convert_to_bytes(data)) + # SAST(id='hololinked.serializers.serializers.PickleSerializer.loads', description='B301:blacklist', tool='bandit') raise RuntimeError("Pickle deserialization is not allowed by the global configuration") @property diff --git a/hololinked/server/__init__.py b/hololinked/server/__init__.py index 8d3cace5..71da8a3e 100644 --- a/hololinked/server/__init__.py +++ b/hololinked/server/__init__.py @@ -1,6 +1,5 @@ import asyncio import threading -import uuid import warnings from ..config import global_config @@ -9,6 +8,7 @@ cancel_pending_tasks_in_current_loop, forkable, get_current_async_loop, + uuid_hex, ) from .server import BaseProtocolServer @@ -35,7 +35,7 @@ def run(*servers: BaseProtocolServer, forked: bool = False) -> None: rpc_server = zmq_servers[0] else: rpc_server = RPCServer( - id=f"rpc-broker-{uuid.uuid4().hex[:8]}", + id=f"rpc-broker-{uuid_hex()}", things=things, context=global_config.zmq_context(), ) diff --git a/hololinked/server/http/__init__.py b/hololinked/server/http/__init__.py index 94f24e35..a3119a07 100644 --- a/hololinked/server/http/__init__.py +++ b/hololinked/server/http/__init__.py @@ -64,6 +64,7 @@ class HTTPServer(BaseProtocolServer): """ address = IPAddress(default="0.0.0.0", doc="IP address") # type: str + # SAST(id='hololinked.server.http.HTTPServer.address', description='B104:hardcoded_bind_all_interfaces', tool='bandit') """IP address, especially to bind to all interfaces or not""" ssl_context = ClassSelector( @@ -126,7 +127,7 @@ def __init__( self, *, port: int = 8080, - address: str = "0.0.0.0", + address: str = "0.0.0.0", # SAST(id='hololinked.server.http.HTTPServer.__init__.address', description='B104:hardcoded_bind_all_interfaces', tool='bandit') things: list[Thing] | None = None, # host: Optional[str] = None, logger: logging.Logger | None = None, @@ -542,6 +543,8 @@ def add_interaction_affordances( for action in actions: if action in self: continue + elif action.name == "get_thing_model": + continue route = self.adapt_route(action.name) if action.thing_id is not None: path = f"/{action.thing_id}{route}" @@ -554,8 +557,15 @@ def add_interaction_affordances( path = f"/{event.thing_id}{route}" self.server.add_event(URL_path=path, event=event, handler=self.server.event_handler) - # thing description handler + # thing model handler get_thing_model_action = next((action for action in actions if action.name == "get_thing_model"), None) + self.server.add_action( + URL_path=f"/{thing_id}/resources/wot-tm" if thing_id else "/resources/wot-tm", + action=get_thing_model_action, + http_method=("GET",), + ) + + # thing description handler get_thing_description_action = deepcopy(get_thing_model_action) get_thing_description_action.override_defaults(name="get_thing_description") self.server.add_action( @@ -703,6 +713,7 @@ def get_basepath(self, authority: str = None, use_localhost: bool = False) -> st if not use_localhost: return f"{protocol}://{socket.gethostname()}{port}" if self.server.address == "0.0.0.0" or self.server.address == "127.0.0.1": + # SAST(id='hololinked.server.http.ApplicationRouter.get_basepath', description='B104:hardcoded_bind_all_interfaces', tool='bandit') return f"{protocol}://127.0.0.1{port}" elif self.server.address == "::": return f"{protocol}://[::1]{port}" diff --git a/hololinked/server/http/handlers.py b/hololinked/server/http/handlers.py index 77a2a05a..1cc55f78 100644 --- a/hololinked/server/http/handlers.py +++ b/hololinked/server/http/handlers.py @@ -1,5 +1,4 @@ import copy -import uuid from typing import Any, Optional @@ -34,18 +33,18 @@ PropertyAffordance, ) from ...td.forms import Form -from ...utils import format_exception_as_json, get_current_async_loop +from ...utils import format_exception_as_json, get_current_async_loop, uuid_hex try: from ..security import BcryptBasicSecurity except ImportError: - BcryptBasicSecurity = None # type: ignore + BcryptBasicSecurity = None try: from ..security import Argon2BasicSecurity except ImportError: - Argon2BasicSecurity = None # type: ignore + Argon2BasicSecurity = None __error_message_types__ = [TIMEOUT, ERROR, INVALID_MESSAGE] @@ -102,8 +101,6 @@ def has_access_control(self) -> bool: """ if not self.allowed_clients and not self.security_schemes: return True - # a flag - authenticated = False # First check if the client is allowed to access the server origin = self.request.headers.get("Origin") if ( @@ -113,41 +110,45 @@ def has_access_control(self) -> bool: ): self.set_status(401, "Unauthorized") return False - # Then check an authentication scheme either if the client is allowed or if there is no such list of allowed clients + # Then check an authentication scheme either if the client is allowed + # or if there is no such list of allowed clients if not self.security_schemes: self.logger.debug("no security schemes defined, allowing access") - authenticated = True - else: - try: - authorization_header = self.request.headers.get("Authorization", None) # type: str - # will simply pass through if no such header is present - if authorization_header and "basic " in authorization_header.lower(): - for security_scheme in self.security_schemes: - if isinstance(security_scheme, (BcryptBasicSecurity, Argon2BasicSecurity)): - self.logger.info( - f"authenticating client from {origin} with {security_scheme.__class__.__name__}" - ) - authenticated = ( - security_scheme.validate_base64(authorization_header.split()[1]) - if security_scheme.expect_base64 - else security_scheme.validate( - username=authorization_header.split()[1].split(":", 1)[0], - password=authorization_header.split()[1].split(":", 1)[1], - ) - ) - break - except Exception as ex: - self.set_status(500, "Authentication error") - self.logger.error(f"error while authenticating client - {str(ex)}") - self.logger.exception(ex) - return False - if authenticated: + return True + if self.is_authenticated: self.logger.info("client authenticated successfully") return True self.set_status(401, "Unauthorized") self.logger.info("client authentication failed or is not authorized to proceed") return False # keep False always at the end + @property + def is_authenticated(self) -> bool: + """enforces authentication using the defined security schemes, freshly computed everytime""" + authorization_header = self.request.headers.get("Authorization", None) # type: str + # will simply pass through if no such header is present + authenticated = False + if authorization_header and "basic " in authorization_header.lower(): + for security_scheme in self.security_schemes: + if isinstance(security_scheme, (BcryptBasicSecurity, Argon2BasicSecurity)): + try: + self.logger.info( + "authenticating client", + origin=self.request.headers.get("Origin"), + security_scheme=security_scheme.__class__.__name__, + ) + if security_scheme.expect_base64: + authenticated = security_scheme.validate_base64(authorization_header.split()[1]) + else: + authenticated = security_scheme.validate_input( + username=authorization_header.split()[1].split(":", 1)[0], + password=authorization_header.split()[1].split(":", 1)[1], + ) + except Exception as ex: + self.logger.error(f"error while authenticating client - {str(ex)}") + return authenticated + return False + def set_access_control_allow_headers(self) -> None: """ For credential login, access control allow headers cannot be a wildcard '*'. @@ -161,12 +162,18 @@ def set_access_control_allow_headers(self) -> None: def set_custom_default_headers(self) -> None: """ - override this to set custom headers without having to reimplement entire handler + sets default headers for RPC (property read-write and action execution). The general headers are listed as follows: + + ```yaml + Content-Type: application/json + Access-Control-Allow-Origin: + ``` """ - raise NotImplementedError( - "implement set_custom_default_headers in child class to automatically call it" - + " while directing the request to Thing" - ) + # Access-Control-Allow-Credentials: true # only for cookie auth + if self.server.config.cors: + # For credential login, access control allow origin cannot be '*', + # See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#examples_of_access_control_scenarios + self.set_header("Access-Control-Allow-Origin", "*") def get_execution_parameters( self, @@ -301,27 +308,10 @@ def is_method_allowed(self, method: str) -> bool: return False if self.message_id is not None and method.upper() == "GET": return True - if method not in self.metadata.get("http_methods", []): - self.set_status(405, "method not allowed") - return False - return True - - def set_custom_default_headers(self) -> None: - """ - sets default headers for RPC (property read-write and action execution). The general headers are listed as follows: - - ```yaml - Content-Type: application/json - Access-Control-Allow-Credentials: true - Access-Control-Allow-Origin: - ``` - """ - self.set_header("Access-Control-Allow-Credentials", "true") - if self.server.config.cors: - # For credential login, access control allow origin cannot be '*', - # See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#examples_of_access_control_scenarios - self.logger.debug("setting Access-Control-Allow-Origin") - self.set_header("Access-Control-Allow-Origin", "*") + if method in self.metadata.get("http_methods", []): + return True + self.set_status(405, "method not allowed") + return False async def options(self) -> None: """ @@ -346,6 +336,7 @@ async def handle_through_thing(self, operation: str) -> None: `writeproperty`, `invokeaction`, `deleteproperty` """ try: + self.set_custom_default_headers() server_execution_context, thing_execution_context, local_execution_context, additional_payload = ( self.get_execution_parameters() ) @@ -353,9 +344,7 @@ async def handle_through_thing(self, operation: str) -> None: payload = payload if payload.value else additional_payload except Exception as ex: self.set_status(400, f"error while decoding request - {str(ex)}") - self.set_custom_default_headers() self.logger.error(f"error while decoding request - {str(ex)}") - self.logger.exception(ex) return try: # TODO - add schema validation here, we are anyway validating at some point within the ZMQ server @@ -389,25 +378,25 @@ async def handle_through_thing(self, operation: str) -> None: response_payload = self.get_response_payload(response_message) self.set_status(200, "ok") self.set_header("Content-Type", response_payload.content_type or "application/json") + if response_payload.value: + self.write(response_payload.value) except ConnectionAbortedError as ex: self.set_status(503, f"lost connection to thing - {str(ex)}") # TODO handle reconnection except Exception as ex: self.logger.error(f"error while scheduling RPC call - {str(ex)}") - self.logger.exception(ex) self.set_status(500, f"error while scheduling RPC call - {str(ex)}") response_payload = SerializableData( value=Serializers.json.dumps({"exception": format_exception_as_json(ex)}), content_type="application/json", ) response_payload.serialize() - self.set_custom_default_headers() # remaining headers are set here - if response_payload.value: self.write(response_payload.value) async def handle_no_block_response(self) -> None: """handles the no-block response for the noblock calls""" try: + self.set_custom_default_headers() self.logger.info("waiting for no-block response", message_id=self.message_id) response_message = await self.zmq_client_pool.async_recv_response( thing_id=self.resource.thing_id, @@ -418,7 +407,6 @@ async def handle_no_block_response(self) -> None: response_payload = self.get_response_payload(response_message) self.set_status(200, "ok") self.set_header("Content-Type", response_payload.content_type or "application/json") - self.set_custom_default_headers() if response_payload.value: self.write(response_payload.value) except KeyError as ex: @@ -430,7 +418,6 @@ async def handle_no_block_response(self) -> None: self.set_status(408, "timeout while waiting for response") except Exception as ex: self.logger.error(f"error while receiving no-block response - {str(ex)}") - self.logger.exception(ex) self.set_status(500, f"error while receiving no-block response - {str(ex)}") response_payload = SerializableData( value=Serializers.json.dumps({"exception": format_exception_as_json(ex)}), @@ -549,7 +536,7 @@ def set_custom_default_headers(self) -> None: self.set_header("Content-Type", "text/event-stream") self.set_header("Cache-Control", "no-cache") self.set_header("Connection", "keep-alive") - self.set_header("Access-Control-Allow-Credentials", "true") + super().set_custom_default_headers() async def get(self): """ @@ -585,7 +572,7 @@ async def handle_datastream(self) -> None: else: form = self.resource.retrieve_form(Operations.observeproperty) event_consumer = AsyncEventConsumer( - id=f"{self.resource.name}|HTTPEventTunnel|{uuid.uuid4().hex[:8]}", + id=f"{self.resource.name}|HTTPEventTunnel|{uuid_hex()}", event_unique_identifier=f"{self.resource.thing_id}/{self.resource.name}", access_point=form.href, context=global_config.zmq_context(), @@ -594,7 +581,6 @@ async def handle_datastream(self) -> None: self.set_status(200) except Exception as ex: self.logger.error(f"error while subscribing to event - {str(ex)}") - self.logger.exception(ex) self.set_status(500, f"could not subscribe to event source from thing - {str(ex)}") self.write(Serializers.json.dumps({"exception": format_exception_as_json(ex)})) return @@ -613,9 +599,7 @@ async def handle_datastream(self) -> None: break except Exception as ex: self.logger.error(f"error while pushing event - {str(ex)}") - self.logger.exception(ex) self.write(self.data_header % Serializers.json.dumps({"exception": format_exception_as_json(ex)})) - event_consumer.exit() class JPEGImageEventHandler(EventHandler): @@ -678,11 +662,10 @@ async def post(self): eventloop.create_task(self.server.async_stop()) # dont call it in sequence, its not clear whether its designed for that self.set_status(204, "ok") - self.set_header("Access-Control-Allow-Credentials", "true") except Exception as ex: self.logger.error(f"error while stopping HTTP server - {str(ex)}") - self.logger.exception(ex) self.set_status(500, f"error while stopping HTTP server - {str(ex)}") + self.set_custom_default_headers() self.finish() @@ -697,7 +680,7 @@ def initialize(self, owner_inst=None) -> None: async def get(self): self.set_status(200, "ok") - self.set_header("Access-Control-Allow-Credentials", "true") + self.set_custom_default_headers() self.finish() @@ -718,7 +701,6 @@ async def get(self): ) except Exception as ex: self.logger.error(f"error while checking readiness - {str(ex)}") - self.logger.exception(ex) self.set_status(500, f"error while checking readiness - {str(ex)}") else: if not all(reply.body[0].deserialize() is None for thing_id, reply in replies.items()): @@ -726,20 +708,13 @@ async def get(self): else: self.set_status(200, "ok") self.write({id: "ready" for id in replies.keys()}) - self.set_header("Access-Control-Allow-Credentials", "true") + self.set_custom_default_headers() self.finish() class ThingDescriptionHandler(BaseHandler): """Thing Description generation handler""" - def set_custom_default_headers(self): - self.set_header("Access-Control-Allow-Credentials", "true") - if self.server.config.cors: - # For credential login, access control allow origin cannot be '*', - # See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#examples_of_access_control_scenarios - self.set_header("Access-Control-Allow-Origin", "*") - async def get(self): if self.has_access_control: try: @@ -926,26 +901,20 @@ def add_top_level_forms(self, TD: dict[str, JSONSerializable], authority: str, u TD["forms"].append(writemultipleproperties.json()) def add_security_definitions(self, TD: dict[str, JSONSerializable]) -> None: - from ...td.security_definitions import SecurityScheme + from ...td.security_definitions import BasicSecurityScheme, NoSecurityScheme - TD["securityDefinitions"] = {} - sec_names: list[str] = [] + TD["securityDefinitions"] = dict() - schemes = getattr(self.server, "security_schemes", None) - if schemes: - for i, scheme in enumerate(schemes): + if self.server.security_schemes: + TD["security"] = [] + for scheme in self.server.security_schemes: if isinstance(scheme, (BcryptBasicSecurity, Argon2BasicSecurity)): - name = f"basic_sc_{i}" - TD["securityDefinitions"][name] = { - "scheme": "basic", - "in": "header", - } - sec_names.append(name) - - if not sec_names: - nosec = SecurityScheme() + sec = BasicSecurityScheme() + sec.build() + TD["securityDefinitions"][scheme.name] = sec.json() + TD["security"].append(scheme.name) + else: + nosec = NoSecurityScheme() nosec.build() - TD["securityDefinitions"]["nosec"] = nosec.json() TD["security"] = ["nosec"] - else: - TD["security"] = sec_names + TD["securityDefinitions"]["nosec"] = nosec.json() diff --git a/hololinked/server/mqtt.py b/hololinked/server/mqtt.py index 26694ac1..834a9f06 100644 --- a/hololinked/server/mqtt.py +++ b/hololinked/server/mqtt.py @@ -1,6 +1,5 @@ import copy import ssl -import uuid from typing import Any, Optional @@ -14,7 +13,7 @@ from ..param.parameters import ClassSelector, Selector, String from ..serializers import Serializers from ..td.interaction_affordance import EventAffordance, PropertyAffordance -from ..utils import get_current_async_loop +from ..utils import get_current_async_loop, uuid_hex from .server import BaseProtocolServer from .utils import consume_broker_pubsub_per_event, consume_broker_queue @@ -109,7 +108,7 @@ async def setup(self) -> None: ) for thing in self._broker_things: thing, td = await consume_broker_queue( - id=f"{self.hostname}:{self.port}|mqtt-publisher|{uuid.uuid4().hex[:8]}", + id=f"{self.hostname}:{self.port}|mqtt-publisher|{uuid_hex()}", server_id=thing.server_id, thing_id=thing.thing_id, access_point=thing.access_point, diff --git a/hololinked/server/security.py b/hololinked/server/security.py index f5fe944a..fd2ff75c 100644 --- a/hololinked/server/security.py +++ b/hololinked/server/security.py @@ -4,8 +4,12 @@ import base64 +from pydantic import BaseModel, PrivateAttr -class Security: +from ..utils import uuid_hex + + +class Security(BaseModel): """Type definition for security schemes""" pass @@ -17,8 +21,7 @@ class Security: class BcryptBasicSecurity(Security): """ A username and password based security scheme using bcrypt. - The password is stored as a hash and will be deleted from memory after initialization - (most likely even after you keep a reference to the object). + The password is stored as a hash. The request must supply an authorization header in of the following formats: @@ -36,10 +39,12 @@ class BcryptBasicSecurity(Security): """ username: str - _password_hash: bytes expect_base64: bool + name: str - def __init__(self, username: str, password: str, expect_base64: bool = True) -> None: + _password_hash: bytes = PrivateAttr() + + def __init__(self, username: str, password: str, expect_base64: bool = True, name: str = "") -> None: """ Parameters ---------- @@ -49,13 +54,17 @@ def __init__(self, username: str, password: str, expect_base64: bool = True) -> The password to be used for authentication expect_base64: bool Whether to expect base64 encoded credentials in the authorization header. Default is True. + name: str + An optional unique name for the security scheme """ - self.username = username + super().__init__( + username=username, + expect_base64=expect_base64, + name=name or f"bcrypt-basic-{uuid_hex()}", + ) self._password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) - del password # Remove password from memory - self.expect_base64 = expect_base64 - def validate(self, username: str, password: str) -> bool: + def validate_input(self, username: str, password: str) -> bool: """ plain validate a username and password @@ -85,7 +94,7 @@ def validate_base64(self, b64_str: str) -> bool: except (ValueError, TypeError): return False username, password = decoded.split(":", 1) - return self.validate(username, password) + return self.validate_input(username, password) except ImportError: pass @@ -96,8 +105,7 @@ def validate_base64(self, b64_str: str) -> bool: class Argon2BasicSecurity(Security): """ A username and password based security scheme using Argon2. - The password is stored as a hash and will be deleted from memory after initialization - (most likely even after you keep a reference to the object). + The password is stored as a hash. The request must supply an authorization header in of the following formats: @@ -112,17 +120,22 @@ class Argon2BasicSecurity(Security): """ username: str - _password_hash: str expect_base64: bool + name: str + + _password_hash: str = PrivateAttr() + _ph: argon2.PasswordHasher | None = PrivateAttr(default=None) - def __init__(self, username: str, password: str, expect_base64: bool = True) -> None: - self.ph = argon2.PasswordHasher() - self.username = username - self.expect_base64 = expect_base64 - self._password_hash = self.ph.hash(password) - del password # Remove password from memory + def __init__(self, username: str, password: str, expect_base64: bool = True, name: str = "") -> None: + super().__init__( + username=username, + expect_base64=expect_base64, + name=name or f"argon2-basic-{uuid_hex()}", + ) + self._ph = argon2.PasswordHasher() + self._password_hash = self._ph.hash(password) - def validate(self, username: str, password: str) -> bool: + def validate_input(self, username: str, password: str) -> bool: """ plain validate a username and password @@ -134,7 +147,7 @@ def validate(self, username: str, password: str) -> bool: if username != self.username: return False try: - return self.ph.verify(self._password_hash, password) + return self._ph.verify(self._password_hash, password) except argon2.exceptions.VerifyMismatchError: return False @@ -155,7 +168,7 @@ def validate_base64(self, b64_str: str) -> bool: except (ValueError, TypeError): return False username, password = decoded.split(":", 1) - return self.validate(username, password) + return self.validate_input(username, password) except ImportError: pass diff --git a/hololinked/server/utils.py b/hololinked/server/utils.py index db6e65b9..699808a5 100644 --- a/hololinked/server/utils.py +++ b/hololinked/server/utils.py @@ -1,5 +1,4 @@ import logging -import uuid from typing import Any, Optional @@ -10,6 +9,7 @@ from ..core import Thing from ..core.zmq import AsyncEventConsumer, AsyncZMQClient from ..td.interaction_affordance import EventAffordance +from ..utils import uuid_hex async def consume_broker_queue( @@ -84,7 +84,7 @@ async def consume_broker_queue( def consume_broker_pubsub(id: str = None, access_point: str = "INPROC") -> AsyncEventConsumer: """Consume all events from ZMQ (usually INPROC) pubsub""" return AsyncEventConsumer( - id=id or f"event-proxy-{uuid.uuid4().hex[:8]}", + id=id or f"event-proxy-{uuid_hex()}", event_unique_identifier="", access_point=access_point, context=global_config.zmq_context(), @@ -97,7 +97,7 @@ def consume_broker_pubsub_per_event(resource: EventAffordance) -> AsyncEventCons else: form = resource.retrieve_form(Operations.observeproperty) return AsyncEventConsumer( - id=f"{resource.name}|EventTunnel|{uuid.uuid4().hex[:8]}", + id=f"{resource.name}|EventTunnel|{uuid_hex()}", event_unique_identifier=f"{resource.thing_id}/{resource.name}", access_point=form.href, context=global_config.zmq_context(), diff --git a/hololinked/td/base.py b/hololinked/td/base.py index d5e5ea50..5e23e756 100644 --- a/hololinked/td/base.py +++ b/hololinked/td/base.py @@ -43,7 +43,5 @@ def format_doc(cls, doc: str): idx = doc.find(marker) if idx != -1: doc = doc[:idx] - doc = doc.replace("\n", " ") - doc = doc.replace("\t", " ") - doc = doc.lstrip().rstrip() + doc = doc.replace("\n", "").replace("\t", " ").lstrip().rstrip() return doc diff --git a/hololinked/td/data_schema.py b/hololinked/td/data_schema.py index 1bdeeea8..57dbbb50 100644 --- a/hololinked/td/data_schema.py +++ b/hololinked/td/data_schema.py @@ -42,9 +42,8 @@ class DataSchema(Schema): const: Optional[bool] = None default: Optional[Any] = None readOnly: Optional[bool] = None - writeOnly: Optional[bool] = ( - None # write only can be considered as actions with no return value, so not used in this repository - ) + writeOnly: Optional[bool] = None + # write only can be considered as actions with no return value, so not used in this repository format: Optional[str] = None unit: Optional[str] = None type: Optional[str] = None @@ -376,6 +375,7 @@ def ds_build_fields_from_property(self, property) -> None: ) for obj in objects: if any(types["type"] == JSONSchema._replacements.get(type(obj), None) for types in self.oneOf): + # just avoid duplicates of same type in oneOf continue if isinstance(property, ClassSelector): if not JSONSchema.is_allowed_type(obj): diff --git a/hololinked/td/security_definitions.py b/hololinked/td/security_definitions.py index b369ee37..04283745 100644 --- a/hololinked/td/security_definitions.py +++ b/hololinked/td/security_definitions.py @@ -1,5 +1,7 @@ from typing import Optional +from pydantic import Field + from .base import Schema @@ -17,8 +19,24 @@ class SecurityScheme(Schema): def __init__(self): super().__init__() + def build(self): + raise NotImplementedError("Please implement specific security scheme builders") + + +class NoSecurityScheme(SecurityScheme): + """No Security Scheme""" + def build(self): self.scheme = "nosec" - self.description = ( - "currently no security scheme supported - use cookie auth directly on hololinked.server.HTTPServer object" - ) + self.description = "currently no security scheme supported" + + +class BasicSecurityScheme(SecurityScheme): + """Basic Security Scheme""" + + in_: str = Field(default="header", alias="in") + + def build(self): + self.scheme = "basic" + self.description = "HTTP Basic Authentication" + self.in_ = "header" diff --git a/pyproject.toml b/pyproject.toml index 026eb608..17ba5184 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ version = "0.3.8" authors = [ {name = "Vignesh Vaidyanathan", email = "info@hololinked.dev"}, ] -description = "A ZMQ-based protocol-agnostic object oriented RPC toolkit primarily focussed for instrumentation control, data acquisition or IoT." +description = "A ZMQ-based protocol-agnostic object oriented RPC toolkit primarily focussed for instrumentation control, data acquisition or IoT" readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" @@ -54,7 +54,7 @@ dependencies = [ "sqlalchemy>2.0.21", "sqlalchemy-utils>=0.41", "psycopg2-binary>=2.9.11", - "pymongo>=4.15.2", + "pymongo>=4.15.2" ] [project.urls] @@ -69,6 +69,7 @@ dev = [ "pandas==2.2.3", "pip>=25.2", "ruff>=0.12.10", + "pre-commit>=4.5.0", ] test = [ "requests==2.32.3", diff --git a/tests/conftest.py b/tests/conftest.py index 60340fd6..9389e265 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import asyncio import logging + from dataclasses import dataclass from uuid import uuid4 diff --git a/tests/helper-scripts/client.ipynb b/tests/helper-scripts/client.ipynb index ae1c508a..7b8df05b 100644 --- a/tests/helper-scripts/client.ipynb +++ b/tests/helper-scripts/client.ipynb @@ -11,21 +11,23 @@ "import os\n", "import sys\n", "\n", + "\n", "sys.path.insert(0, os.path.abspath(os.path.join(os.getcwd(), \"../\")))\n", "\n", - "from pprint import pprint\n", - "import threading \n", "import asyncio\n", "import ssl\n", + "import threading\n", + "\n", + "from pprint import pprint\n", + "\n", + "import things\n", "\n", "import hololinked\n", - "import things \n", + "\n", "\n", "importlib.reload(hololinked)\n", "importlib.reload(things)\n", "\n", - "from hololinked.core import Thing\n", - "from hololinked.constants import Operations\n", "\n", "from things import OceanOpticsSpectrometer, TestThing" ] @@ -39,6 +41,7 @@ "source": [ "from hololinked.config import global_config\n", "\n", + "\n", "log = global_config.logger()" ] }, @@ -1143,6 +1146,8 @@ ], "source": [ "from pprint import pprint\n", + "\n", + "\n", "pprint(thing.rpc_server.get_thing_description(thing, protocol='INPROC', ignore_errors=True), indent=4)" ] }, @@ -1776,6 +1781,7 @@ "source": [ "from hololinked.client.factory import ClientFactory\n", "\n", + "\n", "object_proxy = ClientFactory.zmq(server_id=\"example-test\", thing_id=\"example-test\", protocol=\"IPC\", ignore_TD_errors=True)\n", "pprint(object_proxy.td, indent=4)" ] @@ -1954,6 +1960,7 @@ "source": [ "from hololinked.utils import get_input_model_from_signature\n", "\n", + "\n", "get_input_model_from_signature(TestThing.set_channel_pydantic.obj)" ] }, @@ -2201,6 +2208,7 @@ "source": [ "from hololinked.client import ClientFactory\n", "\n", + "\n", "mqtt_ssl = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)\n", "if not os.path.exists(\"ca.crt\"):\n", " raise FileNotFoundError(\"CA certificate 'ca.crt' not found in current directory for MQTT TLS connection\")\n", diff --git a/tests/test_00_utils.py b/tests/test_00_utils.py index 372914bd..5ac6a4fc 100644 --- a/tests/test_00_utils.py +++ b/tests/test_00_utils.py @@ -4,7 +4,11 @@ from pydantic import BaseModel, ValidationError -from hololinked.utils import get_input_model_from_signature, issubklass, pydantic_validate_args_kwargs +from hololinked.utils import ( + get_input_model_from_signature, + issubklass, + pydantic_validate_args_kwargs, +) def func_without_args(): diff --git a/tests/test_09_rpc_broker.py b/tests/test_09_rpc_broker.py index cfd99b2b..3a111f5c 100644 --- a/tests/test_09_rpc_broker.py +++ b/tests/test_09_rpc_broker.py @@ -15,7 +15,11 @@ from hololinked.client.zmq.consumed_interactions import ZMQAction, ZMQEvent, ZMQProperty from hololinked.core import Thing from hololinked.core.actions import BoundAction -from hololinked.core.zmq.brokers import AsyncZMQClient, EventDispatcher, SyncZMQClient # noqa: F401 +from hololinked.core.zmq.brokers import ( # noqa: F401 + AsyncZMQClient, + EventDispatcher, + SyncZMQClient, +) from hololinked.core.zmq.rpc_server import RPCServer from hololinked.td import ActionAffordance, EventAffordance, PropertyAffordance from hololinked.td.forms import Form diff --git a/tests/test_14_protocols_http.py b/tests/test_14_protocols_http.py index c6f7c47c..82ef2f48 100644 --- a/tests/test_14_protocols_http.py +++ b/tests/test_14_protocols_http.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from typing import Any, Generator +import httpx import pytest import requests @@ -20,11 +21,20 @@ ThingExecutionContext, default_server_execution_context, ) -from hololinked.serializers import BaseSerializer, JSONSerializer, MsgpackSerializer, PickleSerializer +from hololinked.serializers import ( + BaseSerializer, + JSONSerializer, + MsgpackSerializer, + PickleSerializer, +) from hololinked.server import stop from hololinked.server.http import HTTPServer from hololinked.server.http.handlers import RPCHandler -from hololinked.server.security import Argon2BasicSecurity, BcryptBasicSecurity, Security +from hololinked.server.security import ( + Argon2BasicSecurity, + BcryptBasicSecurity, + Security, +) from hololinked.utils import uuid_hex @@ -362,7 +372,7 @@ def do_a_path_e2e(session: requests.Session, endpoint: tuple[str, str, Any], **r assert response.json() == body # check headers assert "Access-Control-Allow-Origin" in response.headers - assert "Access-Control-Allow-Credentials" in response.headers + # assert "Access-Control-Allow-Credentials" in response.headers assert "Content-Type" in response.headers # test unsupported HTTP methods @@ -380,7 +390,7 @@ def do_a_path_e2e(session: requests.Session, endpoint: tuple[str, str, Any], **r response = session.options(path, **request_kwargs) assert response.status_code in [200, 201, 202, 204] assert "Access-Control-Allow-Origin" in response.headers - assert "Access-Control-Allow-Credentials" in response.headers + # assert "Access-Control-Allow-Credentials" in response.headers assert "Access-Control-Allow-Headers" in response.headers assert "Access-Control-Allow-Methods" in response.headers allow_methods = response.headers.get("Access-Control-Allow-Methods", []) @@ -533,26 +543,18 @@ async def test_11_object_proxy_basic(object_proxy: ObjectProxy) -> None: assert object_proxy.read_property("integration_time") == 1200 -# def notest_12_object_proxy_with_basic_auth(self): -# security_scheme = BcryptBasicSecurity(username="cliuser", password="clipass") -# port = 60013 -# thing_id = f"test-basic-proxy-{uuid.uuid4().hex[0:8]}" -# thing = OceanOpticsSpectrometer(id=thing_id, serial_number="simulation", log_level=logging.ERROR + 10) -# thing.run_with_http_server( -# forked=True, -# port=port, -# config={"cors": True}, -# security_schemes=[security_scheme], -# ) -# self.wait_until_server_ready(port=port) - -# object_proxy = ClientFactory.http( -# url=f"http://127.0.0.1:{port}/{thing_id}/resources/wot-td", -# username="cliuser", -# password="clipass", -# ) -# self.assertEqual(object_proxy.read_property("max_intensity"), 16384) -# headers = {} -# token = base64.b64encode("cliuser:clipass".encode("utf-8")).decode("ascii") -# headers["Authorization"] = f"Basic {token}" -# self.stop_server(port=port, thing_ids=[thing_id], headers=headers) +def test_12_object_proxy_with_basic_auth(port: int) -> None: + with running_thing( + id_prefix="test-auth", + port=port, + security_schemes=[BcryptBasicSecurity(username="cliuser", password="clipass")], + ) as thing: + td_endpoint = f"{hostname_prefix}:{port}/{thing.id}/resources/wot-td" + object_proxy = ClientFactory.http( + url=td_endpoint, + username="cliuser", + password="clipass", + ) + assert object_proxy.read_property("max_intensity") == 16384 + + pytest.raises(httpx.HTTPStatusError, ClientFactory.http, url=td_endpoint) diff --git a/tests/things/spectrometer.py b/tests/things/spectrometer.py index 1e91c762..1af9c88a 100644 --- a/tests/things/spectrometer.py +++ b/tests/things/spectrometer.py @@ -8,7 +8,16 @@ import numpy from hololinked.core import Event, Thing, action -from hololinked.core.properties import Boolean, ClassSelector, Integer, List, Number, Selector, String, TypedList +from hololinked.core.properties import ( + Boolean, + ClassSelector, + Integer, + List, + Number, + Selector, + String, + TypedList, +) from hololinked.core.state_machine import StateMachine from hololinked.schema_validators import JSONSchema from hololinked.serializers import JSONSerializer @@ -176,7 +185,7 @@ def apply_trigger_mode(self, value: int): def get_trigger_mode(self): try: return self._trigger_mode - except: + except AttributeError: return OceanOpticsSpectrometer.properties["trigger_mode"].default integration_time = Number( @@ -195,7 +204,7 @@ def apply_integration_time(self, value: float): def get_integration_time(self) -> float: try: return self._integration_time - except: + except AttributeError: return OceanOpticsSpectrometer.properties["integration_time"].default background_correction = Selector( diff --git a/tests/yet-to-be-integrated/not-working/test_14_rpc.py b/tests/yet-to-be-integrated/not-working/test_14_rpc.py index bb30686e..11f412c7 100644 --- a/tests/yet-to-be-integrated/not-working/test_14_rpc.py +++ b/tests/yet-to-be-integrated/not-working/test_14_rpc.py @@ -1,5 +1,4 @@ import asyncio -import logging import multiprocessing import random import threading @@ -23,14 +22,14 @@ class TestRPC(TestCase): def setUpClass(self): print("test RPC") self.thing_cls = TestThing - start_thing_forked( - thing_cls=self.thing_cls, - instance_name="test-rpc", - log_level=logging.WARN, - protocols=["IPC", "TCP"], - tcp_socket_address="tcp://*:58000", - http_server=True, - ) + # start_thing_forked( + # thing_cls=self.thing_cls, + # instance_name="test-rpc", + # log_level=logging.WARN, + # protocols=["IPC", "TCP"], + # tcp_socket_address="tcp://*:58000", + # http_server=True, + # ) self.thing_client = ObjectProxy("test-rpc") # type: TestThing @classmethod diff --git a/uv.lock b/uv.lock index 7d1e210c..da9f18ae 100644 --- a/uv.lock +++ b/uv.lock @@ -330,6 +330,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -519,6 +528,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -558,6 +576,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/ca/086311cdfc017ec964b2436fe0c98c1f4efcb7e4c328956a22456e497655/fastjsonschema-2.20.0-py3-none-any.whl", hash = "sha256:5875f0b0fa7a0043a91e93a9b8f793bcbbba9691e7fd83dca95c28ba26d21f0a", size = 23543 }, ] +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054 }, +] + [[package]] name = "fqdn" version = "1.5.1" @@ -658,6 +685,7 @@ dev = [ { name = "numpy" }, { name = "pandas" }, { name = "pip" }, + { name = "pre-commit" }, { name = "ruff" }, ] scanning = [ @@ -702,6 +730,7 @@ dev = [ { name = "numpy", specifier = ">=2.0.0" }, { name = "pandas", specifier = "==2.2.3" }, { name = "pip", specifier = ">=25.2" }, + { name = "pre-commit", specifier = ">=4.5.0" }, { name = "ruff", specifier = ">=0.12.10" }, ] scanning = [{ name = "bandit", specifier = ">=1.9.1" }] @@ -743,6 +772,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183 }, +] + [[package]] name = "idna" version = "3.11" @@ -1344,6 +1382,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + [[package]] name = "notebook" version = "7.4.7" @@ -1587,6 +1634,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "pre-commit" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429 }, +] + [[package]] name = "prometheus-client" version = "0.23.1" @@ -2596,6 +2659,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, ] +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095 }, +] + [[package]] name = "wcwidth" version = "0.2.14"