process-bigraph-lang 0.1.0__py3-none-win_amd64.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.
- process_bigraph_lang/__init__.py +0 -0
- process_bigraph_lang/bin/.gitignore +4 -0
- process_bigraph_lang/bin/cli-native.exe +4 -0
- process_bigraph_lang/py.typed +0 -0
- process_bigraph_lang/v2/__init__.py +56 -0
- process_bigraph_lang/v2/__main__.py +204 -0
- process_bigraph_lang/v2/api.py +192 -0
- process_bigraph_lang/v2/check.py +73 -0
- process_bigraph_lang/v2/interfaces.py +236 -0
- process_bigraph_lang/v2/langium.py +38 -0
- process_bigraph_lang/v2/normalize.py +384 -0
- process_bigraph_lang/v2/sbml.py +234 -0
- process_bigraph_lang/v2/types.py +244 -0
- process_bigraph_lang-0.1.0.dist-info/METADATA +82 -0
- process_bigraph_lang-0.1.0.dist-info/RECORD +17 -0
- process_bigraph_lang-0.1.0.dist-info/WHEEL +5 -0
- process_bigraph_lang-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interface declarations as data: a minimal stage-A `describe` and the registry the normalizer resolves addresses in.
|
|
3
|
+
|
|
4
|
+
`describe_module` imports a Python module once (the publish-time step; authoring never imports) and turns every
|
|
5
|
+
step/process class it exposes into an `InterfaceDecl`: the address in portable `local:!module.Class` form, the
|
|
6
|
+
`config_schema`, and the ports obtained by calling `inputs()`/`outputs()` with the class standing in for `self`
|
|
7
|
+
(ports that depend on an instance are marked `dynamic_ports`). Types stay in bigraph-schema's own form. The
|
|
8
|
+
`InterfaceRegistry` holds declarations and reads and writes the JSON manifest (`pblang-manifest/0`), which the
|
|
9
|
+
TypeScript checker, printer and binder consume; `render_interfaces` has the CLI print the `.pblang` header file.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import inspect
|
|
16
|
+
import json
|
|
17
|
+
import tempfile
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from process_bigraph_lang.v2.types import MISSING, Named, parse_type
|
|
23
|
+
|
|
24
|
+
MANIFEST_VERSION = "pblang-manifest/0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ConfigField:
|
|
29
|
+
"""One entry of a `config_schema`: the type in bigraph-schema form and its default, when it has one."""
|
|
30
|
+
|
|
31
|
+
type: Any = None
|
|
32
|
+
default: Any = MISSING
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class InterfaceDecl:
|
|
37
|
+
"""A process/step interface as a manifest entry: address, config fields, ports (bigraph-schema forms)."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
kind: str # process | step | adapter
|
|
41
|
+
address: str | None = None
|
|
42
|
+
module: str | None = None
|
|
43
|
+
config: dict[str, ConfigField] = field(default_factory=dict)
|
|
44
|
+
inputs: dict[str, Any] | None = None
|
|
45
|
+
outputs: dict[str, Any] | None = None
|
|
46
|
+
doc: str | None = None
|
|
47
|
+
dynamic_ports: bool = False # inputs()/outputs() could not be evaluated without an instance
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def short_name(address: str) -> str:
|
|
51
|
+
"""`Name` of `local:!pkg.mod.Name`, `local:Name` or `Name`."""
|
|
52
|
+
data = address.split(":", 1)[1] if ":" in address else address
|
|
53
|
+
return data.lstrip("!").rsplit(".", 1)[-1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def describe_module(module_name: str) -> list[InterfaceDecl]:
|
|
57
|
+
"""Declarations for the step/process classes a module defines or re-exports (classes of its own package only)."""
|
|
58
|
+
module = importlib.import_module(module_name)
|
|
59
|
+
is_package = hasattr(module, "__path__")
|
|
60
|
+
decls: list[InterfaceDecl] = []
|
|
61
|
+
for name, cls in inspect.getmembers(module, inspect.isclass):
|
|
62
|
+
if name.startswith("_") or not _is_edge_class(cls) or not _belongs_to(cls, module_name, is_package):
|
|
63
|
+
continue
|
|
64
|
+
decls.append(describe_class(cls, module_name, name))
|
|
65
|
+
return decls
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def describe_class(cls: type, module_name: str | None = None, name: str | None = None) -> InterfaceDecl:
|
|
69
|
+
"""The declaration of one step/process class."""
|
|
70
|
+
module_name = module_name or cls.__module__
|
|
71
|
+
name = name or cls.__name__
|
|
72
|
+
inputs, outputs, dynamic = _ports(cls)
|
|
73
|
+
return InterfaceDecl(
|
|
74
|
+
name=name,
|
|
75
|
+
kind=_kind(cls),
|
|
76
|
+
address=f"local:!{module_name}.{name}",
|
|
77
|
+
module=module_name,
|
|
78
|
+
config=_config_fields(getattr(cls, "config_schema", None) or {}),
|
|
79
|
+
inputs=inputs,
|
|
80
|
+
outputs=outputs,
|
|
81
|
+
doc=_doc(cls),
|
|
82
|
+
dynamic_ports=dynamic,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class InterfaceRegistry:
|
|
88
|
+
"""Declarations by address: the manifest the CLI reads, and what the normalizer resolves edges in."""
|
|
89
|
+
|
|
90
|
+
decls: list[InterfaceDecl] = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_modules(cls, *module_names: str) -> InterfaceRegistry:
|
|
94
|
+
registry = cls()
|
|
95
|
+
for module_name in module_names:
|
|
96
|
+
registry.decls.extend(describe_module(module_name))
|
|
97
|
+
return registry
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_manifest(cls, manifest: dict[str, Any]) -> InterfaceRegistry:
|
|
101
|
+
return cls([_decl_from_manifest(entry) for entry in manifest.get("components", [])])
|
|
102
|
+
|
|
103
|
+
def to_manifest(self) -> dict[str, Any]:
|
|
104
|
+
return {"schema_version": MANIFEST_VERSION, "components": [_decl_to_manifest(d) for d in self.decls]}
|
|
105
|
+
|
|
106
|
+
def lookup(self, address: str) -> InterfaceDecl | None:
|
|
107
|
+
"""By exact address; else by unique short name (`local:Name`, or a class re-exported from another module)."""
|
|
108
|
+
wanted = address if ":" in address else f"local:{address}"
|
|
109
|
+
if not wanted.startswith("local:"):
|
|
110
|
+
return None
|
|
111
|
+
exact = [d for d in self.decls if d.address == wanted]
|
|
112
|
+
if exact:
|
|
113
|
+
return exact[0]
|
|
114
|
+
by_name = [d for d in self.decls if d.name == short_name(wanted)]
|
|
115
|
+
return by_name[0] if len(by_name) == 1 else None
|
|
116
|
+
|
|
117
|
+
def write_manifest(self, path: Path) -> Path:
|
|
118
|
+
path.write_text(json.dumps(self.to_manifest(), indent=2, default=str) + "\n", encoding="utf-8")
|
|
119
|
+
return path
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def render_interfaces(decls: list[InterfaceDecl], module_name: str) -> str:
|
|
123
|
+
"""The `.pblang` header file of a module's declarations, as the CLI prints it."""
|
|
124
|
+
from process_bigraph_lang.v2.langium import run_cli
|
|
125
|
+
|
|
126
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
127
|
+
manifest = Path(tmp) / "manifest.json"
|
|
128
|
+
entries = [_decl_to_manifest(d) for d in decls]
|
|
129
|
+
for entry in entries:
|
|
130
|
+
entry["module"] = module_name
|
|
131
|
+
manifest.write_text(json.dumps({"schema_version": MANIFEST_VERSION, "components": entries}, default=str))
|
|
132
|
+
return run_cli("headers", str(manifest)).stdout
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- describe ----------------------------------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_edge_class(cls: type) -> bool:
|
|
139
|
+
return any(base.__name__ == "Edge" for base in cls.__mro__)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _belongs_to(cls: type, module_name: str, is_package: bool) -> bool:
|
|
143
|
+
"""Defined in the module itself, or (for a package) re-exported from one of its submodules."""
|
|
144
|
+
return cls.__module__ == module_name or (is_package and cls.__module__.startswith(module_name + "."))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _kind(cls: type) -> str:
|
|
148
|
+
names = [base.__name__ for base in cls.__mro__]
|
|
149
|
+
if "Process" in names:
|
|
150
|
+
return "process"
|
|
151
|
+
return "step"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _ports(cls: type) -> tuple[dict[str, Any] | None, dict[str, Any] | None, bool]:
|
|
155
|
+
faces: list[dict[str, Any] | None] = []
|
|
156
|
+
dynamic = False
|
|
157
|
+
for method_name in ("inputs", "outputs"):
|
|
158
|
+
method = getattr(cls, method_name, None)
|
|
159
|
+
if method is None:
|
|
160
|
+
faces.append(None)
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
ports = method(cls) # the class stands in for `self`: works for literal port dicts
|
|
164
|
+
except Exception: # noqa: BLE001 - ports depend on an instance (self.config, children, ...)
|
|
165
|
+
faces.append(None)
|
|
166
|
+
dynamic = True
|
|
167
|
+
continue
|
|
168
|
+
faces.append({str(k): v for k, v in ports.items()} if isinstance(ports, dict) else None)
|
|
169
|
+
return faces[0], faces[1], dynamic
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _config_fields(schema: Any) -> dict[str, ConfigField]:
|
|
173
|
+
if not isinstance(schema, dict):
|
|
174
|
+
return {}
|
|
175
|
+
fields: dict[str, ConfigField] = {}
|
|
176
|
+
for key, spec in schema.items():
|
|
177
|
+
default: Any = MISSING
|
|
178
|
+
try:
|
|
179
|
+
expr = parse_type(spec)
|
|
180
|
+
if isinstance(expr, Named):
|
|
181
|
+
default = expr.default
|
|
182
|
+
except (TypeError, ValueError):
|
|
183
|
+
pass
|
|
184
|
+
fields[str(key)] = ConfigField(spec if isinstance(spec, (str, dict)) else None, default)
|
|
185
|
+
return fields
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _doc(cls: type) -> str | None:
|
|
189
|
+
text = cls.__dict__.get("__doc__")
|
|
190
|
+
if not text:
|
|
191
|
+
return None
|
|
192
|
+
first = inspect.cleandoc(text).split("\n\n", 1)[0].strip()
|
|
193
|
+
return first or None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --- manifest ----------------------------------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _decl_to_manifest(decl: InterfaceDecl) -> dict[str, Any]:
|
|
200
|
+
entry: dict[str, Any] = {
|
|
201
|
+
"name": decl.name,
|
|
202
|
+
"kind": decl.kind,
|
|
203
|
+
"address": decl.address,
|
|
204
|
+
"module": decl.module,
|
|
205
|
+
"config": {
|
|
206
|
+
k: {"type": f.type, **({"default": f.default} if f.default is not MISSING else {})}
|
|
207
|
+
for k, f in decl.config.items()
|
|
208
|
+
},
|
|
209
|
+
"inputs": dict(decl.inputs) if decl.inputs is not None else None,
|
|
210
|
+
"outputs": dict(decl.outputs) if decl.outputs is not None else None,
|
|
211
|
+
"dynamic_ports": decl.dynamic_ports,
|
|
212
|
+
}
|
|
213
|
+
if decl.doc:
|
|
214
|
+
entry["doc"] = decl.doc
|
|
215
|
+
return entry
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _decl_from_manifest(entry: dict[str, Any]) -> InterfaceDecl:
|
|
219
|
+
config = {k: ConfigField(f.get("type"), f.get("default", MISSING)) for k, f in (entry.get("config") or {}).items()}
|
|
220
|
+
return InterfaceDecl(
|
|
221
|
+
name=entry["name"],
|
|
222
|
+
kind=entry.get("kind", "process"),
|
|
223
|
+
address=entry["address"] if entry.get("address") else None,
|
|
224
|
+
module=entry.get("module"),
|
|
225
|
+
config=config,
|
|
226
|
+
inputs=_ports_from_manifest(entry.get("inputs")),
|
|
227
|
+
outputs=_ports_from_manifest(entry.get("outputs")),
|
|
228
|
+
doc=entry.get("doc"),
|
|
229
|
+
dynamic_ports=bool(entry.get("dynamic_ports", False)),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _ports_from_manifest(ports: Any) -> dict[str, Any] | None:
|
|
234
|
+
if ports is None:
|
|
235
|
+
return None
|
|
236
|
+
return {str(k): v for k, v in ports.items()}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The DSL CLI from Python: `cli-native` (the same binary v1 uses, built once by Deno at wheel-build time) or
|
|
3
|
+
`node dsl/bin/cli.js` in a development checkout. Everything the v2 language does, parsing, checking, compiling,
|
|
4
|
+
decompiling and rendering headers, lives in TypeScript (`dsl/src/language/v2/`); Python only calls it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_ROOT = Path(__file__).resolve().parent.parent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def langium_cli() -> list[str] | None:
|
|
16
|
+
"""The command that runs the DSL CLI, or None when neither the native binary nor a built checkout exists."""
|
|
17
|
+
for candidate in (_ROOT / "bin").glob("cli-native*"):
|
|
18
|
+
return [str(candidate)]
|
|
19
|
+
script = _ROOT.parent / "dsl" / "bin" / "cli.js"
|
|
20
|
+
if script.exists() and (_ROOT.parent / "dsl" / "out").exists():
|
|
21
|
+
return ["node", str(script)]
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_cli(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
26
|
+
"""Run a CLI command; raise with its stderr when it fails (unless `check=False`)."""
|
|
27
|
+
command = langium_cli()
|
|
28
|
+
if command is None:
|
|
29
|
+
raise RuntimeError("no Langium CLI: build dsl/ (npm run build) or install the cli-native binary")
|
|
30
|
+
result = subprocess.run([*command, *args], capture_output=True, text=True, encoding="utf-8", check=False)
|
|
31
|
+
if check and result.returncode != 0:
|
|
32
|
+
raise RuntimeError(
|
|
33
|
+
f"Langium CLI failed ({' '.join(args[:2])}): {result.stderr.strip() or result.stdout.strip()}"
|
|
34
|
+
)
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["langium_cli", "run_cli"]
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Equivalence of composite documents modulo representation.
|
|
3
|
+
|
|
4
|
+
Two documents are equivalent when they realize the same composite. `normalize_document` rewrites a document
|
|
5
|
+
into one canonical shape: types are pushed through bigraph-schema (`core.access` + `core.render(defaults=True)`),
|
|
6
|
+
addresses become `protocol:data` strings, wires become lists, tuples and arrays become lists, runtime keys
|
|
7
|
+
(`instance`, `_contract`, the root `global_time`) are dropped, and the edge entries of the schema half are folded
|
|
8
|
+
into the state half. `equivalent` compares two normalized documents; `diff_documents` explains a difference.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import difflib
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from process_bigraph_lang.v2.interfaces import InterfaceRegistry
|
|
21
|
+
from process_bigraph_lang.v2.types import MISSING, Group, Named, Struct, TypeExpr, UnionType, parse_type
|
|
22
|
+
|
|
23
|
+
EDGE_KINDS = ("process", "step", "shared_process", "shared_process_ref")
|
|
24
|
+
RUNTIME_EDGE_KEYS = ("instance", "_contract")
|
|
25
|
+
COMPOSITE_OPTION_KEYS = (
|
|
26
|
+
"global_time_precision",
|
|
27
|
+
"run_steps_on_init",
|
|
28
|
+
"parallel_steps",
|
|
29
|
+
"parallel_processes",
|
|
30
|
+
"parallel_workers",
|
|
31
|
+
"skip_process_state",
|
|
32
|
+
"contract_strict",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_site(value: Any) -> bool:
|
|
37
|
+
"""A template site: `{"_type": "site", "_sort": ..., "_default"?: ...}`."""
|
|
38
|
+
return isinstance(value, dict) and value.get("_type") == "site"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_edge_node(value: Any) -> bool:
|
|
42
|
+
"""A step/process node in the state half (its `_type` says so, or it carries an `address`)."""
|
|
43
|
+
return isinstance(value, dict) and (value.get("_type") in EDGE_KINDS or "address" in value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_EdgeTable = dict[tuple[str, ...], dict[str, Any]]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize_document(doc: dict[str, Any], core: Any, registry: InterfaceRegistry | None = None) -> dict[str, Any]:
|
|
50
|
+
"""
|
|
51
|
+
The canonical shape of a document (see the module docstring). `core` is a bigraph-schema core. With a
|
|
52
|
+
`registry`, an edge's `_inputs`/`_outputs` that merely repeat its interface's face are dropped (process-bigraph
|
|
53
|
+
merges them with `interface()`, so they carry no information).
|
|
54
|
+
"""
|
|
55
|
+
table: _EdgeTable = {}
|
|
56
|
+
raw_schema = doc.get("schema") or {}
|
|
57
|
+
schema = _norm_schema(raw_schema, core, (), table)
|
|
58
|
+
state = _norm_state(doc.get("state") or {}, raw_schema, core, (), table, registry)
|
|
59
|
+
options = {k: _canon(doc[k], core) for k in COMPOSITE_OPTION_KEYS if k in doc}
|
|
60
|
+
return {"schema": schema if isinstance(schema, dict) else {}, "state": state, "options": options}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def equivalent(a: dict[str, Any], b: dict[str, Any], core: Any, registry: InterfaceRegistry | None = None) -> bool:
|
|
64
|
+
return deep_equal(normalize_document(a, core, registry), normalize_document(b, core, registry))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def diff_documents(
|
|
68
|
+
a: dict[str, Any], b: dict[str, Any], core: Any, limit: int = 200, registry: InterfaceRegistry | None = None
|
|
69
|
+
) -> str:
|
|
70
|
+
"""A readable account of how two documents differ after normalization (empty when equivalent)."""
|
|
71
|
+
left, right = normalize_document(a, core, registry), normalize_document(b, core, registry)
|
|
72
|
+
flat_left, flat_right = _flatten(left), _flatten(right)
|
|
73
|
+
lines: list[str] = []
|
|
74
|
+
for key in sorted(set(flat_left) | set(flat_right)):
|
|
75
|
+
if key not in flat_right:
|
|
76
|
+
lines.append(f"- {key}: {_show(flat_left[key])}")
|
|
77
|
+
elif key not in flat_left:
|
|
78
|
+
lines.append(f"+ {key}: {_show(flat_right[key])}")
|
|
79
|
+
elif not deep_equal(flat_left[key], flat_right[key]):
|
|
80
|
+
lines.append(f"~ {key}: {_show(flat_left[key])} -> {_show(flat_right[key])}")
|
|
81
|
+
if len(lines) > limit:
|
|
82
|
+
lines = [*lines[:limit], f"... {len(lines) - limit} more"]
|
|
83
|
+
if not lines and not deep_equal(left, right):
|
|
84
|
+
dump_left = json.dumps(left, indent=2, sort_keys=True, default=str).splitlines()
|
|
85
|
+
dump_right = json.dumps(right, indent=2, sort_keys=True, default=str).splitlines()
|
|
86
|
+
lines = list(difflib.unified_diff(dump_left, dump_right, "a", "b", lineterm=""))
|
|
87
|
+
return "\n".join(lines)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def deep_equal(a: Any, b: Any, *, strict: bool = False, rel_tol: float = 1e-9) -> bool:
|
|
91
|
+
"""
|
|
92
|
+
Structural equality that tolerates numpy arrays and float noise.
|
|
93
|
+
|
|
94
|
+
With `strict=False` (the normalizer's mode) tuples equal lists and ints equal floats; with `strict=True`
|
|
95
|
+
container types and numeric types must match exactly (the v1 parity tests use this).
|
|
96
|
+
"""
|
|
97
|
+
if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
|
|
98
|
+
if strict and not (isinstance(a, np.ndarray) and isinstance(b, np.ndarray)):
|
|
99
|
+
return False
|
|
100
|
+
return deep_equal(_to_list(a), _to_list(b), strict=False, rel_tol=rel_tol)
|
|
101
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
102
|
+
return set(a) == set(b) and all(deep_equal(a[k], b[k], strict=strict, rel_tol=rel_tol) for k in a)
|
|
103
|
+
if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)):
|
|
104
|
+
if strict and type(a) is not type(b):
|
|
105
|
+
return False
|
|
106
|
+
return len(a) == len(b) and all(deep_equal(x, y, strict=strict, rel_tol=rel_tol) for x, y in zip(a, b))
|
|
107
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
108
|
+
return a is b or (isinstance(a, bool) and isinstance(b, bool) and a == b)
|
|
109
|
+
if _is_number(a) and _is_number(b):
|
|
110
|
+
if strict and (isinstance(a, float) != isinstance(b, float)):
|
|
111
|
+
return False
|
|
112
|
+
return math.isclose(float(a), float(b), rel_tol=rel_tol, abs_tol=0.0)
|
|
113
|
+
return bool(a == b)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --- schema half -------------------------------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _norm_schema(schema: Any, core: Any, path: tuple[str, ...], table: _EdgeTable) -> Any:
|
|
120
|
+
if isinstance(schema, dict) and schema.get("_type") in EDGE_KINDS:
|
|
121
|
+
table[path] = {k: schema[k] for k in ("_type", "_inputs", "_outputs") if k in schema}
|
|
122
|
+
return None
|
|
123
|
+
if isinstance(schema, dict) and "_type" not in schema:
|
|
124
|
+
out: dict[str, Any] = {}
|
|
125
|
+
for key in sorted(schema, key=str):
|
|
126
|
+
if not path and key == "global_time":
|
|
127
|
+
continue
|
|
128
|
+
value = _norm_schema(schema[key], core, (*path, str(key)), table)
|
|
129
|
+
if value is not None:
|
|
130
|
+
out[str(key)] = value
|
|
131
|
+
return out
|
|
132
|
+
return _canon_type(schema, core)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _canon_type(spec: Any, core: Any) -> Any:
|
|
136
|
+
"""
|
|
137
|
+
One canonical shape for every spelling of a type: `float{3.5}` and `{"_type": "float", "_default": 3.5}`,
|
|
138
|
+
`map[string,float]` and `map[float]`, `a:float|b:float` and `{"a": "float", "b": "float"}`,
|
|
139
|
+
`array[(2|3),T]` and `{"_type": "array", "_shape": [2, 3], "_data": T}`. The canonical form is built from
|
|
140
|
+
the parsed type tree, so unknown (custom) type names survive.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
expr = parse_type(spec)
|
|
144
|
+
except (TypeError, ValueError, KeyError, IndexError):
|
|
145
|
+
return _canon(spec, core)
|
|
146
|
+
return _canon_expr(expr, core)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _canon_expr(expr: TypeExpr, core: Any) -> Any:
|
|
150
|
+
if isinstance(expr, Named):
|
|
151
|
+
return _canon_named(expr, core)
|
|
152
|
+
if isinstance(expr, Struct):
|
|
153
|
+
return {k: _canon_expr(v, core) for k, v in sorted(expr.fields.items())}
|
|
154
|
+
if isinstance(expr, UnionType):
|
|
155
|
+
return {"_type": "union", "_options": [_canon_expr(o, core) for o in expr.options]}
|
|
156
|
+
if isinstance(expr, Group):
|
|
157
|
+
return [int(i) if str(i).lstrip("-").isdigit() else i for i in expr.items]
|
|
158
|
+
out: dict[str, Any] = {}
|
|
159
|
+
for key, value in sorted(expr.attrs.items(), key=lambda kv: str(kv[0])):
|
|
160
|
+
if key in ("_inputs", "_outputs"):
|
|
161
|
+
out[key] = _canon_ports(value, core)
|
|
162
|
+
elif key in ("_value", "_element", "_data", "_sort", "_key", "_leaf") and isinstance(value, (str, dict)):
|
|
163
|
+
out[key] = _canon_type(value, core)
|
|
164
|
+
else:
|
|
165
|
+
out[key] = _canon(value, core)
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _canon_named(expr: Named, core: Any) -> Any:
|
|
170
|
+
name = _TYPE_ALIASES.get(expr.name, expr.name)
|
|
171
|
+
groups = [a for a in expr.args if isinstance(a, Group)]
|
|
172
|
+
others = [a for a in expr.args if not isinstance(a, Group)]
|
|
173
|
+
if name == "map" and len(others) == 2 and isinstance(others[0], Named) and others[0].name == "string":
|
|
174
|
+
others = others[1:]
|
|
175
|
+
out: Any
|
|
176
|
+
numeric = [a for a in others if isinstance(a, Named) and a.name.lstrip("-").isdigit()]
|
|
177
|
+
if name == "array" or groups or expr.units is not None:
|
|
178
|
+
dims = _canon_expr(groups[0], core) if groups else [int(a.name) for a in numeric]
|
|
179
|
+
data = [a for a in others if a not in numeric]
|
|
180
|
+
out = {"_type": name, "_shape": dims or [1], "_data": _canon_expr(data[-1], core) if data else "float"}
|
|
181
|
+
if expr.units is not None:
|
|
182
|
+
out["_units"] = expr.units
|
|
183
|
+
elif others:
|
|
184
|
+
out = {"_type": name, "_args": [_canon_expr(a, core) for a in others]}
|
|
185
|
+
else:
|
|
186
|
+
out = name
|
|
187
|
+
if expr.default is not MISSING:
|
|
188
|
+
out = {"_type": out} if isinstance(out, str) else out
|
|
189
|
+
out["_default"] = _canon(expr.default, core)
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _canon_ports(ports: Any, core: Any) -> Any:
|
|
194
|
+
if isinstance(ports, str):
|
|
195
|
+
try:
|
|
196
|
+
parsed = parse_type(ports)
|
|
197
|
+
except (TypeError, ValueError, KeyError, IndexError):
|
|
198
|
+
return ports
|
|
199
|
+
ports = parsed.fields if isinstance(parsed, Struct) else {}
|
|
200
|
+
return {str(k): _canon_expr(v, core) for k, v in sorted(ports.items())}
|
|
201
|
+
if isinstance(ports, dict):
|
|
202
|
+
return {str(k): _canon_type(v, core) for k, v in sorted(ports.items(), key=lambda kv: str(kv[0]))}
|
|
203
|
+
return _canon(ports, core)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_TYPE_ALIASES = {"int": "integer", "bool": "boolean"}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --- state half --------------------------------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _norm_state(
|
|
213
|
+
state: Any,
|
|
214
|
+
schema: Any,
|
|
215
|
+
core: Any,
|
|
216
|
+
path: tuple[str, ...],
|
|
217
|
+
table: _EdgeTable,
|
|
218
|
+
registry: InterfaceRegistry | None = None,
|
|
219
|
+
) -> Any:
|
|
220
|
+
if is_site(state):
|
|
221
|
+
return _canon_site(state, core)
|
|
222
|
+
if isinstance(state, dict) and (is_edge_node(state) or path in table):
|
|
223
|
+
return _canon_edge(state, core, path, table, registry)
|
|
224
|
+
if isinstance(state, dict) and not (isinstance(schema, dict) and "_type" in schema):
|
|
225
|
+
out: dict[str, Any] = {}
|
|
226
|
+
sub_schema = schema if isinstance(schema, dict) else {}
|
|
227
|
+
keys = set(state) | {k for k in sub_schema if (*path, str(k)) not in table}
|
|
228
|
+
for key in sorted(keys, key=str):
|
|
229
|
+
if not path and key == "global_time":
|
|
230
|
+
continue
|
|
231
|
+
if key in state:
|
|
232
|
+
out[str(key)] = _norm_state(state[key], sub_schema.get(key), core, (*path, str(key)), table, registry)
|
|
233
|
+
else:
|
|
234
|
+
default = _schema_default(sub_schema[key], core)
|
|
235
|
+
if default is not None:
|
|
236
|
+
out[str(key)] = _norm_state(default, sub_schema[key], core, (*path, str(key)), table, registry)
|
|
237
|
+
return out
|
|
238
|
+
return _canon(state, core)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _schema_default(schema: Any, core: Any) -> Any:
|
|
242
|
+
"""What process-bigraph materializes for a schema entry that has no state (a saved document contains it)."""
|
|
243
|
+
if isinstance(schema, dict) and schema.get("_type") in EDGE_KINDS:
|
|
244
|
+
return None
|
|
245
|
+
try:
|
|
246
|
+
default = core.default(core.access(schema))
|
|
247
|
+
except Exception: # noqa: BLE001
|
|
248
|
+
return None
|
|
249
|
+
return default[1] if isinstance(default, tuple) and len(default) == 2 else default
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _canon_edge(
|
|
253
|
+
node: dict[str, Any],
|
|
254
|
+
core: Any,
|
|
255
|
+
path: tuple[str, ...],
|
|
256
|
+
table: _EdgeTable,
|
|
257
|
+
registry: InterfaceRegistry | None = None,
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
info = table.get(path, {})
|
|
260
|
+
out: dict[str, Any] = {"_type": node.get("_type") or info.get("_type")}
|
|
261
|
+
address = node.get("address")
|
|
262
|
+
if address is not None:
|
|
263
|
+
out["address"] = _canon_address(address, core)
|
|
264
|
+
decl = None
|
|
265
|
+
if registry is not None and address is not None and not is_site(address):
|
|
266
|
+
decl = registry.lookup(_canon_address(address, core))
|
|
267
|
+
if decl is not None and decl.address is not None:
|
|
268
|
+
out["address"] = decl.address # `local:Name` and `local:!module.Name` name the same component
|
|
269
|
+
for key, attr in (("_inputs", "inputs"), ("_outputs", "outputs")):
|
|
270
|
+
ports = node.get(key, info.get(key))
|
|
271
|
+
if ports is None:
|
|
272
|
+
continue
|
|
273
|
+
canon = _canon_ports(ports, core)
|
|
274
|
+
declared = getattr(decl, attr) if decl is not None else None
|
|
275
|
+
if declared is not None and canon == _canon_ports(declared, core):
|
|
276
|
+
continue # the node repeats the interface's face: no information
|
|
277
|
+
out[key] = canon
|
|
278
|
+
config = node.get("config")
|
|
279
|
+
if isinstance(config, dict) and _is_composite_address(address):
|
|
280
|
+
config = dict(config)
|
|
281
|
+
inner_table: _EdgeTable = {}
|
|
282
|
+
inner_schema = config.get("schema") or {}
|
|
283
|
+
config["schema"] = _norm_schema(inner_schema, core, (), inner_table) or {}
|
|
284
|
+
config["state"] = _norm_state(config.get("state") or {}, inner_schema, core, (), inner_table, registry)
|
|
285
|
+
if config:
|
|
286
|
+
out["config"] = _canon(config, core)
|
|
287
|
+
for key in ("inputs", "outputs", "_triggers"):
|
|
288
|
+
if node.get(key):
|
|
289
|
+
out[key] = _canon_wires(node[key])
|
|
290
|
+
# a process without an interval runs every 1.0: the default is not a difference
|
|
291
|
+
if (
|
|
292
|
+
node.get("interval") is not None
|
|
293
|
+
and not _is_number(node["interval"])
|
|
294
|
+
or (_is_number(node.get("interval")) and not math.isclose(float(node["interval"]), 1.0))
|
|
295
|
+
):
|
|
296
|
+
out["interval"] = _canon(node["interval"], core)
|
|
297
|
+
if node.get("priority") is not None:
|
|
298
|
+
out["priority"] = _canon(node["priority"], core)
|
|
299
|
+
for key, value in node.items():
|
|
300
|
+
if key in out or key in RUNTIME_EDGE_KEYS or key in ("_type", "address", "config", "inputs", "outputs"):
|
|
301
|
+
continue
|
|
302
|
+
if key in ("_inputs", "_outputs", "_triggers", "interval", "priority"):
|
|
303
|
+
continue
|
|
304
|
+
out[str(key)] = _canon(value, core)
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _is_composite_address(address: Any) -> bool:
|
|
309
|
+
text = _canon_address(address, None) if address is not None else ""
|
|
310
|
+
return isinstance(text, str) and text.split(":", 1)[-1].lstrip("!").rsplit(".", 1)[-1] == "Composite"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _canon_address(address: Any, core: Any) -> Any:
|
|
314
|
+
if is_site(address):
|
|
315
|
+
return _canon_site(address, core)
|
|
316
|
+
if isinstance(address, dict) and "protocol" in address:
|
|
317
|
+
return f"{address['protocol']}:{address.get('data', '')}"
|
|
318
|
+
if isinstance(address, str) and ":" not in address:
|
|
319
|
+
return f"local:{address}"
|
|
320
|
+
return address
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _canon_site(site: dict[str, Any], core: Any) -> dict[str, Any]:
|
|
324
|
+
out: dict[str, Any] = {"_type": "site"}
|
|
325
|
+
if "_sort" in site:
|
|
326
|
+
sort = site["_sort"]
|
|
327
|
+
out["_sort"] = _canon_type(sort, core) if core is not None else _canon(sort, core)
|
|
328
|
+
if "_default" in site:
|
|
329
|
+
out["_default"] = _canon(site["_default"], core)
|
|
330
|
+
return out
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _canon_wires(wires: Any) -> Any:
|
|
334
|
+
if isinstance(wires, dict):
|
|
335
|
+
return {str(k): _canon_wires(v) for k, v in sorted(wires.items(), key=lambda kv: str(kv[0]))}
|
|
336
|
+
if isinstance(wires, str):
|
|
337
|
+
return [wires]
|
|
338
|
+
if isinstance(wires, (list, tuple)):
|
|
339
|
+
return [int(s) if isinstance(s, (int, np.integer)) and not isinstance(s, bool) else str(s) for s in wires]
|
|
340
|
+
return wires
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# --- values ------------------------------------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _canon(value: Any, core: Any) -> Any:
|
|
347
|
+
if is_site(value):
|
|
348
|
+
return _canon_site(value, core)
|
|
349
|
+
if isinstance(value, dict):
|
|
350
|
+
return {str(k): _canon(v, core) for k, v in sorted(value.items(), key=lambda kv: str(kv[0]))}
|
|
351
|
+
if isinstance(value, (list, tuple)):
|
|
352
|
+
return [_canon(v, core) for v in value]
|
|
353
|
+
if isinstance(value, np.ndarray):
|
|
354
|
+
return _canon(value.tolist(), core)
|
|
355
|
+
if isinstance(value, np.generic):
|
|
356
|
+
return value.item()
|
|
357
|
+
return value
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _to_list(value: Any) -> Any:
|
|
361
|
+
return value.tolist() if isinstance(value, np.ndarray) else value
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _is_number(value: Any) -> bool:
|
|
365
|
+
return isinstance(value, (int, float, np.integer, np.floating)) and not isinstance(value, bool)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _flatten(tree: Any, prefix: str = "") -> dict[str, Any]:
|
|
369
|
+
if isinstance(tree, dict) and tree:
|
|
370
|
+
out: dict[str, Any] = {}
|
|
371
|
+
for key, value in tree.items():
|
|
372
|
+
out.update(_flatten(value, f"{prefix}/{key}" if prefix else str(key)))
|
|
373
|
+
return out
|
|
374
|
+
if isinstance(tree, list) and tree and any(isinstance(v, (dict, list)) for v in tree):
|
|
375
|
+
out = {}
|
|
376
|
+
for index, value in enumerate(tree):
|
|
377
|
+
out.update(_flatten(value, f"{prefix}[{index}]"))
|
|
378
|
+
return out
|
|
379
|
+
return {prefix: tree}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _show(value: Any) -> str:
|
|
383
|
+
text = json.dumps(value, default=str)
|
|
384
|
+
return text if len(text) <= 120 else text[:117] + "..."
|