bytesim 0.1.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.
- bytesim/__init__.py +14 -0
- bytesim/__main__.py +3 -0
- bytesim/cli.py +45 -0
- bytesim/constants.py +48 -0
- bytesim/demos.py +74 -0
- bytesim/model/__init__.py +5 -0
- bytesim/model/addressing.py +160 -0
- bytesim/model/base.py +57 -0
- bytesim/model/cloning.py +249 -0
- bytesim/model/equivalence.py +86 -0
- bytesim/model/groups.py +133 -0
- bytesim/model/ha.py +93 -0
- bytesim/model/layer2.py +106 -0
- bytesim/model/layout.py +203 -0
- bytesim/model/naming.py +114 -0
- bytesim/model/network.py +138 -0
- bytesim/model/routing.py +117 -0
- bytesim/model/stp.py +315 -0
- bytesim/model/types.py +107 -0
- bytesim/py.typed +0 -0
- bytesim/scripting.py +108 -0
- bytesim/synthesis.py +644 -0
- bytesim/topology.py +517 -0
- bytesim/tui/__init__.py +4 -0
- bytesim/tui/app.py +161 -0
- bytesim/tui/cables.py +58 -0
- bytesim/tui/canvas.py +489 -0
- bytesim/tui/command_input.py +29 -0
- bytesim/tui/commands/__init__.py +1 -0
- bytesim/tui/commands/addressing.py +235 -0
- bytesim/tui/commands/base.py +43 -0
- bytesim/tui/commands/groups.py +215 -0
- bytesim/tui/commands/history.py +134 -0
- bytesim/tui/commands/interpreter.py +85 -0
- bytesim/tui/commands/naming.py +68 -0
- bytesim/tui/commands/registry.py +28 -0
- bytesim/tui/commands/scripting.py +54 -0
- bytesim/tui/commands/switching.py +192 -0
- bytesim/tui/commands/topology.py +255 -0
- bytesim/tui/commands/traffic.py +312 -0
- bytesim/tui/help.py +50 -0
- bytesim/tui/inspector.py +115 -0
- bytesim/tui/theme.py +12 -0
- bytesim-0.1.0.dist-info/METADATA +178 -0
- bytesim-0.1.0.dist-info/RECORD +48 -0
- bytesim-0.1.0.dist-info/WHEEL +4 -0
- bytesim-0.1.0.dist-info/entry_points.txt +2 -0
- bytesim-0.1.0.dist-info/licenses/LICENSE +21 -0
bytesim/topology.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""The declarative topology format (``bytesim/topology-v1``): load and export.
|
|
2
|
+
|
|
3
|
+
A topology file describes the *architecture*: devices, groups and cables,
|
|
4
|
+
optionally built from reusable templates and link macros. It leaves out
|
|
5
|
+
runtime state: positions (auto-arranged), addresses (re-learned), failures and
|
|
6
|
+
storms. Ports are allocated in file order unless pinned (``r1:3``).
|
|
7
|
+
|
|
8
|
+
schema: bytesim/topology-v1
|
|
9
|
+
defaults: {links: data, stp: on, interfaces: {computer: dhcp}}
|
|
10
|
+
templates:
|
|
11
|
+
site:
|
|
12
|
+
devices: # roles
|
|
13
|
+
routers: {kind: router, count: 2, ports: 8}
|
|
14
|
+
access: {kind: switch, count: 2, ports: 5}
|
|
15
|
+
groups: # local names; a list = members, a mapping = subgroups
|
|
16
|
+
core: routers
|
|
17
|
+
office: {room1: ["access[0]"], room2: ["access[1]"]}
|
|
18
|
+
links:
|
|
19
|
+
- mesh: [routers, access]
|
|
20
|
+
instances:
|
|
21
|
+
site1:
|
|
22
|
+
template: site
|
|
23
|
+
group: site1 # wrap the instance in a group (default: its own name)
|
|
24
|
+
devices: {routers: [r1, r2], access: [s1, s2]} # optional: fixed ids
|
|
25
|
+
groups: {core: rfab1, office.room1: room1} # optional: names by key or dotted path
|
|
26
|
+
devices: {s8: 5, s12: {ports: 5, stp: off}} # stand-alone devices: id → ports | spec
|
|
27
|
+
groups: {link1: [s8, s12]}
|
|
28
|
+
links:
|
|
29
|
+
- parallel: [s8, s12, 2]
|
|
30
|
+
- {connect: ["r1:5", "r2:5"], role: control}
|
|
31
|
+
|
|
32
|
+
Link macros: ``connect: [a, b]``, ``mesh: [A, B]`` (every pairing),
|
|
33
|
+
``zip: [A, B]`` (pairwise), ``parallel: [a, b, n]``, ``chain: [a, b, c…]`` and
|
|
34
|
+
``ring: [a, b, c…]``. A name can be a device id (optionally ``:port``), a role
|
|
35
|
+
or ``role[i]`` inside a template, a group (all devices in it), or a list.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import re
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
import yaml
|
|
43
|
+
|
|
44
|
+
from .constants import ALIASES, AUTO, GROUP_NAME, KINDS, POLICIES, RANK, REF, ROLES
|
|
45
|
+
from .model import NetError, Network
|
|
46
|
+
from .model.types import Device, Iface
|
|
47
|
+
|
|
48
|
+
SCHEMA = "bytesim/topology-v1"
|
|
49
|
+
GROUP_KEYS = {"members", "cluster"}
|
|
50
|
+
INDEXED = re.compile(r"^([A-Za-z_][\w-]*)\[(\d+)\](?::(?:p|nic|eth)?(\d+))?$")
|
|
51
|
+
KIND_NAME = {name: k for k, (name, _, _) in KINDS.items()}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def looks_like_topology(text: str) -> bool:
|
|
55
|
+
"""True for a topology file (YAML, or JSON with a schema key), False for a save file."""
|
|
56
|
+
head = text.lstrip()[:400]
|
|
57
|
+
return "bytesim/topology" in head or (not head.startswith("{") and "counters" not in head)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------- loading
|
|
61
|
+
class _Builder:
|
|
62
|
+
def __init__(self, spec: dict) -> None:
|
|
63
|
+
self.spec = spec
|
|
64
|
+
self.net = Network()
|
|
65
|
+
d = spec.get("defaults") or {}
|
|
66
|
+
self.default_role = str(d.get("links", "data"))
|
|
67
|
+
self.default_stp = _onoff(d.get("stp", "on"), "defaults.stp")
|
|
68
|
+
self.default_ifaces = {_kind(k, "defaults.interfaces"): str(v)
|
|
69
|
+
for k, v in (d.get("interfaces") or {}).items()}
|
|
70
|
+
if self.default_role not in ROLES:
|
|
71
|
+
raise NetError(f"defaults.links: role must be one of {', '.join(ROLES)}")
|
|
72
|
+
|
|
73
|
+
# ---- devices ---------------------------------------------------------
|
|
74
|
+
def _declare(self, did: str, kind: str, spec: Any, where: str) -> Device:
|
|
75
|
+
m = REF.match(did)
|
|
76
|
+
if not m or m[3]:
|
|
77
|
+
raise NetError(f"{where}: {did!r} isn't a device id like r1 or s12")
|
|
78
|
+
if m[1].lower() != kind:
|
|
79
|
+
raise NetError(f"{where}: {did} is named like a {KINDS[m[1].lower()][0]} but declared a {KINDS[kind][0]}")
|
|
80
|
+
did = did.lower()
|
|
81
|
+
if did in self.net.devices:
|
|
82
|
+
raise NetError(f"{where}: {did} is declared twice")
|
|
83
|
+
spec = {"ports": spec} if isinstance(spec, int) else dict(spec or {})
|
|
84
|
+
dev = Device(kind, int(m[2]), int(spec.get("ports", KINDS[kind][1])))
|
|
85
|
+
if not 1 <= dev.nports <= 64:
|
|
86
|
+
raise NetError(f"{where}: {did} ports must be 1..64")
|
|
87
|
+
self.net.devices[did] = dev
|
|
88
|
+
self.net.counters[kind] = max(self.net.counters[kind], dev.num)
|
|
89
|
+
self._settings(dev, spec, where)
|
|
90
|
+
return dev
|
|
91
|
+
|
|
92
|
+
def _auto(self, kind: str, spec: dict, where: str) -> Device:
|
|
93
|
+
"""The lowest free number of this kind, skipping every id the file fixes."""
|
|
94
|
+
n = 1
|
|
95
|
+
while f"{kind}{n}" in self.net.devices or f"{kind}{n}" in self.reserved:
|
|
96
|
+
n += 1
|
|
97
|
+
return self._declare(f"{kind}{n}", kind, spec, where)
|
|
98
|
+
|
|
99
|
+
def _settings(self, dev: Device, spec: dict, where: str) -> None:
|
|
100
|
+
net = self.net
|
|
101
|
+
if dev.kind == "s" and not _onoff(spec.get("stp", self.default_stp), f"{where}.stp"):
|
|
102
|
+
net.stp_off.add(dev.id)
|
|
103
|
+
if "priority" in spec:
|
|
104
|
+
net.stp_priority[dev.id] = int(spec["priority"])
|
|
105
|
+
if dev.kind in "rg" and not _onoff(spec.get("dhcp", True), f"{where}.dhcp"):
|
|
106
|
+
net.dhcp_off.add(dev.id)
|
|
107
|
+
mode = spec.get("interfaces", self.default_ifaces.get(dev.kind))
|
|
108
|
+
if mode is not None:
|
|
109
|
+
if mode not in ("dhcp", "adhoc", "none", "auto"):
|
|
110
|
+
raise NetError(f"{where}.interfaces: use dhcp, adhoc, none or auto")
|
|
111
|
+
for p in range(1, dev.nports + 1):
|
|
112
|
+
net.ifaces[f"{dev.id}:{p}"] = Iface(mode)
|
|
113
|
+
|
|
114
|
+
# ---- names → devices ---------------------------------------------------
|
|
115
|
+
def _resolve(self, ref: Any, roles: dict[str, list[str]], where: str) -> list[tuple[str, int | None]]:
|
|
116
|
+
if isinstance(ref, list):
|
|
117
|
+
return [d for r in ref for d in self._resolve(r, roles, where)]
|
|
118
|
+
ref = str(ref)
|
|
119
|
+
if (m := INDEXED.match(ref)):
|
|
120
|
+
role, i, port = m[1], int(m[2]), m[3]
|
|
121
|
+
if role not in roles:
|
|
122
|
+
raise NetError(f"{where}: no role {role!r} here")
|
|
123
|
+
if i >= len(roles[role]):
|
|
124
|
+
raise NetError(f"{where}: {role} has {len(roles[role])} devices, so {ref} doesn't exist")
|
|
125
|
+
return [(roles[role][i], int(port) if port else None)]
|
|
126
|
+
if ref in roles:
|
|
127
|
+
return [(d, None) for d in roles[ref]]
|
|
128
|
+
if (m := REF.match(ref)):
|
|
129
|
+
did = f"{m[1].lower()}{int(m[2])}"
|
|
130
|
+
if did not in self.net.devices:
|
|
131
|
+
raise NetError(f"{where}: no device {did}")
|
|
132
|
+
return [(did, int(m[3]) if m[3] else None)]
|
|
133
|
+
if ref.lower() in self.net.groups:
|
|
134
|
+
return [(d, None) for d in self.net.devices_in(ref.lower())]
|
|
135
|
+
raise NetError(f"{where}: {ref!r} is not a device, role or group")
|
|
136
|
+
|
|
137
|
+
def _links(self, items: list, roles: dict[str, list[str]], where: str) -> None:
|
|
138
|
+
for n, item in enumerate(items or []):
|
|
139
|
+
here = f"{where}[{n}]"
|
|
140
|
+
if not isinstance(item, dict):
|
|
141
|
+
raise NetError(f"{here}: expected e.g. {{mesh: [a, b]}}")
|
|
142
|
+
role = str(item.get("role", self.default_role))
|
|
143
|
+
if role not in ROLES:
|
|
144
|
+
raise NetError(f"{here}: role must be one of {', '.join(ROLES)}")
|
|
145
|
+
ops = [k for k in item if k != "role"]
|
|
146
|
+
if len(ops) != 1:
|
|
147
|
+
raise NetError(f"{here}: one of connect, mesh, zip, parallel, chain, ring")
|
|
148
|
+
op, args = ops[0], item[ops[0]]
|
|
149
|
+
pairs: list[tuple[tuple[str, int | None], tuple[str, int | None]]] = []
|
|
150
|
+
if op in ("connect", "mesh", "zip"):
|
|
151
|
+
if not (isinstance(args, list) and len(args) == 2):
|
|
152
|
+
raise NetError(f"{here}: {op} takes two names")
|
|
153
|
+
a, b = (self._resolve(x, roles, here) for x in args)
|
|
154
|
+
if op == "connect":
|
|
155
|
+
if len(a) != 1 or len(b) != 1:
|
|
156
|
+
raise NetError(f"{here}: connect joins single devices (use mesh or zip for sets)")
|
|
157
|
+
pairs = [(a[0], b[0])]
|
|
158
|
+
elif op == "mesh":
|
|
159
|
+
pairs = [(x, y) for x in a for y in b]
|
|
160
|
+
else:
|
|
161
|
+
if len(a) != len(b):
|
|
162
|
+
raise NetError(f"{here}: zip needs equal sizes ({len(a)} vs {len(b)})")
|
|
163
|
+
pairs = list(zip(a, b))
|
|
164
|
+
elif op == "parallel":
|
|
165
|
+
if not (isinstance(args, list) and len(args) == 3 and isinstance(args[2], int)):
|
|
166
|
+
raise NetError(f"{here}: parallel takes [a, b, count]")
|
|
167
|
+
a, b = (self._resolve(x, roles, here) for x in args[:2])
|
|
168
|
+
if len(a) != 1 or len(b) != 1:
|
|
169
|
+
raise NetError(f"{here}: parallel joins two single devices")
|
|
170
|
+
pairs = [(a[0], b[0])] * args[2]
|
|
171
|
+
elif op in ("chain", "ring"):
|
|
172
|
+
seq = self._resolve(args, roles, here)
|
|
173
|
+
pairs = list(zip(seq, seq[1:])) + ([(seq[-1], seq[0])] if op == "ring" and len(seq) > 2 else [])
|
|
174
|
+
else:
|
|
175
|
+
raise NetError(f"{here}: unknown macro {op!r}")
|
|
176
|
+
for (da, pa), (db, pb) in pairs:
|
|
177
|
+
try:
|
|
178
|
+
self.net.connect(da, pa, db, pb, role)
|
|
179
|
+
except NetError as e:
|
|
180
|
+
raise NetError(f"{here}: {e}") from None
|
|
181
|
+
|
|
182
|
+
# ---- groups --------------------------------------------------------------
|
|
183
|
+
def _groups(self, tree: dict, roles: dict[str, list[str]], names: dict[str, str] | None,
|
|
184
|
+
path: str, where: str) -> list[str]:
|
|
185
|
+
"""Create a group tree; returns the top-level group names in order.
|
|
186
|
+
|
|
187
|
+
``names`` maps template paths to chosen names; unmapped template groups
|
|
188
|
+
take their key if it's free, else the next free variant (core → core-2).
|
|
189
|
+
Stand-alone groups (``names`` is None) are named exactly by their key."""
|
|
190
|
+
made = []
|
|
191
|
+
for key, val in (tree or {}).items():
|
|
192
|
+
local = f"{path}.{key}" if path else str(key)
|
|
193
|
+
key = str(key).lower()
|
|
194
|
+
claimed = frozenset(self.reserved_groups)
|
|
195
|
+
try:
|
|
196
|
+
if names is None:
|
|
197
|
+
name = self.net.resolve_group_name(key, claimed)
|
|
198
|
+
elif local in names:
|
|
199
|
+
name = self.net.resolve_group_name(names[local], claimed)
|
|
200
|
+
elif key not in self.net.groups and key not in self.net.devices and key not in claimed:
|
|
201
|
+
name = key
|
|
202
|
+
else: # a template's own name, taken: use a free variant
|
|
203
|
+
name = self.net.next_group_name(key, claimed)
|
|
204
|
+
except NetError as e:
|
|
205
|
+
raise NetError(f"{where}.{local}: {e}") from None
|
|
206
|
+
if not GROUP_NAME.match(name) or REF.match(name):
|
|
207
|
+
raise NetError(f"{where}.{local}: bad group name {name!r} (start with a letter, "
|
|
208
|
+
f"at most 16 characters, not a device id)")
|
|
209
|
+
if name in self.net.groups:
|
|
210
|
+
raise NetError(f"{where}: group {name} is defined twice")
|
|
211
|
+
members: list[str] = []
|
|
212
|
+
subs: dict = {}
|
|
213
|
+
cluster = None
|
|
214
|
+
if isinstance(val, (str, list)):
|
|
215
|
+
members = [d for d, _ in self._resolve(val, roles, f"{where}.{local}")]
|
|
216
|
+
elif isinstance(val, dict):
|
|
217
|
+
members = [d for d, _ in self._resolve(val.get("members", []), roles, f"{where}.{local}")]
|
|
218
|
+
cluster = val.get("cluster")
|
|
219
|
+
subs = {k: v for k, v in val.items() if k not in GROUP_KEYS}
|
|
220
|
+
else:
|
|
221
|
+
raise NetError(f"{where}.{local}: a group is a list of members or a mapping of subgroups")
|
|
222
|
+
for d in members: # membership is a tree
|
|
223
|
+
if (owner := self.net.group_of(d)) is not None or members.count(d) > 1:
|
|
224
|
+
raise NetError(f"{where}.{local}: {d} is already in group {owner or name}"
|
|
225
|
+
f" (a device belongs to one group)")
|
|
226
|
+
self.net.groups[name] = [] # reserve before the children
|
|
227
|
+
children = self._groups(subs, roles, names, local, where)
|
|
228
|
+
self.net.groups[name] = children + members
|
|
229
|
+
if not self.net.groups[name]:
|
|
230
|
+
raise NetError(f"{where}.{local}: group {name} is empty")
|
|
231
|
+
if cluster is not None:
|
|
232
|
+
cluster = "none" if cluster is True else str(cluster)
|
|
233
|
+
if cluster not in POLICIES:
|
|
234
|
+
raise NetError(f"{where}.{local}.cluster: one of {', '.join(POLICIES)} (or true)")
|
|
235
|
+
self.net.clusters[name] = cluster
|
|
236
|
+
made.append(name)
|
|
237
|
+
return made
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def _explicit_group_names(spec: dict, instances: dict) -> set[str]:
|
|
241
|
+
"""Group names fixed in the file: instance names for template groups, wrapper
|
|
242
|
+
groups, and every stand-alone group (nested ones included)."""
|
|
243
|
+
out: set[str] = set()
|
|
244
|
+
|
|
245
|
+
def fixed(name: Any) -> None:
|
|
246
|
+
if name and not AUTO.match(str(name)):
|
|
247
|
+
out.add(str(name).lower())
|
|
248
|
+
|
|
249
|
+
for iname, inst in instances.items():
|
|
250
|
+
inst = inst or {}
|
|
251
|
+
fixed(inst.get("group", iname))
|
|
252
|
+
for new in (inst.get("groups") or {}).values():
|
|
253
|
+
fixed(new)
|
|
254
|
+
|
|
255
|
+
def walk(tree: Any) -> None:
|
|
256
|
+
for key, val in (tree or {}).items():
|
|
257
|
+
fixed(key)
|
|
258
|
+
if isinstance(val, dict):
|
|
259
|
+
walk({k: v for k, v in val.items() if k not in GROUP_KEYS})
|
|
260
|
+
|
|
261
|
+
walk(spec.get("groups"))
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _expand_ids(ids: Any, kind: str, where: str) -> list[str | None] | None:
|
|
266
|
+
"""Fixed ids, with auto-number patterns (s(2)) expanded to placeholders."""
|
|
267
|
+
if ids is None:
|
|
268
|
+
return None
|
|
269
|
+
if not isinstance(ids, list):
|
|
270
|
+
raise NetError(f"{where}: a list of ids, e.g. [{kind}1, {kind}2] or [{kind}(2)]")
|
|
271
|
+
out: list[str | None] = []
|
|
272
|
+
for d in ids:
|
|
273
|
+
m = AUTO.match(str(d))
|
|
274
|
+
if not m:
|
|
275
|
+
out.append(str(d))
|
|
276
|
+
continue
|
|
277
|
+
if m[1].lower() != kind:
|
|
278
|
+
raise NetError(f"{where}: this role holds {KINDS[kind][0]}s, so write {kind}({m[2]}), not {d}")
|
|
279
|
+
out += [None] * int(m[2])
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _group_paths(tree: dict, path: str = "") -> list[str]:
|
|
284
|
+
out = []
|
|
285
|
+
for key, val in (tree or {}).items():
|
|
286
|
+
local = f"{path}.{key}" if path else str(key)
|
|
287
|
+
out.append(local)
|
|
288
|
+
if isinstance(val, dict):
|
|
289
|
+
out += _Builder._group_paths({k: v for k, v in val.items() if k not in GROUP_KEYS}, local)
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
# ---- the whole file ------------------------------------------------------
|
|
293
|
+
def build(self) -> Network:
|
|
294
|
+
spec, net = self.spec, self.net
|
|
295
|
+
schema = spec.get("schema", SCHEMA)
|
|
296
|
+
if not str(schema).startswith("bytesim/topology"):
|
|
297
|
+
raise NetError(f"schema {schema!r} isn't a ByteSim topology")
|
|
298
|
+
templates = spec.get("templates") or {}
|
|
299
|
+
instances = spec.get("instances") or {}
|
|
300
|
+
if "sites" in spec:
|
|
301
|
+
raise NetError("use `instances:` for template instances (see docs/topology-format.md)")
|
|
302
|
+
|
|
303
|
+
# Pass 1: every name the file fixes explicitly is reserved before anything is
|
|
304
|
+
# allocated, so auto-numbering (s(2), fab(1)) never depends on what comes later.
|
|
305
|
+
self.reserved = {str(d).lower() for d in (spec.get("devices") or {})}
|
|
306
|
+
self.reserved_groups = self._explicit_group_names(spec, instances)
|
|
307
|
+
for inst in instances.values():
|
|
308
|
+
for ids in ((inst or {}).get("devices") or {}).values():
|
|
309
|
+
self.reserved |= {str(d).lower() for d in ids if not AUTO.match(str(d))}
|
|
310
|
+
|
|
311
|
+
for did, dspec in (spec.get("devices") or {}).items():
|
|
312
|
+
m = REF.match(str(did))
|
|
313
|
+
if not m:
|
|
314
|
+
raise NetError(f"devices: {did!r} isn't a device id like r1 or s12")
|
|
315
|
+
self._declare(str(did), m[1].lower(), dspec, f"devices.{did}")
|
|
316
|
+
|
|
317
|
+
built: list[tuple[str, dict, dict, dict[str, list[str]]]] = []
|
|
318
|
+
for iname, inst in instances.items():
|
|
319
|
+
where = f"instances.{iname}"
|
|
320
|
+
inst = inst or {}
|
|
321
|
+
tname = inst.get("template")
|
|
322
|
+
if tname not in templates:
|
|
323
|
+
raise NetError(f"{where}: unknown template {tname!r}")
|
|
324
|
+
tmpl = templates[tname] or {}
|
|
325
|
+
fixed = inst.get("devices") or {}
|
|
326
|
+
roles: dict[str, list[str]] = {}
|
|
327
|
+
for role, rspec in (tmpl.get("devices") or {}).items():
|
|
328
|
+
rspec = dict(rspec or {})
|
|
329
|
+
kind = _kind(rspec.get("kind"), f"templates.{tname}.devices.{role}.kind")
|
|
330
|
+
count = int(rspec.get("count", 1))
|
|
331
|
+
here = f"{where}.devices.{role}"
|
|
332
|
+
ids = self._expand_ids(fixed.get(role), kind, here)
|
|
333
|
+
if ids is not None and len(ids) != count:
|
|
334
|
+
raise NetError(f"{here}: the template has {count}, you gave {len(ids)}")
|
|
335
|
+
roles[role] = [self._declare(d, kind, rspec, here).id if d else self._auto(kind, rspec, here).id
|
|
336
|
+
for d in (ids or [None] * count)]
|
|
337
|
+
unknown = set(fixed) - set(roles)
|
|
338
|
+
if unknown:
|
|
339
|
+
raise NetError(f"{where}.devices: the template has no role {sorted(unknown)[0]!r}")
|
|
340
|
+
built.append((iname, tmpl, inst, roles))
|
|
341
|
+
|
|
342
|
+
# Groups: instances first (each optionally wrapped), then stand-alone ones.
|
|
343
|
+
for iname, tmpl, inst, roles in built:
|
|
344
|
+
where = f"instances.{iname}"
|
|
345
|
+
paths = self._group_paths(tmpl.get("groups") or {})
|
|
346
|
+
names = {}
|
|
347
|
+
for key, new in (inst.get("groups") or {}).items():
|
|
348
|
+
hits = [p for p in paths if p == key or p.split(".")[-1] == key]
|
|
349
|
+
if len(hits) != 1:
|
|
350
|
+
raise NetError(f"{where}.groups: {key!r} " + ("matches no group in the template" if not hits
|
|
351
|
+
else f"is ambiguous, say which: {', '.join(hits)}"))
|
|
352
|
+
names[hits[0]] = str(new).lower()
|
|
353
|
+
tops = self._groups(tmpl.get("groups") or {}, roles, names, "", where)
|
|
354
|
+
wrap = inst.get("group", iname)
|
|
355
|
+
if wrap:
|
|
356
|
+
try:
|
|
357
|
+
wrap = net.resolve_group_name(str(wrap), frozenset(self.reserved_groups))
|
|
358
|
+
except NetError as e:
|
|
359
|
+
raise NetError(f"{where}.group: {e}") from None
|
|
360
|
+
loose = [d for ids in roles.values() for d in ids if net.group_of(d) is None]
|
|
361
|
+
net.group_add(wrap, tops + loose)
|
|
362
|
+
self._groups(spec.get("groups") or {}, {}, None, "", "groups")
|
|
363
|
+
|
|
364
|
+
# Cables, in file order: every instance's template links, then the stand-alone ones.
|
|
365
|
+
for iname, tmpl, inst, roles in built:
|
|
366
|
+
self._links(tmpl.get("links"), roles, f"instances.{iname}.links")
|
|
367
|
+
self._links(spec.get("links"), {}, "links")
|
|
368
|
+
return net
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def load_topology(text: str) -> Network:
|
|
372
|
+
"""Build a Network from topology YAML (or JSON). Raises NetError with the place it broke."""
|
|
373
|
+
try:
|
|
374
|
+
spec = yaml.safe_load(text)
|
|
375
|
+
except yaml.YAMLError as e:
|
|
376
|
+
raise NetError(f"not valid YAML: {e}") from None
|
|
377
|
+
if not isinstance(spec, dict):
|
|
378
|
+
raise NetError("a topology file is a mapping (schema, devices, groups, links…)")
|
|
379
|
+
return _Builder(spec).build()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _kind(word: Any, where: str) -> str:
|
|
383
|
+
w = str(word).lower()
|
|
384
|
+
if w in KIND_NAME:
|
|
385
|
+
return KIND_NAME[w]
|
|
386
|
+
if w in ALIASES:
|
|
387
|
+
return ALIASES[w]
|
|
388
|
+
raise NetError(f"{where}: kind must be gateway, router, switch or computer")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _onoff(v: Any, where: str) -> bool:
|
|
392
|
+
if isinstance(v, bool):
|
|
393
|
+
return v
|
|
394
|
+
if str(v).lower() in ("on", "enabled", "yes", "true"):
|
|
395
|
+
return True
|
|
396
|
+
if str(v).lower() in ("off", "disabled", "no", "false"):
|
|
397
|
+
return False
|
|
398
|
+
raise NetError(f"{where}: use on or off")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------- export
|
|
402
|
+
def dump_topology(net: Network, pin_ports: bool = False) -> str:
|
|
403
|
+
"""Write a network as a topology file (no templates: those can't be inferred).
|
|
404
|
+
|
|
405
|
+
Compact by default: meshes between groups and parallel cables are folded
|
|
406
|
+
into macros, and ports are left to be allocated on load. ``pin_ports``
|
|
407
|
+
writes every cable as an explicit ``connect`` with its ports, so loading it
|
|
408
|
+
rebuilds identical cabling (and so identical MACs on each end)."""
|
|
409
|
+
out = [f"schema: {SCHEMA}",
|
|
410
|
+
"# Exported by ByteSim. Positions, addresses, failures and storms are not included.",
|
|
411
|
+
""]
|
|
412
|
+
stp_default = len(net.stp_off) <= sum(1 for d in net.devices.values() if d.kind == "s") / 2
|
|
413
|
+
out.append(f"defaults: {{links: data, stp: {'on' if stp_default else 'off'}}}")
|
|
414
|
+
out.append("")
|
|
415
|
+
|
|
416
|
+
out.append("devices:")
|
|
417
|
+
for kind in KINDS:
|
|
418
|
+
devs = [d for d in net.sorted_devices() if d.kind == kind]
|
|
419
|
+
if not devs:
|
|
420
|
+
continue
|
|
421
|
+
name = KINDS[kind][0]
|
|
422
|
+
out.append(f" # {name}{'es' if name.endswith('h') else 's'}")
|
|
423
|
+
for d in devs:
|
|
424
|
+
extra = {}
|
|
425
|
+
if d.kind == "s" and (d.id in net.stp_off) == stp_default:
|
|
426
|
+
extra["stp"] = "off" if d.id in net.stp_off else "on"
|
|
427
|
+
if d.id in net.stp_priority:
|
|
428
|
+
extra["priority"] = net.stp_priority[d.id]
|
|
429
|
+
if d.id in net.dhcp_off:
|
|
430
|
+
extra["dhcp"] = "off"
|
|
431
|
+
modes = {i.mode for p in range(1, d.nports + 1) if (i := net.ifaces.get(f"{d.id}:{p}"))}
|
|
432
|
+
if d.kind == "c" and modes and modes != {"dhcp"} and len(modes) == 1 and "static" not in modes:
|
|
433
|
+
extra["interfaces"] = modes.pop()
|
|
434
|
+
if extra:
|
|
435
|
+
inner = ", ".join([f"ports: {d.nports}"] + [f"{k}: {v}" for k, v in extra.items()])
|
|
436
|
+
out.append(f" {d.id}: {{{inner}}}")
|
|
437
|
+
else:
|
|
438
|
+
out.append(f" {d.id}: {d.nports}")
|
|
439
|
+
|
|
440
|
+
if net.groups:
|
|
441
|
+
out += ["", "groups:"]
|
|
442
|
+
for g in net.groups:
|
|
443
|
+
if net.parent_of(g) is None:
|
|
444
|
+
out += _dump_group(net, g, 1)
|
|
445
|
+
|
|
446
|
+
out += ["", "links:"]
|
|
447
|
+
out += _dump_links(net, pin_ports)
|
|
448
|
+
return "\n".join(out) + "\n"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _dump_group(net: Network, g: str, depth: int) -> list[str]:
|
|
452
|
+
pad = " " * depth
|
|
453
|
+
subs = net.subgroups(g)
|
|
454
|
+
devs = [m for m in net.groups[g] if m in net.devices]
|
|
455
|
+
cluster = net.clusters.get(g)
|
|
456
|
+
if not subs and cluster is None:
|
|
457
|
+
return [f"{pad}{g}: [{', '.join(devs)}]"]
|
|
458
|
+
lines = [f"{pad}{g}:"]
|
|
459
|
+
if cluster is not None:
|
|
460
|
+
lines.append(f"{pad} cluster: {cluster}")
|
|
461
|
+
if devs:
|
|
462
|
+
lines.append(f"{pad} members: [{', '.join(devs)}]")
|
|
463
|
+
for sub in subs:
|
|
464
|
+
lines += _dump_group(net, sub, depth + 1)
|
|
465
|
+
return lines
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _dump_links(net: Network, pin_ports: bool) -> list[str]:
|
|
469
|
+
def end(d: str, p: int) -> str:
|
|
470
|
+
return f"{d}:{p}" if pin_ports else d
|
|
471
|
+
|
|
472
|
+
lines: list[str] = []
|
|
473
|
+
if pin_ports:
|
|
474
|
+
for l in net.links:
|
|
475
|
+
role = f", role: {l.role}" if l.role != "data" else ""
|
|
476
|
+
lines.append(f' - {{connect: [{end(l.a, l.pa)}, {end(l.b, l.pb)}]{role}}}')
|
|
477
|
+
return lines
|
|
478
|
+
|
|
479
|
+
# Cables as a multiset of unordered device pairs, per role.
|
|
480
|
+
remaining = [(min(l.a, l.b, key=_order), max(l.a, l.b, key=_order), l.role) for l in net.links]
|
|
481
|
+
data_pairs: dict[tuple[str, str], int] = {}
|
|
482
|
+
for a, b, role in remaining:
|
|
483
|
+
if role == "data":
|
|
484
|
+
data_pairs[(a, b)] = data_pairs.get((a, b), 0) + 1
|
|
485
|
+
|
|
486
|
+
def take(a: str, b: str) -> None:
|
|
487
|
+
key = (min(a, b, key=_order), max(a, b, key=_order))
|
|
488
|
+
data_pairs[key] -= 1
|
|
489
|
+
if not data_pairs[key]:
|
|
490
|
+
del data_pairs[key]
|
|
491
|
+
|
|
492
|
+
def has(a: str, b: str) -> bool:
|
|
493
|
+
return data_pairs.get((min(a, b, key=_order), max(a, b, key=_order)), 0) > 0
|
|
494
|
+
|
|
495
|
+
# Meshes between groups' direct devices (largest first), then parallels, then singles.
|
|
496
|
+
sets = sorted(((g, [m for m in net.groups[g] if m in net.devices]) for g in net.groups),
|
|
497
|
+
key=lambda t: -len(t[1]))
|
|
498
|
+
sets = [(g, ds) for g, ds in sets if len(ds) >= 1 and not net.subgroups(g)]
|
|
499
|
+
for i, (ga, a) in enumerate(sets):
|
|
500
|
+
for gb, b in sets[i + 1:]:
|
|
501
|
+
if set(a) & set(b) or len(a) * len(b) < 2:
|
|
502
|
+
continue
|
|
503
|
+
if all(has(x, y) for x in a for y in b):
|
|
504
|
+
for x in a:
|
|
505
|
+
for y in b:
|
|
506
|
+
take(x, y)
|
|
507
|
+
lines.append(f" - mesh: [{ga}, {gb}]")
|
|
508
|
+
for (a, b), n in sorted(data_pairs.items(), key=lambda kv: (_order(kv[0][0]), _order(kv[0][1]))):
|
|
509
|
+
lines.append(f" - parallel: [{a}, {b}, {n}]" if n > 1 else f" - connect: [{a}, {b}]")
|
|
510
|
+
for a, b, role in remaining:
|
|
511
|
+
if role != "data":
|
|
512
|
+
lines.append(f" - {{connect: [{a}, {b}], role: {role}}}")
|
|
513
|
+
return lines
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _order(did: str) -> tuple[int, int]:
|
|
517
|
+
return RANK[did[0]], int(did[1:])
|
bytesim/tui/__init__.py
ADDED
bytesim/tui/app.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The ByteSim Textual app: layout, the command line, and keeping everything in sync."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from rich.markup import escape
|
|
5
|
+
from textual import events
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
9
|
+
from textual.widgets import Footer, Input, RichLog, Static
|
|
10
|
+
|
|
11
|
+
from .. import __version__
|
|
12
|
+
from ..model import NetError, Network
|
|
13
|
+
from .canvas import NetCanvas
|
|
14
|
+
from .command_input import CommandInput
|
|
15
|
+
from .commands.addressing import AddressCommands
|
|
16
|
+
from .commands.groups import GroupCommands
|
|
17
|
+
from .commands.history import HistoryCommands
|
|
18
|
+
from .commands.interpreter import Interpreter
|
|
19
|
+
from .commands.naming import NamingCommands
|
|
20
|
+
from .commands.scripting import ScriptCommands
|
|
21
|
+
from .commands.switching import SwitchingCommands
|
|
22
|
+
from .commands.registry import collect
|
|
23
|
+
from .commands.topology import TopologyCommands
|
|
24
|
+
from .commands.traffic import TrafficCommands
|
|
25
|
+
from .inspector import render_inspector
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ByteSimApp(TopologyCommands, GroupCommands, AddressCommands, TrafficCommands, NamingCommands,
|
|
29
|
+
SwitchingCommands, ScriptCommands, HistoryCommands, Interpreter, App):
|
|
30
|
+
"""Canvas on the left, inspector on the right, console and command line below."""
|
|
31
|
+
|
|
32
|
+
TITLE = "ByteSim"
|
|
33
|
+
CSS = """
|
|
34
|
+
#main { height: 1fr; }
|
|
35
|
+
NetCanvas { width: 1fr; height: 1fr; border: round $panel-lighten-2; }
|
|
36
|
+
NetCanvas:focus { border: round $accent; }
|
|
37
|
+
#side { width: 38; border: round $panel-lighten-2; padding: 0 1; }
|
|
38
|
+
#log { height: 9; border: round $panel-lighten-2; padding: 0 1; }
|
|
39
|
+
#cmd { border: tall $panel-lighten-2; }
|
|
40
|
+
#cmd:focus { border: tall $accent; }
|
|
41
|
+
"""
|
|
42
|
+
BINDINGS = [Binding("ctrl+q", "quit", "Quit")]
|
|
43
|
+
AUTO_FOCUS = "#cmd"
|
|
44
|
+
|
|
45
|
+
def __init__(self, network: Network | None = None, startup: list[str] | None = None,
|
|
46
|
+
auto: bool | None = None) -> None:
|
|
47
|
+
"""``network`` starts the app on an existing model; ``startup`` commands run once mounted.
|
|
48
|
+
``auto`` turns auto-arrange on or off (default: on, unless a network with its own
|
|
49
|
+
positions is given)."""
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.net = network or Network()
|
|
52
|
+
self.auto = (network is None) if auto is None else auto
|
|
53
|
+
self.graph = False # graph-only mode: connectivity without addressing
|
|
54
|
+
self._startup = startup or []
|
|
55
|
+
self._ha_seen: dict = {}
|
|
56
|
+
self._commands = collect(type(self))
|
|
57
|
+
|
|
58
|
+
def compose(self) -> ComposeResult:
|
|
59
|
+
with Horizontal(id="main"):
|
|
60
|
+
yield NetCanvas(id="canvas")
|
|
61
|
+
with VerticalScroll(id="side"):
|
|
62
|
+
yield Static(id="info")
|
|
63
|
+
yield RichLog(id="log", markup=True, wrap=True)
|
|
64
|
+
yield CommandInput(id="cmd", placeholder="r1 s2 to cable · add r 8 · ping c1 c4 · help")
|
|
65
|
+
yield Footer()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def canvas(self) -> NetCanvas:
|
|
69
|
+
return self.query_one(NetCanvas)
|
|
70
|
+
|
|
71
|
+
def on_mount(self) -> None:
|
|
72
|
+
self.query_one("#canvas").border_title = "network"
|
|
73
|
+
self.query_one("#side").border_title = "inspector"
|
|
74
|
+
self.query_one("#log").border_title = "console"
|
|
75
|
+
self.log_msg(f"[b]ByteSim {__version__}[/] — type [cyan]help[/] for commands, "
|
|
76
|
+
"or [cyan]demo[/] / [cyan]demo ha[/] for a sample network.")
|
|
77
|
+
if self.net.devices:
|
|
78
|
+
self.call_after_refresh(self.changed)
|
|
79
|
+
for line in self._startup:
|
|
80
|
+
self.call_after_refresh(self.run_command, line)
|
|
81
|
+
self.refresh_sidebar()
|
|
82
|
+
|
|
83
|
+
# ---- shared helpers (used by the canvas and the command mixins) ----
|
|
84
|
+
def log_msg(self, msg: str) -> None:
|
|
85
|
+
if self.replaying: # a script is running: keep the console quiet
|
|
86
|
+
return
|
|
87
|
+
self.query_one(RichLog).write(msg)
|
|
88
|
+
|
|
89
|
+
def action_focus_cmd(self) -> None:
|
|
90
|
+
self.query_one(CommandInput).focus()
|
|
91
|
+
|
|
92
|
+
def action_focus_canvas(self) -> None:
|
|
93
|
+
self.canvas.focus()
|
|
94
|
+
|
|
95
|
+
def arrange(self, announce: bool = False) -> None:
|
|
96
|
+
self.auto = True
|
|
97
|
+
self.net.auto_layout(self.canvas.size.width or 120)
|
|
98
|
+
self.canvas.pan = (0, 0)
|
|
99
|
+
if announce:
|
|
100
|
+
self.log_msg("[green]auto-arrange on[/]")
|
|
101
|
+
self.changed()
|
|
102
|
+
|
|
103
|
+
def manual_moved(self) -> None:
|
|
104
|
+
if self.auto:
|
|
105
|
+
self.auto = False
|
|
106
|
+
self.log_msg("[dim]auto-arrange paused — press [b]a[/b] or type [b]auto[/b] to re-arrange[/]")
|
|
107
|
+
|
|
108
|
+
def replace_network(self, net: Network, auto: bool) -> None:
|
|
109
|
+
"""Swap in a whole new network (clear, demo, load)."""
|
|
110
|
+
self.net = net
|
|
111
|
+
self.auto = auto
|
|
112
|
+
self._ha_seen = {}
|
|
113
|
+
self.canvas.reset()
|
|
114
|
+
self.changed()
|
|
115
|
+
|
|
116
|
+
def changed(self, skip: frozenset[tuple[str, int]] = frozenset()) -> None:
|
|
117
|
+
"""Call after any structural change: re-address, renumber, report HA, re-layout, redraw."""
|
|
118
|
+
if self.replaying: # a script is running: do it all once at the end
|
|
119
|
+
return
|
|
120
|
+
self.reconcile(skip)
|
|
121
|
+
self.auto_renumber()
|
|
122
|
+
if not self.graph:
|
|
123
|
+
self._ha_report()
|
|
124
|
+
if self.auto:
|
|
125
|
+
self.net.auto_layout(self.canvas.size.width or 120)
|
|
126
|
+
c = self.canvas
|
|
127
|
+
if c.selected not in self.net.devices:
|
|
128
|
+
c.selected = None
|
|
129
|
+
if c.selected_group not in self.net.groups:
|
|
130
|
+
c.selected_group = None
|
|
131
|
+
c.hot_links.clear()
|
|
132
|
+
c.refresh()
|
|
133
|
+
self.refresh_sidebar()
|
|
134
|
+
|
|
135
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
136
|
+
if self.auto:
|
|
137
|
+
self.call_after_refresh(self.changed)
|
|
138
|
+
|
|
139
|
+
def refresh_sidebar(self) -> None:
|
|
140
|
+
c = self.canvas
|
|
141
|
+
self.query_one("#info", Static).update(
|
|
142
|
+
render_inspector(self.net, c.selected, c.selected_group, self.graph, self.auto,
|
|
143
|
+
self.naming_text()))
|
|
144
|
+
|
|
145
|
+
# ---- the command line ----------------------------------------------
|
|
146
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
147
|
+
line = event.value.strip()
|
|
148
|
+
inp = self.query_one(CommandInput)
|
|
149
|
+
inp.remember(line)
|
|
150
|
+
inp.value = ""
|
|
151
|
+
if line:
|
|
152
|
+
self.log_msg(f"[dim]›[/] {escape(line)}")
|
|
153
|
+
self.run_command(line)
|
|
154
|
+
|
|
155
|
+
def run_command(self, line: str) -> None:
|
|
156
|
+
"""Run one command line, reporting errors in the console. Anything it changes
|
|
157
|
+
(even partly, before an error) becomes one undo step."""
|
|
158
|
+
try:
|
|
159
|
+
self.recorded(line, lambda: self.dispatch(self.tokens(line)))
|
|
160
|
+
except NetError as e:
|
|
161
|
+
self.log_msg(f"[red]✗ {escape(str(e))}[/]")
|