snmp-query-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: snmp-query-engine
3
+ Version: 0.1.0
4
+ Summary: Client library for the snmp-query-engine SNMP query daemon
5
+ Project-URL: Homepage, https://github.com/tobez/sqe-python
6
+ Project-URL: Changelog, https://github.com/tobez/sqe-python/blob/main/CHANGELOG.md
7
+ Author-email: Anton Berezin <tobez@tobez.org>
8
+ License-Expression: BSD-2-Clause
9
+ License-File: LICENSE
10
+ Keywords: monitoring,msgpack,snmp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: System :: Networking :: Monitoring
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: msgpack>=1.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # snmp-query-engine (Python client)
28
+
29
+ Python client library for the
30
+ [snmp-query-engine](https://github.com/tobez/snmp-query-engine) daemon — a
31
+ multiplexing SNMP query engine that performs throttled SNMP queries towards
32
+ many destinations on behalf of its clients.
33
+
34
+ Distribution name `snmp-query-engine`, import name `sqe`.
35
+
36
+ ```sh
37
+ pip install snmp-query-engine # or: uv add snmp-query-engine
38
+ ```
39
+
40
+ ## Synchronous client
41
+
42
+ ```python
43
+ import sqe
44
+
45
+ with sqe.Client() as c: # daemon on 127.0.0.1:7667
46
+ c.setopt("10.0.0.1", 161, {"community": "secret", "version": 2})
47
+ for vb in c.get("10.0.0.1", 161, ["1.3.6.1.2.1.1.5.0"]):
48
+ print(vb.oid, vb.value if vb.ok else f"error: {vb.error}")
49
+ for vb in c.gettable("10.0.0.1", 161, "1.3.6.1.2.1.2.2.1.2"):
50
+ print(vb.oid, vb.value)
51
+ ```
52
+
53
+ The client is thread-safe; call it from a thread pool for concurrency.
54
+
55
+ ## asyncio client
56
+
57
+ ```python
58
+ import asyncio
59
+ import sqe
60
+
61
+ async def main():
62
+ async with sqe.AsyncClient() as c:
63
+ vbs = await c.get("10.0.0.1", 161, ["1.3.6.1.2.1.1.5.0"])
64
+ print(vbs[0].value)
65
+
66
+ asyncio.run(main())
67
+ ```
68
+
69
+ Both clients expose the same six methods: `setopt`, `getopt`, `get`,
70
+ `gettable`, `info`, `dest_info`. See the
71
+ [daemon manual](https://github.com/tobez/snmp-query-engine/blob/main/manual.mdwn)
72
+ for the available options and the semantics behind them.
73
+
74
+ ## Semantics worth knowing
75
+
76
+ - **Values are msgpack-native.** SNMP string values arrive as `bytes`
77
+ (they are genuinely binary); OIDs, map keys, and error strings are `str`.
78
+ - **Per-OID errors are values, not exceptions**: check `VarBind.ok` /
79
+ `VarBind.error`. Request-level daemon errors raise `sqe.RequestError`.
80
+ - **Reconnect is automatic by default**: on connection loss, in-flight
81
+ requests fail with `sqe.ConnectionLost`, the client reconnects with
82
+ capped exponential backoff, replays your accumulated `setopt` options,
83
+ and only then releases new traffic. Opt out with `reconnect=False`.
84
+ - **Per-call safety timeout**: every method takes `timeout=` (seconds) as a
85
+ guard against a wedged daemon; `None` (default) trusts the daemon's own
86
+ timeout/retry machinery, which always answers eventually.
87
+ - **SETOPT options pass through verbatim** as a dict, named exactly as in
88
+ the daemon manual. Convenience: `bytes` values for the hexstring options
89
+ (`engineid`, `authkul`, `privkul`) are hex-encoded automatically.
90
+
91
+ ## Development
92
+
93
+ ```sh
94
+ uv sync
95
+ uv run pytest # unit + transport tiers
96
+ uv run pytest -m integration # live tier: needs a daemon binary
97
+ # ($SQE_BINARY, PATH, or ../snmp-query-engine)
98
+ uv run ruff check . && uv run mypy
99
+ ```
100
+
101
+ The sans-I/O protocol core lives in `sqe/protocol.py`; alternative event
102
+ loops (trio, ...) can drive it directly.
103
+
104
+ ## License
105
+
106
+ BSD 2-clause, matching the daemon and the Perl client
107
+ [Net::SNMP::QueryEngine::AnyEvent](https://metacpan.org/pod/Net::SNMP::QueryEngine::AnyEvent).
@@ -0,0 +1,10 @@
1
+ sqe/__init__.py,sha256=mnX-SzZ5lAm-H368EycnKaUhqFjmSgwmoHdnV5uSe54,556
2
+ sqe/aio.py,sha256=F7cOMzvYlOT5rgtFhSRtP-JrMVHJYS0dQ5pvH_WAhJg,10601
3
+ sqe/client.py,sha256=JA99EMESryaMUSMj8eEx-1PMD8W3gmXusQOXCC9nfUA,11311
4
+ sqe/errors.py,sha256=ogBaR_U08WAkG_5_RcXboIRQNZbSwnU3Hobpjh8fVBQ,503
5
+ sqe/protocol.py,sha256=HFMWu6wmx-rp0rpTLpolCM7pVcfi2pLkI3S2oo0DP_A,9884
6
+ sqe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ snmp_query_engine-0.1.0.dist-info/METADATA,sha256=HCF0cH2kO2veru0TSTJaVjwTw93li7TySLBp4N0jpkI,4005
8
+ snmp_query_engine-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ snmp_query_engine-0.1.0.dist-info/licenses/LICENSE,sha256=cf3WfBUmWJd03HzbCYBKMjbzS-Ts-_inalDQWRRyWwU,1319
10
+ snmp_query_engine-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,24 @@
1
+ Copyright (c) 2026, Anton Berezin <tobez@tobez.org>
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice,
8
+ this list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
17
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
18
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
19
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
20
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
21
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
22
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
23
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
24
+ POSSIBILITY OF SUCH DAMAGE.
sqe/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ # ABOUTME: Public package surface for the snmp-query-engine client library.
2
+ # ABOUTME: Re-exports the clients, VarBind, and the exception hierarchy.
3
+ """Python client library for the snmp-query-engine daemon."""
4
+
5
+ from .aio import AsyncClient
6
+ from .client import Client
7
+ from .errors import ConnectionLost, ProtocolError, RequestError, SqeError
8
+ from .protocol import VarBind
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "AsyncClient",
14
+ "Client",
15
+ "ConnectionLost",
16
+ "ProtocolError",
17
+ "RequestError",
18
+ "SqeError",
19
+ "VarBind",
20
+ "__version__",
21
+ ]
sqe/aio.py ADDED
@@ -0,0 +1,284 @@
1
+ # ABOUTME: asyncio client for the snmp-query-engine daemon: a reader task
2
+ # ABOUTME: dispatches responses to per-request futures; auto-reconnect built in.
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import contextlib
8
+ from collections.abc import Callable
9
+ from typing import Any, cast
10
+
11
+ from . import protocol
12
+ from .errors import ConnectionLost, ProtocolError
13
+
14
+ _Encoder = Callable[[protocol.Connection], "tuple[int, bytes]"]
15
+
16
+
17
+ class AsyncClient:
18
+ """asyncio client; safe for concurrent use from many tasks of one loop.
19
+
20
+ Construction does no I/O; the connection is established on first use
21
+ (or by an explicit await connect()).
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ host: str = "127.0.0.1",
27
+ port: int = 7667,
28
+ *,
29
+ reconnect: bool = True,
30
+ reconnect_initial_delay: float = 0.1,
31
+ reconnect_max_delay: float = 5.0,
32
+ connect_timeout: float = 5.0,
33
+ ) -> None:
34
+ self._host = host
35
+ self._port = port
36
+ self._reconnect = reconnect
37
+ self._reconnect_initial_delay = reconnect_initial_delay
38
+ self._reconnect_max_delay = reconnect_max_delay
39
+ self._connect_timeout = connect_timeout
40
+ self._conn = protocol.Connection()
41
+ self._stream: asyncio.StreamReader | None = None
42
+ self._writer: asyncio.StreamWriter | None = None
43
+ self._reader_task: asyncio.Task[None] | None = None
44
+ self._futures: dict[int, asyncio.Future[protocol.Response]] = {}
45
+ self._connected = asyncio.Event()
46
+ self._connect_lock = asyncio.Lock()
47
+ self._closed = False
48
+ self._broken = False
49
+
50
+ # -- lifecycle --
51
+
52
+ async def __aenter__(self) -> AsyncClient:
53
+ return self
54
+
55
+ async def __aexit__(self, *exc: object) -> None:
56
+ await self.close()
57
+
58
+ async def connect(self) -> None:
59
+ """Connect now; after a drop with reconnect=False this revives the client."""
60
+ async with self._connect_lock:
61
+ if self._closed:
62
+ raise ConnectionLost("client is closed")
63
+ self._broken = False
64
+ if self._writer is None and self._reader_task is None:
65
+ await self._establish()
66
+
67
+ async def close(self) -> None:
68
+ """Close the connection; every in-flight request fails with ConnectionLost."""
69
+ if self._closed:
70
+ return
71
+ self._closed = True
72
+ task, self._reader_task = self._reader_task, None
73
+ writer, self._writer = self._writer, None
74
+ for response in self._conn.connection_lost():
75
+ self._dispatch(response)
76
+ self._connected.set() # wake reconnect waiters; they re-check _closed
77
+ if task is not None and task is not asyncio.current_task():
78
+ task.cancel()
79
+ with contextlib.suppress(asyncio.CancelledError):
80
+ await task
81
+ if writer is not None:
82
+ writer.close()
83
+ with contextlib.suppress(OSError):
84
+ await writer.wait_closed()
85
+
86
+ # -- requests --
87
+
88
+ async def setopt(
89
+ self, host: str, port: int, options: dict[str, Any], *, timeout: float | None = None
90
+ ) -> dict[str, Any]:
91
+ opts = dict(options)
92
+ return cast(
93
+ "dict[str, Any]",
94
+ await self._request(lambda c: c.send_setopt(host, port, opts), timeout),
95
+ )
96
+
97
+ async def getopt(self, host: str, port: int, *, timeout: float | None = None) -> dict[str, Any]:
98
+ return cast(
99
+ "dict[str, Any]",
100
+ await self._request(lambda c: c.send_getopt(host, port), timeout),
101
+ )
102
+
103
+ async def info(self, *, timeout: float | None = None) -> dict[str, Any]:
104
+ return cast("dict[str, Any]", await self._request(lambda c: c.send_info(), timeout))
105
+
106
+ async def get(
107
+ self, host: str, port: int, oids: list[str], *, timeout: float | None = None
108
+ ) -> list[protocol.VarBind]:
109
+ oid_list = list(oids)
110
+ return cast(
111
+ "list[protocol.VarBind]",
112
+ await self._request(lambda c: c.send_get(host, port, oid_list), timeout),
113
+ )
114
+
115
+ async def gettable(
116
+ self,
117
+ host: str,
118
+ port: int,
119
+ oid: str,
120
+ max_repetitions: int | None = None,
121
+ *,
122
+ timeout: float | None = None,
123
+ ) -> list[protocol.VarBind]:
124
+ return cast(
125
+ "list[protocol.VarBind]",
126
+ await self._request(
127
+ lambda c: c.send_gettable(host, port, oid, max_repetitions), timeout
128
+ ),
129
+ )
130
+
131
+ async def dest_info(
132
+ self, host: str, port: int, *, timeout: float | None = None
133
+ ) -> dict[str, Any]:
134
+ return cast(
135
+ "dict[str, Any]",
136
+ await self._request(lambda c: c.send_dest_info(host, port), timeout),
137
+ )
138
+
139
+ # -- plumbing --
140
+
141
+ async def _request(self, encode: _Encoder, timeout: float | None) -> Any:
142
+ loop = asyncio.get_running_loop()
143
+ deadline = None if timeout is None else loop.time() + timeout
144
+ while True:
145
+ await self._await_link(deadline)
146
+ if self._closed:
147
+ raise ConnectionLost("client is closed")
148
+ if self._writer is None:
149
+ continue # link dropped between checks; wait again
150
+ request_id, data = encode(self._conn)
151
+ future: asyncio.Future[protocol.Response] = loop.create_future()
152
+ self._futures[request_id] = future
153
+ # No drain(): requests are small and the daemon reads promptly,
154
+ # so relying on StreamWriter's own buffering keeps writes off the
155
+ # per-call deadline.
156
+ self._writer.write(data)
157
+ break
158
+ try:
159
+ remaining = None if deadline is None else max(0.0, deadline - loop.time())
160
+ response = await asyncio.wait_for(future, remaining)
161
+ except asyncio.TimeoutError:
162
+ self._abandon(request_id)
163
+ raise TimeoutError(f"no response from daemon within {timeout} seconds") from None
164
+ except asyncio.CancelledError:
165
+ self._abandon(request_id)
166
+ raise
167
+ if response.error is not None:
168
+ raise response.error
169
+ return response.value
170
+
171
+ def _abandon(self, request_id: int) -> None:
172
+ self._conn.abandon(request_id)
173
+ self._futures.pop(request_id, None)
174
+
175
+ async def _await_link(self, deadline: float | None) -> None:
176
+ if self._closed:
177
+ raise ConnectionLost("client is closed")
178
+ if self._broken:
179
+ raise ConnectionLost("connection lost; call connect() to retry")
180
+ if self._writer is not None:
181
+ return
182
+ if self._reader_task is None:
183
+ async with self._connect_lock:
184
+ if self._closed:
185
+ raise ConnectionLost("client is closed")
186
+ if self._writer is None and self._reader_task is None:
187
+ await self._establish() # lazy first connect; errors propagate
188
+ return
189
+ loop = asyncio.get_running_loop()
190
+ remaining = None if deadline is None else max(0.0, deadline - loop.time())
191
+ try:
192
+ await asyncio.wait_for(self._connected.wait(), remaining)
193
+ except asyncio.TimeoutError:
194
+ raise TimeoutError("daemon connection was not restored in time") from None
195
+ if self._closed:
196
+ raise ConnectionLost("client is closed")
197
+ if self._broken:
198
+ raise ConnectionLost("connection lost; call connect() to retry")
199
+
200
+ async def _establish(self) -> None:
201
+ try:
202
+ reader, writer = await asyncio.wait_for(
203
+ asyncio.open_connection(self._host, self._port), self._connect_timeout
204
+ )
205
+ except asyncio.TimeoutError:
206
+ raise TimeoutError(
207
+ f"connection to {self._host}:{self._port} timed out after "
208
+ f"{self._connect_timeout} seconds"
209
+ ) from None
210
+ if self._closed:
211
+ writer.close()
212
+ return
213
+ self._stream = reader
214
+ self._attach(writer)
215
+ self._reader_task = asyncio.get_running_loop().create_task(self._reader_main())
216
+
217
+ def _attach(self, writer: asyncio.StreamWriter) -> None:
218
+ self._writer = writer
219
+ for _request_id, data in self._conn.replay_requests():
220
+ writer.write(data)
221
+ self._connected.set()
222
+
223
+ async def _reader_main(self) -> None:
224
+ while True:
225
+ await self._read_until_drop()
226
+ if not await self._handle_drop():
227
+ return
228
+
229
+ async def _read_until_drop(self) -> None:
230
+ stream = self._stream
231
+ assert stream is not None
232
+ while True:
233
+ try:
234
+ data = await stream.read(65536)
235
+ except OSError:
236
+ return
237
+ if not data:
238
+ return
239
+ try:
240
+ self._conn.feed(data)
241
+ while (response := self._conn.next_response()) is not None:
242
+ self._dispatch(response)
243
+ except ProtocolError:
244
+ return # connection-fatal; treated exactly like a drop
245
+
246
+ async def _handle_drop(self) -> bool:
247
+ if self._closed:
248
+ return False
249
+ self._connected.clear()
250
+ old_writer, self._writer = self._writer, None
251
+ if old_writer is not None:
252
+ old_writer.close()
253
+ with contextlib.suppress(OSError):
254
+ await old_writer.wait_closed()
255
+ for response in self._conn.connection_lost():
256
+ self._dispatch(response)
257
+ if not self._reconnect:
258
+ self._broken = True
259
+ self._reader_task = None
260
+ return False
261
+ delay = self._reconnect_initial_delay
262
+ while True:
263
+ try:
264
+ reader, writer = await asyncio.wait_for(
265
+ asyncio.open_connection(self._host, self._port), self._connect_timeout
266
+ )
267
+ except (OSError, asyncio.TimeoutError):
268
+ await asyncio.sleep(delay)
269
+ delay = min(delay * 2, self._reconnect_max_delay)
270
+ if self._closed:
271
+ return False
272
+ continue
273
+ if self._closed:
274
+ writer.close()
275
+ return False
276
+ self._stream = reader
277
+ self._attach(writer)
278
+ return True
279
+
280
+ def _dispatch(self, response: protocol.Response) -> None:
281
+ future = self._futures.pop(response.request_id, None)
282
+ if future is not None and not future.done():
283
+ future.set_result(response)
284
+ # responses with no future (replayed SETOPTs, abandoned calls) drop here
sqe/client.py ADDED
@@ -0,0 +1,301 @@
1
+ # ABOUTME: Synchronous, thread-safe client for the snmp-query-engine daemon.
2
+ # ABOUTME: A background reader thread dispatches responses to blocked callers.
3
+
4
+ from __future__ import annotations
5
+
6
+ import socket
7
+ import threading
8
+ import time
9
+ from collections.abc import Callable
10
+ from typing import Any, cast
11
+
12
+ from . import protocol
13
+ from .errors import ConnectionLost, ProtocolError
14
+
15
+ _Encoder = Callable[[protocol.Connection], "tuple[int, bytes]"]
16
+
17
+
18
+ def _close_quietly(sock: socket.socket) -> None:
19
+ try:
20
+ sock.close()
21
+ except OSError:
22
+ pass
23
+
24
+
25
+ class _Waiter:
26
+ __slots__ = ("event", "response")
27
+
28
+ def __init__(self) -> None:
29
+ self.event = threading.Event()
30
+ self.response: protocol.Response | None = None
31
+
32
+
33
+ class Client:
34
+ """Synchronous client; safe for concurrent use from multiple threads.
35
+
36
+ Construction does no I/O; the connection is established on first use
37
+ (or by an explicit connect()).
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ host: str = "127.0.0.1",
43
+ port: int = 7667,
44
+ *,
45
+ reconnect: bool = True,
46
+ reconnect_initial_delay: float = 0.1,
47
+ reconnect_max_delay: float = 5.0,
48
+ connect_timeout: float = 5.0,
49
+ ) -> None:
50
+ self._host = host
51
+ self._port = port
52
+ self._reconnect = reconnect
53
+ self._reconnect_initial_delay = reconnect_initial_delay
54
+ self._reconnect_max_delay = reconnect_max_delay
55
+ self._connect_timeout = connect_timeout
56
+ self._lock = threading.Lock()
57
+ self._send_lock = threading.Lock()
58
+ self._conn = protocol.Connection()
59
+ self._sock: socket.socket | None = None
60
+ self._reader: threading.Thread | None = None
61
+ self._waiters: dict[int, _Waiter] = {}
62
+ self._connected = threading.Event()
63
+ self._wake = threading.Event() # interrupts reconnect backoff on close
64
+ self._closed = False
65
+ self._broken = False
66
+
67
+ # -- lifecycle --
68
+
69
+ def __enter__(self) -> Client:
70
+ return self
71
+
72
+ def __exit__(self, *exc: object) -> None:
73
+ self.close()
74
+
75
+ def connect(self) -> None:
76
+ """Connect now; after a drop with reconnect=False this revives the client."""
77
+ with self._lock:
78
+ if self._closed:
79
+ raise ConnectionLost("client is closed")
80
+ self._broken = False
81
+ if self._sock is None and self._reader is None:
82
+ self._connect_locked()
83
+
84
+ def close(self) -> None:
85
+ """Close the connection; every in-flight request fails with ConnectionLost."""
86
+ with self._lock:
87
+ if self._closed:
88
+ return
89
+ self._closed = True
90
+ self._wake.set()
91
+ sock, self._sock = self._sock, None
92
+ reader, self._reader = self._reader, None
93
+ if sock is not None:
94
+ _close_quietly(sock)
95
+ for response in self._conn.connection_lost():
96
+ self._dispatch_locked(response)
97
+ self._connected.set() # wake reconnect waiters; they re-check _closed
98
+ if reader is not None and reader is not threading.current_thread():
99
+ reader.join(timeout=5)
100
+
101
+ # -- requests --
102
+
103
+ def setopt(
104
+ self, host: str, port: int, options: dict[str, Any], *, timeout: float | None = None
105
+ ) -> dict[str, Any]:
106
+ opts = dict(options)
107
+ return cast(
108
+ "dict[str, Any]",
109
+ self._request(lambda c: c.send_setopt(host, port, opts), timeout),
110
+ )
111
+
112
+ def getopt(self, host: str, port: int, *, timeout: float | None = None) -> dict[str, Any]:
113
+ return cast("dict[str, Any]", self._request(lambda c: c.send_getopt(host, port), timeout))
114
+
115
+ def info(self, *, timeout: float | None = None) -> dict[str, Any]:
116
+ return cast("dict[str, Any]", self._request(lambda c: c.send_info(), timeout))
117
+
118
+ def get(
119
+ self, host: str, port: int, oids: list[str], *, timeout: float | None = None
120
+ ) -> list[protocol.VarBind]:
121
+ oid_list = list(oids)
122
+ return cast(
123
+ "list[protocol.VarBind]",
124
+ self._request(lambda c: c.send_get(host, port, oid_list), timeout),
125
+ )
126
+
127
+ def gettable(
128
+ self,
129
+ host: str,
130
+ port: int,
131
+ oid: str,
132
+ max_repetitions: int | None = None,
133
+ *,
134
+ timeout: float | None = None,
135
+ ) -> list[protocol.VarBind]:
136
+ return cast(
137
+ "list[protocol.VarBind]",
138
+ self._request(lambda c: c.send_gettable(host, port, oid, max_repetitions), timeout),
139
+ )
140
+
141
+ def dest_info(self, host: str, port: int, *, timeout: float | None = None) -> dict[str, Any]:
142
+ return cast(
143
+ "dict[str, Any]", self._request(lambda c: c.send_dest_info(host, port), timeout)
144
+ )
145
+
146
+ # -- plumbing --
147
+
148
+ def _request(self, encode: _Encoder, timeout: float | None) -> Any:
149
+ deadline = None if timeout is None else time.monotonic() + timeout
150
+ waiter = _Waiter()
151
+ while True:
152
+ self._await_link(deadline)
153
+ with self._lock:
154
+ if self._closed:
155
+ raise ConnectionLost("client is closed")
156
+ if self._sock is None:
157
+ continue # link dropped between checks; wait again
158
+ request_id, data = encode(self._conn)
159
+ self._waiters[request_id] = waiter
160
+ sock = self._sock
161
+ try:
162
+ with self._send_lock:
163
+ sock.sendall(data)
164
+ except OSError:
165
+ pass # the reader observes the drop and fails this waiter
166
+ break
167
+ remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
168
+ if not waiter.event.wait(remaining):
169
+ with self._lock:
170
+ self._conn.abandon(request_id)
171
+ self._waiters.pop(request_id, None)
172
+ raise TimeoutError(f"no response from daemon within {timeout} seconds")
173
+ response = waiter.response
174
+ assert response is not None
175
+ if response.error is not None:
176
+ raise response.error
177
+ return response.value
178
+
179
+ def _await_link(self, deadline: float | None) -> None:
180
+ with self._lock:
181
+ if self._closed:
182
+ raise ConnectionLost("client is closed")
183
+ if self._broken:
184
+ raise ConnectionLost("connection lost; call connect() to retry")
185
+ if self._sock is not None:
186
+ return
187
+ if self._reader is None:
188
+ self._connect_locked() # lazy first connect; errors propagate
189
+ return
190
+ # a reconnect is in progress; wait for the reader thread to restore it
191
+ remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
192
+ if not self._connected.wait(remaining):
193
+ raise TimeoutError("daemon connection was not restored in time")
194
+ with self._lock:
195
+ if self._closed:
196
+ raise ConnectionLost("client is closed")
197
+ if self._broken:
198
+ raise ConnectionLost("connection lost; call connect() to retry")
199
+
200
+ def _connect_locked(self) -> None:
201
+ sock = socket.create_connection((self._host, self._port), timeout=self._connect_timeout)
202
+ sock.settimeout(None)
203
+ self._attach_locked(sock)
204
+ self._reader = threading.Thread(target=self._reader_main, args=(sock,), daemon=True)
205
+ self._reader.start()
206
+
207
+ def _attach_locked(self, sock: socket.socket) -> None:
208
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
209
+ self._sock = sock
210
+ # Safe under _lock: the reader for this socket starts only after we return,
211
+ # so no concurrent reader can starve waiting for the lock while this send happens.
212
+ self._send_payloads(sock, self._conn.replay_requests())
213
+ self._connected.set()
214
+
215
+ def _send_payloads(self, sock: socket.socket, payloads: list[tuple[int, bytes]]) -> None:
216
+ """Send pre-encoded payloads, serialized against concurrent senders."""
217
+ with self._send_lock:
218
+ for _request_id, data in payloads:
219
+ try:
220
+ sock.sendall(data)
221
+ except OSError:
222
+ break # the reader observes the drop
223
+
224
+ def _reader_main(self, sock: socket.socket) -> None:
225
+ while True:
226
+ self._read_until_drop(sock)
227
+ next_sock = self._handle_drop(sock)
228
+ if next_sock is None:
229
+ return
230
+ sock = next_sock
231
+
232
+ def _read_until_drop(self, sock: socket.socket) -> None:
233
+ while True:
234
+ try:
235
+ data = sock.recv(65536)
236
+ except OSError:
237
+ return
238
+ if not data:
239
+ return
240
+ with self._lock:
241
+ try:
242
+ self._conn.feed(data)
243
+ while (response := self._conn.next_response()) is not None:
244
+ self._dispatch_locked(response)
245
+ except ProtocolError:
246
+ return # connection-fatal; treated exactly like a drop
247
+
248
+ def _handle_drop(self, sock: socket.socket) -> socket.socket | None:
249
+ with self._lock:
250
+ if self._closed or sock is not self._sock:
251
+ return None
252
+ self._connected.clear()
253
+ self._sock = None
254
+ _close_quietly(sock)
255
+ for response in self._conn.connection_lost():
256
+ self._dispatch_locked(response)
257
+ if not self._reconnect:
258
+ self._broken = True
259
+ self._reader = None
260
+ return None
261
+ return self._reconnect_link()
262
+
263
+ def _reconnect_link(self) -> socket.socket | None:
264
+ delay = self._reconnect_initial_delay
265
+ while True:
266
+ with self._lock:
267
+ if self._closed:
268
+ return None
269
+ try:
270
+ sock = socket.create_connection(
271
+ (self._host, self._port), timeout=self._connect_timeout
272
+ )
273
+ sock.settimeout(None)
274
+ except OSError:
275
+ if self._wake.wait(delay):
276
+ return None # close() interrupted the backoff
277
+ delay = min(delay * 2, self._reconnect_max_delay)
278
+ continue
279
+ with self._lock:
280
+ if self._closed:
281
+ _close_quietly(sock)
282
+ return None
283
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
284
+ payloads = self._conn.replay_requests()
285
+ # send outside self._lock: a full send buffer must not block the
286
+ # reader (or any other caller) out of the lock while replaying
287
+ self._send_payloads(sock, payloads)
288
+ with self._lock:
289
+ if self._closed:
290
+ _close_quietly(sock)
291
+ return None
292
+ self._sock = sock
293
+ self._connected.set()
294
+ return sock
295
+
296
+ def _dispatch_locked(self, response: protocol.Response) -> None:
297
+ waiter = self._waiters.pop(response.request_id, None)
298
+ if waiter is not None:
299
+ waiter.response = response
300
+ waiter.event.set()
301
+ # responses with no waiter (replayed SETOPTs, abandoned calls) drop here
sqe/errors.py ADDED
@@ -0,0 +1,18 @@
1
+ # ABOUTME: Exception hierarchy for the sqe client library.
2
+ # ABOUTME: All sqe exceptions derive from SqeError.
3
+
4
+
5
+ class SqeError(Exception):
6
+ """Base class for all sqe errors."""
7
+
8
+
9
+ class RequestError(SqeError):
10
+ """The daemon rejected a request; carries the daemon's error message."""
11
+
12
+
13
+ class ConnectionLost(SqeError):
14
+ """The connection to the daemon was lost while the request was in flight."""
15
+
16
+
17
+ class ProtocolError(SqeError):
18
+ """The daemon sent something the protocol does not allow."""
sqe/protocol.py ADDED
@@ -0,0 +1,261 @@
1
+ # ABOUTME: Sans-I/O protocol core for the snmp-query-engine client protocol:
2
+ # ABOUTME: request encoding, response decoding and matching, SETOPT replay cache.
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import msgpack
11
+
12
+ from .errors import ConnectionLost, ProtocolError, RequestError, SqeError
13
+
14
+ SETOPT = 1
15
+ GETOPT = 2
16
+ INFO = 3
17
+ GET = 4
18
+ GETTABLE = 5
19
+ DEST_INFO = 6
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class VarBind:
24
+ """One OID/value pair from a GET or GETTABLE reply.
25
+
26
+ Per-OID wire errors ("timeout", "no-such-object", ...) land in `error`;
27
+ they never raise.
28
+ """
29
+
30
+ oid: str
31
+ value: Any = None
32
+ error: str | None = None
33
+
34
+ @property
35
+ def ok(self) -> bool:
36
+ return self.error is None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Response:
41
+ """A decoded daemon reply, matched to the request id that caused it."""
42
+
43
+ request_id: int
44
+ value: Any = None
45
+ error: SqeError | None = None
46
+
47
+
48
+ _REPLY_OK = 0x10
49
+ _REPLY_ERROR = 0x20
50
+
51
+ _HEXSTRING_OPTIONS = ("engineid", "authkul", "privkul")
52
+ _V3_OPTIONS = frozenset(
53
+ {
54
+ "engineid",
55
+ "username",
56
+ "authprotocol",
57
+ "authpassword",
58
+ "authkul",
59
+ "privprotocol",
60
+ "privpassword",
61
+ "privkul",
62
+ }
63
+ )
64
+
65
+
66
+ def _text(value: bytes | str) -> str:
67
+ """Decode bytes to string, mirroring daemon's bin-everything strategy."""
68
+ if isinstance(value, str):
69
+ return value
70
+ try:
71
+ return value.decode("utf-8")
72
+ except UnicodeDecodeError as exc:
73
+ raise ProtocolError(f"undecodable string from daemon: {value!r}") from exc
74
+
75
+
76
+ @dataclass
77
+ class _Pending:
78
+ """Bookkeeping for one request awaiting its reply."""
79
+
80
+ rtype: int
81
+ host: str | None = None
82
+ port: int | None = None
83
+ options: dict[str, Any] | None = None
84
+
85
+
86
+ class Connection:
87
+ """Sans-I/O protocol state machine.
88
+
89
+ Encodes requests to wire bytes and decodes/matches wire bytes fed back
90
+ in; never touches a socket. Transports own all I/O.
91
+ """
92
+
93
+ def __init__(self) -> None:
94
+ self._next_id = 1
95
+ self._pending: dict[int, _Pending] = {}
96
+ self._tombstones: set[int] = set()
97
+ self._option_cache: dict[tuple[str, int], dict[str, Any]] = {}
98
+ self._unpacker = msgpack.Unpacker()
99
+
100
+ def _send(self, pending: _Pending, tail: list[Any]) -> tuple[int, bytes]:
101
+ request_id = self._next_id
102
+ self._next_id += 1
103
+ self._pending[request_id] = pending
104
+ data: bytes = msgpack.packb([pending.rtype, request_id, *tail])
105
+ return request_id, data
106
+
107
+ def send_setopt(self, host: str, port: int, options: dict[str, Any]) -> tuple[int, bytes]:
108
+ encoded = dict(options)
109
+ for name in _HEXSTRING_OPTIONS:
110
+ value = encoded.get(name)
111
+ if isinstance(value, bytes):
112
+ encoded[name] = value.hex()
113
+ pending = _Pending(SETOPT, host, port, options=encoded)
114
+ return self._send(pending, [host, port, encoded])
115
+
116
+ def send_getopt(self, host: str, port: int) -> tuple[int, bytes]:
117
+ return self._send(_Pending(GETOPT, host, port), [host, port])
118
+
119
+ def send_info(self) -> tuple[int, bytes]:
120
+ return self._send(_Pending(INFO), [])
121
+
122
+ def send_get(self, host: str, port: int, oids: Iterable[str]) -> tuple[int, bytes]:
123
+ return self._send(_Pending(GET, host, port), [host, port, list(oids)])
124
+
125
+ def send_gettable(
126
+ self, host: str, port: int, oid: str, max_repetitions: int | None = None
127
+ ) -> tuple[int, bytes]:
128
+ tail: list[Any] = [host, port, oid]
129
+ if max_repetitions is not None:
130
+ tail.append(max_repetitions)
131
+ return self._send(_Pending(GETTABLE, host, port), tail)
132
+
133
+ def send_dest_info(self, host: str, port: int) -> tuple[int, bytes]:
134
+ return self._send(_Pending(DEST_INFO, host, port), [host, port])
135
+
136
+ def feed(self, data: bytes) -> None:
137
+ """Give the connection bytes read from the wire."""
138
+ self._unpacker.feed(data)
139
+
140
+ def next_response(self) -> Response | None:
141
+ """Return the next decoded response, or None if more bytes are needed.
142
+
143
+ Call repeatedly after each feed(): one feed can complete zero or
144
+ more responses. Raises ProtocolError on anything malformed;
145
+ transports must treat that as connection-fatal.
146
+ """
147
+ while True:
148
+ try:
149
+ obj = self._unpacker.unpack()
150
+ except msgpack.exceptions.OutOfData:
151
+ return None
152
+ except (msgpack.exceptions.UnpackException, ValueError) as exc:
153
+ raise ProtocolError(f"malformed msgpack from daemon: {exc}") from exc
154
+ response = self._handle(obj)
155
+ if response is not None:
156
+ return response
157
+
158
+ def _handle(self, obj: Any) -> Response | None:
159
+ """Process a decoded msgpack object, returning a Response or None if skipped."""
160
+ if not isinstance(obj, list) or len(obj) < 2:
161
+ raise ProtocolError(f"response is not a well-formed array: {obj!r}")
162
+ rtype, request_id = obj[0], obj[1]
163
+ if not isinstance(rtype, int) or not isinstance(request_id, int):
164
+ raise ProtocolError(f"response type/id are not integers: {obj!r}")
165
+ if request_id in self._tombstones:
166
+ self._tombstones.discard(request_id)
167
+ return None
168
+ pending = self._pending.get(request_id)
169
+ if pending is None:
170
+ raise ProtocolError(f"response for unknown request id {request_id}")
171
+ if rtype == pending.rtype | _REPLY_ERROR:
172
+ del self._pending[request_id]
173
+ if len(obj) != 3 or not isinstance(obj[2], (bytes, str)):
174
+ raise ProtocolError(f"malformed error reply: {obj!r}")
175
+ return Response(request_id, error=RequestError(_text(obj[2])))
176
+ if rtype != pending.rtype | _REPLY_OK:
177
+ raise ProtocolError(
178
+ f"reply type 0x{rtype:x} does not match request type {pending.rtype}"
179
+ )
180
+ del self._pending[request_id]
181
+ if len(obj) != 3:
182
+ raise ProtocolError(f"success reply is not a 3-element array: {obj!r}")
183
+ value = self._decode_payload(pending.rtype, obj[2])
184
+ if pending.rtype == SETOPT:
185
+ self._cache_setopt(pending)
186
+ return Response(request_id, value=value)
187
+
188
+ def _decode_payload(self, rtype: int, payload: Any) -> Any:
189
+ """Decode a payload based on response type."""
190
+ if rtype in (GET, GETTABLE):
191
+ return self._decode_varbinds(payload)
192
+ return self._decode_map(payload)
193
+
194
+ def _decode_map(self, payload: Any) -> dict[str, Any]:
195
+ """Decode a map, normalizing all keys to strings recursively."""
196
+ if not isinstance(payload, dict):
197
+ raise ProtocolError(f"reply payload is not a map: {payload!r}")
198
+ out: dict[str, Any] = {}
199
+ for key, value in payload.items():
200
+ if not isinstance(key, (bytes, str)):
201
+ raise ProtocolError(f"non-string map key in reply: {key!r}")
202
+ # Values are scalars or nested maps; the daemon never nests maps inside lists.
203
+ out[_text(key)] = self._decode_map(value) if isinstance(value, dict) else value
204
+ return out
205
+
206
+ def _decode_varbinds(self, payload: Any) -> list[VarBind]:
207
+ """Decode a list of OID/value pairs (GET/GETTABLE result)."""
208
+ if not isinstance(payload, list):
209
+ raise ProtocolError(f"oid reply payload is not an array: {payload!r}")
210
+ out: list[VarBind] = []
211
+ for row in payload:
212
+ if not isinstance(row, list) or len(row) != 2 or not isinstance(row[0], (bytes, str)):
213
+ raise ProtocolError(f"malformed varbind row: {row!r}")
214
+ oid, value = _text(row[0]), row[1]
215
+ if isinstance(value, list):
216
+ if len(value) != 1 or not isinstance(value[0], (bytes, str)):
217
+ raise ProtocolError(f"malformed per-oid error for {oid}: {value!r}")
218
+ out.append(VarBind(oid, error=_text(value[0])))
219
+ else:
220
+ out.append(VarBind(oid, value=value))
221
+ return out
222
+
223
+ def _cache_setopt(self, pending: _Pending) -> None:
224
+ """Cache SETOPT options for this host/port pair."""
225
+ assert pending.host is not None and pending.port is not None
226
+ assert pending.options is not None
227
+ cache = self._option_cache.setdefault((pending.host, pending.port), {})
228
+ if _V3_OPTIONS & pending.options.keys():
229
+ for name in _V3_OPTIONS:
230
+ cache.pop(name, None)
231
+ cache.update(pending.options)
232
+
233
+ def connection_lost(self) -> list[Response]:
234
+ """The transport lost the TCP connection.
235
+
236
+ Every pending request drains as a ConnectionLost-carrying response
237
+ for the transport to dispatch. Tombstones and any partially received
238
+ frame are discarded; the option cache survives (it is per-client
239
+ state, not per-TCP-connection state).
240
+ """
241
+ drained = [
242
+ Response(request_id, error=ConnectionLost("connection to daemon lost"))
243
+ for request_id in self._pending
244
+ ]
245
+ self._pending.clear()
246
+ self._tombstones.clear()
247
+ self._unpacker = msgpack.Unpacker()
248
+ return drained
249
+
250
+ def replay_requests(self) -> list[tuple[int, bytes]]:
251
+ """Encode a SETOPT per cached destination, for replay after reconnect."""
252
+ out: list[tuple[int, bytes]] = []
253
+ for (host, port), options in self._option_cache.items():
254
+ if options:
255
+ out.append(self.send_setopt(host, port, options))
256
+ return out
257
+
258
+ def abandon(self, request_id: int) -> None:
259
+ """Forget a pending request; its eventual response is dropped silently."""
260
+ if self._pending.pop(request_id, None) is not None:
261
+ self._tombstones.add(request_id)
sqe/py.typed ADDED
File without changes