piphi-runtime-kit-python 0.6.0__py3-none-any.whl → 0.7.0__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 +26 -0
- piphi_runtime_kit_python/adapters/fastapi.py +26 -0
- piphi_runtime_kit_python/automations.py +416 -0
- piphi_runtime_kit_python/fastapi.py +2 -0
- piphi_runtime_kit_python/schemas.py +10 -0
- {piphi_runtime_kit_python-0.6.0.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/METADATA +47 -2
- {piphi_runtime_kit_python-0.6.0.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/RECORD +10 -9
- {piphi_runtime_kit_python-0.6.0.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/WHEEL +0 -0
- {piphi_runtime_kit_python-0.6.0.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/entry_points.txt +0 -0
- {piphi_runtime_kit_python-0.6.0.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
from .automations import (
|
|
2
|
+
AutomationActionDefinition,
|
|
3
|
+
AutomationActionRequest,
|
|
4
|
+
AutomationActionResult,
|
|
5
|
+
AutomationClient,
|
|
6
|
+
AutomationContractReport,
|
|
7
|
+
AutomationEventDefinition,
|
|
8
|
+
AutomationIdempotencyStore,
|
|
9
|
+
AutomationRegistry,
|
|
10
|
+
InMemoryAutomationIdempotencyStore,
|
|
11
|
+
assert_behaviors_contract,
|
|
12
|
+
audit_behaviors_contract,
|
|
13
|
+
build_mock_automation_event,
|
|
14
|
+
)
|
|
1
15
|
from .runtime.auth import (
|
|
2
16
|
RUNTIME_CONTAINER_ID_HEADER_NAME,
|
|
3
17
|
RUNTIME_INTERNAL_TOKEN_HEADER_NAME,
|
|
@@ -139,6 +153,15 @@ from .schemas import (
|
|
|
139
153
|
)
|
|
140
154
|
|
|
141
155
|
__all__ = [
|
|
156
|
+
"AutomationActionDefinition",
|
|
157
|
+
"AutomationActionRequest",
|
|
158
|
+
"AutomationActionResult",
|
|
159
|
+
"AutomationClient",
|
|
160
|
+
"AutomationContractReport",
|
|
161
|
+
"AutomationEventDefinition",
|
|
162
|
+
"AutomationIdempotencyStore",
|
|
163
|
+
"AutomationRegistry",
|
|
164
|
+
"InMemoryAutomationIdempotencyStore",
|
|
142
165
|
"BackgroundTaskLimitError",
|
|
143
166
|
"ConfigSyncCoordinator",
|
|
144
167
|
"CoreAuthError",
|
|
@@ -193,6 +216,7 @@ __all__ = [
|
|
|
193
216
|
"IntegrationEventIngestResponse",
|
|
194
217
|
"IntegrationEventListResponse",
|
|
195
218
|
"build_runtime_config_snapshot_from_core_rows",
|
|
219
|
+
"build_mock_automation_event",
|
|
196
220
|
"build_discovery_response",
|
|
197
221
|
"classify_core_delivery_error",
|
|
198
222
|
"build_core_event_payload",
|
|
@@ -241,6 +265,8 @@ __all__ = [
|
|
|
241
265
|
"resolve_device_id",
|
|
242
266
|
"validate_typed_config",
|
|
243
267
|
"validate_typed_configs",
|
|
268
|
+
"assert_behaviors_contract",
|
|
269
|
+
"audit_behaviors_contract",
|
|
244
270
|
"DEFAULT_SOURCE_TOPIC_PREFIX",
|
|
245
271
|
"build_source_errors_topic",
|
|
246
272
|
"build_source_model_packets_topic",
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING, Any, Mapping
|
|
6
6
|
|
|
7
|
+
from ..automations import AutomationActionResult, AutomationRegistry
|
|
7
8
|
from ..runtime.auth import RuntimeAuthHeaders
|
|
8
9
|
from ..runtime.context import RuntimeContext
|
|
9
10
|
|
|
@@ -11,6 +12,31 @@ if TYPE_CHECKING:
|
|
|
11
12
|
from fastapi import Request
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
async def dispatch_automation_action_from_fastapi(
|
|
16
|
+
registry: AutomationRegistry,
|
|
17
|
+
request: "Request",
|
|
18
|
+
payload: object | Mapping[str, Any],
|
|
19
|
+
) -> AutomationActionResult:
|
|
20
|
+
"""Dispatch a Core command while preserving its stable idempotency key.
|
|
21
|
+
|
|
22
|
+
The key is accepted from the canonical header first and then from the JSON
|
|
23
|
+
payload for compatibility with runtimes that proxy requests internally.
|
|
24
|
+
"""
|
|
25
|
+
payload_data = (
|
|
26
|
+
dict(payload)
|
|
27
|
+
if isinstance(payload, Mapping)
|
|
28
|
+
else payload.model_dump(mode="python")
|
|
29
|
+
if hasattr(payload, "model_dump")
|
|
30
|
+
else dict(vars(payload))
|
|
31
|
+
)
|
|
32
|
+
header_key = str(request.headers.get("X-PiPhi-Idempotency-Key") or "").strip()
|
|
33
|
+
payload_key = str(payload_data.get("idempotency_key") or "").strip()
|
|
34
|
+
return await registry.dispatch(
|
|
35
|
+
payload_data,
|
|
36
|
+
idempotency_key=header_key or payload_key or None,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
14
40
|
def get_payload_container_id(
|
|
15
41
|
payload: object | Mapping[str, Any] | None,
|
|
16
42
|
*,
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Typed automation helpers for PiPhi integration runtimes.
|
|
2
|
+
|
|
3
|
+
``behaviors.json`` remains the declarative catalog consumed by Core and the
|
|
4
|
+
builders. This module implements the runtime half of that contract: emitting
|
|
5
|
+
declared events, dispatching declared actions, preserving idempotency keys, and
|
|
6
|
+
checking that runtime handlers match the published behavior schema.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Literal, Protocol, TypeAlias
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
from .runtime.auth import RuntimeAuthContext
|
|
19
|
+
from .runtime.events import EventClient, build_core_event_payload
|
|
20
|
+
from .runtime.identity import RuntimeDeviceRef
|
|
21
|
+
from .schemas import CoreEventPayload, EventSeverity
|
|
22
|
+
|
|
23
|
+
class AutomationActionRequest(BaseModel):
|
|
24
|
+
"""Normalized action request received from PiPhi Core."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
27
|
+
|
|
28
|
+
command: str = Field(min_length=1)
|
|
29
|
+
args: dict[str, Any] = Field(default_factory=dict)
|
|
30
|
+
idempotency_key: str | None = None
|
|
31
|
+
command_id: str | None = None
|
|
32
|
+
correlation_id: str | None = None
|
|
33
|
+
integration_id: str | None = None
|
|
34
|
+
config_id: str | None = None
|
|
35
|
+
container_id: str | None = None
|
|
36
|
+
device_id: str | None = None
|
|
37
|
+
entity_id: str | None = None
|
|
38
|
+
capability_id: str | None = None
|
|
39
|
+
run_id: str | None = None
|
|
40
|
+
node_id: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AutomationActionResult(BaseModel):
|
|
44
|
+
"""Structured result returned by an automation action handler."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
status: Literal["success", "failed"]
|
|
49
|
+
result: dict[str, Any] = Field(default_factory=dict)
|
|
50
|
+
error: str | None = None
|
|
51
|
+
retryable: bool = False
|
|
52
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
53
|
+
replayed: bool = False
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def ok(self) -> bool:
|
|
57
|
+
return self.status == "success"
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def success(
|
|
61
|
+
cls,
|
|
62
|
+
result: Mapping[str, Any] | None = None,
|
|
63
|
+
**values: Any,
|
|
64
|
+
) -> "AutomationActionResult":
|
|
65
|
+
payload = dict(result or {})
|
|
66
|
+
payload.update(values)
|
|
67
|
+
return cls(status="success", result=payload)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def failure(
|
|
71
|
+
cls,
|
|
72
|
+
error: str,
|
|
73
|
+
*,
|
|
74
|
+
retryable: bool = False,
|
|
75
|
+
metadata: Mapping[str, Any] | None = None,
|
|
76
|
+
) -> "AutomationActionResult":
|
|
77
|
+
return cls(
|
|
78
|
+
status="failed",
|
|
79
|
+
error=str(error),
|
|
80
|
+
retryable=retryable,
|
|
81
|
+
metadata=dict(metadata or {}),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
AutomationHandlerValue: TypeAlias = (
|
|
86
|
+
AutomationActionResult | Mapping[str, Any] | None
|
|
87
|
+
)
|
|
88
|
+
AutomationActionHandler: TypeAlias = Callable[
|
|
89
|
+
[AutomationActionRequest],
|
|
90
|
+
AutomationHandlerValue | Awaitable[AutomationHandlerValue],
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AutomationIdempotencyStore(Protocol):
|
|
95
|
+
"""Persistence contract for action results keyed by Core idempotency key."""
|
|
96
|
+
|
|
97
|
+
async def get(self, key: str) -> AutomationActionResult | None: ...
|
|
98
|
+
|
|
99
|
+
async def put(self, key: str, result: AutomationActionResult) -> None: ...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class InMemoryAutomationIdempotencyStore:
|
|
103
|
+
"""Process-local idempotency store for development and tests.
|
|
104
|
+
|
|
105
|
+
Production runtimes should provide a durable implementation when an action
|
|
106
|
+
changes external state. Core supplies a stable key, but the runtime owns the
|
|
107
|
+
device-side effect and therefore must persist the final outcome.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self._results: dict[str, AutomationActionResult] = {}
|
|
112
|
+
|
|
113
|
+
async def get(self, key: str) -> AutomationActionResult | None:
|
|
114
|
+
result = self._results.get(key)
|
|
115
|
+
return result.model_copy(deep=True) if result else None
|
|
116
|
+
|
|
117
|
+
async def put(self, key: str, result: AutomationActionResult) -> None:
|
|
118
|
+
self._results[key] = result.model_copy(deep=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class AutomationActionDefinition:
|
|
123
|
+
command: str
|
|
124
|
+
label: str
|
|
125
|
+
parameter_schema: Mapping[str, Any]
|
|
126
|
+
result_schema: Mapping[str, Any]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class AutomationEventDefinition:
|
|
131
|
+
event_type: str
|
|
132
|
+
label: str
|
|
133
|
+
data_schema: Mapping[str, Any]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class AutomationContractReport:
|
|
138
|
+
missing_action_handlers: tuple[str, ...]
|
|
139
|
+
missing_event_publishers: tuple[str, ...]
|
|
140
|
+
undeclared_action_handlers: tuple[str, ...]
|
|
141
|
+
undeclared_event_publishers: tuple[str, ...]
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def ok(self) -> bool:
|
|
145
|
+
return not any(
|
|
146
|
+
(
|
|
147
|
+
self.missing_action_handlers,
|
|
148
|
+
self.missing_event_publishers,
|
|
149
|
+
self.undeclared_action_handlers,
|
|
150
|
+
self.undeclared_event_publishers,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def messages(self) -> tuple[str, ...]:
|
|
155
|
+
groups = (
|
|
156
|
+
("Missing action handlers", self.missing_action_handlers),
|
|
157
|
+
("Missing event publishers", self.missing_event_publishers),
|
|
158
|
+
("Handlers absent from behaviors.json", self.undeclared_action_handlers),
|
|
159
|
+
("Events absent from behaviors.json", self.undeclared_event_publishers),
|
|
160
|
+
)
|
|
161
|
+
return tuple(f"{label}: {', '.join(values)}" for label, values in groups if values)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class AutomationRegistry:
|
|
165
|
+
"""Registry for typed runtime action handlers and event definitions."""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
*,
|
|
170
|
+
idempotency_store: AutomationIdempotencyStore | None = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
self.idempotency_store = idempotency_store
|
|
173
|
+
self._actions: dict[str, tuple[AutomationActionDefinition, AutomationActionHandler]] = {}
|
|
174
|
+
self._events: dict[str, AutomationEventDefinition] = {}
|
|
175
|
+
self._key_locks: dict[str, asyncio.Lock] = {}
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def action_definitions(self) -> tuple[AutomationActionDefinition, ...]:
|
|
179
|
+
return tuple(item[0] for item in self._actions.values())
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def event_definitions(self) -> tuple[AutomationEventDefinition, ...]:
|
|
183
|
+
return tuple(self._events.values())
|
|
184
|
+
|
|
185
|
+
def action(
|
|
186
|
+
self,
|
|
187
|
+
command: str,
|
|
188
|
+
*,
|
|
189
|
+
label: str | None = None,
|
|
190
|
+
parameter_schema: Mapping[str, Any] | None = None,
|
|
191
|
+
result_schema: Mapping[str, Any] | None = None,
|
|
192
|
+
) -> Callable[[AutomationActionHandler], AutomationActionHandler]:
|
|
193
|
+
"""Register an action handler while preserving its builder schemas."""
|
|
194
|
+
normalized = str(command or "").strip()
|
|
195
|
+
if not normalized:
|
|
196
|
+
raise ValueError("Automation action command cannot be empty")
|
|
197
|
+
|
|
198
|
+
def decorator(handler: AutomationActionHandler) -> AutomationActionHandler:
|
|
199
|
+
if normalized in self._actions:
|
|
200
|
+
raise ValueError(f"Automation action '{normalized}' is already registered")
|
|
201
|
+
definition = AutomationActionDefinition(
|
|
202
|
+
command=normalized,
|
|
203
|
+
label=str(label or normalized).strip(),
|
|
204
|
+
parameter_schema=dict(parameter_schema or {}),
|
|
205
|
+
result_schema=dict(result_schema or {}),
|
|
206
|
+
)
|
|
207
|
+
self._actions[normalized] = (definition, handler)
|
|
208
|
+
return handler
|
|
209
|
+
|
|
210
|
+
return decorator
|
|
211
|
+
|
|
212
|
+
def event(
|
|
213
|
+
self,
|
|
214
|
+
event_type: str,
|
|
215
|
+
*,
|
|
216
|
+
label: str | None = None,
|
|
217
|
+
data_schema: Mapping[str, Any] | None = None,
|
|
218
|
+
) -> AutomationEventDefinition:
|
|
219
|
+
"""Declare an event the integration may publish."""
|
|
220
|
+
normalized = str(event_type or "").strip()
|
|
221
|
+
if not normalized:
|
|
222
|
+
raise ValueError("Automation event type cannot be empty")
|
|
223
|
+
if normalized in self._events:
|
|
224
|
+
raise ValueError(f"Automation event '{normalized}' is already registered")
|
|
225
|
+
definition = AutomationEventDefinition(
|
|
226
|
+
event_type=normalized,
|
|
227
|
+
label=str(label or normalized).strip(),
|
|
228
|
+
data_schema=dict(data_schema or {}),
|
|
229
|
+
)
|
|
230
|
+
self._events[normalized] = definition
|
|
231
|
+
return definition
|
|
232
|
+
|
|
233
|
+
async def dispatch(
|
|
234
|
+
self,
|
|
235
|
+
request: AutomationActionRequest | Mapping[str, Any],
|
|
236
|
+
*,
|
|
237
|
+
idempotency_key: str | None = None,
|
|
238
|
+
) -> AutomationActionResult:
|
|
239
|
+
"""Validate and dispatch an action, replaying durable terminal outcomes."""
|
|
240
|
+
action_request = (
|
|
241
|
+
request
|
|
242
|
+
if isinstance(request, AutomationActionRequest)
|
|
243
|
+
else AutomationActionRequest.model_validate(request)
|
|
244
|
+
)
|
|
245
|
+
if idempotency_key:
|
|
246
|
+
action_request = action_request.model_copy(
|
|
247
|
+
update={"idempotency_key": str(idempotency_key).strip() or None}
|
|
248
|
+
)
|
|
249
|
+
registered = self._actions.get(action_request.command)
|
|
250
|
+
if registered is None:
|
|
251
|
+
return AutomationActionResult.failure(
|
|
252
|
+
f"Unknown automation action '{action_request.command}'",
|
|
253
|
+
retryable=False,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
scoped_key = (
|
|
257
|
+
f"{action_request.command}:{action_request.idempotency_key}"
|
|
258
|
+
if action_request.idempotency_key
|
|
259
|
+
else ""
|
|
260
|
+
)
|
|
261
|
+
if scoped_key and self.idempotency_store is not None:
|
|
262
|
+
lock = self._key_locks.setdefault(scoped_key, asyncio.Lock())
|
|
263
|
+
async with lock:
|
|
264
|
+
cached = await self.idempotency_store.get(scoped_key)
|
|
265
|
+
if cached is not None:
|
|
266
|
+
return cached.model_copy(update={"replayed": True})
|
|
267
|
+
result = await self._invoke(registered[1], action_request)
|
|
268
|
+
if result.ok or not result.retryable:
|
|
269
|
+
await self.idempotency_store.put(scoped_key, result)
|
|
270
|
+
return result
|
|
271
|
+
return await self._invoke(registered[1], action_request)
|
|
272
|
+
|
|
273
|
+
async def _invoke(
|
|
274
|
+
self,
|
|
275
|
+
handler: AutomationActionHandler,
|
|
276
|
+
request: AutomationActionRequest,
|
|
277
|
+
) -> AutomationActionResult:
|
|
278
|
+
try:
|
|
279
|
+
candidate = handler(request)
|
|
280
|
+
value = await candidate if isinstance(candidate, Awaitable) else candidate
|
|
281
|
+
if isinstance(value, AutomationActionResult):
|
|
282
|
+
return value
|
|
283
|
+
if value is None:
|
|
284
|
+
return AutomationActionResult.success()
|
|
285
|
+
if isinstance(value, Mapping):
|
|
286
|
+
return AutomationActionResult.success(value)
|
|
287
|
+
return AutomationActionResult.failure(
|
|
288
|
+
"Automation handler returned an unsupported result type"
|
|
289
|
+
)
|
|
290
|
+
except Exception as exc:
|
|
291
|
+
return AutomationActionResult.failure(str(exc), retryable=False)
|
|
292
|
+
|
|
293
|
+
def contract_snapshot(self) -> dict[str, Any]:
|
|
294
|
+
"""Return stable JSON data for conformance snapshots and tooling."""
|
|
295
|
+
return {
|
|
296
|
+
"actions": [
|
|
297
|
+
{
|
|
298
|
+
"command": item.command,
|
|
299
|
+
"label": item.label,
|
|
300
|
+
"parameter_schema": dict(item.parameter_schema),
|
|
301
|
+
"result_schema": dict(item.result_schema),
|
|
302
|
+
}
|
|
303
|
+
for item in self.action_definitions
|
|
304
|
+
],
|
|
305
|
+
"events": [
|
|
306
|
+
{
|
|
307
|
+
"event_type": item.event_type,
|
|
308
|
+
"label": item.label,
|
|
309
|
+
"data_schema": dict(item.data_schema),
|
|
310
|
+
}
|
|
311
|
+
for item in self.event_definitions
|
|
312
|
+
],
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass(slots=True)
|
|
317
|
+
class AutomationClient:
|
|
318
|
+
"""High-level automation event publisher backed by the existing EventClient."""
|
|
319
|
+
|
|
320
|
+
event_client: EventClient
|
|
321
|
+
auth_context: RuntimeAuthContext
|
|
322
|
+
registry: AutomationRegistry | None = None
|
|
323
|
+
|
|
324
|
+
async def emit(
|
|
325
|
+
self,
|
|
326
|
+
event_type: str,
|
|
327
|
+
*,
|
|
328
|
+
device: RuntimeDeviceRef | Mapping[str, Any],
|
|
329
|
+
data: Mapping[str, Any] | None = None,
|
|
330
|
+
source: str | None = None,
|
|
331
|
+
severity: EventSeverity | str = EventSeverity.info,
|
|
332
|
+
event_id: str | None = None,
|
|
333
|
+
) -> bool:
|
|
334
|
+
if self.registry is not None and event_type not in {
|
|
335
|
+
item.event_type for item in self.registry.event_definitions
|
|
336
|
+
}:
|
|
337
|
+
raise ValueError(f"Automation event '{event_type}' is not registered")
|
|
338
|
+
return await self.event_client.send_device_event(
|
|
339
|
+
device=device,
|
|
340
|
+
event_type=event_type,
|
|
341
|
+
auth_context=self.auth_context,
|
|
342
|
+
payload=data,
|
|
343
|
+
source=source,
|
|
344
|
+
severity=severity,
|
|
345
|
+
event_id=event_id,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def build_mock_automation_event(
|
|
350
|
+
event_type: str,
|
|
351
|
+
*,
|
|
352
|
+
data: Mapping[str, Any] | None = None,
|
|
353
|
+
integration_id: str = "mock-integration",
|
|
354
|
+
config_id: str = "mock-config",
|
|
355
|
+
container_id: str = "mock-container",
|
|
356
|
+
device_id: str = "mock-device",
|
|
357
|
+
event_id: str | None = None,
|
|
358
|
+
) -> CoreEventPayload:
|
|
359
|
+
"""Create a canonical, non-dispatched event fixture for automated tests."""
|
|
360
|
+
return build_core_event_payload(
|
|
361
|
+
event_type=event_type,
|
|
362
|
+
integration_id=integration_id,
|
|
363
|
+
config_id=config_id,
|
|
364
|
+
container_id=container_id,
|
|
365
|
+
device_id=device_id,
|
|
366
|
+
payload=data,
|
|
367
|
+
source="automation-sdk-mock",
|
|
368
|
+
event_id=event_id,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def audit_behaviors_contract(
|
|
373
|
+
behaviors: Mapping[str, Any],
|
|
374
|
+
registry: AutomationRegistry,
|
|
375
|
+
) -> AutomationContractReport:
|
|
376
|
+
"""Compare runtime registrations with commands/events in ``behaviors.json``."""
|
|
377
|
+
declared_actions: set[str] = set()
|
|
378
|
+
declared_events: set[str] = set()
|
|
379
|
+
devices = behaviors.get("devices", [])
|
|
380
|
+
if not isinstance(devices, list):
|
|
381
|
+
devices = []
|
|
382
|
+
for device in devices:
|
|
383
|
+
if not isinstance(device, Mapping):
|
|
384
|
+
continue
|
|
385
|
+
actions = device.get("actions", [])
|
|
386
|
+
triggers = device.get("triggers", [])
|
|
387
|
+
for action in actions if isinstance(actions, list) else []:
|
|
388
|
+
if not isinstance(action, Mapping):
|
|
389
|
+
continue
|
|
390
|
+
runtime = action.get("runtime")
|
|
391
|
+
if isinstance(runtime, Mapping) and runtime.get("command"):
|
|
392
|
+
declared_actions.add(str(runtime["command"]).strip())
|
|
393
|
+
for trigger in triggers if isinstance(triggers, list) else []:
|
|
394
|
+
if not isinstance(trigger, Mapping):
|
|
395
|
+
continue
|
|
396
|
+
runtime = trigger.get("runtime")
|
|
397
|
+
if isinstance(runtime, Mapping) and runtime.get("event"):
|
|
398
|
+
declared_events.add(str(runtime["event"]).strip())
|
|
399
|
+
|
|
400
|
+
registered_actions = {item.command for item in registry.action_definitions}
|
|
401
|
+
registered_events = {item.event_type for item in registry.event_definitions}
|
|
402
|
+
return AutomationContractReport(
|
|
403
|
+
missing_action_handlers=tuple(sorted(declared_actions - registered_actions)),
|
|
404
|
+
missing_event_publishers=tuple(sorted(declared_events - registered_events)),
|
|
405
|
+
undeclared_action_handlers=tuple(sorted(registered_actions - declared_actions)),
|
|
406
|
+
undeclared_event_publishers=tuple(sorted(registered_events - declared_events)),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def assert_behaviors_contract(
|
|
411
|
+
behaviors: Mapping[str, Any],
|
|
412
|
+
registry: AutomationRegistry,
|
|
413
|
+
) -> None:
|
|
414
|
+
report = audit_behaviors_contract(behaviors, registry)
|
|
415
|
+
if not report.ok:
|
|
416
|
+
raise ValueError("; ".join(report.messages()))
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"""Convenience re-exports for the optional FastAPI adapter layer."""
|
|
2
2
|
|
|
3
3
|
from .adapters.fastapi import (
|
|
4
|
+
dispatch_automation_action_from_fastapi,
|
|
4
5
|
get_payload_container_id,
|
|
5
6
|
sync_runtime_auth_from_fastapi_payload,
|
|
6
7
|
sync_runtime_auth_from_fastapi_request,
|
|
7
8
|
)
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
11
|
+
"dispatch_automation_action_from_fastapi",
|
|
10
12
|
"get_payload_container_id",
|
|
11
13
|
"sync_runtime_auth_from_fastapi_payload",
|
|
12
14
|
"sync_runtime_auth_from_fastapi_request",
|
|
@@ -114,11 +114,21 @@ class IntegrationDiscoveryResponse(BaseModel):
|
|
|
114
114
|
class IntegrationCommandRequest(BaseModel):
|
|
115
115
|
"""Standard command payload forwarded from PiPhi Core to a runtime."""
|
|
116
116
|
|
|
117
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
118
|
+
|
|
117
119
|
command: str
|
|
118
120
|
capability_id: str | None = None
|
|
119
121
|
device_id: str | None = None
|
|
120
122
|
entity_id: str | None = None
|
|
121
123
|
args: dict[str, Any] = Field(default_factory=dict)
|
|
124
|
+
idempotency_key: str | None = None
|
|
125
|
+
command_id: str | None = None
|
|
126
|
+
correlation_id: str | None = None
|
|
127
|
+
integration_id: str | None = None
|
|
128
|
+
config_id: str | None = None
|
|
129
|
+
container_id: str | None = None
|
|
130
|
+
run_id: str | None = None
|
|
131
|
+
node_id: str | None = None
|
|
122
132
|
|
|
123
133
|
|
|
124
134
|
class RuntimeEntityDashboardResponse(BaseModel):
|
{piphi_runtime_kit_python-0.6.0.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: piphi-runtime-kit-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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.
|
|
37
|
+
Version `0.7.0` 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
|
|
|
@@ -95,6 +95,8 @@ The SDK is meant to own the shared PiPhi runtime plumbing:
|
|
|
95
95
|
- discovery normalization and response helpers
|
|
96
96
|
- lifecycle bootstrap helpers
|
|
97
97
|
- runtime health and diagnostics helpers
|
|
98
|
+
- typed automation actions and event publishers
|
|
99
|
+
- automation idempotency propagation and contract auditing
|
|
98
100
|
- in-memory runtime registry for active entries, state, and recent events
|
|
99
101
|
- clearer PiPhi-specific delivery errors
|
|
100
102
|
|
|
@@ -167,6 +169,49 @@ The SDK remains intentionally thin: it validates and sends the data, while
|
|
|
167
169
|
PiPhi Core owns state-change derivation, persistence policy, and automation
|
|
168
170
|
semantics.
|
|
169
171
|
|
|
172
|
+
## Automation SDK
|
|
173
|
+
|
|
174
|
+
`behaviors.json` and the Automation SDK solve two different halves of the same
|
|
175
|
+
integration contract:
|
|
176
|
+
|
|
177
|
+
- `behaviors.json` is the declarative catalog. It tells Core and the builders
|
|
178
|
+
which triggers and actions exist, how they are labeled, and which fields the
|
|
179
|
+
user can configure.
|
|
180
|
+
- the Automation SDK is executable runtime code. It publishes declared events,
|
|
181
|
+
receives action commands, validates their context, invokes the correct
|
|
182
|
+
handler, and preserves Core's idempotency key.
|
|
183
|
+
|
|
184
|
+
Declare the user-facing card in `behaviors.json`, then implement its runtime
|
|
185
|
+
command with `AutomationRegistry`:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from piphi_runtime_kit_python import (
|
|
189
|
+
AutomationActionRequest,
|
|
190
|
+
AutomationRegistry,
|
|
191
|
+
InMemoryAutomationIdempotencyStore,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
automations = AutomationRegistry(
|
|
195
|
+
# Development/tests only. Use a durable store for production side effects.
|
|
196
|
+
idempotency_store=InMemoryAutomationIdempotencyStore(),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@automations.action("set_fan_speed", label="Set fan speed")
|
|
200
|
+
async def set_fan_speed(request: AutomationActionRequest) -> dict[str, int]:
|
|
201
|
+
speed = int(request.args["speed"])
|
|
202
|
+
await vendor_client.set_speed(request.device_id, speed)
|
|
203
|
+
return {"speed": speed}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Core supplies a stable idempotency key for each action. Integrations that
|
|
207
|
+
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.
|
|
210
|
+
|
|
211
|
+
For the complete FastAPI wiring, event publishing, contract audit, and mock
|
|
212
|
+
event testing patterns, read [Building automation-capable integrations](docs/automations.md)
|
|
213
|
+
and run the [automation runtime example](examples/automation_runtime/app.py).
|
|
214
|
+
|
|
170
215
|
## Install
|
|
171
216
|
|
|
172
217
|
The package currently supports Python `>=3.11`.
|
{piphi_runtime_kit_python-0.6.0.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/RECORD
RENAMED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
piphi_runtime_kit_python-0.
|
|
2
|
-
piphi_runtime_kit_python-0.
|
|
3
|
-
piphi_runtime_kit_python-0.
|
|
4
|
-
piphi_runtime_kit_python-0.
|
|
5
|
-
piphi_runtime_kit_python/__init__.py,sha256=
|
|
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
|
|
6
6
|
piphi_runtime_kit_python/adapters/__init__.py,sha256=3jBhPZSBN3Qo7MKHlxAol2jPU3cK23BXHR2oTlircsw,361
|
|
7
|
-
piphi_runtime_kit_python/adapters/fastapi.py,sha256=
|
|
8
|
-
piphi_runtime_kit_python/
|
|
7
|
+
piphi_runtime_kit_python/adapters/fastapi.py,sha256=6sAEqJLKM_p4CYt3WB5VLsylXlyhe4v3GxBQawO4rps,3078
|
|
8
|
+
piphi_runtime_kit_python/automations.py,sha256=aQN19M3wAH9AH2qw5JOoEmPrTtAX55u8QiKmMno5odI,15053
|
|
9
|
+
piphi_runtime_kit_python/fastapi.py,sha256=xvHU19-CVDPKjmXW6nW72mpdbJEOLZzT8Twal7lCCkc,453
|
|
9
10
|
piphi_runtime_kit_python/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
10
11
|
piphi_runtime_kit_python/runtime/__init__.py,sha256=cVJj6eniXw9oM_H795vVhVyI1c_dYrcZ-eYQgbT4LE4,4081
|
|
11
12
|
piphi_runtime_kit_python/runtime/auth.py,sha256=qKKkAK4RB-WFPyjQAoXp3t8sQuLDEPb-DR5TTe_OSnA,4702
|
|
@@ -25,5 +26,5 @@ piphi_runtime_kit_python/runtime/starter.py,sha256=D_mCuwiQl6xLLKQHjf963tKB2EUdm
|
|
|
25
26
|
piphi_runtime_kit_python/runtime/state.py,sha256=OIwVxAzX2ErsZ2XdrWlIP5r3GHHub5y9PDtoqNDlwkQ,952
|
|
26
27
|
piphi_runtime_kit_python/runtime/tasks.py,sha256=E9WoE75NQiJp3buJYexysl3NuP8HJHJ3KfaGVKLdiw0,3238
|
|
27
28
|
piphi_runtime_kit_python/runtime/telemetry.py,sha256=vV8f1SY4zD-YjVNZWGl3UdCAEYYxFzaPiSNJ7iFBDNI,6729
|
|
28
|
-
piphi_runtime_kit_python/schemas.py,sha256=
|
|
29
|
-
piphi_runtime_kit_python-0.
|
|
29
|
+
piphi_runtime_kit_python/schemas.py,sha256=Dq6PVJxA2rOIt9rEKopHlmRfZujpnVfZIWYzW1uc3iw,10458
|
|
30
|
+
piphi_runtime_kit_python-0.7.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|