From d1b7d3b706e21978e52d156a0db5fd830bff80f1 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Mon, 13 Apr 2026 18:36:53 +0300 Subject: [PATCH 01/18] Remove Port._eval_and_write leftover --- qtoggleserver/core/ports.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index 2f88987d..f413ea4d 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -754,34 +754,6 @@ async def _write_loop(self) -> None: except asyncio.CancelledError: self.debug("eval task cancelled") - async def _eval_and_write(self, context: core_expressions.EvalContext) -> None: - expression = self.get_expression() - - try: - value = await expression.eval(context) - except expressions_exceptions.ExpressionEvalException as e: - self.debug('evaluation did not complete for expression "%s": %s', expression, e) - return - except Exception as e: - self.error('failed to evaluate expression "%s": %s', expression, e, exc_info=True) - return - - adapted_value = self.adapt_value_type(value) - if adapted_value is not None: - self.debug( - 'expression "%s" evaluated to %s (adapted to %s)', - expression, - json_utils.dumps(value), - json_utils.dumps(adapted_value), - ) - - # Only write value to port if it differs from the last written value - if not self._last_written_value or self._last_written_value[0] != adapted_value: - try: - await self.transform_and_write_value(adapted_value) - except Exception as e: - self.error("failed to write value: %s", e) - async def transform_and_write_value(self, value: NullablePortValue) -> None: """Apply write transform (if any) and write the value to the port.""" From facf0e74271e096da2ac770468cf4316baaa647c Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Mon, 13 Apr 2026 18:51:56 +0300 Subject: [PATCH 02/18] Rename Debouncer to Debounced --- qtoggleserver/core/main.py | 2 +- qtoggleserver/core/ports.py | 1 + qtoggleserver/lib/filtereventhandler.py | 2 ++ qtoggleserver/utils/{debouncer.py => debounced.py} | 4 ++-- 4 files changed, 6 insertions(+), 3 deletions(-) rename qtoggleserver/utils/{debouncer.py => debounced.py} (98%) diff --git a/qtoggleserver/core/main.py b/qtoggleserver/core/main.py index b15bf817..9071a255 100644 --- a/qtoggleserver/core/main.py +++ b/qtoggleserver/core/main.py @@ -199,7 +199,7 @@ async def handle_value_changes( if await port.is_persisted(): port.save_asap() - # Reevaluate the expressions depending on changed ports + # Reevaluate all port expressions depending on changed ports for port in core_ports.get_all(): if not port.is_enabled(): continue diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index f413ea4d..7d72792c 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -711,6 +711,7 @@ async def eval_and_push_write(self, now_ms: int) -> None: exceptions from caller, but make sure to log them.""" port_values = {p.get_id(): p.get_last_value() for p in get_all() if p.is_enabled()} + # TODO: eval attrs context = self._make_eval_context(port_values, now_ms) expression = self.get_expression() diff --git a/qtoggleserver/lib/filtereventhandler.py b/qtoggleserver/lib/filtereventhandler.py index 07c2f370..45b83806 100644 --- a/qtoggleserver/lib/filtereventhandler.py +++ b/qtoggleserver/lib/filtereventhandler.py @@ -275,6 +275,7 @@ async def accepts_port_value( return False elif isinstance(self._filter_port_value, core_expressions.Expression): # an expression port_values = {p.get_id(): p.get_last_read_value() for p in core_ports.get_all() if p.is_enabled()} + # TODO: eval attrs eval_context = core_expressions.EvalContext(port_values=port_values, now_ms=int(time.time() * 1000)) try: if new_value != await self._filter_port_value.eval(context=eval_context): @@ -343,6 +344,7 @@ async def accepts( if self._filter_expression: port_values = {p.get_id(): p.get_last_read_value() for p in core_ports.get_all() if p.is_enabled()} + # TODO: eval attrs eval_context = core_expressions.EvalContext(port_values=port_values, now_ms=int(time.time() * 1000)) try: if not await self._filter_expression.eval(context=eval_context): diff --git a/qtoggleserver/utils/debouncer.py b/qtoggleserver/utils/debounced.py similarity index 98% rename from qtoggleserver/utils/debouncer.py rename to qtoggleserver/utils/debounced.py index 5a9ac78f..9965975d 100644 --- a/qtoggleserver/utils/debouncer.py +++ b/qtoggleserver/utils/debounced.py @@ -6,12 +6,12 @@ from functools import reduce -__all__ = ["Debouncer"] +__all__ = ["Debounced"] logger = logging.getLogger(__name__) -class Debouncer: +class Debounced: """ Utility to debounce calls to a function. From f968622629881464e1ee818dd5d1c87c7f1b2519 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Mon, 13 Apr 2026 19:26:58 +0300 Subject: [PATCH 03/18] core/ports: Debounce after-processing of Port.set_attr --- qtoggleserver/core/ports.py | 12 +++++++----- qtoggleserver/utils/debounced.py | 4 +++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index 7d72792c..af6a8c46 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -31,6 +31,7 @@ from qtoggleserver.utils import dynload as dynload_utils from qtoggleserver.utils import json as json_utils from qtoggleserver.utils import logging as logging_utils +from qtoggleserver.utils.debounced import Debounced TYPE_BOOLEAN = "boolean" @@ -251,6 +252,7 @@ def __init__(self, port_id: str) -> None: self._pending_save: bool = False self._loaded: bool = False + self._after_set_attr_debounced = Debounced(self._after_set_attr) def __str__(self) -> str: return f"port {self._id}" @@ -384,12 +386,12 @@ async def set_attr(self, name: str, value: Attribute) -> None: self.invalidate_attrdefs() if self.is_loaded(): - await main.update() # TODO: don't update immediately, batch multiple attribute sets - - # Skip an IO loop iteration, allowing setting multiple attributes before triggering a port-update - await asyncio.sleep(0) - await self.trigger_update() # TODO: don't trigger immediately, batch multiple attribute sets + self._after_set_attr_debounced.call(name, value) + async def _after_set_attr(self, *args) -> None: + await main.update() + await self.trigger_update() + for name, value in args: await self.handle_attr_change(name, value) def invalidate_attr(self, name: str) -> None: diff --git a/qtoggleserver/utils/debounced.py b/qtoggleserver/utils/debounced.py index 9965975d..1b4ccb14 100644 --- a/qtoggleserver/utils/debounced.py +++ b/qtoggleserver/utils/debounced.py @@ -48,9 +48,11 @@ def call(self, *args, **kwargs) -> None: async def _run(self) -> None: await asyncio.sleep(self._delay) - args = self._args + args = list(self._args) if self._args_reducer: args += reduce(self._args_reducer, (q[0] for q in self._queue)) + else: + args += (q[0] for q in self._queue) kwargs = self._kwargs if self._kwargs_reducer: From 68bb28b9ff0bd6fabe0e9fb3e694981844722691 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Mon, 13 Apr 2026 19:40:58 +0300 Subject: [PATCH 04/18] core/ports: Cache Port.get_attrs() --- qtoggleserver/core/ports.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index af6a8c46..8be04aaf 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -224,6 +224,8 @@ def __init__(self, port_id: str) -> None: # Attributes cache is used to prevent computing an attribute value more than once per core iteration self._attrs_cache: Attributes = {} + # `_get_attrs_cache` simply caches the `get_attrs()` result for + self._get_attrs_cache: Attributes | None = None # Cache attribute definitions self._standard_attrdefs_cache: AttributeDefinitions | None = None @@ -295,7 +297,7 @@ async def get_additional_attrdefs(self) -> AttributeDefinitions: return self.ADDITIONAL_ATTRDEFS def invalidate_attrdefs(self) -> None: - self._attrs_cache = {} + self.invalidate_attrs() self._standard_attrdefs_cache = None self._additional_attrdefs_cache = None self._schema = None @@ -309,19 +311,22 @@ async def get_modifiable_attrs(self) -> set[str]: return {n for (n, v) in attrdefs.items() if v.get("modifiable")} async def get_attrs(self) -> Attributes: - d = {} + if self._get_attrs_cache is not None: + return self._get_attrs_cache + self._get_attrs_cache = {} for name in await self.get_attrdefs(): v = await self.get_attr(name) if v is None: continue - d[name] = v + self._get_attrs_cache[name] = v - return d + return self._get_attrs_cache def invalidate_attrs(self) -> None: self._attrs_cache = {} + self._get_attrs_cache = None async def get_attr(self, name: str) -> Attribute | None: value = self._attrs_cache.get(name) @@ -396,6 +401,7 @@ async def _after_set_attr(self, *args) -> None: def invalidate_attr(self, name: str) -> None: self._attrs_cache.pop(name, None) + self._get_attrs_cache = None async def handle_attr_change(self, name: str, value: Attribute) -> None: method_name = f"handle_{name}" From 5e8d2f5ee9c661c288ce14333de89a1d3a637d8e Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Mon, 13 Apr 2026 19:47:53 +0300 Subject: [PATCH 05/18] core/ports: Remove useeless Port._make_eval_context --- qtoggleserver/core/expressions/base.py | 2 +- qtoggleserver/core/ports.py | 23 ++++++++------------- tests/unit/qtoggleserver/core/test_ports.py | 4 ++-- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/qtoggleserver/core/expressions/base.py b/qtoggleserver/core/expressions/base.py index e3f1cef8..fcaf48f6 100644 --- a/qtoggleserver/core/expressions/base.py +++ b/qtoggleserver/core/expressions/base.py @@ -72,7 +72,7 @@ def parse(self_port_id: str | None, sexpression: str, role: Role, pos: int) -> E class EvalContext: - def __init__(self, port_values: dict[str, NullablePortValue], now_ms: int) -> None: + def __init__(self, port_values: dict[str, NullablePortValue], now_ms: int = 0) -> None: self.port_values: dict[str, NullablePortValue] = port_values self.now_ms: int = now_ms diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index 8be04aaf..be8a701a 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -18,6 +18,7 @@ from qtoggleserver.core import expressions as core_expressions from qtoggleserver.core import history as core_history from qtoggleserver.core import sequences as core_sequences +from qtoggleserver.core.expressions import EvalContext from qtoggleserver.core.expressions import exceptions as expressions_exceptions from qtoggleserver.core.typing import ( Attribute, @@ -224,7 +225,7 @@ def __init__(self, port_id: str) -> None: # Attributes cache is used to prevent computing an attribute value more than once per core iteration self._attrs_cache: Attributes = {} - # `_get_attrs_cache` simply caches the `get_attrs()` result for + # `_get_attrs_cache` simply caches the `get_attrs()` result self._get_attrs_cache: Attributes | None = None # Cache attribute definitions @@ -312,7 +313,7 @@ async def get_modifiable_attrs(self) -> set[str]: async def get_attrs(self) -> Attributes: if self._get_attrs_cache is not None: - return self._get_attrs_cache + return dict(self._get_attrs_cache) self._get_attrs_cache = {} for name in await self.get_attrdefs(): @@ -674,7 +675,7 @@ async def read_transformed_value(self) -> NullablePortValue: raise if self._transform_read: - context = self._make_eval_context(port_values={self.get_id(): value}) + context = EvalContext(port_values={self.get_id(): value}) try: value = self.adapt_value_type(await self._transform_read.eval(context)) except expressions_exceptions.ValueUnavailable: @@ -720,7 +721,7 @@ async def eval_and_push_write(self, now_ms: int) -> None: port_values = {p.get_id(): p.get_last_value() for p in get_all() if p.is_enabled()} # TODO: eval attrs - context = self._make_eval_context(port_values, now_ms) + context = EvalContext(port_values, now_ms) expression = self.get_expression() try: @@ -769,7 +770,7 @@ async def transform_and_write_value(self, value: NullablePortValue) -> None: value_str = json_utils.dumps(value) if self._transform_write: - context = self._make_eval_context(port_values={self.get_id(): value}) + context = EvalContext(port_values={self.get_id(): value}) try: value = self.adapt_value_type(await self._transform_write.eval(context)) except expressions_exceptions.ValueUnavailable: @@ -784,12 +785,6 @@ async def transform_and_write_value(self, value: NullablePortValue) -> None: self.error("failed to write value %s", value_str, exc_info=True) raise - def _make_eval_context( - self, port_values: dict[str, NullablePortValue], now_ms: int = 0 - ) -> core_expressions.EvalContext: - now_ms = now_ms or int(time.time() * 1000) - return core_expressions.EvalContext(port_values, now_ms) - def get_last_value(self) -> NullablePortValue: """Returns the most recent value known to the port, preferring pending and last written values over last read value. @@ -942,9 +937,7 @@ async def load_from_data(self, data: GenericJSONDict) -> None: if self._transform_write: try: value = self.adapt_value_type( - await self._transform_write.eval( - self._make_eval_context(port_values={self.get_id(): value}) - ) + await self._transform_write.eval(EvalContext(port_values={self.get_id(): value})) ) except expressions_exceptions.ValueUnavailable: value = None @@ -1011,6 +1004,8 @@ async def cleanup(self) -> None: await self._write_task self._write_task = None + await self._after_set_attr_debounced.stop() + def is_loaded(self) -> bool: return self._loaded diff --git a/tests/unit/qtoggleserver/core/test_ports.py b/tests/unit/qtoggleserver/core/test_ports.py index 8344de48..065fbd04 100644 --- a/tests/unit/qtoggleserver/core/test_ports.py +++ b/tests/unit/qtoggleserver/core/test_ports.py @@ -43,7 +43,7 @@ async def test(self, mock_num_port1, mock_num_port2, mocker): eval context. Should then evaluate the expression and push the result to the write queue.""" mocker.patch("qtoggleserver.core.ports.get_all", return_value=[mock_num_port1, mock_num_port2]) - mocker.patch.object(mock_num_port1, "_make_eval_context", return_value="dummy_eval_context") + mock_eval_context = mocker.patch("qtoggleserver.core.ports.EvalContext", return_value="dummy_eval_context") mocker.patch.object(mock_num_port1, "get_last_value", return_value=42) mocker.patch.object(mock_num_port2, "get_last_value", return_value=84) @@ -56,7 +56,7 @@ async def test(self, mock_num_port1, mock_num_port2, mocker): await mock_num_port1.eval_and_push_write(1234) - mock_num_port1._make_eval_context.assert_called_once_with({"nid1": 42, "nid2": 84}, 1234) + mock_eval_context.assert_called_once_with({"nid1": 42, "nid2": 84}, 1234) mock_num_port1.adapt_value_type.assert_called_once_with(mock_expression.eval.return_value) mock_num_port1._write_queue.append.assert_called_once_with(100) mock_expression.eval.assert_called_once_with("dummy_eval_context") From 4f6b35393b55eb31e04b69963082868d158cf6c2 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Mon, 13 Apr 2026 20:41:17 +0300 Subject: [PATCH 06/18] Fix some tests --- tests/integration/expressions/test_misc.py | 8 +++----- tests/unit/qtoggleserver/core/test_ports.py | 4 +++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/expressions/test_misc.py b/tests/integration/expressions/test_misc.py index 58458b03..82ce1dd7 100644 --- a/tests/integration/expressions/test_misc.py +++ b/tests/integration/expressions/test_misc.py @@ -25,10 +25,9 @@ async def test_expression_port_self_value(mock_num_port1): """Test that a port can reference its own value using `$`.""" mock_num_port1.set_writable(True) - mock_num_port1.set_last_read_value(15) await mock_num_port1.set_attr("expression", "ADD($, 1)") - mock_num_port1.set_last_read_value(25) - await asyncio.sleep(0.1) + mock_num_port1.set_last_read_value(15) + await asyncio.sleep(1) assert mock_num_port1.get_last_written_value() == 16 @@ -36,9 +35,8 @@ async def test_expression_port_own_value(mock_num_port1): """Test that a port can reference its own value using its id.""" mock_num_port1.set_writable(True) - mock_num_port1.set_last_read_value(15) await mock_num_port1.set_attr("expression", "ADD($nid1, 1)") - mock_num_port1.set_last_read_value(25) + mock_num_port1.set_last_read_value(15) await asyncio.sleep(0.1) assert mock_num_port1.get_last_written_value() == 16 diff --git a/tests/unit/qtoggleserver/core/test_ports.py b/tests/unit/qtoggleserver/core/test_ports.py index 065fbd04..f60863fc 100644 --- a/tests/unit/qtoggleserver/core/test_ports.py +++ b/tests/unit/qtoggleserver/core/test_ports.py @@ -1,3 +1,5 @@ +import asyncio + from qtoggleserver.core.expressions.exceptions import ValueUnavailable @@ -301,7 +303,6 @@ async def test_invalidate_cache(self, mock_num_port1, mocker): mock_num_port1._my_attribute = "value1" mocker.patch.object(mock_num_port1, "invalidate_attrdefs") await mock_num_port1.set_attr("my_attribute", "value2") - assert mock_num_port1._attrs_cache.get("my_attribute") != "value1" mock_num_port1.invalidate_attrdefs.assert_called_once() async def test_call_handle_attr_change(self, mock_num_port1, mocker): @@ -310,4 +311,5 @@ async def test_call_handle_attr_change(self, mock_num_port1, mocker): mock_num_port1._my_attribute = "value1" mocker.patch.object(mock_num_port1, "handle_attr_change") await mock_num_port1.set_attr("my_attribute", "value2") + await asyncio.sleep(0.1) mock_num_port1.handle_attr_change.assert_called_once_with("my_attribute", "value2") From 6f6f009fce622c780dd0cebbce89558de6284b89 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 14 Apr 2026 15:13:22 +0300 Subject: [PATCH 07/18] core/device: Cache attrs --- qtoggleserver/core/device/attrs.py | 20 +- .../qtoggleserver/core/device/test_attrs.py | 954 ++++++++++-------- 2 files changed, 541 insertions(+), 433 deletions(-) diff --git a/qtoggleserver/core/device/attrs.py b/qtoggleserver/core/device/attrs.py index cd532810..613b7a3b 100644 --- a/qtoggleserver/core/device/attrs.py +++ b/qtoggleserver/core/device/attrs.py @@ -47,6 +47,7 @@ _schema: GenericJSONDict | None = None _attrdefs: AttributeDefinitions | None = None _attrs_watch_task: asyncio.Task | None = None +_attrs_cache: Attributes | None = None class AttrDefDriver(metaclass=abc.ABCMeta): @@ -682,6 +683,11 @@ def get_schema(loose: bool = False) -> GenericJSONDict: async def get_attrs() -> Attributes: + global _attrs_cache + + if _attrs_cache is not None: + return _attrs_cache.copy() + attrdefs = get_attrdefs() # Do a first round to gather all required calls and ensure we only call each function once, caching its result @@ -699,7 +705,7 @@ async def get_attrs() -> Attributes: call_results[call] = result # Do a second round to prepare attribute values - attrs = {} + _attrs_cache = {} for n, attrdef in attrdefs.items(): getter = attrdef["getter"] if not getter: @@ -721,13 +727,14 @@ async def get_attrs() -> Attributes: else: continue - attrs[n] = value + _attrs_cache[n] = value - return attrs + return _attrs_cache.copy() async def set_attrs(attrs: Attributes, ignore_extra: bool = False) -> bool: core_device_attrs = sys.modules[__name__] + invalidate_attrs() reboot_required = False attrdefs = get_attrdefs() @@ -817,6 +824,12 @@ async def set_attrs(attrs: Attributes, ignore_extra: bool = False) -> bool: return reboot_required +def invalidate_attrs() -> None: + global _attrs_cache + + _attrs_cache = None + + async def to_json() -> GenericJSONDict: attrdefs: AttributeDefinitions = copy.deepcopy(get_attrdefs()) filtered_attrdefs: AttributeDefinitions = {} @@ -881,6 +894,7 @@ async def _attrs_watch_loop() -> None: logger.error("network attributes data check failed: %s", e, exc_info=True) if changed: + invalidate_attrs() await device_events.trigger_update() await asyncio.sleep(NETWORK_ATTRS_WATCH_INTERVAL) diff --git a/tests/unit/qtoggleserver/core/device/test_attrs.py b/tests/unit/qtoggleserver/core/device/test_attrs.py index 9ecb57de..fc0a925e 100644 --- a/tests/unit/qtoggleserver/core/device/test_attrs.py +++ b/tests/unit/qtoggleserver/core/device/test_attrs.py @@ -265,546 +265,640 @@ async def test_to_attrdef_no_return_none(self): } -def test_attr_get_name_internal(mocker): - """Should simply return the internal value of `name`.""" +class TestNameAttr: + def test_get_internal(self, mocker): + """Should simply return the internal value of `name`.""" - mocker.patch.object(device_attrs, "name", "dummy1") - assert device_attrs.attr_get_name() == "dummy1" + mocker.patch.object(device_attrs, "name", "dummy1") + assert device_attrs.attr_get_name() == "dummy1" + def test_get_cmd(self, mocker): + """Should call `run_get_cmd` with appropriate arguments to obtain the name and return it.""" -def test_attr_get_name_get_cmd(mocker): - """Should call `run_get_cmd` with appropriate arguments to obtain the name and return it.""" + mocker.patch.object(device_attrs, "name", "dummy1") + mocker.patch("qtoggleserver.conf.settings.core.device_name.get_cmd", "dummy get cmd") + spy_run_get_cmd = mocker.patch("qtoggleserver.core.device.attrs.run_get_cmd", return_value={"name": "dummy2"}) + assert device_attrs.attr_get_name() == "dummy2" + spy_run_get_cmd.assert_called_once_with("dummy get cmd", cmd_name="device name", required_fields=["name"]) - mocker.patch.object(device_attrs, "name", "dummy1") - mocker.patch("qtoggleserver.conf.settings.core.device_name.get_cmd", "dummy get cmd") - spy_run_get_cmd = mocker.patch("qtoggleserver.core.device.attrs.run_get_cmd", return_value={"name": "dummy2"}) - assert device_attrs.attr_get_name() == "dummy2" - spy_run_get_cmd.assert_called_once_with("dummy get cmd", cmd_name="device name", required_fields=["name"]) + def test_set_internal(self, mocker): + """Should simply update internal `name` value.""" + mocker.patch.object(device_attrs, "name", "old1") + device_attrs.attr_set_name("new1") + assert device_attrs.name == "new1" -def test_attr_set_name_internal(mocker): - """Should simply update internal `name` value.""" + def test_set_cmd(self, mocker): + """Should call `run_set_cmd` with new name and set command, while also updating internal `name` value.""" - mocker.patch.object(device_attrs, "name", "old1") - device_attrs.attr_set_name("new1") - assert device_attrs.name == "new1" + mocker.patch.object(device_attrs, "name", "old1") + mocker.patch("qtoggleserver.conf.settings.core.device_name.set_cmd", "dummy set cmd") + spy_run_set_cmd = mocker.patch("qtoggleserver.core.device.attrs.run_set_cmd") + device_attrs.attr_set_name("new1") + assert device_attrs.name == "new1" + spy_run_set_cmd.assert_called_once_with("dummy set cmd", cmd_name="device name", name="new1") -def test_attr_set_name_set_cmd(mocker): - """Should call `run_set_cmd` with new name and set command, while also updating internal `name` value.""" +class TestDisplayNameAttr: + def test_get(self, mocker): + """Should return the value of the internal `display_name`.""" - mocker.patch.object(device_attrs, "name", "old1") - mocker.patch("qtoggleserver.conf.settings.core.device_name.set_cmd", "dummy set cmd") - spy_run_set_cmd = mocker.patch("qtoggleserver.core.device.attrs.run_set_cmd") - device_attrs.attr_set_name("new1") - assert device_attrs.name == "new1" - spy_run_set_cmd.assert_called_once_with("dummy set cmd", cmd_name="device name", name="new1") + mocker.patch.object(device_attrs, "display_name", "dummy1") + assert device_attrs.attr_get_display_name() == "dummy1" + def test_set(self, mocker): + """Should update the internal `display_name` value.""" -def test_attr_get_display_name(mocker): - """Should return the value of the internal `display_name`.""" + mocker.patch.object(device_attrs, "display_name", "old1") + device_attrs.attr_set_display_name("new1") + assert device_attrs.display_name == "new1" - mocker.patch.object(device_attrs, "display_name", "dummy1") - assert device_attrs.attr_get_display_name() == "dummy1" +class TestAPIVersionAttr: + def test_get(self): + """Should return the correct API version.""" -def test_attr_set_display_name(mocker): - """Should update the internal `display_name` value.""" + assert device_attrs.attr_get_api_version() == core_api.API_VERSION - mocker.patch.object(device_attrs, "display_name", "old1") - device_attrs.attr_set_display_name("new1") - assert device_attrs.display_name == "new1" +class TestFlagsAttr: + def test_default(self): + """Should return the list of flags enabled by default.""" -def test_attr_get_api_version(): - """Should return the correct API version.""" + assert device_attrs.attr_get_flags() == ["expressions", "backup", "listen", "master", "sequences", "tls"] - assert device_attrs.attr_get_api_version() == core_api.API_VERSION + def test_firmware(self, mocker): + """Should return `firmware` flag.""" + mocker.patch("qtoggleserver.conf.settings.system.fwupdate.driver", "dummy") + assert "firmware" in device_attrs.attr_get_flags() -def test_attr_get_flags_default(): - """Should return the list of flags enabled by default.""" + def test_backup(self, mocker): + """Should not return `backup` flag.""" - assert device_attrs.attr_get_flags() == ["expressions", "backup", "listen", "master", "sequences", "tls"] + mocker.patch("qtoggleserver.conf.settings.core.backup_support", False) + assert "backup" not in device_attrs.attr_get_flags() + def test_history(self, mocker): + """Should return `history` flag.""" -def test_attr_get_flags_firmware(mocker): - """Should return `firmware` flag.""" + mocker.patch("qtoggleserver.core.history.is_enabled", return_value=True) + assert "history" in device_attrs.attr_get_flags() - mocker.patch("qtoggleserver.conf.settings.system.fwupdate.driver", "dummy") - assert "firmware" in device_attrs.attr_get_flags() + def test_listen(self, mocker): + """Should not return `listen` flag.""" + mocker.patch("qtoggleserver.conf.settings.core.listen_support", False) + assert "listen" not in device_attrs.attr_get_flags() -def test_attr_get_flags_backup(mocker): - """Should not return `backup` flag.""" + def test_master(self, mocker): + """Should not return `master` flag.""" - mocker.patch("qtoggleserver.conf.settings.core.backup_support", False) - assert "backup" not in device_attrs.attr_get_flags() + mocker.patch("qtoggleserver.conf.settings.slaves.enabled", False) + assert "master" not in device_attrs.attr_get_flags() + def test_sequences(self, mocker): + """Should not return `sequences` flag.""" -def test_attr_get_flags_history(mocker): - """Should return `history` flag.""" + mocker.patch("qtoggleserver.conf.settings.core.sequences_support", False) + assert "sequences" not in device_attrs.attr_get_flags() - mocker.patch("qtoggleserver.core.history.is_enabled", return_value=True) - assert "history" in device_attrs.attr_get_flags() + def test_tls(self, mocker): + """Should not return `tls` flag.""" + mocker.patch("qtoggleserver.conf.settings.core.tls_support", False) + assert "tls" not in device_attrs.attr_get_flags() -def test_attr_get_flags_listen(mocker): - """Should not return `listen` flag.""" - mocker.patch("qtoggleserver.conf.settings.core.listen_support", False) - assert "listen" not in device_attrs.attr_get_flags() +class TestPasswordAttrs: + def test_get_empty(self, mocker): + """Should return empty string due to password being unset.""" + mocker.patch("qtoggleserver.core.device.attrs.admin_password_hash", device_attrs.EMPTY_PASSWORD_HASH) + assert device_attrs.attr_get_password("admin") == "" -def test_attr_get_flags_master(mocker): - """Should not return `master` flag.""" + def test_get_not_empty(self, mocker): + """Should return string `set` due to password being set.""" - mocker.patch("qtoggleserver.conf.settings.slaves.enabled", False) - assert "master" not in device_attrs.attr_get_flags() + mocker.patch("qtoggleserver.core.device.attrs.admin_password_hash", "dummy1234") + assert device_attrs.attr_get_password("admin") == "set" + def test_get_unknown_user(self): + """Should raise exception due to unknown user.""" -def test_attr_get_flags_sequences(mocker): - """Should not return `sequences` flag.""" + with pytest.raises(AttributeError): + device_attrs.attr_get_password("unknown") - mocker.patch("qtoggleserver.conf.settings.core.sequences_support", False) - assert "sequences" not in device_attrs.attr_get_flags() + def test_set_internal(self, mocker): + """Should set internal value hash to corresponding password hash.""" + mocker.patch("qtoggleserver.core.device.attrs.admin_password_hash", "old1") + device_attrs.attr_set_password("admin", "new1") + assert device_attrs.admin_password_hash == hashlib.sha256(b"new1").hexdigest() -def test_attr_get_flags_tls(mocker): - """Should not return `tls` flag.""" + def test_set_internal_empty(self, mocker): + """Should set internal value hash to empty password hash.""" - mocker.patch("qtoggleserver.conf.settings.core.tls_support", False) - assert "tls" not in device_attrs.attr_get_flags() + mocker.patch("qtoggleserver.core.device.attrs.admin_password_hash", "old1") + device_attrs.attr_set_password("admin", "") + assert device_attrs.admin_password_hash == device_attrs.EMPTY_PASSWORD_HASH + def test_set_cmd(self, mocker): + """Should call `run_set_cmd` with password and set command, while also updating internal value hash.""" -def test_attr_get_password_empty(mocker): - """Should return empty string due to password being unset.""" + mocker.patch("qtoggleserver.core.device.attrs.admin_password_hash", "old1") + mocker.patch("qtoggleserver.conf.settings.core.passwords.set_cmd", "dummy set cmd") + spy_run_set_cmd = mocker.patch("qtoggleserver.core.device.attrs.run_set_cmd") + device_attrs.attr_set_password("admin", "new1") + assert device_attrs.admin_password_hash == hashlib.sha256(b"new1").hexdigest() + spy_run_set_cmd.assert_called_once_with( + "dummy set cmd", cmd_name="password", username="admin", password="new1", log_values=False + ) + + +class TestAttrdefs: + def test_get_first_call(self, mocker): + """Should initialize attrdefs, calling callable fields, filtering out disabled attributes and returning the resulted + dictionary.""" + + mocker.patch.object(device_attrs, "_attrdefs", None) + attrdefs = device_attrs.get_attrdefs() + assert attrdefs is not None + assert isinstance(attrdefs, dict) + + for attrdef in attrdefs.values(): + # 'enabled' should not be returned, but rather corresponding attrdefs should be filtered out + assert "enabled" not in attrdef + assert not callable(attrdef.get("modifiable")) + assert not callable(attrdef.get("min")) + assert not callable(attrdef.get("max")) + + def test_get_subsequent_call(self, mocker): + """Should return cached _attrdefs module member, without initializing attrdefs again.""" - mocker.patch("qtoggleserver.core.device.attrs.admin_password_hash", device_attrs.EMPTY_PASSWORD_HASH) - assert device_attrs.attr_get_password("admin") == "" + attrdefs_mock = mocker.patch.object(device_attrs, "_attrdefs") + assert device_attrs.get_attrdefs() is attrdefs_mock -def test_attr_get_password_set(mocker): - """Should return string `set` due to password being set.""" +class TestGetSchema: + def test_get_first_call(self): + """Should initialize a JSON schema corresponding to device attribute definitions and return it. The schema should + not contain attribute definition metadata.""" - mocker.patch("qtoggleserver.core.device.attrs.admin_password_hash", "dummy1234") - assert device_attrs.attr_get_password("admin") == "set" + schema = device_attrs.get_schema() + assert isinstance(schema, dict) + assert schema["type"] == "object" + assert schema["additionalProperties"] is False + properties = schema["properties"] + assert isinstance(properties, dict) + assert "name" in properties -def test_attr_get_password_unknown_user(): - """Should raise exception due to unknown user.""" + for prop in properties.values(): + for attr in ["min", "max", "choices", "step", "modifiable", "standard", "getter", "setter", "reconnect"]: + assert attr not in prop - with pytest.raises(AttributeError): - device_attrs.attr_get_password("unknown") + def test_get_subsequent_call(self, mocker): + """Should return cached _schema module member, without initializing schema again.""" + schema_mock = mocker.patch.object(device_attrs, "_schema") + assert device_attrs.get_schema() is schema_mock -def test_attr_set_password_internal(mocker): - """Should set internal value hash to corresponding password hash.""" + def test_get_loose(self, mocker): + """Should return device attribute definitions JSON schema with additional properties allowed. Should not use or + update cached schema.""" - mocker.patch("qtoggleserver.core.device.attrs.admin_password_hash", "old1") - device_attrs.attr_set_password("admin", "new1") - assert device_attrs.admin_password_hash == hashlib.sha256(b"new1").hexdigest() + cached_schema = device_attrs._schema + schema_mock = mocker.patch.object(device_attrs, "_schema") + schema = device_attrs.get_schema(loose=True) + assert schema is not schema_mock + assert schema is not cached_schema + assert isinstance(schema, dict) + assert schema["type"] == "object" + assert schema["additionalProperties"] is True -def test_attr_set_password_internal_empty(mocker): - """Should set internal value hash to empty password hash.""" - mocker.patch("qtoggleserver.core.device.attrs.admin_password_hash", "old1") - device_attrs.attr_set_password("admin", "") - assert device_attrs.admin_password_hash == device_attrs.EMPTY_PASSWORD_HASH +class TestGetAttrs: + async def test_get(self, mocker): + """Should call `get_attrdefs`, prepare and return a dictionary of corresponding device attributes by calling the + getter of each attribute definition.""" + call_count34 = 0 -def test_attr_set_password_set_cmd(mocker): - """Should call `run_set_cmd` with password and set command, while also updating internal value hash.""" + async def call5(): + return "dummy5" - mocker.patch("qtoggleserver.core.device.attrs.admin_password_hash", "old1") - mocker.patch("qtoggleserver.conf.settings.core.passwords.set_cmd", "dummy set cmd") - spy_run_set_cmd = mocker.patch("qtoggleserver.core.device.attrs.run_set_cmd") - device_attrs.attr_set_password("admin", "new1") - assert device_attrs.admin_password_hash == hashlib.sha256(b"new1").hexdigest() - spy_run_set_cmd.assert_called_once_with( - "dummy set cmd", cmd_name="password", username="admin", password="new1", log_values=False - ) + async def call34(): + nonlocal call_count34 + call_count34 += 1 + return {"key3": "dummy3", "key4": "dummy4"} + + mocker.patch.object( + device_attrs, + "get_attrdefs", + return_value={ + "name1": { + "type": "string", + "getter": lambda: "dummy1", + }, + "name2": { + "type": "string", + "getter": { + "call": lambda: "dummy2", + "transform": lambda v: v + " transformed", + }, + }, + "name3": { + "type": "string", + "getter": { + "call": call34, + "key": "key3", + }, + }, + "name4": { + "type": "string", + "getter": { + "call": call34, + "key": "key4", + "transform": lambda v: v + " transformed", + }, + }, + "name5": { + "type": "string", + "getter": call5, + }, + }, + ) + attrs = await device_attrs.get_attrs() + assert attrs == { + "name1": "dummy1", + "name2": "dummy2 transformed", + "name3": "dummy3", + "name4": "dummy4 transformed", + "name5": "dummy5", + } + assert call_count34 == 1 # call result should be cached instead of being called once for each attribute + + async def test_get_cache_hit(self, mocker): + """Should return cached attribute values on the second call without invoking any getter again.""" + + getter = mock.MagicMock(return_value="dummy1") + mocker.patch.object( + device_attrs, + "get_attrdefs", + return_value={ + "name1": { + "type": "string", + "getter": getter, + }, + }, + ) -def test_get_attrdefs_first_call(mocker): - """Should initialize attrdefs, calling callable fields, filtering out disabled attributes and returning the resulted - dictionary.""" + device_attrs.invalidate_attrs() + attrs1 = await device_attrs.get_attrs() + attrs2 = await device_attrs.get_attrs() - mocker.patch.object(device_attrs, "_attrdefs", None) - attrdefs = device_attrs.get_attrdefs() - assert attrdefs is not None - assert isinstance(attrdefs, dict) + assert attrs1 == attrs2 == {"name1": "dummy1"} + getter.assert_called_once() # getter invoked only on first call; second call returns from cache - for attrdef in attrdefs.values(): - # 'enabled' should not be returned, but rather corresponding attrdefs should be filtered out - assert "enabled" not in attrdef - assert not callable(attrdef.get("modifiable")) - assert not callable(attrdef.get("min")) - assert not callable(attrdef.get("max")) + async def test_get_returns_copy(self, mocker): + """Should return a copy of the cache so that mutations to the returned dict do not corrupt the cache.""" + mocker.patch.object( + device_attrs, + "get_attrdefs", + return_value={ + "name1": { + "type": "string", + "getter": lambda: "original", + }, + }, + ) -def test_get_attrdefs_subsequent_call(mocker): - """Should return cached _attrdefs module member, without initializing attrdefs again.""" + device_attrs.invalidate_attrs() + attrs = await device_attrs.get_attrs() + attrs["name1"] = "mutated" - attrdefs_mock = mocker.patch.object(device_attrs, "_attrdefs") - assert device_attrs.get_attrdefs() is attrdefs_mock + attrs2 = await device_attrs.get_attrs() + assert attrs2["name1"] == "original" + def test_invalidate_attrs(self): + """Should set the attrs cache to None so the next `get_attrs` call recomputes attribute values.""" -def test_get_schema_first_call(): - """Should initialize a JSON schema corresponding to device attribute definitions and return it. The schema should - not contain attribute definition metadata.""" + device_attrs._attrs_cache = {"name1": "dummy1"} + device_attrs.invalidate_attrs() + assert device_attrs._attrs_cache is None - schema = device_attrs.get_schema() - assert isinstance(schema, dict) - assert schema["type"] == "object" - assert schema["additionalProperties"] is False + async def test_get_after_invalidate(self, mocker): + """Should recompute attribute values after `invalidate_attrs` is called, re-invoking getters.""" - properties = schema["properties"] - assert isinstance(properties, dict) - assert "name" in properties + call_count = 0 - for prop in properties.values(): - for attr in ["min", "max", "choices", "step", "modifiable", "standard", "getter", "setter", "reconnect"]: - assert attr not in prop + def getter(): + nonlocal call_count + call_count += 1 + return f"value{call_count}" + mocker.patch.object( + device_attrs, + "get_attrdefs", + return_value={ + "name1": { + "type": "string", + "getter": getter, + }, + }, + ) -def test_get_schema_subsequent_call(mocker): - """Should return cached _schema module member, without initializing schema again.""" + attrs1 = await device_attrs.get_attrs() + device_attrs.invalidate_attrs() + attrs2 = await device_attrs.get_attrs() + + assert attrs1 == {"name1": "value1"} + assert attrs2 == {"name1": "value2"} + assert call_count == 2 + + async def test_set_invalidates_cache(self, mocker): + """Should invalidate the attrs cache when `set_attrs` is called, so that subsequent `get_attrs` calls + recompute attribute values from scratch.""" + + mocker.patch.object( + device_attrs, + "get_attrdefs", + return_value={ + "name1": { + "type": "string", + "modifiable": True, + "getter": mock.MagicMock(return_value="value1"), + "setter": mock.MagicMock(), + }, + }, + ) - schema_mock = mocker.patch.object(device_attrs, "_schema") - assert device_attrs.get_schema() is schema_mock + invalidate_spy = mocker.spy(device_attrs, "invalidate_attrs") + await device_attrs.get_attrs() + assert device_attrs._attrs_cache is not None -def test_get_schema_loose(mocker): - """Should return device attribute definitions JSON schema with additional properties allowed. Should not use or - update cached schema.""" + await device_attrs.set_attrs({"name1": "new_value"}) - cached_schema = device_attrs._schema - schema_mock = mocker.patch.object(device_attrs, "_schema") - schema = device_attrs.get_schema(loose=True) - assert schema is not schema_mock - assert schema is not cached_schema + invalidate_spy.assert_called_once() + assert device_attrs._attrs_cache is None - assert isinstance(schema, dict) - assert schema["type"] == "object" - assert schema["additionalProperties"] is True +class TestSetAttrs: + async def test_set(self, mocker): + """Should obtain the attrdef of corresponding to each supplied attribute and call the associated setter; should + return False, indicating that no reboot is required.""" -async def test_get_attrs(mocker): - """Should call `get_attrdefs`, prepare and return a dictionary of corresponding device attributes by calling the - getter of each attribute definition.""" + call1 = mock.MagicMock() + call2 = mock.MagicMock() - call_count34 = 0 + call_count34 = 0 - async def call5(): - return "dummy5" + async def call34(key3: str, key4: str): + nonlocal call_count34 + call_count34 += 1 + assert key3 == "dummy3" + assert key4 == "dummy4 transformed" - async def call34(): - nonlocal call_count34 - call_count34 += 1 - return {"key3": "dummy3", "key4": "dummy4"} + async def call5(value: str): + assert value == "dummy5" - mocker.patch.object( - device_attrs, - "get_attrdefs", - return_value={ - "name1": { - "type": "string", - "getter": lambda: "dummy1", - }, - "name2": { - "type": "string", - "getter": { - "call": lambda: "dummy2", - "transform": lambda v: v + " transformed", - }, - }, - "name3": { - "type": "string", - "getter": { - "call": call34, - "key": "key3", + mocker.patch.object( + device_attrs, + "get_attrdefs", + return_value={ + "name1": { + "type": "string", + "modifiable": True, + "getter": mock.MagicMock(), + "setter": call1, }, - }, - "name4": { - "type": "string", - "getter": { - "call": call34, - "key": "key4", - "transform": lambda v: v + " transformed", + "name2": { + "type": "string", + "modifiable": True, + "getter": mock.MagicMock(), + "setter": { + "call": call2, + "transform": lambda v: v + " transformed", + }, }, - }, - "name5": { - "type": "string", - "getter": call5, - }, - }, - ) - - attrs = await device_attrs.get_attrs() - assert attrs == { - "name1": "dummy1", - "name2": "dummy2 transformed", - "name3": "dummy3", - "name4": "dummy4 transformed", - "name5": "dummy5", - } - assert call_count34 == 1 # call result should be cached instead of being called once for each attribute - - -async def test_set_attrs(mocker): - """Should obtain the attrdef of corresponding to each supplied attribute and call the associated setter; should - return False, indicating that no reboot is required.""" - - call1 = mock.MagicMock() - call2 = mock.MagicMock() - - call_count34 = 0 - - async def call34(key3: str, key4: str): - nonlocal call_count34 - call_count34 += 1 - assert key3 == "dummy3" - assert key4 == "dummy4 transformed" - - async def call5(value: str): - assert value == "dummy5" - - mocker.patch.object( - device_attrs, - "get_attrdefs", - return_value={ - "name1": { - "type": "string", - "modifiable": True, - "getter": mock.MagicMock(), - "setter": call1, - }, - "name2": { - "type": "string", - "modifiable": True, - "getter": mock.MagicMock(), - "setter": { - "call": call2, - "transform": lambda v: v + " transformed", + "name3": { + "type": "string", + "modifiable": True, + "getter": mock.MagicMock(), + "setter": { + "call": call34, + "key": "key3", + }, }, - }, - "name3": { - "type": "string", - "modifiable": True, - "getter": mock.MagicMock(), - "setter": { - "call": call34, - "key": "key3", + "name4": { + "type": "string", + "modifiable": True, + "getter": mock.MagicMock(), + "setter": { + "call": call34, + "key": "key4", + "transform": lambda v: v + " transformed", + }, }, - }, - "name4": { - "type": "string", - "modifiable": True, - "getter": mock.MagicMock(), - "setter": { - "call": call34, - "key": "key4", - "transform": lambda v: v + " transformed", + "name5": { + "type": "string", + "modifiable": True, + "getter": mock.MagicMock(), + "setter": call5, }, }, - "name5": { - "type": "string", - "modifiable": True, - "getter": mock.MagicMock(), - "setter": call5, - }, - }, - ) - - result = await device_attrs.set_attrs( - { - "name1": "dummy1", - "name2": "dummy2", - "name3": "dummy3", - "name4": "dummy4", - "name5": "dummy5", - } - ) - assert result is False # reboot not required - assert call_count34 == 1 - - call1.assert_called_once_with("dummy1") - call2.assert_called_once_with("dummy2 transformed") - - -async def test_set_attrs_reboot(mocker): - """Should return True, indicating that reboot is required, if at least one of the supplied attributes has the - `reconnect` field set.""" - - mocker.patch.object( - device_attrs, - "get_attrdefs", - return_value={ - "name1": { - "type": "string", - "modifiable": True, - "getter": mock.MagicMock(), - "setter": mock.MagicMock(), - }, - "name2": { - "type": "string", - "modifiable": True, - "reconnect": True, - "getter": mock.MagicMock(), - "setter": mock.MagicMock(), - }, - }, - ) + ) - result = await device_attrs.set_attrs( - { - "name1": "dummy1", - "name2": "dummy2", - } - ) - assert result is True # reboot required - - -async def test_set_attrs_ignore_extra(mocker): - """Should raise exception when supplying an undefined attribute and `ignore_extra` is set to false; Should silently - ignore undefined attribute when `ignore_extra` is set to true.""" - - mocker.patch.object( - device_attrs, - "get_attrdefs", - return_value={ - "name1": { - "type": "string", - "modifiable": True, - "getter": mock.MagicMock(), - "setter": mock.MagicMock(), - }, - "name2": { - "type": "string", - "modifiable": True, - "reconnect": True, - "getter": mock.MagicMock(), - "setter": mock.MagicMock(), + result = await device_attrs.set_attrs( + { + "name1": "dummy1", + "name2": "dummy2", + "name3": "dummy3", + "name4": "dummy4", + "name5": "dummy5", + } + ) + assert result is False # reboot not required + assert call_count34 == 1 + + call1.assert_called_once_with("dummy1") + call2.assert_called_once_with("dummy2 transformed") + + async def test_set_reboot(self, mocker): + """Should return True, indicating that reboot is required, if at least one of the supplied attributes has the + `reconnect` field set.""" + + mocker.patch.object( + device_attrs, + "get_attrdefs", + return_value={ + "name1": { + "type": "string", + "modifiable": True, + "getter": mock.MagicMock(), + "setter": mock.MagicMock(), + }, + "name2": { + "type": "string", + "modifiable": True, + "reconnect": True, + "getter": mock.MagicMock(), + "setter": mock.MagicMock(), + }, }, - }, - ) + ) - with pytest.raises(device_attrs.DeviceAttributeError) as e: - await device_attrs.set_attrs( + result = await device_attrs.set_attrs( { "name1": "dummy1", - "inexistent": "dummy2", + "name2": "dummy2", } ) + assert result is True # reboot required + + async def test_set_ignore_extra(self, mocker): + """Should raise exception when supplying an undefined attribute and `ignore_extra` is set to false; Should silently + ignore undefined attribute when `ignore_extra` is set to true.""" + + mocker.patch.object( + device_attrs, + "get_attrdefs", + return_value={ + "name1": { + "type": "string", + "modifiable": True, + "getter": mock.MagicMock(), + "setter": mock.MagicMock(), + }, + "name2": { + "type": "string", + "modifiable": True, + "reconnect": True, + "getter": mock.MagicMock(), + "setter": mock.MagicMock(), + }, + }, + ) - assert e.error == "no-such-attribute" - assert e.attribute == "inexistent" + with pytest.raises(device_attrs.DeviceAttributeError) as e: + await device_attrs.set_attrs( + { + "name1": "dummy1", + "inexistent": "dummy2", + } + ) - await device_attrs.set_attrs( - { - "name1": "dummy1", - "inexistent": "dummy2", - }, - ignore_extra=True, - ) - - -async def test_set_attrs_not_modifiable(mocker): - """Should raise exception when supplying an attribute that is not modifiable.""" - - mocker.patch.object( - device_attrs, - "get_attrdefs", - return_value={ - "name1": { - "type": "string", - "modifiable": True, - "getter": mock.MagicMock(), - "setter": mock.MagicMock(), - }, - "name2": { - "type": "string", - "modifiable": False, - "getter": mock.MagicMock(), - "setter": mock.MagicMock(), - }, - }, - ) + assert e.error == "no-such-attribute" + assert e.attribute == "inexistent" - with pytest.raises(device_attrs.DeviceAttributeError) as e: await device_attrs.set_attrs( { "name1": "dummy1", - "name2": "dummy2", - } + "inexistent": "dummy2", + }, + ignore_extra=True, ) - assert e.error == "attribute-not-modifiable" - assert e.attribute == "name2" - - -async def test_to_json(mocker): - """Should return a dictionary with all attribute values along with their definitions, for non-standard ones.""" - - mocker.patch.object( - device_attrs, - "get_attrdefs", - return_value={ - "name1": { - "type": "string", - "modifiable": True, - "pattern": "^.*$", - "reconnect": False, - "getter": lambda: "dummy1", - "standard": False, - "choices": [ - {"display_name": "Choice 1", "value": "choice1"}, - {"display_name": "Choice 2", "value": "choice2"}, - ], - }, - "name2": { - "type": "number", - "pattern": "^.*$", - "reconnect": False, - "modifiable": False, - "getter": lambda: 2, - "standard": True, - }, - "name3": { - "type": "boolean", - "pattern": "^.*$", - "reconnect": False, - "modifiable": False, - "getter": lambda: True, - "standard": False, + async def test_set_not_modifiable(self, mocker): + """Should raise exception when supplying an attribute that is not modifiable.""" + + mocker.patch.object( + device_attrs, + "get_attrdefs", + return_value={ + "name1": { + "type": "string", + "modifiable": True, + "getter": mock.MagicMock(), + "setter": mock.MagicMock(), + }, + "name2": { + "type": "string", + "modifiable": False, + "getter": mock.MagicMock(), + "setter": mock.MagicMock(), + }, }, - }, - ) - - result = await device_attrs.to_json() - assert result == { - "name1": "dummy1", - "name2": 2, - "name3": True, - "definitions": { - "name1": { - "type": "string", - "modifiable": True, - "pattern": "^.*$", - "choices": [ - {"display_name": "Choice 1", "value": "choice1"}, - {"display_name": "Choice 2", "value": "choice2"}, - ], + ) + + with pytest.raises(device_attrs.DeviceAttributeError) as e: + await device_attrs.set_attrs( + { + "name1": "dummy1", + "name2": "dummy2", + } + ) + + assert e.error == "attribute-not-modifiable" + assert e.attribute == "name2" + + +class TestToJSON: + async def test(self, mocker): + """Should return a dictionary with all attribute values along with their definitions, for non-standard ones.""" + + mocker.patch.object( + device_attrs, + "get_attrdefs", + return_value={ + "name1": { + "type": "string", + "modifiable": True, + "pattern": "^.*$", + "reconnect": False, + "getter": lambda: "dummy1", + "standard": False, + "choices": [ + {"display_name": "Choice 1", "value": "choice1"}, + {"display_name": "Choice 2", "value": "choice2"}, + ], + }, + "name2": { + "type": "number", + "pattern": "^.*$", + "reconnect": False, + "modifiable": False, + "getter": lambda: 2, + "standard": True, + }, + "name3": { + "type": "boolean", + "pattern": "^.*$", + "reconnect": False, + "modifiable": False, + "getter": lambda: True, + "standard": False, + }, }, - "name3": { - "type": "boolean", - "modifiable": False, - "pattern": "^.*$", + ) + + result = await device_attrs.to_json() + assert result == { + "name1": "dummy1", + "name2": 2, + "name3": True, + "definitions": { + "name1": { + "type": "string", + "modifiable": True, + "pattern": "^.*$", + "choices": [ + {"display_name": "Choice 1", "value": "choice1"}, + {"display_name": "Choice 2", "value": "choice2"}, + ], + }, + "name3": { + "type": "boolean", + "modifiable": False, + "pattern": "^.*$", + }, }, - }, - } + } class TestLoadDynamicAttrDef: From 131fd84da839e4b4a2fd31baca6536bf88aeb13b Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 14 Apr 2026 15:28:52 +0300 Subject: [PATCH 08/18] Use dict.copy() instead of dict(...) whenever possible --- qtoggleserver/core/api/funcs/ports.py | 2 +- qtoggleserver/core/device/attrs.py | 8 +++---- qtoggleserver/core/ports.py | 11 +++++----- qtoggleserver/drivers/persist/json.py | 8 +++---- qtoggleserver/drivers/persist/mongo.py | 12 +++++----- qtoggleserver/drivers/persist/redis.py | 8 +++---- qtoggleserver/lib/polled.py | 2 +- qtoggleserver/peripherals/__init__.py | 4 ++-- qtoggleserver/peripherals/peripheral.py | 2 +- qtoggleserver/slaves/api/funcs/devices.py | 4 ++-- qtoggleserver/slaves/devices.py | 16 +++++++------- qtoggleserver/slaves/ports.py | 16 +++++++------- qtoggleserver/web/base.py | 2 +- .../qtoggleserver/core/device/test_attrs.py | 4 ++-- tests/unit/qtoggleserver/mock/peripherals.py | 2 +- .../peripherals/api/test_funcs.py | 22 +++++++++---------- 16 files changed, 61 insertions(+), 62 deletions(-) diff --git a/qtoggleserver/core/api/funcs/ports.py b/qtoggleserver/core/api/funcs/ports.py index d17af8ac..459c6f64 100644 --- a/qtoggleserver/core/api/funcs/ports.py +++ b/qtoggleserver/core/api/funcs/ports.py @@ -177,7 +177,7 @@ async def put_ports(request: core_api.APIRequest, params: GenericJSONList) -> No for port in core_ports.get_all(): await port.reset() - add_port_schema: GenericJSONDict = dict(core_api_schema.POST_PORTS) + add_port_schema: GenericJSONDict = core_api_schema.POST_PORTS.copy() add_port_schema["additionalProperties"] = True # Restore supplied attributes diff --git a/qtoggleserver/core/device/attrs.py b/qtoggleserver/core/device/attrs.py index 613b7a3b..9149f4b5 100644 --- a/qtoggleserver/core/device/attrs.py +++ b/qtoggleserver/core/device/attrs.py @@ -589,7 +589,7 @@ def attr_set_password(which: str, value: str) -> None: def load_dynamic_attrdef(name: str, params: dict[str, Any]) -> AttributeDefinition: - params = dict(params) + params = params.copy() class_path = params.pop("driver") logger.debug('creating device attribute "%s" with driver "%s"', name, class_path) @@ -605,7 +605,7 @@ def load_dynamic_attrdefs() -> AttributeDefinitions: attrdefs = {} for params in settings.core.device_attrs: - params = dict(params) + params = params.copy() name = params.pop("name") try: @@ -651,7 +651,7 @@ def get_schema(loose: bool = False) -> GenericJSONDict: if not attrdef.get("modifiable"): continue - attr_schema = dict(attrdef) + attr_schema = attrdef.copy() if attr_schema["type"] == "string": if "min" in attr_schema: attr_schema["minLength"] = attr_schema.pop("min") @@ -852,7 +852,7 @@ async def to_json() -> GenericJSONDict: filtered_attrdefs[attr_name] = attrdef - result: dict[str, Any] = dict(await get_attrs()) + result: dict[str, Any] = (await get_attrs()).copy() result["definitions"] = filtered_attrdefs return result diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index be8a701a..a83b149b 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -268,7 +268,7 @@ def initialize(self) -> None: async def get_attrdefs(self) -> AttributeDefinitions: if self._standard_attrdefs_cache is None: - self._standard_attrdefs_cache = dict(await self.get_standard_attrdefs()) + self._standard_attrdefs_cache = (await self.get_standard_attrdefs()).copy() for name, attrdef in list(self._standard_attrdefs_cache.items()): enabled = attrdef.get("enabled", True) if callable(enabled): @@ -279,7 +279,7 @@ async def get_attrdefs(self) -> AttributeDefinitions: self._standard_attrdefs_cache.pop(name) if self._additional_attrdefs_cache is None: - self._additional_attrdefs_cache = dict(await self.get_additional_attrdefs()) + self._additional_attrdefs_cache = (await self.get_additional_attrdefs()).copy() for name, attrdef in list(self._additional_attrdefs_cache.items()): enabled = attrdef.get("enabled", True) if callable(enabled): @@ -313,7 +313,7 @@ async def get_modifiable_attrs(self) -> set[str]: async def get_attrs(self) -> Attributes: if self._get_attrs_cache is not None: - return dict(self._get_attrs_cache) + return self._get_attrs_cache.copy() self._get_attrs_cache = {} for name in await self.get_attrdefs(): @@ -323,7 +323,7 @@ async def get_attrs(self) -> Attributes: self._get_attrs_cache[name] = v - return self._get_attrs_cache + return self._get_attrs_cache.copy() def invalidate_attrs(self) -> None: self._attrs_cache = {} @@ -849,7 +849,6 @@ def heart_beat_second(self) -> None: async def to_json(self) -> GenericJSONDict: attrs: GenericJSONDict = await self.get_attrs() - attrs = dict(attrs) if self._enabled: attrs["value"] = self._last_read_value[0] if self._last_read_value else None @@ -1113,7 +1112,7 @@ async def load(port_args: list[dict[str, Any]], trigger_add: bool = True) -> lis # Create ports for ps in port_args: - ps = dict(ps) + ps = ps.copy() driver = ps.pop("driver", None) if not driver: raise PortLoadError("Missing port driver") diff --git a/qtoggleserver/drivers/persist/json.py b/qtoggleserver/drivers/persist/json.py index daca4cca..a7172eff 100644 --- a/qtoggleserver/drivers/persist/json.py +++ b/qtoggleserver/drivers/persist/json.py @@ -73,7 +73,7 @@ async def query( records = [] if isinstance(filt.get("id"), Id): # look for specific record id - filt = dict(filt) + filt = filt.copy() id_ = filt.pop("id") record = coll.get(id_) @@ -130,7 +130,7 @@ async def update(self, collection: str, record_part: Record, filt: dict[str, Any modified_count = 0 if isinstance(filt.get("id"), Id): - filt = dict(filt) + filt = filt.copy() id_ = filt.pop("id") record = coll.get(id_) @@ -157,7 +157,7 @@ async def replace(self, collection: str, id_: Id, record: Record) -> bool: if coll.get(id_) is None: return False # no record found, no replacing - record = dict(record) + record = record.copy() # Never change record id with replace record["id"] = id_ @@ -172,7 +172,7 @@ async def remove(self, collection: str, filt: dict[str, Any]) -> int: removed_count = 0 if isinstance(filt.get("id"), Id): - filt = dict(filt) + filt = filt.copy() id_ = filt.pop("id") record = coll.get(id_) diff --git a/qtoggleserver/drivers/persist/mongo.py b/qtoggleserver/drivers/persist/mongo.py index a013962a..726bba88 100644 --- a/qtoggleserver/drivers/persist/mongo.py +++ b/qtoggleserver/drivers/persist/mongo.py @@ -55,7 +55,7 @@ async def query( fields["_id"] = 0 if "id" in filt: - filt = dict(filt) + filt = filt.copy() filt["_id"] = self._id_to_db_rec(filt.pop("id")) db_filt = self._filt_to_db(filt) @@ -72,7 +72,7 @@ async def query( return self._query_gen_wrapper(q) async def insert(self, collection: str, record: Record) -> Id: - record = dict(record) + record = record.copy() if "id" in record: record["_id"] = self._id_to_db(record.pop("id")) @@ -80,11 +80,11 @@ async def insert(self, collection: str, record: Record) -> Id: async def update(self, collection: str, record_part: Record, filt: dict[str, Any]) -> int: if "id" in record_part: - record_part = dict(record_part) + record_part = record_part.copy() record_part["_id"] = self._id_to_db(record_part.pop("id")) if "id" in filt: - filt = dict(filt) + filt = filt.copy() filt["_id"] = self._id_to_db_rec(filt.pop("id")) db_filt = self._filt_to_db(filt) @@ -92,7 +92,7 @@ async def update(self, collection: str, record_part: Record, filt: dict[str, Any return self._db[collection].update_many(db_filt, {"$set": record_part}, upsert=False).modified_count async def replace(self, collection: str, id_: Id, record: Record) -> bool: - record = dict(record) + record = record.copy() id_ = self._id_to_db(id_) record["_id"] = id_ @@ -102,7 +102,7 @@ async def replace(self, collection: str, id_: Id, record: Record) -> bool: async def remove(self, collection: str, filt: dict[str, Any]) -> int: if "id" in filt: - filt = dict(filt) + filt = filt.copy() filt["_id"] = self._id_to_db_rec(filt.pop("id")) db_filt = self._filt_to_db(filt) diff --git a/qtoggleserver/drivers/persist/redis.py b/qtoggleserver/drivers/persist/redis.py index 76879a1c..507d5fc4 100644 --- a/qtoggleserver/drivers/persist/redis.py +++ b/qtoggleserver/drivers/persist/redis.py @@ -50,7 +50,7 @@ async def query( db_records = [] if isinstance(filt.get("id"), Id): # look for specific record id - filt = dict(filt) + filt = filt.copy() id_ = filt.pop("id") db_record = self._client.hgetall(self._make_record_key(collection, id_)) @@ -89,7 +89,7 @@ async def query( async def insert(self, collection: str, record: Record) -> Id: # Make sure we have an id - record = dict(record) + record = record.copy() id_ = record.pop("id", None) if id_ is None: id_ = self._get_next_id(collection) @@ -120,7 +120,7 @@ async def update(self, collection: str, record_part: Record, filt: dict[str, Any modified_count = 0 if isinstance(filt.get("id"), Id): - filt = dict(filt) + filt = filt.copy() id_ = filt.pop("id") key = self._make_record_key(collection, id_) @@ -181,7 +181,7 @@ async def remove(self, collection: str, filt: dict[str, Any]) -> int: removed_count = 0 if isinstance(filt.get("id"), Id): - filt = dict(filt) + filt = filt.copy() id_ = filt.pop("id") key = self._make_record_key(collection, id_) db_record = self._client.hgetall(key) diff --git a/qtoggleserver/lib/polled.py b/qtoggleserver/lib/polled.py index b82190f2..d35ee0e0 100644 --- a/qtoggleserver/lib/polled.py +++ b/qtoggleserver/lib/polled.py @@ -151,7 +151,7 @@ def __init__(self, **kwargs) -> None: async def get_additional_attrdefs(self) -> AttributeDefinitions: attrdefs: AttributeDefinitions = {} if self.READ_INTERVAL_MIN is not None: - attrdef: AttributeDefinition = dict(READ_INTERVAL_ATTRDEF) + attrdef: AttributeDefinition = READ_INTERVAL_ATTRDEF.copy() unit = self.READ_INTERVAL_UNIT if unit is None: diff --git a/qtoggleserver/peripherals/__init__.py b/qtoggleserver/peripherals/__init__.py index 86e94d5b..23e64468 100644 --- a/qtoggleserver/peripherals/__init__.py +++ b/qtoggleserver/peripherals/__init__.py @@ -29,7 +29,7 @@ def get(peripheral_id: str) -> Peripheral | None: async def add(peripheral_params: dict[str, Any], static: bool = False) -> Peripheral: - params = dict(peripheral_params) + params = peripheral_params.copy() class_path = params.pop("driver") params.pop("static", None) @@ -48,7 +48,7 @@ async def add(peripheral_params: dict[str, Any], static: bool = False) -> Periph _registered_peripherals[p.get_id()] = p if not static: - peripheral_params = dict(peripheral_params) + peripheral_params = peripheral_params.copy() peripheral_params.pop("static", None) await persist.replace("peripherals", p.get_id(), peripheral_params) diff --git a/qtoggleserver/peripherals/peripheral.py b/qtoggleserver/peripherals/peripheral.py index 515da1a3..993ad07e 100644 --- a/qtoggleserver/peripherals/peripheral.py +++ b/qtoggleserver/peripherals/peripheral.py @@ -112,7 +112,7 @@ async def cleanup_ports(self, persisted_data: bool) -> None: async def add_port(self, port_args: dict[str, Any]) -> PeripheralPort: # Supply the peripheral argument - port_args = dict(port_args) + port_args = port_args.copy() port_args.setdefault("peripheral", self) port = cast(PeripheralPort, (await core_ports.load([port_args]))[0]) diff --git a/qtoggleserver/slaves/api/funcs/devices.py b/qtoggleserver/slaves/api/funcs/devices.py index 7968c872..a4d5bc7b 100644 --- a/qtoggleserver/slaves/api/funcs/devices.py +++ b/qtoggleserver/slaves/api/funcs/devices.py @@ -33,7 +33,7 @@ async def add_slave_device(properties: GenericJSONDict) -> slaves_devices.Slave: - properties = dict(properties) # work on copy, don't mess up incoming argument + properties = properties.copy() # work on copy, don't mess up incoming argument scheme = properties.pop("scheme") host = properties.pop("host") @@ -157,7 +157,7 @@ async def put_slave_devices(request: core_api.APIRequest, params: GenericJSONLis for slave in slaves_devices.get_all(): await slaves_devices.remove(slave) - add_device_schema: GenericJSONDict = dict(api_schema.POST_SLAVE_DEVICES) + add_device_schema: GenericJSONDict = api_schema.POST_SLAVE_DEVICES.copy() add_device_schema["additionalProperties"] = True # Validate supplied slave properties diff --git a/qtoggleserver/slaves/devices.py b/qtoggleserver/slaves/devices.py index 10f70ab6..91e72ead 100644 --- a/qtoggleserver/slaves/devices.py +++ b/qtoggleserver/slaves/devices.py @@ -184,7 +184,7 @@ def get_cached_attr(self, name: str) -> Attribute | None: return self._cached_attrs.get(name) def get_cached_attrs(self) -> Attributes: - return self._cached_attrs + return self._cached_attrs.copy() async def update_cached_attrs(self, attrs: Attributes, partial: bool = False) -> None: # If the name has changed remove the device and re-add the device from scratch @@ -397,7 +397,7 @@ def to_json(self) -> GenericJSONDict: "last_sync": self._last_sync, "online": self._online, "provisioning": provisioning, - "attrs": self._cached_attrs, + "attrs": self.get_cached_attrs(), } def prepare_for_save(self) -> GenericJSONDict: @@ -412,10 +412,10 @@ def prepare_for_save(self) -> GenericJSONDict: "poll_interval": self._poll_interval, "listen_enabled": self._listen_enabled, "last_sync": self._last_sync, - "attrs": self._cached_attrs, - "webhooks": self._cached_webhooks, - "reverse": self._cached_reverse, - "provisioning_attrs": list(self._provisioning_attrs), + "attrs": self._cached_attrs.copy(), + "webhooks": self._cached_webhooks.copy(), + "reverse": self._cached_reverse.copy(), + "provisioning_attrs": self._provisioning_attrs.copy(), "provisioning_webhooks": self._provisioning_webhooks, "provisioning_reverse": self._provisioning_reverse, } @@ -1160,7 +1160,7 @@ async def _handle_device_update(self, **attrs: Attribute) -> None: provisioning_attrs = self.get_provisioning_attrs() # We're working on a copy, just to be sure we can safely pop stuff from it - attrs = dict(attrs) + attrs = attrs.copy() for name in attrs: if name in provisioning_attrs: @@ -1484,7 +1484,7 @@ def intercept_error(self, error: Exception) -> Exception: field = error.params.get("field", "") m = _INVALID_EXPRESSION_FIELD_RE.match(field) or _INVALID_HISTORY_FIELD_RE.match(field) if m: - params = dict(error.params) + params = error.params.copy() params["field"] = "device_" + m.group(1) return core_responses.HTTPError(error.status, error.code, **params) diff --git a/qtoggleserver/slaves/ports.py b/qtoggleserver/slaves/ports.py index 39bf9fbe..6de46314 100644 --- a/qtoggleserver/slaves/ports.py +++ b/qtoggleserver/slaves/ports.py @@ -127,7 +127,7 @@ async def get_standard_attrdefs(self) -> AttributeDefinitions: if slave_name not in self._cached_attrs: break - attrdefs[master_name] = dict(self._DEVICE_EXPRESSION_ATTRDEF) + attrdefs[master_name] = self._DEVICE_EXPRESSION_ATTRDEF.copy() # device_*history_interval for i in range(1, 10): @@ -136,7 +136,7 @@ async def get_standard_attrdefs(self) -> AttributeDefinitions: if slave_name not in self._cached_attrs: break - attrdefs[master_name] = dict(self._DEVICE_HISTORY_INTERVAL_ATTRDEF) + attrdefs[master_name] = self._DEVICE_HISTORY_INTERVAL_ATTRDEF.copy() # device_*history_retention for i in range(1, 10): @@ -145,12 +145,12 @@ async def get_standard_attrdefs(self) -> AttributeDefinitions: if slave_name not in self._cached_attrs: break - attrdefs[master_name] = dict(self._DEVICE_HISTORY_RETENTION_ATTRDEF) + attrdefs[master_name] = self._DEVICE_HISTORY_RETENTION_ATTRDEF.copy() # Various master-specific standard attributes - attrdefs["last_sync"] = dict(self._LAST_SYNC_ATTRDEF) - attrdefs["expires"] = dict(self._EXPIRES_ATTRDEF) - attrdefs["provisioning"] = dict(self._PROVISIONING_ATTRDEF) + attrdefs["last_sync"] = self._LAST_SYNC_ATTRDEF.copy() + attrdefs["expires"] = self._EXPIRES_ATTRDEF.copy() + attrdefs["provisioning"] = self._PROVISIONING_ATTRDEF.copy() return attrdefs @@ -210,10 +210,10 @@ def get_cached_attr(self, name: str) -> Attribute | None: return self._cached_attrs.get(name) def get_cached_attrs(self) -> Attributes: - return dict(self._cached_attrs) + return self._cached_attrs.copy() def update_cached_attrs(self, attrs: Attributes) -> None: - self._cached_attrs = dict(attrs) + self._cached_attrs = attrs.copy() # Value can be found among attrs, but we don't want it as attribute if "value" in attrs: diff --git a/qtoggleserver/web/base.py b/qtoggleserver/web/base.py index 3f8f283b..113503cb 100644 --- a/qtoggleserver/web/base.py +++ b/qtoggleserver/web/base.py @@ -162,7 +162,7 @@ async def call_api_func(self, func: Callable, default_status: int = 200, **kwarg await self._handle_api_call_exception(func, kwargs, e) async def _handle_api_call_exception(self, func: Callable, kwargs: dict, error: Exception) -> None: - kwargs = dict(kwargs) + kwargs = kwargs.copy() params = kwargs.pop("params", None) args = json_utils.dumps(kwargs) body = params and json_utils.dumps(params) or "{}" diff --git a/tests/unit/qtoggleserver/core/device/test_attrs.py b/tests/unit/qtoggleserver/core/device/test_attrs.py index fc0a925e..fe8bd44b 100644 --- a/tests/unit/qtoggleserver/core/device/test_attrs.py +++ b/tests/unit/qtoggleserver/core/device/test_attrs.py @@ -988,7 +988,7 @@ def test_params_not_mutated(self, mocker): mocker.patch("qtoggleserver.utils.dynload.load_attr", return_value=mock_driver_class) original_params = {"driver": "module.path.DriverClass", "param1": "value1"} - params_copy = dict(original_params) + params_copy = original_params.copy() device_attrs.load_dynamic_attrdef("test_attr", original_params) @@ -1121,7 +1121,7 @@ def test_original_config_not_mutated(self, mocker): original_config = [ {"name": "attr1", "driver": "module.Driver1", "param": "value1"}, ] - config_copy = [dict(item) for item in original_config] + config_copy = [item.copy() for item in original_config] mocker.patch("qtoggleserver.conf.settings.core.device_attrs", original_config) diff --git a/tests/unit/qtoggleserver/mock/peripherals.py b/tests/unit/qtoggleserver/mock/peripherals.py index c3271c11..81d0fb47 100644 --- a/tests/unit/qtoggleserver/mock/peripherals.py +++ b/tests/unit/qtoggleserver/mock/peripherals.py @@ -14,7 +14,7 @@ async def read_value(self) -> NullablePortValue: class MockPeripheral(Peripheral): def __init__(self, *, dummy_param: str, **kwargs) -> None: - kwargs = dict(kwargs) + kwargs = kwargs.copy() kwargs.setdefault("driver", f"{self.__class__.__module__}.{self.__class__.__name__}") kwargs.setdefault("params", dict(kwargs, dummy_param=dummy_param)) self._dummy_param: str = dummy_param diff --git a/tests/unit/qtoggleserver/peripherals/api/test_funcs.py b/tests/unit/qtoggleserver/peripherals/api/test_funcs.py index a6342e52..72362dd8 100644 --- a/tests/unit/qtoggleserver/peripherals/api/test_funcs.py +++ b/tests/unit/qtoggleserver/peripherals/api/test_funcs.py @@ -62,7 +62,7 @@ async def test_ok_with_name_and_id(self, mock_api_request_maker, mock_peripheral dummy_param=MOCK_PERIPHERAL2_DATA["dummy_param"], ) - payload = dict(MOCK_PERIPHERAL2_DATA) + payload = MOCK_PERIPHERAL2_DATA.copy() payload.pop("static") request = mock_api_request_maker("POST", "/api/peripherals", access_level=core_api.ACCESS_LEVEL_ADMIN) @@ -81,7 +81,7 @@ async def test_ok_with_name(self, mock_api_request_maker, mock_peripheral1, mock dummy_param=MOCK_PERIPHERAL2_DATA["dummy_param"], ) - payload = dict(MOCK_PERIPHERAL2_DATA) + payload = MOCK_PERIPHERAL2_DATA.copy() payload.pop("static") payload.pop("id") request = mock_api_request_maker("POST", "/api/peripherals", access_level=core_api.ACCESS_LEVEL_ADMIN) @@ -101,7 +101,7 @@ async def test_ok_with_id(self, mock_api_request_maker, mock_peripheral1, mocker dummy_param=MOCK_PERIPHERAL2_DATA["dummy_param"], ) - payload = dict(MOCK_PERIPHERAL2_DATA) + payload = MOCK_PERIPHERAL2_DATA.copy() payload.pop("static") payload.pop("name") request = mock_api_request_maker("POST", "/api/peripherals", access_level=core_api.ACCESS_LEVEL_ADMIN) @@ -118,7 +118,7 @@ async def test_ok_with_id(self, mock_api_request_maker, mock_peripheral1, mocker async def test_ok_no_name_no_id(self, mock_api_request_maker, mock_peripheral1, mocker): mock_peripheral2 = MockPeripheral(dummy_param=MOCK_PERIPHERAL2_DATA["dummy_param"]) - payload = dict(MOCK_PERIPHERAL2_DATA) + payload = MOCK_PERIPHERAL2_DATA.copy() payload.pop("static") payload.pop("name") payload.pop("id") @@ -135,7 +135,7 @@ async def test_ok_no_name_no_id(self, mock_api_request_maker, mock_peripheral1, assert result == dict(payload, name=None, static=False) async def test_no_such_driver(self, mock_api_request_maker, mock_peripheral1): - payload = dict(MOCK_PERIPHERAL2_DATA) + payload = MOCK_PERIPHERAL2_DATA.copy() payload.pop("static") payload["driver"] = "does.not.exist" request = mock_api_request_maker("POST", "/api/peripherals", access_level=core_api.ACCESS_LEVEL_ADMIN) @@ -145,7 +145,7 @@ async def test_no_such_driver(self, mock_api_request_maker, mock_peripheral1): assert e.value.status == 404 async def test_duplicate_peripheral(self, mock_api_request_maker, mock_peripheral1): - payload = dict(MOCK_PERIPHERAL1_DATA) + payload = MOCK_PERIPHERAL1_DATA.copy() payload.pop("static") request = mock_api_request_maker("POST", "/api/peripherals", access_level=core_api.ACCESS_LEVEL_ADMIN) @@ -154,7 +154,7 @@ async def test_duplicate_peripheral(self, mock_api_request_maker, mock_periphera assert e.value.status == 400 async def test_normal_user_permissions(self, mock_api_request_maker, mock_peripheral1): - payload = dict(MOCK_PERIPHERAL2_DATA) + payload = MOCK_PERIPHERAL2_DATA.copy() payload.pop("static") request = mock_api_request_maker("POST", "/api/peripherals", access_level=core_api.ACCESS_LEVEL_NORMAL) @@ -163,7 +163,7 @@ async def test_normal_user_permissions(self, mock_api_request_maker, mock_periph assert e.value.status == 403 async def test_viewonly_user_permissions(self, mock_api_request_maker, mock_peripheral1): - payload = dict(MOCK_PERIPHERAL2_DATA) + payload = MOCK_PERIPHERAL2_DATA.copy() payload.pop("static") request = mock_api_request_maker("POST", "/api/peripherals", access_level=core_api.ACCESS_LEVEL_VIEWONLY) @@ -172,7 +172,7 @@ async def test_viewonly_user_permissions(self, mock_api_request_maker, mock_peri assert e.value.status == 403 async def test_anonymous_user_permissions(self, mock_api_request_maker, mock_peripheral1): - payload = dict(MOCK_PERIPHERAL2_DATA) + payload = MOCK_PERIPHERAL2_DATA.copy() payload.pop("static") request = mock_api_request_maker("POST", "/api/peripherals", access_level=core_api.ACCESS_LEVEL_NONE) @@ -229,8 +229,8 @@ async def test_ok(self, mock_api_request_maker, mock_peripheral1, mocker): name=MOCK_PERIPHERAL3_DATA["name"], dummy_param=MOCK_PERIPHERAL3_DATA["dummy_param"], ) - payload2 = dict(MOCK_PERIPHERAL2_DATA) - payload3 = dict(MOCK_PERIPHERAL3_DATA) + payload2 = MOCK_PERIPHERAL2_DATA.copy() + payload3 = MOCK_PERIPHERAL3_DATA.copy() payload2.pop("static") payload3.pop("static") From 96c8547cd2c75e1707cc388f0ce2ddabc5c7ea75 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 14 Apr 2026 16:25:56 +0300 Subject: [PATCH 09/18] Optimize get_all() calls --- qtoggleserver/core/ports.py | 6 +++--- qtoggleserver/peripherals/__init__.py | 5 +++-- qtoggleserver/slaves/devices.py | 5 +++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index a83b149b..08a8b50c 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -9,7 +9,7 @@ import time from collections import deque -from collections.abc import Callable +from collections.abc import Callable, ValuesView from typing import Any from qtoggleserver import persist @@ -1194,8 +1194,8 @@ def get(port_id: str) -> BasePort | None: return _ports_by_id.get(port_id) -def get_all() -> list[BasePort]: - return list(_ports_by_id.values()) +def get_all() -> ValuesView[BasePort]: + return _ports_by_id.values() async def save_loop() -> None: diff --git a/qtoggleserver/peripherals/__init__.py b/qtoggleserver/peripherals/__init__.py index 23e64468..662ec9a8 100644 --- a/qtoggleserver/peripherals/__init__.py +++ b/qtoggleserver/peripherals/__init__.py @@ -1,6 +1,7 @@ import asyncio import logging +from collections.abc import ValuesView from typing import Any from qtoggleserver import persist @@ -20,8 +21,8 @@ _registered_peripherals: dict[str, Peripheral] = {} -def get_all() -> list[Peripheral]: - return list(_registered_peripherals.values()) +def get_all() -> ValuesView[Peripheral]: + return _registered_peripherals.values() def get(peripheral_id: str) -> Peripheral | None: diff --git a/qtoggleserver/slaves/devices.py b/qtoggleserver/slaves/devices.py index 91e72ead..8ea3dd47 100644 --- a/qtoggleserver/slaves/devices.py +++ b/qtoggleserver/slaves/devices.py @@ -8,6 +8,7 @@ import time import types +from collections.abc import ValuesView from typing import Any from tornado.httpclient import AsyncHTTPClient, HTTPRequest @@ -1618,8 +1619,8 @@ async def _handle_rename(slave: Slave, new_name: str) -> None: await slave.wait_online(timeout=settings.slaves.long_timeout) -def get_all() -> list[Slave]: - return list(_slaves_by_name.values()) +def get_all() -> ValuesView[Slave]: + return _slaves_by_name.values() def _slave_ready(slave: Slave) -> bool: From 9359b22c8b84332422d746d62bf1f715e0e03e21 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 14 Apr 2026 16:28:20 +0300 Subject: [PATCH 10/18] core/ports: Small Port.get_attr optimization --- qtoggleserver/core/ports.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index 08a8b50c..412c2dbe 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -334,7 +334,7 @@ async def get_attr(self, name: str) -> Attribute | None: if value is not None: return value - method = getattr(self, "attr_get_" + name, getattr(self, "attr_is_" + name, None)) + method = getattr(self, "attr_get_" + name) or getattr(self, "attr_is_" + name, None) if method: value = method() if inspect.isawaitable(value): @@ -353,7 +353,7 @@ async def get_attr(self, name: str) -> Attribute | None: self._attrs_cache[name] = value return value - method = getattr(self, "attr_get_default_" + name, getattr(self, "attr_is_default_" + name, None)) + method = getattr(self, "attr_get_default_" + name) or getattr(self, "attr_is_default_" + name, None) if method: value = method() if inspect.isawaitable(value): From bbb5dbf7a33bc30e424e52fd227c0d6390bde2f7 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 14 Apr 2026 20:53:43 +0300 Subject: [PATCH 11/18] core/sessions: Use deque instead of plain list --- qtoggleserver/core/ports.py | 4 +- qtoggleserver/core/sessions.py | 25 +- .../unit/qtoggleserver/core/test_sessions.py | 214 ++++++++++++++++++ 3 files changed, 228 insertions(+), 15 deletions(-) create mode 100644 tests/unit/qtoggleserver/core/test_sessions.py diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index 412c2dbe..0375a8d8 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -334,7 +334,7 @@ async def get_attr(self, name: str) -> Attribute | None: if value is not None: return value - method = getattr(self, "attr_get_" + name) or getattr(self, "attr_is_" + name, None) + method = getattr(self, "attr_get_" + name, None) or getattr(self, "attr_is_" + name, None) if method: value = method() if inspect.isawaitable(value): @@ -353,7 +353,7 @@ async def get_attr(self, name: str) -> Attribute | None: self._attrs_cache[name] = value return value - method = getattr(self, "attr_get_default_" + name) or getattr(self, "attr_is_default_" + name, None) + method = getattr(self, "attr_get_default_" + name, None) or getattr(self, "attr_is_default_" + name, None) if method: value = method() if inspect.isawaitable(value): diff --git a/qtoggleserver/core/sessions.py b/qtoggleserver/core/sessions.py index c16c42dc..87e8b5b2 100644 --- a/qtoggleserver/core/sessions.py +++ b/qtoggleserver/core/sessions.py @@ -4,6 +4,8 @@ import logging import time +from collections import deque + from qtoggleserver.conf import settings from qtoggleserver.core import events as core_events from qtoggleserver.utils import logging as logging_utils @@ -26,7 +28,7 @@ def __init__(self, session_id: str) -> None: self.timeout: int = 0 self.access_level: int = 0 self.future: asyncio.Future | None = None - self.queue: list[core_events.Event] = [] + self.queue: deque[core_events.Event] = deque() def reset_and_wait(self, timeout: int, access_level: int) -> asyncio.Future: self.debug("resetting (timeout=%s, access_level=%s)", timeout, access_level) @@ -37,7 +39,7 @@ def reset_and_wait(self, timeout: int, access_level: int) -> asyncio.Future: future = asyncio.get_running_loop().create_future() - self.accessed = time.time() + self.accessed = int(time.time()) self.timeout = timeout self.access_level = access_level self.future = future @@ -56,7 +58,7 @@ def is_active(self) -> bool: def respond(self) -> None: events = list(self.queue) - self.queue = [] + self.queue = deque() if not self.future: return @@ -65,15 +67,12 @@ def respond(self) -> None: self.future = None def push(self, event: core_events.Event) -> None: - # Deduplicate events - while True: - duplicates = [e for e in self.queue if event.is_duplicate(e)] - if not duplicates: - break - - for d in duplicates: - self.queue.remove(d) - self.debug("dropping duplicate event %s", d) + # Deduplicate events in a single pass, replacing the queue with a filtered copy + filtered = deque(e for e in self.queue if not event.is_duplicate(e)) + dropped = len(self.queue) - len(filtered) + if dropped: + self.debug("dropped %d duplicate event(s) %s", dropped, event) + self.queue = filtered # Ensure max queue size while len(self.queue) >= settings.core.event_queue_size: @@ -82,7 +81,7 @@ def push(self, event: core_events.Event) -> None: self.debug("queue full, dropping oldest event") self.queue.pop() - self.queue.insert(0, event) + self.queue.appendleft(event) def __str__(self) -> str: return f"session {self.id}" diff --git a/tests/unit/qtoggleserver/core/test_sessions.py b/tests/unit/qtoggleserver/core/test_sessions.py new file mode 100644 index 00000000..29716016 --- /dev/null +++ b/tests/unit/qtoggleserver/core/test_sessions.py @@ -0,0 +1,214 @@ +import asyncio + +from collections import deque + +import pytest + +from qtoggleserver.core.sessions import Session + + +class MockEvent: + """A simple event stub with configurable duplicate behavior.""" + + REQUIRED_ACCESS = 0 + + def __init__(self, name: str, duplicate_of: "MockEvent | list[MockEvent] | None" = None) -> None: + self.name = name + if duplicate_of is None: + self._duplicate_of: list[MockEvent] = [] + elif isinstance(duplicate_of, list): + self._duplicate_of = duplicate_of + else: + self._duplicate_of = [duplicate_of] + + def is_duplicate(self, other: "MockEvent") -> bool: + return other in self._duplicate_of or self in other._duplicate_of + + def __repr__(self) -> str: + return f"MockEvent({self.name!r})" + + +@pytest.fixture +def session() -> Session: + return Session("test-session") + + +class TestSessionIsEmpty: + def test_empty_on_creation(self, session): + """Should be empty when first created.""" + + assert session.is_empty() + + def test_not_empty_after_push(self, session): + """Should not be empty after an event is pushed.""" + + session.push(MockEvent("e1")) + assert not session.is_empty() + + def test_empty_after_respond(self, session): + """Should be empty after responding (queue is cleared).""" + + session.push(MockEvent("e1")) + session.respond() + assert session.is_empty() + + +class TestSessionIsActive: + async def test_inactive_on_creation(self, session): + """Should be inactive when first created (no future set).""" + + assert not session.is_active() + + async def test_active_after_reset_and_wait(self, session): + """Should be active after reset_and_wait is called.""" + + future = session.reset_and_wait(timeout=30, access_level=0) + assert session.is_active() + future.cancel() + + async def test_inactive_after_respond(self, session): + """Should become inactive after responding.""" + + future = session.reset_and_wait(timeout=30, access_level=0) + session.respond() + assert not session.is_active() + assert future.done() + + +class TestSessionResetAndWait: + async def test_returns_future(self, session): + """Should return an asyncio.Future.""" + + future = session.reset_and_wait(timeout=30, access_level=0) + assert isinstance(future, asyncio.Future) + future.cancel() + + async def test_sets_timeout_and_access_level(self, session): + """Should store the provided timeout and access_level on the session.""" + + future = session.reset_and_wait(timeout=60, access_level=3) + assert session.timeout == 60 + assert session.access_level == 3 + future.cancel() + + async def test_existing_future_is_resolved(self, session): + """Should resolve the existing future when reset_and_wait is called again.""" + + future1 = session.reset_and_wait(timeout=30, access_level=0) + future2 = session.reset_and_wait(timeout=30, access_level=0) + assert future1.done() + assert not future2.done() + future2.cancel() + + async def test_queued_events_trigger_immediate_respond(self, session): + """Should respond immediately if there are queued events at reset_and_wait time.""" + + session.push(MockEvent("e1")) + future = session.reset_and_wait(timeout=30, access_level=0) + assert future.done() + assert session.is_empty() + + +class TestSessionRespond: + async def test_clears_queue(self, session): + """Should clear the queue after responding.""" + + session.push(MockEvent("e1")) + session.push(MockEvent("e2")) + session.respond() + assert session.is_empty() + + async def test_resolves_future_with_events(self, session): + """Should resolve the future with the queued events in oldest-first order.""" + + e1 = MockEvent("e1") + e2 = MockEvent("e2") + e3 = MockEvent("e3") + session.push(e1) + session.push(e2) + session.push(e3) + + future = session.reset_and_wait(timeout=30, access_level=0) + # reset_and_wait responds immediately since the queue is already populated + result = list(future.result()) + assert result == [e1, e2, e3] + + async def test_no_future_does_not_raise(self, session): + """Should not raise if respond is called with no active future.""" + + session.push(MockEvent("e1")) + session.respond() # no future, should be a no-op + + +class TestSessionPush: + def test_event_is_queued(self, session): + """Should add the event to the queue.""" + + e = MockEvent("e1") + session.push(e) + assert e in session.queue + + def test_multiple_events_newest_first(self, session): + """Should queue events with the newest at the front.""" + + e1 = MockEvent("e1") + e2 = MockEvent("e2") + session.push(e1) + session.push(e2) + assert list(session.queue) == [e2, e1] + + def test_duplicate_is_dropped(self, session): + """Should remove the existing event when a duplicate is pushed.""" + + e1 = MockEvent("e1") + e2 = MockEvent("e2", duplicate_of=e1) + session.push(e1) + session.push(e2) + assert len(session.queue) == 1 + assert e2 in session.queue + assert e1 not in session.queue + + def test_multiple_duplicates_all_dropped(self, session): + """Should remove all existing events that the new event considers duplicates of.""" + + e1 = MockEvent("e1") + e2 = MockEvent("e2") + e3 = MockEvent("e3", duplicate_of=[e1, e2]) + session.push(e1) + session.push(e2) + session.push(e3) + assert len(session.queue) == 1 + assert e3 in session.queue + assert e1 not in session.queue + assert e2 not in session.queue + + def test_non_duplicate_is_not_dropped(self, session): + """Should keep a non-duplicate event in the queue when a new event is pushed.""" + + e1 = MockEvent("e1") + e2 = MockEvent("e2") + session.push(e1) + session.push(e2) + assert e1 in session.queue + assert e2 in session.queue + + def test_queue_size_limit_drops_oldest(self, session, mocker): + """Should drop the oldest event when the queue is full.""" + + mocker.patch("qtoggleserver.core.sessions.settings.core.event_queue_size", 3) + e1 = MockEvent("e1") + e2 = MockEvent("e2") + e3 = MockEvent("e3") + e4 = MockEvent("e4") + session.push(e1) + session.push(e2) + session.push(e3) + session.push(e4) + assert len(session.queue) == 3 + assert e1 not in session.queue # e1 is the oldest, dropped first + assert e4 in session.queue + + def test_queue_uses_deque(self, session): + """Queue should be a deque instance.""" + + assert isinstance(session.queue, deque) From c9f16bf8b45f6326570ecd69d7829718ec76d9bc Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 14 Apr 2026 20:58:36 +0300 Subject: [PATCH 12/18] drivers/persist/json: Improve insert performance --- qtoggleserver/drivers/persist/json.py | 30 ++++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/qtoggleserver/drivers/persist/json.py b/qtoggleserver/drivers/persist/json.py index a7172eff..5dc70e04 100644 --- a/qtoggleserver/drivers/persist/json.py +++ b/qtoggleserver/drivers/persist/json.py @@ -60,6 +60,7 @@ def __init__( self._use_backup: bool = use_backup self._data: IndexedData = self._index(self._load()) + self._max_ids: dict[str, int] = self._compute_max_ids(self._data) async def query( self, @@ -114,10 +115,17 @@ async def insert(self, collection: str, record: Record) -> Id: id_ = record.get("id") if id_ is None: - id_ = self._find_next_id(coll) + next_id = self._max_ids.get(collection, 0) + 1 + self._max_ids[collection] = next_id + id_ = str(next_id) record = dict(record, id=id_) elif id_ in coll: raise DuplicateRecordId(id_) + else: + try: + self._max_ids[collection] = max(self._max_ids.get(collection, 0), int(id_)) + except (ValueError, TypeError): + pass coll[id_] = record @@ -218,15 +226,17 @@ def _filter_value_matches(db_record_value: Any, filt_value: Any) -> bool: return db_record_value == filt_value @staticmethod - def _find_next_id(coll: Collection) -> Id: - int_ids = [0] - for id_ in coll.keys(): - try: - int_ids.append(int(id_)) - except ValueError: - continue - - return str(max(int_ids) + 1) + def _compute_max_ids(data: IndexedData) -> dict[str, int]: + max_ids: dict[str, int] = {} + for coll, records in data.items(): + max_id = 0 + for id_ in records: + try: + max_id = max(max_id, int(id_)) + except (ValueError, TypeError): + pass + max_ids[coll] = max_id + return max_ids def _get_backup_file_path(self) -> str | None: if not self._file_path: From 751b48314cfc665b151ce8ae34b667ada20c2314 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 14 Apr 2026 21:17:10 +0300 Subject: [PATCH 13/18] core/device: Cache filtered attrdefs used in to_json() --- qtoggleserver/core/device/attrs.py | 66 +++++++++++-------- .../qtoggleserver/core/device/test_attrs.py | 42 +++++++++--- 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/qtoggleserver/core/device/attrs.py b/qtoggleserver/core/device/attrs.py index 9149f4b5..fb6cdf67 100644 --- a/qtoggleserver/core/device/attrs.py +++ b/qtoggleserver/core/device/attrs.py @@ -45,7 +45,8 @@ viewonly_password_hash: str | None = None _schema: GenericJSONDict | None = None -_attrdefs: AttributeDefinitions | None = None +_attrdefs_cache: AttributeDefinitions | None = None +_filtered_attrdefs_cache: AttributeDefinitions | None = None _attrs_watch_task: asyncio.Task | None = None _attrs_cache: Attributes | None = None @@ -594,11 +595,11 @@ def load_dynamic_attrdef(name: str, params: dict[str, Any]) -> AttributeDefiniti logger.debug('creating device attribute "%s" with driver "%s"', name, class_path) try: - peripheral_class = dynload_utils.load_attr(class_path) + attrdef_driver_class = dynload_utils.load_attr(class_path) except Exception: raise NoSuchDriver(class_path) - return peripheral_class(**params).to_attrdef() + return attrdef_driver_class(**params).to_attrdef() def load_dynamic_attrdefs() -> AttributeDefinitions: @@ -620,22 +621,22 @@ def load_dynamic_attrdefs() -> AttributeDefinitions: def get_attrdefs() -> AttributeDefinitions: - global _attrdefs + global _attrdefs_cache - if _attrdefs is None: + if _attrdefs_cache is None: logger.debug("initializing attribute definitions") - _attrdefs = copy.deepcopy(ATTRDEFS) | load_dynamic_attrdefs() + _attrdefs_cache = copy.deepcopy(ATTRDEFS) | load_dynamic_attrdefs() # Transform some callable values into corresponding results - for n, attrdef in list(_attrdefs.items()): + for n, attrdef in list(_attrdefs_cache.items()): for k, v in attrdef.items(): if callable(v) and k in ATTRDEF_CALLABLE_FIELDS: attrdef[k] = v() if attrdef.pop("enabled", True) is False: - _attrdefs.pop(n) + _attrdefs_cache.pop(n) - return _attrdefs + return _attrdefs_cache def get_schema(loose: bool = False) -> GenericJSONDict: @@ -830,30 +831,43 @@ def invalidate_attrs() -> None: _attrs_cache = None +def invalidate_attrdefs() -> None: + global _filtered_attrdefs_cache + global _attrdefs_cache + + _filtered_attrdefs_cache = None + _attrdefs_cache = None + + async def to_json() -> GenericJSONDict: - attrdefs: AttributeDefinitions = copy.deepcopy(get_attrdefs()) - filtered_attrdefs: AttributeDefinitions = {} - for attr_name, attrdef in attrdefs.items(): - if attrdef.pop("standard", False): - continue + global _filtered_attrdefs_cache - # Remove unwanted fields from attribute definition - for field in ("persisted", "setter", "getter"): - attrdef.pop(field, None) + if _filtered_attrdefs_cache is None: + attrdefs: AttributeDefinitions = copy.deepcopy(get_attrdefs()) + filtered_attrdefs: AttributeDefinitions = {} + for attr_name, attrdef in attrdefs.items(): + if attrdef.pop("standard", False): + continue - # Remove optional boolean fields that are false - for field in ("integer", "reconnect"): - if not attrdef.get(field): + # Remove unwanted fields from attribute definition + for field in ("persisted", "setter", "getter"): attrdef.pop(field, None) - for key in list(attrdef): - if key.startswith("_"): - attrdef.pop(key) + # Remove optional boolean fields that are false + for field in ("integer", "reconnect"): + if not attrdef.get(field): + attrdef.pop(field, None) + + for key in list(attrdef): + if key.startswith("_"): + attrdef.pop(key) + + filtered_attrdefs[attr_name] = attrdef - filtered_attrdefs[attr_name] = attrdef + _filtered_attrdefs_cache = filtered_attrdefs - result: dict[str, Any] = (await get_attrs()).copy() - result["definitions"] = filtered_attrdefs + result: dict[str, Any] = await get_attrs() + result["definitions"] = _filtered_attrdefs_cache return result diff --git a/tests/unit/qtoggleserver/core/device/test_attrs.py b/tests/unit/qtoggleserver/core/device/test_attrs.py index fe8bd44b..b16884a5 100644 --- a/tests/unit/qtoggleserver/core/device/test_attrs.py +++ b/tests/unit/qtoggleserver/core/device/test_attrs.py @@ -418,10 +418,10 @@ def test_set_cmd(self, mocker): class TestAttrdefs: def test_get_first_call(self, mocker): - """Should initialize attrdefs, calling callable fields, filtering out disabled attributes and returning the resulted - dictionary.""" + """Should initialize attrdefs, calling callable fields, filtering out disabled attributes and returning the + resulted dictionary.""" - mocker.patch.object(device_attrs, "_attrdefs", None) + mocker.patch.object(device_attrs, "_attrdefs_cache", None) attrdefs = device_attrs.get_attrdefs() assert attrdefs is not None assert isinstance(attrdefs, dict) @@ -436,14 +436,14 @@ def test_get_first_call(self, mocker): def test_get_subsequent_call(self, mocker): """Should return cached _attrdefs module member, without initializing attrdefs again.""" - attrdefs_mock = mocker.patch.object(device_attrs, "_attrdefs") + attrdefs_mock = mocker.patch.object(device_attrs, "_attrdefs_cache") assert device_attrs.get_attrdefs() is attrdefs_mock class TestGetSchema: def test_get_first_call(self): - """Should initialize a JSON schema corresponding to device attribute definitions and return it. The schema should - not contain attribute definition metadata.""" + """Should initialize a JSON schema corresponding to device attribute definitions and return it. The schema + should not contain attribute definition metadata.""" schema = device_attrs.get_schema() assert isinstance(schema, dict) @@ -762,8 +762,8 @@ async def test_set_reboot(self, mocker): assert result is True # reboot required async def test_set_ignore_extra(self, mocker): - """Should raise exception when supplying an undefined attribute and `ignore_extra` is set to false; Should silently - ignore undefined attribute when `ignore_extra` is set to true.""" + """Should raise exception when supplying an undefined attribute and `ignore_extra` is set to false; Should + silently ignore undefined attribute when `ignore_extra` is set to true.""" mocker.patch.object( device_attrs, @@ -842,6 +842,7 @@ class TestToJSON: async def test(self, mocker): """Should return a dictionary with all attribute values along with their definitions, for non-standard ones.""" + mocker.patch.object(device_attrs, "_filtered_attrdefs_cache", None) mocker.patch.object( device_attrs, "get_attrdefs", @@ -900,6 +901,31 @@ async def test(self, mocker): }, } + async def test_filtered_attrdefs_cached(self, mocker): + """Should compute filtered attrdefs only once; subsequent calls reuse the cached object.""" + + mocker.patch.object(device_attrs, "_filtered_attrdefs_cache", None) + get_attrdefs_mock = mocker.patch.object( + device_attrs, + "get_attrdefs", + return_value={ + "name1": { + "type": "string", + "modifiable": True, + "pattern": "^.*$", + "reconnect": False, + "getter": lambda: "dummy1", + "standard": False, + }, + }, + ) + + result1 = await device_attrs.to_json() + result2 = await device_attrs.to_json() + + assert result1["definitions"] is result2["definitions"] + get_attrdefs_mock.assert_called_once() + class TestLoadDynamicAttrDef: def test_successful_load(self, mocker): From 6463c1497871bd3326574797f5f2586d88ff27f1 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 14 Apr 2026 21:24:58 +0300 Subject: [PATCH 14/18] core/ports: Cache filtered attrdefs used in to_json() --- qtoggleserver/core/device/attrs.py | 14 ++-- qtoggleserver/core/ports.py | 22 +++--- .../qtoggleserver/core/device/test_attrs.py | 4 +- tests/unit/qtoggleserver/core/test_ports.py | 77 +++++++++++++++++++ 4 files changed, 99 insertions(+), 18 deletions(-) diff --git a/qtoggleserver/core/device/attrs.py b/qtoggleserver/core/device/attrs.py index fb6cdf67..847d72c5 100644 --- a/qtoggleserver/core/device/attrs.py +++ b/qtoggleserver/core/device/attrs.py @@ -46,7 +46,7 @@ _schema: GenericJSONDict | None = None _attrdefs_cache: AttributeDefinitions | None = None -_filtered_attrdefs_cache: AttributeDefinitions | None = None +_to_json_attrdefs_cache: AttributeDefinitions | None = None _attrs_watch_task: asyncio.Task | None = None _attrs_cache: Attributes | None = None @@ -832,17 +832,17 @@ def invalidate_attrs() -> None: def invalidate_attrdefs() -> None: - global _filtered_attrdefs_cache + global _to_json_attrdefs_cache global _attrdefs_cache - _filtered_attrdefs_cache = None + _to_json_attrdefs_cache = None _attrdefs_cache = None async def to_json() -> GenericJSONDict: - global _filtered_attrdefs_cache + global _to_json_attrdefs_cache - if _filtered_attrdefs_cache is None: + if _to_json_attrdefs_cache is None: attrdefs: AttributeDefinitions = copy.deepcopy(get_attrdefs()) filtered_attrdefs: AttributeDefinitions = {} for attr_name, attrdef in attrdefs.items(): @@ -864,10 +864,10 @@ async def to_json() -> GenericJSONDict: filtered_attrdefs[attr_name] = attrdef - _filtered_attrdefs_cache = filtered_attrdefs + _to_json_attrdefs_cache = filtered_attrdefs result: dict[str, Any] = await get_attrs() - result["definitions"] = _filtered_attrdefs_cache + result["definitions"] = _to_json_attrdefs_cache return result diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index 0375a8d8..43da44ac 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -231,6 +231,7 @@ def __init__(self, port_id: str) -> None: # Cache attribute definitions self._standard_attrdefs_cache: AttributeDefinitions | None = None self._additional_attrdefs_cache: AttributeDefinitions | None = None + self._to_json_attrdefs_cache: AttributeDefinitions | None = None self._schema: GenericJSONDict | None = None self._value_schema: GenericJSONDict | None = None @@ -301,6 +302,7 @@ def invalidate_attrdefs(self) -> None: self.invalidate_attrs() self._standard_attrdefs_cache = None self._additional_attrdefs_cache = None + self._to_json_attrdefs_cache = None self._schema = None async def get_non_modifiable_attrs(self) -> set[str]: @@ -857,15 +859,17 @@ async def to_json(self) -> GenericJSONDict: attrs["value"] = None attrs["pending_value"] = None - attrdefs: AttributeDefinitions = copy.deepcopy(await self.get_additional_attrdefs()) - for attrdef in attrdefs.values(): - # Remove unwanted fields from attribute definition - for name in list(attrdef): - if name.startswith("_"): - attrdef.pop(name) - attrdef.pop("pattern", None) - - attrs["definitions"] = attrdefs + if self._to_json_attrdefs_cache is None: + attrdefs: AttributeDefinitions = copy.deepcopy(await self.get_additional_attrdefs()) + for attrdef in attrdefs.values(): + # Remove unwanted fields from attribute definition + for name in list(attrdef): + if name.startswith("_"): + attrdef.pop(name) + attrdef.pop("pattern", None) + self._to_json_attrdefs_cache = attrdefs + + attrs["definitions"] = self._to_json_attrdefs_cache return attrs diff --git a/tests/unit/qtoggleserver/core/device/test_attrs.py b/tests/unit/qtoggleserver/core/device/test_attrs.py index b16884a5..2502f97f 100644 --- a/tests/unit/qtoggleserver/core/device/test_attrs.py +++ b/tests/unit/qtoggleserver/core/device/test_attrs.py @@ -842,7 +842,7 @@ class TestToJSON: async def test(self, mocker): """Should return a dictionary with all attribute values along with their definitions, for non-standard ones.""" - mocker.patch.object(device_attrs, "_filtered_attrdefs_cache", None) + mocker.patch.object(device_attrs, "_to_json_attrdefs_cache", None) mocker.patch.object( device_attrs, "get_attrdefs", @@ -904,7 +904,7 @@ async def test(self, mocker): async def test_filtered_attrdefs_cached(self, mocker): """Should compute filtered attrdefs only once; subsequent calls reuse the cached object.""" - mocker.patch.object(device_attrs, "_filtered_attrdefs_cache", None) + mocker.patch.object(device_attrs, "_to_json_attrdefs_cache", None) get_attrdefs_mock = mocker.patch.object( device_attrs, "get_attrdefs", diff --git a/tests/unit/qtoggleserver/core/test_ports.py b/tests/unit/qtoggleserver/core/test_ports.py index f60863fc..760bb00c 100644 --- a/tests/unit/qtoggleserver/core/test_ports.py +++ b/tests/unit/qtoggleserver/core/test_ports.py @@ -313,3 +313,80 @@ async def test_call_handle_attr_change(self, mock_num_port1, mocker): await mock_num_port1.set_attr("my_attribute", "value2") await asyncio.sleep(0.1) mock_num_port1.handle_attr_change.assert_called_once_with("my_attribute", "value2") + + +class TestPortToJSON: + async def test_definitions_filtered(self, mock_num_port1, mocker): + """Should strip private (`_`-prefixed) fields and `pattern` from additional attrdefs in the result.""" + + mocker.patch.object( + mock_num_port1, + "get_additional_attrdefs", + return_value={ + "extra_attr": { + "type": "string", + "modifiable": True, + "pattern": "^.*$", + "_internal": "should_be_removed", + }, + }, + ) + mock_num_port1._to_json_attrdefs_cache = None + + result = await mock_num_port1.to_json() + + assert "definitions" in result + assert result["definitions"] == { + "extra_attr": { + "type": "string", + "modifiable": True, + }, + } + + async def test_additional_attrdefs_cached(self, mock_num_port1, mocker): + """Should compute filtered additional attrdefs only once; subsequent calls reuse the cached object.""" + + mocker.patch.object( + mock_num_port1, + "get_additional_attrdefs", + return_value={ + "extra_attr": { + "type": "string", + "modifiable": True, + "pattern": "^.*$", + }, + }, + ) + mock_num_port1._to_json_attrdefs_cache = None + + result1 = await mock_num_port1.to_json() + result2 = await mock_num_port1.to_json() + + assert result1["definitions"] is result2["definitions"] + mock_num_port1.get_additional_attrdefs.assert_called_once() + + async def test_invalidate_attrdefs_clears_cache(self, mock_num_port1, mocker): + """Should recompute additional attrdefs after `invalidate_attrdefs()` is called.""" + + call_count = 0 + attrdefs_versions = [ + {"extra_attr": {"type": "string", "modifiable": True}}, + {"extra_attr": {"type": "number", "modifiable": False}}, + ] + + async def get_additional_attrdefs(): + nonlocal call_count + r = attrdefs_versions[min(call_count, 1)] + call_count += 1 + return r + + mocker.patch.object(mock_num_port1, "get_additional_attrdefs", side_effect=get_additional_attrdefs) + mock_num_port1._to_json_attrdefs_cache = None + + result1 = await mock_num_port1.to_json() + mock_num_port1.invalidate_attrdefs() + result2 = await mock_num_port1.to_json() + + assert result1["definitions"]["extra_attr"]["type"] == "string" + assert result2["definitions"]["extra_attr"]["type"] == "number" + assert result1["definitions"] is not result2["definitions"] From c67e29797c5e8280b37f59f9f10d083c6d8521f5 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 14 Apr 2026 22:16:28 +0300 Subject: [PATCH 15/18] Resolve remarks from review --- qtoggleserver/core/api/funcs/ports.py | 2 +- qtoggleserver/peripherals/api/funcs.py | 2 +- qtoggleserver/slaves/api/funcs/devices.py | 2 +- qtoggleserver/slaves/devices.py | 2 +- tests/integration/expressions/test_misc.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/qtoggleserver/core/api/funcs/ports.py b/qtoggleserver/core/api/funcs/ports.py index 459c6f64..77e64275 100644 --- a/qtoggleserver/core/api/funcs/ports.py +++ b/qtoggleserver/core/api/funcs/ports.py @@ -163,7 +163,7 @@ async def put_ports(request: core_api.APIRequest, params: GenericJSONList) -> No try: # Remove all (local) virtual ports - for port in core_ports.get_all(): + for port in list(core_ports.get_all()): if not isinstance(port, core_vports.VirtualPort): continue diff --git a/qtoggleserver/peripherals/api/funcs.py b/qtoggleserver/peripherals/api/funcs.py index be9356bc..e2cc5c66 100644 --- a/qtoggleserver/peripherals/api/funcs.py +++ b/qtoggleserver/peripherals/api/funcs.py @@ -64,7 +64,7 @@ async def put_peripherals(request: core_api.APIRequest, params: GenericJSONList) logger.debug("restoring peripherals") - for p in peripherals.get_all(): + for p in list(peripherals.get_all()): if p.is_static(): continue await p.cleanup_ports(persisted_data=True) diff --git a/qtoggleserver/slaves/api/funcs/devices.py b/qtoggleserver/slaves/api/funcs/devices.py index a4d5bc7b..0c8f9cdc 100644 --- a/qtoggleserver/slaves/api/funcs/devices.py +++ b/qtoggleserver/slaves/api/funcs/devices.py @@ -154,7 +154,7 @@ async def put_slave_devices(request: core_api.APIRequest, params: GenericJSONLis try: # Remove all slave devices - for slave in slaves_devices.get_all(): + for slave in list(slaves_devices.get_all()): await slaves_devices.remove(slave) add_device_schema: GenericJSONDict = api_schema.POST_SLAVE_DEVICES.copy() diff --git a/qtoggleserver/slaves/devices.py b/qtoggleserver/slaves/devices.py index 8ea3dd47..5ecaaf98 100644 --- a/qtoggleserver/slaves/devices.py +++ b/qtoggleserver/slaves/devices.py @@ -416,7 +416,7 @@ def prepare_for_save(self) -> GenericJSONDict: "attrs": self._cached_attrs.copy(), "webhooks": self._cached_webhooks.copy(), "reverse": self._cached_reverse.copy(), - "provisioning_attrs": self._provisioning_attrs.copy(), + "provisioning_attrs": list(sorted(self._provisioning_attrs)), "provisioning_webhooks": self._provisioning_webhooks, "provisioning_reverse": self._provisioning_reverse, } diff --git a/tests/integration/expressions/test_misc.py b/tests/integration/expressions/test_misc.py index 82ce1dd7..34558a31 100644 --- a/tests/integration/expressions/test_misc.py +++ b/tests/integration/expressions/test_misc.py @@ -27,7 +27,7 @@ async def test_expression_port_self_value(mock_num_port1): mock_num_port1.set_writable(True) await mock_num_port1.set_attr("expression", "ADD($, 1)") mock_num_port1.set_last_read_value(15) - await asyncio.sleep(1) + await asyncio.sleep(0.1) assert mock_num_port1.get_last_written_value() == 16 From 0d0b971e4131b8ece7e45a78e052ac011f070da8 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 09:14:25 +0300 Subject: [PATCH 16/18] Fix core.ports.get_all() usage in some places --- qtoggleserver/core/api/funcs/ports.py | 2 +- qtoggleserver/core/history.py | 4 ++-- qtoggleserver/core/ports.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/qtoggleserver/core/api/funcs/ports.py b/qtoggleserver/core/api/funcs/ports.py index 77e64275..1387a8e7 100644 --- a/qtoggleserver/core/api/funcs/ports.py +++ b/qtoggleserver/core/api/funcs/ports.py @@ -174,7 +174,7 @@ async def put_ports(request: core_api.APIRequest, params: GenericJSONList) -> No await core_ports.reset() if settings.slaves.enabled: await slaves.reset_ports() - for port in core_ports.get_all(): + for port in list(core_ports.get_all()): await port.reset() add_port_schema: GenericJSONDict = core_api_schema.POST_PORTS.copy() diff --git a/qtoggleserver/core/history.py b/qtoggleserver/core/history.py index 2c9ec18c..11cd17c0 100644 --- a/qtoggleserver/core/history.py +++ b/qtoggleserver/core/history.py @@ -60,7 +60,7 @@ async def sampling_task() -> None: continue # don't record history unless we've got real date/time now_ms = int(time.time() * 1000) - for port in core_ports.get_all(): + for port in list(core_ports.get_all()): if not port.is_enabled(): continue @@ -93,7 +93,7 @@ async def janitor_task() -> None: continue now = int(time.time()) - for port in core_ports.get_all(): + for port in list(core_ports.get_all()): history_retention = await port.get_history_retention() if history_retention <= 0: continue diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index 43da44ac..1e997d6b 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -1205,7 +1205,7 @@ def get_all() -> ValuesView[BasePort]: async def save_loop() -> None: while True: try: - for port in get_all(): + for port in list(get_all()): if not port.is_pending_save(): continue From 9a263f518d9644ad60b502955996fc2246aae4c3 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 09:17:15 +0300 Subject: [PATCH 17/18] core/api: Properly handle CancelledError during session wait --- qtoggleserver/core/api/funcs/various.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/qtoggleserver/core/api/funcs/various.py b/qtoggleserver/core/api/funcs/various.py index 3d98b4d0..253e4729 100644 --- a/qtoggleserver/core/api/funcs/various.py +++ b/qtoggleserver/core/api/funcs/various.py @@ -2,6 +2,8 @@ import inspect import traceback +from asyncio import CancelledError + from qtoggleserver import slaves, system from qtoggleserver.conf import settings from qtoggleserver.core import api as core_api @@ -41,7 +43,11 @@ async def get_listen(request: core_api.APIRequest) -> GenericJSONList: timeout = 60 # default session = core_sessions.get(session_id) - events = await session.reset_and_wait(timeout, request.access_level) + try: + events = await session.reset_and_wait(timeout, request.access_level) + except CancelledError: + session.debug("waiting cancelled") + return [] return [await e.to_json() for e in events] From 21831ae82ba2a0b43190487fa5afb429e2644e1e Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 11:14:52 +0300 Subject: [PATCH 18/18] core/api: Fix more usages around core.ports.get_all() --- qtoggleserver/core/main.py | 10 +++--- tests/unit/qtoggleserver/core/test_main.py | 38 ++++++++++++++-------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/qtoggleserver/core/main.py b/qtoggleserver/core/main.py index 9071a255..9c8f08f7 100644 --- a/qtoggleserver/core/main.py +++ b/qtoggleserver/core/main.py @@ -47,7 +47,7 @@ async def update() -> None: - from . import ports, sessions + from . import sessions global _last_time global _last_minute @@ -102,7 +102,8 @@ async def update() -> None: _last_year = now_dt.year changed_set.add(DEP_YEAR) - for port in ports.get_all(): + all_ports = list(core_ports.get_all()) + for port in all_ports: if not port.is_enabled(): continue @@ -139,7 +140,7 @@ async def update() -> None: changed_set.add(port) value_pairs[port] = old_value, new_value - await handle_value_changes(changed_set, value_pairs, now_ms) + await handle_value_changes(all_ports, changed_set, value_pairs, now_ms) sessions.update() @@ -159,6 +160,7 @@ async def update_loop() -> None: async def handle_value_changes( + all_ports: list[core_ports.BasePort], changed_set: set[core_ports.BasePort | str], value_pairs: dict[core_ports.BasePort, tuple[NullablePortValue, NullablePortValue]], now_ms: int, @@ -200,7 +202,7 @@ async def handle_value_changes( port.save_asap() # Reevaluate all port expressions depending on changed ports - for port in core_ports.get_all(): + for port in all_ports: if not port.is_enabled(): continue diff --git a/tests/unit/qtoggleserver/core/test_main.py b/tests/unit/qtoggleserver/core/test_main.py index 3f1242a4..a83a8bd9 100644 --- a/tests/unit/qtoggleserver/core/test_main.py +++ b/tests/unit/qtoggleserver/core/test_main.py @@ -14,7 +14,7 @@ async def test_change_time_asap(self, freezer, mocker, mock_num_port1, dummy_utc await update() spy_handle_value_changes = mocker.patch("qtoggleserver.core.main.handle_value_changes") await update() - spy_handle_value_changes.assert_called_once_with({DEP_ASAP}, {}, int(time.time() * 1000)) + spy_handle_value_changes.assert_called_once_with([mock_num_port1], {DEP_ASAP}, {}, int(time.time() * 1000)) async def test_change_time_second(self, freezer, mocker, mock_num_port1, dummy_utc_datetime): """Should call `handle_value_changes` with {DEP_ASAP, DEP_SECOND} when second changes.""" @@ -25,7 +25,9 @@ async def test_change_time_second(self, freezer, mocker, mock_num_port1, dummy_u freezer.move_to(dummy_utc_datetime + timedelta(seconds=1)) spy_handle_value_changes = mocker.patch("qtoggleserver.core.main.handle_value_changes") await update() - spy_handle_value_changes.assert_called_once_with({DEP_ASAP, DEP_SECOND}, {}, int(time.time() * 1000)) + spy_handle_value_changes.assert_called_once_with( + [mock_num_port1], {DEP_ASAP, DEP_SECOND}, {}, int(time.time() * 1000) + ) async def test_change_time_minute(self, freezer, mocker, mock_num_port1, dummy_utc_datetime): """Should call `handle_value_changes` with {DEP_ASAP, DEP_SECOND, DEP_MINUTE} when minute changes.""" @@ -37,7 +39,7 @@ async def test_change_time_minute(self, freezer, mocker, mock_num_port1, dummy_u spy_handle_value_changes = mocker.patch("qtoggleserver.core.main.handle_value_changes") await update() spy_handle_value_changes.assert_called_once_with( - {DEP_ASAP, DEP_SECOND, DEP_MINUTE}, {}, int(time.time() * 1000) + [mock_num_port1], {DEP_ASAP, DEP_SECOND, DEP_MINUTE}, {}, int(time.time() * 1000) ) async def test_change_time_hour(self, freezer, mocker, mock_num_port1, dummy_utc_datetime): @@ -50,7 +52,7 @@ async def test_change_time_hour(self, freezer, mocker, mock_num_port1, dummy_utc spy_handle_value_changes = mocker.patch("qtoggleserver.core.main.handle_value_changes") await update() spy_handle_value_changes.assert_called_once_with( - {DEP_ASAP, DEP_SECOND, DEP_MINUTE, DEP_HOUR}, {}, int(time.time() * 1000) + [mock_num_port1], {DEP_ASAP, DEP_SECOND, DEP_MINUTE, DEP_HOUR}, {}, int(time.time() * 1000) ) async def test_change_time_day(self, freezer, mocker, mock_num_port1, dummy_utc_datetime): @@ -64,7 +66,7 @@ async def test_change_time_day(self, freezer, mocker, mock_num_port1, dummy_utc_ spy_handle_value_changes = mocker.patch("qtoggleserver.core.main.handle_value_changes") await update() spy_handle_value_changes.assert_called_once_with( - {DEP_ASAP, DEP_SECOND, DEP_MINUTE, DEP_HOUR, DEP_DAY}, {}, int(time.time() * 1000) + [mock_num_port1], {DEP_ASAP, DEP_SECOND, DEP_MINUTE, DEP_HOUR, DEP_DAY}, {}, int(time.time() * 1000) ) async def test_change_time_month(self, freezer, mocker, mock_num_port1, dummy_utc_datetime): @@ -78,7 +80,10 @@ async def test_change_time_month(self, freezer, mocker, mock_num_port1, dummy_ut spy_handle_value_changes = mocker.patch("qtoggleserver.core.main.handle_value_changes") await update() spy_handle_value_changes.assert_called_once_with( - {DEP_ASAP, DEP_SECOND, DEP_MINUTE, DEP_HOUR, DEP_DAY, DEP_MONTH}, {}, int(time.time() * 1000) + [mock_num_port1], + {DEP_ASAP, DEP_SECOND, DEP_MINUTE, DEP_HOUR, DEP_DAY, DEP_MONTH}, + {}, + int(time.time() * 1000), ) async def test_change_time_year(self, freezer, mocker, mock_num_port1, dummy_utc_datetime): @@ -92,7 +97,10 @@ async def test_change_time_year(self, freezer, mocker, mock_num_port1, dummy_utc spy_handle_value_changes = mocker.patch("qtoggleserver.core.main.handle_value_changes") await update() spy_handle_value_changes.assert_called_once_with( - {DEP_ASAP, DEP_SECOND, DEP_MINUTE, DEP_HOUR, DEP_DAY, DEP_MONTH, DEP_YEAR}, {}, int(time.time() * 1000) + [mock_num_port1], + {DEP_ASAP, DEP_SECOND, DEP_MINUTE, DEP_HOUR, DEP_DAY, DEP_MONTH, DEP_YEAR}, + {}, + int(time.time() * 1000), ) @@ -104,7 +112,9 @@ async def test_self_port_value_trigger_eval(self, mocker, mock_num_port1): mock_num_port1.set_expression("MUL($, 2)") mocker.patch.object(mock_num_port1, "eval_and_push_write") - await handle_value_changes(changed_set={mock_num_port1}, value_pairs={mock_num_port1: (10, 20)}, now_ms=0) + await handle_value_changes( + [mock_num_port1], changed_set={mock_num_port1}, value_pairs={mock_num_port1: (10, 20)}, now_ms=0 + ) mock_num_port1.eval_and_push_write.assert_called_once() async def test_own_port_value_trigger_eval(self, mocker, mock_num_port1): @@ -114,7 +124,9 @@ async def test_own_port_value_trigger_eval(self, mocker, mock_num_port1): mock_num_port1.set_expression("MUL($nid1, 2)") mocker.patch.object(mock_num_port1, "eval_and_push_write") - await handle_value_changes(changed_set={mock_num_port1}, value_pairs={mock_num_port1: (10, 20)}, now_ms=0) + await handle_value_changes( + [mock_num_port1], changed_set={mock_num_port1}, value_pairs={mock_num_port1: (10, 20)}, now_ms=0 + ) mock_num_port1.eval_and_push_write.assert_called_once() async def test_disabled_port_no_trigger_eval(self, mocker, mock_num_port1): @@ -127,7 +139,7 @@ async def test_disabled_port_no_trigger_eval(self, mocker, mock_num_port1): (mocker.patch.object(mock_num_port1, "eval_and_push_write"),) (mocker.patch.object(mock_num_port1, "is_enabled", return_value=False),) - await handle_value_changes(changed_set=set(), value_pairs={}, now_ms=0) + await handle_value_changes([mock_num_port1], changed_set=set(), value_pairs={}, now_ms=0) mock_num_port1.eval_and_push_write.assert_not_called() async def test_asap_trigger_eval(self, mocker, mock_num_port1): @@ -137,7 +149,7 @@ async def test_asap_trigger_eval(self, mocker, mock_num_port1): mock_num_port1.set_expression("TIMEMS()") mocker.patch.object(mock_num_port1, "eval_and_push_write") - await handle_value_changes(changed_set={DEP_ASAP}, value_pairs={}, now_ms=0) + await handle_value_changes([mock_num_port1], changed_set={DEP_ASAP}, value_pairs={}, now_ms=0) mock_num_port1.eval_and_push_write.assert_called_once() async def test_asap_eval_paused_no_trigger_eval(self, mocker, mock_num_port1): @@ -149,7 +161,7 @@ async def test_asap_eval_paused_no_trigger_eval(self, mocker, mock_num_port1): mocker.patch.object(mock_num_port1, "eval_and_push_write") e.pause_asap_eval(1000) - await handle_value_changes(changed_set={DEP_ASAP}, value_pairs={}, now_ms=999) + await handle_value_changes([mock_num_port1], changed_set={DEP_ASAP}, value_pairs={}, now_ms=999) mock_num_port1.eval_and_push_write.assert_not_called() async def test_asap_eval_not_paused_trigger_eval(self, mocker, mock_num_port1): @@ -161,5 +173,5 @@ async def test_asap_eval_not_paused_trigger_eval(self, mocker, mock_num_port1): mocker.patch.object(mock_num_port1, "eval_and_push_write") e.pause_asap_eval(1000) - await handle_value_changes(changed_set={DEP_ASAP}, value_pairs={}, now_ms=1000) + await handle_value_changes([mock_num_port1], changed_set={DEP_ASAP}, value_pairs={}, now_ms=1000) mock_num_port1.eval_and_push_write.assert_called_once()