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.
- iop/__init__.py +276 -18
- iop/__main__.py +3 -3
- iop/cli/__init__.py +1 -0
- iop/cli/formatting.py +45 -0
- iop/{_cli.py → cli/main.py} +238 -216
- iop/cli/parser.py +165 -0
- iop/cli/types.py +73 -0
- iop/cls/IOP/Common.cls +11 -2
- iop/cls/IOP/PrivateSession/Duplex.cls +1 -1
- iop/cls/IOP/Service/Remote/Rest/v1.cls +152 -5
- iop/cls/IOP/Utils.cls +814 -7
- iop/cls/IOP/Wrapper.cls +1 -1
- iop/components/__init__.py +16 -0
- iop/{_async_request.py → components/async_request.py} +7 -7
- iop/components/business_host.py +363 -0
- iop/components/business_operation.py +84 -0
- iop/{_business_process.py → components/business_process.py} +145 -125
- iop/components/business_service.py +106 -0
- iop/{_common.py → components/common.py} +160 -164
- iop/{_debugpy.py → components/debugpy.py} +11 -11
- iop/{_generator_request.py → components/generator_request.py} +4 -4
- iop/{_inbound_adapter.py → components/inbound_adapter.py} +10 -18
- iop/{_log_manager.py → components/log_manager.py} +1 -1
- iop/{_outbound_adapter.py → components/outbound_adapter.py} +1 -1
- iop/components/polling_business_service.py +48 -0
- iop/{_private_session_duplex.py → components/private_session_duplex.py} +7 -9
- iop/{_private_session_process.py → components/private_session_process.py} +2 -2
- iop/{_settings.py → components/settings.py} +23 -3
- iop/messages/__init__.py +7 -0
- iop/messages/base.py +61 -0
- iop/{_decorators.py → messages/decorators.py} +8 -2
- iop/messages/dispatch.py +351 -0
- iop/{_persistent_message.py → messages/persistent.py} +24 -19
- iop/{_serialization.py → messages/serialization.py} +34 -21
- iop/{_message_validator.py → messages/validation.py} +15 -13
- iop/migration/__init__.py +1 -0
- iop/migration/io.py +61 -0
- iop/migration/manifest.py +534 -0
- iop/migration/plans.py +80 -0
- iop/migration/utils.py +1126 -0
- iop/production/__init__.py +55 -0
- iop/production/actions.py +525 -0
- iop/production/common.py +103 -0
- iop/production/component.py +234 -0
- iop/production/declarations.py +190 -0
- iop/production/declarative.py +214 -0
- iop/production/diff.py +327 -0
- iop/production/import_.py +296 -0
- iop/production/inspection.py +92 -0
- iop/production/model.py +1143 -0
- iop/production/planning.py +678 -0
- iop/production/reconstruction.py +341 -0
- iop/production/rendering.py +610 -0
- iop/production/runtime.py +91 -0
- iop/production/source_inference.py +729 -0
- iop/production/types.py +546 -0
- iop/production/validation.py +452 -0
- iop/runtime/__init__.py +1 -0
- iop/runtime/director.py +415 -0
- iop/runtime/environment.py +59 -0
- iop/{_iris.py → runtime/iris.py} +2 -2
- iop/runtime/local.py +145 -0
- iop/{_director_protocol.py → runtime/protocol.py} +25 -8
- iop/runtime/remote/__init__.py +12 -0
- iop/runtime/remote/client.py +90 -0
- iop/runtime/remote/director.py +283 -0
- iop/runtime/remote/migration.py +57 -0
- iop/runtime/remote/settings.py +69 -0
- iop/runtime/remote/setup.py +72 -0
- {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/METADATA +11 -2
- iris_pex_embedded_python-4.0.0.dist-info/RECORD +99 -0
- iris_pex_embedded_python-4.0.0.dist-info/entry_points.txt +2 -0
- iris_pex_embedded_python-4.0.0.dist-info/top_level.txt +1 -0
- grongier/__init__.py +0 -0
- grongier/cls/Grongier/PEX/BusinessOperation.cls +0 -8
- grongier/cls/Grongier/PEX/BusinessProcess.cls +0 -13
- grongier/cls/Grongier/PEX/BusinessService.cls +0 -8
- grongier/cls/Grongier/PEX/Common.cls +0 -10
- grongier/cls/Grongier/PEX/Director.cls +0 -10
- grongier/cls/Grongier/PEX/Duplex/Operation.cls +0 -4
- grongier/cls/Grongier/PEX/Duplex/Process.cls +0 -13
- grongier/cls/Grongier/PEX/Duplex/Service.cls +0 -4
- grongier/cls/Grongier/PEX/InboundAdapter.cls +0 -8
- grongier/cls/Grongier/PEX/Message.cls +0 -13
- grongier/cls/Grongier/PEX/OutboundAdapter.cls +0 -8
- grongier/cls/Grongier/PEX/PickleMessage.cls +0 -13
- grongier/cls/Grongier/PEX/PrivateSession/Duplex.cls +0 -8
- grongier/cls/Grongier/PEX/PrivateSession/Message/Ack.cls +0 -14
- grongier/cls/Grongier/PEX/PrivateSession/Message/Poll.cls +0 -14
- grongier/cls/Grongier/PEX/PrivateSession/Message/Start.cls +0 -14
- grongier/cls/Grongier/PEX/PrivateSession/Message/Stop.cls +0 -14
- grongier/cls/Grongier/PEX/Test.cls +0 -10
- grongier/cls/Grongier/PEX/Utils.cls +0 -10
- grongier/cls/Grongier/Service/WSGI.cls +0 -4
- grongier/pex/__init__.py +0 -75
- grongier/pex/__main__.py +0 -4
- grongier/pex/_business_host.py +0 -1
- grongier/pex/_cli.py +0 -4
- grongier/pex/_common.py +0 -1
- grongier/pex/_director.py +0 -1
- grongier/pex/_utils.py +0 -1
- grongier/pex/wsgi/handlers.py +0 -104
- iop/_business_host.py +0 -322
- iop/_business_operation.py +0 -77
- iop/_business_service.py +0 -67
- iop/_director.py +0 -333
- iop/_dispatch.py +0 -194
- iop/_local.py +0 -106
- iop/_message.py +0 -45
- iop/_polling_business_service.py +0 -6
- iop/_remote.py +0 -473
- iop/_utils.py +0 -912
- iop/cls/IOP/Service/WSGI.cls +0 -310
- iop/wsgi/handlers.py +0 -112
- iris_pex_embedded_python-3.7.2b2.dist-info/RECORD +0 -96
- iris_pex_embedded_python-3.7.2b2.dist-info/entry_points.txt +0 -2
- iris_pex_embedded_python-3.7.2b2.dist-info/top_level.txt +0 -2
- {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/WHEEL +0 -0
- {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/licenses/LICENSE +0 -0
iop/production/types.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, overload
|
|
8
|
+
|
|
9
|
+
from ..components.settings import Category, Setting, controls
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TargetSetting(Setting):
|
|
13
|
+
"""Production target setting descriptor created by target()."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, **kwargs: Any):
|
|
16
|
+
kwargs.setdefault("iris_type", "Ens.DataType.ConfigName")
|
|
17
|
+
kwargs.setdefault("category", Category.BASIC)
|
|
18
|
+
kwargs.setdefault("control", controls.production_item())
|
|
19
|
+
super().__init__("", **kwargs)
|
|
20
|
+
|
|
21
|
+
@overload
|
|
22
|
+
def __get__(self, instance: None, owner: type | None = None) -> TargetSetting: ...
|
|
23
|
+
|
|
24
|
+
@overload
|
|
25
|
+
def __get__(self, instance: object, owner: type | None = None) -> str: ...
|
|
26
|
+
|
|
27
|
+
def __get__(
|
|
28
|
+
self,
|
|
29
|
+
instance: object | None,
|
|
30
|
+
owner: type | None = None,
|
|
31
|
+
) -> TargetSetting | str:
|
|
32
|
+
if instance is None:
|
|
33
|
+
return self
|
|
34
|
+
return super().__get__(instance, owner)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def target(**kwargs: Any) -> TargetSetting:
|
|
38
|
+
"""Purpose:
|
|
39
|
+
Declare a configurable outbound target setting on a component class.
|
|
40
|
+
|
|
41
|
+
Use when:
|
|
42
|
+
A service or process sends messages to another production component.
|
|
43
|
+
|
|
44
|
+
Lifecycle:
|
|
45
|
+
The descriptor becomes an IRIS production setting. Production.connect(...)
|
|
46
|
+
binds it to a concrete target component.
|
|
47
|
+
|
|
48
|
+
Best practices:
|
|
49
|
+
Use target() instead of hard-coded component names for routes that
|
|
50
|
+
should be visible and configurable in the production graph.
|
|
51
|
+
|
|
52
|
+
Common mistakes:
|
|
53
|
+
Do not use setting(...) for outbound routing when target() should expose
|
|
54
|
+
a production target.
|
|
55
|
+
|
|
56
|
+
Minimal example:
|
|
57
|
+
class Router(BusinessProcess):
|
|
58
|
+
Output = target()
|
|
59
|
+
|
|
60
|
+
prod.connect(router.Output, operation)
|
|
61
|
+
|
|
62
|
+
Related:
|
|
63
|
+
docs/cookbooks/production-settings-and-targets.md,
|
|
64
|
+
docs/production-graph.md
|
|
65
|
+
"""
|
|
66
|
+
return TargetSetting(**kwargs)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class TargetSettingRef:
|
|
71
|
+
"""Bound reference to a component target setting inside a Production."""
|
|
72
|
+
|
|
73
|
+
production: Any
|
|
74
|
+
component: Any
|
|
75
|
+
name: str
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def item_name(self) -> str:
|
|
79
|
+
return self.component.name
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def path(self) -> str:
|
|
83
|
+
return f"{self.item_name}.{self.name}"
|
|
84
|
+
|
|
85
|
+
def resolve(self) -> str:
|
|
86
|
+
return self.production.resolve_target_setting_ref(self)
|
|
87
|
+
|
|
88
|
+
def __str__(self) -> str:
|
|
89
|
+
return self.path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class PersistentMessageRegistration:
|
|
94
|
+
iris_classname: str
|
|
95
|
+
message_class: type
|
|
96
|
+
sync_schema: bool = True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class GraphNode:
|
|
101
|
+
name: str
|
|
102
|
+
class_name: str
|
|
103
|
+
kind: str = "component"
|
|
104
|
+
enabled: bool | str = True
|
|
105
|
+
category: str = ""
|
|
106
|
+
adapter_class_name: str = ""
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, Any]:
|
|
109
|
+
data: dict[str, Any] = {
|
|
110
|
+
"name": self.name,
|
|
111
|
+
"class_name": self.class_name,
|
|
112
|
+
"kind": self.kind,
|
|
113
|
+
"enabled": self.enabled,
|
|
114
|
+
"category": self.category,
|
|
115
|
+
}
|
|
116
|
+
if self.adapter_class_name:
|
|
117
|
+
data["adapter_class_name"] = self.adapter_class_name
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class GraphEdge:
|
|
123
|
+
"""Possible communication route between two production items.
|
|
124
|
+
|
|
125
|
+
An IRIS production topology can be modeled as a directed multigraph of
|
|
126
|
+
possible communication routes. A graph edge is not an execution dependency
|
|
127
|
+
and does not imply DAG semantics.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
source_item: str
|
|
131
|
+
target: str
|
|
132
|
+
source_target_setting: str = ""
|
|
133
|
+
origin: str = "authored"
|
|
134
|
+
interaction: str = "request"
|
|
135
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
136
|
+
|
|
137
|
+
def __post_init__(self) -> None:
|
|
138
|
+
origin = self.origin or "authored"
|
|
139
|
+
if origin not in {"authored", "runtime", "inferred"}:
|
|
140
|
+
raise ValueError(f"Unsupported GraphEdge origin: {origin!r}")
|
|
141
|
+
object.__setattr__(self, "origin", origin)
|
|
142
|
+
object.__setattr__(self, "interaction", self.interaction or "unknown")
|
|
143
|
+
object.__setattr__(self, "metadata", dict(self.metadata or {}))
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def runtime(self) -> bool:
|
|
147
|
+
return self.origin == "runtime"
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def inferred(self) -> bool:
|
|
151
|
+
return self.origin == "inferred"
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def source(self) -> str:
|
|
155
|
+
if self.source_target_setting:
|
|
156
|
+
return f"{self.source_item}.{self.source_target_setting}"
|
|
157
|
+
return self.source_item
|
|
158
|
+
|
|
159
|
+
def to_dict(self) -> dict[str, Any]:
|
|
160
|
+
data: dict[str, Any] = {
|
|
161
|
+
"source": self.source,
|
|
162
|
+
"source_item": self.source_item,
|
|
163
|
+
"source_target_setting": self.source_target_setting,
|
|
164
|
+
"target": self.target,
|
|
165
|
+
"origin": self.origin,
|
|
166
|
+
"interaction": self.interaction,
|
|
167
|
+
}
|
|
168
|
+
if self.metadata:
|
|
169
|
+
data["metadata"] = dict(self.metadata)
|
|
170
|
+
if self.runtime:
|
|
171
|
+
data["runtime"] = True
|
|
172
|
+
if self.inferred:
|
|
173
|
+
data["inferred"] = True
|
|
174
|
+
return data
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class ProductionGraph:
|
|
179
|
+
production_name: str
|
|
180
|
+
nodes: tuple[GraphNode, ...]
|
|
181
|
+
edges: tuple[GraphEdge, ...]
|
|
182
|
+
warnings: tuple[str, ...] = ()
|
|
183
|
+
|
|
184
|
+
def to_dict(self) -> dict[str, Any]:
|
|
185
|
+
data: dict[str, Any] = {
|
|
186
|
+
"production": self.production_name,
|
|
187
|
+
"nodes": [node.to_dict() for node in self.nodes],
|
|
188
|
+
"edges": [edge.to_dict() for edge in self.edges],
|
|
189
|
+
}
|
|
190
|
+
if self.warnings:
|
|
191
|
+
data["warnings"] = list(self.warnings)
|
|
192
|
+
return data
|
|
193
|
+
|
|
194
|
+
def to_text(self) -> str:
|
|
195
|
+
outgoing: dict[str, list[GraphEdge]] = {node.name: [] for node in self.nodes}
|
|
196
|
+
node_names = set(outgoing)
|
|
197
|
+
for edge in self.edges:
|
|
198
|
+
outgoing.setdefault(edge.source_item, []).append(edge)
|
|
199
|
+
|
|
200
|
+
lines = [self.production_name]
|
|
201
|
+
for node in self.nodes:
|
|
202
|
+
label = f" [{node.class_name}]" if node.class_name else ""
|
|
203
|
+
lines.append(f" {node.name}{label}")
|
|
204
|
+
for edge in sorted(
|
|
205
|
+
outgoing.get(node.name, ()),
|
|
206
|
+
key=lambda item: (item.source_target_setting, item.target),
|
|
207
|
+
):
|
|
208
|
+
source_target_setting = edge.source_target_setting or "(runtime)"
|
|
209
|
+
suffix = "" if edge.target in node_names else " (unresolved)"
|
|
210
|
+
labels = []
|
|
211
|
+
if edge.origin != "authored":
|
|
212
|
+
labels.append(edge.origin)
|
|
213
|
+
if edge.interaction not in ("", "request"):
|
|
214
|
+
labels.append(edge.interaction)
|
|
215
|
+
label = f" [{', '.join(labels)}]" if labels else ""
|
|
216
|
+
lines.append(
|
|
217
|
+
f" {source_target_setting} -> {edge.target}{suffix}{label}"
|
|
218
|
+
)
|
|
219
|
+
if self.warnings:
|
|
220
|
+
lines.append(" warnings:")
|
|
221
|
+
lines.extend(f" {warning}" for warning in self.warnings)
|
|
222
|
+
return "\n".join(lines)
|
|
223
|
+
|
|
224
|
+
def to_mermaid(self, *, direction: str = "LR") -> str:
|
|
225
|
+
"""Render the production topology as a Mermaid flowchart."""
|
|
226
|
+
direction = _mermaid_direction(direction)
|
|
227
|
+
node_ids = _mermaid_node_ids(self.nodes, self.edges)
|
|
228
|
+
defined_node_names = {node.name for node in self.nodes}
|
|
229
|
+
|
|
230
|
+
lines = [
|
|
231
|
+
f"flowchart {direction}",
|
|
232
|
+
f" %% Production: {_mermaid_comment(self.production_name)}",
|
|
233
|
+
]
|
|
234
|
+
lines.extend(_mermaid_production_layout_node_lines(self.nodes, node_ids))
|
|
235
|
+
|
|
236
|
+
unresolved_names = sorted({
|
|
237
|
+
name
|
|
238
|
+
for edge in self.edges
|
|
239
|
+
for name in (edge.source_item, edge.target)
|
|
240
|
+
if name not in defined_node_names
|
|
241
|
+
})
|
|
242
|
+
for name in unresolved_names:
|
|
243
|
+
lines.append(
|
|
244
|
+
f" {node_ids[name]}[\"{_mermaid_text(name)}<br/>(unresolved)\"]"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
lines.extend(_mermaid_production_layout_hint_lines(self.nodes, node_ids))
|
|
248
|
+
|
|
249
|
+
for edge in sorted(
|
|
250
|
+
self.edges,
|
|
251
|
+
key=lambda item: (
|
|
252
|
+
item.source_item,
|
|
253
|
+
item.source_target_setting,
|
|
254
|
+
item.target,
|
|
255
|
+
item.origin,
|
|
256
|
+
item.interaction,
|
|
257
|
+
),
|
|
258
|
+
):
|
|
259
|
+
edge_line = _mermaid_edge_line(
|
|
260
|
+
node_ids[edge.source_item],
|
|
261
|
+
node_ids[edge.target],
|
|
262
|
+
edge,
|
|
263
|
+
)
|
|
264
|
+
lines.append(
|
|
265
|
+
f" {edge_line}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if self.warnings:
|
|
269
|
+
lines.append(" %% Warnings:")
|
|
270
|
+
lines.extend(
|
|
271
|
+
f" %% - {_mermaid_comment(warning)}"
|
|
272
|
+
for warning in self.warnings
|
|
273
|
+
)
|
|
274
|
+
return "\n".join(lines) + "\n"
|
|
275
|
+
|
|
276
|
+
def __str__(self) -> str:
|
|
277
|
+
return self.to_text()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True)
|
|
281
|
+
class ProductionDiffEntry:
|
|
282
|
+
"""One deterministic desired-vs-current production difference."""
|
|
283
|
+
|
|
284
|
+
action: str
|
|
285
|
+
kind: str
|
|
286
|
+
path: str
|
|
287
|
+
before: Any = None
|
|
288
|
+
after: Any = None
|
|
289
|
+
detail: str = ""
|
|
290
|
+
|
|
291
|
+
def to_dict(self) -> dict[str, Any]:
|
|
292
|
+
data: dict[str, Any] = {
|
|
293
|
+
"action": self.action,
|
|
294
|
+
"kind": self.kind,
|
|
295
|
+
"path": self.path,
|
|
296
|
+
}
|
|
297
|
+
if self.before is not None:
|
|
298
|
+
data["before"] = self.before
|
|
299
|
+
if self.after is not None:
|
|
300
|
+
data["after"] = self.after
|
|
301
|
+
if self.detail:
|
|
302
|
+
data["detail"] = self.detail
|
|
303
|
+
return data
|
|
304
|
+
|
|
305
|
+
def to_text(self) -> str:
|
|
306
|
+
line = f"{self.action} {self.kind} {self.path}"
|
|
307
|
+
if self.detail:
|
|
308
|
+
return f"{line}: {self.detail}"
|
|
309
|
+
if self.action == "change":
|
|
310
|
+
return f"{line}: {_diff_value_text(self.before)} -> {_diff_value_text(self.after)}"
|
|
311
|
+
if self.action == "add":
|
|
312
|
+
return f"{line}: {_diff_value_text(self.after)}"
|
|
313
|
+
if self.action == "remove":
|
|
314
|
+
return f"{line}: {_diff_value_text(self.before)}"
|
|
315
|
+
return line
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass(frozen=True)
|
|
319
|
+
class ProductionDiff:
|
|
320
|
+
"""Directional diff from current runtime/imported state to desired Python state."""
|
|
321
|
+
|
|
322
|
+
production_name: str
|
|
323
|
+
changes: tuple[ProductionDiffEntry, ...]
|
|
324
|
+
warnings: tuple[str, ...] = ()
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def has_changes(self) -> bool:
|
|
328
|
+
return bool(self.changes)
|
|
329
|
+
|
|
330
|
+
def to_dict(self) -> dict[str, Any]:
|
|
331
|
+
data: dict[str, Any] = {
|
|
332
|
+
"production": self.production_name,
|
|
333
|
+
"has_changes": self.has_changes,
|
|
334
|
+
"changes": [change.to_dict() for change in self.changes],
|
|
335
|
+
}
|
|
336
|
+
if self.warnings:
|
|
337
|
+
data["warnings"] = list(self.warnings)
|
|
338
|
+
return data
|
|
339
|
+
|
|
340
|
+
def to_text(self) -> str:
|
|
341
|
+
lines = [f"Production diff: {self.production_name}"]
|
|
342
|
+
if self.changes:
|
|
343
|
+
lines.extend(f" {change.to_text()}" for change in self.changes)
|
|
344
|
+
else:
|
|
345
|
+
lines.append(" no changes")
|
|
346
|
+
if self.warnings:
|
|
347
|
+
lines.append(" warnings:")
|
|
348
|
+
lines.extend(f" {warning}" for warning in self.warnings)
|
|
349
|
+
return "\n".join(lines)
|
|
350
|
+
|
|
351
|
+
def __str__(self) -> str:
|
|
352
|
+
return self.to_text()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _edge_identity(edge: GraphEdge) -> tuple[Any, ...]:
|
|
356
|
+
return (
|
|
357
|
+
edge.source_item,
|
|
358
|
+
edge.source_target_setting,
|
|
359
|
+
edge.target,
|
|
360
|
+
edge.origin,
|
|
361
|
+
edge.interaction,
|
|
362
|
+
tuple(
|
|
363
|
+
sorted(
|
|
364
|
+
(str(key), _canonical_value(value))
|
|
365
|
+
for key, value in edge.metadata.items()
|
|
366
|
+
)
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _canonical_value(value: Any) -> Any:
|
|
372
|
+
from .common import _bool_text
|
|
373
|
+
|
|
374
|
+
if isinstance(value, dict):
|
|
375
|
+
return {
|
|
376
|
+
str(key): _canonical_value(value[key])
|
|
377
|
+
for key in sorted(value, key=lambda item: str(item))
|
|
378
|
+
}
|
|
379
|
+
if isinstance(value, (list, tuple)):
|
|
380
|
+
return [_canonical_value(item) for item in value]
|
|
381
|
+
if isinstance(value, bool):
|
|
382
|
+
return _bool_text(value)
|
|
383
|
+
if value is None:
|
|
384
|
+
return ""
|
|
385
|
+
return value
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _diff_value_text(value: Any) -> str:
|
|
389
|
+
if isinstance(value, str):
|
|
390
|
+
return repr(value)
|
|
391
|
+
try:
|
|
392
|
+
return json.dumps(value, sort_keys=True)
|
|
393
|
+
except TypeError:
|
|
394
|
+
return repr(value)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _mermaid_direction(value: str) -> str:
|
|
398
|
+
direction = str(value or "").strip().upper()
|
|
399
|
+
if direction not in {"TB", "TD", "BT", "RL", "LR"}:
|
|
400
|
+
raise ValueError(f"Unsupported Mermaid flowchart direction: {value!r}")
|
|
401
|
+
return direction
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _mermaid_node_ids(
|
|
405
|
+
nodes: tuple[GraphNode, ...],
|
|
406
|
+
edges: tuple[GraphEdge, ...],
|
|
407
|
+
) -> dict[str, str]:
|
|
408
|
+
names: list[str] = [node.name for node in nodes]
|
|
409
|
+
for edge in edges:
|
|
410
|
+
for name in (edge.source_item, edge.target):
|
|
411
|
+
if name not in names:
|
|
412
|
+
names.append(name)
|
|
413
|
+
|
|
414
|
+
used: set[str] = set()
|
|
415
|
+
node_ids: dict[str, str] = {}
|
|
416
|
+
for name in names:
|
|
417
|
+
base = re.sub(r"\W+", "_", name).strip("_")
|
|
418
|
+
if not base:
|
|
419
|
+
base = "node"
|
|
420
|
+
base = f"node_{base}"
|
|
421
|
+
candidate = base
|
|
422
|
+
index = 2
|
|
423
|
+
while candidate in used:
|
|
424
|
+
candidate = f"{base}_{index}"
|
|
425
|
+
index += 1
|
|
426
|
+
used.add(candidate)
|
|
427
|
+
node_ids[name] = candidate
|
|
428
|
+
return node_ids
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _mermaid_production_layout_node_lines(
|
|
432
|
+
nodes: tuple[GraphNode, ...],
|
|
433
|
+
node_ids: dict[str, str],
|
|
434
|
+
) -> list[str]:
|
|
435
|
+
lines: list[str] = []
|
|
436
|
+
for kind, title in _MERMAID_PRODUCTION_GROUPS:
|
|
437
|
+
group_nodes = [
|
|
438
|
+
node
|
|
439
|
+
for node in nodes
|
|
440
|
+
if _mermaid_production_group_kind(node.kind) == kind
|
|
441
|
+
]
|
|
442
|
+
if not group_nodes:
|
|
443
|
+
continue
|
|
444
|
+
lines.append(f" subgraph group_{kind}[\"{title}\"]")
|
|
445
|
+
lines.append(" direction TB")
|
|
446
|
+
lines.extend(
|
|
447
|
+
f" {node_ids[node.name]}[\"{_mermaid_node_label(node)}\"]"
|
|
448
|
+
for node in group_nodes
|
|
449
|
+
)
|
|
450
|
+
lines.append(" end")
|
|
451
|
+
return lines
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _mermaid_production_layout_hint_lines(
|
|
455
|
+
nodes: tuple[GraphNode, ...],
|
|
456
|
+
node_ids: dict[str, str],
|
|
457
|
+
) -> list[str]:
|
|
458
|
+
first_node_by_kind: dict[str, GraphNode] = {}
|
|
459
|
+
for node in nodes:
|
|
460
|
+
kind = _mermaid_production_group_kind(node.kind)
|
|
461
|
+
first_node_by_kind.setdefault(kind, node)
|
|
462
|
+
|
|
463
|
+
ordered_ids = [
|
|
464
|
+
node_ids[first_node_by_kind[kind].name]
|
|
465
|
+
for kind, _title in _MERMAID_PRODUCTION_GROUPS
|
|
466
|
+
if kind in first_node_by_kind
|
|
467
|
+
]
|
|
468
|
+
return [
|
|
469
|
+
f" {left} ~~~ {right}"
|
|
470
|
+
for left, right in zip(ordered_ids, ordered_ids[1:], strict=False)
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
_MERMAID_PRODUCTION_GROUPS = (
|
|
475
|
+
("service", "Services"),
|
|
476
|
+
("process", "Processes"),
|
|
477
|
+
("operation", "Operations"),
|
|
478
|
+
("component", "Other Components"),
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _mermaid_production_group_kind(kind: str) -> str:
|
|
483
|
+
normalized = str(kind or "").strip().lower()
|
|
484
|
+
if normalized in {"service", "process", "operation"}:
|
|
485
|
+
return normalized
|
|
486
|
+
return "component"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _mermaid_node_label(node: GraphNode) -> str:
|
|
490
|
+
label = _mermaid_text(node.name)
|
|
491
|
+
if node.class_name:
|
|
492
|
+
label = f"{label}<br/>{_mermaid_text(node.class_name)}"
|
|
493
|
+
return label
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _mermaid_edge_label(edge: GraphEdge) -> str:
|
|
497
|
+
parts = [edge.source_target_setting or "(runtime)"]
|
|
498
|
+
message = _mermaid_message_label(edge.metadata)
|
|
499
|
+
if message:
|
|
500
|
+
parts.append(message)
|
|
501
|
+
if edge.origin != "authored":
|
|
502
|
+
parts.append(edge.origin)
|
|
503
|
+
if edge.interaction not in ("", "request"):
|
|
504
|
+
parts.append(edge.interaction)
|
|
505
|
+
return _mermaid_text(" | ".join(parts))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _mermaid_edge_line(source_id: str, target_id: str, edge: GraphEdge) -> str:
|
|
509
|
+
label = _mermaid_edge_label(edge)
|
|
510
|
+
if _mermaid_edge_is_sync(edge):
|
|
511
|
+
return f"{source_id} <-- \"{label}\" --> {target_id}"
|
|
512
|
+
return (
|
|
513
|
+
f"{source_id} -- "
|
|
514
|
+
f"\"{label}\" "
|
|
515
|
+
f"--> {target_id}"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _mermaid_edge_is_sync(edge: GraphEdge) -> bool:
|
|
520
|
+
return str(edge.interaction or "").strip().lower() == "sync"
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _mermaid_message_label(metadata: dict[str, Any]) -> str:
|
|
524
|
+
messages = (
|
|
525
|
+
metadata.get("messages")
|
|
526
|
+
or metadata.get("message_types")
|
|
527
|
+
or metadata.get("accepted_messages")
|
|
528
|
+
)
|
|
529
|
+
if isinstance(messages, (list, tuple)):
|
|
530
|
+
return ", ".join(str(message) for message in messages if message)
|
|
531
|
+
if messages:
|
|
532
|
+
return str(messages)
|
|
533
|
+
message = metadata.get("message") or metadata.get("request")
|
|
534
|
+
if message:
|
|
535
|
+
return str(message)
|
|
536
|
+
return ""
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _mermaid_text(value: Any) -> str:
|
|
540
|
+
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
|
|
541
|
+
text = " ".join(part.strip() for part in text.split("\n"))
|
|
542
|
+
return html.escape(text, quote=True)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _mermaid_comment(value: Any) -> str:
|
|
546
|
+
return str(value).replace("\r\n", " ").replace("\n", " ")
|