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.
Files changed (48) hide show
  1. bytesim/__init__.py +14 -0
  2. bytesim/__main__.py +3 -0
  3. bytesim/cli.py +45 -0
  4. bytesim/constants.py +48 -0
  5. bytesim/demos.py +74 -0
  6. bytesim/model/__init__.py +5 -0
  7. bytesim/model/addressing.py +160 -0
  8. bytesim/model/base.py +57 -0
  9. bytesim/model/cloning.py +249 -0
  10. bytesim/model/equivalence.py +86 -0
  11. bytesim/model/groups.py +133 -0
  12. bytesim/model/ha.py +93 -0
  13. bytesim/model/layer2.py +106 -0
  14. bytesim/model/layout.py +203 -0
  15. bytesim/model/naming.py +114 -0
  16. bytesim/model/network.py +138 -0
  17. bytesim/model/routing.py +117 -0
  18. bytesim/model/stp.py +315 -0
  19. bytesim/model/types.py +107 -0
  20. bytesim/py.typed +0 -0
  21. bytesim/scripting.py +108 -0
  22. bytesim/synthesis.py +644 -0
  23. bytesim/topology.py +517 -0
  24. bytesim/tui/__init__.py +4 -0
  25. bytesim/tui/app.py +161 -0
  26. bytesim/tui/cables.py +58 -0
  27. bytesim/tui/canvas.py +489 -0
  28. bytesim/tui/command_input.py +29 -0
  29. bytesim/tui/commands/__init__.py +1 -0
  30. bytesim/tui/commands/addressing.py +235 -0
  31. bytesim/tui/commands/base.py +43 -0
  32. bytesim/tui/commands/groups.py +215 -0
  33. bytesim/tui/commands/history.py +134 -0
  34. bytesim/tui/commands/interpreter.py +85 -0
  35. bytesim/tui/commands/naming.py +68 -0
  36. bytesim/tui/commands/registry.py +28 -0
  37. bytesim/tui/commands/scripting.py +54 -0
  38. bytesim/tui/commands/switching.py +192 -0
  39. bytesim/tui/commands/topology.py +255 -0
  40. bytesim/tui/commands/traffic.py +312 -0
  41. bytesim/tui/help.py +50 -0
  42. bytesim/tui/inspector.py +115 -0
  43. bytesim/tui/theme.py +12 -0
  44. bytesim-0.1.0.dist-info/METADATA +178 -0
  45. bytesim-0.1.0.dist-info/RECORD +48 -0
  46. bytesim-0.1.0.dist-info/WHEEL +4 -0
  47. bytesim-0.1.0.dist-info/entry_points.txt +2 -0
  48. 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:])
@@ -0,0 +1,4 @@
1
+ """The Textual user interface."""
2
+ from .app import ByteSimApp
3
+
4
+ __all__ = ["ByteSimApp"]
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))}[/]")