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,1143 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..runtime.protocol import DirectorProtocol as _DirectorProtocol
6
+ from . import actions as _actions
7
+ from .common import (
8
+ PRODUCTION_SETTING_FIELDS,
9
+ _adapter_type_from_component_class,
10
+ _apply_settings_update,
11
+ _auto_proxy_class_name,
12
+ )
13
+ from .component import ComponentRef
14
+ from .declarative import _DeclarativeProductionMixin
15
+ from .diff import _diff_productions
16
+ from .inspection import component_runtime_info, inspect_component
17
+ from .planning import (
18
+ ProductionApplyResult,
19
+ ProductionChangePlan,
20
+ ProductionVerifyResult,
21
+ build_change_plan,
22
+ )
23
+ from .reconstruction import production_from_dict
24
+ from .rendering import (
25
+ production_graph,
26
+ production_to_class,
27
+ production_to_dict,
28
+ production_to_python,
29
+ production_to_xml,
30
+ )
31
+ from .runtime import _ProductionRuntime
32
+ from .types import (
33
+ GraphEdge,
34
+ PersistentMessageRegistration,
35
+ ProductionDiff,
36
+ ProductionGraph,
37
+ TargetSettingRef,
38
+ _edge_identity,
39
+ )
40
+
41
+ _MISSING = object()
42
+
43
+
44
+ class Production(_DeclarativeProductionMixin):
45
+ """Purpose:
46
+ Python authoring DSL for IRIS interoperability production topology.
47
+
48
+ Use when:
49
+ A project should declare services, processes, operations, settings, and
50
+ graph connections in Python.
51
+
52
+ Lifecycle:
53
+ A settings.py module exports PRODUCTIONS = [prod]. Migration renders the
54
+ graph to IRIS; IRIS remains the runtime source of truth.
55
+
56
+ Best practices:
57
+ Declare topology with service(), process(), operation(), target(), and
58
+ connect(). Keep the Production object import-safe.
59
+
60
+ Common mistakes:
61
+ Do not execute migration or runtime side effects at module import time.
62
+ Do not use raw CLASSES entries for graph components.
63
+
64
+ Minimal example:
65
+ prod = Production("Demo.Production")
66
+ svc = prod.service("Demo.Service", MyService)
67
+ op = prod.operation("Demo.Operation", MyOperation)
68
+ prod.connect(svc.Output, op)
69
+ PRODUCTIONS = [prod]
70
+
71
+ Related:
72
+ docs/cookbooks/hello-world-production.md,
73
+ docs/production-graph.md
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ name: str | object = _MISSING,
79
+ *,
80
+ testing_enabled: bool | str | object = _MISSING,
81
+ log_general_trace_events: bool | str | object = _MISSING,
82
+ actor_pool_size: int | str | object = _MISSING,
83
+ description: str | object = _MISSING,
84
+ shutdown_timeout: int | str | object = _MISSING,
85
+ update_timeout: int | str | object = _MISSING,
86
+ alert_notification_manager: str | object = _MISSING,
87
+ alert_notification_operation: str | object = _MISSING,
88
+ alert_notification_recipients: str | object = _MISSING,
89
+ alert_action_window: int | str | object = _MISSING,
90
+ namespace: str | None | object = _MISSING,
91
+ director: _DirectorProtocol | None | object = _MISSING,
92
+ ):
93
+ self._initialize(
94
+ name,
95
+ testing_enabled=testing_enabled,
96
+ log_general_trace_events=log_general_trace_events,
97
+ actor_pool_size=actor_pool_size,
98
+ description=description,
99
+ shutdown_timeout=shutdown_timeout,
100
+ update_timeout=update_timeout,
101
+ alert_notification_manager=alert_notification_manager,
102
+ alert_notification_operation=alert_notification_operation,
103
+ alert_notification_recipients=alert_notification_recipients,
104
+ alert_action_window=alert_action_window,
105
+ namespace=namespace,
106
+ director=director,
107
+ hydrate_declarations=True,
108
+ )
109
+
110
+ @classmethod
111
+ def _new_unhydrated(
112
+ cls,
113
+ name: str | object = _MISSING,
114
+ *,
115
+ testing_enabled: bool | str | object = _MISSING,
116
+ log_general_trace_events: bool | str | object = _MISSING,
117
+ actor_pool_size: int | str | object = _MISSING,
118
+ description: str | object = _MISSING,
119
+ shutdown_timeout: int | str | object = _MISSING,
120
+ update_timeout: int | str | object = _MISSING,
121
+ alert_notification_manager: str | object = _MISSING,
122
+ alert_notification_operation: str | object = _MISSING,
123
+ alert_notification_recipients: str | object = _MISSING,
124
+ alert_action_window: int | str | object = _MISSING,
125
+ namespace: str | None | object = _MISSING,
126
+ director: _DirectorProtocol | None | object = _MISSING,
127
+ ) -> Production:
128
+ production = object.__new__(cls)
129
+ Production._initialize(
130
+ production,
131
+ name,
132
+ testing_enabled=testing_enabled,
133
+ log_general_trace_events=log_general_trace_events,
134
+ actor_pool_size=actor_pool_size,
135
+ description=description,
136
+ shutdown_timeout=shutdown_timeout,
137
+ update_timeout=update_timeout,
138
+ alert_notification_manager=alert_notification_manager,
139
+ alert_notification_operation=alert_notification_operation,
140
+ alert_notification_recipients=alert_notification_recipients,
141
+ alert_action_window=alert_action_window,
142
+ namespace=namespace,
143
+ director=director,
144
+ hydrate_declarations=False,
145
+ )
146
+ return production
147
+
148
+ def _initialize(
149
+ self,
150
+ name: str | object = _MISSING,
151
+ *,
152
+ testing_enabled: bool | str | object = _MISSING,
153
+ log_general_trace_events: bool | str | object = _MISSING,
154
+ actor_pool_size: int | str | object = _MISSING,
155
+ description: str | object = _MISSING,
156
+ shutdown_timeout: int | str | object = _MISSING,
157
+ update_timeout: int | str | object = _MISSING,
158
+ alert_notification_manager: str | object = _MISSING,
159
+ alert_notification_operation: str | object = _MISSING,
160
+ alert_notification_recipients: str | object = _MISSING,
161
+ alert_action_window: int | str | object = _MISSING,
162
+ namespace: str | None | object = _MISSING,
163
+ director: _DirectorProtocol | None | object = _MISSING,
164
+ hydrate_declarations: bool,
165
+ ) -> None:
166
+ self.name = self._resolve_production_name(name)
167
+ self.testing_enabled = self._production_default(
168
+ "testing_enabled", testing_enabled, False
169
+ )
170
+ self.log_general_trace_events = self._production_default(
171
+ "log_general_trace_events", log_general_trace_events, False
172
+ )
173
+ self.actor_pool_size = self._production_default(
174
+ "actor_pool_size", actor_pool_size, 2
175
+ )
176
+ self.description = self._production_default("description", description, "")
177
+ self.shutdown_timeout = self._production_default(
178
+ "shutdown_timeout",
179
+ shutdown_timeout,
180
+ PRODUCTION_SETTING_FIELDS["shutdown_timeout"][1],
181
+ )
182
+ self.update_timeout = self._production_default(
183
+ "update_timeout",
184
+ update_timeout,
185
+ PRODUCTION_SETTING_FIELDS["update_timeout"][1],
186
+ )
187
+ self.alert_notification_manager = self._production_default(
188
+ "alert_notification_manager",
189
+ alert_notification_manager,
190
+ PRODUCTION_SETTING_FIELDS["alert_notification_manager"][1],
191
+ )
192
+ self.alert_notification_operation = self._production_default(
193
+ "alert_notification_operation",
194
+ alert_notification_operation,
195
+ PRODUCTION_SETTING_FIELDS["alert_notification_operation"][1],
196
+ )
197
+ self.alert_notification_recipients = self._production_default(
198
+ "alert_notification_recipients",
199
+ alert_notification_recipients,
200
+ PRODUCTION_SETTING_FIELDS["alert_notification_recipients"][1],
201
+ )
202
+ self.alert_action_window = self._production_default(
203
+ "alert_action_window",
204
+ alert_action_window,
205
+ PRODUCTION_SETTING_FIELDS["alert_action_window"][1],
206
+ )
207
+ self.namespace = self._production_default("namespace", namespace, None)
208
+ self._director = self._production_default("director", director, None)
209
+ self._items: list[ComponentRef] = []
210
+ self._items_by_name: dict[str, ComponentRef] = {}
211
+ self._connections: dict[tuple[str, str], list[str]] = {}
212
+ self._edges: list[GraphEdge] = []
213
+ self._graph_warnings: list[str] = []
214
+ self._queue_info: dict[str, dict[str, Any]] = {}
215
+ self._messages: list[PersistentMessageRegistration] = []
216
+ if hydrate_declarations:
217
+ self._hydrate_declared_items()
218
+
219
+ def _resolve_production_name(self, name: str | object) -> str:
220
+ if name is not _MISSING:
221
+ return name # type: ignore[return-value]
222
+ class_name = getattr(type(self), "name", _MISSING)
223
+ if class_name is not _MISSING:
224
+ return class_name
225
+ return f"{type(self).__module__}.{type(self).__name__}"
226
+
227
+ def _production_default(
228
+ self,
229
+ field_name: str,
230
+ explicit: Any,
231
+ default: Any,
232
+ ) -> Any:
233
+ if explicit is not _MISSING:
234
+ return explicit
235
+ class_value = getattr(type(self), field_name, _MISSING)
236
+ if class_value is not _MISSING:
237
+ return class_value
238
+ return default
239
+
240
+ def testing(self, enabled: bool | str = True) -> Production:
241
+ self.testing_enabled = enabled
242
+ return self
243
+
244
+ def tracing(self, enabled: bool | str = True) -> Production:
245
+ self.log_general_trace_events = enabled
246
+ return self
247
+
248
+ def actor_pool(self, size: int | str) -> Production:
249
+ self.actor_pool_size = size
250
+ return self
251
+
252
+ def describe(self, text: str) -> Production:
253
+ self.description = text
254
+ return self
255
+
256
+ def timeouts(
257
+ self,
258
+ *,
259
+ shutdown: int | str | None = None,
260
+ update: int | str | None = None,
261
+ ) -> Production:
262
+ if shutdown is not None:
263
+ self.shutdown_timeout = shutdown
264
+ if update is not None:
265
+ self.update_timeout = update
266
+ return self
267
+
268
+ def alerting(
269
+ self,
270
+ *,
271
+ manager: str | None = None,
272
+ operation: str | None = None,
273
+ recipients: str | None = None,
274
+ action_window: int | str | None = None,
275
+ ) -> Production:
276
+ if manager is not None:
277
+ self.alert_notification_manager = manager
278
+ if operation is not None:
279
+ self.alert_notification_operation = operation
280
+ if recipients is not None:
281
+ self.alert_notification_recipients = recipients
282
+ if action_window is not None:
283
+ self.alert_action_window = action_window
284
+ return self
285
+
286
+ def in_namespace(self, namespace: str | None) -> Production:
287
+ self.namespace = namespace
288
+ return self
289
+
290
+ def with_director(self, director: _DirectorProtocol | None) -> Production:
291
+ self._director = director
292
+ return self
293
+
294
+ @classmethod
295
+ def from_iris(
296
+ cls,
297
+ name: str,
298
+ *,
299
+ namespace: str | None = None,
300
+ director: _DirectorProtocol | None = None,
301
+ ) -> Production:
302
+ seed = cls._new_unhydrated(
303
+ name,
304
+ namespace=namespace,
305
+ director=director,
306
+ )
307
+ runtime_director = _ProductionRuntime(seed).director
308
+ exported = runtime_director.export_production(name)
309
+ connections = None
310
+ try:
311
+ connections = runtime_director.export_production_connections(name)
312
+ except AttributeError:
313
+ connections = None
314
+ except Exception as exc:
315
+ connections = {"warnings": [f"Could not export runtime connections: {exc}"]}
316
+ if connections is not None and not isinstance(connections, (dict, list)):
317
+ connections = None
318
+
319
+ return cls.from_dict(
320
+ exported,
321
+ connections=connections,
322
+ namespace=namespace,
323
+ director=director,
324
+ )
325
+
326
+ @classmethod
327
+ def from_dict(
328
+ cls,
329
+ data: dict[str, Any],
330
+ *,
331
+ connections: Any = None,
332
+ queue_info: Any = None,
333
+ namespace: str | None = None,
334
+ director: Any = None,
335
+ ) -> Production:
336
+ return production_from_dict(
337
+ cls,
338
+ data,
339
+ connections=connections,
340
+ queue_info=queue_info,
341
+ namespace=namespace,
342
+ director=director,
343
+ )
344
+
345
+ @property
346
+ def items(self) -> tuple[ComponentRef, ...]:
347
+ return tuple(self._items)
348
+
349
+ @property
350
+ def edges(self) -> tuple[GraphEdge, ...]:
351
+ return tuple(self._edges)
352
+
353
+ def item(self, name: str) -> ComponentRef:
354
+ try:
355
+ return self._items_by_name[name]
356
+ except KeyError as exc:
357
+ raise ValueError(f"Production item does not exist: {name}") from exc
358
+
359
+ def component_ref(
360
+ self,
361
+ component: ComponentRef | TargetSettingRef | str,
362
+ ) -> ComponentRef:
363
+ component_name = self._runtime_component_name(component)
364
+ return self.item(component_name)
365
+
366
+ def get_component(
367
+ self,
368
+ component: ComponentRef | TargetSettingRef | str,
369
+ ) -> ComponentRef:
370
+ return self.component_ref(component)
371
+
372
+ def component(
373
+ self,
374
+ name_or_cls: str | type,
375
+ cls: type | None = None,
376
+ *,
377
+ class_name: str | None = None,
378
+ adapter_class: type | str | None = None,
379
+ adapter_class_name: str | None = None,
380
+ kind: str = "component",
381
+ enabled: bool | str = True,
382
+ pool_size: int | str = 1,
383
+ category: str = "",
384
+ foreground: bool | str = False,
385
+ comment: str = "",
386
+ log_trace_events: bool | str = False,
387
+ schedule: str = "",
388
+ settings: dict[str, Any] | None = None,
389
+ adapter_settings: dict[str, Any] | None = None,
390
+ ) -> ComponentRef:
391
+ item_name: str
392
+ component_class: type | None
393
+
394
+ if isinstance(name_or_cls, type) and cls is None:
395
+ component_class = name_or_cls
396
+ item_name = component_class.__name__
397
+ else:
398
+ item_name = str(name_or_cls)
399
+ component_class = cls
400
+
401
+ if item_name in self._items_by_name:
402
+ raise ValueError(f"Production item already exists: {item_name}")
403
+
404
+ adapter_class_ref: type | None = None
405
+ resolved_adapter_class_name = adapter_class_name or ""
406
+ if adapter_class is not None:
407
+ if isinstance(adapter_class, type):
408
+ adapter_class_ref = adapter_class
409
+ resolved_adapter_class_name = (
410
+ resolved_adapter_class_name
411
+ or _auto_proxy_class_name(adapter_class)
412
+ )
413
+ else:
414
+ resolved_adapter_class_name = (
415
+ resolved_adapter_class_name
416
+ or str(adapter_class)
417
+ )
418
+
419
+ ref = ComponentRef(
420
+ production=self,
421
+ name=item_name,
422
+ component_class=component_class,
423
+ class_name=class_name,
424
+ adapter_class=adapter_class_ref,
425
+ adapter_class_name=resolved_adapter_class_name,
426
+ kind=kind,
427
+ category=category,
428
+ pool_size=pool_size,
429
+ enabled=enabled,
430
+ foreground=foreground,
431
+ comment=comment,
432
+ log_trace_events=log_trace_events,
433
+ schedule=schedule,
434
+ host_settings=dict(settings or {}),
435
+ adapter_settings=dict(adapter_settings or {}),
436
+ )
437
+ self._add_item(ref)
438
+ return ref
439
+
440
+ def add_component(self, *args: Any, **kwargs: Any) -> ComponentRef:
441
+ return self.component(*args, **kwargs)
442
+
443
+ def update_component(
444
+ self,
445
+ item: ComponentRef | str,
446
+ **kwargs: Any,
447
+ ) -> ComponentRef:
448
+ ref = self._component_ref(item)
449
+ scalar_fields = {
450
+ "kind",
451
+ "category",
452
+ "pool_size",
453
+ "enabled",
454
+ "foreground",
455
+ "comment",
456
+ "log_trace_events",
457
+ "schedule",
458
+ }
459
+ for field_name in scalar_fields:
460
+ if field_name in kwargs:
461
+ setattr(ref, field_name, kwargs.pop(field_name))
462
+
463
+ if "component_class" in kwargs:
464
+ ref.component_class = kwargs.pop("component_class")
465
+ if "class_name" not in kwargs and ref.component_class is not None:
466
+ ref.class_name = _auto_proxy_class_name(ref.component_class)
467
+ if "class_name" in kwargs:
468
+ ref.class_name = kwargs.pop("class_name")
469
+ if ref.class_name is None and ref.component_class is None:
470
+ raise ValueError("component_class or class_name is required")
471
+ if "adapter_class" in kwargs:
472
+ adapter_class = kwargs.pop("adapter_class")
473
+ if adapter_class is None:
474
+ ref.adapter_class = None
475
+ elif isinstance(adapter_class, type):
476
+ ref.adapter_class = adapter_class
477
+ if "adapter_class_name" not in kwargs:
478
+ ref.adapter_class_name = _auto_proxy_class_name(adapter_class)
479
+ else:
480
+ ref.adapter_class = None
481
+ if "adapter_class_name" not in kwargs:
482
+ ref.adapter_class_name = str(adapter_class)
483
+ if "adapter_class_name" in kwargs:
484
+ ref.adapter_class_name = str(kwargs.pop("adapter_class_name") or "")
485
+ if not ref.adapter_class_name:
486
+ ref.adapter_class_name = _adapter_type_from_component_class(
487
+ ref.component_class
488
+ )
489
+
490
+ if "settings" in kwargs:
491
+ _apply_settings_update(ref.host_settings, kwargs.pop("settings"))
492
+ if "host_settings" in kwargs:
493
+ _apply_settings_update(ref.host_settings, kwargs.pop("host_settings"))
494
+ if "adapter_settings" in kwargs:
495
+ _apply_settings_update(ref.adapter_settings, kwargs.pop("adapter_settings"))
496
+ if "other_settings" in kwargs:
497
+ ref.other_settings = [dict(value) for value in kwargs.pop("other_settings")]
498
+ if "target_settings" in kwargs:
499
+ ref.target_setting_names.update(
500
+ str(value) for value in kwargs.pop("target_settings")
501
+ )
502
+
503
+ if kwargs:
504
+ names = ", ".join(sorted(kwargs))
505
+ raise ValueError(f"Unsupported component update fields: {names}")
506
+ return ref
507
+
508
+ def delete_component(self, item: ComponentRef | str) -> None:
509
+ ref = self._component_ref(item)
510
+ self._items = [existing for existing in self._items if existing is not ref]
511
+ self._items_by_name.pop(ref.name, None)
512
+
513
+ for edge in list(self._edges):
514
+ if edge.target != ref.name:
515
+ continue
516
+ source_item = edge.source_item
517
+ source_target_setting = edge.source_target_setting
518
+ source_ref = self._items_by_name.get(source_item)
519
+ if source_ref is not None and source_target_setting:
520
+ if source_ref.host_settings.get(source_target_setting) == ref.name:
521
+ source_ref.host_settings.pop(source_target_setting, None)
522
+
523
+ self._connections = {
524
+ key: targets
525
+ for key, targets in (
526
+ (key, [target for target in targets if target != ref.name])
527
+ for key, targets in self._connections.items()
528
+ if key[0] != ref.name
529
+ )
530
+ if targets
531
+ }
532
+ self._edges = [
533
+ edge
534
+ for edge in self._edges
535
+ if edge.source_item != ref.name and edge.target != ref.name
536
+ ]
537
+
538
+ def disconnect(self, source: TargetSettingRef | str) -> None:
539
+ if isinstance(source, TargetSettingRef):
540
+ if source.production is not self:
541
+ raise ValueError(
542
+ "source target setting belongs to a different Production"
543
+ )
544
+ item_name = source.item_name
545
+ target_setting_name = source.name
546
+ else:
547
+ item_name, separator, target_setting_name = str(source).rpartition(".")
548
+ if not separator:
549
+ raise ValueError(
550
+ "source must be a TargetSettingRef or an "
551
+ "Item.TargetSetting string"
552
+ )
553
+
554
+ key = (item_name, target_setting_name)
555
+ if key not in self._connections:
556
+ raise ValueError(
557
+ "Production target setting is not connected: "
558
+ f"{item_name}.{target_setting_name}"
559
+ )
560
+ self._connections.pop(key, None)
561
+ ref = self.item(item_name)
562
+ ref.host_settings.pop(target_setting_name, None)
563
+ self._edges = [
564
+ edge
565
+ for edge in self._edges
566
+ if not (
567
+ edge.source_item == item_name
568
+ and edge.source_target_setting == target_setting_name
569
+ )
570
+ ]
571
+
572
+ def service(self, name_or_cls: str | type, cls: type | None = None, **kwargs):
573
+ """Add a BusinessService item to the production graph.
574
+
575
+ See docs/cookbooks/hello-world-production.md and
576
+ docs/cookbooks/hl7v2-native-input.md.
577
+ """
578
+ return self.component(name_or_cls, cls, kind="service", **kwargs)
579
+
580
+ def process(self, name_or_cls: str | type, cls: type | None = None, **kwargs):
581
+ """Add a BusinessProcess item to the production graph.
582
+
583
+ See docs/cookbooks/add-business-process.md.
584
+ """
585
+ return self.component(name_or_cls, cls, kind="process", **kwargs)
586
+
587
+ def operation(self, name_or_cls: str | type, cls: type | None = None, **kwargs):
588
+ """Add a BusinessOperation item to the production graph.
589
+
590
+ See docs/cookbooks/add-business-operation.md.
591
+ """
592
+ return self.component(name_or_cls, cls, kind="operation", **kwargs)
593
+
594
+ def message(
595
+ self,
596
+ iris_classname: str,
597
+ msg_cls: type,
598
+ *,
599
+ sync_schema: bool = True,
600
+ ) -> Production:
601
+ from ..messages.persistent import (
602
+ get_python_classname,
603
+ is_persistent_message_class,
604
+ )
605
+
606
+ iris_classname = str(iris_classname or "").strip()
607
+ if not iris_classname:
608
+ raise ValueError("PersistentMessage IRIS classname is required")
609
+ if not is_persistent_message_class(msg_cls):
610
+ raise TypeError("msg_cls must be a concrete PersistentMessage subclass")
611
+
612
+ python_classname = get_python_classname(msg_cls)
613
+ for registration in self._messages:
614
+ registered_python_classname = get_python_classname(
615
+ registration.message_class
616
+ )
617
+ if registration.iris_classname == iris_classname:
618
+ if registered_python_classname != python_classname:
619
+ raise ValueError(
620
+ "PersistentMessage IRIS classname already registered: "
621
+ f"{iris_classname}"
622
+ )
623
+ if registration.sync_schema != bool(sync_schema):
624
+ raise ValueError(
625
+ "PersistentMessage registration has conflicting sync_schema "
626
+ f"for {iris_classname}"
627
+ )
628
+ return self
629
+ if registered_python_classname == python_classname:
630
+ raise ValueError(
631
+ "PersistentMessage class already registered with IRIS classname "
632
+ f"{registration.iris_classname}"
633
+ )
634
+
635
+ self._messages.append(
636
+ PersistentMessageRegistration(
637
+ iris_classname=iris_classname,
638
+ message_class=msg_cls,
639
+ sync_schema=bool(sync_schema),
640
+ )
641
+ )
642
+ return self
643
+
644
+ def connect(
645
+ self,
646
+ source: TargetSettingRef,
647
+ target_component: ComponentRef | str | None = None,
648
+ *,
649
+ mode: str = "replace",
650
+ ) -> None:
651
+ if not isinstance(source, TargetSettingRef):
652
+ raise TypeError(
653
+ "source must be a TargetSettingRef returned from a ComponentRef"
654
+ )
655
+ if source.production is not self:
656
+ raise ValueError("source target setting belongs to a different Production")
657
+
658
+ mode = _connection_mode(mode)
659
+ if mode == "remove":
660
+ self._remove_connection(source, target_component)
661
+ return
662
+ if target_component is None:
663
+ raise ValueError("target_component is required unless mode='remove'")
664
+
665
+ target_name = self._component_name(target_component)
666
+ if mode == "replace":
667
+ source.component.set_host_setting(source.name, target_name)
668
+ else:
669
+ existing = source.component.host_settings.get(source.name, "")
670
+ existing_targets = [t.strip() for t in existing.split(",") if t.strip()]
671
+ if target_name not in existing_targets:
672
+ source.component.host_settings[source.name] = ",".join(
673
+ existing_targets + [target_name]
674
+ )
675
+ source.component.target_setting_names.add(source.name)
676
+ self._register_connection(
677
+ source.item_name,
678
+ source.name,
679
+ target_name,
680
+ replace_target_setting=mode == "replace",
681
+ )
682
+
683
+ def _remove_connection(
684
+ self,
685
+ source: TargetSettingRef,
686
+ target_component: ComponentRef | str | None,
687
+ ) -> None:
688
+ if target_component is None:
689
+ self.disconnect(source)
690
+ return
691
+
692
+ target_name = self._component_name(target_component)
693
+ key = (source.item_name, source.name)
694
+ targets = self._connections.get(key, [])
695
+ edge_exists = any(
696
+ edge.source_item == source.item_name
697
+ and edge.source_target_setting == source.name
698
+ and edge.target == target_name
699
+ for edge in self._edges
700
+ )
701
+ if target_name not in targets and not edge_exists:
702
+ raise ValueError(
703
+ "Production target setting is not connected: "
704
+ f"{source.path} -> {target_name}"
705
+ )
706
+
707
+ remaining_targets = [target for target in targets if target != target_name]
708
+ if remaining_targets:
709
+ self._connections[key] = remaining_targets
710
+ source.component.host_settings[source.name] = ",".join(remaining_targets)
711
+ else:
712
+ self._connections.pop(key, None)
713
+ source.component.host_settings.pop(source.name, None)
714
+
715
+ self._edges = [
716
+ edge
717
+ for edge in self._edges
718
+ if not (
719
+ edge.source_item == source.item_name
720
+ and edge.source_target_setting == source.name
721
+ and edge.target == target_name
722
+ )
723
+ ]
724
+
725
+ def resolve_target_setting_ref(self, target_setting_ref: TargetSettingRef) -> str:
726
+ if target_setting_ref.production is not self:
727
+ raise ValueError(
728
+ "target setting ref belongs to a different Production"
729
+ )
730
+ return self._resolve_connection_target(
731
+ target_setting_ref.item_name,
732
+ target_setting_ref.name,
733
+ target_setting_ref.path,
734
+ )
735
+
736
+ def _resolve_connection_target(
737
+ self,
738
+ item_name: str,
739
+ target_setting_name: str,
740
+ path: str,
741
+ ) -> str:
742
+ key = (item_name, target_setting_name)
743
+ targets = self._connections.get(key)
744
+ if not targets:
745
+ raise ValueError(f"Production target setting is not connected: {path}")
746
+ if len(targets) > 1:
747
+ target_list = ", ".join(repr(target) for target in targets)
748
+ raise ValueError(
749
+ f"Production target setting is ambiguous: {path} resolves to "
750
+ f"multiple targets ({target_list})"
751
+ )
752
+ return targets[0]
753
+
754
+ def resolve_target(
755
+ self,
756
+ target_or_ref: str | TargetSettingRef | ComponentRef,
757
+ ) -> str:
758
+ if isinstance(target_or_ref, TargetSettingRef):
759
+ return self.resolve_target_setting_ref(target_or_ref)
760
+ if isinstance(target_or_ref, ComponentRef):
761
+ return self._component_name(target_or_ref)
762
+
763
+ target_name = str(target_or_ref)
764
+ if target_name in self._items_by_name:
765
+ return target_name
766
+
767
+ if "." in target_name:
768
+ item_name, _, target_setting_name = target_name.rpartition(".")
769
+ if item_name not in self._items_by_name:
770
+ raise ValueError(f"Production item does not exist: {item_name}")
771
+ key = (item_name, target_setting_name)
772
+ if key not in self._connections:
773
+ raise ValueError(
774
+ f"Production target setting is not connected: {target_name}"
775
+ )
776
+ return self._resolve_connection_target(
777
+ item_name,
778
+ target_setting_name,
779
+ target_name,
780
+ )
781
+
782
+ raise ValueError(f"Production item does not exist: {target_name}")
783
+
784
+ def to_dict(self) -> dict[str, Any]:
785
+ return production_to_dict(self)
786
+
787
+ def to_xml(self) -> str:
788
+ return production_to_xml(self)
789
+
790
+ def to_python(self) -> str:
791
+ return production_to_python(self)
792
+
793
+ def to_class(self) -> str:
794
+ return production_to_class(self)
795
+
796
+ def to_mermaid(self, *, direction: str = "LR") -> str:
797
+ return self.graph().to_mermaid(direction=direction)
798
+
799
+ def graph(self) -> ProductionGraph:
800
+ return production_graph(self)
801
+
802
+ def diff(
803
+ self,
804
+ other: Production | dict[str, Any] | None = None,
805
+ ) -> ProductionDiff:
806
+ """Compare deployable IRIS shape against current/imported state.
807
+
808
+ With no *other* argument, IRIS is queried through ``from_iris()`` and the
809
+ returned diff describes what would change to make that runtime
810
+ reconstruction match this Python object.
811
+ """
812
+ return self.diff_deployable(other)
813
+
814
+ def diff_deployable(
815
+ self,
816
+ other: Production | dict[str, Any] | None = None,
817
+ ) -> ProductionDiff:
818
+ """Compare settings, items, and deployable routes.
819
+
820
+ Edge import metadata such as ``origin`` is ignored so an authored Python
821
+ route and an equivalent IRIS setting-inferred route do not produce a
822
+ false deployment change.
823
+ """
824
+ current = self._diff_current(other)
825
+ return _diff_productions(desired=self, current=current)
826
+
827
+ def graph_diff(
828
+ self,
829
+ other: Production | dict[str, Any] | None = None,
830
+ ) -> ProductionDiff:
831
+ """Compare graph topology including edge origin and route metadata."""
832
+ current = self._diff_current(other)
833
+ return _diff_productions(
834
+ desired=self,
835
+ current=current,
836
+ include_graph_metadata=True,
837
+ )
838
+
839
+ def plan(
840
+ self,
841
+ other: Production | dict[str, Any] | None = None,
842
+ ) -> ProductionChangePlan:
843
+ """Build a conservative granular change plan against current/imported state."""
844
+ current = self._diff_current(other)
845
+ return build_change_plan(self, current)
846
+
847
+ def component_registrations(self) -> tuple[ComponentRef, ...]:
848
+ return tuple(
849
+ item for item in self._items if item.component_class is not None
850
+ )
851
+
852
+ def adapter_registrations(self) -> tuple[ComponentRef, ...]:
853
+ return tuple(
854
+ item for item in self._items if item.adapter_class is not None
855
+ )
856
+
857
+ def message_registrations(self) -> tuple[PersistentMessageRegistration, ...]:
858
+ return tuple(self._messages)
859
+
860
+ def validate(self, *, strict: bool = False):
861
+ from .validation import validate_production
862
+
863
+ return validate_production(self, strict=strict, warn=not strict)
864
+
865
+ def start(self, detach: bool = True) -> None:
866
+ _actions.start(self, detach=detach)
867
+
868
+ def stop(self) -> None:
869
+ _actions.stop(self)
870
+
871
+ def restart(self) -> None:
872
+ _actions.restart(self)
873
+
874
+ def kill(self) -> None:
875
+ _actions.kill(self)
876
+
877
+ def status(self) -> dict:
878
+ return _actions.status(self)
879
+
880
+ def queue(self, *, refresh: bool = True) -> dict[str, dict[str, Any]]:
881
+ return _actions.queue(self, refresh=refresh)
882
+
883
+ def queue_info(self, *, refresh: bool = True) -> dict[str, dict[str, Any]]:
884
+ return self.queue(refresh=refresh)
885
+
886
+ def update(self) -> None:
887
+ _actions.update(self)
888
+
889
+ def inspect_component(
890
+ self,
891
+ component: ComponentRef | TargetSettingRef | str,
892
+ *,
893
+ refresh: bool = True,
894
+ ) -> dict[str, Any]:
895
+ return inspect_component(self, component, refresh=refresh)
896
+
897
+ def start_component(self, component: ComponentRef | TargetSettingRef | str) -> None:
898
+ _actions.start_component(self, component)
899
+
900
+ def stop_component(self, component: ComponentRef | TargetSettingRef | str) -> None:
901
+ _actions.stop_component(self, component)
902
+
903
+ def restart_component(
904
+ self,
905
+ component: ComponentRef | TargetSettingRef | str,
906
+ ) -> None:
907
+ _actions.restart_component(self, component)
908
+
909
+ def export(self) -> dict:
910
+ return _actions.export(self)
911
+
912
+ def set_default(self) -> None:
913
+ _actions.set_default(self)
914
+
915
+ def sync(self, *, root_path: str | None = None, update: bool = True) -> None:
916
+ _actions.sync(self, root_path=root_path, update_runtime=update)
917
+
918
+ def apply(
919
+ self,
920
+ plan: ProductionChangePlan | None = None,
921
+ *,
922
+ allow_destructive: bool = False,
923
+ backup_dir: str = ".iop/backups",
924
+ root_path: str | None = None,
925
+ update: bool = True,
926
+ ) -> ProductionApplyResult:
927
+ return _actions.apply(
928
+ self,
929
+ plan=plan,
930
+ allow_destructive=allow_destructive,
931
+ backup_dir=backup_dir,
932
+ root_path=root_path,
933
+ update_runtime=update,
934
+ )
935
+
936
+ def verify(self, plan: ProductionChangePlan) -> ProductionVerifyResult:
937
+ return _actions.verify(self, plan)
938
+
939
+ @staticmethod
940
+ def rollback_backup(
941
+ backup_path: str,
942
+ *,
943
+ director: _DirectorProtocol | None = None,
944
+ namespace: str | None = None,
945
+ allow_destructive: bool = False,
946
+ update: bool = True,
947
+ ) -> ProductionVerifyResult:
948
+ return _actions.rollback_backup(
949
+ backup_path,
950
+ director=director,
951
+ namespace=namespace,
952
+ allow_destructive=allow_destructive,
953
+ update_runtime=update,
954
+ )
955
+
956
+ def log(self, top: int | None = None) -> None:
957
+ _actions.log(self, top)
958
+
959
+ def test_component(
960
+ self,
961
+ target_or_ref: str | TargetSettingRef | ComponentRef,
962
+ message: Any = None,
963
+ classname: str | None = None,
964
+ body: str | dict | None = None,
965
+ ) -> Any:
966
+ return _actions.test_component(
967
+ self,
968
+ target_or_ref,
969
+ message=message,
970
+ classname=classname,
971
+ body=body,
972
+ )
973
+
974
+ def test(
975
+ self,
976
+ target_or_ref: str | TargetSettingRef | ComponentRef,
977
+ message: Any = None,
978
+ classname: str | None = None,
979
+ body: str | dict | None = None,
980
+ ) -> Any:
981
+ return self.test_component(
982
+ target_or_ref,
983
+ message=message,
984
+ classname=classname,
985
+ body=body,
986
+ )
987
+
988
+ def _raise_if_existing_production_not_running(self, director: Any) -> None:
989
+ _actions.raise_if_existing_production_not_running(self, director)
990
+
991
+ def _require_current_production(self, director: Any, action: str) -> None:
992
+ _actions.require_current_production(self, director, action)
993
+
994
+ def _require_current_runtime(self, director: Any, action: str) -> None:
995
+ _actions.require_current_runtime(self, director, action)
996
+
997
+ def _read_status(self, director: Any, action: str) -> tuple[str, str]:
998
+ return _actions.read_status(self, director, action)
999
+
1000
+ def _switch_running_production_message(self, current_production: str) -> str:
1001
+ return _actions.switch_running_production_message(self, current_production)
1002
+
1003
+ def _component_runtime_info(self, component_name: str) -> dict[str, Any]:
1004
+ return component_runtime_info(self, component_name)
1005
+
1006
+ def _component_name(self, target_component: ComponentRef | str) -> str:
1007
+ if isinstance(target_component, ComponentRef):
1008
+ if target_component.production is not self:
1009
+ raise ValueError("target component belongs to a different Production")
1010
+ return target_component.name
1011
+ target_name = str(target_component)
1012
+ if target_name not in self._items_by_name:
1013
+ raise ValueError(f"Production item does not exist: {target_name}")
1014
+ return target_name
1015
+
1016
+ def _runtime_component_name(
1017
+ self,
1018
+ component: ComponentRef | TargetSettingRef | str,
1019
+ ) -> str:
1020
+ if isinstance(component, TargetSettingRef):
1021
+ component_name = self.resolve_target_setting_ref(component)
1022
+ elif isinstance(component, ComponentRef):
1023
+ component_name = self._component_name(component)
1024
+ else:
1025
+ component_name = self.resolve_target(str(component))
1026
+ if component_name not in self._items_by_name:
1027
+ raise ValueError(f"Production item does not exist: {component_name}")
1028
+ return component_name
1029
+
1030
+ def _component_ref(self, item: ComponentRef | str) -> ComponentRef:
1031
+ if isinstance(item, ComponentRef):
1032
+ if item.production is not self:
1033
+ raise ValueError("component belongs to a different Production")
1034
+ return item
1035
+ return self.item(str(item))
1036
+
1037
+ def _add_item(self, ref: ComponentRef) -> None:
1038
+ if not ref.name:
1039
+ raise ValueError("Production item name is required")
1040
+ if ref.name in self._items_by_name:
1041
+ raise ValueError(f"Production item already exists: {ref.name}")
1042
+ if ref.component_class is not None:
1043
+ for existing in self._items:
1044
+ if (
1045
+ existing.component_class is not None
1046
+ and existing.component_class is not ref.component_class
1047
+ and existing.class_name == ref.class_name
1048
+ ):
1049
+ raise ValueError(
1050
+ f"Python classes {existing.component_class.__qualname__!r} and "
1051
+ f"{ref.component_class.__qualname__!r} produce the same IRIS "
1052
+ f"proxy class name {ref.class_name!r}. Rename one of the "
1053
+ "Python classes or supply an explicit class_name= to resolve "
1054
+ "the collision."
1055
+ )
1056
+ self._items.append(ref)
1057
+ self._items_by_name[ref.name] = ref
1058
+
1059
+ def _diff_current(
1060
+ self,
1061
+ other: Production | dict[str, Any] | None,
1062
+ ) -> Production:
1063
+ if other is None:
1064
+ return self.from_iris(
1065
+ self.name,
1066
+ namespace=self.namespace,
1067
+ director=self._director,
1068
+ )
1069
+ if isinstance(other, Production):
1070
+ return other
1071
+ if isinstance(other, dict):
1072
+ return self.from_dict(
1073
+ other,
1074
+ namespace=self.namespace,
1075
+ director=self._director,
1076
+ )
1077
+ raise TypeError("other must be a Production, production dictionary, or None")
1078
+
1079
+ def _register_connection(
1080
+ self,
1081
+ source_item: str,
1082
+ source_target_setting: str,
1083
+ target_name: str,
1084
+ *,
1085
+ origin: str = "authored",
1086
+ interaction: str = "request",
1087
+ metadata: dict[str, Any] | None = None,
1088
+ replace_target_setting: bool = False,
1089
+ validate_target: bool = True,
1090
+ ) -> None:
1091
+ if source_item not in self._items_by_name:
1092
+ raise ValueError(f"Production item does not exist: {source_item}")
1093
+ if validate_target and target_name not in self._items_by_name:
1094
+ raise ValueError(f"Production item does not exist: {target_name}")
1095
+ if source_target_setting:
1096
+ key = (source_item, source_target_setting)
1097
+ if replace_target_setting:
1098
+ self._connections[key] = [target_name]
1099
+ else:
1100
+ self._connections.setdefault(key, [])
1101
+ if target_name not in self._connections[key]:
1102
+ self._connections[key].append(target_name)
1103
+
1104
+ edge = GraphEdge(
1105
+ source_item=source_item,
1106
+ source_target_setting=source_target_setting,
1107
+ target=target_name,
1108
+ origin=origin,
1109
+ interaction=interaction,
1110
+ metadata=dict(metadata or {}),
1111
+ )
1112
+
1113
+ if replace_target_setting and source_target_setting:
1114
+ self._edges = [
1115
+ existing
1116
+ for existing in self._edges
1117
+ if not (
1118
+ existing.source_item == source_item
1119
+ and existing.source_target_setting == source_target_setting
1120
+ )
1121
+ ]
1122
+
1123
+ edge_key = _edge_identity(edge)
1124
+ self._edges = [
1125
+ existing
1126
+ for existing in self._edges
1127
+ if _edge_identity(existing) != edge_key
1128
+ ]
1129
+ self._edges.append(edge)
1130
+
1131
+
1132
+ def _connection_mode(mode: str) -> str:
1133
+ normalized = str(mode or "").strip().lower()
1134
+ if normalized in {"replace", "set"}:
1135
+ return "replace"
1136
+ if normalized in {"add", "append"}:
1137
+ return "add"
1138
+ if normalized in {"remove", "delete", "disconnect"}:
1139
+ return "remove"
1140
+ raise ValueError(
1141
+ "Unsupported connection mode: "
1142
+ f"{mode!r}. Expected 'replace', 'add', or 'remove'."
1143
+ )