iris-pex-embedded-python 3.7.2b2__py3-none-any.whl → 4.0.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 (119) hide show
  1. iop/__init__.py +276 -18
  2. iop/__main__.py +3 -3
  3. iop/cli/__init__.py +1 -0
  4. iop/cli/formatting.py +45 -0
  5. iop/{_cli.py → cli/main.py} +238 -216
  6. iop/cli/parser.py +165 -0
  7. iop/cli/types.py +73 -0
  8. iop/cls/IOP/Common.cls +11 -2
  9. iop/cls/IOP/PrivateSession/Duplex.cls +1 -1
  10. iop/cls/IOP/Service/Remote/Rest/v1.cls +152 -5
  11. iop/cls/IOP/Utils.cls +814 -7
  12. iop/cls/IOP/Wrapper.cls +1 -1
  13. iop/components/__init__.py +16 -0
  14. iop/{_async_request.py → components/async_request.py} +7 -7
  15. iop/components/business_host.py +363 -0
  16. iop/components/business_operation.py +84 -0
  17. iop/{_business_process.py → components/business_process.py} +145 -125
  18. iop/components/business_service.py +106 -0
  19. iop/{_common.py → components/common.py} +160 -164
  20. iop/{_debugpy.py → components/debugpy.py} +11 -11
  21. iop/{_generator_request.py → components/generator_request.py} +4 -4
  22. iop/{_inbound_adapter.py → components/inbound_adapter.py} +10 -18
  23. iop/{_log_manager.py → components/log_manager.py} +1 -1
  24. iop/{_outbound_adapter.py → components/outbound_adapter.py} +1 -1
  25. iop/components/polling_business_service.py +48 -0
  26. iop/{_private_session_duplex.py → components/private_session_duplex.py} +7 -9
  27. iop/{_private_session_process.py → components/private_session_process.py} +2 -2
  28. iop/{_settings.py → components/settings.py} +23 -3
  29. iop/messages/__init__.py +7 -0
  30. iop/messages/base.py +61 -0
  31. iop/{_decorators.py → messages/decorators.py} +8 -2
  32. iop/messages/dispatch.py +351 -0
  33. iop/{_persistent_message.py → messages/persistent.py} +24 -19
  34. iop/{_serialization.py → messages/serialization.py} +34 -21
  35. iop/{_message_validator.py → messages/validation.py} +15 -13
  36. iop/migration/__init__.py +1 -0
  37. iop/migration/io.py +61 -0
  38. iop/migration/manifest.py +534 -0
  39. iop/migration/plans.py +80 -0
  40. iop/migration/utils.py +1126 -0
  41. iop/production/__init__.py +55 -0
  42. iop/production/actions.py +525 -0
  43. iop/production/common.py +103 -0
  44. iop/production/component.py +234 -0
  45. iop/production/declarations.py +190 -0
  46. iop/production/declarative.py +214 -0
  47. iop/production/diff.py +327 -0
  48. iop/production/import_.py +296 -0
  49. iop/production/inspection.py +92 -0
  50. iop/production/model.py +1143 -0
  51. iop/production/planning.py +678 -0
  52. iop/production/reconstruction.py +341 -0
  53. iop/production/rendering.py +610 -0
  54. iop/production/runtime.py +91 -0
  55. iop/production/source_inference.py +729 -0
  56. iop/production/types.py +546 -0
  57. iop/production/validation.py +452 -0
  58. iop/runtime/__init__.py +1 -0
  59. iop/runtime/director.py +415 -0
  60. iop/runtime/environment.py +59 -0
  61. iop/{_iris.py → runtime/iris.py} +2 -2
  62. iop/runtime/local.py +145 -0
  63. iop/{_director_protocol.py → runtime/protocol.py} +25 -8
  64. iop/runtime/remote/__init__.py +12 -0
  65. iop/runtime/remote/client.py +90 -0
  66. iop/runtime/remote/director.py +283 -0
  67. iop/runtime/remote/migration.py +57 -0
  68. iop/runtime/remote/settings.py +69 -0
  69. iop/runtime/remote/setup.py +72 -0
  70. {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/METADATA +11 -2
  71. iris_pex_embedded_python-4.0.0.dist-info/RECORD +99 -0
  72. iris_pex_embedded_python-4.0.0.dist-info/entry_points.txt +2 -0
  73. iris_pex_embedded_python-4.0.0.dist-info/top_level.txt +1 -0
  74. grongier/__init__.py +0 -0
  75. grongier/cls/Grongier/PEX/BusinessOperation.cls +0 -8
  76. grongier/cls/Grongier/PEX/BusinessProcess.cls +0 -13
  77. grongier/cls/Grongier/PEX/BusinessService.cls +0 -8
  78. grongier/cls/Grongier/PEX/Common.cls +0 -10
  79. grongier/cls/Grongier/PEX/Director.cls +0 -10
  80. grongier/cls/Grongier/PEX/Duplex/Operation.cls +0 -4
  81. grongier/cls/Grongier/PEX/Duplex/Process.cls +0 -13
  82. grongier/cls/Grongier/PEX/Duplex/Service.cls +0 -4
  83. grongier/cls/Grongier/PEX/InboundAdapter.cls +0 -8
  84. grongier/cls/Grongier/PEX/Message.cls +0 -13
  85. grongier/cls/Grongier/PEX/OutboundAdapter.cls +0 -8
  86. grongier/cls/Grongier/PEX/PickleMessage.cls +0 -13
  87. grongier/cls/Grongier/PEX/PrivateSession/Duplex.cls +0 -8
  88. grongier/cls/Grongier/PEX/PrivateSession/Message/Ack.cls +0 -14
  89. grongier/cls/Grongier/PEX/PrivateSession/Message/Poll.cls +0 -14
  90. grongier/cls/Grongier/PEX/PrivateSession/Message/Start.cls +0 -14
  91. grongier/cls/Grongier/PEX/PrivateSession/Message/Stop.cls +0 -14
  92. grongier/cls/Grongier/PEX/Test.cls +0 -10
  93. grongier/cls/Grongier/PEX/Utils.cls +0 -10
  94. grongier/cls/Grongier/Service/WSGI.cls +0 -4
  95. grongier/pex/__init__.py +0 -75
  96. grongier/pex/__main__.py +0 -4
  97. grongier/pex/_business_host.py +0 -1
  98. grongier/pex/_cli.py +0 -4
  99. grongier/pex/_common.py +0 -1
  100. grongier/pex/_director.py +0 -1
  101. grongier/pex/_utils.py +0 -1
  102. grongier/pex/wsgi/handlers.py +0 -104
  103. iop/_business_host.py +0 -322
  104. iop/_business_operation.py +0 -77
  105. iop/_business_service.py +0 -67
  106. iop/_director.py +0 -333
  107. iop/_dispatch.py +0 -194
  108. iop/_local.py +0 -106
  109. iop/_message.py +0 -45
  110. iop/_polling_business_service.py +0 -6
  111. iop/_remote.py +0 -473
  112. iop/_utils.py +0 -912
  113. iop/cls/IOP/Service/WSGI.cls +0 -310
  114. iop/wsgi/handlers.py +0 -112
  115. iris_pex_embedded_python-3.7.2b2.dist-info/RECORD +0 -96
  116. iris_pex_embedded_python-3.7.2b2.dist-info/entry_points.txt +0 -2
  117. iris_pex_embedded_python-3.7.2b2.dist-info/top_level.txt +0 -2
  118. {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/WHEEL +0 -0
  119. {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,678 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import socket
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .diff import _connection_signature, _item_signature, _production_signature
14
+ from .types import ProductionDiffEntry
15
+
16
+ SAFE = "safe"
17
+ DESTRUCTIVE = "destructive"
18
+ UNSUPPORTED = "unsupported"
19
+
20
+ _ITEM_FIELD_OPS = {
21
+ "category",
22
+ "pool_size",
23
+ "enabled",
24
+ "foreground",
25
+ "comment",
26
+ "log_trace_events",
27
+ "schedule",
28
+ }
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ProductionPlanOperation:
33
+ """One granular operation in a production change plan."""
34
+
35
+ id: str
36
+ op_type: str
37
+ action: str
38
+ kind: str
39
+ path: str
40
+ before: Any = None
41
+ after: Any = None
42
+ risk: str = SAFE
43
+ reason: str = ""
44
+ detail: str = ""
45
+
46
+ @property
47
+ def supported(self) -> bool:
48
+ return self.risk != UNSUPPORTED
49
+
50
+ @property
51
+ def allowed_by_default(self) -> bool:
52
+ return self.risk == SAFE
53
+
54
+ @property
55
+ def requires_allow_destructive(self) -> bool:
56
+ return self.risk == DESTRUCTIVE
57
+
58
+ def can_apply(self, *, allow_destructive: bool = False) -> bool:
59
+ if not self.supported:
60
+ return False
61
+ if self.allowed_by_default:
62
+ return True
63
+ return bool(allow_destructive and self.requires_allow_destructive)
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ data: dict[str, Any] = {
67
+ "id": self.id,
68
+ "op_type": self.op_type,
69
+ "action": self.action,
70
+ "kind": self.kind,
71
+ "path": self.path,
72
+ "risk": self.risk,
73
+ "allowed_by_default": self.allowed_by_default,
74
+ }
75
+ if self.before is not None:
76
+ data["before"] = self.before
77
+ if self.after is not None:
78
+ data["after"] = self.after
79
+ if self.reason:
80
+ data["reason"] = self.reason
81
+ if self.detail:
82
+ data["detail"] = self.detail
83
+ data.update(_operation_target_data(self))
84
+ return data
85
+
86
+ @classmethod
87
+ def from_dict(cls, data: dict[str, Any]) -> ProductionPlanOperation:
88
+ return cls(
89
+ id=str(data["id"]),
90
+ op_type=str(data["op_type"]),
91
+ action=str(data.get("action", "")),
92
+ kind=str(data.get("kind", "")),
93
+ path=str(data.get("path", "")),
94
+ before=data.get("before"),
95
+ after=data.get("after"),
96
+ risk=str(data.get("risk", SAFE)),
97
+ reason=str(data.get("reason", "")),
98
+ detail=str(data.get("detail", "")),
99
+ )
100
+
101
+ def to_text(self) -> str:
102
+ suffix = f" [{self.risk}]"
103
+ reason = f": {self.reason}" if self.reason else ""
104
+ return f"{self.id} {self.op_type} {self.path}{suffix}{reason}"
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class ProductionChangePlan:
109
+ """Versioned desired-vs-current production change plan."""
110
+
111
+ id: str
112
+ production_name: str
113
+ namespace: str = ""
114
+ created_at: str = ""
115
+ source_fingerprint: str = ""
116
+ desired_fingerprint: str = ""
117
+ operations: tuple[ProductionPlanOperation, ...] = ()
118
+ warnings: tuple[str, ...] = ()
119
+ metadata: dict[str, Any] = field(default_factory=dict)
120
+
121
+ @property
122
+ def has_changes(self) -> bool:
123
+ return bool(self.operations)
124
+
125
+ @property
126
+ def safe_operations(self) -> tuple[ProductionPlanOperation, ...]:
127
+ return tuple(op for op in self.operations if op.allowed_by_default)
128
+
129
+ @property
130
+ def blocked_operations(self) -> tuple[ProductionPlanOperation, ...]:
131
+ return tuple(op for op in self.operations if not op.allowed_by_default)
132
+
133
+ def operations_for_apply(
134
+ self,
135
+ *,
136
+ allow_destructive: bool = False,
137
+ ) -> tuple[ProductionPlanOperation, ...]:
138
+ return tuple(
139
+ op for op in self.operations if op.can_apply(allow_destructive=allow_destructive)
140
+ )
141
+
142
+ def to_dict(self) -> dict[str, Any]:
143
+ data: dict[str, Any] = {
144
+ "id": self.id,
145
+ "production": self.production_name,
146
+ "namespace": self.namespace,
147
+ "created_at": self.created_at,
148
+ "source_fingerprint": self.source_fingerprint,
149
+ "desired_fingerprint": self.desired_fingerprint,
150
+ "has_changes": self.has_changes,
151
+ "operations": [op.to_dict() for op in self.operations],
152
+ }
153
+ if self.warnings:
154
+ data["warnings"] = list(self.warnings)
155
+ if self.metadata:
156
+ data["metadata"] = dict(self.metadata)
157
+ return data
158
+
159
+ @classmethod
160
+ def from_dict(cls, data: dict[str, Any]) -> ProductionChangePlan:
161
+ return cls(
162
+ id=str(data["id"]),
163
+ production_name=str(data.get("production") or data.get("production_name")),
164
+ namespace=str(data.get("namespace") or ""),
165
+ created_at=str(data.get("created_at") or ""),
166
+ source_fingerprint=str(data.get("source_fingerprint") or ""),
167
+ desired_fingerprint=str(data.get("desired_fingerprint") or ""),
168
+ operations=tuple(
169
+ ProductionPlanOperation.from_dict(op)
170
+ for op in data.get("operations", ())
171
+ ),
172
+ warnings=tuple(str(item) for item in data.get("warnings", ())),
173
+ metadata=dict(data.get("metadata") or {}),
174
+ )
175
+
176
+ def to_text(self) -> str:
177
+ lines = [
178
+ f"Production change plan: {self.production_name}",
179
+ f" id: {self.id}",
180
+ ]
181
+ if self.namespace:
182
+ lines.append(f" namespace: {self.namespace}")
183
+ if not self.operations:
184
+ lines.append(" no changes")
185
+ else:
186
+ lines.append(" operations:")
187
+ lines.extend(f" {op.to_text()}" for op in self.operations)
188
+ if self.warnings:
189
+ lines.append(" warnings:")
190
+ lines.extend(f" {warning}" for warning in self.warnings)
191
+ return "\n".join(lines)
192
+
193
+ def save(self, path: str | os.PathLike[str]) -> None:
194
+ destination = Path(path)
195
+ destination.parent.mkdir(parents=True, exist_ok=True)
196
+ destination.write_text(
197
+ json.dumps(self.to_dict(), indent=2, sort_keys=True),
198
+ encoding="utf-8",
199
+ )
200
+
201
+ @classmethod
202
+ def load(cls, path: str | os.PathLike[str]) -> ProductionChangePlan:
203
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
204
+ return cls.from_dict(data)
205
+
206
+ def __str__(self) -> str:
207
+ return self.to_text()
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class ProductionApplyResult:
212
+ """Result returned after applying a production change plan."""
213
+
214
+ plan_id: str
215
+ production_name: str
216
+ backup_path: str = ""
217
+ operations: tuple[dict[str, Any], ...] = ()
218
+ updated_runtime: bool = False
219
+
220
+ @property
221
+ def applied(self) -> int:
222
+ return sum(1 for op in self.operations if op.get("status") == "applied")
223
+
224
+ @property
225
+ def skipped(self) -> int:
226
+ return sum(1 for op in self.operations if op.get("status") == "skipped")
227
+
228
+ @property
229
+ def failed(self) -> int:
230
+ return sum(1 for op in self.operations if op.get("status") == "failed")
231
+
232
+ @property
233
+ def success(self) -> bool:
234
+ return self.failed == 0
235
+
236
+ def to_dict(self) -> dict[str, Any]:
237
+ data: dict[str, Any] = {
238
+ "plan_id": self.plan_id,
239
+ "production": self.production_name,
240
+ "success": self.success,
241
+ "applied": self.applied,
242
+ "skipped": self.skipped,
243
+ "failed": self.failed,
244
+ "updated_runtime": self.updated_runtime,
245
+ "operations": list(self.operations),
246
+ }
247
+ if self.backup_path:
248
+ data["backup_path"] = self.backup_path
249
+ return data
250
+
251
+ @classmethod
252
+ def from_dict(cls, data: dict[str, Any]) -> ProductionApplyResult:
253
+ return cls(
254
+ plan_id=str(data.get("plan_id") or data.get("plan") or ""),
255
+ production_name=str(data.get("production") or ""),
256
+ backup_path=str(data.get("backup_path") or ""),
257
+ operations=tuple(dict(op) for op in data.get("operations", ())),
258
+ updated_runtime=bool(data.get("updated_runtime", False)),
259
+ )
260
+
261
+ def to_text(self) -> str:
262
+ lines = [
263
+ f"Production apply result: {self.production_name}",
264
+ f" plan: {self.plan_id}",
265
+ f" applied: {self.applied}, skipped: {self.skipped}, failed: {self.failed}",
266
+ ]
267
+ if self.backup_path:
268
+ lines.append(f" backup: {self.backup_path}")
269
+ if self.operations:
270
+ lines.append(" operations:")
271
+ for op in self.operations:
272
+ lines.append(
273
+ f" {op.get('id', '')} {op.get('op_type', '')}: "
274
+ f"{op.get('status', 'unknown')}"
275
+ )
276
+ return "\n".join(lines)
277
+
278
+ def __str__(self) -> str:
279
+ return self.to_text()
280
+
281
+
282
+ @dataclass(frozen=True)
283
+ class ProductionVerifyResult:
284
+ """Result returned after verifying a production change plan."""
285
+
286
+ plan_id: str
287
+ production_name: str
288
+ converged_operations: tuple[str, ...] = ()
289
+ failed_operations: tuple[dict[str, Any], ...] = ()
290
+ residual_operations: tuple[dict[str, Any], ...] = ()
291
+ warnings: tuple[str, ...] = ()
292
+
293
+ @property
294
+ def success(self) -> bool:
295
+ return not self.failed_operations
296
+
297
+ def to_dict(self) -> dict[str, Any]:
298
+ data: dict[str, Any] = {
299
+ "plan_id": self.plan_id,
300
+ "production": self.production_name,
301
+ "success": self.success,
302
+ "converged_operations": list(self.converged_operations),
303
+ "failed_operations": list(self.failed_operations),
304
+ "residual_operations": list(self.residual_operations),
305
+ }
306
+ if self.warnings:
307
+ data["warnings"] = list(self.warnings)
308
+ return data
309
+
310
+ def to_text(self) -> str:
311
+ lines = [
312
+ f"Production verify result: {self.production_name}",
313
+ f" plan: {self.plan_id}",
314
+ f" success: {str(self.success).lower()}",
315
+ f" converged: {len(self.converged_operations)}",
316
+ f" failed: {len(self.failed_operations)}",
317
+ f" residual: {len(self.residual_operations)}",
318
+ ]
319
+ if self.failed_operations:
320
+ lines.append(" failed operations:")
321
+ lines.extend(
322
+ f" {op.get('id', '')} {op.get('path', '')}" for op in self.failed_operations
323
+ )
324
+ if self.residual_operations:
325
+ lines.append(" residual operations:")
326
+ lines.extend(
327
+ f" {op.get('id', '')} {op.get('path', '')}"
328
+ for op in self.residual_operations
329
+ )
330
+ if self.warnings:
331
+ lines.append(" warnings:")
332
+ lines.extend(f" {warning}" for warning in self.warnings)
333
+ return "\n".join(lines)
334
+
335
+ def __str__(self) -> str:
336
+ return self.to_text()
337
+
338
+
339
+ def build_change_plan(desired, current) -> ProductionChangePlan:
340
+ diff = desired.diff_deployable(current)
341
+ operations = tuple(_operation_from_diff(index, change) for index, change in enumerate(diff.changes, start=1))
342
+ created_at = datetime.now(UTC).isoformat()
343
+ source_fingerprint = production_fingerprint(current)
344
+ desired_fingerprint = production_fingerprint(desired)
345
+ plan_id = _plan_id(
346
+ desired.name,
347
+ source_fingerprint,
348
+ desired_fingerprint,
349
+ operations,
350
+ )
351
+ namespace = getattr(desired, "namespace", None) or getattr(current, "namespace", None) or ""
352
+ return ProductionChangePlan(
353
+ id=plan_id,
354
+ production_name=desired.name,
355
+ namespace=str(namespace or ""),
356
+ created_at=created_at,
357
+ source_fingerprint=source_fingerprint,
358
+ desired_fingerprint=desired_fingerprint,
359
+ operations=operations,
360
+ warnings=diff.warnings,
361
+ metadata={
362
+ "generator": "iop.production.plan.v1",
363
+ "default_policy": "safe-only",
364
+ },
365
+ )
366
+
367
+
368
+ def production_fingerprint(production) -> str:
369
+ payload = production.to_dict()
370
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
371
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
372
+
373
+
374
+ def create_backup(
375
+ *,
376
+ backup_dir: str | os.PathLike[str],
377
+ plan: ProductionChangePlan,
378
+ current,
379
+ current_export: dict[str, Any],
380
+ connections: Any,
381
+ queues: Any,
382
+ ) -> Path:
383
+ timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
384
+ backup_path = Path(backup_dir) / f"{timestamp}-{plan.id[:12]}"
385
+ backup_path.mkdir(parents=True, exist_ok=False)
386
+ _write_json(backup_path / "production.json", current_export)
387
+ (backup_path / "production.xml").write_text(current.to_xml(), encoding="utf-8")
388
+ _write_json(backup_path / "connections.json", connections or {})
389
+ _write_json(backup_path / "queues.json", queues or {})
390
+ plan.save(backup_path / "plan.json")
391
+ _write_json(
392
+ backup_path / "metadata.json",
393
+ {
394
+ "backup_id": backup_path.name,
395
+ "created_at": datetime.now(UTC).isoformat(),
396
+ "user": getpass.getuser(),
397
+ "host": socket.gethostname(),
398
+ "namespace": plan.namespace,
399
+ "production": plan.production_name,
400
+ "plan_id": plan.id,
401
+ "source_fingerprint": plan.source_fingerprint,
402
+ "desired_fingerprint": plan.desired_fingerprint,
403
+ },
404
+ )
405
+ return backup_path
406
+
407
+
408
+ def verify_change_plan(plan: ProductionChangePlan, current) -> ProductionVerifyResult:
409
+ converged: list[str] = []
410
+ failed: list[dict[str, Any]] = []
411
+ residual: list[dict[str, Any]] = []
412
+
413
+ for operation in plan.operations:
414
+ if not operation.allowed_by_default:
415
+ residual.append(operation.to_dict())
416
+ continue
417
+ if _operation_converged(operation, current):
418
+ converged.append(operation.id)
419
+ else:
420
+ failed.append(operation.to_dict())
421
+
422
+ return ProductionVerifyResult(
423
+ plan_id=plan.id,
424
+ production_name=plan.production_name,
425
+ converged_operations=tuple(converged),
426
+ failed_operations=tuple(failed),
427
+ residual_operations=tuple(residual),
428
+ warnings=tuple(current.graph().warnings),
429
+ )
430
+
431
+
432
+ def operation_result(
433
+ operation: ProductionPlanOperation,
434
+ *,
435
+ status: str,
436
+ message: str = "",
437
+ ) -> dict[str, Any]:
438
+ data = operation.to_dict()
439
+ data["status"] = status
440
+ if message:
441
+ data["message"] = message
442
+ return data
443
+
444
+
445
+ def skipped_operation_results(
446
+ plan: ProductionChangePlan,
447
+ *,
448
+ allow_destructive: bool,
449
+ ) -> list[dict[str, Any]]:
450
+ results = []
451
+ for operation in plan.operations:
452
+ if operation.can_apply(allow_destructive=allow_destructive):
453
+ continue
454
+ if operation.risk == DESTRUCTIVE:
455
+ message = "requires allow_destructive=True"
456
+ elif operation.risk == UNSUPPORTED:
457
+ message = operation.reason or "unsupported operation"
458
+ else:
459
+ message = "operation is not applicable"
460
+ results.append(operation_result(operation, status="skipped", message=message))
461
+ return results
462
+
463
+
464
+ def _operation_from_diff(index: int, change: ProductionDiffEntry) -> ProductionPlanOperation:
465
+ op_type, risk, reason = _classify_change(change)
466
+ return ProductionPlanOperation(
467
+ id=f"{index:04d}-{op_type}",
468
+ op_type=op_type,
469
+ action=change.action,
470
+ kind=change.kind,
471
+ path=change.path,
472
+ before=change.before,
473
+ after=change.after,
474
+ risk=risk,
475
+ reason=reason,
476
+ detail=change.detail,
477
+ )
478
+
479
+
480
+ def _classify_change(change: ProductionDiffEntry) -> tuple[str, str, str]:
481
+ if change.kind == "item":
482
+ if change.action == "add":
483
+ return "add_item", SAFE, ""
484
+ if change.action == "remove":
485
+ return "delete_item", DESTRUCTIVE, "item deletion is destructive"
486
+ field_name = _item_field_name(change.path)
487
+ if field_name == "class_name":
488
+ return "replace_item_class", DESTRUCTIVE, "class replacement can change runtime behavior"
489
+ if field_name in _ITEM_FIELD_OPS:
490
+ return "set_item_field", SAFE, ""
491
+ return "set_item_field", UNSUPPORTED, "unsupported item field"
492
+
493
+ if change.kind == "setting":
494
+ if change.action == "remove":
495
+ return "remove_setting", DESTRUCTIVE, "setting removal is destructive"
496
+ return "set_setting", SAFE, ""
497
+
498
+ if change.kind == "connection":
499
+ source_item, source_target_setting = _connection_source(change.path)
500
+ if not source_item or not source_target_setting:
501
+ return (
502
+ "set_route_setting",
503
+ UNSUPPORTED,
504
+ "runtime-only route has no source setting to mutate",
505
+ )
506
+ if change.action == "remove":
507
+ return "remove_route", DESTRUCTIVE, "route removal is destructive"
508
+ return "set_route_setting", SAFE, ""
509
+
510
+ if change.kind == "production":
511
+ return "set_production_field", SAFE, ""
512
+
513
+ return "unsupported", UNSUPPORTED, "unsupported diff entry"
514
+
515
+
516
+ def _operation_converged(operation: ProductionPlanOperation, current) -> bool:
517
+ if operation.op_type == "add_item":
518
+ return _item_matches(current, operation.path, operation.after)
519
+ if operation.op_type == "delete_item":
520
+ item_name = _item_name(operation.path)
521
+ return item_name not in current._items_by_name
522
+ if operation.op_type in {"set_item_field", "replace_item_class"}:
523
+ item_name = _item_name(operation.path)
524
+ field_name = _item_field_name(operation.path)
525
+ ref = current._items_by_name.get(item_name)
526
+ if ref is None:
527
+ return False
528
+ return _item_signature(ref).get(field_name) == operation.after
529
+ if operation.op_type == "set_production_field":
530
+ field_name = operation.path.removeprefix("production.")
531
+ return _production_signature(current).get(field_name) == operation.after
532
+ if operation.op_type == "set_setting":
533
+ parsed = _setting_path(operation.path)
534
+ if parsed is None:
535
+ return False
536
+ item_name, target, setting_name = parsed
537
+ ref = current._items_by_name.get(item_name)
538
+ if ref is None:
539
+ return False
540
+ if target == "Host":
541
+ return _string_value(ref.host_settings.get(setting_name)) == _string_value(operation.after)
542
+ if target == "Adapter":
543
+ return _string_value(ref.adapter_settings.get(setting_name)) == _string_value(operation.after)
544
+ if target == "Other":
545
+ return ref.other_settings == operation.after
546
+ return False
547
+ if operation.op_type == "remove_setting":
548
+ parsed = _setting_path(operation.path)
549
+ if parsed is None:
550
+ return False
551
+ item_name, target, setting_name = parsed
552
+ ref = current._items_by_name.get(item_name)
553
+ if ref is None:
554
+ return True
555
+ if target == "Host":
556
+ return setting_name not in ref.host_settings
557
+ if target == "Adapter":
558
+ return setting_name not in ref.adapter_settings
559
+ return False
560
+ if operation.op_type == "set_route_setting":
561
+ source_item, source_target_setting = _connection_source(operation.path)
562
+ if not source_item or not source_target_setting:
563
+ return False
564
+ connections = _connection_signature(current)
565
+ return (
566
+ connections.get((source_item, source_target_setting), [])
567
+ == operation.after
568
+ )
569
+ if operation.op_type == "remove_route":
570
+ source_item, source_target_setting = _connection_source(operation.path)
571
+ connections = _connection_signature(current)
572
+ return (source_item, source_target_setting) not in connections
573
+ return False
574
+
575
+
576
+ def _item_matches(current, path: str, expected: Any) -> bool:
577
+ item_name = _item_name(path)
578
+ ref = current._items_by_name.get(item_name)
579
+ return ref is not None and _item_signature(ref) == expected
580
+
581
+
582
+ def _item_name(path: str) -> str:
583
+ if not path.startswith("items."):
584
+ return ""
585
+ remainder = path.removeprefix("items.")
586
+ if ".settings." in remainder:
587
+ return remainder.split(".settings.", 1)[0]
588
+ for field_name in ("class_name", *_ITEM_FIELD_OPS):
589
+ suffix = f".{field_name}"
590
+ if remainder.endswith(suffix):
591
+ return remainder[: -len(suffix)]
592
+ return remainder
593
+
594
+
595
+ def _item_field_name(path: str) -> str:
596
+ for field_name in ("class_name", *_ITEM_FIELD_OPS):
597
+ if path.endswith(f".{field_name}"):
598
+ return field_name
599
+ return ""
600
+
601
+
602
+ def _setting_path(path: str) -> tuple[str, str, str] | None:
603
+ if not path.startswith("items.") or ".settings." not in path:
604
+ return None
605
+ item_name, rest = path.removeprefix("items.").split(".settings.", 1)
606
+ if "." not in rest:
607
+ return item_name, rest, ""
608
+ target, setting_name = rest.split(".", 1)
609
+ return item_name, target, setting_name
610
+
611
+
612
+ def _connection_source(path: str) -> tuple[str, str]:
613
+ source = path.removeprefix("connections.")
614
+ source_item, separator, source_target_setting = source.rpartition(".")
615
+ if not separator:
616
+ return source, ""
617
+ return source_item, source_target_setting
618
+
619
+
620
+ def _string_value(value: Any) -> str:
621
+ if value is None:
622
+ return ""
623
+ return str(value)
624
+
625
+
626
+ def _operation_target_data(operation: ProductionPlanOperation) -> dict[str, Any]:
627
+ if operation.op_type in {"add_item", "delete_item"}:
628
+ return {"item": _item_name(operation.path)}
629
+ if operation.op_type in {"set_item_field", "replace_item_class"}:
630
+ return {
631
+ "item": _item_name(operation.path),
632
+ "field": _item_field_name(operation.path),
633
+ }
634
+ if operation.op_type in {"set_setting", "remove_setting"}:
635
+ parsed = _setting_path(operation.path)
636
+ if parsed is None:
637
+ return {}
638
+ item_name, target, setting_name = parsed
639
+ return {
640
+ "item": item_name,
641
+ "target": target,
642
+ "setting": setting_name,
643
+ }
644
+ if operation.op_type in {"set_route_setting", "remove_route"}:
645
+ source_item, source_target_setting = _connection_source(operation.path)
646
+ return {
647
+ "source_item": source_item,
648
+ "source_target_setting": source_target_setting,
649
+ "item": source_item,
650
+ "target": "Host",
651
+ "setting": source_target_setting,
652
+ }
653
+ if operation.op_type == "set_production_field":
654
+ return {"field": operation.path.removeprefix("production.")}
655
+ return {}
656
+
657
+
658
+ def _plan_id(
659
+ production_name: str,
660
+ source_fingerprint: str,
661
+ desired_fingerprint: str,
662
+ operations: tuple[ProductionPlanOperation, ...],
663
+ ) -> str:
664
+ payload = {
665
+ "production": production_name,
666
+ "source": source_fingerprint,
667
+ "desired": desired_fingerprint,
668
+ "operations": [op.to_dict() for op in operations],
669
+ }
670
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
671
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
672
+
673
+
674
+ def _write_json(path: Path, data: Any) -> None:
675
+ path.write_text(
676
+ json.dumps(data, indent=2, sort_keys=True, default=str),
677
+ encoding="utf-8",
678
+ )