python-netgear-switch-library 0.0.post154__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 (66) hide show
  1. netgear_switch/__init__.py +132 -0
  2. netgear_switch/_dispatch.py +178 -0
  3. netgear_switch/_version.py +24 -0
  4. netgear_switch/aio_api.py +529 -0
  5. netgear_switch/cli/__init__.py +1 -0
  6. netgear_switch/cli/capture.py +131 -0
  7. netgear_switch/cli/context.py +39 -0
  8. netgear_switch/cli/format.py +201 -0
  9. netgear_switch/cli/main.py +484 -0
  10. netgear_switch/cli/resolve.py +108 -0
  11. netgear_switch/cli/safety.py +71 -0
  12. netgear_switch/config.py +184 -0
  13. netgear_switch/errors.py +52 -0
  14. netgear_switch/http_read.py +174 -0
  15. netgear_switch/http_write.py +420 -0
  16. netgear_switch/models.py +156 -0
  17. netgear_switch/nsdp_read.py +221 -0
  18. netgear_switch/nsdp_write.py +315 -0
  19. netgear_switch/protocols/__init__.py +1 -0
  20. netgear_switch/protocols/http/__init__.py +1 -0
  21. netgear_switch/protocols/http/crypt.py +29 -0
  22. netgear_switch/protocols/http/endpoints.py +165 -0
  23. netgear_switch/protocols/http/forms.py +77 -0
  24. netgear_switch/protocols/http/parse.py +238 -0
  25. netgear_switch/protocols/http/session.py +29 -0
  26. netgear_switch/protocols/nsdp/__init__.py +7 -0
  27. netgear_switch/protocols/nsdp/auth.py +33 -0
  28. netgear_switch/protocols/nsdp/client.py +67 -0
  29. netgear_switch/protocols/nsdp/parsers.py +209 -0
  30. netgear_switch/protocols/nsdp/protocol.py +201 -0
  31. netgear_switch/protocols/nsdp/types.py +137 -0
  32. netgear_switch/protocols/nsdp/write.py +98 -0
  33. netgear_switch/protocols/snmp/__init__.py +1 -0
  34. netgear_switch/protocols/snmp/client.py +88 -0
  35. netgear_switch/protocols/snmp/oids.py +125 -0
  36. netgear_switch/protocols/snmp/parse.py +777 -0
  37. netgear_switch/protocols/snmp/write.py +112 -0
  38. netgear_switch/py.typed +0 -0
  39. netgear_switch/registry.py +227 -0
  40. netgear_switch/snmp_read.py +226 -0
  41. netgear_switch/snmp_write.py +625 -0
  42. netgear_switch/sync_api.py +557 -0
  43. netgear_switch/transport/__init__.py +1 -0
  44. netgear_switch/transport/aio/__init__.py +1 -0
  45. netgear_switch/transport/aio/nsdp_udp.py +152 -0
  46. netgear_switch/transport/aio/snmp_pysnmp.py +247 -0
  47. netgear_switch/transport/http/__init__.py +1 -0
  48. netgear_switch/transport/http/client.py +217 -0
  49. netgear_switch/transport/sync/__init__.py +1 -0
  50. netgear_switch/transport/sync/nsdp_udp.py +109 -0
  51. netgear_switch/transport/sync/snmp_netsnmp_cli.py +257 -0
  52. netgear_switch/virtual/__init__.py +8 -0
  53. netgear_switch/virtual/faces/__init__.py +2 -0
  54. netgear_switch/virtual/faces/http.py +164 -0
  55. netgear_switch/virtual/faces/mibview.py +92 -0
  56. netgear_switch/virtual/faces/nsdp.py +124 -0
  57. netgear_switch/virtual/faces/snmp.py +412 -0
  58. netgear_switch/virtual/seed.py +220 -0
  59. netgear_switch/virtual/server.py +106 -0
  60. netgear_switch/virtual/state.py +615 -0
  61. netgear_switch/virtual/web.py +210 -0
  62. python_netgear_switch_library-0.0.post154.dist-info/METADATA +85 -0
  63. python_netgear_switch_library-0.0.post154.dist-info/RECORD +66 -0
  64. python_netgear_switch_library-0.0.post154.dist-info/WHEEL +4 -0
  65. python_netgear_switch_library-0.0.post154.dist-info/entry_points.txt +2 -0
  66. python_netgear_switch_library-0.0.post154.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,201 @@
1
+ """Pure output formatting for ngsw: JSON and human-readable tables.
2
+
3
+ Every function is a pure ``model object(s) -> str`` map (except ``emit``, which
4
+ prints), so the whole module is unit-testable without a switch or network.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import enum
11
+ import json
12
+ from typing import TYPE_CHECKING, TypeVar
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable, Sequence
16
+
17
+ from netgear_switch.models import (
18
+ LLDPNeighbor,
19
+ MacEntry,
20
+ MgmtIpConfig,
21
+ PoEStatus,
22
+ PortStats,
23
+ PortStatus,
24
+ Sensor,
25
+ SwitchData,
26
+ VLANInfo,
27
+ )
28
+
29
+ from .context import CliContext
30
+
31
+ T = TypeVar("T")
32
+
33
+
34
+ def jsonify(obj: object) -> object:
35
+ """Recursively convert dataclasses / enums / sets into JSON-native values."""
36
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
37
+ return {f.name: jsonify(getattr(obj, f.name)) for f in dataclasses.fields(obj)}
38
+ if isinstance(obj, enum.Enum):
39
+ return obj.value
40
+ if isinstance(obj, set | frozenset):
41
+ return sorted(obj)
42
+ if isinstance(obj, list | tuple):
43
+ return [jsonify(x) for x in obj]
44
+ return obj
45
+
46
+
47
+ def to_json(obj: object) -> str:
48
+ return json.dumps(jsonify(obj), indent=2)
49
+
50
+
51
+ def emit(ctx: CliContext, obj: T, table_fn: Callable[[T], str]) -> None:
52
+ """Print ``obj`` as JSON (when ``ctx.as_json``) or via ``table_fn``."""
53
+ print(to_json(obj) if ctx.as_json else table_fn(obj), file=ctx.out)
54
+
55
+
56
+ def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
57
+ widths = [len(h) for h in headers]
58
+ for row in rows:
59
+ for i, cell in enumerate(row):
60
+ widths[i] = max(widths[i], len(cell))
61
+
62
+ def render(cells: Sequence[str]) -> str:
63
+ return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells))
64
+
65
+ return "\n".join([render(headers), *(render(r) for r in rows)])
66
+
67
+
68
+ def _ports(port_set: frozenset[int]) -> str:
69
+ return ",".join(str(p) for p in sorted(port_set)) or "-"
70
+
71
+
72
+ def ports_table(ports: Sequence[PortStatus]) -> str:
73
+ rows = [
74
+ [
75
+ str(p.port),
76
+ p.name or "-",
77
+ "up" if p.link_up else "down",
78
+ "enabled" if p.admin_enabled else "disabled",
79
+ "-" if p.speed_mbps is None else str(p.speed_mbps),
80
+ p.description or "-",
81
+ ]
82
+ for p in ports
83
+ ]
84
+ return _table(("Port", "Name", "Link", "Admin", "Speed", "Description"), rows)
85
+
86
+
87
+ def poe_table(entries: Sequence[PoEStatus]) -> str:
88
+ rows = [
89
+ [
90
+ str(e.port),
91
+ "enabled" if e.admin_enabled else "disabled",
92
+ e.detect.value,
93
+ "-" if e.power_mw is None else str(e.power_mw),
94
+ ]
95
+ for e in entries
96
+ ]
97
+ return _table(("Port", "Admin", "Detect", "Power(mW)"), rows)
98
+
99
+
100
+ def vlans_table(vlans: Sequence[VLANInfo]) -> str:
101
+ rows = [
102
+ [
103
+ str(v.vlan_id),
104
+ v.name or "-",
105
+ _ports(v.untagged_ports),
106
+ _ports(v.tagged_ports),
107
+ ]
108
+ for v in vlans
109
+ ]
110
+ return _table(("VLAN", "Name", "Untagged", "Tagged"), rows)
111
+
112
+
113
+ def pvids_table(pvids: Sequence[tuple[int, int]]) -> str:
114
+ rows = [[str(port), str(vlan)] for port, vlan in pvids]
115
+ return _table(("Port", "PVID"), rows)
116
+
117
+
118
+ def lldp_table(neighbors: Sequence[LLDPNeighbor]) -> str:
119
+ rows = [
120
+ [
121
+ str(n.local_port),
122
+ n.remote_sys_name or "-",
123
+ n.remote_port_id or "-",
124
+ n.remote_port_desc or "-",
125
+ n.remote_chassis_id or "-",
126
+ ]
127
+ for n in neighbors
128
+ ]
129
+ return _table(
130
+ ("Port", "Neighbor", "RemotePortId", "RemotePortDesc", "ChassisID"), rows
131
+ )
132
+
133
+
134
+ def macs_table(entries: Sequence[MacEntry]) -> str:
135
+ rows = [
136
+ [e.mac, str(e.port), "-" if e.vlan_id is None else str(e.vlan_id)]
137
+ for e in entries
138
+ ]
139
+ return _table(("MAC", "Port", "VLAN"), rows)
140
+
141
+
142
+ def stats_table(stats: Sequence[PortStats]) -> str:
143
+ def cell(value: int | None) -> str:
144
+ return "-" if value is None else str(value)
145
+
146
+ rows = [
147
+ [
148
+ str(s.port),
149
+ cell(s.rx_bytes),
150
+ cell(s.tx_bytes),
151
+ cell(s.rx_packets),
152
+ cell(s.tx_packets),
153
+ cell(s.rx_errors),
154
+ cell(s.tx_errors),
155
+ ]
156
+ for s in stats
157
+ ]
158
+ headers = (
159
+ "Port", "RxBytes", "TxBytes", "RxPackets", "TxPackets", "RxErrors", "TxErrors",
160
+ )
161
+ return _table(headers, rows)
162
+
163
+
164
+ def sensors_table(sensors: Sequence[Sensor]) -> str:
165
+ rows = [[s.name, s.kind, f"{s.value:g}", s.unit] for s in sensors]
166
+ return _table(("Sensor", "Kind", "Value", "Unit"), rows)
167
+
168
+
169
+ def mgmt_ip_text(cfg: MgmtIpConfig) -> str:
170
+ return "\n".join(
171
+ [
172
+ f"mode: {cfg.mode.value}",
173
+ f"address: {cfg.address or '-'}",
174
+ f"netmask: {cfg.netmask or '-'}",
175
+ f"gateway: {cfg.gateway or '-'}",
176
+ f"mac: {cfg.base_mac or '-'}",
177
+ ]
178
+ )
179
+
180
+
181
+ def snapshot_text(data: SwitchData) -> str:
182
+ sections = [
183
+ f"# {data.model} @ {data.host}",
184
+ "## Ports",
185
+ ports_table(data.ports),
186
+ "## PoE",
187
+ poe_table(data.poe),
188
+ "## VLANs",
189
+ vlans_table(data.vlans),
190
+ "## PVIDs",
191
+ pvids_table(data.pvids),
192
+ "## LLDP",
193
+ lldp_table(data.lldp),
194
+ "## MACs",
195
+ macs_table(data.macs),
196
+ "## Sensors",
197
+ sensors_table(data.sensors),
198
+ ]
199
+ if data.mgmt_ip is not None:
200
+ sections += ["## Mgmt IP", mgmt_ip_text(data.mgmt_ip)]
201
+ return "\n".join(sections)
@@ -0,0 +1,484 @@
1
+ """``ngsw`` entry point: argparse wiring, dispatch, and error handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import json
8
+ import sys
9
+ import traceback
10
+ from typing import TYPE_CHECKING, TypedDict
11
+
12
+ from netgear_switch.errors import NetgearSwitchError
13
+ from netgear_switch.models import VlanMode
14
+ from netgear_switch.registry import MODELS
15
+
16
+ from . import capture, safety
17
+ from . import format as fmt
18
+ from .context import EXIT_OK, EXIT_USAGE, CliContext, exit_code_for
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable, Mapping
22
+ from typing import TextIO
23
+
24
+ from netgear_switch.sync_api import SyncSwitch
25
+
26
+
27
+ def _global_parser(*, suppress_defaults: bool = False) -> argparse.ArgumentParser:
28
+ """Options shared by the top-level parser and every subparser.
29
+
30
+ ``argparse`` subparsers parse into a *fresh* namespace and then copy
31
+ every attribute (including unset defaults) back onto the parent
32
+ namespace, so a global flag given before the subcommand (e.g.
33
+ ``ngsw --json models``) would otherwise be clobbered back to its
34
+ default by the subparser's own copy of the same option. When this
35
+ parser is used as a parent for a *subparser* (``suppress_defaults``),
36
+ each option's default becomes ``argparse.SUPPRESS`` so an option left
37
+ unset at the subcommand level doesn't overwrite a value already set
38
+ at the top level, while explicitly repeating the flag after the
39
+ subcommand still takes effect normally.
40
+ """
41
+ default = argparse.SUPPRESS if suppress_defaults else None
42
+ gp = argparse.ArgumentParser(add_help=False)
43
+ gp.add_argument(
44
+ "--config",
45
+ metavar="INVENTORY.toml",
46
+ help="TOML inventory file",
47
+ default=default,
48
+ )
49
+ gp.add_argument(
50
+ "--switch",
51
+ metavar="NAME",
52
+ help="switch name from the inventory",
53
+ default=default,
54
+ )
55
+ gp.add_argument(
56
+ "--host", metavar="HOST", help="switch host (with --model)", default=default
57
+ )
58
+ gp.add_argument(
59
+ "--model", metavar="KEY", help="model key (with --host)", default=default
60
+ )
61
+ gp.add_argument(
62
+ "--community",
63
+ metavar="STR",
64
+ help="SNMP read community override",
65
+ default=default,
66
+ )
67
+ gp.add_argument(
68
+ "--write-community",
69
+ metavar="STR",
70
+ help="SNMP write community override",
71
+ default=default,
72
+ )
73
+ gp.add_argument(
74
+ "--json",
75
+ action="store_true",
76
+ help="emit machine-readable JSON output",
77
+ default=argparse.SUPPRESS if suppress_defaults else False,
78
+ )
79
+ gp.add_argument(
80
+ "-v",
81
+ "--verbose",
82
+ action="store_true",
83
+ help="print tracebacks on error",
84
+ default=argparse.SUPPRESS if suppress_defaults else False,
85
+ )
86
+ return gp
87
+
88
+
89
+ _ModelRow = TypedDict(
90
+ "_ModelRow",
91
+ {
92
+ "key": str,
93
+ "display_name": str,
94
+ "class": str,
95
+ "ports": int,
96
+ "backends": list[str],
97
+ "verified": bool,
98
+ },
99
+ )
100
+
101
+
102
+ def _cmd_models(
103
+ args: argparse.Namespace,
104
+ ctx: CliContext,
105
+ get_switch: Callable[[], SyncSwitch],
106
+ ) -> int:
107
+ del args, get_switch # unused: this handler needs neither
108
+ rows: list[_ModelRow] = [
109
+ {
110
+ "key": m.key,
111
+ "display_name": m.display_name,
112
+ "class": m.switch_class.value,
113
+ "ports": m.port_count,
114
+ "backends": sorted(b.value for b in m.backends),
115
+ "verified": m.verified,
116
+ }
117
+ for m in MODELS.values()
118
+ ]
119
+ if ctx.as_json:
120
+ print(json.dumps(rows, indent=2), file=ctx.out)
121
+ else:
122
+ for row in rows:
123
+ # UNVERIFIED-pending-capture models (registry.py's `verified`
124
+ # flag) are marked so `ngsw models` never implies these are
125
+ # capture-confirmed like the rest of the registry.
126
+ suffix = "" if row["verified"] else " [UNVERIFIED]"
127
+ print(
128
+ f"{row['key']:<12} {row['display_name']:<24} "
129
+ f"{'+'.join(row['backends'])}{suffix}",
130
+ file=ctx.out,
131
+ )
132
+ return EXIT_OK
133
+
134
+
135
+ def _cmd_ports(
136
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
137
+ ) -> int:
138
+ fmt.emit(ctx, get_switch().get_ports(), fmt.ports_table)
139
+ return EXIT_OK
140
+
141
+
142
+ def _cmd_stats(
143
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
144
+ ) -> int:
145
+ fmt.emit(ctx, get_switch().get_stats(), fmt.stats_table)
146
+ return EXIT_OK
147
+
148
+
149
+ def _cmd_vlans(
150
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
151
+ ) -> int:
152
+ fmt.emit(ctx, get_switch().get_vlans(), fmt.vlans_table)
153
+ return EXIT_OK
154
+
155
+
156
+ def _cmd_pvids(
157
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
158
+ ) -> int:
159
+ fmt.emit(ctx, get_switch().get_pvids(), fmt.pvids_table)
160
+ return EXIT_OK
161
+
162
+
163
+ def _cmd_lldp(
164
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
165
+ ) -> int:
166
+ fmt.emit(ctx, get_switch().get_lldp(), fmt.lldp_table)
167
+ return EXIT_OK
168
+
169
+
170
+ def _cmd_macs(
171
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
172
+ ) -> int:
173
+ fmt.emit(ctx, get_switch().get_macs(), fmt.macs_table)
174
+ return EXIT_OK
175
+
176
+
177
+ def _cmd_sensors(
178
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
179
+ ) -> int:
180
+ fmt.emit(ctx, get_switch().get_sensors(), fmt.sensors_table)
181
+ return EXIT_OK
182
+
183
+
184
+ def _cmd_show(
185
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
186
+ ) -> int:
187
+ fmt.emit(ctx, get_switch().snapshot(), fmt.snapshot_text)
188
+ return EXIT_OK
189
+
190
+
191
+ def _cmd_poe(
192
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
193
+ ) -> int:
194
+ switch = get_switch()
195
+ if args.port is None:
196
+ fmt.emit(ctx, switch.get_poe(), fmt.poe_table)
197
+ return EXIT_OK
198
+ if args.action is None:
199
+ print(
200
+ "error: an action (on|off|cycle|clear-fault) is required with a port",
201
+ file=ctx.err,
202
+ )
203
+ return EXIT_USAGE
204
+ actions: dict[str, Callable[[], None]] = {
205
+ "on": lambda: switch.set_poe(args.port, True, force=args.force),
206
+ "off": lambda: switch.set_poe(args.port, False, force=args.force),
207
+ "cycle": lambda: switch.cycle_poe(args.port, force=args.force),
208
+ "clear-fault": lambda: switch.clear_poe_fault(args.port, force=args.force),
209
+ }
210
+ return safety.do_write(
211
+ ctx,
212
+ dry_run=args.dry_run,
213
+ assume_yes=args.yes,
214
+ host=switch.host,
215
+ description=f"set PoE port {args.port} -> {args.action}",
216
+ action=actions[args.action],
217
+ )
218
+
219
+
220
+ def _cmd_port(
221
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
222
+ ) -> int:
223
+ switch = get_switch()
224
+ enabled = args.state == "up"
225
+ return safety.do_write(
226
+ ctx,
227
+ dry_run=args.dry_run,
228
+ assume_yes=args.yes,
229
+ host=switch.host,
230
+ description=f"set port {args.port} {'up' if enabled else 'down'}",
231
+ action=lambda: switch.set_port_enabled(args.port, enabled, force=args.force),
232
+ )
233
+
234
+
235
+ def _cmd_pvid(
236
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
237
+ ) -> int:
238
+ switch = get_switch()
239
+ return safety.do_write(
240
+ ctx,
241
+ dry_run=args.dry_run,
242
+ assume_yes=args.yes,
243
+ host=switch.host,
244
+ description=f"set PVID port {args.port} -> VLAN {args.vlan}",
245
+ action=lambda: switch.set_pvid(args.port, args.vlan, force=args.force),
246
+ )
247
+
248
+
249
+ def _cmd_vlan_set(
250
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
251
+ ) -> int:
252
+ switch = get_switch()
253
+ mode = VlanMode(args.mode)
254
+ return safety.do_write(
255
+ ctx,
256
+ dry_run=args.dry_run,
257
+ assume_yes=args.yes,
258
+ host=switch.host,
259
+ description=f"set VLAN {args.vlan} port {args.port} -> {args.mode}",
260
+ action=lambda: switch.set_vlan_membership(
261
+ args.vlan, args.port, mode, force=args.force
262
+ ),
263
+ )
264
+
265
+
266
+ def _cmd_vlan_create(
267
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
268
+ ) -> int:
269
+ switch = get_switch()
270
+ return safety.do_write(
271
+ ctx,
272
+ dry_run=args.dry_run,
273
+ assume_yes=args.yes,
274
+ host=switch.host,
275
+ description=f"create VLAN {args.vlan} named {args.name!r}",
276
+ action=lambda: switch.create_vlan(args.vlan, args.name, force=args.force),
277
+ )
278
+
279
+
280
+ def _cmd_vlan_delete(
281
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
282
+ ) -> int:
283
+ switch = get_switch()
284
+ return safety.do_write(
285
+ ctx,
286
+ dry_run=args.dry_run,
287
+ assume_yes=args.yes,
288
+ host=switch.host,
289
+ description=f"delete VLAN {args.vlan}",
290
+ action=lambda: switch.delete_vlan(args.vlan, force=args.force),
291
+ )
292
+
293
+
294
+ def _cmd_ip(
295
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
296
+ ) -> int:
297
+ del args
298
+ fmt.emit(ctx, get_switch().get_mgmt_ip(), fmt.mgmt_ip_text)
299
+ return EXIT_OK
300
+
301
+
302
+ def _cmd_ip_set(
303
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
304
+ ) -> int:
305
+ switch = get_switch()
306
+ return safety.do_write(
307
+ ctx,
308
+ dry_run=args.dry_run,
309
+ assume_yes=args.yes,
310
+ host=switch.host,
311
+ description=(
312
+ f"set mgmt IP {args.address} netmask {args.netmask} gw {args.gateway}"
313
+ ),
314
+ action=lambda: switch.set_mgmt_ip(
315
+ args.address, args.netmask, args.gateway, force=args.force
316
+ ),
317
+ warning="WARNING: a wrong management-IP change can strand the switch.",
318
+ )
319
+
320
+
321
+ def _cmd_capture(
322
+ args: argparse.Namespace, ctx: CliContext, get_switch: Callable[[], SyncSwitch]
323
+ ) -> int:
324
+ from pathlib import Path
325
+
326
+ switch = get_switch()
327
+ record = capture.run_capture(
328
+ switch,
329
+ Path(args.output),
330
+ snapshot_only=args.snapshot_only,
331
+ raw_walk=None if args.snapshot_only else capture.default_raw_walk,
332
+ )
333
+ print(f"wrote capture for {record.model} to {args.output}", file=ctx.out)
334
+ for note in record.notes:
335
+ print(f"note: {note}", file=ctx.err)
336
+ return EXIT_OK
337
+
338
+
339
+ def build_parser() -> argparse.ArgumentParser:
340
+ gp = _global_parser()
341
+ parser = argparse.ArgumentParser(
342
+ prog="ngsw",
343
+ parents=[gp],
344
+ description="Query and control Netgear switches over the SyncSwitch facade.",
345
+ )
346
+ parser.set_defaults(func=None)
347
+ sub = parser.add_subparsers(dest="command")
348
+ child_gp = _global_parser(suppress_defaults=True)
349
+ models = sub.add_parser(
350
+ "models", parents=[child_gp], help="list the known switch models"
351
+ )
352
+ models.set_defaults(func=_cmd_models)
353
+
354
+ def read_cmd(name: str, handler: object, help_text: str) -> None:
355
+ parser_ = sub.add_parser(name, parents=[child_gp], help=help_text)
356
+ parser_.set_defaults(func=handler)
357
+
358
+ read_cmd("ports", _cmd_ports, "show port status")
359
+ read_cmd("stats", _cmd_stats, "show port RX/TX counters")
360
+ read_cmd("vlans", _cmd_vlans, "show VLANs")
361
+ read_cmd("pvids", _cmd_pvids, "show per-port PVIDs")
362
+ read_cmd("lldp", _cmd_lldp, "show LLDP neighbours")
363
+ read_cmd("macs", _cmd_macs, "show the MAC/FDB table")
364
+ read_cmd("sensors", _cmd_sensors, "show sensors")
365
+ read_cmd("show", _cmd_show, "show a full switch snapshot")
366
+
367
+ poe = sub.add_parser(
368
+ "poe", parents=[child_gp], help="show PoE status, or control a port's PoE"
369
+ )
370
+ poe.add_argument("port", type=int, nargs="?", help="port number to control")
371
+ poe.add_argument(
372
+ "action",
373
+ nargs="?",
374
+ choices=("on", "off", "cycle", "clear-fault"),
375
+ help="PoE action for the given port",
376
+ )
377
+ safety.add_write_args(poe)
378
+ poe.set_defaults(func=_cmd_poe)
379
+
380
+ port = sub.add_parser("port", parents=[child_gp], help="bring a port up or down")
381
+ port.add_argument("port", type=int, help="port number")
382
+ port.add_argument("state", choices=("up", "down"), help="admin state")
383
+ safety.add_write_args(port)
384
+ port.set_defaults(func=_cmd_port)
385
+
386
+ pvid = sub.add_parser("pvid", parents=[child_gp], help="set a port's PVID")
387
+ pvid.add_argument("port", type=int, help="port number")
388
+ pvid.add_argument("vlan", type=int, help="VLAN id")
389
+ safety.add_write_args(pvid)
390
+ pvid.set_defaults(func=_cmd_pvid)
391
+
392
+ vlan = sub.add_parser(
393
+ "vlan", parents=[child_gp], help="create/delete VLANs or set membership"
394
+ )
395
+ vlan_sub = vlan.add_subparsers(dest="vlan_cmd", required=True)
396
+
397
+ vlan_set = vlan_sub.add_parser(
398
+ "set", parents=[child_gp], help="set port VLAN membership"
399
+ )
400
+ vlan_set.add_argument("vlan", type=int)
401
+ vlan_set.add_argument("port", type=int)
402
+ vlan_set.add_argument("mode", choices=("untagged", "tagged", "excluded"))
403
+ safety.add_write_args(vlan_set)
404
+ vlan_set.set_defaults(func=_cmd_vlan_set)
405
+
406
+ vlan_create = vlan_sub.add_parser(
407
+ "create", parents=[child_gp], help="create a VLAN"
408
+ )
409
+ vlan_create.add_argument("vlan", type=int)
410
+ vlan_create.add_argument("name")
411
+ safety.add_write_args(vlan_create)
412
+ vlan_create.set_defaults(func=_cmd_vlan_create)
413
+
414
+ vlan_delete = vlan_sub.add_parser(
415
+ "delete", parents=[child_gp], help="delete a VLAN"
416
+ )
417
+ vlan_delete.add_argument("vlan", type=int)
418
+ safety.add_write_args(vlan_delete)
419
+ vlan_delete.set_defaults(func=_cmd_vlan_delete)
420
+
421
+ ip = sub.add_parser("ip", parents=[child_gp], help="show or set the management IP")
422
+ ip.set_defaults(func=_cmd_ip)
423
+ ip_sub = ip.add_subparsers(dest="ip_cmd")
424
+ ip_set = ip_sub.add_parser(
425
+ "set", parents=[child_gp], help="set the management IP"
426
+ )
427
+ ip_set.add_argument("address")
428
+ ip_set.add_argument("netmask")
429
+ ip_set.add_argument("gateway")
430
+ safety.add_write_args(ip_set)
431
+ ip_set.set_defaults(func=_cmd_ip_set)
432
+
433
+ cap = sub.add_parser(
434
+ "capture",
435
+ parents=[child_gp],
436
+ help="record a real switch's state + protocol exchanges (opt-in, live)",
437
+ )
438
+ cap.add_argument("output", help="output JSON file path")
439
+ cap.add_argument(
440
+ "--snapshot-only",
441
+ action="store_true",
442
+ help="record only the state snapshot (skip the live raw protocol walk)",
443
+ )
444
+ cap.set_defaults(func=_cmd_capture)
445
+
446
+ return parser
447
+
448
+
449
+ def main(
450
+ argv: list[str] | None = None,
451
+ *,
452
+ switch_factory: Callable[[argparse.Namespace, CliContext], SyncSwitch]
453
+ | None = None,
454
+ stdin: TextIO | None = None,
455
+ stdout: TextIO | None = None,
456
+ stderr: TextIO | None = None,
457
+ env: Mapping[str, str] | None = None,
458
+ prompt: Callable[[str], str] = getpass.getpass,
459
+ ) -> int:
460
+ parser = build_parser()
461
+ args = parser.parse_args(argv)
462
+ out = sys.stdout if stdout is None else stdout
463
+ err = sys.stderr if stderr is None else stderr
464
+ inp = sys.stdin if stdin is None else stdin
465
+ ctx = CliContext(out=out, err=err, inp=inp, as_json=args.json, verbose=args.verbose)
466
+ if args.func is None:
467
+ parser.print_help(err)
468
+ return EXIT_USAGE
469
+
470
+ def get_switch() -> SyncSwitch:
471
+ if switch_factory is not None:
472
+ return switch_factory(args, ctx)
473
+ from .resolve import resolve_switch
474
+
475
+ return resolve_switch(args, env=env, prompt=prompt)
476
+
477
+ try:
478
+ result: int = args.func(args, ctx, get_switch)
479
+ return result
480
+ except NetgearSwitchError as exc:
481
+ if ctx.verbose:
482
+ traceback.print_exc(file=err)
483
+ print(f"error: {exc}", file=err)
484
+ return exit_code_for(exc)