From 2f9c0c4d2c2428f90198ebb4827e0c7d20a16390 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 14 Apr 2026 15:13:22 +0300 Subject: [PATCH 01/15] core/device: Cache attrs --- tests/unit/qtoggleserver/core/device/test_attrs.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/qtoggleserver/core/device/test_attrs.py b/tests/unit/qtoggleserver/core/device/test_attrs.py index 2502f97f..308b32af 100644 --- a/tests/unit/qtoggleserver/core/device/test_attrs.py +++ b/tests/unit/qtoggleserver/core/device/test_attrs.py @@ -416,10 +416,9 @@ 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.""" +def test_get_attrdefs_first_call(mocker): + """Should initialize attrdefs, calling callable fields, filtering out disabled attributes and returning the resulted + dictionary.""" mocker.patch.object(device_attrs, "_attrdefs_cache", None) attrdefs = device_attrs.get_attrdefs() From 29fde8a1318b01eeebcd96a42cf867ca4f54096a Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 11:33:18 +0300 Subject: [PATCH 02/15] core/expressions: Use deque instead of list --- .../core/expressions/timeprocessing.py | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/qtoggleserver/core/expressions/timeprocessing.py b/qtoggleserver/core/expressions/timeprocessing.py index 3d6ee4ab..ba31352a 100644 --- a/qtoggleserver/core/expressions/timeprocessing.py +++ b/qtoggleserver/core/expressions/timeprocessing.py @@ -1,3 +1,5 @@ +from collections import deque + from . import TIME_JUMP_THRESHOLD from .base import DEP_ASAP, EvalContext, EvalResult from .functions import Function, function @@ -13,7 +15,7 @@ class DelayFunction(Function): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self._queue: list[tuple[int, float]] = [] + self._queue: deque[tuple[int, float]] = deque(maxlen=self.HISTORY_SIZE) self._last_value: float | None = None self._current_value: float | None = None @@ -28,16 +30,11 @@ async def _eval(self, context: EvalContext) -> EvalResult: # Detect value transitions and build history if value != self._last_value: self._last_value = value - - # Drop elements from queue if history size reached - while len(self._queue) >= self.HISTORY_SIZE: - self._queue.pop(0) - self._queue.append((context.now_ms, value)) # Process history while self._queue and (context.now_ms - self._queue[0][0]) >= delay: - self._current_value = self._queue.pop(0)[1] + self._current_value = self._queue.popleft()[1] if self._queue: self.pause_asap_eval(self._queue[0][0] + delay) @@ -256,13 +253,13 @@ class FMAvgFunction(Function): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self._queue: list[float] = [] + self._queue: deque[float] = deque() self._last_time_ms: int = 0 self._last_result: float = 0 async def _eval(self, context: EvalContext) -> EvalResult: value, width, sampling_interval = await self.eval_args(context) - width = min(width, self.MAX_QUEUE_SIZE) + width = min(int(width), self.MAX_QUEUE_SIZE) if self._last_time_ms > 0: if context.now_ms - self._last_time_ms < sampling_interval: @@ -271,13 +268,12 @@ async def _eval(self, context: EvalContext) -> EvalResult: # Make room for the new element while len(self._queue) >= width: - self._queue.pop(0) + self._queue.popleft() self._queue.append(value) self._last_time_ms = context.now_ms - queue = self._queue[-int(width) :] - self._last_result = sum(queue) / len(queue) + self._last_result = sum(self._queue) / len(self._queue) self.pause_asap_eval(self._last_time_ms + sampling_interval) return self._last_result @@ -293,30 +289,28 @@ class FMedianFunction(Function): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self._queue: list[float] = [] + self._queue: deque[float] = deque() self._last_time_ms: int = 0 self._last_result: float = 0 async def _eval(self, context: EvalContext) -> EvalResult: value, width, sampling_interval = await self.eval_args(context) - width = min(width, self.MAX_QUEUE_SIZE) + width = min(int(width), self.MAX_QUEUE_SIZE) if self._last_time_ms > 0: if context.now_ms - self._last_time_ms < sampling_interval: - self.pause_asap_eval(self._last_time_ms + sampling_interval) self.pause_asap_eval(self._last_time_ms + sampling_interval) return self._last_result # Make room for the new element while len(self._queue) >= width: - self._queue.pop(0) + self._queue.popleft() self._queue.append(value) self._last_time_ms = context.now_ms self.pause_asap_eval(self._last_time_ms + sampling_interval) - queue = self._queue[-int(width) :] - queue.sort() + queue = sorted(self._queue) self._last_result = queue[len(queue) // 2] return self._last_result From 407725488ff2c84750a8ef50459d54004eb04f0e Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 11:49:24 +0300 Subject: [PATCH 03/15] core/expressions: Improve _get_min_asap_eval_paused_until_ms() performance --- qtoggleserver/core/expressions/functions.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/qtoggleserver/core/expressions/functions.py b/qtoggleserver/core/expressions/functions.py index f8cdf625..fadecfc5 100644 --- a/qtoggleserver/core/expressions/functions.py +++ b/qtoggleserver/core/expressions/functions.py @@ -36,6 +36,7 @@ def __init__(self, args: list[Expression], role: Role) -> None: super().__init__(role) self.args: list[Expression] = args + self._function_args: list[Function] = [arg for arg in args if isinstance(arg, Function)] def __str__(self) -> str: s = getattr(self, "_str", None) @@ -56,11 +57,12 @@ def is_asap_eval_paused(self, now_ms: int) -> bool: return now_ms < self._get_min_asap_eval_paused_until_ms() def _get_min_asap_eval_paused_until_ms(self) -> int: - min_asap_eval_paused_until_ms = self._asap_eval_paused_until_ms if DEP_ASAP in self.DEPS else 1e13 - min_asap_eval_paused_until_ms_args = [ - arg._get_min_asap_eval_paused_until_ms() for arg in self.args if isinstance(arg, Function) - ] - return min([min_asap_eval_paused_until_ms, *min_asap_eval_paused_until_ms_args]) + result = self._asap_eval_paused_until_ms if DEP_ASAP in self.DEPS else int(1e13) + for arg in self._function_args: + child = arg._get_min_asap_eval_paused_until_ms() + if child < result: + result = child + return result async def eval_args(self, context: EvalContext) -> list[EvalResult]: return list(await asyncio.gather(*(a.eval(context) for a in self.args))) From 2598d7eef77e69fb6adad831c53aeb46be4d0d91 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 11:59:43 +0300 Subject: [PATCH 04/15] core/ports: Add BasePort.is_removed() to improve PortValue expressions performance --- qtoggleserver/core/expressions/ports.py | 11 +++++-- qtoggleserver/core/ports.py | 8 +++-- .../core/expressions/test_port.py | 29 +++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/qtoggleserver/core/expressions/ports.py b/qtoggleserver/core/expressions/ports.py index f1cc3be7..a42a2c27 100644 --- a/qtoggleserver/core/expressions/ports.py +++ b/qtoggleserver/core/expressions/ports.py @@ -16,9 +16,14 @@ def __init__(self, port_id: str, prefix: str, role: Role) -> None: self.port_id: str = port_id self.prefix: str = prefix - - def get_port(self) -> core_ports.BasePort: - return core_ports.get(self.port_id) + self._cached_port: core_ports.BasePort | None = None + + def get_port(self) -> core_ports.BasePort | None: + port = self._cached_port + if port is None or port.is_removed(): + port = core_ports.get(self.port_id) + self._cached_port = port + return port @staticmethod def parse(self_port_id: str | None, sexpression: str, role: Role, pos: int) -> Expression: diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index 1e997d6b..560076ed 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -256,6 +256,7 @@ def __init__(self, port_id: str) -> None: self._pending_save: bool = False self._loaded: bool = False + self._removed: bool = False self._after_set_attr_debounced = Debounced(self._after_set_attr) def __str__(self) -> str: @@ -879,7 +880,7 @@ async def load(self) -> None: data = await persist.get(self.PERSIST_COLLECTION, self.get_id()) or {} await self.load_from_data(data) - self.set_loaded() + self._loaded = True self.initialize() async def reset(self) -> None: @@ -1012,13 +1013,14 @@ async def cleanup(self) -> None: def is_loaded(self) -> bool: return self._loaded - def set_loaded(self) -> None: - self._loaded = True + def is_removed(self) -> bool: + return self._removed async def remove(self, persisted_data: bool = True) -> None: await self.cleanup() self.debug("removing port") + self._removed = True _ports_by_id.pop(self._id, None) if persisted_data: diff --git a/tests/unit/qtoggleserver/core/expressions/test_port.py b/tests/unit/qtoggleserver/core/expressions/test_port.py index ac6c8c0c..08106288 100644 --- a/tests/unit/qtoggleserver/core/expressions/test_port.py +++ b/tests/unit/qtoggleserver/core/expressions/test_port.py @@ -92,3 +92,32 @@ async def test_eval_unknown(self, mock_num_port1, dummy_eval_context): with pytest.raises(UnknownPortId) as exc_info: await e._eval(dummy_eval_context) assert exc_info.value.port_id == "inexistent" + + +class TestGetPortCache: + def test_cache_populated_on_first_call(self, mock_num_port1): + """get_port() should populate the cache after the first lookup.""" + + e = PortValue("nid1", prefix="$", role=Role.VALUE) + assert e._cached_port is None + port = e.get_port() + assert port is mock_num_port1 + assert e._cached_port is mock_num_port1 + + def test_cache_hit_on_second_call(self, mock_num_port1): + """get_port() called twice should return the same object without re-lookup.""" + + e = PortValue("nid1", prefix="$", role=Role.VALUE) + port1 = e.get_port() + port2 = e.get_port() + assert port1 is port2 is mock_num_port1 + + async def test_cache_invalidated_on_port_removal(self, mock_num_port1): + """get_port() should return None after the port is removed.""" + + e = PortValue("nid1", prefix="$", role=Role.VALUE) + assert e.get_port() is mock_num_port1 + + await mock_num_port1.remove(persisted_data=False) + assert mock_num_port1.is_removed() + assert e.get_port() is None From 70804765f41de2bd566838c722c1e3f7066685f0 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 12:01:33 +0300 Subject: [PATCH 05/15] core/expressions: Improve EvalContext.timestamp performance --- qtoggleserver/core/expressions/base.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/qtoggleserver/core/expressions/base.py b/qtoggleserver/core/expressions/base.py index fcaf48f6..98c8d821 100644 --- a/qtoggleserver/core/expressions/base.py +++ b/qtoggleserver/core/expressions/base.py @@ -75,10 +75,7 @@ class EvalContext: 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 - - @property - def timestamp(self) -> int: - return int(self.now_ms / 1000) + self.timestamp: int = now_ms // 1000 def __str__(self) -> str: return f"EvalContext(now_ms={self.now_ms}, port_values={self.port_values})" From 6398e3421a56e9339f2b0f7b6c8acbc75d7082d0 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 12:07:23 +0300 Subject: [PATCH 06/15] core/expressions: Improve Sequence function performance --- qtoggleserver/core/expressions/various.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/qtoggleserver/core/expressions/various.py b/qtoggleserver/core/expressions/various.py index bb3f373c..7232085e 100644 --- a/qtoggleserver/core/expressions/various.py +++ b/qtoggleserver/core/expressions/various.py @@ -171,33 +171,25 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._start_time_ms: int = 0 + self._num_values: int = len(self.args) // 2 async def _eval(self, context: EvalContext) -> EvalResult: if self._start_time_ms == 0: self._start_time_ms = context.now_ms args = await self.eval_args(context) - num_values = len(args) // 2 - values = [] - delays = [] total_delay = 0 - for i in range(num_values): - values.append(args[i * 2]) - delays.append(args[i * 2 + 1]) + for i in range(self._num_values): total_delay += args[i * 2 + 1] - if len(delays) < len(values): - delays.append(0) - - delta = context.now_ms - self._start_time_ms - delta = delta % total_delay # work modulo total_delay, to create repeat effect + delta = (context.now_ms - self._start_time_ms) % total_delay delay_so_far = 0 - result = values[0] - for i in range(num_values): - delay_so_far += delays[i] + result = args[0] + for i in range(self._num_values): + delay_so_far += args[i * 2 + 1] if delay_so_far >= delta: self.pause_asap_eval(context.now_ms + delay_so_far - delta) - result = values[i] + result = args[i * 2] break return result From 97a9e1373ce9b59267d1f7da1f17d0046038713a Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 12:22:53 +0300 Subject: [PATCH 07/15] core/expressions: Improve LiteralValue performance --- qtoggleserver/core/expressions/literalvalues.py | 11 +++++------ .../qtoggleserver/core/expressions/test_literal.py | 11 +++++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/qtoggleserver/core/expressions/literalvalues.py b/qtoggleserver/core/expressions/literalvalues.py index 40057cb6..ec941bef 100644 --- a/qtoggleserver/core/expressions/literalvalues.py +++ b/qtoggleserver/core/expressions/literalvalues.py @@ -12,18 +12,17 @@ def __init__(self, value: CoreNullablePortValue, sexpression: str, role: Role) - self.value: CoreNullablePortValue = value self.sexpression: str = sexpression + self._coerced_value: EvalResult | None = value + if isinstance(value, bool): + self._coerced_value = int(value) def __str__(self) -> str: return self.sexpression async def _eval(self, context: EvalContext) -> EvalResult: - if self.value is None: + if self._coerced_value is None: raise ValueUnavailable - - if isinstance(self.value, int): - return self.value - else: - return float(self.value) + return self._coerced_value @staticmethod def parse(self_port_id: str | None, sexpression: str, role: Role, pos: int) -> Expression: diff --git a/tests/unit/qtoggleserver/core/expressions/test_literal.py b/tests/unit/qtoggleserver/core/expressions/test_literal.py index ff9d47b7..189ad2af 100644 --- a/tests/unit/qtoggleserver/core/expressions/test_literal.py +++ b/tests/unit/qtoggleserver/core/expressions/test_literal.py @@ -9,13 +9,16 @@ async def test_eval(self, dummy_eval_context): e = literalvalues.LiteralValue(42, "42", role=Role.VALUE) assert await e._eval(dummy_eval_context) == 42 - e.value = 84.5 + e = literalvalues.LiteralValue(84.5, "84.5", role=Role.VALUE) assert await e._eval(dummy_eval_context) == 84.5 - e.value = False - assert await e._eval(dummy_eval_context) is False + e = literalvalues.LiteralValue(False, "false", role=Role.VALUE) + assert await e._eval(dummy_eval_context) == 0 - e.value = None + e = literalvalues.LiteralValue(True, "true", role=Role.VALUE) + assert await e._eval(dummy_eval_context) == 1 + + e = literalvalues.LiteralValue(None, "unavailable", role=Role.VALUE) with pytest.raises(ValueUnavailable): await e._eval(dummy_eval_context) From 18d8f13fd2ed94db798d9090b4cd00fa285cf8cc Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 12:24:34 +0300 Subject: [PATCH 08/15] core/expressions: Remove bool from EvalResult --- qtoggleserver/core/expressions/base.py | 2 +- qtoggleserver/core/expressions/various.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/qtoggleserver/core/expressions/base.py b/qtoggleserver/core/expressions/base.py index 98c8d821..d4cee6ce 100644 --- a/qtoggleserver/core/expressions/base.py +++ b/qtoggleserver/core/expressions/base.py @@ -81,4 +81,4 @@ def __str__(self) -> str: return f"EvalContext(now_ms={self.now_ms}, port_values={self.port_values})" -EvalResult: TypeAlias = bool | int | float | str +EvalResult: TypeAlias = int | float | str diff --git a/qtoggleserver/core/expressions/various.py b/qtoggleserver/core/expressions/various.py index 7232085e..8c59b0ba 100644 --- a/qtoggleserver/core/expressions/various.py +++ b/qtoggleserver/core/expressions/various.py @@ -14,9 +14,9 @@ class AvailableFunction(Function): async def _eval(self, context: EvalContext) -> EvalResult: try: - return await self.args[0].eval(context) is not None + return bool(await self.args[0].eval(context) is not None) except ValueUnavailable: - return False + return 0 @function("DEFAULT") @@ -154,9 +154,9 @@ class OnOffAutoFunction(Function): async def _eval(self, context: EvalContext) -> EvalResult: value, auto = await self.eval_args(context) if value > 0: - return True + return 1 elif value < 0: - return False + return 0 else: return auto From 58b090e48f483614ab44926c695fa1eca7960be1 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 12:30:56 +0300 Subject: [PATCH 09/15] core/expressions: Slightly improve Expression.parse --- qtoggleserver/core/expressions/__init__.py | 9 +++------ qtoggleserver/core/expressions/base.py | 3 --- qtoggleserver/core/expressions/literalvalues.py | 9 +++------ qtoggleserver/core/expressions/ports.py | 11 +++-------- .../qtoggleserver/core/expressions/test_various.py | 8 ++++---- 5 files changed, 13 insertions(+), 27 deletions(-) diff --git a/qtoggleserver/core/expressions/__init__.py b/qtoggleserver/core/expressions/__init__.py index e517ccff..b268b925 100644 --- a/qtoggleserver/core/expressions/__init__.py +++ b/qtoggleserver/core/expressions/__init__.py @@ -50,12 +50,9 @@ def parse(self_port_id: str | None, sexpression: str, role: Role, pos: int = 1) -> Expression: - while sexpression and sexpression[0].isspace(): - sexpression = sexpression[1:] - pos += 1 - - while sexpression and sexpression[-1].isspace(): - sexpression = sexpression[:-1] + stripped = sexpression.lstrip() + pos += len(sexpression) - len(stripped) + sexpression = stripped.rstrip() if sexpression and sexpression[0] in ("$", "@"): return PortExpression.parse(self_port_id, sexpression, role, pos) diff --git a/qtoggleserver/core/expressions/base.py b/qtoggleserver/core/expressions/base.py index d4cee6ce..7fef343c 100644 --- a/qtoggleserver/core/expressions/base.py +++ b/qtoggleserver/core/expressions/base.py @@ -77,8 +77,5 @@ def __init__(self, port_values: dict[str, NullablePortValue], now_ms: int = 0) - self.now_ms: int = now_ms self.timestamp: int = now_ms // 1000 - def __str__(self) -> str: - return f"EvalContext(now_ms={self.now_ms}, port_values={self.port_values})" - EvalResult: TypeAlias = int | float | str diff --git a/qtoggleserver/core/expressions/literalvalues.py b/qtoggleserver/core/expressions/literalvalues.py index ec941bef..93daf37a 100644 --- a/qtoggleserver/core/expressions/literalvalues.py +++ b/qtoggleserver/core/expressions/literalvalues.py @@ -26,12 +26,9 @@ async def _eval(self, context: EvalContext) -> EvalResult: @staticmethod def parse(self_port_id: str | None, sexpression: str, role: Role, pos: int) -> Expression: - while sexpression and sexpression[0].isspace(): - sexpression = sexpression[1:] - pos += 1 - - while sexpression and sexpression[-1].isspace(): - sexpression = sexpression[:-1] + stripped = sexpression.lstrip() + pos += len(sexpression) - len(stripped) + sexpression = stripped.rstrip() if not sexpression: raise EmptyExpression() diff --git a/qtoggleserver/core/expressions/ports.py b/qtoggleserver/core/expressions/ports.py index a42a2c27..69c629fd 100644 --- a/qtoggleserver/core/expressions/ports.py +++ b/qtoggleserver/core/expressions/ports.py @@ -27,14 +27,9 @@ def get_port(self) -> core_ports.BasePort | None: @staticmethod def parse(self_port_id: str | None, sexpression: str, role: Role, pos: int) -> Expression: - # Remove leading whitespace - while sexpression and sexpression[0].isspace(): - sexpression = sexpression[1:] - pos += 1 - - # Remove trailing whitespace - while sexpression and sexpression[-1].isspace(): - sexpression = sexpression[:-1] + stripped = sexpression.lstrip() + pos += len(sexpression) - len(stripped) + sexpression = stripped.rstrip() prefix = sexpression[0] port_id = sexpression[1:] diff --git a/tests/unit/qtoggleserver/core/expressions/test_various.py b/tests/unit/qtoggleserver/core/expressions/test_various.py index b551806b..c52f1d3b 100644 --- a/tests/unit/qtoggleserver/core/expressions/test_various.py +++ b/tests/unit/qtoggleserver/core/expressions/test_various.py @@ -320,16 +320,16 @@ async def test(self, dummy_eval_context): assert await expr.eval(dummy_eval_context) == 13 value_expr.set_value(-1) - assert await expr.eval(dummy_eval_context) is False + assert await expr.eval(dummy_eval_context) == 0 value_expr.set_value(-10) - assert await expr.eval(dummy_eval_context) is False + assert await expr.eval(dummy_eval_context) == 0 value_expr.set_value(1) - assert await expr.eval(dummy_eval_context) is True + assert await expr.eval(dummy_eval_context) == 1 value_expr.set_value(10) - assert await expr.eval(dummy_eval_context) is True + assert await expr.eval(dummy_eval_context) == 1 def test_parse(self): e = Function.parse(None, "ONOFFAUTO(1, 2)", Role.VALUE, 0) From 64e201d416c923a0b38ca75901b81a3709c7cd99 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 15 Apr 2026 23:50:57 +0300 Subject: [PATCH 10/15] Fix test case after rebase --- tests/unit/qtoggleserver/core/device/test_attrs.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/qtoggleserver/core/device/test_attrs.py b/tests/unit/qtoggleserver/core/device/test_attrs.py index 308b32af..f8aa7727 100644 --- a/tests/unit/qtoggleserver/core/device/test_attrs.py +++ b/tests/unit/qtoggleserver/core/device/test_attrs.py @@ -416,9 +416,10 @@ def test_set_cmd(self, mocker): ) -def test_get_attrdefs_first_call(mocker): - """Should initialize attrdefs, calling callable fields, filtering out disabled attributes and returning the resulted - dictionary.""" +class TestGetAttrdefs: + def test_get_attrdefs_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_cache", None) attrdefs = device_attrs.get_attrdefs() From 2a2a408413e8fd80441e56f070a764c789d63ee1 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sat, 18 Apr 2026 10:09:33 +0300 Subject: [PATCH 11/15] Fix some expression functions return types --- qtoggleserver/core/expressions/timeprocessing.py | 6 +++--- qtoggleserver/core/expressions/various.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/qtoggleserver/core/expressions/timeprocessing.py b/qtoggleserver/core/expressions/timeprocessing.py index ba31352a..c3929985 100644 --- a/qtoggleserver/core/expressions/timeprocessing.py +++ b/qtoggleserver/core/expressions/timeprocessing.py @@ -166,7 +166,7 @@ async def _eval(self, context: EvalContext) -> EvalResult: self._start_time_ms = 0 # stop timer self.pause_asap_eval() - return self._start_time_ms > 0 and context.now_ms - self._start_time_ms >= duration + return int(self._start_time_ms > 0 and context.now_ms - self._start_time_ms >= duration) @function("DERIV") @@ -259,7 +259,7 @@ def __init__(self, *args, **kwargs) -> None: async def _eval(self, context: EvalContext) -> EvalResult: value, width, sampling_interval = await self.eval_args(context) - width = min(int(width), self.MAX_QUEUE_SIZE) + width = max(1, min(int(width), self.MAX_QUEUE_SIZE)) if self._last_time_ms > 0: if context.now_ms - self._last_time_ms < sampling_interval: @@ -295,7 +295,7 @@ def __init__(self, *args, **kwargs) -> None: async def _eval(self, context: EvalContext) -> EvalResult: value, width, sampling_interval = await self.eval_args(context) - width = min(int(width), self.MAX_QUEUE_SIZE) + width = max(1, min(int(width), self.MAX_QUEUE_SIZE)) if self._last_time_ms > 0: if context.now_ms - self._last_time_ms < sampling_interval: diff --git a/qtoggleserver/core/expressions/various.py b/qtoggleserver/core/expressions/various.py index 8c59b0ba..f154f4c1 100644 --- a/qtoggleserver/core/expressions/various.py +++ b/qtoggleserver/core/expressions/various.py @@ -14,7 +14,7 @@ class AvailableFunction(Function): async def _eval(self, context: EvalContext) -> EvalResult: try: - return bool(await self.args[0].eval(context) is not None) + return int(await self.args[0].eval(context) is not None) except ValueUnavailable: return 0 @@ -58,9 +58,9 @@ def __init__(self, *args, **kwargs) -> None: async def _eval(self, context: EvalContext) -> EvalResult: value = await self.args[0].eval(context) - result = False + result = 0 if self._last_value is not None and value > self._last_value: - result = True + result = 1 self._last_value = value return result @@ -78,9 +78,9 @@ def __init__(self, *args, **kwargs) -> None: async def _eval(self, context: EvalContext) -> EvalResult: value = await self.args[0].eval(context) - result = False + result = 0 if self._last_value is not None and value < self._last_value: - result = True + result = 1 self._last_value = value return result From 3586b90e7a58921740b50920d2d2693cc140c878 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sat, 18 Apr 2026 10:23:12 +0300 Subject: [PATCH 12/15] Fix some typos --- pyproject.toml | 2 +- tests/integration/expressions/test_eval_triggers.py | 8 ++++---- tests/unit/qtoggleserver/core/device/test_attrs.py | 4 ++-- tests/unit/qtoggleserver/utils/test_template.py | 12 ++++++------ 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ac951f01..b5b19796 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ package = true [tool.ruff] line-length = 120 -lint.extend-select = ["I", "RUF022", "ANN"] +lint.extend-select = ["I", "RUF022", "ANN", "E501"] lint.extend-ignore = ["ANN002", "ANN003", "ANN401"] lint.isort.lines-after-imports = 2 lint.isort.lines-between-types = 1 diff --git a/tests/integration/expressions/test_eval_triggers.py b/tests/integration/expressions/test_eval_triggers.py index fea2fb8e..ab5ff8b2 100644 --- a/tests/integration/expressions/test_eval_triggers.py +++ b/tests/integration/expressions/test_eval_triggers.py @@ -4,7 +4,7 @@ async def test_eval_trigger_set_expression(mock_num_port1, mock_num_port2, mocker): - """Should trigger expression evaluation and write resulted value when setting an expression to a port.""" + """Should trigger expression evaluation and write resulting value when setting an expression to a port.""" mock_num_port1.set_last_read_value(4) mock_num_port2.set_last_read_value(5) @@ -17,7 +17,7 @@ async def test_eval_trigger_set_expression(mock_num_port1, mock_num_port2, mocke async def test_eval_trigger_value_change(mock_num_port1, mock_num_port2, mocker): - """Should trigger expression evaluation and write resulted value when a dependent port's value changes.""" + """Should trigger expression evaluation and write resulting value when a dependent port's value changes.""" mock_num_port1.set_last_read_value(4) mock_num_port2.set_last_read_value(5) @@ -32,7 +32,7 @@ async def test_eval_trigger_value_change(mock_num_port1, mock_num_port2, mocker) async def test_eval_trigger_value_change_own(mock_num_port1, mock_num_port2, mocker): - """Should trigger expression evaluation and write resulted value when own port value changes, as a regular dep.""" + """Should trigger expression evaluation and write resulting value when own port value changes, as a regular dep.""" mock_num_port1.set_last_read_value(4) mock_num_port2.set_last_read_value(5) @@ -62,7 +62,7 @@ async def test_eval_trigger_ignore_inexistent_port(mock_num_port1, mock_num_port async def test_eval_trigger_port_enabled(mock_num_port1, mocker): - """Should trigger expression evaluation and write resulted value when port becomes enabled.""" + """Should trigger expression evaluation and write resulting value when port becomes enabled.""" mock_num_port1.set_last_read_value(4) mock_num_port1.set_writable(True) diff --git a/tests/unit/qtoggleserver/core/device/test_attrs.py b/tests/unit/qtoggleserver/core/device/test_attrs.py index f8aa7727..afab59ca 100644 --- a/tests/unit/qtoggleserver/core/device/test_attrs.py +++ b/tests/unit/qtoggleserver/core/device/test_attrs.py @@ -418,8 +418,8 @@ def test_set_cmd(self, mocker): class TestGetAttrdefs: def test_get_attrdefs_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 + resulting dictionary.""" mocker.patch.object(device_attrs, "_attrdefs_cache", None) attrdefs = device_attrs.get_attrdefs() diff --git a/tests/unit/qtoggleserver/utils/test_template.py b/tests/unit/qtoggleserver/utils/test_template.py index 5090c077..3e447b96 100644 --- a/tests/unit/qtoggleserver/utils/test_template.py +++ b/tests/unit/qtoggleserver/utils/test_template.py @@ -186,7 +186,7 @@ def test_singleton_sync_async(self, mocker): class TestMake: def test_default(self, mocker): """Should obtain the environment using `get_env(False)` and call `from_string()` on it with the source, - returning the resulted template.""" + returning the resulting template.""" mock_env = mocker.Mock() spy_get_env = mocker.patch("qtoggleserver.utils.template.get_env", return_value=mock_env) @@ -199,7 +199,7 @@ def test_default(self, mocker): def test_async(self, mocker): """Should obtain the environment using `get_env(False)` and call `from_string()` on it with the source, - returning the resulted template.""" + returning the resulting template.""" mock_env = mocker.Mock() spy_get_env = mocker.patch("qtoggleserver.utils.template.get_env", return_value=mock_env) @@ -212,7 +212,7 @@ def test_async(self, mocker): def test_sync(self, mocker): """Should obtain the environment using `get_env(True)` and call `from_string()` on it with the source, - returning the resulted template.""" + returning the resulting template.""" mock_env = mocker.Mock() spy_get_env = mocker.patch("qtoggleserver.utils.template.get_env", return_value=mock_env) @@ -227,7 +227,7 @@ def test_sync(self, mocker): class TestMakeNative: def test_default(self, mocker): """Should obtain the environment using `get_native_env(False)` and call `from_string()` on it with the source, - returning the resulted template.""" + returning the resulting template.""" mock_env = mocker.Mock() spy_get_native_env = mocker.patch("qtoggleserver.utils.template.get_native_env", return_value=mock_env) @@ -240,7 +240,7 @@ def test_default(self, mocker): def test_async(self, mocker): """Should obtain the environment using `get_native_env(False)` and call `from_string()` on it with the source, - returning the resulted template.""" + returning the resulting template.""" mock_env = mocker.Mock() spy_get_native_env = mocker.patch("qtoggleserver.utils.template.get_native_env", return_value=mock_env) @@ -253,7 +253,7 @@ def test_async(self, mocker): def test_sync(self, mocker): """Should obtain the environment using `get_native_env(True)` and call `from_string()` on it with the source, - returning the resulted template.""" + returning the resulting template.""" mock_env = mocker.Mock() spy_get_native_env = mocker.patch("qtoggleserver.utils.template.get_native_env", return_value=mock_env) From 7cd411b3420f62a422f8fe68f78974027ff38e7e Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sat, 18 Apr 2026 10:35:13 +0300 Subject: [PATCH 13/15] Cancel session future on stop --- qtoggleserver/core/api/funcs/various.py | 1 + qtoggleserver/core/sessions.py | 7 +++++++ qtoggleserver/startup.py | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/qtoggleserver/core/api/funcs/various.py b/qtoggleserver/core/api/funcs/various.py index 253e4729..64c7f8a4 100644 --- a/qtoggleserver/core/api/funcs/various.py +++ b/qtoggleserver/core/api/funcs/various.py @@ -47,6 +47,7 @@ async def get_listen(request: core_api.APIRequest) -> GenericJSONList: events = await session.reset_and_wait(timeout, request.access_level) except CancelledError: session.debug("waiting cancelled") + session.cancel() return [] return [await e.to_json() for e in events] diff --git a/qtoggleserver/core/sessions.py b/qtoggleserver/core/sessions.py index 87e8b5b2..cb802355 100644 --- a/qtoggleserver/core/sessions.py +++ b/qtoggleserver/core/sessions.py @@ -66,6 +66,13 @@ def respond(self) -> None: self.future.set_result(reversed(events)) self.future = None + def cancel(self) -> None: + if self.future: + self.debug("canceling future") + if not self.future.done(): + self.future.cancel() + self.future = None + def push(self, event: core_events.Event) -> None: # 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)) diff --git a/qtoggleserver/startup.py b/qtoggleserver/startup.py index d79f4b75..25c14c87 100644 --- a/qtoggleserver/startup.py +++ b/qtoggleserver/startup.py @@ -146,7 +146,7 @@ async def cleanup_loop() -> None: tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] for task in tasks: - logger.debug(f"cancelling {task.get_coro()}") + logger.debug(f"canceling {task.get_coro()}") task.cancel() await asyncio.gather(*tasks) From 6cf8e3d1e74d4aa151b7470b71ea9d3bad019220 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sat, 18 Apr 2026 14:20:17 +0300 Subject: [PATCH 14/15] Remove useless LiteralValue.value attribute --- qtoggleserver/core/expressions/literalvalues.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/qtoggleserver/core/expressions/literalvalues.py b/qtoggleserver/core/expressions/literalvalues.py index 93daf37a..2c87631b 100644 --- a/qtoggleserver/core/expressions/literalvalues.py +++ b/qtoggleserver/core/expressions/literalvalues.py @@ -1,16 +1,15 @@ import re -from qtoggleserver.core.typing import NullablePortValue as CoreNullablePortValue +from qtoggleserver.core.typing import NullablePortValue from .base import EvalContext, EvalResult, Expression, Role from .exceptions import EmptyExpression, UnexpectedCharacter, ValueUnavailable class LiteralValue(Expression): - def __init__(self, value: CoreNullablePortValue, sexpression: str, role: Role) -> None: + def __init__(self, value: NullablePortValue, sexpression: str, role: Role) -> None: super().__init__(role) - self.value: CoreNullablePortValue = value self.sexpression: str = sexpression self._coerced_value: EvalResult | None = value if isinstance(value, bool): From 7a0ee063adf15be79ffade9c4bac3c70d24ee969 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sat, 18 Apr 2026 14:23:43 +0300 Subject: [PATCH 15/15] Fix test --- .../qtoggleserver/core/expressions/test_literal.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/qtoggleserver/core/expressions/test_literal.py b/tests/unit/qtoggleserver/core/expressions/test_literal.py index 189ad2af..65947e23 100644 --- a/tests/unit/qtoggleserver/core/expressions/test_literal.py +++ b/tests/unit/qtoggleserver/core/expressions/test_literal.py @@ -25,22 +25,22 @@ async def test_eval(self, dummy_eval_context): def test_bool(self): e = parse(None, "false", role=Role.VALUE) assert isinstance(e, literalvalues.LiteralValue) - assert e.value == 0 + assert e._coerced_value == 0 e = parse(None, "true", role=Role.VALUE) assert isinstance(e, literalvalues.LiteralValue) - assert e.value == 1 + assert e._coerced_value == 1 def test_num(self): e = parse(None, "16384", role=Role.VALUE) assert isinstance(e, literalvalues.LiteralValue) - assert e.value == 16384 + assert e._coerced_value == 16384 e = parse(None, "-3.14", role=Role.VALUE) assert isinstance(e, literalvalues.LiteralValue) - assert e.value == -3.14 + assert e._coerced_value == -3.14 async def test_unavailable(self, dummy_eval_context): e = parse(None, "unavailable", role=Role.VALUE) assert isinstance(e, literalvalues.LiteralValue) - assert e.value is None + assert e._coerced_value is None