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.
@@ -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 typing import Any, Literal, Protocol, TypeAlias
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: piphi-runtime-kit-python
3
- Version: 0.7.0
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.0` is the current documented baseline.
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
- InMemoryAutomationIdempotencyStore,
191
+ SQLiteAutomationIdempotencyStore,
192
192
  )
193
193
 
194
194
  automations = AutomationRegistry(
195
- # Development/tests only. Use a durable store for production side effects.
196
- idempotency_store=InMemoryAutomationIdempotencyStore(),
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`; the included in-memory store is deliberately for
209
- tests and local development only.
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)
@@ -1,11 +1,11 @@
1
- piphi_runtime_kit_python-0.7.0.dist-info/METADATA,sha256=BwOlIggxC3uZuVxEtfw6eTmbZW29Df_OfBXoG3xyIyg,36458
2
- piphi_runtime_kit_python-0.7.0.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90
3
- piphi_runtime_kit_python-0.7.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
- piphi_runtime_kit_python-0.7.0.dist-info/licenses/LICENSE,sha256=pS6szQI4X7qAin1ODdoDp-XwZvsFy6ZZ28-JAwGHgzo,1070
5
- piphi_runtime_kit_python/__init__.py,sha256=9IEH3g4lIeGeJCnCmQqJbfjBuq_EmsFfmK61UwelDDA,8024
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=aQN19M3wAH9AH2qw5JOoEmPrTtAX55u8QiKmMno5odI,15053
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.0.dist-info/RECORD,,
30
+ piphi_runtime_kit_python-0.7.1.dist-info/RECORD,,