piphi-runtime-kit-python 0.7.0__py3-none-any.whl → 0.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- piphi_runtime_kit_python/__init__.py +4 -0
- piphi_runtime_kit_python/automations.py +139 -1
- {piphi_runtime_kit_python-0.7.0.dist-info → piphi_runtime_kit_python-0.7.1.dist-info}/METADATA +9 -7
- {piphi_runtime_kit_python-0.7.0.dist-info → piphi_runtime_kit_python-0.7.1.dist-info}/RECORD +7 -7
- {piphi_runtime_kit_python-0.7.0.dist-info → piphi_runtime_kit_python-0.7.1.dist-info}/WHEEL +0 -0
- {piphi_runtime_kit_python-0.7.0.dist-info → piphi_runtime_kit_python-0.7.1.dist-info}/entry_points.txt +0 -0
- {piphi_runtime_kit_python-0.7.0.dist-info → piphi_runtime_kit_python-0.7.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -6,8 +6,10 @@ from .automations import (
|
|
|
6
6
|
AutomationContractReport,
|
|
7
7
|
AutomationEventDefinition,
|
|
8
8
|
AutomationIdempotencyStore,
|
|
9
|
+
AutomationIdempotencyClaimStore,
|
|
9
10
|
AutomationRegistry,
|
|
10
11
|
InMemoryAutomationIdempotencyStore,
|
|
12
|
+
SQLiteAutomationIdempotencyStore,
|
|
11
13
|
assert_behaviors_contract,
|
|
12
14
|
audit_behaviors_contract,
|
|
13
15
|
build_mock_automation_event,
|
|
@@ -160,8 +162,10 @@ __all__ = [
|
|
|
160
162
|
"AutomationContractReport",
|
|
161
163
|
"AutomationEventDefinition",
|
|
162
164
|
"AutomationIdempotencyStore",
|
|
165
|
+
"AutomationIdempotencyClaimStore",
|
|
163
166
|
"AutomationRegistry",
|
|
164
167
|
"InMemoryAutomationIdempotencyStore",
|
|
168
|
+
"SQLiteAutomationIdempotencyStore",
|
|
165
169
|
"BackgroundTaskLimitError",
|
|
166
170
|
"ConfigSyncCoordinator",
|
|
167
171
|
"CoreAuthError",
|
|
@@ -10,8 +10,11 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
12
|
from collections.abc import Awaitable, Callable, Mapping
|
|
13
|
+
from contextlib import closing
|
|
13
14
|
from dataclasses import dataclass
|
|
14
|
-
from
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import sqlite3
|
|
17
|
+
from typing import Any, Literal, Protocol, TypeAlias, runtime_checkable
|
|
15
18
|
|
|
16
19
|
from pydantic import BaseModel, ConfigDict, Field
|
|
17
20
|
|
|
@@ -99,6 +102,15 @@ class AutomationIdempotencyStore(Protocol):
|
|
|
99
102
|
async def put(self, key: str, result: AutomationActionResult) -> None: ...
|
|
100
103
|
|
|
101
104
|
|
|
105
|
+
@runtime_checkable
|
|
106
|
+
class AutomationIdempotencyClaimStore(AutomationIdempotencyStore, Protocol):
|
|
107
|
+
"""Store that atomically claims keys across processes before side effects."""
|
|
108
|
+
|
|
109
|
+
async def claim(self, key: str) -> bool: ...
|
|
110
|
+
|
|
111
|
+
async def release(self, key: str) -> None: ...
|
|
112
|
+
|
|
113
|
+
|
|
102
114
|
class InMemoryAutomationIdempotencyStore:
|
|
103
115
|
"""Process-local idempotency store for development and tests.
|
|
104
116
|
|
|
@@ -118,6 +130,111 @@ class InMemoryAutomationIdempotencyStore:
|
|
|
118
130
|
self._results[key] = result.model_copy(deep=True)
|
|
119
131
|
|
|
120
132
|
|
|
133
|
+
class SQLiteAutomationIdempotencyStore:
|
|
134
|
+
"""Durable, process-safe action claim ledger backed by SQLite.
|
|
135
|
+
|
|
136
|
+
A key is inserted as ``dispatching`` before the handler runs. A second
|
|
137
|
+
process cannot claim the same key. Terminal outcomes are replayed, while a
|
|
138
|
+
surviving ``dispatching`` row is treated as an ambiguous earlier attempt
|
|
139
|
+
and is never silently executed again.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, path: str | Path) -> None:
|
|
143
|
+
self.path = str(Path(path).expanduser().resolve())
|
|
144
|
+
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
self._initialize()
|
|
146
|
+
|
|
147
|
+
def _connect(self) -> sqlite3.Connection:
|
|
148
|
+
connection = sqlite3.connect(self.path, timeout=5.0)
|
|
149
|
+
connection.execute("PRAGMA busy_timeout = 5000")
|
|
150
|
+
return connection
|
|
151
|
+
|
|
152
|
+
def _initialize(self) -> None:
|
|
153
|
+
with closing(self._connect()) as connection, connection:
|
|
154
|
+
connection.execute(
|
|
155
|
+
"""
|
|
156
|
+
CREATE TABLE IF NOT EXISTS automation_action_delivery (
|
|
157
|
+
action_key TEXT PRIMARY KEY,
|
|
158
|
+
status TEXT NOT NULL,
|
|
159
|
+
result_json TEXT,
|
|
160
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
161
|
+
)
|
|
162
|
+
"""
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def claim(self, key: str) -> bool:
|
|
166
|
+
return await asyncio.to_thread(self._claim_sync, key)
|
|
167
|
+
|
|
168
|
+
def _claim_sync(self, key: str) -> bool:
|
|
169
|
+
with closing(self._connect()) as connection, connection:
|
|
170
|
+
cursor = connection.execute(
|
|
171
|
+
"""
|
|
172
|
+
INSERT OR IGNORE INTO automation_action_delivery
|
|
173
|
+
(action_key, status)
|
|
174
|
+
VALUES (?, 'dispatching')
|
|
175
|
+
""",
|
|
176
|
+
(key,),
|
|
177
|
+
)
|
|
178
|
+
return cursor.rowcount == 1
|
|
179
|
+
|
|
180
|
+
async def get(self, key: str) -> AutomationActionResult | None:
|
|
181
|
+
return await asyncio.to_thread(self._get_sync, key)
|
|
182
|
+
|
|
183
|
+
def _get_sync(self, key: str) -> AutomationActionResult | None:
|
|
184
|
+
with closing(self._connect()) as connection:
|
|
185
|
+
row = connection.execute(
|
|
186
|
+
"""
|
|
187
|
+
SELECT status, result_json
|
|
188
|
+
FROM automation_action_delivery
|
|
189
|
+
WHERE action_key = ?
|
|
190
|
+
""",
|
|
191
|
+
(key,),
|
|
192
|
+
).fetchone()
|
|
193
|
+
if row is None:
|
|
194
|
+
return None
|
|
195
|
+
status, result_json = row
|
|
196
|
+
if status == "dispatching":
|
|
197
|
+
return AutomationActionResult.failure(
|
|
198
|
+
"An earlier action attempt may still have reached the device; manual review is required",
|
|
199
|
+
retryable=False,
|
|
200
|
+
metadata={"delivery_status": "ambiguous"},
|
|
201
|
+
)
|
|
202
|
+
if not result_json:
|
|
203
|
+
return None
|
|
204
|
+
return AutomationActionResult.model_validate_json(result_json)
|
|
205
|
+
|
|
206
|
+
async def put(self, key: str, result: AutomationActionResult) -> None:
|
|
207
|
+
await asyncio.to_thread(self._put_sync, key, result)
|
|
208
|
+
|
|
209
|
+
def _put_sync(self, key: str, result: AutomationActionResult) -> None:
|
|
210
|
+
with closing(self._connect()) as connection, connection:
|
|
211
|
+
connection.execute(
|
|
212
|
+
"""
|
|
213
|
+
INSERT INTO automation_action_delivery
|
|
214
|
+
(action_key, status, result_json, updated_at)
|
|
215
|
+
VALUES (?, 'terminal', ?, CURRENT_TIMESTAMP)
|
|
216
|
+
ON CONFLICT(action_key) DO UPDATE SET
|
|
217
|
+
status = 'terminal',
|
|
218
|
+
result_json = excluded.result_json,
|
|
219
|
+
updated_at = CURRENT_TIMESTAMP
|
|
220
|
+
""",
|
|
221
|
+
(key, result.model_dump_json()),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
async def release(self, key: str) -> None:
|
|
225
|
+
await asyncio.to_thread(self._release_sync, key)
|
|
226
|
+
|
|
227
|
+
def _release_sync(self, key: str) -> None:
|
|
228
|
+
with closing(self._connect()) as connection, connection:
|
|
229
|
+
connection.execute(
|
|
230
|
+
"""
|
|
231
|
+
DELETE FROM automation_action_delivery
|
|
232
|
+
WHERE action_key = ? AND status = 'dispatching'
|
|
233
|
+
""",
|
|
234
|
+
(key,),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
121
238
|
@dataclass(frozen=True, slots=True)
|
|
122
239
|
class AutomationActionDefinition:
|
|
123
240
|
command: str
|
|
@@ -264,9 +381,30 @@ class AutomationRegistry:
|
|
|
264
381
|
cached = await self.idempotency_store.get(scoped_key)
|
|
265
382
|
if cached is not None:
|
|
266
383
|
return cached.model_copy(update={"replayed": True})
|
|
384
|
+
claim_store = (
|
|
385
|
+
self.idempotency_store
|
|
386
|
+
if isinstance(
|
|
387
|
+
self.idempotency_store,
|
|
388
|
+
AutomationIdempotencyClaimStore,
|
|
389
|
+
)
|
|
390
|
+
else None
|
|
391
|
+
)
|
|
392
|
+
if claim_store is not None and not await claim_store.claim(scoped_key):
|
|
393
|
+
claimed_result = await claim_store.get(scoped_key)
|
|
394
|
+
return (
|
|
395
|
+
claimed_result.model_copy(update={"replayed": True})
|
|
396
|
+
if claimed_result is not None
|
|
397
|
+
else AutomationActionResult.failure(
|
|
398
|
+
"The action idempotency key is already being processed",
|
|
399
|
+
retryable=False,
|
|
400
|
+
metadata={"delivery_status": "ambiguous"},
|
|
401
|
+
)
|
|
402
|
+
)
|
|
267
403
|
result = await self._invoke(registered[1], action_request)
|
|
268
404
|
if result.ok or not result.retryable:
|
|
269
405
|
await self.idempotency_store.put(scoped_key, result)
|
|
406
|
+
elif claim_store is not None:
|
|
407
|
+
await claim_store.release(scoped_key)
|
|
270
408
|
return result
|
|
271
409
|
return await self._invoke(registered[1], action_request)
|
|
272
410
|
|
{piphi_runtime_kit_python-0.7.0.dist-info → piphi_runtime_kit_python-0.7.1.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: piphi-runtime-kit-python
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: PiPhi Network runtime integration helpers
|
|
5
5
|
Keywords: piphi,runtime,integration,iot
|
|
6
6
|
Author-Email: KelvinSan <support@piphi.network>
|
|
@@ -34,7 +34,7 @@ plumbing without hiding the HTTP contract behind a large framework. The goal
|
|
|
34
34
|
is to help developers move faster while still understanding what their runtime
|
|
35
35
|
is doing.
|
|
36
36
|
|
|
37
|
-
Version `0.7.
|
|
37
|
+
Version `0.7.1` is the current documented baseline.
|
|
38
38
|
|
|
39
39
|
> New to PiPhi? Start with [First 15 Minutes](#first-15-minutes), then read [The Golden Path](#the-golden-path), then compare your code to the example app.
|
|
40
40
|
|
|
@@ -188,12 +188,13 @@ command with `AutomationRegistry`:
|
|
|
188
188
|
from piphi_runtime_kit_python import (
|
|
189
189
|
AutomationActionRequest,
|
|
190
190
|
AutomationRegistry,
|
|
191
|
-
|
|
191
|
+
SQLiteAutomationIdempotencyStore,
|
|
192
192
|
)
|
|
193
193
|
|
|
194
194
|
automations = AutomationRegistry(
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
idempotency_store=SQLiteAutomationIdempotencyStore(
|
|
196
|
+
"/data/piphi/automation-actions.sqlite3"
|
|
197
|
+
),
|
|
197
198
|
)
|
|
198
199
|
|
|
199
200
|
@automations.action("set_fan_speed", label="Set fan speed")
|
|
@@ -205,8 +206,9 @@ async def set_fan_speed(request: AutomationActionRequest) -> dict[str, int]:
|
|
|
205
206
|
|
|
206
207
|
Core supplies a stable idempotency key for each action. Integrations that
|
|
207
208
|
change a device or external service must persist terminal results in a durable
|
|
208
|
-
`AutomationIdempotencyStore
|
|
209
|
-
|
|
209
|
+
`AutomationIdempotencyStore`. The SQLite implementation atomically claims keys
|
|
210
|
+
across local runtime processes; the in-memory store is deliberately for tests
|
|
211
|
+
and local development only.
|
|
210
212
|
|
|
211
213
|
For the complete FastAPI wiring, event publishing, contract audit, and mock
|
|
212
214
|
event testing patterns, read [Building automation-capable integrations](docs/automations.md)
|
{piphi_runtime_kit_python-0.7.0.dist-info → piphi_runtime_kit_python-0.7.1.dist-info}/RECORD
RENAMED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
piphi_runtime_kit_python-0.7.
|
|
2
|
-
piphi_runtime_kit_python-0.7.
|
|
3
|
-
piphi_runtime_kit_python-0.7.
|
|
4
|
-
piphi_runtime_kit_python-0.7.
|
|
5
|
-
piphi_runtime_kit_python/__init__.py,sha256=
|
|
1
|
+
piphi_runtime_kit_python-0.7.1.dist-info/METADATA,sha256=tGubJnlnCSLE74rqkMbf7TL9tVLVrUEvvEDYA1GEXH8,36501
|
|
2
|
+
piphi_runtime_kit_python-0.7.1.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90
|
|
3
|
+
piphi_runtime_kit_python-0.7.1.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
|
|
4
|
+
piphi_runtime_kit_python-0.7.1.dist-info/licenses/LICENSE,sha256=pS6szQI4X7qAin1ODdoDp-XwZvsFy6ZZ28-JAwGHgzo,1070
|
|
5
|
+
piphi_runtime_kit_python/__init__.py,sha256=c0UP0yoq03cdAvd-o-KzlGjA2CmxruuDGQFZ9fQKScY,8178
|
|
6
6
|
piphi_runtime_kit_python/adapters/__init__.py,sha256=3jBhPZSBN3Qo7MKHlxAol2jPU3cK23BXHR2oTlircsw,361
|
|
7
7
|
piphi_runtime_kit_python/adapters/fastapi.py,sha256=6sAEqJLKM_p4CYt3WB5VLsylXlyhe4v3GxBQawO4rps,3078
|
|
8
|
-
piphi_runtime_kit_python/automations.py,sha256=
|
|
8
|
+
piphi_runtime_kit_python/automations.py,sha256=wA5BMG-4Zkv1rjE0MKVcW6tPx4DIhz3vHKoVvmBGMO4,20489
|
|
9
9
|
piphi_runtime_kit_python/fastapi.py,sha256=xvHU19-CVDPKjmXW6nW72mpdbJEOLZzT8Twal7lCCkc,453
|
|
10
10
|
piphi_runtime_kit_python/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
11
11
|
piphi_runtime_kit_python/runtime/__init__.py,sha256=cVJj6eniXw9oM_H795vVhVyI1c_dYrcZ-eYQgbT4LE4,4081
|
|
@@ -27,4 +27,4 @@ piphi_runtime_kit_python/runtime/state.py,sha256=OIwVxAzX2ErsZ2XdrWlIP5r3GHHub5y
|
|
|
27
27
|
piphi_runtime_kit_python/runtime/tasks.py,sha256=E9WoE75NQiJp3buJYexysl3NuP8HJHJ3KfaGVKLdiw0,3238
|
|
28
28
|
piphi_runtime_kit_python/runtime/telemetry.py,sha256=vV8f1SY4zD-YjVNZWGl3UdCAEYYxFzaPiSNJ7iFBDNI,6729
|
|
29
29
|
piphi_runtime_kit_python/schemas.py,sha256=Dq6PVJxA2rOIt9rEKopHlmRfZujpnVfZIWYzW1uc3iw,10458
|
|
30
|
-
piphi_runtime_kit_python-0.7.
|
|
30
|
+
piphi_runtime_kit_python-0.7.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|