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,152 @@
1
+ """Asynchronous NSDP UDP transport (stdlib asyncio datagram endpoint).
2
+
3
+ Mirrors the sync ``UdpNsdpClient`` but over ``loop.create_datagram_endpoint``.
4
+ The datagram exchange is factored into an injectable ``transceive`` coroutine so
5
+ read/write are unit-testable with a fake exchange (no real UDP), the async
6
+ analogue of the sync client's ``sock_factory`` seam. As with the sync client,
7
+ ``client_port=0`` binds an unprivileged ephemeral port for the virtual face.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import socket
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import TYPE_CHECKING
15
+
16
+ from ...protocols.nsdp.client import NsdpError, check_result, read_interface_mac
17
+ from ...protocols.nsdp.protocol import NSDPPacket, Op
18
+ from ...protocols.nsdp.write import build_read_request, build_write_request
19
+
20
+ if TYPE_CHECKING:
21
+ from ...protocols.nsdp.protocol import Tag, TLVEntry
22
+
23
+ Transceive = Callable[..., Awaitable[bytes]]
24
+
25
+ _DUMMY_MAC = b"\x00\x00\x00\x00\x00\x01"
26
+ _BROADCAST_MAC = b"\x00" * 6
27
+
28
+
29
+ class _OneShotProtocol(asyncio.DatagramProtocol):
30
+ """Resolves a future with the first datagram (or an error) received."""
31
+
32
+ def __init__(self, future: asyncio.Future[bytes]) -> None:
33
+ self._future = future
34
+
35
+ def datagram_received(self, data: bytes, _addr: object) -> None:
36
+ if not self._future.done():
37
+ self._future.set_result(data)
38
+
39
+ def error_received(self, exc: Exception) -> None:
40
+ if not self._future.done():
41
+ self._future.set_exception(exc)
42
+
43
+
44
+ async def _udp_transceive(
45
+ payload: bytes,
46
+ addr: tuple[str, int],
47
+ *,
48
+ client_port: int,
49
+ interface: str | None,
50
+ timeout: float,
51
+ ) -> bytes:
52
+ loop = asyncio.get_running_loop()
53
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
54
+ try:
55
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
56
+ if interface is not None:
57
+ sock.setsockopt(
58
+ socket.SOL_SOCKET, socket.SO_BINDTODEVICE, interface.encode() + b"\0"
59
+ )
60
+ sock.bind(("", client_port))
61
+ future: asyncio.Future[bytes] = loop.create_future()
62
+ transport, _proto = await loop.create_datagram_endpoint(
63
+ lambda: _OneShotProtocol(future), sock=sock
64
+ )
65
+ except BaseException:
66
+ # setsockopt/bind (or the endpoint handoff itself) failed before
67
+ # create_datagram_endpoint took ownership of the socket on success —
68
+ # nothing else will ever close it, so close it here to avoid an fd
69
+ # leak. Once the try above succeeds, only the transport (below) owns
70
+ # the socket and closes it.
71
+ sock.close()
72
+ raise
73
+ try:
74
+ transport.sendto(payload, addr)
75
+ return await asyncio.wait_for(future, timeout)
76
+ finally:
77
+ transport.close()
78
+
79
+
80
+ class AsyncUdpNsdpClient:
81
+ """Async NSDP read+write client over UDP for a single switch."""
82
+
83
+ def __init__(
84
+ self,
85
+ host: str,
86
+ *,
87
+ interface: str | None = None,
88
+ client_mac: bytes | None = None,
89
+ client_port: int = 63321,
90
+ server_port: int = 63322,
91
+ timeout: float = 2.0,
92
+ transceive: Transceive = _udp_transceive,
93
+ ) -> None:
94
+ self.host = host
95
+ self._interface = interface
96
+ self._client_port = client_port
97
+ self._server_port = server_port
98
+ self._timeout = timeout
99
+ self._transceive = transceive
100
+ self._sequence = 0
101
+ if client_mac is not None:
102
+ self._client_mac = client_mac
103
+ elif interface is not None:
104
+ self._client_mac = read_interface_mac(interface)
105
+ else:
106
+ self._client_mac = _DUMMY_MAC
107
+
108
+ def _next_seq(self) -> int:
109
+ self._sequence = (self._sequence + 1) & 0xFFFF
110
+ return self._sequence
111
+
112
+ async def _exchange(self, request: NSDPPacket) -> NSDPPacket:
113
+ try:
114
+ data = await self._transceive(
115
+ request.encode(),
116
+ (self.host, self._server_port),
117
+ client_port=self._client_port,
118
+ interface=self._interface,
119
+ timeout=self._timeout,
120
+ )
121
+ except TimeoutError as exc:
122
+ raise NsdpError(f"NSDP request to {self.host} timed out") from exc
123
+ try:
124
+ return NSDPPacket.decode(data)
125
+ except ValueError as exc:
126
+ raise NsdpError(
127
+ f"malformed NSDP response from {self.host}: {exc}"
128
+ ) from exc
129
+
130
+ async def read(self, tags: list[Tag]) -> NSDPPacket:
131
+ req = build_read_request(
132
+ self._client_mac, _BROADCAST_MAC, self._next_seq(), tags
133
+ )
134
+ resp = await self._exchange(req)
135
+ if resp.op != Op.READ_RESPONSE:
136
+ raise NsdpError(f"expected READ_RESPONSE from {self.host}, got {resp.op}")
137
+ return resp
138
+
139
+ async def write(self, tlvs: list[TLVEntry], *, password: str) -> NSDPPacket:
140
+ req = build_write_request(
141
+ self._client_mac, _BROADCAST_MAC, self._next_seq(), password, tlvs
142
+ )
143
+ resp = await self._exchange(req)
144
+ # Guard the op-code before trusting result (symmetric with read()): a
145
+ # misrouted/duplicate UDP datagram (e.g. a stray READ_RESPONSE with
146
+ # result=0) must not silently pass check_result as a successful write.
147
+ if resp.op != Op.WRITE_RESPONSE:
148
+ raise NsdpError(
149
+ f"expected WRITE_RESPONSE from {self.host}, got {resp.op}"
150
+ )
151
+ check_result(resp)
152
+ return resp
@@ -0,0 +1,247 @@
1
+ """Asynchronous SNMP v2c client on pysnmp v7. pysnmp is imported lazily.
2
+
3
+ Value parity: each pysnmp SMI value is normalized to the SAME plain Python type
4
+ the net-snmp CLI client (Task 10) produces — int for integer-family, str for
5
+ text/OID/IP, bytes for non-printable octet strings (Hex-STRING). Task 16's
6
+ sync/async equivalence test compares these values, so they must match.
7
+
8
+ pysnmp ships with no type stubs and is untyped under mypy --strict. Rather than
9
+ a blanket `ignore_missing_imports` for the whole package, `_pysnmp_asyncio()`
10
+ is the single lazy-import seam. It resolves the module dynamically via
11
+ `importlib.import_module` (a plain `str -> ModuleType` call mypy can't follow
12
+ into pysnmp's untyped internals), so no `type: ignore` is needed at all;
13
+ everything downstream of this one seam is deliberately treated as `Any`, and
14
+ the rest of the module is fully typed.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import importlib
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from ...protocols.snmp.client import ABSENT_TYPES, SnmpError, SnmpRow
22
+
23
+ if TYPE_CHECKING:
24
+ from ...protocols.snmp.write import SetVarbind
25
+
26
+ Triple = tuple[str, int | str | bytes, str]
27
+
28
+
29
+ def _pysnmp_asyncio() -> Any:
30
+ """Lazily import pysnmp's v3arch asyncio hlapi module."""
31
+ return importlib.import_module("pysnmp.hlapi.v3arch.asyncio")
32
+
33
+ # pysnmp class name -> net-snmp-style type token (parity with the CLI client).
34
+ _TOKEN = {
35
+ "Integer": "INTEGER",
36
+ "Integer32": "INTEGER",
37
+ "Gauge32": "Gauge32",
38
+ "Unsigned32": "Gauge32",
39
+ "Counter32": "Counter32",
40
+ "Counter64": "Counter64",
41
+ "TimeTicks": "Timeticks",
42
+ "IpAddress": "IpAddress",
43
+ "ObjectIdentifier": "OID",
44
+ "ObjectIdentity": "OID",
45
+ }
46
+ _INT_CLASSES = frozenset(
47
+ {"Integer", "Integer32", "Gauge32", "Unsigned32", "Counter32",
48
+ "Counter64", "TimeTicks"}
49
+ )
50
+ _ABSENT_CLASSES = frozenset({"NoSuchObject", "NoSuchInstance", "EndOfMibView"})
51
+
52
+
53
+ def _octet_value(raw: bytes) -> tuple[str | bytes, str]:
54
+ """Render an octet string as net-snmp does: printable -> STRING, else Hex."""
55
+ if raw == b"" or all(0x20 <= b < 0x7F for b in raw):
56
+ return raw.decode("ascii"), "STRING"
57
+ return raw, "Hex-STRING"
58
+
59
+
60
+ def _normalize_varbind(name: Any, value: Any) -> Triple:
61
+ """Convert a pysnmp (name, value) varbind into a normalized SnmpRow triple."""
62
+ oid = str(name).lstrip(".")
63
+ cls = value.__class__.__name__
64
+ if cls in _ABSENT_CLASSES:
65
+ return oid, "", cls.upper() # e.g. "NOSUCHOBJECT" ∈ ABSENT_TYPES
66
+ if cls in _INT_CLASSES:
67
+ return oid, int(value), _TOKEN[cls]
68
+ if cls == "OctetString":
69
+ norm, token = _octet_value(bytes(value.asOctets()))
70
+ return oid, norm, token
71
+ if cls in ("ObjectIdentifier", "ObjectIdentity"):
72
+ # NOT value.prettyPrint(): hlapi's get_cmd/bulk_walk_cmd auto-resolve
73
+ # an OBJECT IDENTIFIER *value* against their attached MIB view
74
+ # controller, so prettyPrint() can render a well-known prefix
75
+ # symbolically (e.g. "SNMPv2-SMI::enterprises.4526.10.100.14")
76
+ # instead of the plain numeric dotted OID str(value) always gives
77
+ # (confirmed empirically: str(value) == "1.3.6.1.4.1.4526.10.100.14",
78
+ # matching the net-snmp CLI client's numeric ("-On") output exactly).
79
+ return oid, str(value).lstrip("."), "OID"
80
+ if cls == "IpAddress":
81
+ return oid, value.prettyPrint(), "IpAddress"
82
+ return oid, value.prettyPrint(), cls # textual fallback
83
+
84
+
85
+ def _to_set_value(hlapi: Any, vb: SetVarbind) -> Any:
86
+ """Map a SetVarbind's type letter to the matching pysnmp SMI value object.
87
+
88
+ ``s`` and ``x`` both become OctetString (bytes on the wire); ``s`` str
89
+ values are latin-1 encoded (the inverse of the read normalizer). Kept as a
90
+ plain function taking ``hlapi`` so it is unit-testable with a fake module,
91
+ with no live pysnmp import.
92
+ """
93
+ if vb.type_letter == "i":
94
+ return hlapi.Integer32(int(vb.value))
95
+ if vb.type_letter == "u":
96
+ return hlapi.Gauge32(int(vb.value))
97
+ if vb.type_letter == "a":
98
+ return hlapi.IpAddress(str(vb.value))
99
+ if vb.type_letter in ("s", "x"):
100
+ data = (
101
+ vb.value
102
+ if isinstance(vb.value, bytes)
103
+ else str(vb.value).encode("latin-1")
104
+ )
105
+ return hlapi.OctetString(data)
106
+ raise SnmpError(f"unsupported SET type letter {vb.type_letter!r}")
107
+
108
+
109
+ class PysnmpClient:
110
+ """Async SNMP v2c read/write client for a single switch."""
111
+
112
+ def __init__(
113
+ self,
114
+ host: str,
115
+ community: str,
116
+ *,
117
+ port: int = 161,
118
+ timeout: float = 2.0,
119
+ retries: int = 1,
120
+ ) -> None:
121
+ self.host = host
122
+ self.community = community
123
+ self.port = port
124
+ self.timeout = timeout
125
+ self.retries = retries
126
+
127
+ async def _do_get(self, oids: list[str]) -> list[Triple]:
128
+ hlapi = _pysnmp_asyncio()
129
+ engine = hlapi.SnmpEngine()
130
+ try:
131
+ target = await hlapi.UdpTransportTarget.create(
132
+ (self.host, self.port), timeout=self.timeout, retries=self.retries
133
+ )
134
+ err_ind, err_stat, _idx, binds = await hlapi.get_cmd(
135
+ engine, hlapi.CommunityData(self.community), target,
136
+ hlapi.ContextData(),
137
+ *[hlapi.ObjectType(hlapi.ObjectIdentity(o)) for o in oids],
138
+ )
139
+ if err_ind or err_stat:
140
+ raise SnmpError(f"GET {oids} on {self.host}: {err_ind or err_stat}")
141
+ return [_normalize_varbind(vb[0], vb[1]) for vb in binds]
142
+ finally:
143
+ engine.close_dispatcher()
144
+
145
+ async def _do_walk(self, base_oid: str) -> list[Triple]:
146
+ hlapi = _pysnmp_asyncio()
147
+ engine = hlapi.SnmpEngine()
148
+ rows: list[Triple] = []
149
+ try:
150
+ target = await hlapi.UdpTransportTarget.create(
151
+ (self.host, self.port), timeout=self.timeout, retries=self.retries
152
+ )
153
+ async for err_ind, err_stat, _idx, binds in hlapi.bulk_walk_cmd(
154
+ engine, hlapi.CommunityData(self.community), target,
155
+ hlapi.ContextData(), 0, 25,
156
+ hlapi.ObjectType(hlapi.ObjectIdentity(base_oid)),
157
+ lexicographicMode=False,
158
+ ):
159
+ if err_ind or err_stat:
160
+ raise SnmpError(
161
+ f"WALK {base_oid} on {self.host}: {err_ind or err_stat}"
162
+ )
163
+ done = False
164
+ for vb in binds:
165
+ oid, value, typ = _normalize_varbind(vb[0], vb[1])
166
+ if typ == "ENDOFMIBVIEW":
167
+ # Benign terminator (mirrors the sync client's
168
+ # _END_OF_MIB_MARKERS): stop, keep rows so far.
169
+ done = True
170
+ break
171
+ if typ.upper() in ABSENT_TYPES:
172
+ raise SnmpError(
173
+ f"absent OID in pysnmp WALK response: {oid}"
174
+ )
175
+ rows.append((oid, value, typ))
176
+ if done:
177
+ break
178
+ return rows
179
+ finally:
180
+ engine.close_dispatcher()
181
+
182
+ async def get(self, oids: list[str]) -> list[SnmpRow]:
183
+ if not oids:
184
+ return []
185
+ try:
186
+ raw = await self._do_get(oids)
187
+ except SnmpError:
188
+ raise
189
+ except Exception as exc:
190
+ raise SnmpError(f"GET {oids} on {self.host} failed: {exc}") from exc
191
+ rows: list[SnmpRow] = []
192
+ for oid, value, typ in raw:
193
+ if typ.upper() in ABSENT_TYPES:
194
+ raise SnmpError(f"absent OID in pysnmp GET response: {oid}")
195
+ rows.append(SnmpRow(oid, value, typ))
196
+ return rows
197
+
198
+ async def walk(self, base_oid: str) -> list[SnmpRow]:
199
+ try:
200
+ raw = await self._do_walk(base_oid)
201
+ except SnmpError:
202
+ raise
203
+ except Exception as exc:
204
+ raise SnmpError(f"WALK {base_oid} on {self.host} failed: {exc}") from exc
205
+ return [
206
+ SnmpRow(oid, value, typ)
207
+ for oid, value, typ in raw
208
+ if typ.upper() not in ABSENT_TYPES
209
+ ]
210
+
211
+ async def _do_set(self, varbinds: list[SetVarbind]) -> None:
212
+ hlapi = _pysnmp_asyncio()
213
+ engine = hlapi.SnmpEngine()
214
+ try:
215
+ target = await hlapi.UdpTransportTarget.create(
216
+ (self.host, self.port), timeout=self.timeout, retries=self.retries
217
+ )
218
+ objects = [
219
+ hlapi.ObjectType(hlapi.ObjectIdentity(vb.oid), _to_set_value(hlapi, vb))
220
+ for vb in varbinds
221
+ ]
222
+ err_ind, err_stat, _idx, _binds = await hlapi.set_cmd(
223
+ engine, hlapi.CommunityData(self.community), target,
224
+ hlapi.ContextData(), *objects,
225
+ )
226
+ if err_ind or err_stat:
227
+ raise SnmpError(
228
+ f"SET {[vb.oid for vb in varbinds]} on {self.host}: "
229
+ f"{err_ind or err_stat}"
230
+ )
231
+ finally:
232
+ engine.close_dispatcher()
233
+
234
+ async def set(self, varbind: SetVarbind) -> None:
235
+ await self.set_many([varbind])
236
+
237
+ async def set_many(self, varbinds: list[SetVarbind]) -> None:
238
+ if not varbinds:
239
+ return
240
+ try:
241
+ await self._do_set(varbinds)
242
+ except SnmpError:
243
+ raise
244
+ except Exception as exc:
245
+ raise SnmpError(
246
+ f"SET {[vb.oid for vb in varbinds]} on {self.host} failed: {exc}"
247
+ ) from exc
@@ -0,0 +1 @@
1
+ """httpx-backed HTTP web-UI transport (sync + async)."""
@@ -0,0 +1,217 @@
1
+ """httpx-backed web-UI clients implementing the session Protocols.
2
+
3
+ One codebase: all URL/crypto/parse logic lives in the pure ``protocols.http``
4
+ package; only the actual GET/POST differ between the sync ``httpx.Client`` and
5
+ async ``httpx.AsyncClient``. Legacy Plus switches are HTTP-only, so ``base_url``
6
+ is ``http://`` and TLS verification (when a model ever needs https) defaults to
7
+ off for permissive legacy behaviour.
8
+
9
+ httpx is an optional dependency (``[http]`` extra); it is imported at module
10
+ top-level because this module lives under ``transport/http`` and is only ever
11
+ imported lazily by ``_dispatch`` (function-local imports), exactly like the
12
+ SNMP transports — ``import netgear_switch`` never reaches here.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ import httpx
19
+
20
+ from ...errors import HttpAuthError, HttpError, HttpUnexpectedPageError
21
+ from ...protocols.http.crypt import merge_hash_md5
22
+ from ...protocols.http.endpoints import LoginScheme
23
+ from ...protocols.http.parse import parse_login_rand
24
+
25
+ if TYPE_CHECKING:
26
+ from types import TracebackType
27
+ from typing import Self
28
+
29
+ from ...protocols.http.endpoints import HttpModelSpec
30
+
31
+ _TIMEOUT = 15.0
32
+
33
+
34
+ def _login_body(
35
+ spec: HttpModelSpec, password: str, login_page_html: str
36
+ ) -> dict[str, str]:
37
+ """Build the login POST body for ``spec`` (pure; shared sync+async).
38
+
39
+ MERGE_HASH_CGI/GAMBIT hash the password with the page ``rand`` nonce;
40
+ CHEETAH_FORM posts the plaintext password. Raises ``HttpUnexpectedPageError``
41
+ if a required ``rand`` nonce is missing from the login page.
42
+ """
43
+ if spec.scheme is LoginScheme.CHEETAH_FORM:
44
+ return {spec.password_field: password}
45
+ rand = parse_login_rand(login_page_html) if spec.needs_rand else None
46
+ if spec.needs_rand and not rand:
47
+ raise HttpUnexpectedPageError(
48
+ f"no login 'rand' nonce on {spec.login_path} — not a {spec.model_key}?"
49
+ )
50
+ hashed = merge_hash_md5(password, rand or "")
51
+ return {spec.password_field: hashed}
52
+
53
+
54
+ def _check_authed(spec: HttpModelSpec, cookies: httpx.Cookies) -> None:
55
+ if spec.cookie_name not in cookies:
56
+ raise HttpAuthError(
57
+ f"web-UI login failed for {spec.model_key} — no {spec.cookie_name} cookie "
58
+ "(check password, or switch may be locked out)"
59
+ )
60
+
61
+
62
+ def _validate_response(
63
+ resp: httpx.Response, *, context: str, path: str | None = None
64
+ ) -> None:
65
+ """Raise on an HTTP-error status, or (if ``path`` given) a lost session.
66
+
67
+ Pure; shared by every sync/async GET/POST call site so status-code and
68
+ stale-session handling cannot drift between the two codebases.
69
+
70
+ ``context`` names the request for the status-code error (e.g. ``"GET
71
+ /login.cgi"``). ``path`` is only passed by mid-session reads that should
72
+ also detect the web-UI silently redirecting back to the login page.
73
+ """
74
+ if resp.status_code >= 400:
75
+ raise HttpError(f"{context} returned HTTP {resp.status_code}")
76
+ if path is not None and "redirect to login" in resp.text.lower():
77
+ raise HttpAuthError(f"session lost fetching {path}")
78
+
79
+
80
+ class HttpClient:
81
+ """Synchronous httpx web-UI session (implements ``HttpSession``)."""
82
+
83
+ def __init__(
84
+ self,
85
+ host: str,
86
+ password: str,
87
+ spec: HttpModelSpec,
88
+ *,
89
+ verify_tls: bool = False,
90
+ transport: httpx.MockTransport | None = None,
91
+ ) -> None:
92
+ self._spec = spec
93
+ self._password = password
94
+ self._client = httpx.Client(
95
+ base_url=f"http://{host}",
96
+ timeout=_TIMEOUT,
97
+ verify=verify_tls,
98
+ transport=transport,
99
+ follow_redirects=True,
100
+ )
101
+ self._logged_in = False
102
+
103
+ def __enter__(self) -> Self:
104
+ return self
105
+
106
+ def __exit__(
107
+ self,
108
+ exc_type: type[BaseException] | None,
109
+ exc: BaseException | None,
110
+ tb: TracebackType | None,
111
+ ) -> None:
112
+ self.close()
113
+
114
+ def login(self) -> None:
115
+ try:
116
+ page = self._client.get(self._spec.login_path)
117
+ _validate_response(page, context=f"GET {self._spec.login_path}")
118
+ body = _login_body(self._spec, self._password, page.text)
119
+ resp = self._client.post(self._spec.login_path, data=body)
120
+ _validate_response(resp, context=f"POST {self._spec.login_path}")
121
+ except httpx.HTTPError as exc:
122
+ raise HttpError(f"web-UI login transport error: {exc}") from exc
123
+ _check_authed(self._spec, self._client.cookies)
124
+ self._logged_in = True
125
+
126
+ def get_page(self, path: str) -> str:
127
+ if not self._logged_in:
128
+ self.login()
129
+ try:
130
+ resp = self._client.get(path)
131
+ except httpx.HTTPError as exc:
132
+ raise HttpError(f"GET {path} transport error: {exc}") from exc
133
+ _validate_response(resp, context=f"GET {path}", path=path)
134
+ return resp.text
135
+
136
+ def post_form(self, path: str, data: dict[str, str]) -> str:
137
+ if not self._logged_in:
138
+ self.login()
139
+ try:
140
+ resp = self._client.post(path, data=data)
141
+ except httpx.HTTPError as exc:
142
+ raise HttpError(f"POST {path} transport error: {exc}") from exc
143
+ _validate_response(resp, context=f"POST {path}")
144
+ return resp.text
145
+
146
+ def close(self) -> None:
147
+ self._client.close()
148
+
149
+
150
+ class AsyncHttpClient:
151
+ """Asynchronous httpx web-UI session (implements ``AsyncHttpSession``)."""
152
+
153
+ def __init__(
154
+ self,
155
+ host: str,
156
+ password: str,
157
+ spec: HttpModelSpec,
158
+ *,
159
+ verify_tls: bool = False,
160
+ transport: httpx.MockTransport | None = None,
161
+ ) -> None:
162
+ self._spec = spec
163
+ self._password = password
164
+ self._client = httpx.AsyncClient(
165
+ base_url=f"http://{host}",
166
+ timeout=_TIMEOUT,
167
+ verify=verify_tls,
168
+ transport=transport,
169
+ follow_redirects=True,
170
+ )
171
+ self._logged_in = False
172
+
173
+ async def __aenter__(self) -> Self:
174
+ return self
175
+
176
+ async def __aexit__(
177
+ self,
178
+ exc_type: type[BaseException] | None,
179
+ exc: BaseException | None,
180
+ tb: TracebackType | None,
181
+ ) -> None:
182
+ await self.aclose()
183
+
184
+ async def login(self) -> None:
185
+ try:
186
+ page = await self._client.get(self._spec.login_path)
187
+ _validate_response(page, context=f"GET {self._spec.login_path}")
188
+ body = _login_body(self._spec, self._password, page.text)
189
+ resp = await self._client.post(self._spec.login_path, data=body)
190
+ _validate_response(resp, context=f"POST {self._spec.login_path}")
191
+ except httpx.HTTPError as exc:
192
+ raise HttpError(f"web-UI login transport error: {exc}") from exc
193
+ _check_authed(self._spec, self._client.cookies)
194
+ self._logged_in = True
195
+
196
+ async def get_page(self, path: str) -> str:
197
+ if not self._logged_in:
198
+ await self.login()
199
+ try:
200
+ resp = await self._client.get(path)
201
+ except httpx.HTTPError as exc:
202
+ raise HttpError(f"GET {path} transport error: {exc}") from exc
203
+ _validate_response(resp, context=f"GET {path}", path=path)
204
+ return resp.text
205
+
206
+ async def post_form(self, path: str, data: dict[str, str]) -> str:
207
+ if not self._logged_in:
208
+ await self.login()
209
+ try:
210
+ resp = await self._client.post(path, data=data)
211
+ except httpx.HTTPError as exc:
212
+ raise HttpError(f"POST {path} transport error: {exc}") from exc
213
+ _validate_response(resp, context=f"POST {path}")
214
+ return resp.text
215
+
216
+ async def aclose(self) -> None:
217
+ await self._client.aclose()
@@ -0,0 +1 @@
1
+ """Synchronous SNMP transport implementations."""