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,529 @@
1
+ """Public asynchronous read/write facade: AsyncSwitch (mirror of SyncSwitch)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import TYPE_CHECKING, Any, TypeVar
6
+
7
+ from ._dispatch import (
8
+ build_async_http_client,
9
+ build_async_nsdp_client,
10
+ build_async_snmp_client,
11
+ build_async_snmp_write_client,
12
+ http_reads_supported,
13
+ require_mac_table,
14
+ )
15
+ from .errors import CredentialError, ProtectedPortError, UnsupportedCapabilityError
16
+ from .http_read import AsyncHttpReader
17
+ from .http_write import AsyncHttpWriter
18
+ from .models import SwitchData
19
+ from .nsdp_read import AsyncNsdpReader
20
+ from .nsdp_write import AsyncNsdpWriter
21
+ from .registry import Backend
22
+ from .snmp_read import AsyncSnmpReader, async_read_system_info
23
+ from .snmp_write import AsyncSnmpWriter, PoeCycleTimeouts
24
+
25
+ _DEFAULT_POE_TIMEOUTS = PoeCycleTimeouts()
26
+
27
+ _R = TypeVar("_R")
28
+ # Per-op backend preference: try SNMP, then NSDP, then HTTP; the first backend
29
+ # whose reader/writer serves an op wins. HTTP only ever fills the gaps the
30
+ # higher-priority backends raise UnsupportedCapabilityError for.
31
+ _BACKEND_PREFERENCE = (Backend.SNMP, Backend.NSDP, Backend.HTTP)
32
+
33
+
34
+ class _Unset:
35
+ """Sentinel type for "write community not yet resolved" (see
36
+ AsyncSwitch._resolved_write_community): a resolved value of None (no
37
+ community configured) must stay distinguishable from "never resolved"."""
38
+
39
+
40
+ _UNSET = _Unset()
41
+
42
+
43
+ class _LazyAsyncHttpSession:
44
+ """Async mirror of ``sync_api._LazyHttpSession``: defers building the real
45
+ AsyncHttpSession (which needs a resolved password) until an op that
46
+ genuinely reaches the wire (``login``/``get_page``/``post_form``) is
47
+ called. Ops an AsyncHttpReader/AsyncHttpWriter refuses honestly WITHOUT
48
+ ever touching the session (e.g. ``get_macs``, ``set_mgmt_ip``) must never
49
+ trigger HTTP password resolution or a live connection."""
50
+
51
+ def __init__(self, resolve: Callable[[], AsyncHttpSession]) -> None:
52
+ self._resolve = resolve
53
+
54
+ async def login(self) -> None:
55
+ await self._resolve().login()
56
+
57
+ async def get_page(self, path: str) -> str:
58
+ return await self._resolve().get_page(path)
59
+
60
+ async def post_form(self, path: str, data: dict[str, str]) -> str:
61
+ return await self._resolve().post_form(path, data)
62
+
63
+
64
+ if TYPE_CHECKING:
65
+ from collections.abc import Awaitable, Callable, Mapping
66
+ from types import TracebackType
67
+ from typing import Self
68
+
69
+ from .config import SwitchConfig
70
+ from .models import (
71
+ DetectedModel,
72
+ LLDPNeighbor,
73
+ MacEntry,
74
+ MgmtIpConfig,
75
+ PoEStatus,
76
+ PortStats,
77
+ PortStatus,
78
+ Sensor,
79
+ VLANInfo,
80
+ VlanMode,
81
+ )
82
+ from .protocols.http.session import AsyncHttpSession
83
+ from .protocols.nsdp.client import AsyncNsdpClient, AsyncNsdpWriteClient
84
+ from .protocols.nsdp.types import NsdpDevice
85
+ from .protocols.snmp.client import AsyncSnmpClient, AsyncSnmpWriteClient
86
+ from .registry import SwitchModel
87
+ from .transport.http.client import AsyncHttpClient
88
+
89
+
90
+ async def async_detect_model(
91
+ host: str, *, community: str | None = None, client: AsyncSnmpClient | None = None
92
+ ) -> DetectedModel:
93
+ """Async twin of ``sync_api.detect_model`` -- see there."""
94
+ if client is None:
95
+ client = build_async_snmp_client(host, community)
96
+ return await async_read_system_info(client)
97
+
98
+
99
+ class AsyncSwitch:
100
+ """Asynchronous, model-driven read/write facade over one switch."""
101
+
102
+ def __init__(
103
+ self,
104
+ model: SwitchModel,
105
+ host: str,
106
+ *,
107
+ snmp_community: str | None = None,
108
+ snmp_client: AsyncSnmpClient | None = None,
109
+ snmp_write_community: str | None = None,
110
+ snmp_write_client: AsyncSnmpWriteClient | None = None,
111
+ snmp_write_community_resolver: Callable[[], str | None] | None = None,
112
+ nsdp_interface: str | None = None,
113
+ nsdp_client: AsyncNsdpClient | None = None,
114
+ nsdp_write_client: AsyncNsdpWriteClient | None = None,
115
+ nsdp_password: str | None = None,
116
+ nsdp_password_resolver: Callable[[], str | None] | None = None,
117
+ http_client: AsyncHttpSession | None = None,
118
+ http_password: str | None = None,
119
+ http_password_resolver: Callable[[], str | None] | None = None,
120
+ protected_ports: frozenset[int] = frozenset(),
121
+ ) -> None:
122
+ self.model = model
123
+ self.host = host
124
+ self._snmp_community = snmp_community
125
+ self._snmp_client = snmp_client
126
+ self._snmp_write_community = snmp_write_community
127
+ self._snmp_write_client = snmp_write_client
128
+ # Deferred write-community resolution: from_config stashes a closure here
129
+ # instead of resolving eagerly, so read-only construction never raises a
130
+ # CredentialError for an unresolvable write-community spec (review item 4).
131
+ self._snmp_write_community_resolver = snmp_write_community_resolver
132
+ # Sentinel meaning "not yet resolved"; distinct from a resolved value
133
+ # of None (no community configured) so we only ever resolve once.
134
+ self._resolved_write_community: str | None | _Unset = _UNSET
135
+ self._nsdp_interface = nsdp_interface
136
+ self._nsdp_client = nsdp_client
137
+ self._nsdp_write_client = nsdp_write_client
138
+ self._nsdp_password = nsdp_password
139
+ self._nsdp_password_resolver = nsdp_password_resolver
140
+ self._resolved_nsdp_password: str | None | _Unset = _UNSET
141
+ self._http_client = http_client
142
+ self._http_password = http_password
143
+ self._http_password_resolver = http_password_resolver
144
+ self._resolved_http_password: str | None | _Unset = _UNSET
145
+ # A self-built AsyncHttpClient is the ONLY backend that holds a
146
+ # persistent connection worth closing (SNMP/NSDP clients are built
147
+ # fresh per call and need no equivalent teardown). Tracked separately
148
+ # from `_http_client` so `aclose()` only ever tears down a client THIS
149
+ # facade built -- never one the caller injected and therefore owns.
150
+ self._built_http_client: AsyncHttpClient | None = None
151
+ self._reader_cache: dict[
152
+ Backend, AsyncSnmpReader | AsyncNsdpReader | AsyncHttpReader
153
+ ] = {}
154
+ self._writer_cache: dict[
155
+ Backend, AsyncSnmpWriter | AsyncNsdpWriter | AsyncHttpWriter
156
+ ] = {}
157
+ self.protected_ports = protected_ports
158
+
159
+ async def __aenter__(self) -> Self:
160
+ return self
161
+
162
+ async def __aexit__(
163
+ self,
164
+ exc_type: type[BaseException] | None,
165
+ exc: BaseException | None,
166
+ tb: TracebackType | None,
167
+ ) -> None:
168
+ await self.aclose()
169
+
170
+ async def aclose(self) -> None:
171
+ """Release the HTTP client THIS facade built (never one injected by
172
+ the caller). Safe to call even when no HTTP op was ever dispatched."""
173
+ if self._built_http_client is not None:
174
+ await self._built_http_client.aclose()
175
+ self._built_http_client = None
176
+
177
+ @classmethod
178
+ def from_config(
179
+ cls, cfg: SwitchConfig, *, env: Mapping[str, str] | None = None
180
+ ) -> AsyncSwitch:
181
+ # Resolve the SNMP write community LAZILY (on first write), never here
182
+ # (mirrors SyncSwitch.from_config -- review item 4).
183
+ _env = env if env is not None else os.environ
184
+
185
+ def _resolve_write_community() -> str | None:
186
+ return cfg.snmp_write_community(env=_env)
187
+
188
+ def _resolve_nsdp_password() -> str | None:
189
+ # Plus switches share ONE web-admin password across HTTP + NSDP, so
190
+ # reusing the http_password spec as the NSDP v1 auth password is
191
+ # intentional and correct. A dedicated ``nsdp.password`` config key is
192
+ # a trivial future follow-up (the facade already accepts a distinct
193
+ # nsdp_password/nsdp_password_resolver) if a deployment ever needs to
194
+ # split them; do NOT add a separate key now.
195
+ return cfg.http_password(env=_env)
196
+
197
+ def _resolve_http_password() -> str | None:
198
+ return cfg.http_password(env=_env)
199
+
200
+ return cls(
201
+ cfg.model, cfg.host,
202
+ snmp_community=cfg.snmp_community,
203
+ snmp_write_community_resolver=_resolve_write_community,
204
+ nsdp_interface=cfg.nsdp_interface,
205
+ nsdp_password_resolver=_resolve_nsdp_password,
206
+ http_password_resolver=_resolve_http_password,
207
+ protected_ports=cfg.protected_ports,
208
+ )
209
+
210
+ def _http_session(self) -> AsyncHttpSession:
211
+ if self._http_client is not None:
212
+ return self._http_client
213
+ if self._built_http_client is None:
214
+ self._built_http_client = build_async_http_client(
215
+ self.host, self._resolve_http_password(), self.model
216
+ )
217
+ return self._built_http_client
218
+
219
+ def _resolve_http_password(self) -> str | None:
220
+ if not isinstance(self._resolved_http_password, _Unset):
221
+ return self._resolved_http_password
222
+ resolved: str | None
223
+ if self._http_password is not None:
224
+ resolved = self._http_password
225
+ elif self._http_password_resolver is not None:
226
+ resolved = self._http_password_resolver()
227
+ else:
228
+ resolved = None
229
+ self._resolved_http_password = resolved
230
+ return resolved
231
+
232
+ def _reader_for(
233
+ self, backend: Backend
234
+ ) -> AsyncSnmpReader | AsyncNsdpReader | AsyncHttpReader:
235
+ cached = self._reader_cache.get(backend)
236
+ if cached is not None:
237
+ return cached
238
+ reader: AsyncSnmpReader | AsyncNsdpReader | AsyncHttpReader
239
+ if backend is Backend.SNMP:
240
+ client = self._snmp_client
241
+ if client is None:
242
+ client = build_async_snmp_client(self.host, self._snmp_community)
243
+ reader = AsyncSnmpReader(client, self.model)
244
+ elif backend is Backend.NSDP:
245
+ nsdp = self._nsdp_client
246
+ if nsdp is None:
247
+ nsdp = build_async_nsdp_client(self.host, self._nsdp_interface)
248
+ reader = AsyncNsdpReader(nsdp, self.model)
249
+ else: # Backend.HTTP
250
+ if not http_reads_supported(self.model):
251
+ raise UnsupportedCapabilityError(
252
+ f"model {self.model.key!r} HTTP reads are "
253
+ "UNVERIFIED-pending-capture"
254
+ )
255
+ reader = AsyncHttpReader(
256
+ _LazyAsyncHttpSession(self._http_session), self.model
257
+ )
258
+ self._reader_cache[backend] = reader
259
+ return reader
260
+
261
+ def _writer_for(
262
+ self, backend: Backend
263
+ ) -> AsyncSnmpWriter | AsyncNsdpWriter | AsyncHttpWriter:
264
+ cached = self._writer_cache.get(backend)
265
+ if cached is not None:
266
+ return cached
267
+ writer: AsyncSnmpWriter | AsyncNsdpWriter | AsyncHttpWriter
268
+ if backend is Backend.SNMP:
269
+ client = self._snmp_write_client
270
+ if client is None:
271
+ client = build_async_snmp_write_client(
272
+ self.host, self._resolve_write_community()
273
+ )
274
+ writer = AsyncSnmpWriter(
275
+ client, self.model, protected_ports=self.protected_ports
276
+ )
277
+ elif backend is Backend.NSDP:
278
+ nsdp = self._nsdp_write_client
279
+ if nsdp is None:
280
+ nsdp = build_async_nsdp_client(self.host, self._nsdp_interface)
281
+ password = self._resolve_nsdp_password()
282
+ if password is None:
283
+ raise CredentialError(
284
+ f"no NSDP admin password configured for {self.host!r}"
285
+ )
286
+ writer = AsyncNsdpWriter(
287
+ nsdp, self.model, password=password,
288
+ protected_ports=self.protected_ports,
289
+ )
290
+ else: # Backend.HTTP
291
+ if not http_reads_supported(self.model):
292
+ raise UnsupportedCapabilityError(
293
+ f"model {self.model.key!r} HTTP writes are "
294
+ "UNVERIFIED-pending-capture"
295
+ )
296
+ writer = AsyncHttpWriter(
297
+ _LazyAsyncHttpSession(self._http_session), self.model,
298
+ protected_ports=self.protected_ports,
299
+ )
300
+ self._writer_cache[backend] = writer
301
+ return writer
302
+
303
+ async def _read(
304
+ self,
305
+ op: Callable[
306
+ [AsyncSnmpReader | AsyncNsdpReader | AsyncHttpReader], Awaitable[_R]
307
+ ],
308
+ ) -> _R:
309
+ last: UnsupportedCapabilityError | None = None
310
+ for backend in _BACKEND_PREFERENCE:
311
+ if backend not in self.model.backends:
312
+ continue
313
+ try:
314
+ reader = self._reader_for(backend)
315
+ except UnsupportedCapabilityError as exc:
316
+ last = exc
317
+ continue
318
+ try:
319
+ return await op(reader)
320
+ except UnsupportedCapabilityError as exc:
321
+ last = exc
322
+ if last is not None:
323
+ raise last
324
+ raise UnsupportedCapabilityError(
325
+ f"model {self.model.key!r} has no backend supporting this operation"
326
+ )
327
+
328
+ async def _write(
329
+ self,
330
+ op: Callable[
331
+ [AsyncSnmpWriter | AsyncNsdpWriter | AsyncHttpWriter], Awaitable[None]
332
+ ],
333
+ ) -> None:
334
+ last: UnsupportedCapabilityError | None = None
335
+ for backend in _BACKEND_PREFERENCE:
336
+ if backend not in self.model.backends:
337
+ continue
338
+ try:
339
+ writer = self._writer_for(backend)
340
+ except UnsupportedCapabilityError as exc:
341
+ last = exc
342
+ continue
343
+ try:
344
+ await op(writer)
345
+ return
346
+ except UnsupportedCapabilityError as exc:
347
+ last = exc
348
+ if last is not None:
349
+ raise last
350
+ raise UnsupportedCapabilityError(
351
+ f"model {self.model.key!r} has no backend supporting this operation"
352
+ )
353
+
354
+ async def get_ports(self) -> list[PortStatus]:
355
+ return await self._read(lambda r: r.get_ports())
356
+
357
+ async def get_stats(self) -> list[PortStats]:
358
+ return await self._read(lambda r: r.get_stats())
359
+
360
+ async def get_vlans(self) -> list[VLANInfo]:
361
+ return await self._read(lambda r: r.get_vlans())
362
+
363
+ async def get_pvids(self) -> list[tuple[int, int]]:
364
+ return await self._read(lambda r: r.get_pvids())
365
+
366
+ async def get_lldp(self) -> list[LLDPNeighbor]:
367
+ return await self._read(lambda r: r.get_lldp())
368
+
369
+ async def get_macs(self) -> list[MacEntry]:
370
+ require_mac_table(self.model)
371
+ return await self._read(lambda r: r.get_macs())
372
+
373
+ async def get_poe(self) -> list[PoEStatus]:
374
+ return await self._read(lambda r: r.get_poe())
375
+
376
+ async def get_sensors(self) -> list[Sensor]:
377
+ return await self._read(lambda r: r.get_sensors())
378
+
379
+ async def get_mgmt_ip(self) -> MgmtIpConfig:
380
+ return await self._read(lambda r: r.get_mgmt_ip())
381
+
382
+ async def nsdp_device(self) -> NsdpDevice:
383
+ """Async twin of ``SyncSwitch.nsdp_device`` -- see there."""
384
+ reader = self._reader_for(Backend.NSDP)
385
+ assert isinstance(reader, AsyncNsdpReader)
386
+ return await reader.get_device()
387
+
388
+ async def identify(self) -> DetectedModel:
389
+ """Async twin of ``SyncSwitch.identify`` -- see there."""
390
+ client = self._snmp_client
391
+ if client is None:
392
+ client = build_async_snmp_client(self.host, self._snmp_community)
393
+ return await async_read_system_info(client)
394
+
395
+ async def snapshot(self) -> SwitchData:
396
+ """Aggregate every read op, routing each field to the first backend
397
+ that supports it (SNMP > NSDP > HTTP). A field NO backend can serve
398
+ degrades to ()/None; a field a backend DOES serve stays populated
399
+ (gs305ep: ports/stats/vlans/pvids/mgmt via NSDP, poe via HTTP)."""
400
+
401
+ async def _opt(
402
+ op: Callable[
403
+ [AsyncSnmpReader | AsyncNsdpReader | AsyncHttpReader],
404
+ Awaitable[list[Any]],
405
+ ],
406
+ ) -> tuple[Any, ...]:
407
+ try:
408
+ return tuple(await self._read(op))
409
+ except UnsupportedCapabilityError:
410
+ return ()
411
+
412
+ try:
413
+ mgmt: MgmtIpConfig | None = await self._read(lambda r: r.get_mgmt_ip())
414
+ except UnsupportedCapabilityError:
415
+ mgmt = None
416
+
417
+ return SwitchData(
418
+ model=self.model.key,
419
+ host=self.host,
420
+ ports=await _opt(lambda r: r.get_ports()),
421
+ stats=await _opt(lambda r: r.get_stats()),
422
+ vlans=await _opt(lambda r: r.get_vlans()),
423
+ pvids=await _opt(lambda r: r.get_pvids()),
424
+ mgmt_ip=mgmt,
425
+ poe=await _opt(lambda r: r.get_poe()),
426
+ lldp=await _opt(lambda r: r.get_lldp()),
427
+ sensors=await _opt(lambda r: r.get_sensors()),
428
+ macs=await _opt(lambda r: r.get_macs()),
429
+ )
430
+
431
+ def _resolve_write_community(self) -> str | None:
432
+ # Resolved once on first write, then cached: an explicit community
433
+ # wins, else the stashed from_config resolver runs now (may raise),
434
+ # else None. Every subsequent write reuses the cached result instead
435
+ # of re-invoking the resolver (e.g. a ``!command`` spec must not
436
+ # re-exec its subprocess on every single write).
437
+ if not isinstance(self._resolved_write_community, _Unset):
438
+ return self._resolved_write_community
439
+ resolved: str | None
440
+ if self._snmp_write_community is not None:
441
+ resolved = self._snmp_write_community
442
+ elif self._snmp_write_community_resolver is not None:
443
+ resolved = self._snmp_write_community_resolver()
444
+ else:
445
+ resolved = None
446
+ self._resolved_write_community = resolved
447
+ return resolved
448
+
449
+ def _resolve_nsdp_password(self) -> str | None:
450
+ if not isinstance(self._resolved_nsdp_password, _Unset):
451
+ return self._resolved_nsdp_password
452
+ resolved: str | None
453
+ if self._nsdp_password is not None:
454
+ resolved = self._nsdp_password
455
+ elif self._nsdp_password_resolver is not None:
456
+ resolved = self._nsdp_password_resolver()
457
+ else:
458
+ resolved = None
459
+ self._resolved_nsdp_password = resolved
460
+ return resolved
461
+
462
+ async def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
463
+ await self._write(lambda w: w.set_poe(port, on, force=force))
464
+
465
+ async def set_port_enabled(
466
+ self, port: int, enabled: bool, *, force: bool = False
467
+ ) -> None:
468
+ await self._write(lambda w: w.set_port_enabled(port, enabled, force=force))
469
+
470
+ async def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
471
+ await self._write(lambda w: w.set_pvid(port, vlan, force=force))
472
+
473
+ async def set_vlan_membership(
474
+ self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
475
+ ) -> None:
476
+ await self._write(
477
+ lambda w: w.set_vlan_membership(vlan, port, mode, force=force)
478
+ )
479
+
480
+ async def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
481
+ await self._write(lambda w: w.create_vlan(vlan, name, force=force))
482
+
483
+ async def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
484
+ # SAFETY RAIL: mirrors SyncSwitch.delete_vlan (see its docstring).
485
+ # HttpWriter.delete_vlan does NOT itself guard protected member ports,
486
+ # and NsdpWriter.delete_vlan ALWAYS raises UnsupportedCapabilityError
487
+ # (NSDP has no VLAN lifecycle ops), so any {NSDP, HTTP} model falls
488
+ # straight through to HTTP -- guard here so every backend gets the
489
+ # same protected-port safety rail.
490
+ await self._guard_vlan_delete_members(vlan, force=force)
491
+ await self._write(lambda w: w.delete_vlan(vlan, force=force))
492
+
493
+ async def _guard_vlan_delete_members(self, vlan: int, *, force: bool) -> None:
494
+ if force:
495
+ return
496
+ try:
497
+ vlans = await self._read(lambda r: r.get_vlans())
498
+ except UnsupportedCapabilityError:
499
+ return
500
+ for v in vlans:
501
+ if v.vlan_id == vlan:
502
+ clash = v.member_ports & self.protected_ports
503
+ if clash:
504
+ raise ProtectedPortError(
505
+ f"VLAN {vlan} includes protected port(s) {sorted(clash)}; "
506
+ f"pass force=True to delete it anyway"
507
+ )
508
+ return
509
+
510
+ async def cycle_poe(
511
+ self, port: int, *, force: bool = False,
512
+ timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
513
+ ) -> None:
514
+ await self._write(lambda w: w.cycle_poe(port, force=force, timeouts=timeouts))
515
+
516
+ async def clear_poe_fault(
517
+ self, port: int, *, force: bool = False,
518
+ timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
519
+ ) -> None:
520
+ await self._write(
521
+ lambda w: w.clear_poe_fault(port, force=force, timeouts=timeouts)
522
+ )
523
+
524
+ async def set_mgmt_ip(
525
+ self, address: str, netmask: str, gateway: str, *, force: bool = False
526
+ ) -> None:
527
+ await self._write(
528
+ lambda w: w.set_mgmt_ip(address, netmask, gateway, force=force)
529
+ )
@@ -0,0 +1 @@
1
+ """The ``ngsw`` command-line interface (a thin layer over SyncSwitch)."""
@@ -0,0 +1,131 @@
1
+ """``ngsw capture``: record a real switch's state + protocol exchanges.
2
+
3
+ Opt-in, live-switch, never run in CI. The state snapshot uses the public
4
+ ``SyncSwitch.snapshot()`` (works against any backend); the reference raw walk
5
+ (``snmpbulkwalk`` output) requires live-switch access and is only recorded when
6
+ a ``raw_walk`` callable is supplied. Output is a JSON file used *for reference*
7
+ when hand-authoring fixtures (design spec Sec7.1) -- never committed as-is.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import subprocess
13
+ from dataclasses import dataclass, field
14
+ from datetime import UTC, datetime
15
+ from typing import TYPE_CHECKING
16
+
17
+ from netgear_switch.errors import ConfigError
18
+
19
+ from . import format as fmt
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Callable, Sequence
23
+ from pathlib import Path
24
+
25
+ from netgear_switch.config import Runner
26
+ from netgear_switch.sync_api import SyncSwitch
27
+
28
+ _WALK_TIMEOUT = 30
29
+
30
+
31
+ @dataclass
32
+ class CaptureRecord:
33
+ model: str
34
+ host: str
35
+ captured_at: str
36
+ snapshot: object
37
+ raw_exchanges: list[dict[str, object]] = field(default_factory=list)
38
+ notes: list[str] = field(default_factory=list)
39
+
40
+
41
+ def default_raw_walk(
42
+ host: str,
43
+ base: str,
44
+ *,
45
+ community: str = "public",
46
+ runner: Runner = subprocess.run,
47
+ ) -> list[str]:
48
+ """Shell out to net-snmp's snmpbulkwalk (live hardware) for a reference walk.
49
+
50
+ Never uses ``shell=True`` or string interpolation -- the argv is a fixed
51
+ list, so there is no shell-injection surface even though ``host``/``base``
52
+ are caller-controlled. A missing binary or a timeout propagates as the
53
+ normal ``subprocess`` exception (``FileNotFoundError`` /
54
+ ``subprocess.TimeoutExpired``); a nonzero exit is turned into a
55
+ ``RuntimeError`` carrying the process's stderr. ``run_capture`` is
56
+ responsible for catching these and recording an honest failure instead of
57
+ crashing the whole capture.
58
+ """
59
+ result = runner(
60
+ ["snmpbulkwalk", "-v2c", "-c", community, host, base],
61
+ capture_output=True,
62
+ text=True,
63
+ timeout=_WALK_TIMEOUT,
64
+ )
65
+ if result.returncode != 0:
66
+ raise RuntimeError(
67
+ f"snmpbulkwalk exited {result.returncode}: {result.stderr.strip()}"
68
+ )
69
+ return result.stdout.splitlines()
70
+
71
+
72
+ def run_capture(
73
+ switch: SyncSwitch,
74
+ out_path: Path,
75
+ *,
76
+ snapshot_only: bool = False,
77
+ raw_walk: Callable[[str, str], Sequence[str]] | None = None,
78
+ now: Callable[[], datetime] | None = None,
79
+ ) -> CaptureRecord:
80
+ clock = now or (lambda: datetime.now(UTC))
81
+ record = CaptureRecord(
82
+ model=switch.model.key,
83
+ host=switch.host,
84
+ captured_at=clock().isoformat(),
85
+ snapshot=fmt.jsonify(switch.snapshot()),
86
+ )
87
+ if snapshot_only:
88
+ record.notes.append("snapshot-only: no raw protocol exchange recorded")
89
+ elif raw_walk is None:
90
+ record.notes.append(
91
+ "no raw-capture backend available; recording a raw protocol exchange "
92
+ "needs live-switch access (SNMP walk / NSDP / HTTP). Re-run on hardware."
93
+ )
94
+ else:
95
+ base = switch.model.snmp_vendor_base or "1.3.6.1.2.1"
96
+ request = f"walk {base}"
97
+ try:
98
+ lines = list(raw_walk(switch.host, base))
99
+ except Exception as exc:
100
+ # A raw walk can fail in many ways on real hardware (missing
101
+ # snmpbulkwalk binary, nonzero exit, network timeout). The walk is
102
+ # optional and best-effort: record the honest failure so the
103
+ # capture record isn't silently missing data, but never let it
104
+ # abort the rest of the capture.
105
+ error = f"{type(exc).__name__}: {exc}"
106
+ record.raw_exchanges.append(
107
+ {"protocol": "snmp", "request": request, "error": error}
108
+ )
109
+ record.notes.append(f"raw protocol walk failed: {error}")
110
+ else:
111
+ record.raw_exchanges.append(
112
+ {"protocol": "snmp", "request": request, "response": lines}
113
+ )
114
+ try:
115
+ out_path.write_text(json.dumps(_as_dict(record), indent=2))
116
+ except OSError as exc:
117
+ raise ConfigError(
118
+ f"cannot write capture output to {out_path}: {exc}"
119
+ ) from exc
120
+ return record
121
+
122
+
123
+ def _as_dict(record: CaptureRecord) -> dict[str, object]:
124
+ return {
125
+ "model": record.model,
126
+ "host": record.host,
127
+ "captured_at": record.captured_at,
128
+ "snapshot": record.snapshot,
129
+ "raw_exchanges": record.raw_exchanges,
130
+ "notes": record.notes,
131
+ }
@@ -0,0 +1,39 @@
1
+ """Shared CLI context and exit-code policy (leaf module, no cli/ imports)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from netgear_switch.errors import ProtectedPortError, WriteVerificationError
9
+
10
+ if TYPE_CHECKING:
11
+ from typing import TextIO
12
+
13
+ from netgear_switch.errors import NetgearSwitchError
14
+
15
+ EXIT_OK = 0
16
+ EXIT_ERROR = 1
17
+ EXIT_USAGE = 2
18
+ EXIT_VERIFY = 3
19
+ EXIT_PROTECTED = 4
20
+
21
+
22
+ @dataclass
23
+ class CliContext:
24
+ """Streams and global flags threaded through every command handler."""
25
+
26
+ out: TextIO
27
+ err: TextIO
28
+ inp: TextIO
29
+ as_json: bool
30
+ verbose: bool
31
+
32
+
33
+ def exit_code_for(exc: NetgearSwitchError) -> int:
34
+ """Map a library error to a distinct process exit code."""
35
+ if isinstance(exc, WriteVerificationError):
36
+ return EXIT_VERIFY
37
+ if isinstance(exc, ProtectedPortError):
38
+ return EXIT_PROTECTED
39
+ return EXIT_ERROR