Skip to content

Commit 807de8f

Browse files
authored
refactor(client)!: keyword-only execute_action_group and typed gateway-selection error (#2120)
## Summary Final API-polish items before tagging 2.0.0. Both are breaking, so they're locked in now rather than after release. - **`execute_action_group()` is keyword-only** — `actions`/`mode`/`label` must be passed by name, matching the keyword-only constructor. Updated all callers, doc examples, and the migration guide. - **Gateway selection raises `UnsupportedOperationError`** — `discover_gateways()` / `select_gateway()` previously raised a bare `TypeError`. They now raise `UnsupportedOperationError` (in the `BaseOverkizError` hierarchy), so they're catchable alongside other Overkiz errors. Also clarified the `open_local_pairing()` docstring. We probed the live endpoint against both Somfy and Atlantic — both return an empty `{}` on success — but the response shape under other conditions is unconfirmed, so the return type stays `Any` and the raw response is passed through untouched (can be narrowed in a 2.x minor without breaking callers). ## Test plan - `pytest` — 518 passed - `ruff check` / `ruff format` — clean - pre-commit hooks (incl. mypy) pass
1 parent 49b5f8a commit 807de8f

8 files changed

Lines changed: 87 additions & 67 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ Full documentation is available at **[imicknl.github.io/python-overkiz-api](http
3030
- Somfy TaHoma Switch
3131
- Thermor Cozytouch
3232

33-
\[*] _This server's authentication method isn't supported yet. To use it, obtain an access token (by sniffing the original app) and create a local user on the Overkiz API platform._
34-
\[**] _Requires OAuth credentials provided by Rexel._
33+
**\*** _This server's authentication method isn't supported yet. To use it, obtain an access token (by sniffing the original app) and create a local user on the Overkiz API platform._
34+
35+
**\*\*** _Requires OAuth credentials provided by Rexel._
3536

3637
## Installation
3738

docs/action-queue.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ Three commands for three different devices produce three actions in one action g
1818

1919
```python
2020
# These three calls arrive within the delay window:
21-
await client.execute_action_group([Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
22-
await client.execute_action_group([Action(device_url="io://1234-5678-1234/87654321", commands=[Command(name=OverkizCommand.OPEN)])])
23-
await client.execute_action_group([Action(device_url="io://1234-5678-1234/11111111", commands=[Command(name=OverkizCommand.STOP)])])
21+
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
22+
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/87654321", commands=[Command(name=OverkizCommand.OPEN)])])
23+
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/11111111", commands=[Command(name=OverkizCommand.STOP)])])
2424

2525
# Sent as one API call:
2626
# ActionGroup(actions=[
@@ -35,8 +35,8 @@ await client.execute_action_group([Action(device_url="io://1234-5678-1234/111111
3535
When two calls target the same device, the queue merges their commands into a single action:
3636

3737
```python
38-
await client.execute_action_group([Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
39-
await client.execute_action_group([Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.SET_CLOSURE, parameters=[50])])])
38+
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
39+
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.SET_CLOSURE, parameters=[50])])])
4040

4141
# Sent as one API call:
4242
# ActionGroup(actions=[
@@ -47,8 +47,8 @@ await client.execute_action_group([Action(device_url="io://1234-5678-1234/123456
4747
### Mixed — both behaviors combined
4848

4949
```python
50-
await client.execute_action_group([Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
51-
await client.execute_action_group([
50+
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
51+
await client.execute_action_group(actions=[
5252
Action(device_url="io://1234-5678-1234/87654321", commands=[Command(name=OverkizCommand.OPEN)]),
5353
Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.SET_CLOSURE, parameters=[50])]),
5454
])
@@ -91,8 +91,8 @@ action2 = Action(
9191
commands=[Command(name=OverkizCommand.OPEN)],
9292
)
9393

94-
task1 = asyncio.create_task(client.execute_action_group([action1]))
95-
task2 = asyncio.create_task(client.execute_action_group([action2]))
94+
task1 = asyncio.create_task(client.execute_action_group(actions=[action1]))
95+
task2 = asyncio.create_task(client.execute_action_group(actions=[action2]))
9696
exec_id1, exec_id2 = await asyncio.gather(task1, task2)
9797

9898
print(exec_id1 == exec_id2)
@@ -160,7 +160,7 @@ action = Action(
160160
commands=[Command(name=OverkizCommand.CLOSE)],
161161
)
162162

163-
exec_task = asyncio.create_task(client.execute_action_group([action]))
163+
exec_task = asyncio.create_task(client.execute_action_group(actions=[action]))
164164

165165
# Give it time to enter the queue
166166
await asyncio.sleep(0.05)
@@ -199,7 +199,7 @@ action = Action(
199199
commands=[Command(name=OverkizCommand.CLOSE)],
200200
)
201201

202-
exec_task = asyncio.create_task(client.execute_action_group([action]))
202+
exec_task = asyncio.create_task(client.execute_action_group(actions=[action]))
203203
await asyncio.sleep(0.01)
204204

205205
pending = client.get_pending_actions_count()

docs/index.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,6 @@ pyOverkiz is an async Python library for interacting with Overkiz-based platform
3636
- Somfy TaHoma Switch
3737
- Thermor Cozytouch
3838

39-
\[*] _This server's authentication method isn't supported yet. To use it, obtain an access token (by sniffing the original app) and create a local user on the Overkiz API platform._
40-
\[**] _Requires OAuth credentials provided by Rexel._
39+
**\*** _This server's authentication method isn't supported yet. To use it, obtain an access token (by sniffing the original app) and create a local user on the Overkiz API platform._
40+
41+
**\*\*** _Requires OAuth credentials provided by Rexel._

docs/migration-v2.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ The command execution API has been consolidated into a single method.
110110
)
111111
```
112112

113+
`execute_action_group()` is keyword-only — pass `actions=[...]` (and optional `mode=`/`label=`) by name, not positionally.
114+
113115
v2 also supports sending actions to **multiple devices** in a single call and choosing an `ExecutionMode` (`HIGH_PRIORITY`, `GEOLOCATED`, `INTERNAL`). Execution modes are only supported by the Cloud API; the local API rejects them (see [Somfy-TaHoma-Developer-Mode#227](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/227)).
114116

115117
### `Command` is no longer a `dict`

pyoverkiz/client.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,7 @@ async def _execute_action_group_direct(
595595

596596
async def execute_action_group(
597597
self,
598+
*,
598599
actions: list[Action],
599600
mode: ExecutionMode | None = None,
600601
label: str | None = "pyOverkiz",
@@ -906,17 +907,25 @@ async def get_device_manufacturer_references(
906907
return converter.structure(response, list[DeviceManufacturerReference])
907908

908909
async def discover_gateways(self) -> list[GatewayCandidate]:
909-
"""Discover selectable gateways. Raises TypeError if unsupported."""
910+
"""Discover selectable gateways.
911+
912+
Raises:
913+
UnsupportedOperationError: When the server does not support gateway selection.
914+
"""
910915
if not isinstance(self._auth, SupportsGatewaySelection):
911-
raise TypeError(
916+
raise UnsupportedOperationError(
912917
f"{self.server_config.name} does not support gateway selection."
913918
)
914919
return await self._auth.discover_gateways()
915920

916921
def select_gateway(self, gateway_id: str) -> None:
917-
"""Select the gateway to scope requests to. Raises TypeError if unsupported."""
922+
"""Select the gateway to scope requests to.
923+
924+
Raises:
925+
UnsupportedOperationError: When the server does not support gateway selection.
926+
"""
918927
if not isinstance(self._auth, SupportsGatewaySelection):
919-
raise TypeError(
928+
raise UnsupportedOperationError(
920929
f"{self.server_config.name} does not support gateway selection."
921930
)
922931
self._auth.select_gateway(gateway_id)
@@ -972,6 +981,10 @@ async def open_local_pairing(self, gateway_id: str) -> Any:
972981
During this window, new tokens can be registered directly on the
973982
gateway without requiring developer mode.
974983
984+
Returns the raw response. Observed as an empty dict on success, but the
985+
shape under other conditions is not yet confirmed, so the response is
986+
passed through as-is.
987+
975988
.. warning::
976989
Experimental (preview). This endpoint is not yet fully validated
977990
and its behaviour or signature may change in a future release.

tests/test_client.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,7 @@ async def test_execute_action_group_omits_none_fields(self, client: OverkizClien
753753
with patch.object(aiohttp.ClientSession, "post") as mock_post:
754754
mock_post.return_value = resp
755755

756-
exec_id = await client.execute_action_group([action])
756+
exec_id = await client.execute_action_group(actions=[action])
757757

758758
assert exec_id == "exec-123"
759759

@@ -981,7 +981,7 @@ async def test_execute_action_group_rts_close(self, client: OverkizClient):
981981

982982
with patch.object(aiohttp.ClientSession, "post") as mock_post:
983983
mock_post.return_value = resp
984-
exec_id = await client.execute_action_group([action])
984+
exec_id = await client.execute_action_group(actions=[action])
985985

986986
assert exec_id == "ee7a5676-c68f-43a3-956d-6f5efc745954"
987987
_, kwargs = mock_post.call_args
@@ -1010,7 +1010,7 @@ async def test_execute_action_group_multiple_rts_devices(
10101010

10111011
with patch.object(aiohttp.ClientSession, "post") as mock_post:
10121012
mock_post.return_value = resp
1013-
exec_id = await client.execute_action_group(actions)
1013+
exec_id = await client.execute_action_group(actions=actions)
10141014

10151015
assert exec_id == "aaa-bbb-ccc"
10161016
_, kwargs = mock_post.call_args
@@ -1200,7 +1200,7 @@ async def test_local_execute_action_group_rts_close(
12001200

12011201
with patch.object(aiohttp.ClientSession, "post") as mock_post:
12021202
mock_post.return_value = resp
1203-
exec_id = await local_client.execute_action_group([action])
1203+
exec_id = await local_client.execute_action_group(actions=[action])
12041204

12051205
assert exec_id == "45e52d27-3c08-4fd5-87f2-03d650b67f4b"
12061206

@@ -1255,9 +1255,12 @@ async def test_discover_gateways_delegates_to_strategy(
12551255
async def test_discover_gateways_raises_for_unsupported_strategy(
12561256
self, client: OverkizClient
12571257
) -> None:
1258-
"""discover_gateways raises TypeError when the strategy lacks the capability."""
1258+
"""discover_gateways raises UnsupportedOperationError when the strategy lacks the capability."""
12591259
# The default Somfy strategy does not implement SupportsGatewaySelection.
1260-
with pytest.raises(TypeError, match="does not support gateway selection"):
1260+
with pytest.raises(
1261+
exceptions.UnsupportedOperationError,
1262+
match="does not support gateway selection",
1263+
):
12611264
await client.discover_gateways()
12621265

12631266
def test_select_gateway_delegates_to_strategy(self, client: OverkizClient) -> None:

tests/test_client_queue_integration.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ async def test_client_without_queue_executes_immediately():
2929
with patch.object(client, "_post", new_callable=AsyncMock) as mock_post:
3030
mock_post.return_value = {"execId": "exec-123"}
3131

32-
result = await client.execute_action_group([action])
32+
result = await client.execute_action_group(actions=[action])
3333

3434
# Should return exec_id directly (string)
3535
assert isinstance(result, str)
@@ -62,9 +62,9 @@ async def test_client_with_queue_batches_actions():
6262
mock_post.return_value = {"execId": "exec-batched"}
6363

6464
# Queue multiple actions quickly - start them as tasks to allow batching
65-
task1 = asyncio.create_task(client.execute_action_group([actions[0]]))
66-
task2 = asyncio.create_task(client.execute_action_group([actions[1]]))
67-
task3 = asyncio.create_task(client.execute_action_group([actions[2]]))
65+
task1 = asyncio.create_task(client.execute_action_group(actions=[actions[0]]))
66+
task2 = asyncio.create_task(client.execute_action_group(actions=[actions[1]]))
67+
task3 = asyncio.create_task(client.execute_action_group(actions=[actions[2]]))
6868

6969
# Give them a moment to queue
7070
await asyncio.sleep(0.01)
@@ -111,7 +111,7 @@ async def test_client_manual_flush():
111111
mock_post.return_value = {"execId": "exec-flushed"}
112112

113113
# Start execution as a task to allow checking pending count
114-
exec_task = asyncio.create_task(client.execute_action_group([action]))
114+
exec_task = asyncio.create_task(client.execute_action_group(actions=[action]))
115115

116116
# Give it a moment to queue
117117
await asyncio.sleep(0.01)
@@ -151,7 +151,7 @@ async def test_client_close_flushes_queue():
151151
mock_post.return_value = {"execId": "exec-closed"}
152152

153153
# Start execution as a task
154-
exec_task = asyncio.create_task(client.execute_action_group([action]))
154+
exec_task = asyncio.create_task(client.execute_action_group(actions=[action]))
155155

156156
# Give it a moment to queue
157157
await asyncio.sleep(0.01)
@@ -192,8 +192,8 @@ async def test_client_queue_respects_max_actions():
192192
mock_post.return_value = {"execId": "exec-123"}
193193

194194
# Add 2 actions as tasks to trigger flush
195-
task1 = asyncio.create_task(client.execute_action_group([actions[0]]))
196-
task2 = asyncio.create_task(client.execute_action_group([actions[1]]))
195+
task1 = asyncio.create_task(client.execute_action_group(actions=[actions[0]]))
196+
task2 = asyncio.create_task(client.execute_action_group(actions=[actions[1]]))
197197

198198
# Wait a bit for flush
199199
await asyncio.sleep(0.05)
@@ -205,7 +205,7 @@ async def test_client_queue_respects_max_actions():
205205
assert exec_id2 == "exec-123"
206206

207207
# Add third action - starts new batch
208-
exec_id3 = await client.execute_action_group([actions[2]])
208+
exec_id3 = await client.execute_action_group(actions=[actions[2]])
209209

210210
# Should have exec_id directly (waited for batch to complete)
211211
assert exec_id3 == "exec-123"

0 commit comments

Comments
 (0)