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.
Files changed (29) hide show
  1. piphi_runtime_kit_python/__init__.py +42 -3
  2. piphi_runtime_kit_python/adapters/fastapi.py +26 -0
  3. piphi_runtime_kit_python/automations.py +416 -0
  4. piphi_runtime_kit_python/fastapi.py +2 -0
  5. piphi_runtime_kit_python/py.typed +1 -0
  6. piphi_runtime_kit_python/runtime/__init__.py +12 -2
  7. piphi_runtime_kit_python/runtime/auth.py +7 -3
  8. piphi_runtime_kit_python/runtime/config_sync.py +11 -6
  9. piphi_runtime_kit_python/runtime/configuration.py +11 -3
  10. piphi_runtime_kit_python/runtime/context.py +1 -0
  11. piphi_runtime_kit_python/runtime/dispatch.py +14 -10
  12. piphi_runtime_kit_python/runtime/errors.py +2 -2
  13. piphi_runtime_kit_python/runtime/events.py +6 -5
  14. piphi_runtime_kit_python/runtime/health.py +3 -0
  15. piphi_runtime_kit_python/runtime/identity.py +6 -2
  16. piphi_runtime_kit_python/runtime/lifespan.py +8 -3
  17. piphi_runtime_kit_python/runtime/mqtt.py +6 -2
  18. piphi_runtime_kit_python/runtime/registry.py +6 -2
  19. piphi_runtime_kit_python/runtime/starter.py +38 -10
  20. piphi_runtime_kit_python/runtime/state.py +8 -0
  21. piphi_runtime_kit_python/runtime/tasks.py +71 -12
  22. piphi_runtime_kit_python/runtime/telemetry.py +4 -3
  23. piphi_runtime_kit_python/schemas.py +25 -0
  24. {piphi_runtime_kit_python-0.5.1.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/METADATA +78 -5
  25. piphi_runtime_kit_python-0.7.0.dist-info/RECORD +30 -0
  26. piphi_runtime_kit_python-0.5.1.dist-info/RECORD +0 -28
  27. {piphi_runtime_kit_python-0.5.1.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/WHEEL +0 -0
  28. {piphi_runtime_kit_python-0.5.1.dist-info → piphi_runtime_kit_python-0.7.0.dist-info}/entry_points.txt +0 -0
  29. {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 build_runtime_diagnostics_response, build_runtime_health_response
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 RuntimeStarter, build_entities_response, create_runtime_starter
96
- from .runtime.tasks import create_tracked_task, shutdown_background_tasks, track_background_task
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 ConfigSyncCoordinator, build_sync_response, reconcile_config_ids
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 create_tracked_task, shutdown_background_tasks, track_background_task
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 = self.internal_token or (os.getenv(token_env_name) or "").strip()
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 default.rstrip("/") if isinstance(default, str) and default.strip() else default
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() if get_active_config_ids is not None else incoming_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(