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/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """ByteSim: a text-mode network simulator.
2
+
3
+ The model (``bytesim.model``) can be used on its own::
4
+
5
+ from bytesim import Network
6
+ net = Network()
7
+ net.add("r"); net.add("c")
8
+ net.connect("r1", None, "c1", None)
9
+ """
10
+ __version__ = "0.1.0"
11
+
12
+ from .model import Device, Iface, Link, NetError, Network # noqa: E402
13
+
14
+ __all__ = ["Device", "Iface", "Link", "NetError", "Network", "__version__"]
bytesim/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
bytesim/cli.py ADDED
@@ -0,0 +1,45 @@
1
+ """Command-line entry point: ``bytesim [FILE] [--demo NAME] [--run CMD ...]``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ p = argparse.ArgumentParser(
13
+ prog="bytesim",
14
+ description="A text-mode network simulator: gateways, routers, switches and computers, "
15
+ "cabled together and simulated in your terminal.")
16
+ p.add_argument("file", nargs="?", type=Path,
17
+ help="a saved network (.json) or a topology (.yaml) to open")
18
+ p.add_argument("--demo", choices=["basic", "ha", "loop"], help="start with a sample network")
19
+ p.add_argument("--run", "-c", metavar="CMD", action="append", default=[],
20
+ help="a console command to run at startup (repeatable)")
21
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
22
+ return p
23
+
24
+
25
+ def main(argv: list[str] | None = None) -> None:
26
+ args = build_parser().parse_args(argv)
27
+ from .model import NetError, Network
28
+ from .topology import load_topology, looks_like_topology
29
+ from .tui import ByteSimApp
30
+
31
+ network, topology = None, False
32
+ if args.file is not None:
33
+ try:
34
+ text = args.file.read_text()
35
+ topology = looks_like_topology(text)
36
+ network = load_topology(text) if topology else Network.from_json(text)
37
+ except (OSError, ValueError, KeyError, TypeError, NetError) as e:
38
+ sys.exit(f"bytesim: couldn't open {args.file}: {e}")
39
+ startup = ([f"demo {args.demo}"] if args.demo else []) + args.run
40
+ # A topology carries no positions, so it starts auto-arranged.
41
+ ByteSimApp(network=network, startup=startup, auto=True if topology else None).run()
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
bytesim/constants.py ADDED
@@ -0,0 +1,48 @@
1
+ """Device kinds, sizes and the token grammar shared by the model and the UI."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ KINDS = {
7
+ "g": ("gateway", 4, "magenta"),
8
+ "r": ("router", 4, "cyan"),
9
+ "s": ("switch", 8, "green"),
10
+ "c": ("computer", 1, "dodger_blue1"),
11
+ }
12
+
13
+ RANK = {"g": 0, "r": 1, "s": 2, "c": 3}
14
+
15
+ ALIASES = {
16
+ "g": "g", "gw": "g", "gateway": "g",
17
+ "r": "r", "router": "r",
18
+ "s": "s", "sw": "s", "switch": "s",
19
+ "c": "c", "pc": "c", "computer": "c", "host": "c",
20
+ }
21
+
22
+ MAX_PORTS = 64
23
+
24
+ BOX_H = 4 # top border, label, port row, bottom border
25
+
26
+ LAYER_GAP = 5 # empty rows between layers (room for link routing)
27
+
28
+ H_GAP = 4 # columns between neighbouring boxes
29
+
30
+
31
+ # Auto-numbering: prefix(n) stands for n new names, each prefix + the lowest free number.
32
+ # s(2) → two new switches; fab(1) → one new group named fab<next>.
33
+ AUTO = re.compile(r"^([A-Za-z][\w-]*?)\((\d+)\)$")
34
+ REF = re.compile(r"^([grsc])(\d+)(?::(?:p|nic|eth)?(\d+))?$", re.I)
35
+
36
+ GROUP_NAME = re.compile(r"^[a-z][\w-]{0,15}$", re.I)
37
+
38
+ ADDR = re.compile(r"^\(?(\d+),(\d+)\)?$") # (network, host) e.g. (2,14) or 2,14
39
+
40
+ HOST_MAX = 255 # 8-bit host numbers: 1..254, 255 = broadcast
41
+
42
+ VIP_HOST = 254 # virtual gateway shared by an HA router cluster
43
+
44
+ POOL = range(10, VIP_HOST) # DHCP / ad-hoc hosts; routers take 1, 2, ...
45
+
46
+ ROLES = ("data", "control", "control-backup", "fabric")
47
+
48
+ POLICIES = ("none", "quorum", "witness")
bytesim/demos.py ADDED
@@ -0,0 +1,74 @@
1
+ """Ready-made networks. Each builder returns (network, hint for the console)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Callable
5
+
6
+ from .model import Network
7
+
8
+
9
+ def basic() -> tuple[Network, str]:
10
+ """A gateway, two routers, three switches and ten computers (two of them an ad-hoc pair)."""
11
+ net = Network()
12
+ net.add("g", 2)
13
+ for _ in range(2):
14
+ net.add("r", 4)
15
+ for _ in range(3):
16
+ net.add("s", 6)
17
+ for i in range(10):
18
+ net.add("c", 2 if i == 2 else 1)
19
+ for a, b in [("g1", "r1"), ("r1", "r2"), ("r1", "s1"), ("r1", "s2"), ("r2", "s3"),
20
+ ("s1", "c1"), ("s1", "c2"), ("s1", "c3"), ("s2", "c3"), ("s2", "c4"),
21
+ ("s2", "c5"), ("s3", "c6"), ("s3", "c7"), ("s3", "c8"),
22
+ ("c9", "c10")]:
23
+ net.connect(a, None, b, None)
24
+ net.group_add("core", ["r1", "r2"])
25
+ net.group_add("fab1", ["s1", "s2", "s3"])
26
+ return net, ("[green]demo network loaded[/] — try [cyan]ping c1 c8[/], [cyan]mcast c1 c4 c6 c8[/], "
27
+ "[cyan]dhcp c4[/], [cyan]ip[/], or [cyan]ping c9 c10[/] (an ad-hoc pair, no router)")
28
+
29
+
30
+ def ha() -> tuple[Network, str]:
31
+ """Two fabrics joined by a clustered router pair with redundant control links."""
32
+ net = Network()
33
+ for _ in range(2):
34
+ net.add("r", 6)
35
+ for _ in range(4):
36
+ net.add("s", 6)
37
+ for _ in range(6):
38
+ net.add("c", 1)
39
+ for a, pa, b in [("s1", None, "s2"), ("s3", None, "s4"), ("r1", 1, "s1"), ("r2", 1, "s2"),
40
+ ("r1", 2, "s3"), ("r2", 2, "s4"),
41
+ ("s1", None, "c1"), ("s1", None, "c2"), ("s2", None, "c3"),
42
+ ("s3", None, "c4"), ("s3", None, "c5"), ("s4", None, "c6")]:
43
+ net.connect(a, pa, b, None)
44
+ net.connect("r1", 5, "r2", 5, "control")
45
+ net.connect("r1", 6, "r2", 6, "control-backup")
46
+ net.connect("r1", 4, "r2", 4, "fabric")
47
+ net.group_add("fab1", ["s1", "s2"])
48
+ net.group_add("fab2", ["s3", "s4"])
49
+ net.group_add("rfab1", ["r1", "r2"])
50
+ net.clusters["rfab1"] = "none"
51
+ return net, ("[green]HA demo loaded[/]: r1+r2 form cluster rfab1 between fab1 and fab2.\n"
52
+ " try [cyan]ping c1 c6[/], then [cyan]fail r1:5[/], [cyan]fail r1:6[/] (split-brain), "
53
+ "[cyan]cluster rfab1 policy=witness[/], [cyan]fail r1[/], [cyan]fail r1:2[/], [cyan]restore all[/]")
54
+
55
+
56
+ def loop() -> tuple[Network, str]:
57
+ """Three switches cabled in a triangle with spanning tree switched off."""
58
+ net = Network()
59
+ net.add("r", 2)
60
+ for _ in range(3):
61
+ net.add("s", 6)
62
+ for _ in range(3):
63
+ net.add("c", 1)
64
+ for a, b in [("r1", "s1"), ("s1", "s2"), ("s2", "s3"), ("s3", "s1"),
65
+ ("s1", "c1"), ("s2", "c2"), ("s3", "c3")]:
66
+ net.connect(a, None, b, None)
67
+ net.group_add("ring", ["s1", "s2", "s3"])
68
+ net.stp_off = {"s1", "s2", "s3"}
69
+ return net, ("[green]loop demo loaded[/]: s1–s2–s3 form a triangle and STP is off.\n"
70
+ " try [cyan]broadcast c1[/] (or [cyan]ping c1 c2[/]) to set off a storm, then "
71
+ "[cyan]stp on[/], [cyan]stp[/], [cyan]fail s1:2[/], [cyan]ping c1 c2[/]")
72
+
73
+
74
+ DEMOS: dict[str, Callable[[], tuple[Network, str]]] = {"basic": basic, "ha": ha, "loop": loop}
@@ -0,0 +1,5 @@
1
+ """The network model: devices, cables, groups, addressing, routing and HA. No UI code."""
2
+ from .network import Network
3
+ from .types import Device, Iface, Leg, Link, NetError, fmt
4
+
5
+ __all__ = ["Device", "Iface", "Leg", "Link", "NetError", "Network", "fmt"]
@@ -0,0 +1,160 @@
1
+ """Addresses as (network, 8-bit host); router autoconfig, DHCP and ad-hoc."""
2
+ from __future__ import annotations
3
+
4
+ from ..constants import HOST_MAX, POOL, VIP_HOST
5
+ from .base import NetworkBase
6
+ from .types import Iface, NetError, fmt
7
+
8
+
9
+ class AddressingMixin(NetworkBase):
10
+ def iface(self, dev_id: str, port: int) -> Iface:
11
+ key = f"{dev_id}:{port}"
12
+ if key not in self.ifaces:
13
+ self.ifaces[key] = Iface("dhcp" if self.devices[dev_id].kind == "c" else "auto")
14
+ return self.ifaces[key]
15
+
16
+ def addr_of(self, ifc: tuple[str, int]) -> tuple[int, int] | None:
17
+ i = self.ifaces.get(f"{ifc[0]}:{ifc[1]}")
18
+ return i.addr if i else None
19
+
20
+ def addressed(self, dev_id: str) -> list[tuple[int, Iface]]:
21
+ dev = self.devices[dev_id]
22
+ if dev.kind == "s":
23
+ return []
24
+ return [(p, self.iface(dev_id, p)) for p in range(1, dev.nports + 1)
25
+ if self.iface(dev_id, p).addr]
26
+
27
+ def hosts_on(self, net: int) -> set[int]:
28
+ return {i.host for i in self.ifaces.values() if i.net == net and i.host is not None}
29
+
30
+ def resolve(self, addr: tuple[int, int]) -> tuple[str, int] | None:
31
+ if addr[1] == VIP_HOST and (owners := self.vip_owners(addr[0])):
32
+ return owners[0]
33
+ for key, i in self.ifaces.items():
34
+ if i.addr == addr:
35
+ d, p = key.split(":")
36
+ return d, int(p)
37
+ return None
38
+
39
+ def local_net(self, nic: tuple[str, int]) -> int | None:
40
+ """Network id already in use on this broadcast domain (routers' first)."""
41
+ seg = sorted(self.segment(*nic), key=lambda e: not self.is_router(e[0]))
42
+ return next((self.addr_of(e)[0] for e in seg if self.addr_of(e)), None)
43
+
44
+ def new_net(self) -> int:
45
+ used = {i.net for i in self.ifaces.values() if i.net is not None}
46
+ return next(n for n in range(1, 10**6) if n not in used)
47
+
48
+ def autoconf_routers(self) -> list[str]:
49
+ """Give every cabled router/gateway port an address on its local network,
50
+ opening a new network id if the segment doesn't have one yet."""
51
+ notes = []
52
+ for dev in self.sorted_devices():
53
+ if dev.kind not in "rg":
54
+ continue
55
+ for p in range(1, dev.nports + 1):
56
+ ifc = self.iface(dev.id, p)
57
+ if ifc.mode != "auto":
58
+ continue
59
+ li = self.link_index(dev.id, p)
60
+ if li is None or self.links[li].control:
61
+ ifc.clear() # unplugged, or a control link: no data address
62
+ continue
63
+ if not self.usable(self.links[li]):
64
+ continue # failed: keep the config for when it comes back
65
+ local = self.local_net((dev.id, p))
66
+ if ifc.addr and (local is None or local == ifc.net):
67
+ continue
68
+ net = local if local is not None else self.new_net()
69
+ used = self.hosts_on(net)
70
+ ifc.net, ifc.gw, ifc.via = net, None, "auto"
71
+ ifc.host = next(h for h in range(1, HOST_MAX) if h not in used)
72
+ notes.append(f"{dev.id}:{dev.port_name(p)} → {fmt(ifc.addr)}")
73
+ return notes
74
+
75
+ def dhcp_server(self, client: tuple[str, int]) -> tuple[str, int] | None:
76
+ for e in self.segment(*client):
77
+ if self.is_router(e[0]) and e[0] not in self.dhcp_off and self.addr_of(e):
78
+ return e
79
+ return None
80
+
81
+ def _free_host(self, net: int, ifc: Iface) -> int:
82
+ used = self.hosts_on(net) - ({ifc.host} if ifc.net == net else set())
83
+ if ifc.net == net and ifc.host in POOL and ifc.host not in used:
84
+ return ifc.host # keep the number we already had on this network
85
+ host = next((h for h in POOL if h not in used), None)
86
+ if host is None:
87
+ raise NetError(f"network {net} is full (no free 8-bit host numbers)")
88
+ return host
89
+
90
+ def lease(self, client: tuple[str, int]) -> tuple[str, tuple[str, int] | None]:
91
+ """DHCP for a client NIC (falls back to ad-hoc). Returns (outcome, server)."""
92
+ ifc = self.iface(*client)
93
+ server = self.dhcp_server(client)
94
+ if server is None:
95
+ self.adhoc(client)
96
+ return "adhoc", None
97
+ snet, _ = self.addr_of(server)
98
+ gw = self.expected_gw(server)
99
+ if ifc.via == "dhcp" and ifc.net == snet and ifc.gw == gw:
100
+ return "renew", server
101
+ ifc.host = self._free_host(snet, ifc)
102
+ ifc.net, ifc.gw, ifc.via = snet, gw, "dhcp"
103
+ return "lease", server
104
+
105
+ def expected_gw(self, server: tuple[str, int]) -> int:
106
+ """Gateway a DHCP server hands out: its own host, or its cluster's virtual one."""
107
+ return VIP_HOST if self.cluster_of(server[0]) else self.addr_of(server)[1]
108
+
109
+ def adhoc(self, nic: tuple[str, int]) -> None:
110
+ """Take an unused host number on the local network, no server involved."""
111
+ ifc = self.iface(*nic)
112
+ local = self.local_net(nic)
113
+ if ifc.via == "adhoc" and ifc.addr and local in (None, ifc.net):
114
+ return
115
+ net = local if local is not None else self.new_net()
116
+ ifc.host = self._free_host(net, ifc)
117
+ ifc.net, ifc.gw, ifc.via = net, None, "adhoc"
118
+
119
+ def reconcile(self, skip: frozenset[tuple[str, int]] = frozenset()) -> list[str]:
120
+ """Bring every address in line with the current cabling. Returns what changed.
121
+
122
+ STP runs first (it decides which cables forward). Router ports
123
+ autoconfigure; unplugged client NICs release; DHCP clients
124
+ (re)lease when a server is reachable, keep a lease when none is, and fall
125
+ back to ad-hoc when they have nothing. ``skip`` leaves some NICs alone
126
+ (the UI animates their DHCP exchange itself).
127
+ """
128
+ notes = self.stp_update() # type: ignore[attr-defined]
129
+ notes += [f"addr {n}" for n in self.autoconf_routers()]
130
+ for d in self.sorted_devices():
131
+ if d.kind != "c":
132
+ continue
133
+ for p in range(1, d.nports + 1):
134
+ nic, ifc = (d.id, p), self.iface(d.id, p)
135
+ name = f"{d.id}:{d.port_name(p)}"
136
+ if self.link_index(d.id, p) is None:
137
+ if ifc.mode in ("dhcp", "adhoc") and ifc.addr:
138
+ notes.append(f"{name} link down: released {fmt(ifc.addr)}")
139
+ ifc.clear()
140
+ continue
141
+ if nic in skip:
142
+ continue
143
+ old = ifc.addr
144
+ try:
145
+ if ifc.mode == "adhoc":
146
+ self.adhoc(nic)
147
+ elif ifc.mode == "dhcp":
148
+ server = self.dhcp_server(nic)
149
+ if server is None:
150
+ # Nobody to ask: keep a lease we hold, else go ad-hoc.
151
+ if not ifc.addr or ifc.via == "adhoc":
152
+ self.adhoc(nic)
153
+ elif (ifc.via != "dhcp" or ifc.net != self.addr_of(server)[0]
154
+ or ifc.gw != self.expected_gw(server)):
155
+ self.lease(nic)
156
+ except NetError as e:
157
+ notes.append(f"{name}: {e}")
158
+ if ifc.addr != old:
159
+ notes.append(f"{name} → {fmt(ifc.addr)} via {ifc.via}")
160
+ return notes
bytesim/model/base.py ADDED
@@ -0,0 +1,57 @@
1
+ """State shared by every part of the network model, plus the basic lookups."""
2
+ from __future__ import annotations
3
+
4
+ from ..constants import KINDS, RANK
5
+ from .types import Device, Iface, Link, NetError
6
+
7
+
8
+ class NetworkBase:
9
+ """Holds the network's state. The mixins in this package add behaviour on top."""
10
+
11
+ def __init__(self) -> None:
12
+ self.devices: dict[str, Device] = {}
13
+ self.links: list[Link] = []
14
+ self.counters = {k: 0 for k in KINDS}
15
+ self.groups: dict[str, list[str]] = {} # group -> member device ids / group names
16
+ self.ifaces: dict[str, Iface] = {} # "c1:1" -> Iface
17
+ self.dhcp_off: set[str] = set() # routers/gateways not serving DHCP
18
+ self.mac_tables: dict[str, dict[str, int]] = {} # switch -> {mac: port}
19
+ self.clusters: dict[str, str] = {} # HA cluster (a group of routers) -> split policy
20
+ self.notes: list[str] = [] # remarks from the last route() for the console
21
+ self.stp_off: set[str] = set() # switches with spanning tree switched off
22
+ self.stp_priority: dict[str, int] = {} # bridge priority (default 32768; lower = root)
23
+ self.stp_blocked: set[tuple[str, int]] = set() # (switch, port) held blocking by STP
24
+ self.stp_roots: dict[str, str] = {} # switch -> its area's root bridge
25
+ self.stp_root_ports: dict[str, int] = {} # switch -> port toward the root
26
+ self.storming: set[str] = set() # switches drowning in a broadcast storm
27
+ self.mac_flaps: dict[str, dict[str, set[int]]] = {} # switch -> mac -> ports it flapped between
28
+
29
+ def get(self, dev_id: str) -> Device:
30
+ try:
31
+ return self.devices[dev_id.lower()]
32
+ except KeyError:
33
+ raise NetError(f"no such device: {dev_id}") from None
34
+
35
+ def sorted_devices(self) -> list[Device]:
36
+ return sorted(self.devices.values(), key=lambda d: (RANK[d.kind], d.num))
37
+
38
+ def link_on(self, dev_id: str, port: int) -> Link | None:
39
+ for l in self.links:
40
+ if (l.a == dev_id and l.pa == port) or (l.b == dev_id and l.pb == port):
41
+ return l
42
+ return None
43
+
44
+ def link_index(self, dev_id: str, port: int) -> int | None:
45
+ for i, l in enumerate(self.links):
46
+ if (l.a == dev_id and l.pa == port) or (l.b == dev_id and l.pb == port):
47
+ return i
48
+ return None
49
+
50
+ def free_port(self, dev: Device) -> int | None:
51
+ for p in range(1, dev.nports + 1):
52
+ if self.link_on(dev.id, p) is None:
53
+ return p
54
+ return None
55
+
56
+ def is_router(self, dev_id: str) -> bool:
57
+ return self.devices[dev_id].kind in "rg"
@@ -0,0 +1,249 @@
1
+ """Cloning a group: its devices, nested groups and internal cables."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+
7
+ from ..constants import GROUP_NAME, RANK, REF
8
+ from .base import NetworkBase
9
+ from .types import Device, Iface, Link, NetError
10
+
11
+
12
+ @dataclass
13
+ class CloneResult:
14
+ group: str # the new top-level group
15
+ devices: dict[str, str] # old device id -> new device id
16
+ groups: dict[str, str] # old group name -> new group name
17
+ cables: int # internal cables copied
18
+ skipped: int # cables to the outside, not copied
19
+ rewired: int = 0 # outside cables re-made onto new endpoints
20
+ moved: list[str] | None = None # "r3:p6→p7": ports that couldn't be kept
21
+
22
+
23
+ class CloningMixin(NetworkBase):
24
+ def next_group_name(self, base: str, reserved: frozenset[str] = frozenset()) -> str:
25
+ """A free group name like base: fab1 → fab2 (or fab3…), core → core-2."""
26
+ m = re.match(r"^(.*?)(\d+)$", base)
27
+ stem, n, sep = (m[1], int(m[2]), "") if m else (base, 1, "-")
28
+ while True:
29
+ n += 1
30
+ name = f"{stem}{sep}{n}"[-16:]
31
+ if (name not in self.groups and name not in self.devices and name not in reserved
32
+ and not REF.match(name)):
33
+ return name
34
+
35
+ def endpoint_map(self, pairs: list[tuple[str, str]], group: str) -> dict[str, str]:
36
+ """Old outside endpoint → new one, from (old, new) pairs of groups or devices.
37
+
38
+ Group members are matched by position, so the two sides need the same size.
39
+ Neither side may be inside the group being cloned."""
40
+ inside = set(self.devices_in(group)) # type: ignore[attr-defined]
41
+
42
+ def expand(ref: str) -> list[str]:
43
+ ref = ref.lower()
44
+ if ref in self.groups:
45
+ return self.devices_in(ref) # type: ignore[attr-defined]
46
+ if ref in self.devices:
47
+ return [ref]
48
+ raise NetError(f"{ref} is neither a group nor a device")
49
+
50
+ mapping: dict[str, str] = {}
51
+ for old, new in pairs:
52
+ a, b = expand(old), expand(new)
53
+ if len(a) != len(b):
54
+ raise NetError(f"{old} has {len(a)} devices but {new} has {len(b)}: they're matched one to one")
55
+ for x, y in zip(a, b):
56
+ if x in inside or y in inside:
57
+ raise NetError(f"{x if x in inside else y} is inside {group}: endpoints must be outside it")
58
+ if x in mapping:
59
+ raise NetError(f"{x} is listed twice")
60
+ if self.devices[x].kind != self.devices[y].kind:
61
+ raise NetError(f"{x} is a {self.devices[x].name} but {y} is a {self.devices[y].name}")
62
+ mapping[x] = y
63
+ return mapping
64
+
65
+ def clone_group(self, name: str, new_name: str | None = None, dx: int = 0, dy: int = 0,
66
+ rewire: list[tuple[str, str]] | None = None) -> CloneResult:
67
+ """Copy a group: every device in it (nested groups too), every cable with
68
+ both ends inside, the group tree, cluster policies, STP and DHCP-server
69
+ settings. Cables to the outside are not copied, unless ``rewire`` says where
70
+ they go: pairs of (old endpoint, new endpoint), groups or devices, e.g.
71
+ [("rfab1", "rfab2"), ("rfab2", "rfab3")]. A cable from the group to a device
72
+ of the old endpoint is re-made from the copy to the matching device of the
73
+ new one (same port number there if it's free, else the first free one).
74
+
75
+ Copies start healthy (nothing failed) and without addresses: interfaces keep
76
+ their mode, except static ones, which would clash, so they become DHCP
77
+ (computers) or automatic (routers). The new group joins the original's
78
+ parent. dx/dy shift the copies' positions.
79
+ """
80
+ if name not in self.groups:
81
+ raise NetError(f"no such group: {name}")
82
+ if new_name is not None:
83
+ if not GROUP_NAME.match(new_name) or REF.match(new_name):
84
+ raise NetError(f"bad group name {new_name!r}: start with a letter and must not look like a device id")
85
+ if new_name in self.groups or new_name in self.devices:
86
+ raise NetError(f"{new_name} already exists")
87
+
88
+ members = self.devices_in(name) # type: ignore[attr-defined]
89
+ inside = set(members)
90
+ ends = self.endpoint_map(rewire or [], name)
91
+
92
+ # Check room on every new endpoint before creating anything.
93
+ outside = [l for l in self.links if (l.a in inside) != (l.b in inside)]
94
+ need: dict[str, int] = {}
95
+ for l in outside:
96
+ far = l.b if l.a in inside else l.a
97
+ if far in ends:
98
+ need[ends[far]] = need.get(ends[far], 0) + 1
99
+ for dev, n in need.items():
100
+ free = sum(1 for p in range(1, self.devices[dev].nports + 1) if self.link_on(dev, p) is None)
101
+ if free < n:
102
+ d = self.devices[dev]
103
+ raise NetError(f"{dev} needs {n} free port{'s' if n != 1 else ''} for the copy but has {free} "
104
+ f"(try `ports {dev} {d.nports + n - free}`)")
105
+
106
+ # Devices, in a stable order so new numbers follow the old ones.
107
+ dev_map: dict[str, str] = {}
108
+ for old in sorted(members, key=lambda d: (RANK[d[0]], int(d[1:]))):
109
+ src: Device = self.devices[old]
110
+ new: Device = self.add(src.kind, src.nports) # type: ignore[attr-defined]
111
+ new.x, new.y = src.x + dx, src.y + dy
112
+ dev_map[old] = new.id
113
+
114
+ # Internal cables keep their ports and roles. Outside ones are re-made onto
115
+ # their new endpoint if one was given, else left behind.
116
+ cables = skipped = rewired = 0
117
+ moved: list[str] = []
118
+ for l in list(self.links):
119
+ if l.a in inside and l.b in inside:
120
+ self.links.append(Link(dev_map[l.a], l.pa, dev_map[l.b], l.pb, l.role))
121
+ cables += 1
122
+ elif l.a in inside or l.b in inside:
123
+ near, near_port = (l.a, l.pa) if l.a in inside else (l.b, l.pb)
124
+ far, far_port = (l.b, l.pb) if l.a in inside else (l.a, l.pa)
125
+ if far not in ends:
126
+ skipped += 1
127
+ continue
128
+ target = ends[far]
129
+ port = self._port_like(target, far_port)
130
+ if port != far_port:
131
+ moved.append(f"{target}:p{far_port}→p{port}")
132
+ self.links.append(Link(dev_map[near], near_port, target, port, l.role))
133
+ rewired += 1
134
+
135
+ # The group tree, depth first, so every nested group gets a fresh name.
136
+ group_map: dict[str, str] = {}
137
+
138
+ def copy(g: str, forced: str | None) -> str:
139
+ new_g = forced or self.next_group_name(g)
140
+ self.groups[new_g] = [] # reserve the name before naming children
141
+ group_map[g] = new_g
142
+ self.groups[new_g] = [copy(m, None) if m in self.groups else dev_map[m]
143
+ for m in list(self.groups[g])]
144
+ if g in self.clusters:
145
+ self.clusters[new_g] = self.clusters[g]
146
+ return new_g
147
+
148
+ top = copy(name, new_name)
149
+ parent = self.parent_of(name) # type: ignore[attr-defined]
150
+ if parent is not None:
151
+ siblings = self.groups[parent]
152
+ siblings.insert(siblings.index(name) + 1, top)
153
+
154
+ # Per-device settings travel with the copy; addresses don't.
155
+ for old, new in dev_map.items():
156
+ if old in self.stp_off:
157
+ self.stp_off.add(new)
158
+ if old in self.stp_priority:
159
+ self.stp_priority[new] = self.stp_priority[old]
160
+ if old in self.dhcp_off:
161
+ self.dhcp_off.add(new)
162
+ for p in range(1, self.devices[old].nports + 1):
163
+ ifc = self.ifaces.get(f"{old}:{p}")
164
+ if ifc is None:
165
+ continue
166
+ mode = ifc.mode
167
+ if mode == "static":
168
+ mode = "dhcp" if self.devices[old].kind == "c" else "auto"
169
+ self.ifaces[f"{new}:{p}"] = Iface(mode)
170
+ return CloneResult(top, dev_map, group_map, cables, skipped, rewired, moved)
171
+
172
+ def _port_like(self, dev: str, port: int) -> int:
173
+ """The same port number if it's free on dev, else dev's first free port."""
174
+ if port <= self.devices[dev].nports and self.link_on(dev, port) is None:
175
+ return port
176
+ free = self.free_port(self.devices[dev])
177
+ if free is None:
178
+ raise NetError(f"{dev} has no free ports")
179
+ return free
180
+
181
+ def copy_cabling(self, src: str, dst: str, pairs: list[tuple[str, str]]) -> CablingResult:
182
+ """Give group dst the same outside cabling src has to some endpoints, but onto others.
183
+
184
+ ``pairs`` are (old endpoint, new endpoint), groups or devices: for every cable
185
+ from a device of src to a device of an old endpoint, a cable is made from the
186
+ matching device of dst to the matching device of the new endpoint. Matching
187
+ is by position, so src and dst (and each endpoint pair) need the same size
188
+ and kinds. No devices are created and src's internal cables are not copied.
189
+ Cables dst already has are counted, so running it twice changes nothing.
190
+ Ports: the same number as the original where free, else the first free one.
191
+ Everything is checked before any cable is made.
192
+ """
193
+ for g in (src, dst):
194
+ if g not in self.groups:
195
+ raise NetError(f"no such group: {g}")
196
+ a, b = self.devices_in(src), self.devices_in(dst) # type: ignore[attr-defined]
197
+ if len(a) != len(b):
198
+ raise NetError(f"{src} has {len(a)} devices but {dst} has {len(b)}: they're matched one to one")
199
+ match: dict[str, str] = {}
200
+ for x, y in zip(a, b):
201
+ if self.devices[x].kind != self.devices[y].kind:
202
+ raise NetError(f"{x} is a {self.devices[x].name} but {y} is a {self.devices[y].name}")
203
+ match[x] = y
204
+ ends = self.endpoint_map(pairs, src)
205
+ clash = set(ends.values()) & set(b)
206
+ if clash:
207
+ raise NetError(f"{sorted(clash)[0]} is inside {dst}: endpoints must be outside it")
208
+
209
+ # The pattern: how many cables (per role) join each src device to each old endpoint.
210
+ wanted: dict[tuple[str, str, str], list[tuple[int, int]]] = {}
211
+ for l in self.links:
212
+ for near, far in ((l.a, l.b), (l.b, l.a)):
213
+ if near in match and far in ends:
214
+ key = (match[near], ends[far], l.role)
215
+ wanted.setdefault(key, []).append((l.port_of(near), l.port_of(far)))
216
+ made: list[tuple[str, int, str, int, str]] = []
217
+ already = 0
218
+ for (x, y, role), ports in wanted.items():
219
+ have = sum(1 for l in self.links if l.role == role and {l.a, l.b} == {x, y})
220
+ already += min(have, len(ports))
221
+ for px, py in ports[have:]:
222
+ made.append((x, px, y, py, role))
223
+
224
+ need: dict[str, int] = {}
225
+ for x, _, y, _, _ in made:
226
+ need[x] = need.get(x, 0) + 1
227
+ need[y] = need.get(y, 0) + 1
228
+ for dev, n in need.items():
229
+ free = sum(1 for p in range(1, self.devices[dev].nports + 1) if self.link_on(dev, p) is None)
230
+ if free < n:
231
+ nports = self.devices[dev].nports
232
+ raise NetError(f"{dev} needs {n} free port{'s' if n != 1 else ''} but has {free} "
233
+ f"(try `ports {dev} {nports + n - free}`)")
234
+
235
+ moved: list[str] = []
236
+ for x, px, y, py, role in made:
237
+ qx, qy = self._port_like(x, px), self._port_like(y, py)
238
+ moved += [f"{d}:p{p}→p{q}" for d, p, q in ((x, px, qx), (y, py, qy)) if p != q]
239
+ self.links.append(Link(x, qx, y, qy, role))
240
+ if made:
241
+ self.mac_tables = {}
242
+ return CablingResult(len(made), already, moved)
243
+
244
+
245
+ @dataclass
246
+ class CablingResult:
247
+ made: int # cables created
248
+ already: int # cables the pattern wanted that already existed
249
+ moved: list[str] # "r3:p6→p7": ports that couldn't mirror the original