Skip to content

Commit a6482c1

Browse files
authored
Split ActionGroup into base class and PersistedActionGroup (#2017)
## Summary - Split `ActionGroup` into a lean base class (user-facing) and `PersistedActionGroup` subclass (API-returned from `/actionGroups`) - `PersistedActionGroup` has non-optional `oid: str`, `creation_time: int`, `last_update_time: int` - `get_action_groups()` now returns `list[PersistedActionGroup]` - `Execution.action_group` stays typed as `ActionGroup | None` ## Breaking changes - `get_action_groups()` returns `list[PersistedActionGroup]` instead of `list[ActionGroup]`; `ActionGroup` no longer has `oid`, `creation_time`, or `last_update_time` fields ## Test plan - [x] 7 new model tests for ActionGroup/PersistedActionGroup construction, inheritance, and type guarantees - [x] Updated `test_get_action_groups` assertions to verify `PersistedActionGroup` type and `oid` as `str` - [x] Updated `test_get_current_execution` to verify execution-embedded groups are not `PersistedActionGroup` - [x] Full test suite passes (262 tests in test_models + test_client) - [x] ruff clean, mypy clean
1 parent 06212ef commit a6482c1

8 files changed

Lines changed: 137 additions & 83 deletions

File tree

pyoverkiz/action_queue.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,18 +88,15 @@ def __init__(
8888
executor: Callable[
8989
[list[Action], ExecutionMode | None, str | None], Coroutine[None, None, str]
9090
],
91-
delay: float = 0.5,
92-
max_actions: int = 20,
91+
settings: ActionQueueSettings | None = None,
9392
) -> None:
9493
"""Initialize the action queue.
9594
9695
:param executor: Async function to execute batched actions
97-
:param delay: Seconds to wait before auto-flushing (default 0.5)
98-
:param max_actions: Maximum actions per batch before forced flush (default 20)
96+
:param settings: Queue configuration (uses defaults if None)
9997
"""
10098
self._executor = executor
101-
self._delay = delay
102-
self._max_actions = max_actions
99+
self._settings = settings or ActionQueueSettings()
103100

104101
self._pending_actions: list[Action] = []
105102
self._pending_mode: ExecutionMode | None = None
@@ -188,7 +185,7 @@ async def add(
188185
self._pending_waiters.append(waiter)
189186

190187
# If we hit max actions, flush immediately
191-
if len(self._pending_actions) >= self._max_actions:
188+
if len(self._pending_actions) >= self._settings.max_actions:
192189
# Prepare the current batch for flushing (which includes the actions
193190
# we just added). If we already flushed due to mode change, this is
194191
# a second batch.
@@ -208,7 +205,7 @@ async def _delayed_flush(self) -> None:
208205
"""Wait for the delay period, then flush the queue."""
209206
waiters: list[QueuedExecution] = []
210207
try:
211-
await asyncio.sleep(self._delay)
208+
await asyncio.sleep(self._settings.delay)
212209
async with self._lock:
213210
if not self._pending_actions:
214211
return

pyoverkiz/auth/factory.py

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def build_auth_strategy(
3939

4040
if server == Server.SOMFY_EUROPE:
4141
return SomfyAuthStrategy(
42-
_ensure_username_password(credentials),
42+
_ensure_credentials(credentials, UsernamePasswordCredentials),
4343
session,
4444
server_config,
4545
ssl_context,
@@ -51,23 +51,23 @@ def build_auth_strategy(
5151
Server.SAUTER_COZYTOUCH,
5252
}:
5353
return CozytouchAuthStrategy(
54-
_ensure_username_password(credentials),
54+
_ensure_credentials(credentials, UsernamePasswordCredentials),
5555
session,
5656
server_config,
5757
ssl_context,
5858
)
5959

6060
if server == Server.NEXITY:
6161
return NexityAuthStrategy(
62-
_ensure_username_password(credentials),
62+
_ensure_credentials(credentials, UsernamePasswordCredentials),
6363
session,
6464
server_config,
6565
ssl_context,
6666
)
6767

6868
if server == Server.REXEL:
6969
return RexelAuthStrategy(
70-
_ensure_rexel(credentials),
70+
_ensure_credentials(credentials, RexelOAuthCodeCredentials),
7171
session,
7272
server_config,
7373
ssl_context,
@@ -79,7 +79,7 @@ def build_auth_strategy(
7979
credentials, session, server_config, ssl_context
8080
)
8181
return BearerTokenAuthStrategy(
82-
_ensure_token(credentials),
82+
_ensure_credentials(credentials, TokenCredentials),
8383
session,
8484
server_config,
8585
ssl_context,
@@ -91,29 +91,17 @@ def build_auth_strategy(
9191
return BearerTokenAuthStrategy(credentials, session, server_config, ssl_context)
9292

9393
return SessionLoginStrategy(
94-
_ensure_username_password(credentials),
94+
_ensure_credentials(credentials, UsernamePasswordCredentials),
9595
session,
9696
server_config,
9797
ssl_context,
9898
)
9999

100100

101-
def _ensure_username_password(credentials: Credentials) -> UsernamePasswordCredentials:
102-
"""Validate that credentials are username/password based."""
103-
if not isinstance(credentials, UsernamePasswordCredentials):
104-
raise TypeError("UsernamePasswordCredentials are required for this server.")
105-
return credentials
106-
107-
108-
def _ensure_token(credentials: Credentials) -> TokenCredentials:
109-
"""Validate that credentials carry a bearer token."""
110-
if not isinstance(credentials, TokenCredentials):
111-
raise TypeError("TokenCredentials are required for this server.")
112-
return credentials
113-
114-
115-
def _ensure_rexel(credentials: Credentials) -> RexelOAuthCodeCredentials:
116-
"""Validate that credentials are of Rexel OAuth code type."""
117-
if not isinstance(credentials, RexelOAuthCodeCredentials):
118-
raise TypeError("RexelOAuthCodeCredentials are required for this server.")
101+
def _ensure_credentials[C: Credentials](
102+
credentials: Credentials, expected: type[C]
103+
) -> C:
104+
"""Validate that credentials match the expected type."""
105+
if not isinstance(credentials, expected):
106+
raise TypeError(f"{expected.__name__} are required for this server.")
119107
return credentials

pyoverkiz/client.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
)
3939
from pyoverkiz.models import (
4040
Action,
41-
ActionGroup,
4241
Device,
4342
Event,
4443
Execution,
@@ -47,6 +46,7 @@
4746
HistoryExecution,
4847
Option,
4948
OptionParameter,
49+
PersistedActionGroup,
5050
Place,
5151
ProtocolType,
5252
ServerConfig,
@@ -222,8 +222,7 @@ def __init__(
222222
queue_settings.validate()
223223
self._action_queue = ActionQueue(
224224
executor=self._execute_action_group_direct,
225-
delay=queue_settings.delay,
226-
max_actions=queue_settings.max_actions,
225+
settings=queue_settings,
227226
)
228227

229228
self._auth = build_auth_strategy(
@@ -577,10 +576,10 @@ async def cancel_execution(self, exec_id: str) -> None:
577576
await self._delete(f"exec/current/setup/{exec_id}")
578577

579578
@retry_on_auth_error
580-
async def get_action_groups(self) -> list[ActionGroup]:
579+
async def get_action_groups(self) -> list[PersistedActionGroup]:
581580
"""List action groups persisted on the server."""
582581
response = await self._get("actionGroups")
583-
return converter.structure(decamelize(response), list[ActionGroup])
582+
return converter.structure(decamelize(response), list[PersistedActionGroup])
584583

585584
@retry_on_auth_error
586585
async def get_places(self) -> Place:

pyoverkiz/models.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,13 @@ class States:
134134

135135
_states: list[State]
136136
_index: dict[str, State]
137+
_pos: dict[str, int]
137138

138139
def __init__(self, states: list[State] | None = None) -> None:
139140
"""Create a States container from a list of State objects or empty."""
140141
self._states = list(states) if states else []
141142
self._index = {state.name: state for state in self._states}
143+
self._pos = {state.name: i for i, state in enumerate(self._states)}
142144

143145
def __iter__(self) -> Iterator[State]:
144146
"""Return an iterator over contained State objects."""
@@ -159,10 +161,10 @@ def __setitem__(self, name: str, state: State) -> None:
159161
"""Set or append a State identified by name."""
160162
if state.name != name:
161163
raise ValueError(f"State name {state.name!r} does not match key {name!r}")
162-
if name in self._index:
163-
idx = self._states.index(self._index[name])
164-
self._states[idx] = state
164+
if name in self._pos:
165+
self._states[self._pos[name]] = state
165166
else:
167+
self._pos[name] = len(self._states)
166168
self._states.append(state)
167169
self._index[name] = state
168170

@@ -509,24 +511,25 @@ class ActionGroup:
509511
"""
510512

511513
actions: list[Action] = field(factory=list)
512-
creation_time: int | None = None
513-
last_update_time: int | None = None
514-
label: str = field(repr=obfuscate_string, default="")
514+
label: str | None = field(repr=obfuscate_string, default=None)
515515
metadata: str | None = None
516516
shortcut: bool | None = None
517517
notification_type_mask: int | None = None
518518
notification_condition: str | None = None
519519
notification_text: str | None = None
520520
notification_title: str | None = None
521-
oid: str | None = field(repr=obfuscate_id, default=None)
522521

523-
def __attrs_post_init__(self) -> None:
524-
"""Default label to empty string when None."""
525-
if self.label is None:
526-
self.label = ""
522+
523+
@define(kw_only=True)
524+
class PersistedActionGroup(ActionGroup):
525+
"""A server-persisted action group returned by the /actionGroups endpoint."""
526+
527+
oid: str = field(repr=obfuscate_id)
528+
creation_time: int = 0
529+
last_update_time: int = 0
527530

528531
@property
529-
def id(self) -> str | None:
532+
def id(self) -> str:
530533
"""Alias for oid."""
531534
return self.oid
532535

tests/test_action_queue.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import pytest
77

8-
from pyoverkiz.action_queue import ActionQueue, QueuedExecution
8+
from pyoverkiz.action_queue import ActionQueue, ActionQueueSettings, QueuedExecution
99
from pyoverkiz.enums import ExecutionMode, OverkizCommand
1010
from pyoverkiz.models import Action, Command
1111

@@ -24,7 +24,7 @@ async def executor(actions, mode, label):
2424
@pytest.mark.asyncio
2525
async def test_action_queue_single_action(mock_executor):
2626
"""Test queue with a single action."""
27-
queue = ActionQueue(executor=mock_executor, delay=0.1)
27+
queue = ActionQueue(executor=mock_executor, settings=ActionQueueSettings(delay=0.1))
2828

2929
action = Action(
3030
device_url="io://1234-5678-9012/1",
@@ -45,7 +45,7 @@ async def test_action_queue_single_action(mock_executor):
4545
@pytest.mark.asyncio
4646
async def test_action_queue_batching(mock_executor):
4747
"""Test that multiple actions are batched together."""
48-
queue = ActionQueue(executor=mock_executor, delay=0.2)
48+
queue = ActionQueue(executor=mock_executor, settings=ActionQueueSettings(delay=0.2))
4949

5050
actions = [
5151
Action(
@@ -75,7 +75,9 @@ async def test_action_queue_batching(mock_executor):
7575
@pytest.mark.asyncio
7676
async def test_action_queue_max_actions_flush(mock_executor):
7777
"""Test that queue flushes when max actions is reached."""
78-
queue = ActionQueue(executor=mock_executor, delay=10.0, max_actions=3)
78+
queue = ActionQueue(
79+
executor=mock_executor, settings=ActionQueueSettings(delay=10.0, max_actions=3)
80+
)
7981

8082
actions = [
8183
Action(
@@ -112,8 +114,8 @@ async def test_action_queue_max_actions_flush(mock_executor):
112114

113115
@pytest.mark.asyncio
114116
async def test_action_queue_mode_change_flush(mock_executor):
115-
"""Test that queue flushes when execution mode changes."""
116-
queue = ActionQueue(executor=mock_executor, delay=0.5)
117+
"""Test that queue flushes when command mode changes."""
118+
queue = ActionQueue(executor=mock_executor, settings=ActionQueueSettings(delay=0.5))
117119

118120
action = Action(
119121
device_url="io://1234-5678-9012/1",
@@ -140,7 +142,7 @@ async def test_action_queue_mode_change_flush(mock_executor):
140142
@pytest.mark.asyncio
141143
async def test_action_queue_label_change_flush(mock_executor):
142144
"""Test that queue flushes when label changes."""
143-
queue = ActionQueue(executor=mock_executor, delay=0.5)
145+
queue = ActionQueue(executor=mock_executor, settings=ActionQueueSettings(delay=0.5))
144146

145147
action = Action(
146148
device_url="io://1234-5678-9012/1",
@@ -167,7 +169,7 @@ async def test_action_queue_label_change_flush(mock_executor):
167169
@pytest.mark.asyncio
168170
async def test_action_queue_duplicate_device_merge(mock_executor):
169171
"""Test that queue merges commands for duplicate devices."""
170-
queue = ActionQueue(executor=mock_executor, delay=0.5)
172+
queue = ActionQueue(executor=mock_executor, settings=ActionQueueSettings(delay=0.5))
171173

172174
action1 = Action(
173175
device_url="io://1234-5678-9012/1",
@@ -191,7 +193,7 @@ async def test_action_queue_duplicate_device_merge(mock_executor):
191193
@pytest.mark.asyncio
192194
async def test_action_queue_duplicate_device_merge_order(mock_executor):
193195
"""Test that command order is preserved when merging."""
194-
queue = ActionQueue(executor=mock_executor, delay=0.1)
196+
queue = ActionQueue(executor=mock_executor, settings=ActionQueueSettings(delay=0.1))
195197

196198
action1 = Action(
197199
device_url="io://1234-5678-9012/1",
@@ -219,7 +221,7 @@ async def test_action_queue_duplicate_device_merge_does_not_mutate_inputs(
219221
mock_executor,
220222
):
221223
"""Test that merge behavior does not mutate caller-owned Action commands."""
222-
queue = ActionQueue(executor=mock_executor, delay=0.1)
224+
queue = ActionQueue(executor=mock_executor, settings=ActionQueueSettings(delay=0.1))
223225

224226
action1 = Action(
225227
device_url="io://1234-5678-9012/1",
@@ -240,7 +242,9 @@ async def test_action_queue_duplicate_device_merge_does_not_mutate_inputs(
240242
@pytest.mark.asyncio
241243
async def test_action_queue_manual_flush(mock_executor):
242244
"""Test manual flush of the queue."""
243-
queue = ActionQueue(executor=mock_executor, delay=10.0) # Long delay
245+
queue = ActionQueue(
246+
executor=mock_executor, settings=ActionQueueSettings(delay=10.0)
247+
) # Long delay
244248

245249
action = Action(
246250
device_url="io://1234-5678-9012/1",
@@ -261,7 +265,9 @@ async def test_action_queue_manual_flush(mock_executor):
261265
@pytest.mark.asyncio
262266
async def test_action_queue_shutdown(mock_executor):
263267
"""Test that shutdown flushes pending actions."""
264-
queue = ActionQueue(executor=mock_executor, delay=10.0)
268+
queue = ActionQueue(
269+
executor=mock_executor, settings=ActionQueueSettings(delay=10.0)
270+
)
265271

266272
action = Action(
267273
device_url="io://1234-5678-9012/1",
@@ -284,7 +290,7 @@ async def test_action_queue_error_propagation(mock_executor):
284290
# Make executor raise an exception
285291
mock_executor.side_effect = ValueError("API Error")
286292

287-
queue = ActionQueue(executor=mock_executor, delay=0.1)
293+
queue = ActionQueue(executor=mock_executor, settings=ActionQueueSettings(delay=0.1))
288294

289295
action = Action(
290296
device_url="io://1234-5678-9012/1",
@@ -306,7 +312,7 @@ async def test_action_queue_error_propagation(mock_executor):
306312
async def test_action_queue_get_pending_count():
307313
"""Test getting pending action count."""
308314
mock_executor = AsyncMock(return_value="exec-123")
309-
queue = ActionQueue(executor=mock_executor, delay=0.5)
315+
queue = ActionQueue(executor=mock_executor, settings=ActionQueueSettings(delay=0.5))
310316

311317
assert queue.get_pending_count() == 0
312318

0 commit comments

Comments
 (0)