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
@@ -0,0 +1,85 @@
1
+ """The command interpreter: tokens → handler. Shared by the app and the headless runner."""
2
+ from __future__ import annotations
3
+
4
+ from ...constants import REF
5
+ from ...model import NetError
6
+ from .base import CommandHost
7
+
8
+
9
+ class Interpreter(CommandHost):
10
+ _commands: dict[str, str]
11
+
12
+ @staticmethod
13
+ def tokens(line: str) -> list[str]:
14
+ """Split on spaces, except inside parentheses: link1(rfab1, rfab2) is one token."""
15
+ out, cur, depth = [], "", 0
16
+ for ch in line:
17
+ depth += (ch == "(") - (ch == ")")
18
+ if ch.isspace() and depth <= 0:
19
+ if cur:
20
+ out.append(cur)
21
+ cur, depth = "", 0
22
+ elif not (ch.isspace() and depth > 0):
23
+ cur += ch
24
+ if depth > 0:
25
+ raise NetError(f"unclosed parenthesis in {cur!r}")
26
+ return out + ([cur] if cur else [])
27
+
28
+ def dispatch(self, toks: list[str]) -> None:
29
+ """Route tokens to a handler. Shortcuts: ``r1`` selects, ``r1 s2`` cables,
30
+ a bare group name selects the group. ``key=value`` tokens become options."""
31
+ if not toks:
32
+ return
33
+ opts = {k.lower(): v.lower() for k, _, v in (t.partition("=") for t in toks if "=" in t)}
34
+ toks = [t for t in toks if "=" not in t]
35
+ if not toks:
36
+ raise NetError("missing command")
37
+ word, args = toks[0].lower(), toks[1:]
38
+
39
+ if REF.match(toks[0]):
40
+ if len(toks) == 1:
41
+ did, _ = self._ref(toks[0])
42
+ self.net.get(did)
43
+ self.canvas.select(did)
44
+ self.canvas.focus()
45
+ return
46
+ if len(toks) != 2:
47
+ raise NetError("to cable devices type two ids, e.g. r1 s2")
48
+ word, args = "link", toks
49
+ elif word not in self._commands and word in self.net.groups and not args:
50
+ word, args = "group", [word]
51
+
52
+ handler = self._commands.get(word)
53
+ if handler is None:
54
+ raise NetError(f"unknown command {word!r} — type help")
55
+ getattr(self, handler)(args, opts)
56
+
57
+ @staticmethod
58
+ def script_lines(text: str) -> list[tuple[int, str]]:
59
+ """(line number, command) for every non-blank line, with # comments removed."""
60
+ out = []
61
+ for n, raw in enumerate(text.splitlines(), 1):
62
+ line = raw.split("#", 1)[0].strip()
63
+ if line:
64
+ out.append((n, line))
65
+ return out
66
+
67
+ def run_script(self, text: str, origin: str = "script") -> int:
68
+ """Run a script's commands in order; stop at the first error, saying where.
69
+
70
+ While a script runs, the host may batch its usual per-command work (redraws,
71
+ re-addressing, DHCP animations) until the end. Returns how many commands ran."""
72
+ self.replaying += 1
73
+ ran = 0
74
+ try:
75
+ for n, line in self.script_lines(text):
76
+ try:
77
+ self.dispatch(self.tokens(line))
78
+ except NetError as e:
79
+ raise NetError(f"{origin}, line {n} ({line}): {e}") from None
80
+ ran += 1
81
+ finally:
82
+ self.replaying -= 1
83
+ if not self.replaying:
84
+ self.changed()
85
+ return ran
@@ -0,0 +1,68 @@
1
+ """Renumbering devices by BFS or DFS, once or automatically after every change."""
2
+ from __future__ import annotations
3
+
4
+ from ...model import Device, NetError
5
+ from ...model.naming import ORDERS
6
+ from .base import CommandHost
7
+ from .registry import command
8
+
9
+
10
+ class NamingCommands(CommandHost):
11
+ # (order, root device or None) while auto-renumbering is on. The root is held
12
+ # as a Device so it survives being renamed itself.
13
+ naming: tuple[str, Device | None] | None = None
14
+
15
+ @command("renumber", "rename")
16
+ def cmd_renumber(self, args: list[str], opts: dict[str, str]) -> None:
17
+ usage = "usage: renumber bfs|dfs [root] · renumber auto bfs|dfs [root] · renumber off"
18
+ if not args:
19
+ self.log_msg(f"auto-renumber: [b]{self.naming_text()}[/] ({usage[7:]})")
20
+ return
21
+ words = [a.lower() for a in args]
22
+ if words == ["off"]:
23
+ self.naming = None
24
+ self.log_msg("auto-renumber off: ids stay as they are")
25
+ self.refresh_sidebar()
26
+ return
27
+ auto = words[0] == "auto"
28
+ if auto:
29
+ words, args = words[1:], args[1:]
30
+ if not words or words[0] not in ORDERS or len(words) > 2:
31
+ raise NetError(usage)
32
+ order = words[0]
33
+ root = self.net.get(self._ref(args[1])[0]) if len(args) == 2 else None
34
+ if auto:
35
+ self.naming = (order, root)
36
+ self.log_msg(f"auto-renumber on: [b]{self.naming_text()}[/] after every change")
37
+ self.changed() # applies it now
38
+ else:
39
+ self._apply_renumber(order, root, announce_none=True)
40
+ self.changed()
41
+
42
+ def naming_text(self) -> str:
43
+ if self.naming is None:
44
+ return "off"
45
+ order, root = self.naming
46
+ alive = root is not None and root.id in self.net.devices and self.net.devices[root.id] is root
47
+ return f"{order.upper()} from {root.id if alive else self.net.default_root() or '—'}"
48
+
49
+ def auto_renumber(self) -> None:
50
+ """Called from changed(): keep ids in traversal order while the mode is on."""
51
+ if self.naming is not None:
52
+ order, root = self.naming
53
+ if root is not None and self.net.devices.get(root.id) is not root:
54
+ root = None # the root was deleted: fall back to the default
55
+ self._apply_renumber(order, root, announce_none=False)
56
+
57
+ def _apply_renumber(self, order: str, root: Device | None, announce_none: bool) -> None:
58
+ c = self.canvas
59
+ mapping = self.net.renumber(order, root.id if root else None)
60
+ if c.selected in mapping:
61
+ c.selected = mapping[c.selected]
62
+ if mapping:
63
+ pairs = sorted(mapping.items(), key=lambda kv: (kv[1][0], int(kv[1][1:])))
64
+ shown = " ".join(f"{old}→{new}" for old, new in pairs[:12])
65
+ more = f" (+{len(pairs) - 12} more)" if len(pairs) > 12 else ""
66
+ self.log_msg(f"[cyan]renumbered ({order.upper()}):[/] {shown}{more}")
67
+ elif announce_none:
68
+ self.log_msg(f"already in {order.upper()} order: nothing to rename")
@@ -0,0 +1,28 @@
1
+ """A tiny command registry: decorate a handler with the words that invoke it."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Callable
5
+
6
+ Handler = Callable[..., None]
7
+
8
+
9
+ def command(*names: str) -> Callable[[Handler], Handler]:
10
+ """Mark a method as the handler for one or more command words.
11
+
12
+ Handlers are called as ``handler(self, args, opts)`` where ``args`` are the
13
+ remaining tokens and ``opts`` holds any ``key=value`` tokens (lower-cased).
14
+ """
15
+ def mark(fn: Handler) -> Handler:
16
+ fn.__commands__ = names # type: ignore[attr-defined]
17
+ return fn
18
+ return mark
19
+
20
+
21
+ def collect(cls: type) -> dict[str, str]:
22
+ """Map every command word to the name of the method that handles it."""
23
+ table: dict[str, str] = {}
24
+ for klass in reversed(cls.__mro__):
25
+ for attr, fn in vars(klass).items():
26
+ for word in getattr(fn, "__commands__", ()):
27
+ table[word] = attr
28
+ return table
@@ -0,0 +1,54 @@
1
+ """Construction scripts: export the network as console commands, and run script files."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from rich.markup import escape
7
+
8
+ from ...model import NetError
9
+ from ...synthesis import MODES, synthesize
10
+ from .base import CommandHost
11
+ from .registry import command
12
+
13
+
14
+ class ScriptCommands(CommandHost):
15
+ @command("run", "source")
16
+ def cmd_run(self, args: list[str], opts: dict[str, str]) -> None:
17
+ if len(args) != 1:
18
+ raise NetError("usage: run <file> (one console command per line; # starts a comment)")
19
+ path = Path(args[0]).expanduser()
20
+ try:
21
+ text = path.read_text()
22
+ except OSError as e:
23
+ raise NetError(f"couldn't read {path}: {e}") from None
24
+ ran = self.run_script(text, origin=str(path)) # type: ignore[attr-defined]
25
+ self.log_msg(f"ran {ran} command{'s' if ran != 1 else ''} from {path}: "
26
+ f"{len(self.net.devices)} devices, {len(self.net.links)} cables, {len(self.net.groups)} groups")
27
+
28
+ def export_script(self, args: list[str]) -> None:
29
+ """export script [explicit|readable|shortest] [file]"""
30
+ modes = [a for a in args if a.lower() in MODES]
31
+ files = [a for a in args if a.lower() not in MODES]
32
+ if len(modes) > 1 or len(files) > 1:
33
+ raise NetError(f"usage: export script [{'|'.join(MODES)}] [file] (no file: show it here)")
34
+ mode = modes[0].lower() if modes else "readable"
35
+ res = synthesize(self.net, mode)
36
+ reuse = (f", reusing structure {len(res.used)} time{'s' if len(res.used) != 1 else ''} "
37
+ f"({res.explicit_commands} without)" if res.used else "")
38
+ summary = f"{mode} script: {res.commands} commands{reuse}, verified by replaying it"
39
+ if files:
40
+ path = Path(files[0]).expanduser()
41
+ try:
42
+ path.write_text(res.script)
43
+ except OSError as e:
44
+ raise NetError(f"couldn't write {path}: {e}") from None
45
+ self.log_msg(f"[green]exported {summary} → {path}[/]")
46
+ self.log_msg(f" [dim]replay it with: clear, then run {path}[/]")
47
+ else:
48
+ for line in res.script.splitlines():
49
+ self.log_msg(f"[dim]{escape(line)}[/]" if line.startswith("#") else escape(line))
50
+ self.log_msg(f"[green]{summary}[/]")
51
+ for s in res.used:
52
+ self.log_msg(f" [cyan]{escape(s.command())}[/] [dim]{escape(s.describe())}[/]")
53
+ for text in res.limitations:
54
+ self.log_msg(f" [yellow]not expressible as a command: {escape(text)}[/]")
@@ -0,0 +1,192 @@
1
+ """Switching loops: broadcasts, spanning tree, and broadcast storms."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Callable
5
+
6
+ from ...model import NetError
7
+ from ...model.stp import DEFAULT_PRIORITY, Flood
8
+ from ..cables import route
9
+ from .base import CommandHost
10
+ from .registry import command
11
+
12
+ ANIMATED_STORM_HOPS = 6 # how many generations of a storm to draw before it's declared
13
+
14
+
15
+ class SwitchingCommands(CommandHost):
16
+ @command("broadcast", "bcast", "flood")
17
+ def cmd_broadcast(self, args: list[str], opts: dict[str, str]) -> None:
18
+ if len(args) != 1:
19
+ raise NetError("usage: broadcast <id[:port]> (one frame to ff:ff:ff:ff:ff:ff)")
20
+ nic = self._sending_port(args[0])
21
+ name = self._ifname(nic) # type: ignore[attr-defined]
22
+ net = self.net
23
+ mac = net.devices[nic[0]].mac(nic[1])
24
+ self._refuse_if_storming(nic, "broadcast")
25
+ fl = net.flood(nic)
26
+ if not fl.generations:
27
+ raise NetError(f"{name} can't send: its cable is down or blocked")
28
+ if fl.storm:
29
+ self.log_msg(f"[b gold1]BROADCAST[/] {name} [dim]{mac}[/] → everyone")
30
+ self.set_off_storm(fl, f"a broadcast from {name}")
31
+ return
32
+ net.learn_flood(fl)
33
+ hops, reached = len(fl.generations), sorted(fl.delivered)
34
+ self.log_msg(f"[b gold1]BROADCAST[/] {name} [dim]{mac}[/] → everyone: reached "
35
+ f"{len(reached)} interface{'s' if len(reached) != 1 else ''} in {hops} hop"
36
+ f"{'s' if hops != 1 else ''}, then the frames died out (no loop)")
37
+
38
+ def done() -> None:
39
+ for dev, _ in reached:
40
+ self.canvas.flash_device(dev, "gold1")
41
+ self.refresh_sidebar()
42
+
43
+ self.animate_flood(fl, "gold1", done)
44
+
45
+ @command("stp")
46
+ def cmd_stp(self, args: list[str], opts: dict[str, str]) -> None:
47
+ net = self.net
48
+ words = [a.lower() for a in args]
49
+ if not words:
50
+ self._stp_status()
51
+ return
52
+ if words[0] == "priority":
53
+ if len(args) != 3 or not args[2].isdigit():
54
+ raise NetError("usage: stp priority <switch> <0-65535> (lower wins the root election)")
55
+ sw = self._switch(args[1])
56
+ net.stp_priority[sw] = int(args[2])
57
+ if net.stp_priority[sw] == DEFAULT_PRIORITY:
58
+ del net.stp_priority[sw]
59
+ self.log_msg(f"{sw}: bridge priority {args[2]}")
60
+ self.changed()
61
+ return
62
+ if words[-1] not in ("on", "off"):
63
+ raise NetError("usage: stp · stp on|off · stp <switch…> on|off · stp priority <switch> <n>")
64
+ switches = ([self._switch(a) for a in args[:-1]] if len(args) > 1
65
+ else [d.id for d in net.sorted_devices() if d.kind == "s"])
66
+ if words[-1] == "on":
67
+ net.stp_off -= set(switches)
68
+ else:
69
+ net.stp_off |= set(switches)
70
+ self.log_msg(f"STP {words[-1]} for {' '.join(switches) if len(args) > 1 else 'every switch'}")
71
+ self.changed()
72
+
73
+ # ---- storms -----------------------------------------------------------
74
+ def set_off_storm(self, fl: Flood, cause: str) -> None:
75
+ """A broadcast hit a live loop: mark the storm, explain it, and draw it."""
76
+ net = self.net
77
+ before = set(net.storming)
78
+ net.start_storm(fl)
79
+ totals = fl.totals
80
+ growth = (" → ".join(f"{n:,}" for n in totals[:7])
81
+ + (f" → … → {totals[-1]:,}" if len(totals) > 7 else ""))
82
+ switches = " ".join(sorted(net.storming - before, key=net.bridge_id))
83
+ # A plain ring just circles; a meshier loop copies the frame faster than it dies.
84
+ multiplying = totals[-1] > 2 * max(totals[:4])
85
+ how = "multiplying as it goes" if multiplying else "copies circling in both directions"
86
+ self.log_msg(f"[b red]⚡ BROADCAST STORM[/] set off by {cause}: the frame goes round a switching "
87
+ f"loop with no STP to block it, {how}")
88
+ self.log_msg(f" [red]frames in flight per hop: {growth}, and it never stops "
89
+ f"(layer-2 frames have no TTL)[/]")
90
+ mac = net.devices[fl.source[0]].mac(fl.source[1])
91
+ for sw, ports in sorted(fl.flaps.items(), key=lambda kv: net.bridge_id(kv[0])):
92
+ self.log_msg(f" [orange1]MAC {mac} flapping on {sw}: "
93
+ f"{'⇄'.join(f'p{p}' for p in sorted(ports))}[/]")
94
+ self.log_msg(f" [red]{switches} saturated: nothing else gets through until the loop is broken "
95
+ f"([b]stp on[/b], or fail/unlink a cable in it)[/]")
96
+ self.animate_flood(fl, "red", None, max_hops=ANIMATED_STORM_HOPS)
97
+ self.canvas.animate()
98
+ self.changed()
99
+
100
+ def storm_hint(self, *devs: str) -> str:
101
+ """' (broadcast storm on …)' if a storm is what's in the way, else ''."""
102
+ hit: set[str] = set()
103
+ for d in devs:
104
+ if d in self.net.devices:
105
+ hit |= self.net.storm_near(d)
106
+ if not hit:
107
+ return ""
108
+ return f" — broadcast storm on {' '.join(sorted(hit, key=self.net.bridge_id))} is saturating the links"
109
+
110
+ def _refuse_if_storming(self, nic: tuple[str, int], what: str) -> None:
111
+ if self.net.storm_near(nic[0]):
112
+ raise NetError(f"{what} from {self._ifname(nic)} can't get out" # type: ignore[attr-defined]
113
+ + self.storm_hint(nic[0]))
114
+
115
+ def animate_flood(self, fl: Flood, color: str, on_done: Callable[[], None] | None,
116
+ max_hops: int | None = None) -> None:
117
+ """Draw a flood generation by generation: every copy of hop n, then hop n+1…"""
118
+ net, c = self.net, self.canvas
119
+ gens = fl.generations[:max_hops] if max_hops else fl.generations
120
+ links = {li for g in gens for li, _ in g}
121
+ c.heat(links)
122
+
123
+ def run(i: int) -> None:
124
+ if i >= len(gens):
125
+ c.cool(links)
126
+ if on_done:
127
+ on_done()
128
+ return
129
+ moves = list(gens[i])
130
+ pending = [len(moves)]
131
+
132
+ def arrived() -> None:
133
+ pending[0] -= 1
134
+ if pending[0] == 0:
135
+ run(i + 1)
136
+
137
+ hot = "red" if color == "red" or i >= 2 and fl.storm else color
138
+ for li, dev in moves:
139
+ l = net.links[li]
140
+ frm = l.other(dev)[0]
141
+ pts = route(net, l, li)
142
+ if l.a != frm:
143
+ pts = pts[::-1]
144
+ df, dt = net.devices[frm], net.devices[dev]
145
+ cells = ([(df.port_x(l.port_of(frm)), df.y + 2)] + pts
146
+ + [(dt.port_x(l.port_of(dev)), dt.y + 2)])
147
+ c.send_packet(cells, hot, arrived)
148
+
149
+ run(0)
150
+
151
+ # ---- helpers ------------------------------------------------------------
152
+ def _switch(self, tok: str) -> str:
153
+ did = self.net.get(self._ref(tok)[0]).id
154
+ if self.net.devices[did].kind != "s":
155
+ raise NetError(f"{did} isn't a switch: STP runs on switches")
156
+ return did
157
+
158
+ def _sending_port(self, tok: str) -> tuple[str, int]:
159
+ did, port = self._ref(tok)
160
+ dev = self.net.get(did)
161
+ if port is None:
162
+ port = next((p for p in range(1, dev.nports + 1) if self.net.link_index(did, p) is not None), None)
163
+ if port is None:
164
+ raise NetError(f"{did} has no cables")
165
+ elif self.net.link_index(did, port) is None:
166
+ raise NetError(f"{did}:{dev.port_name(port)} has no cable")
167
+ return dev.id, port
168
+
169
+ def _stp_status(self) -> None:
170
+ net = self.net
171
+ areas = [a for a in net._areas(net._trunks(honor_stp=False)) if len(a) > 1]
172
+ live = net.loop_links(honor_stp=True)
173
+ if not areas:
174
+ self.log_msg("[dim]no switch is cabled to another switch: nothing for STP to do[/]")
175
+ for area in areas:
176
+ order = sorted(area, key=net.bridge_id)
177
+ root = net.stp_roots.get(order[0])
178
+ off = [s for s in order if not net.stp_enabled(s)]
179
+ blocked = sorted((s, p) for s, p in net.stp_blocked if s in area)
180
+ loops = {i for i in live if net.links[i].a in area}
181
+ line = f"[b]{' '.join(order)}[/]: " + (f"root bridge [cyan]{root}[/]" if root else "[yellow]no STP switch[/]")
182
+ if off:
183
+ line += f" · STP off on {' '.join(off)}"
184
+ self.log_msg(line)
185
+ if blocked:
186
+ self.log_msg(" blocking " + " ".join(f"{s}:p{p}" for s, p in blocked))
187
+ if area & net.storming:
188
+ self.log_msg(" [b red]⚡ broadcast storm in progress[/]")
189
+ elif loops:
190
+ self.log_msg(f" [red]live loop over {len(loops)} cables: the next broadcast here will storm[/]")
191
+ else:
192
+ self.log_msg(" [green]loop-free[/]")