piphi-runtime-kit-python 0.4.3__tar.gz → 0.4.5__tar.gz

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 (57) hide show
  1. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/PKG-INFO +1 -1
  2. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/pyproject.toml +1 -1
  3. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/__init__.py +8 -0
  4. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/config_sync.py +232 -1
  5. piphi_runtime_kit_python-0.4.5/tests/test_config_sync.py +332 -0
  6. piphi_runtime_kit_python-0.4.3/tests/test_config_sync.py +0 -138
  7. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/LICENSE +0 -0
  8. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/README.md +0 -0
  9. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/adapters/__init__.py +0 -0
  10. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/adapters/fastapi.py +0 -0
  11. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/fastapi.py +0 -0
  12. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/__init__.py +0 -0
  13. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/auth.py +0 -0
  14. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/configuration.py +0 -0
  15. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/context.py +0 -0
  16. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/discovery.py +0 -0
  17. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/dispatch.py +0 -0
  18. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/errors.py +0 -0
  19. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/events.py +0 -0
  20. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/health.py +0 -0
  21. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/identity.py +0 -0
  22. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/lifespan.py +0 -0
  23. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/mqtt.py +0 -0
  24. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/registry.py +0 -0
  25. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/starter.py +0 -0
  26. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/state.py +0 -0
  27. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/tasks.py +0 -0
  28. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/runtime/telemetry.py +0 -0
  29. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/src/piphi_runtime_kit_python/schemas.py +0 -0
  30. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/__init__.py +0 -0
  31. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_fastapi_adapter.py +0 -0
  32. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_auth.py +0 -0
  33. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_auth_matrix.py +0 -0
  34. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_config_sync_matrix.py +0 -0
  35. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_configuration.py +0 -0
  36. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_configuration_matrix.py +0 -0
  37. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_delivery_errors.py +0 -0
  38. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_discovery.py +0 -0
  39. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_discovery_matrix.py +0 -0
  40. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_dispatch.py +0 -0
  41. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_dispatch_matrix.py +0 -0
  42. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_errors_matrix.py +0 -0
  43. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_events.py +0 -0
  44. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_events_matrix.py +0 -0
  45. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_health.py +0 -0
  46. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_identity.py +0 -0
  47. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_lifespan.py +0 -0
  48. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_mqtt.py +0 -0
  49. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_mqtt_matrix.py +0 -0
  50. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_registry.py +0 -0
  51. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_registry_matrix.py +0 -0
  52. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_starter.py +0 -0
  53. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_support_matrix.py +0 -0
  54. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_tasks.py +0 -0
  55. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_telemetry.py +0 -0
  56. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_runtime_telemetry_matrix.py +0 -0
  57. {piphi_runtime_kit_python-0.4.3 → piphi_runtime_kit_python-0.4.5}/tests/test_schemas_matrix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: piphi-runtime-kit-python
3
- Version: 0.4.3
3
+ Version: 0.4.5
4
4
  Summary: PiPhi Network runtime integration helpers
5
5
  Keywords: piphi,runtime,integration,iot
6
6
  Author-Email: KelvinSan <support@piphi.network>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "piphi-runtime-kit-python"
3
- version = "0.4.3"
3
+ version = "0.4.5"
4
4
  description = "PiPhi Network runtime integration helpers"
5
5
  authors = [
6
6
  { name = "KelvinSan", email = "support@piphi.network" },
@@ -14,9 +14,13 @@ from .runtime.config_sync import (
14
14
  DEFAULT_RUNTIME_VOLUME_DIR,
15
15
  RUNTIME_CONFIG_SNAPSHOT_PATH_ENV_NAME,
16
16
  ConfigSyncCoordinator,
17
+ RuntimeConfigRehydrateResult,
18
+ build_runtime_config_snapshot_from_core_rows,
17
19
  build_sync_response,
20
+ fetch_core_runtime_config_snapshot,
18
21
  load_runtime_config_snapshot,
19
22
  reconcile_config_ids,
23
+ rehydrate_runtime_configs,
20
24
  resolve_core_base_url,
21
25
  resolve_runtime_config_snapshot_path,
22
26
  )
@@ -142,6 +146,7 @@ __all__ = [
142
146
  "RuntimeConfig",
143
147
  "RuntimeConfigApplyResponse",
144
148
  "RuntimeConfigRemoveResponse",
149
+ "RuntimeConfigRehydrateResult",
145
150
  "RuntimeConfigSnapshot",
146
151
  "RuntimeConfigSyncResponse",
147
152
  "RuntimeContext",
@@ -158,6 +163,7 @@ __all__ = [
158
163
  "EventTransport",
159
164
  "IntegrationEventIngestResponse",
160
165
  "IntegrationEventListResponse",
166
+ "build_runtime_config_snapshot_from_core_rows",
161
167
  "build_discovery_response",
162
168
  "classify_core_delivery_error",
163
169
  "build_core_event_payload",
@@ -180,6 +186,7 @@ __all__ = [
180
186
  "dispatch_event_delivery",
181
187
  "dispatch_telemetry_delivery",
182
188
  "extract_runtime_auth_headers",
189
+ "fetch_core_runtime_config_snapshot",
183
190
  "format_config_apply_log",
184
191
  "format_discovery_attempt_log",
185
192
  "format_event_log",
@@ -192,6 +199,7 @@ __all__ = [
192
199
  "normalize_event_payload",
193
200
  "redact_config_secrets",
194
201
  "reconcile_config_ids",
202
+ "rehydrate_runtime_configs",
195
203
  "resolve_core_base_url",
196
204
  "resolve_runtime_config_snapshot_path",
197
205
  "runtime_lifespan",
@@ -6,9 +6,14 @@ from dataclasses import dataclass
6
6
  import json
7
7
  import os
8
8
  from pathlib import Path
9
- from typing import Awaitable, Callable, TypeVar
9
+ from typing import Any, Awaitable, Callable, TypeVar
10
+
11
+ import httpx
10
12
 
11
13
  from ..schemas import RuntimeConfig, RuntimeConfigSnapshot, RuntimeConfigSyncResponse
14
+ from .auth import build_runtime_auth_headers
15
+ from .context import RuntimeContext
16
+ from .lifespan import DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS
12
17
  from .state import RuntimeProcessState
13
18
 
14
19
  TConfig = TypeVar("TConfig", bound=RuntimeConfig)
@@ -18,6 +23,12 @@ RUNTIME_CONTAINER_ID_ENV_NAME = "PIPHI_CONTAINER_ID"
18
23
  RUNTIME_CONFIG_SNAPSHOT_PATH_ENV_NAME = "PIPHI_CONFIG_SNAPSHOT_PATH"
19
24
  DEFAULT_RUNTIME_VOLUME_DIR = "/.piphinetwork"
20
25
  DEFAULT_RUNTIME_CONFIG_SNAPSHOT_FILENAME = "configs.json"
26
+ CORE_RUNTIME_CONFIG_FETCH_PATH = (
27
+ "/api/v2/integrations/config/fetch/all/by/container/internal"
28
+ )
29
+
30
+
31
+ SnapshotApplyCallable = Callable[[Any], Awaitable[Any]]
21
32
 
22
33
 
23
34
  def resolve_core_base_url(default: str | None = None) -> str | None:
@@ -85,6 +96,226 @@ def load_runtime_config_snapshot(
85
96
  return None
86
97
 
87
98
 
99
+ @dataclass(slots=True)
100
+ class RuntimeConfigRehydrateResult:
101
+ """Describe what happened during runtime startup config rehydration."""
102
+
103
+ snapshot_found: bool = False
104
+ snapshot_applied: bool = False
105
+ snapshot_config_count: int = 0
106
+ snapshot_generation: int | None = None
107
+ core_attempted: bool = False
108
+ core_applied: bool = False
109
+ core_config_count: int = 0
110
+ core_generation: int | None = None
111
+ core_error: str | None = None
112
+ missing_runtime_auth: bool = False
113
+
114
+ @property
115
+ def applied_any(self) -> bool:
116
+ """Return whether any startup source applied configs."""
117
+ return self.snapshot_applied or self.core_applied
118
+
119
+
120
+ def _model_validate_or_create(model_type: type[Any], payload: dict[str, Any]) -> Any:
121
+ validator = getattr(model_type, "model_validate", None)
122
+ if callable(validator):
123
+ return validator(payload)
124
+ return model_type(**payload)
125
+
126
+
127
+ def _snapshot_validate_or_create(model_type: type[Any], payload: dict[str, Any]) -> Any:
128
+ validator = getattr(model_type, "model_validate", None)
129
+ if callable(validator):
130
+ return validator(payload)
131
+ return model_type(**payload)
132
+
133
+
134
+ def _convert_snapshot_configs(
135
+ snapshot: RuntimeConfigSnapshot,
136
+ *,
137
+ config_model: type[TConfig],
138
+ snapshot_model: type[Any],
139
+ reason: str,
140
+ ) -> Any:
141
+ configs = [
142
+ _model_validate_or_create(config_model, config.model_dump())
143
+ for config in snapshot.configs
144
+ ]
145
+ return _snapshot_validate_or_create(
146
+ snapshot_model,
147
+ {
148
+ "schema_version": snapshot.schema_version,
149
+ "container_id": snapshot.container_id,
150
+ "integration_id": snapshot.integration_id,
151
+ "driver_pid": snapshot.driver_pid,
152
+ "reason": snapshot.reason or reason,
153
+ "generation": snapshot.generation,
154
+ "updated_at": snapshot.updated_at,
155
+ "configs": configs,
156
+ "deleted_config_ids": snapshot.deleted_config_ids,
157
+ "config_hash": snapshot.config_hash,
158
+ "internal_token": snapshot.internal_token,
159
+ },
160
+ )
161
+
162
+
163
+ def build_runtime_config_snapshot_from_core_rows(
164
+ rows: list[dict[str, Any]],
165
+ *,
166
+ container_id: str,
167
+ config_model: type[TConfig] = RuntimeConfig,
168
+ snapshot_model: type[Any] = RuntimeConfigSnapshot,
169
+ reason: str = "startup_rehydrate",
170
+ ) -> Any:
171
+ """Build a runtime config snapshot from Core's internal config rows.
172
+
173
+ Core returns database-shaped rows where the integration config lives under
174
+ ``config_data``. This helper turns those rows into the same snapshot shape
175
+ integrations receive from the normal config-sync endpoint.
176
+ """
177
+ configs: list[Any] = []
178
+ for row in rows:
179
+ if not isinstance(row, dict):
180
+ continue
181
+ config_data = row.get("config_data")
182
+ if not isinstance(config_data, dict):
183
+ continue
184
+ payload = {**config_data}
185
+ payload["container_id"] = payload.get("container_id") or container_id
186
+ configs.append(_model_validate_or_create(config_model, payload))
187
+
188
+ return _snapshot_validate_or_create(
189
+ snapshot_model,
190
+ {
191
+ "container_id": container_id,
192
+ "reason": reason,
193
+ "configs": configs,
194
+ },
195
+ )
196
+
197
+
198
+ async def fetch_core_runtime_config_snapshot(
199
+ *,
200
+ runtime_context: RuntimeContext,
201
+ client: httpx.AsyncClient,
202
+ core_base_url: str | None = None,
203
+ config_model: type[TConfig] = RuntimeConfig,
204
+ snapshot_model: type[Any] = RuntimeConfigSnapshot,
205
+ timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
206
+ reason: str = "startup_rehydrate",
207
+ ) -> Any | None:
208
+ """Fetch the latest runtime configs from Core using managed-runtime auth."""
209
+ container_id, internal_token = runtime_context.auth.resolve()
210
+ if not container_id or not internal_token:
211
+ return None
212
+
213
+ base_url = resolve_core_base_url(core_base_url) or resolve_core_base_url(
214
+ "http://127.0.0.1:31419"
215
+ )
216
+ if not base_url:
217
+ return None
218
+
219
+ response = await client.get(
220
+ f"{base_url}{CORE_RUNTIME_CONFIG_FETCH_PATH}",
221
+ params={"container_id": container_id},
222
+ headers=build_runtime_auth_headers(
223
+ container_id=container_id,
224
+ internal_token=internal_token,
225
+ ),
226
+ timeout=timeout_seconds,
227
+ )
228
+ response.raise_for_status()
229
+
230
+ rows = response.json()
231
+ if not rows:
232
+ return None
233
+ if not isinstance(rows, list):
234
+ raise ValueError("Core runtime config response must be a list.")
235
+
236
+ return build_runtime_config_snapshot_from_core_rows(
237
+ rows,
238
+ container_id=container_id,
239
+ config_model=config_model,
240
+ snapshot_model=snapshot_model,
241
+ reason=reason,
242
+ )
243
+
244
+
245
+ async def rehydrate_runtime_configs(
246
+ *,
247
+ runtime_context: RuntimeContext,
248
+ client: httpx.AsyncClient,
249
+ apply_snapshot: SnapshotApplyCallable,
250
+ config_model: type[TConfig] = RuntimeConfig,
251
+ snapshot_model: type[Any] = RuntimeConfigSnapshot,
252
+ core_base_url: str | None = None,
253
+ timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
254
+ snapshot_path: str | Path | None = None,
255
+ snapshot_volume_dir: str | Path = DEFAULT_RUNTIME_VOLUME_DIR,
256
+ snapshot_reason: str = "startup_snapshot_rehydrate",
257
+ core_reason: str = "startup_rehydrate",
258
+ raise_core_errors: bool = False,
259
+ ) -> RuntimeConfigRehydrateResult:
260
+ """Rehydrate runtime configs from the mounted snapshot and then Core.
261
+
262
+ Integration authors provide only their config model and apply callback. The
263
+ SDK handles startup ordering, managed-runtime auth, Core request headers,
264
+ local snapshot fallback, and safe offline behavior.
265
+ """
266
+ result = RuntimeConfigRehydrateResult()
267
+ container_id = runtime_context.auth.container_id
268
+
269
+ cached_snapshot = load_runtime_config_snapshot(
270
+ path=snapshot_path,
271
+ container_id=container_id,
272
+ volume_dir=snapshot_volume_dir,
273
+ )
274
+ if cached_snapshot is not None:
275
+ result.snapshot_found = True
276
+ snapshot = _convert_snapshot_configs(
277
+ cached_snapshot,
278
+ config_model=config_model,
279
+ snapshot_model=snapshot_model,
280
+ reason=snapshot_reason,
281
+ )
282
+ await apply_snapshot(snapshot)
283
+ result.snapshot_applied = True
284
+ result.snapshot_config_count = len(getattr(snapshot, "configs", []) or [])
285
+ result.snapshot_generation = getattr(snapshot, "generation", None)
286
+
287
+ resolved_container_id, resolved_internal_token = runtime_context.auth.resolve()
288
+ if not resolved_container_id or not resolved_internal_token:
289
+ result.missing_runtime_auth = True
290
+ return result
291
+
292
+ result.core_attempted = True
293
+ try:
294
+ core_snapshot = await fetch_core_runtime_config_snapshot(
295
+ runtime_context=runtime_context,
296
+ client=client,
297
+ core_base_url=core_base_url,
298
+ config_model=config_model,
299
+ snapshot_model=snapshot_model,
300
+ timeout_seconds=timeout_seconds,
301
+ reason=core_reason,
302
+ )
303
+ except Exception as exc:
304
+ result.core_error = f"{type(exc).__name__}: {exc}"
305
+ if raise_core_errors:
306
+ raise
307
+ return result
308
+
309
+ if core_snapshot is None:
310
+ return result
311
+
312
+ await apply_snapshot(core_snapshot)
313
+ result.core_applied = True
314
+ result.core_config_count = len(getattr(core_snapshot, "configs", []) or [])
315
+ result.core_generation = getattr(core_snapshot, "generation", None)
316
+ return result
317
+
318
+
88
319
  def reconcile_config_ids(
89
320
  *,
90
321
  active_ids: list[str],
@@ -0,0 +1,332 @@
1
+ import pytest
2
+ import httpx
3
+
4
+ from piphi_runtime_kit_python import (
5
+ ConfigSyncCoordinator,
6
+ RuntimeConfig,
7
+ RuntimeConfigRehydrateResult,
8
+ RuntimeContext,
9
+ RuntimeConfigSnapshot,
10
+ RuntimeProcessState,
11
+ build_runtime_config_snapshot_from_core_rows,
12
+ fetch_core_runtime_config_snapshot,
13
+ load_runtime_config_snapshot,
14
+ reconcile_config_ids,
15
+ rehydrate_runtime_configs,
16
+ resolve_core_base_url,
17
+ resolve_runtime_config_snapshot_path,
18
+ )
19
+
20
+
21
+ class _CoreConfigResponse:
22
+ def __init__(self, payload):
23
+ self.payload = payload
24
+
25
+ def raise_for_status(self) -> None:
26
+ return None
27
+
28
+ def json(self):
29
+ return self.payload
30
+
31
+
32
+ class _CoreConfigClient:
33
+ def __init__(self, payload):
34
+ self.payload = payload
35
+ self.requests: list[dict] = []
36
+
37
+ async def get(self, url: str, **kwargs):
38
+ self.requests.append({"url": url, **kwargs})
39
+ return _CoreConfigResponse(self.payload)
40
+
41
+
42
+ class _OfflineCoreClient:
43
+ async def get(self, *_args, **_kwargs):
44
+ raise httpx.ConnectError("All connection attempts failed")
45
+
46
+
47
+ def test_reconcile_config_ids_returns_removed_and_unchanged_ids() -> None:
48
+ removed_ids, unchanged_ids = reconcile_config_ids(
49
+ active_ids=["a", "b", "c"],
50
+ incoming_ids=["b", "c", "d"],
51
+ )
52
+
53
+ assert removed_ids == ["a"]
54
+ assert unchanged_ids == ["b", "c"]
55
+
56
+
57
+ def test_config_sync_coordinator_detects_stale_snapshot() -> None:
58
+ state = RuntimeProcessState(current_generation=10)
59
+ coordinator = ConfigSyncCoordinator(process_state=state)
60
+ snapshot = RuntimeConfigSnapshot(
61
+ container_id="container-1",
62
+ generation=9,
63
+ configs=[RuntimeConfig(id="device-1")],
64
+ )
65
+
66
+ assert coordinator.is_stale(snapshot) is True
67
+
68
+
69
+ def test_config_sync_coordinator_updates_generation() -> None:
70
+ state = RuntimeProcessState()
71
+ coordinator = ConfigSyncCoordinator(process_state=state)
72
+
73
+ assert coordinator.mark_generation(42) == 42
74
+ assert state.current_generation == 42
75
+
76
+
77
+ def test_resolve_core_base_url_uses_managed_runtime_env(monkeypatch) -> None:
78
+ monkeypatch.setenv("PIPHI_CORE_BASE_URL", "http://127.0.0.1:31419/")
79
+
80
+ assert resolve_core_base_url() == "http://127.0.0.1:31419"
81
+
82
+
83
+ def test_resolve_runtime_config_snapshot_path_uses_container_id(monkeypatch, tmp_path) -> None:
84
+ monkeypatch.delenv("PIPHI_CONFIG_SNAPSHOT_PATH", raising=False)
85
+ monkeypatch.setenv("PIPHI_CONTAINER_ID", "container-1")
86
+
87
+ assert resolve_runtime_config_snapshot_path(volume_dir=tmp_path) == tmp_path / "container-1.json"
88
+
89
+
90
+ def test_resolve_runtime_config_snapshot_path_prefers_explicit_path(monkeypatch, tmp_path) -> None:
91
+ explicit_path = tmp_path / "custom.json"
92
+ monkeypatch.setenv("PIPHI_CONFIG_SNAPSHOT_PATH", str(explicit_path))
93
+
94
+ assert resolve_runtime_config_snapshot_path(volume_dir=tmp_path) == explicit_path
95
+
96
+
97
+ def test_load_runtime_config_snapshot_reads_core_volume_contract(tmp_path) -> None:
98
+ snapshot_path = tmp_path / "container-1.json"
99
+ snapshot_path.write_text(
100
+ """
101
+ {
102
+ "schema_version": 1,
103
+ "container_id": "container-1",
104
+ "integration_id": "integration-1",
105
+ "reason": "startup",
106
+ "generation": 42,
107
+ "updated_at": "2026-04-27T12:00:00+00:00",
108
+ "configs": [{"id": "device-1", "serial": "abc123"}],
109
+ "deleted_config_ids": ["device-2"],
110
+ "config_hash": "sha256:abc",
111
+ "internal_token": "runtime-token"
112
+ }
113
+ """,
114
+ encoding="utf-8",
115
+ )
116
+
117
+ snapshot = load_runtime_config_snapshot(path=snapshot_path)
118
+
119
+ assert snapshot is not None
120
+ assert snapshot.schema_version == 1
121
+ assert snapshot.container_id == "container-1"
122
+ assert snapshot.generation == 42
123
+ assert snapshot.configs[0].id == "device-1"
124
+ assert snapshot.deleted_config_ids == ["device-2"]
125
+ assert snapshot.config_hash == "sha256:abc"
126
+ assert snapshot.internal_token == "runtime-token"
127
+
128
+
129
+ def test_load_runtime_config_snapshot_returns_none_for_missing_or_invalid_snapshot(tmp_path) -> None:
130
+ assert load_runtime_config_snapshot(path=tmp_path / "missing.json") is None
131
+
132
+ invalid_path = tmp_path / "invalid.json"
133
+ invalid_path.write_text("not-json", encoding="utf-8")
134
+
135
+ assert load_runtime_config_snapshot(path=invalid_path) is None
136
+
137
+
138
+ def test_build_runtime_config_snapshot_from_core_rows_ignores_invalid_rows() -> None:
139
+ snapshot = build_runtime_config_snapshot_from_core_rows(
140
+ [
141
+ {"config_data": {"id": "device-1", "name": "Kitchen"}},
142
+ {"config_data": None},
143
+ {"not_config_data": {"id": "device-2"}},
144
+ ],
145
+ container_id="container-1",
146
+ )
147
+
148
+ assert snapshot.container_id == "container-1"
149
+ assert snapshot.reason == "startup_rehydrate"
150
+ assert len(snapshot.configs) == 1
151
+ assert snapshot.configs[0].id == "device-1"
152
+ assert snapshot.configs[0].container_id == "container-1"
153
+
154
+
155
+ @pytest.mark.asyncio
156
+ async def test_fetch_core_runtime_config_snapshot_uses_runtime_auth_headers() -> None:
157
+ runtime_context = RuntimeContext()
158
+ runtime_context.auth.update(
159
+ container_id="container-1",
160
+ internal_token="secret-token",
161
+ )
162
+ client = _CoreConfigClient(
163
+ [{"config_data": {"id": "device-1", "name": "Kitchen"}}]
164
+ )
165
+
166
+ snapshot = await fetch_core_runtime_config_snapshot(
167
+ runtime_context=runtime_context,
168
+ client=client,
169
+ core_base_url="http://core.local",
170
+ )
171
+
172
+ assert snapshot is not None
173
+ assert snapshot.configs[0].id == "device-1"
174
+ assert client.requests[0]["url"] == (
175
+ "http://core.local/api/v2/integrations/config/fetch/all/by/container/internal"
176
+ )
177
+ assert client.requests[0]["params"] == {"container_id": "container-1"}
178
+ assert client.requests[0]["headers"]["X-Container-Id"] == "container-1"
179
+ assert client.requests[0]["headers"]["X-PiPhi-Integration-Token"] == "secret-token"
180
+
181
+
182
+ @pytest.mark.asyncio
183
+ async def test_rehydrate_runtime_configs_applies_snapshot_then_core(tmp_path) -> None:
184
+ runtime_context = RuntimeContext()
185
+ runtime_context.auth.update(
186
+ container_id="container-1",
187
+ internal_token="secret-token",
188
+ )
189
+ snapshot_path = tmp_path / "container-1.json"
190
+ snapshot_path.write_text(
191
+ """
192
+ {
193
+ "schema_version": 1,
194
+ "container_id": "container-1",
195
+ "reason": "startup_snapshot",
196
+ "generation": 1,
197
+ "configs": [{"id": "snapshot-device", "name": "Snapshot"}]
198
+ }
199
+ """,
200
+ encoding="utf-8",
201
+ )
202
+ applied_sources: list[tuple[str | None, list[str]]] = []
203
+
204
+ async def apply_snapshot(snapshot: RuntimeConfigSnapshot) -> None:
205
+ applied_sources.append((snapshot.reason, [config.id for config in snapshot.configs]))
206
+
207
+ result = await rehydrate_runtime_configs(
208
+ runtime_context=runtime_context,
209
+ client=_CoreConfigClient(
210
+ [{"config_data": {"id": "core-device", "name": "Core"}}]
211
+ ),
212
+ apply_snapshot=apply_snapshot,
213
+ core_base_url="http://core.local",
214
+ snapshot_path=snapshot_path,
215
+ )
216
+
217
+ assert isinstance(result, RuntimeConfigRehydrateResult)
218
+ assert result.snapshot_applied is True
219
+ assert result.core_applied is True
220
+ assert result.snapshot_config_count == 1
221
+ assert result.core_config_count == 1
222
+ assert applied_sources == [
223
+ ("startup_snapshot", ["snapshot-device"]),
224
+ ("startup_rehydrate", ["core-device"]),
225
+ ]
226
+
227
+
228
+ @pytest.mark.asyncio
229
+ async def test_rehydrate_runtime_configs_keeps_snapshot_when_core_is_offline(tmp_path) -> None:
230
+ runtime_context = RuntimeContext()
231
+ runtime_context.auth.update(
232
+ container_id="container-1",
233
+ internal_token="secret-token",
234
+ )
235
+ snapshot_path = tmp_path / "container-1.json"
236
+ snapshot_path.write_text(
237
+ """
238
+ {
239
+ "schema_version": 1,
240
+ "container_id": "container-1",
241
+ "generation": 2,
242
+ "configs": [{"id": "snapshot-device", "name": "Snapshot"}]
243
+ }
244
+ """,
245
+ encoding="utf-8",
246
+ )
247
+ applied_ids: list[str] = []
248
+
249
+ async def apply_snapshot(snapshot: RuntimeConfigSnapshot) -> None:
250
+ applied_ids.extend(config.id for config in snapshot.configs)
251
+
252
+ result = await rehydrate_runtime_configs(
253
+ runtime_context=runtime_context,
254
+ client=_OfflineCoreClient(),
255
+ apply_snapshot=apply_snapshot,
256
+ snapshot_path=snapshot_path,
257
+ )
258
+
259
+ assert result.snapshot_applied is True
260
+ assert result.core_attempted is True
261
+ assert result.core_applied is False
262
+ assert result.core_error is not None
263
+ assert applied_ids == ["snapshot-device"]
264
+
265
+
266
+ @pytest.mark.asyncio
267
+ async def test_rehydrate_runtime_configs_skips_core_without_auth(tmp_path) -> None:
268
+ runtime_context = RuntimeContext()
269
+ snapshot_path = tmp_path / "container-1.json"
270
+ snapshot_path.write_text(
271
+ """
272
+ {
273
+ "schema_version": 1,
274
+ "container_id": "container-1",
275
+ "configs": [{"id": "snapshot-device", "name": "Snapshot"}]
276
+ }
277
+ """,
278
+ encoding="utf-8",
279
+ )
280
+ applied_ids: list[str] = []
281
+
282
+ async def apply_snapshot(snapshot: RuntimeConfigSnapshot) -> None:
283
+ applied_ids.extend(config.id for config in snapshot.configs)
284
+
285
+ result = await rehydrate_runtime_configs(
286
+ runtime_context=runtime_context,
287
+ client=_CoreConfigClient(
288
+ [{"config_data": {"id": "core-device", "name": "Core"}}]
289
+ ),
290
+ apply_snapshot=apply_snapshot,
291
+ snapshot_path=snapshot_path,
292
+ )
293
+
294
+ assert result.snapshot_applied is True
295
+ assert result.core_attempted is False
296
+ assert result.missing_runtime_auth is True
297
+ assert applied_ids == ["snapshot-device"]
298
+
299
+
300
+ @pytest.mark.asyncio
301
+ async def test_config_sync_coordinator_apply_snapshot_orchestrates_callbacks() -> None:
302
+ state = RuntimeProcessState(current_generation=1)
303
+ coordinator = ConfigSyncCoordinator(process_state=state)
304
+ snapshot = RuntimeConfigSnapshot(
305
+ container_id="container-1",
306
+ generation=2,
307
+ configs=[RuntimeConfig(id="device-2"), RuntimeConfig(id="device-3")],
308
+ )
309
+ active_ids = ["device-1", "device-2"]
310
+ applied: list[str] = []
311
+ removed: list[str] = []
312
+
313
+ async def apply_config(config: RuntimeConfig) -> None:
314
+ applied.append(config.id)
315
+
316
+ async def remove_config(config_id: str) -> bool:
317
+ removed.append(config_id)
318
+ return True
319
+
320
+ response = await coordinator.apply_snapshot(
321
+ snapshot=snapshot,
322
+ active_config_ids=active_ids,
323
+ apply_config=apply_config,
324
+ remove_config=remove_config,
325
+ get_active_config_ids=lambda: ["device-2", "device-3"],
326
+ )
327
+
328
+ assert applied == ["device-2", "device-3"]
329
+ assert removed == ["device-1"]
330
+ assert response.status == "synced"
331
+ assert response.generation == 2
332
+ assert response.active_config_ids == ["device-2", "device-3"]
@@ -1,138 +0,0 @@
1
- import pytest
2
-
3
- from piphi_runtime_kit_python import (
4
- ConfigSyncCoordinator,
5
- RuntimeConfig,
6
- RuntimeConfigSnapshot,
7
- RuntimeProcessState,
8
- load_runtime_config_snapshot,
9
- reconcile_config_ids,
10
- resolve_core_base_url,
11
- resolve_runtime_config_snapshot_path,
12
- )
13
-
14
-
15
- def test_reconcile_config_ids_returns_removed_and_unchanged_ids() -> None:
16
- removed_ids, unchanged_ids = reconcile_config_ids(
17
- active_ids=["a", "b", "c"],
18
- incoming_ids=["b", "c", "d"],
19
- )
20
-
21
- assert removed_ids == ["a"]
22
- assert unchanged_ids == ["b", "c"]
23
-
24
-
25
- def test_config_sync_coordinator_detects_stale_snapshot() -> None:
26
- state = RuntimeProcessState(current_generation=10)
27
- coordinator = ConfigSyncCoordinator(process_state=state)
28
- snapshot = RuntimeConfigSnapshot(
29
- container_id="container-1",
30
- generation=9,
31
- configs=[RuntimeConfig(id="device-1")],
32
- )
33
-
34
- assert coordinator.is_stale(snapshot) is True
35
-
36
-
37
- def test_config_sync_coordinator_updates_generation() -> None:
38
- state = RuntimeProcessState()
39
- coordinator = ConfigSyncCoordinator(process_state=state)
40
-
41
- assert coordinator.mark_generation(42) == 42
42
- assert state.current_generation == 42
43
-
44
-
45
- def test_resolve_core_base_url_uses_managed_runtime_env(monkeypatch) -> None:
46
- monkeypatch.setenv("PIPHI_CORE_BASE_URL", "http://127.0.0.1:31419/")
47
-
48
- assert resolve_core_base_url() == "http://127.0.0.1:31419"
49
-
50
-
51
- def test_resolve_runtime_config_snapshot_path_uses_container_id(monkeypatch, tmp_path) -> None:
52
- monkeypatch.delenv("PIPHI_CONFIG_SNAPSHOT_PATH", raising=False)
53
- monkeypatch.setenv("PIPHI_CONTAINER_ID", "container-1")
54
-
55
- assert resolve_runtime_config_snapshot_path(volume_dir=tmp_path) == tmp_path / "container-1.json"
56
-
57
-
58
- def test_resolve_runtime_config_snapshot_path_prefers_explicit_path(monkeypatch, tmp_path) -> None:
59
- explicit_path = tmp_path / "custom.json"
60
- monkeypatch.setenv("PIPHI_CONFIG_SNAPSHOT_PATH", str(explicit_path))
61
-
62
- assert resolve_runtime_config_snapshot_path(volume_dir=tmp_path) == explicit_path
63
-
64
-
65
- def test_load_runtime_config_snapshot_reads_core_volume_contract(tmp_path) -> None:
66
- snapshot_path = tmp_path / "container-1.json"
67
- snapshot_path.write_text(
68
- """
69
- {
70
- "schema_version": 1,
71
- "container_id": "container-1",
72
- "integration_id": "integration-1",
73
- "reason": "startup",
74
- "generation": 42,
75
- "updated_at": "2026-04-27T12:00:00+00:00",
76
- "configs": [{"id": "device-1", "serial": "abc123"}],
77
- "deleted_config_ids": ["device-2"],
78
- "config_hash": "sha256:abc",
79
- "internal_token": "runtime-token"
80
- }
81
- """,
82
- encoding="utf-8",
83
- )
84
-
85
- snapshot = load_runtime_config_snapshot(path=snapshot_path)
86
-
87
- assert snapshot is not None
88
- assert snapshot.schema_version == 1
89
- assert snapshot.container_id == "container-1"
90
- assert snapshot.generation == 42
91
- assert snapshot.configs[0].id == "device-1"
92
- assert snapshot.deleted_config_ids == ["device-2"]
93
- assert snapshot.config_hash == "sha256:abc"
94
- assert snapshot.internal_token == "runtime-token"
95
-
96
-
97
- def test_load_runtime_config_snapshot_returns_none_for_missing_or_invalid_snapshot(tmp_path) -> None:
98
- assert load_runtime_config_snapshot(path=tmp_path / "missing.json") is None
99
-
100
- invalid_path = tmp_path / "invalid.json"
101
- invalid_path.write_text("not-json", encoding="utf-8")
102
-
103
- assert load_runtime_config_snapshot(path=invalid_path) is None
104
-
105
-
106
- @pytest.mark.asyncio
107
- async def test_config_sync_coordinator_apply_snapshot_orchestrates_callbacks() -> None:
108
- state = RuntimeProcessState(current_generation=1)
109
- coordinator = ConfigSyncCoordinator(process_state=state)
110
- snapshot = RuntimeConfigSnapshot(
111
- container_id="container-1",
112
- generation=2,
113
- configs=[RuntimeConfig(id="device-2"), RuntimeConfig(id="device-3")],
114
- )
115
- active_ids = ["device-1", "device-2"]
116
- applied: list[str] = []
117
- removed: list[str] = []
118
-
119
- async def apply_config(config: RuntimeConfig) -> None:
120
- applied.append(config.id)
121
-
122
- async def remove_config(config_id: str) -> bool:
123
- removed.append(config_id)
124
- return True
125
-
126
- response = await coordinator.apply_snapshot(
127
- snapshot=snapshot,
128
- active_config_ids=active_ids,
129
- apply_config=apply_config,
130
- remove_config=remove_config,
131
- get_active_config_ids=lambda: ["device-2", "device-3"],
132
- )
133
-
134
- assert applied == ["device-2", "device-3"]
135
- assert removed == ["device-1"]
136
- assert response.status == "synced"
137
- assert response.generation == 2
138
- assert response.active_config_ids == ["device-2", "device-3"]