smolpy 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.
smolpy/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from smolpy.dsl.mqtt_broker import MQTTBroker
2
+ from smolpy.dsl.network import Network
3
+
4
+ __all__ = ["Network", "MQTTBroker"]
smolpy/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import runpy
5
+ import sys
6
+ import time
7
+ import traceback
8
+
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
12
+ from rich.text import Text
13
+ from rich.theme import Theme
14
+
15
+ _THEME = Theme(
16
+ {
17
+ "banner.title": "bold cyan",
18
+ "banner.sub": "dim white",
19
+ "info": "bold white",
20
+ "success": "bold green",
21
+ "error": "bold red",
22
+ "dim": "dim white",
23
+ }
24
+ )
25
+
26
+ console = Console(theme=_THEME)
27
+
28
+ _BANNER = Text.assemble(
29
+ ("██████╗ ██╗ ██╗███████╗███╗ ███╗ ██████╗ ██╗\n", "bold cyan"),
30
+ ("██╔══██╗╚██╗ ██╔╝██╔════╝████╗ ████║██╔═══██╗██║\n", "bold cyan"),
31
+ ("██████╔╝ ╚████╔╝ ███████╗██╔████╔██║██║ ██║██║\n", "bold cyan"),
32
+ ("██╔═══╝ ╚██╔╝ ╚════██║██║╚██╔╝██║██║ ██║██║\n", "bold cyan"),
33
+ ("██║ ██║ ███████║██║ ╚═╝ ██║╚██████╔╝███████╗\n", "bold cyan"),
34
+ ("╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝\n", "bold cyan"),
35
+ (" Network Description Language & Discrete-Event Simulator", "dim white"),
36
+ )
37
+
38
+
39
+ def _print_banner() -> None:
40
+ console.print()
41
+ console.print(Panel(_BANNER, border_style="cyan", padding=(0, 2)))
42
+ console.print()
43
+
44
+
45
+ def _cmd_run(script: str, output: str | None, text_mode: bool, extra_args: list[str]) -> None:
46
+ _print_banner()
47
+ console.print(f" [info]Script[/] [dim]{script}[/]")
48
+ console.print()
49
+
50
+ import os
51
+
52
+ if text_mode:
53
+ os.environ["SMOLPY_TEXT_MODE"] = "1"
54
+
55
+ start = time.perf_counter()
56
+ namespace: dict = {}
57
+
58
+ with Progress(
59
+ SpinnerColumn(spinner_name="dots", style="cyan"),
60
+ TextColumn("[bold white]{task.description}"),
61
+ TimeElapsedColumn(),
62
+ console=console,
63
+ transient=False,
64
+ ) as progress:
65
+ task = progress.add_task("Running simulation…", total=None)
66
+
67
+ try:
68
+ sys.argv = [script] + extra_args
69
+ namespace = runpy.run_path(script, run_name="__main__")
70
+ progress.update(task, description="Simulation complete")
71
+ except Exception:
72
+ progress.stop()
73
+ console.print()
74
+ console.print(
75
+ Panel(
76
+ traceback.format_exc(),
77
+ title="[error]Error[/]",
78
+ border_style="red",
79
+ padding=(1, 2),
80
+ )
81
+ )
82
+ sys.exit(1)
83
+
84
+ elapsed = time.perf_counter() - start
85
+ console.print()
86
+ console.print(
87
+ Panel(
88
+ f"[success]Finished in {elapsed:.2f}s[/]",
89
+ border_style="green",
90
+ padding=(0, 2),
91
+ )
92
+ )
93
+ console.print()
94
+
95
+ if output:
96
+ result = namespace.get("result")
97
+ if result is not None:
98
+ result.export(output)
99
+ console.print(f" Results exported → {output}")
100
+ console.print()
101
+
102
+
103
+ def _cmd_demo() -> None:
104
+ """Run a built-in 3-client saturation demo in text mode."""
105
+ from smolpy import Network
106
+
107
+ _print_banner()
108
+ console.print(" [info]Demo[/] 3 clients → switch → server (10 s, text mode)\n")
109
+
110
+ net = Network("demo")
111
+ sw = net.switch("core-sw", ports=8, mode="store-and-forward")
112
+ server = net.adapter("server", ip="10.0.0.100")
113
+ net.link(server, sw, speed=1_000, length=2)
114
+
115
+ fps = int(100_000_000 / (1_518 * 8))
116
+ for i in range(1, 4):
117
+ c = net.adapter(f"client-{i}", ip=f"10.0.0.{i}")
118
+ net.link(c, sw, speed=100, length=10)
119
+ c.sends(to=server, rate=fps, size=1_518, pattern="constant", delay_ms=(i - 1) * 2_000)
120
+ net.observe("bytes_sent", on=c, every=500)
121
+
122
+ net.observe("throughput", on=server, every=200)
123
+ net.observe("latency", on=server, every=200)
124
+ net.observe("queue_depth", on=sw, every=200)
125
+ net.observe("bytes_received", on=server, every=500)
126
+
127
+ result = net.simulate(duration=10_000, text=True)
128
+ result.report()
129
+
130
+
131
+ def main() -> None:
132
+ parser = argparse.ArgumentParser(
133
+ prog="smolpy",
134
+ description="SMOLPy — Network simulation DSL and discrete-event simulator",
135
+ )
136
+ sub = parser.add_subparsers(dest="command", required=True)
137
+
138
+ run_cmd = sub.add_parser(
139
+ "run",
140
+ help="Execute a SMOLPy simulation script",
141
+ description="Load and run a Python script that uses the SMOLPy DSL.",
142
+ )
143
+ run_cmd.add_argument("script", help="Path to the .py script to run")
144
+ run_cmd.add_argument(
145
+ "--output",
146
+ "-o",
147
+ default=None,
148
+ metavar="FILE",
149
+ help="Write metric time-series to FILE (.csv or .json)",
150
+ )
151
+ run_cmd.add_argument(
152
+ "--text",
153
+ "-t",
154
+ action="store_true",
155
+ default=False,
156
+ help="Force text-mode dashboard even if the script uses live=True",
157
+ )
158
+ run_cmd.add_argument(
159
+ "args",
160
+ nargs=argparse.REMAINDER,
161
+ help="Extra arguments forwarded to the script via sys.argv",
162
+ )
163
+
164
+ sub.add_parser(
165
+ "demo",
166
+ help="Run a built-in 3-client saturation demo (no script needed)",
167
+ description="Quick sanity-check: 3 clients ramping onto a shared switch, text-mode output.",
168
+ )
169
+
170
+ args = parser.parse_args()
171
+
172
+ if args.command == "run":
173
+ # REMAINDER captures flags that appear after the script name, so check both
174
+ text = args.text or "--text" in args.args or "-t" in args.args
175
+ extra = [a for a in args.args if a not in ("--text", "-t")]
176
+ _cmd_run(args.script, args.output, text, extra)
177
+ elif args.command == "demo":
178
+ _cmd_demo()
smolpy/dsl/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from smolpy.dsl.adapter import Adapter
2
+ from smolpy.dsl.hub import Hub
3
+ from smolpy.dsl.link import Link
4
+ from smolpy.dsl.network import Network
5
+ from smolpy.dsl.switch import Switch
6
+
7
+ __all__ = ["Network", "Adapter", "Switch", "Hub", "Link"]
smolpy/dsl/adapter.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Literal
4
+
5
+ from smolpy.dsl.node import Node
6
+
7
+ if TYPE_CHECKING:
8
+ pass
9
+
10
+ TrafficPattern = Literal["constant", "poisson", "bursty"]
11
+ FrameSize = int | Literal["imix"]
12
+
13
+
14
+ class TrafficSpec:
15
+ def __init__(
16
+ self,
17
+ destination: Adapter,
18
+ rate: float,
19
+ size: FrameSize,
20
+ pattern: TrafficPattern,
21
+ delay_ms: float = 0.0,
22
+ ) -> None:
23
+ self.destination = destination
24
+ self.rate = rate # frames/s
25
+ self.size = size # bytes or "imix"
26
+ self.pattern = pattern
27
+ self.delay_ms = delay_ms
28
+
29
+
30
+ class MQTTSpec:
31
+ def __init__(
32
+ self,
33
+ broker, # MQTTBroker
34
+ topic: str,
35
+ rate_hz: float, # messages per second
36
+ payload_bytes: int,
37
+ qos: int,
38
+ delay_ms: float = 0.0,
39
+ ) -> None:
40
+ self.broker = broker
41
+ self.topic = topic
42
+ self.rate_hz = rate_hz
43
+ self.payload_bytes = payload_bytes
44
+ self.qos = qos
45
+ self.delay_ms = delay_ms
46
+ # Wire-level frame size:
47
+ # Ethernet+IPv4+TCP overhead = 54 B
48
+ # MQTT fixed header = 2 B, topic-length field = 2 B
49
+ # topic name = len(topic) B, packet-ID (QoS>0) = 2 B
50
+ ethernet_ip_tcp = 54
51
+ mqtt_overhead = 2 + 2 + len(topic) + (2 if qos > 0 else 0)
52
+ self.frame_size: int = ethernet_ip_tcp + mqtt_overhead + payload_bytes
53
+
54
+
55
+ class Adapter(Node):
56
+ """NIC — generates and receives Ethernet frames."""
57
+
58
+ def __init__(self, name: str, ip: str, mac: str | None = None) -> None:
59
+ super().__init__(name)
60
+ self.ip = ip
61
+ self.mac = mac or _derive_mac(ip)
62
+ self.traffic_specs: list[TrafficSpec] = []
63
+ self.mqtt_specs: list[MQTTSpec] = []
64
+
65
+ def sends(
66
+ self,
67
+ to: Adapter,
68
+ rate: float,
69
+ size: FrameSize = 512,
70
+ pattern: TrafficPattern = "constant",
71
+ delay_ms: float = 0.0,
72
+ ) -> None:
73
+ self.traffic_specs.append(TrafficSpec(to, rate, size, pattern, delay_ms))
74
+
75
+ def publishes(
76
+ self,
77
+ to, # MQTTBroker
78
+ topic: str,
79
+ rate: float = 1.0, # messages / second
80
+ payload: int = 20, # payload bytes
81
+ qos: int = 0,
82
+ delay_ms: float = 0.0,
83
+ ) -> None:
84
+ self.mqtt_specs.append(MQTTSpec(to, topic, rate, payload, qos, delay_ms))
85
+
86
+
87
+ def _derive_mac(ip: str) -> str:
88
+ octets = ip.split(".")[-3:]
89
+ return f"02:00:{':'.join(f'{int(o):02x}' for o in octets)}"
smolpy/dsl/hub.py ADDED
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from smolpy.dsl.node import Node
4
+
5
+
6
+ class Hub(Node):
7
+ """Layer-1 hub — shared collision domain."""
8
+
9
+ def __init__(self, name: str, ports: int) -> None:
10
+ super().__init__(name)
11
+ self.ports = ports
smolpy/dsl/link.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from smolpy.dsl.node import Node
8
+
9
+
10
+ @dataclass
11
+ class Link:
12
+ """Point-to-point cable between two nodes."""
13
+
14
+ endpoint_a: Node
15
+ endpoint_b: Node
16
+ speed: float # Mb/s
17
+ length: float # metres
18
+ duplex: bool = True
19
+
20
+ @property
21
+ def speed_bps(self) -> float:
22
+ return self.speed * 1_000_000
23
+
24
+ @property
25
+ def propagation_delay_us(self) -> float:
26
+ """One-way propagation delay in microseconds."""
27
+ return (self.length / 2e8) * 1_000_000
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from smolpy.dsl.adapter import _derive_mac
4
+ from smolpy.dsl.node import Node
5
+
6
+
7
+ class MQTTBroker(Node):
8
+ """Application-layer MQTT message broker.
9
+
10
+ Receives PUBLISH frames from client adapters and forwards copies
11
+ to all registered subscribers for each topic.
12
+ Supports QoS 0 (fire-and-forget) and QoS 1 (PUBACK acknowledgement).
13
+ """
14
+
15
+ def __init__(self, name: str, ip: str, mac: str | None = None) -> None:
16
+ super().__init__(name)
17
+ self.ip = ip
18
+ self.mac = mac or _derive_mac(ip)
19
+ self._routes: dict[str, list] = {} # topic → [Adapter, ...]
20
+
21
+ def routes(self, topic: str, to: list) -> None:
22
+ """Register subscriber adapters for *topic*."""
23
+ self._routes[topic] = list(to)
smolpy/dsl/network.py ADDED
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import networkx as nx
4
+
5
+ from smolpy.dsl.adapter import Adapter
6
+ from smolpy.dsl.hub import Hub
7
+ from smolpy.dsl.link import Link
8
+ from smolpy.dsl.mqtt_broker import MQTTBroker
9
+ from smolpy.dsl.node import Node
10
+ from smolpy.dsl.observation import MetricName, Observation
11
+ from smolpy.dsl.switch import Switch, SwitchMode
12
+
13
+
14
+ class SimulationResult:
15
+ def __init__(
16
+ self,
17
+ metrics: dict[str, list[tuple[float, float]]],
18
+ network: Network,
19
+ ) -> None:
20
+ self.metrics = metrics
21
+ self._network = network
22
+
23
+ def export(self, path: str, format: str | None = None) -> None:
24
+ """Write all metric time-series to *path*.
25
+
26
+ Format is inferred from the file extension when *format* is omitted.
27
+ Supported: ``"csv"`` (long format: time_ms, metric, value)
28
+ and ``"json"`` (dict of lists-of-pairs).
29
+ """
30
+ import csv
31
+ import json
32
+ import pathlib
33
+
34
+ p = pathlib.Path(path)
35
+ fmt = (format or p.suffix.lstrip(".")).lower()
36
+
37
+ if fmt == "csv":
38
+ with open(p, "w", newline="", encoding="utf-8") as fh:
39
+ w = csv.writer(fh)
40
+ w.writerow(["time_ms", "metric", "value"])
41
+ for key in sorted(self.metrics):
42
+ for t, v in self.metrics[key]:
43
+ w.writerow([f"{t:.3f}", key, f"{v:.6f}"])
44
+ elif fmt == "json":
45
+ payload = {
46
+ "network": self._network.name,
47
+ "metrics": {
48
+ k: [[round(t, 3), round(v, 6)] for t, v in s] for k, s in self.metrics.items()
49
+ },
50
+ }
51
+ with open(p, "w", encoding="utf-8") as fh:
52
+ json.dump(payload, fh, indent=2)
53
+ else:
54
+ raise ValueError(f"Unknown format {fmt!r}. Supported formats: 'csv', 'json'.")
55
+
56
+ def plot(self) -> None:
57
+ from smolpy.viz.dashboard import show
58
+
59
+ show(self)
60
+
61
+ def report(self) -> None:
62
+ if not self.metrics:
63
+ print("No metrics collected. Add net.observe(...) calls before simulating.")
64
+ return
65
+ _UNITS = {
66
+ "throughput": "Mb/s",
67
+ "latency": "µs",
68
+ "frame_loss": "%",
69
+ "collision_rate": "/s",
70
+ "queue_depth": "fr",
71
+ "utilization": "%",
72
+ "bytes_sent": "MB",
73
+ "bytes_received": "MB",
74
+ }
75
+ col = max(len(k) for k in self.metrics) + 2
76
+ header = f"{'Metric':<{col}} {'n':>6} {'avg':>10} {'min':>10} {'max':>10}"
77
+ print(f"\n{header}")
78
+ print("-" * len(header))
79
+ for key in sorted(self.metrics):
80
+ vals = [v for _, v in self.metrics[key]]
81
+ if not vals:
82
+ continue
83
+ unit = next((u for m, u in _UNITS.items() if m in key), "")
84
+ avg = sum(vals) / len(vals)
85
+ print(
86
+ f"{key:<{col}} {len(vals):>6} {avg:>9.3f}{unit:>5}"
87
+ f" {min(vals):>9.3f}{unit:>5} {max(vals):>9.3f}{unit:>5}"
88
+ )
89
+ print()
90
+
91
+
92
+ class Network:
93
+ """Top-level container — describes topology, traffic, and observations."""
94
+
95
+ def __init__(self, name: str) -> None:
96
+ self.name = name
97
+ self._graph: nx.Graph = nx.Graph()
98
+ self._nodes: dict[str, Node] = {}
99
+ self._links: list[Link] = []
100
+ self._observations: list[Observation] = []
101
+
102
+ # --- topology builders ---
103
+
104
+ def adapter(self, name: str, ip: str, mac: str | None = None) -> Adapter:
105
+ node = Adapter(name, ip=ip, mac=mac)
106
+ self._register(node)
107
+ return node
108
+
109
+ def switch(self, name: str, ports: int, mode: SwitchMode = "store-and-forward") -> Switch:
110
+ node = Switch(name, ports=ports, mode=mode)
111
+ self._register(node)
112
+ return node
113
+
114
+ def hub(self, name: str, ports: int) -> Hub:
115
+ node = Hub(name, ports=ports)
116
+ self._register(node)
117
+ return node
118
+
119
+ def mqtt_broker(self, name: str, ip: str, mac: str | None = None) -> MQTTBroker:
120
+ node = MQTTBroker(name, ip=ip, mac=mac)
121
+ self._register(node)
122
+ return node
123
+
124
+ def link(self, a: Node, b: Node, speed: float, length: float, duplex: bool = True) -> Link:
125
+ lnk = Link(endpoint_a=a, endpoint_b=b, speed=speed, length=length, duplex=duplex)
126
+ a.links.append(lnk)
127
+ b.links.append(lnk)
128
+ self._links.append(lnk)
129
+ self._graph.add_edge(a.name, b.name, link=lnk)
130
+ return lnk
131
+
132
+ # --- observation builder ---
133
+
134
+ def observe(self, metric: MetricName, on: Node, every: float) -> None:
135
+ self._observations.append(Observation(metric=metric, target=on, interval_ms=every))
136
+
137
+ # --- simulation control ---
138
+
139
+ def simulate(
140
+ self,
141
+ duration: float,
142
+ *,
143
+ live: bool = False,
144
+ text: bool = False,
145
+ ) -> SimulationResult:
146
+ import os
147
+
148
+ if os.environ.get("SMOLPY_TEXT_MODE") == "1":
149
+ live, text = False, True
150
+ if live:
151
+ from smolpy.viz.dashboard import show_live
152
+
153
+ return show_live(self, duration)
154
+ if text:
155
+ from smolpy.viz.text_dashboard import show_text
156
+
157
+ return show_text(self, duration)
158
+ from smolpy.sim.engine import run_simulation
159
+
160
+ return run_simulation(self, duration_ms=duration)
161
+
162
+ # --- internals ---
163
+
164
+ def _register(self, node: Node) -> None:
165
+ if node.name in self._nodes:
166
+ raise ValueError(f"Node {node.name!r} already exists in network {self.name!r}")
167
+ self._nodes[node.name] = node
168
+ self._graph.add_node(node.name, node=node)
smolpy/dsl/node.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from smolpy.dsl.link import Link
8
+
9
+
10
+ class Node(ABC):
11
+ """Base class for all network nodes (Adapter, Switch, Hub)."""
12
+
13
+ def __init__(self, name: str) -> None:
14
+ self.name = name
15
+ self.links: list[Link] = []
16
+
17
+ def __repr__(self) -> str:
18
+ return f"{self.__class__.__name__}({self.name!r})"
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Literal
5
+
6
+ if TYPE_CHECKING:
7
+ from smolpy.dsl.node import Node
8
+
9
+ MetricName = Literal[
10
+ "throughput",
11
+ "latency",
12
+ "frame_loss",
13
+ "collision_rate",
14
+ "queue_depth",
15
+ "utilization",
16
+ "bytes_sent",
17
+ "bytes_received",
18
+ "broker_queue",
19
+ ]
20
+
21
+
22
+ @dataclass
23
+ class Observation:
24
+ metric: MetricName
25
+ target: Node
26
+ interval_ms: float
smolpy/dsl/switch.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from smolpy.dsl.node import Node
6
+
7
+ SwitchMode = Literal["store-and-forward", "cut-through"]
8
+
9
+
10
+ class Switch(Node):
11
+ """Layer-2 switch with MAC table and per-port queues."""
12
+
13
+ def __init__(self, name: str, ports: int, mode: SwitchMode = "store-and-forward") -> None:
14
+ super().__init__(name)
15
+ self.ports = ports
16
+ self.mode = mode
17
+ self.mac_table: dict[str, int] = {} # mac -> port index
smolpy/sim/__init__.py ADDED
File without changes