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.
- snmp_query_engine-0.1.0.dist-info/METADATA +107 -0
- snmp_query_engine-0.1.0.dist-info/RECORD +10 -0
- snmp_query_engine-0.1.0.dist-info/WHEEL +4 -0
- snmp_query_engine-0.1.0.dist-info/licenses/LICENSE +24 -0
- sqe/__init__.py +21 -0
- sqe/aio.py +284 -0
- sqe/client.py +301 -0
- sqe/errors.py +18 -0
- sqe/protocol.py +261 -0
- sqe/py.typed +0 -0
|
@@ -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,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
|