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,557 @@
1
+ """Public synchronous read/write facade: 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_sync_http_client,
9
+ build_sync_nsdp_client,
10
+ build_sync_snmp_client,
11
+ build_sync_snmp_write_client,
12
+ http_reads_supported,
13
+ require_mac_table,
14
+ )
15
+ from .errors import CredentialError, ProtectedPortError, UnsupportedCapabilityError
16
+ from .http_read import HttpReader
17
+ from .http_write import HttpWriter
18
+ from .models import SwitchData
19
+ from .nsdp_read import NsdpReader
20
+ from .nsdp_write import NsdpWriter
21
+ from .registry import Backend
22
+ from .snmp_read import SnmpReader, read_system_info
23
+ from .snmp_write import PoeCycleTimeouts, SnmpWriter
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
+ SyncSwitch._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 _LazyHttpSession:
44
+ """Wraps ``SyncSwitch._http_session`` so building the real HttpSession
45
+ (which needs a resolved password) is deferred until an op that genuinely
46
+ reaches the wire (``login``/``get_page``/``post_form``) is called. Ops an
47
+ HttpReader/HttpWriter refuses honestly WITHOUT ever touching the session
48
+ (e.g. ``get_macs``, ``set_mgmt_ip``) must never trigger HTTP password
49
+ resolution or a live connection -- only per-op routing that HTTP actually
50
+ ends up serving should pay that cost."""
51
+
52
+ def __init__(self, resolve: Callable[[], HttpSession]) -> None:
53
+ self._resolve = resolve
54
+
55
+ def login(self) -> None:
56
+ self._resolve().login()
57
+
58
+ def get_page(self, path: str) -> str:
59
+ return self._resolve().get_page(path)
60
+
61
+ def post_form(self, path: str, data: dict[str, str]) -> str:
62
+ return self._resolve().post_form(path, data)
63
+
64
+
65
+ if TYPE_CHECKING:
66
+ from collections.abc import Callable, Mapping
67
+ from types import TracebackType
68
+ from typing import Self
69
+
70
+ from .config import SwitchConfig
71
+ from .models import (
72
+ DetectedModel,
73
+ LLDPNeighbor,
74
+ MacEntry,
75
+ MgmtIpConfig,
76
+ PoEStatus,
77
+ PortStats,
78
+ PortStatus,
79
+ Sensor,
80
+ VLANInfo,
81
+ VlanMode,
82
+ )
83
+ from .protocols.http.session import HttpSession
84
+ from .protocols.nsdp.client import NsdpClient, NsdpWriteClient
85
+ from .protocols.nsdp.types import NsdpDevice
86
+ from .protocols.snmp.client import SnmpClient, SnmpWriteClient
87
+ from .registry import SwitchModel
88
+ from .transport.http.client import HttpClient
89
+
90
+
91
+ def detect_model(
92
+ host: str, *, community: str | None = None, client: SnmpClient | None = None
93
+ ) -> DetectedModel:
94
+ """Identify a switch's model over SNMP, WITHOUT already knowing/hardcoding
95
+ it -- the discovery entry point a caller (e.g. gdoc2netcfg) uses BEFORE it
96
+ can construct a ``SyncSwitch`` at all: call this first, then
97
+ ``registry.get_model(detected.key)`` + ``SyncSwitch(...)`` once
98
+ ``detected.key`` is not ``None``. See ``models.DetectedModel`` /
99
+ ``protocols.snmp.parse.detect_model_from_sysdescr`` for exactly how (and
100
+ why) an unmatched sysDescr honestly yields ``key=None`` rather than a
101
+ guess.
102
+
103
+ Builds the default net-snmp CLI client from ``host``/``community`` unless
104
+ ``client`` is injected (tests, or an already-open connection).
105
+ """
106
+ if client is None:
107
+ client = build_sync_snmp_client(host, community)
108
+ return read_system_info(client)
109
+
110
+
111
+ class SyncSwitch:
112
+ """Synchronous, model-driven read/write facade over one switch."""
113
+
114
+ def __init__(
115
+ self,
116
+ model: SwitchModel,
117
+ host: str,
118
+ *,
119
+ snmp_community: str | None = None,
120
+ snmp_client: SnmpClient | None = None,
121
+ snmp_write_community: str | None = None,
122
+ snmp_write_client: SnmpWriteClient | None = None,
123
+ snmp_write_community_resolver: Callable[[], str | None] | None = None,
124
+ nsdp_interface: str | None = None,
125
+ nsdp_client: NsdpClient | None = None,
126
+ nsdp_write_client: NsdpWriteClient | None = None,
127
+ nsdp_password: str | None = None,
128
+ nsdp_password_resolver: Callable[[], str | None] | None = None,
129
+ http_client: HttpSession | None = None,
130
+ http_password: str | None = None,
131
+ http_password_resolver: Callable[[], str | None] | None = None,
132
+ protected_ports: frozenset[int] = frozenset(),
133
+ ) -> None:
134
+ self.model = model
135
+ self.host = host
136
+ self._snmp_community = snmp_community
137
+ self._snmp_client = snmp_client
138
+ self._snmp_write_community = snmp_write_community
139
+ self._snmp_write_client = snmp_write_client
140
+ # Deferred write-community resolution: from_config stashes a closure here
141
+ # instead of resolving eagerly, so read-only construction never raises a
142
+ # CredentialError for an unresolvable write-community spec (review item 4).
143
+ self._snmp_write_community_resolver = snmp_write_community_resolver
144
+ # Sentinel meaning "not yet resolved"; distinct from a resolved value
145
+ # of None (no community configured) so we only ever resolve once.
146
+ self._resolved_write_community: str | None | _Unset = _UNSET
147
+ self._nsdp_interface = nsdp_interface
148
+ self._nsdp_client = nsdp_client
149
+ self._nsdp_write_client = nsdp_write_client
150
+ self._nsdp_password = nsdp_password
151
+ self._nsdp_password_resolver = nsdp_password_resolver
152
+ self._resolved_nsdp_password: str | None | _Unset = _UNSET
153
+ self._http_client = http_client
154
+ self._http_password = http_password
155
+ self._http_password_resolver = http_password_resolver
156
+ self._resolved_http_password: str | None | _Unset = _UNSET
157
+ # A self-built HttpClient is the ONLY backend that holds a persistent
158
+ # connection worth closing (SNMP/NSDP clients are built fresh per call
159
+ # and need no equivalent teardown). Tracked separately from
160
+ # `_http_client` so `close()` only ever tears down a client THIS facade
161
+ # built -- never one the caller injected and therefore owns.
162
+ self._built_http_client: HttpClient | None = None
163
+ self._reader_cache: dict[Backend, SnmpReader | NsdpReader | HttpReader] = {}
164
+ self._writer_cache: dict[Backend, SnmpWriter | NsdpWriter | HttpWriter] = {}
165
+ self.protected_ports = protected_ports
166
+
167
+ def __enter__(self) -> Self:
168
+ return self
169
+
170
+ def __exit__(
171
+ self,
172
+ exc_type: type[BaseException] | None,
173
+ exc: BaseException | None,
174
+ tb: TracebackType | None,
175
+ ) -> None:
176
+ self.close()
177
+
178
+ def close(self) -> None:
179
+ """Release the HTTP client THIS facade built (never one injected by
180
+ the caller). Safe to call even when no HTTP op was ever dispatched."""
181
+ if self._built_http_client is not None:
182
+ self._built_http_client.close()
183
+ self._built_http_client = None
184
+
185
+ @classmethod
186
+ def from_config(
187
+ cls, cfg: SwitchConfig, *, env: Mapping[str, str] | None = None
188
+ ) -> SyncSwitch:
189
+ # Resolve the SNMP write community LAZILY (on first write), never here.
190
+ # A read-only consumer whose env lacks a resolvable write-community spec
191
+ # (e.g. ``${UNSET_VAR}``) must still be able to construct the facade and
192
+ # read; only an actual write attempt may raise CredentialError/ConfigError
193
+ # (review item 4). We stash a closure that reads the spec + env on demand.
194
+ _env = env if env is not None else os.environ
195
+
196
+ def _resolve_write_community() -> str | None:
197
+ return cfg.snmp_write_community(env=_env)
198
+
199
+ def _resolve_nsdp_password() -> str | None:
200
+ # Plus switches share ONE web-admin password across HTTP + NSDP, so
201
+ # reusing the http_password spec as the NSDP v1 auth password is
202
+ # intentional and correct. A dedicated ``nsdp.password`` config key is
203
+ # a trivial future follow-up (the facade already accepts a distinct
204
+ # nsdp_password/nsdp_password_resolver) if a deployment ever needs to
205
+ # split them; do NOT add a separate key now.
206
+ return cfg.http_password(env=_env)
207
+
208
+ def _resolve_http_password() -> str | None:
209
+ return cfg.http_password(env=_env)
210
+
211
+ return cls(
212
+ cfg.model, cfg.host,
213
+ snmp_community=cfg.snmp_community,
214
+ snmp_write_community_resolver=_resolve_write_community,
215
+ nsdp_interface=cfg.nsdp_interface,
216
+ nsdp_password_resolver=_resolve_nsdp_password,
217
+ http_password_resolver=_resolve_http_password,
218
+ protected_ports=cfg.protected_ports,
219
+ )
220
+
221
+ def _http_session(self) -> HttpSession:
222
+ if self._http_client is not None:
223
+ return self._http_client
224
+ if self._built_http_client is None:
225
+ self._built_http_client = build_sync_http_client(
226
+ self.host, self._resolve_http_password(), self.model
227
+ )
228
+ return self._built_http_client
229
+
230
+ def _resolve_http_password(self) -> str | None:
231
+ if not isinstance(self._resolved_http_password, _Unset):
232
+ return self._resolved_http_password
233
+ resolved: str | None
234
+ if self._http_password is not None:
235
+ resolved = self._http_password
236
+ elif self._http_password_resolver is not None:
237
+ resolved = self._http_password_resolver()
238
+ else:
239
+ resolved = None
240
+ self._resolved_http_password = resolved
241
+ return resolved
242
+
243
+ def _reader_for(self, backend: Backend) -> SnmpReader | NsdpReader | HttpReader:
244
+ cached = self._reader_cache.get(backend)
245
+ if cached is not None:
246
+ return cached
247
+ reader: SnmpReader | NsdpReader | HttpReader
248
+ if backend is Backend.SNMP:
249
+ client = self._snmp_client
250
+ if client is None:
251
+ client = build_sync_snmp_client(self.host, self._snmp_community)
252
+ reader = SnmpReader(client, self.model)
253
+ elif backend is Backend.NSDP:
254
+ nsdp = self._nsdp_client
255
+ if nsdp is None:
256
+ nsdp = build_sync_nsdp_client(self.host, self._nsdp_interface)
257
+ reader = NsdpReader(nsdp, self.model)
258
+ else: # Backend.HTTP
259
+ # UNVERIFIED-reads models (gs110emx Gambit, gsm7228ps cheetah) refuse
260
+ # HERE -- before any session build -- so the per-op loop sees a plain
261
+ # UnsupportedCapabilityError, NOT a CredentialError from resolving a
262
+ # web password this backend will never use.
263
+ if not http_reads_supported(self.model):
264
+ raise UnsupportedCapabilityError(
265
+ f"model {self.model.key!r} HTTP reads are "
266
+ "UNVERIFIED-pending-capture"
267
+ )
268
+ reader = HttpReader(_LazyHttpSession(self._http_session), self.model)
269
+ self._reader_cache[backend] = reader
270
+ return reader
271
+
272
+ def _writer_for(self, backend: Backend) -> SnmpWriter | NsdpWriter | HttpWriter:
273
+ cached = self._writer_cache.get(backend)
274
+ if cached is not None:
275
+ return cached
276
+ writer: SnmpWriter | NsdpWriter | HttpWriter
277
+ if backend is Backend.SNMP:
278
+ client = self._snmp_write_client
279
+ if client is None:
280
+ client = build_sync_snmp_write_client(
281
+ self.host, self._resolve_write_community()
282
+ )
283
+ writer = SnmpWriter(
284
+ client, self.model, protected_ports=self.protected_ports
285
+ )
286
+ elif backend is Backend.NSDP:
287
+ nsdp = self._nsdp_write_client
288
+ if nsdp is None:
289
+ nsdp = build_sync_nsdp_client(self.host, self._nsdp_interface)
290
+ password = self._resolve_nsdp_password()
291
+ if password is None:
292
+ raise CredentialError(
293
+ f"no NSDP admin password configured for {self.host!r}"
294
+ )
295
+ writer = NsdpWriter(
296
+ nsdp, self.model, password=password,
297
+ protected_ports=self.protected_ports,
298
+ )
299
+ else: # Backend.HTTP
300
+ if not http_reads_supported(self.model):
301
+ raise UnsupportedCapabilityError(
302
+ f"model {self.model.key!r} HTTP writes are "
303
+ "UNVERIFIED-pending-capture"
304
+ )
305
+ writer = HttpWriter(
306
+ _LazyHttpSession(self._http_session), self.model,
307
+ protected_ports=self.protected_ports,
308
+ )
309
+ self._writer_cache[backend] = writer
310
+ return writer
311
+
312
+ def _read(self, op: Callable[[SnmpReader | NsdpReader | HttpReader], _R]) -> _R:
313
+ # Try each backend the model has, in preference order (SNMP > NSDP >
314
+ # HTTP), returning the first whose reader serves the op. A backend that
315
+ # cannot be built (construction raises UnsupportedCapabilityError) OR
316
+ # whose method raises it is skipped; if none serve the op, re-raise the
317
+ # last UnsupportedCapabilityError. A CredentialError (e.g. a missing NSDP
318
+ # write password) is NOT swallowed -- it propagates.
319
+ last: UnsupportedCapabilityError | None = None
320
+ for backend in _BACKEND_PREFERENCE:
321
+ if backend not in self.model.backends:
322
+ continue
323
+ try:
324
+ reader = self._reader_for(backend)
325
+ except UnsupportedCapabilityError as exc:
326
+ last = exc
327
+ continue
328
+ try:
329
+ return op(reader)
330
+ except UnsupportedCapabilityError as exc:
331
+ last = exc
332
+ if last is not None:
333
+ raise last
334
+ raise UnsupportedCapabilityError(
335
+ f"model {self.model.key!r} has no backend supporting this operation"
336
+ )
337
+
338
+ def _write(
339
+ self, op: Callable[[SnmpWriter | NsdpWriter | HttpWriter], None]
340
+ ) -> None:
341
+ last: UnsupportedCapabilityError | None = None
342
+ for backend in _BACKEND_PREFERENCE:
343
+ if backend not in self.model.backends:
344
+ continue
345
+ try:
346
+ writer = self._writer_for(backend)
347
+ except UnsupportedCapabilityError as exc:
348
+ last = exc
349
+ continue
350
+ try:
351
+ op(writer)
352
+ return
353
+ except UnsupportedCapabilityError as exc:
354
+ last = exc
355
+ if last is not None:
356
+ raise last
357
+ raise UnsupportedCapabilityError(
358
+ f"model {self.model.key!r} has no backend supporting this operation"
359
+ )
360
+
361
+ def get_ports(self) -> list[PortStatus]:
362
+ return self._read(lambda r: r.get_ports())
363
+
364
+ def get_stats(self) -> list[PortStats]:
365
+ return self._read(lambda r: r.get_stats())
366
+
367
+ def get_vlans(self) -> list[VLANInfo]:
368
+ return self._read(lambda r: r.get_vlans())
369
+
370
+ def get_pvids(self) -> list[tuple[int, int]]:
371
+ return self._read(lambda r: r.get_pvids())
372
+
373
+ def get_lldp(self) -> list[LLDPNeighbor]:
374
+ return self._read(lambda r: r.get_lldp())
375
+
376
+ def get_macs(self) -> list[MacEntry]:
377
+ require_mac_table(self.model)
378
+ return self._read(lambda r: r.get_macs())
379
+
380
+ def get_poe(self) -> list[PoEStatus]:
381
+ return self._read(lambda r: r.get_poe())
382
+
383
+ def get_sensors(self) -> list[Sensor]:
384
+ return self._read(lambda r: r.get_sensors())
385
+
386
+ def get_mgmt_ip(self) -> MgmtIpConfig:
387
+ return self._read(lambda r: r.get_mgmt_ip())
388
+
389
+ def nsdp_device(self) -> NsdpDevice:
390
+ """Return the COMPLETE raw ``NsdpDevice`` for this switch: model, MAC,
391
+ hostname, mgmt IP, firmware, DHCP mode, port count, serial number,
392
+ VLAN engine, raw per-port status (speed byte NOT pre-converted to
393
+ Mbps -- see ``protocols.nsdp.types.NsdpPortStatus.speed``) and
394
+ statistics, VLAN membership, PVIDs, plus QoS engine/mirroring/IGMP
395
+ snooping/broadcast filtering/loop detection.
396
+
397
+ Unlike every other read op, this deliberately bypasses the
398
+ SNMP/NSDP/HTTP backend-preference dispatch (``_read``): NSDP is the
399
+ ONLY backend that can serve it, so a model without an NSDP backend
400
+ raises ``UnsupportedCapabilityError`` directly (mirroring
401
+ ``identify()``'s bypass of that dispatch below, and
402
+ ``NsdpReader.__init__``'s own ``_require_nsdp`` guard).
403
+ """
404
+ reader = self._reader_for(Backend.NSDP)
405
+ assert isinstance(reader, NsdpReader)
406
+ return reader.get_device()
407
+
408
+ def identify(self) -> DetectedModel:
409
+ """Detect this switch's ACTUAL model via SNMP sysDescr, independent of
410
+ ``self.model``.
411
+
412
+ Unlike every other read/write op, this deliberately bypasses the
413
+ per-op SNMP/NSDP/HTTP backend-preference dispatch (``_read``) AND the
414
+ ``self.model`` SNMP-backend gate entirely: it exists precisely to
415
+ confirm/discover a switch's real model when the caller does not yet
416
+ trust the model this facade happens to have been constructed with
417
+ (e.g. a placeholder used only to carry host/credentials). Reuses an
418
+ injected ``snmp_client``/``snmp_community`` exactly like
419
+ ``_reader_for(Backend.SNMP)`` would, but never requires
420
+ ``self.model.backends`` to include SNMP.
421
+ """
422
+ client = self._snmp_client
423
+ if client is None:
424
+ client = build_sync_snmp_client(self.host, self._snmp_community)
425
+ return read_system_info(client)
426
+
427
+ def snapshot(self) -> SwitchData:
428
+ """Aggregate every read op, routing each field to the first backend that
429
+ supports it (SNMP > NSDP > HTTP). A field NO backend can serve degrades
430
+ to ()/None; a field a backend DOES serve stays populated (gs305ep:
431
+ ports/stats/vlans/pvids/mgmt via NSDP, poe via HTTP)."""
432
+
433
+ def _opt(
434
+ op: Callable[[SnmpReader | NsdpReader | HttpReader], list[Any]],
435
+ ) -> tuple[Any, ...]:
436
+ try:
437
+ return tuple(self._read(op))
438
+ except UnsupportedCapabilityError:
439
+ return ()
440
+
441
+ try:
442
+ mgmt: MgmtIpConfig | None = self._read(lambda r: r.get_mgmt_ip())
443
+ except UnsupportedCapabilityError:
444
+ mgmt = None
445
+
446
+ return SwitchData(
447
+ model=self.model.key,
448
+ host=self.host,
449
+ ports=_opt(lambda r: r.get_ports()),
450
+ stats=_opt(lambda r: r.get_stats()),
451
+ vlans=_opt(lambda r: r.get_vlans()),
452
+ pvids=_opt(lambda r: r.get_pvids()),
453
+ mgmt_ip=mgmt,
454
+ poe=_opt(lambda r: r.get_poe()),
455
+ lldp=_opt(lambda r: r.get_lldp()),
456
+ sensors=_opt(lambda r: r.get_sensors()),
457
+ macs=_opt(lambda r: r.get_macs()),
458
+ )
459
+
460
+ def _resolve_write_community(self) -> str | None:
461
+ # Resolved once on first write, then cached: an explicit community
462
+ # wins, else the stashed from_config resolver runs now (may raise),
463
+ # else None. Every subsequent write reuses the cached result instead
464
+ # of re-invoking the resolver (e.g. a ``!command`` spec must not
465
+ # re-exec its subprocess on every single write).
466
+ if not isinstance(self._resolved_write_community, _Unset):
467
+ return self._resolved_write_community
468
+ resolved: str | None
469
+ if self._snmp_write_community is not None:
470
+ resolved = self._snmp_write_community
471
+ elif self._snmp_write_community_resolver is not None:
472
+ resolved = self._snmp_write_community_resolver()
473
+ else:
474
+ resolved = None
475
+ self._resolved_write_community = resolved
476
+ return resolved
477
+
478
+ def _resolve_nsdp_password(self) -> str | None:
479
+ if not isinstance(self._resolved_nsdp_password, _Unset):
480
+ return self._resolved_nsdp_password
481
+ resolved: str | None
482
+ if self._nsdp_password is not None:
483
+ resolved = self._nsdp_password
484
+ elif self._nsdp_password_resolver is not None:
485
+ resolved = self._nsdp_password_resolver()
486
+ else:
487
+ resolved = None
488
+ self._resolved_nsdp_password = resolved
489
+ return resolved
490
+
491
+ def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
492
+ self._write(lambda w: w.set_poe(port, on, force=force))
493
+
494
+ def set_port_enabled(
495
+ self, port: int, enabled: bool, *, force: bool = False
496
+ ) -> None:
497
+ self._write(lambda w: w.set_port_enabled(port, enabled, force=force))
498
+
499
+ def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
500
+ self._write(lambda w: w.set_pvid(port, vlan, force=force))
501
+
502
+ def set_vlan_membership(
503
+ self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
504
+ ) -> None:
505
+ self._write(lambda w: w.set_vlan_membership(vlan, port, mode, force=force))
506
+
507
+ def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
508
+ self._write(lambda w: w.create_vlan(vlan, name, force=force))
509
+
510
+ def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
511
+ # SAFETY RAIL: HttpWriter.delete_vlan does NOT itself guard protected
512
+ # member ports (only its per-port ops carry an internal `_guard`; its
513
+ # own docstring defers VLAN-delete disruptiveness to be "guarded per-
514
+ # member elsewhere"). NsdpWriter.delete_vlan ALWAYS raises
515
+ # UnsupportedCapabilityError (NSDP has no VLAN lifecycle ops at all),
516
+ # so on any {NSDP, HTTP} model delete_vlan falls straight through to
517
+ # HTTP -- meaning nothing would otherwise stand between force=False
518
+ # and stripping a protected port's VLAN membership. Guard here,
519
+ # mirroring SnmpWriter.delete_vlan's own protected-port check, so
520
+ # EVERY backend gets the same safety rail regardless of which one
521
+ # actually ends up serving the delete.
522
+ self._guard_vlan_delete_members(vlan, force=force)
523
+ self._write(lambda w: w.delete_vlan(vlan, force=force))
524
+
525
+ def _guard_vlan_delete_members(self, vlan: int, *, force: bool) -> None:
526
+ if force:
527
+ return
528
+ try:
529
+ vlans = self._read(lambda r: r.get_vlans())
530
+ except UnsupportedCapabilityError:
531
+ return
532
+ for v in vlans:
533
+ if v.vlan_id == vlan:
534
+ clash = v.member_ports & self.protected_ports
535
+ if clash:
536
+ raise ProtectedPortError(
537
+ f"VLAN {vlan} includes protected port(s) {sorted(clash)}; "
538
+ f"pass force=True to delete it anyway"
539
+ )
540
+ return
541
+
542
+ def cycle_poe(
543
+ self, port: int, *, force: bool = False,
544
+ timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
545
+ ) -> None:
546
+ self._write(lambda w: w.cycle_poe(port, force=force, timeouts=timeouts))
547
+
548
+ def clear_poe_fault(
549
+ self, port: int, *, force: bool = False,
550
+ timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
551
+ ) -> None:
552
+ self._write(lambda w: w.clear_poe_fault(port, force=force, timeouts=timeouts))
553
+
554
+ def set_mgmt_ip(
555
+ self, address: str, netmask: str, gateway: str, *, force: bool = False
556
+ ) -> None:
557
+ self._write(lambda w: w.set_mgmt_ip(address, netmask, gateway, force=force))
@@ -0,0 +1 @@
1
+ """Transport implementations (sync net-snmp CLI, async pysnmp) for SNMP I/O."""
@@ -0,0 +1 @@
1
+ """Asynchronous SNMP transport implementations."""