piphi-runtime-kit-python 0.5.1__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 +42 -3
- 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/py.typed +1 -0
- piphi_runtime_kit_python/runtime/__init__.py +12 -2
- piphi_runtime_kit_python/runtime/auth.py +7 -3
- piphi_runtime_kit_python/runtime/config_sync.py +11 -6
- piphi_runtime_kit_python/runtime/configuration.py +11 -3
- piphi_runtime_kit_python/runtime/context.py +1 -0
- piphi_runtime_kit_python/runtime/dispatch.py +14 -10
- piphi_runtime_kit_python/runtime/errors.py +2 -2
- piphi_runtime_kit_python/runtime/events.py +6 -5
- piphi_runtime_kit_python/runtime/health.py +3 -0
- piphi_runtime_kit_python/runtime/identity.py +6 -2
- piphi_runtime_kit_python/runtime/lifespan.py +8 -3
- piphi_runtime_kit_python/runtime/mqtt.py +6 -2
- piphi_runtime_kit_python/runtime/registry.py +6 -2
- piphi_runtime_kit_python/runtime/starter.py +38 -10
- piphi_runtime_kit_python/runtime/state.py +8 -0
- piphi_runtime_kit_python/runtime/tasks.py +71 -12
- piphi_runtime_kit_python/runtime/telemetry.py +4 -3
- piphi_runtime_kit_python/schemas.py +25 -0
- {piphi_runtime_kit_python-0.5.1.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/METADATA +78 -5
- piphi_runtime_kit_python-0.7.0.dist-info/RECORD +30 -0
- piphi_runtime_kit_python-0.5.1.dist-info/RECORD +0 -28
- {piphi_runtime_kit_python-0.5.1.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/WHEEL +0 -0
- {piphi_runtime_kit_python-0.5.1.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/entry_points.txt +0 -0
- {piphi_runtime_kit_python-0.5.1.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,
|
|
@@ -63,7 +77,10 @@ from .runtime.events import (
|
|
|
63
77
|
format_event_log,
|
|
64
78
|
normalize_event_payload,
|
|
65
79
|
)
|
|
66
|
-
from .runtime.health import
|
|
80
|
+
from .runtime.health import (
|
|
81
|
+
build_runtime_diagnostics_response,
|
|
82
|
+
build_runtime_health_response,
|
|
83
|
+
)
|
|
67
84
|
from .runtime.identity import (
|
|
68
85
|
RuntimeDeviceRef,
|
|
69
86
|
RuntimeIdentityError,
|
|
@@ -92,8 +109,17 @@ from .runtime.mqtt import (
|
|
|
92
109
|
)
|
|
93
110
|
from .runtime.registry import RuntimeRegistry
|
|
94
111
|
from .runtime.state import RuntimeProcessState
|
|
95
|
-
from .runtime.starter import
|
|
96
|
-
|
|
112
|
+
from .runtime.starter import (
|
|
113
|
+
RuntimeStarter,
|
|
114
|
+
build_entities_response,
|
|
115
|
+
create_runtime_starter,
|
|
116
|
+
)
|
|
117
|
+
from .runtime.tasks import (
|
|
118
|
+
BackgroundTaskLimitError,
|
|
119
|
+
create_tracked_task,
|
|
120
|
+
shutdown_background_tasks,
|
|
121
|
+
track_background_task,
|
|
122
|
+
)
|
|
97
123
|
from .runtime.telemetry import (
|
|
98
124
|
TelemetryClient,
|
|
99
125
|
TelemetryReading,
|
|
@@ -127,6 +153,16 @@ from .schemas import (
|
|
|
127
153
|
)
|
|
128
154
|
|
|
129
155
|
__all__ = [
|
|
156
|
+
"AutomationActionDefinition",
|
|
157
|
+
"AutomationActionRequest",
|
|
158
|
+
"AutomationActionResult",
|
|
159
|
+
"AutomationClient",
|
|
160
|
+
"AutomationContractReport",
|
|
161
|
+
"AutomationEventDefinition",
|
|
162
|
+
"AutomationIdempotencyStore",
|
|
163
|
+
"AutomationRegistry",
|
|
164
|
+
"InMemoryAutomationIdempotencyStore",
|
|
165
|
+
"BackgroundTaskLimitError",
|
|
130
166
|
"ConfigSyncCoordinator",
|
|
131
167
|
"CoreAuthError",
|
|
132
168
|
"CoreDeliveryError",
|
|
@@ -180,6 +216,7 @@ __all__ = [
|
|
|
180
216
|
"IntegrationEventIngestResponse",
|
|
181
217
|
"IntegrationEventListResponse",
|
|
182
218
|
"build_runtime_config_snapshot_from_core_rows",
|
|
219
|
+
"build_mock_automation_event",
|
|
183
220
|
"build_discovery_response",
|
|
184
221
|
"classify_core_delivery_error",
|
|
185
222
|
"build_core_event_payload",
|
|
@@ -228,6 +265,8 @@ __all__ = [
|
|
|
228
265
|
"resolve_device_id",
|
|
229
266
|
"validate_typed_config",
|
|
230
267
|
"validate_typed_configs",
|
|
268
|
+
"assert_behaviors_contract",
|
|
269
|
+
"audit_behaviors_contract",
|
|
231
270
|
"DEFAULT_SOURCE_TOPIC_PREFIX",
|
|
232
271
|
"build_source_errors_topic",
|
|
233
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",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -8,7 +8,11 @@ from .auth import (
|
|
|
8
8
|
format_runtime_auth_sync_log,
|
|
9
9
|
mask_token,
|
|
10
10
|
)
|
|
11
|
-
from .config_sync import
|
|
11
|
+
from .config_sync import (
|
|
12
|
+
ConfigSyncCoordinator,
|
|
13
|
+
build_sync_response,
|
|
14
|
+
reconcile_config_ids,
|
|
15
|
+
)
|
|
12
16
|
from .configuration import (
|
|
13
17
|
build_config_apply_response,
|
|
14
18
|
build_config_remove_response,
|
|
@@ -61,7 +65,12 @@ from .lifespan import (
|
|
|
61
65
|
from .registry import RuntimeRegistry
|
|
62
66
|
from .state import RuntimeProcessState
|
|
63
67
|
from .starter import RuntimeStarter, create_runtime_starter
|
|
64
|
-
from .tasks import
|
|
68
|
+
from .tasks import (
|
|
69
|
+
BackgroundTaskLimitError,
|
|
70
|
+
create_tracked_task,
|
|
71
|
+
shutdown_background_tasks,
|
|
72
|
+
track_background_task,
|
|
73
|
+
)
|
|
65
74
|
from .telemetry import (
|
|
66
75
|
TelemetryClient,
|
|
67
76
|
TelemetryReading,
|
|
@@ -71,6 +80,7 @@ from .telemetry import (
|
|
|
71
80
|
)
|
|
72
81
|
|
|
73
82
|
__all__ = [
|
|
83
|
+
"BackgroundTaskLimitError",
|
|
74
84
|
"ConfigSyncCoordinator",
|
|
75
85
|
"CoreAuthError",
|
|
76
86
|
"CoreDeliveryError",
|
|
@@ -7,9 +7,9 @@ from dataclasses import dataclass
|
|
|
7
7
|
from typing import Any, Mapping
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
DEFAULT_INTERNAL_TOKEN_ENV_NAME = "PIPHI_INTEGRATION_INTERNAL_TOKEN"
|
|
10
|
+
DEFAULT_INTERNAL_TOKEN_ENV_NAME = "PIPHI_INTEGRATION_INTERNAL_TOKEN" # nosec B105
|
|
11
11
|
RUNTIME_CONTAINER_ID_HEADER_NAME = "X-Container-Id"
|
|
12
|
-
RUNTIME_INTERNAL_TOKEN_HEADER_NAME = "X-PiPhi-Integration-Token"
|
|
12
|
+
RUNTIME_INTERNAL_TOKEN_HEADER_NAME = "X-PiPhi-Integration-Token" # nosec B105
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def mask_token(token: str | None) -> str:
|
|
@@ -32,6 +32,7 @@ def _get_header_value(headers: Mapping[str, Any], name: str) -> str:
|
|
|
32
32
|
@dataclass(slots=True, frozen=True)
|
|
33
33
|
class RuntimeAuthHeaders:
|
|
34
34
|
"""Normalized PiPhi runtime auth values extracted from request headers."""
|
|
35
|
+
|
|
35
36
|
container_id: str = ""
|
|
36
37
|
internal_token: str = ""
|
|
37
38
|
|
|
@@ -81,6 +82,7 @@ class RuntimeAuthContext:
|
|
|
81
82
|
Integrations can keep the most recent container scope and internal token here,
|
|
82
83
|
then reuse it for outbound telemetry or config sync calls.
|
|
83
84
|
"""
|
|
85
|
+
|
|
84
86
|
container_id: str = ""
|
|
85
87
|
internal_token: str = ""
|
|
86
88
|
|
|
@@ -130,5 +132,7 @@ class RuntimeAuthContext:
|
|
|
130
132
|
) -> tuple[str, str]:
|
|
131
133
|
"""Resolve the effective container scope and internal token for a request."""
|
|
132
134
|
resolved_container_id = str(container_id or "").strip() or self.container_id
|
|
133
|
-
resolved_internal_token =
|
|
135
|
+
resolved_internal_token = (
|
|
136
|
+
self.internal_token or (os.getenv(token_env_name) or "").strip()
|
|
137
|
+
)
|
|
134
138
|
return resolved_container_id, resolved_internal_token
|
|
@@ -36,7 +36,9 @@ def resolve_core_base_url(default: str | None = None) -> str | None:
|
|
|
36
36
|
value = (os.getenv(CORE_BASE_URL_ENV_NAME) or "").strip().rstrip("/")
|
|
37
37
|
if value:
|
|
38
38
|
return value
|
|
39
|
-
return
|
|
39
|
+
return (
|
|
40
|
+
default.rstrip("/") if isinstance(default, str) and default.strip() else default
|
|
41
|
+
)
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
def resolve_runtime_config_snapshot_path(
|
|
@@ -164,7 +166,7 @@ def build_runtime_config_snapshot_from_core_rows(
|
|
|
164
166
|
rows: list[dict[str, Any]],
|
|
165
167
|
*,
|
|
166
168
|
container_id: str,
|
|
167
|
-
config_model: type[TConfig] = RuntimeConfig,
|
|
169
|
+
config_model: type[TConfig] = RuntimeConfig, # type: ignore[assignment]
|
|
168
170
|
snapshot_model: type[Any] = RuntimeConfigSnapshot,
|
|
169
171
|
reason: str = "startup_rehydrate",
|
|
170
172
|
) -> Any:
|
|
@@ -200,7 +202,7 @@ async def fetch_core_runtime_config_snapshot(
|
|
|
200
202
|
runtime_context: RuntimeContext,
|
|
201
203
|
client: httpx.AsyncClient,
|
|
202
204
|
core_base_url: str | None = None,
|
|
203
|
-
config_model: type[TConfig] = RuntimeConfig,
|
|
205
|
+
config_model: type[TConfig] = RuntimeConfig, # type: ignore[assignment]
|
|
204
206
|
snapshot_model: type[Any] = RuntimeConfigSnapshot,
|
|
205
207
|
timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
|
|
206
208
|
reason: str = "startup_rehydrate",
|
|
@@ -247,7 +249,7 @@ async def rehydrate_runtime_configs(
|
|
|
247
249
|
runtime_context: RuntimeContext,
|
|
248
250
|
client: httpx.AsyncClient,
|
|
249
251
|
apply_snapshot: SnapshotApplyCallable,
|
|
250
|
-
config_model: type[TConfig] = RuntimeConfig,
|
|
252
|
+
config_model: type[TConfig] = RuntimeConfig, # type: ignore[assignment]
|
|
251
253
|
snapshot_model: type[Any] = RuntimeConfigSnapshot,
|
|
252
254
|
core_base_url: str | None = None,
|
|
253
255
|
timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
|
|
@@ -338,7 +340,7 @@ def build_sync_response(
|
|
|
338
340
|
applied: list[str] | None = None,
|
|
339
341
|
removed: list[str] | None = None,
|
|
340
342
|
active_config_ids: list[str] | None = None,
|
|
341
|
-
metadata: dict | None = None,
|
|
343
|
+
metadata: dict[str, Any] | None = None,
|
|
342
344
|
) -> RuntimeConfigSyncResponse:
|
|
343
345
|
"""Build a standard PiPhi config-sync response payload."""
|
|
344
346
|
return RuntimeConfigSyncResponse(
|
|
@@ -360,6 +362,7 @@ class ConfigSyncCoordinator:
|
|
|
360
362
|
This class intentionally stays small. It helps integrations decide whether
|
|
361
363
|
an incoming snapshot is stale and keeps generation tracking in one place.
|
|
362
364
|
"""
|
|
365
|
+
|
|
363
366
|
process_state: RuntimeProcessState
|
|
364
367
|
|
|
365
368
|
def get_current_generation(self) -> int | None:
|
|
@@ -443,7 +446,9 @@ class ConfigSyncCoordinator:
|
|
|
443
446
|
|
|
444
447
|
current_generation = self.mark_generation(snapshot.generation)
|
|
445
448
|
next_active_config_ids = (
|
|
446
|
-
get_active_config_ids()
|
|
449
|
+
get_active_config_ids()
|
|
450
|
+
if get_active_config_ids is not None
|
|
451
|
+
else incoming_ids
|
|
447
452
|
)
|
|
448
453
|
|
|
449
454
|
return build_sync_response(
|