libbeachcomber 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.
- libbeachcomber/__init__.py +47 -0
- libbeachcomber/client.py +319 -0
- libbeachcomber/discovery.py +35 -0
- libbeachcomber/exceptions.py +35 -0
- libbeachcomber/protocol.py +119 -0
- libbeachcomber/result.py +59 -0
- libbeachcomber-0.1.0.dist-info/METADATA +110 -0
- libbeachcomber-0.1.0.dist-info/RECORD +9 -0
- libbeachcomber-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""beachcomber — Python client SDK for the comb shell-state daemon.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from beachcomber import Client
|
|
6
|
+
|
|
7
|
+
client = Client()
|
|
8
|
+
result = client.get("git.branch", path="/path/to/repo")
|
|
9
|
+
if result.is_hit:
|
|
10
|
+
print(result.data) # "main"
|
|
11
|
+
print(result.age_ms) # 234
|
|
12
|
+
print(result.stale) # False
|
|
13
|
+
|
|
14
|
+
# Full provider object
|
|
15
|
+
result = client.get("git", path="/path/to/repo")
|
|
16
|
+
if result.is_hit:
|
|
17
|
+
print(result["branch"]) # subscript into dict data
|
|
18
|
+
|
|
19
|
+
# Force recomputation
|
|
20
|
+
client.poke("git", path="/path/to/repo")
|
|
21
|
+
|
|
22
|
+
# Persistent connection for multiple queries
|
|
23
|
+
with client.session() as session:
|
|
24
|
+
session.set_context("/path/to/repo")
|
|
25
|
+
branch = session.get("git.branch")
|
|
26
|
+
host = session.get("hostname")
|
|
27
|
+
|
|
28
|
+
# List providers and daemon status
|
|
29
|
+
providers = client.list()
|
|
30
|
+
status = client.status()
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from .client import Client, Session
|
|
34
|
+
from .exceptions import CombError, DaemonNotRunning, ProtocolError, ServerError
|
|
35
|
+
from .result import CombResult
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Client",
|
|
39
|
+
"Session",
|
|
40
|
+
"CombResult",
|
|
41
|
+
"CombError",
|
|
42
|
+
"DaemonNotRunning",
|
|
43
|
+
"ProtocolError",
|
|
44
|
+
"ServerError",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
__version__ = "0.1.0"
|
libbeachcomber/client.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Client and Session classes for the beachcomber daemon.
|
|
2
|
+
|
|
3
|
+
Typical usage::
|
|
4
|
+
|
|
5
|
+
from beachcomber import Client
|
|
6
|
+
|
|
7
|
+
client = Client()
|
|
8
|
+
result = client.get("git.branch", path="/path/to/repo")
|
|
9
|
+
if result.is_hit:
|
|
10
|
+
print(result.data)
|
|
11
|
+
|
|
12
|
+
For multiple queries on one connection use a session::
|
|
13
|
+
|
|
14
|
+
with client.session() as session:
|
|
15
|
+
session.set_context("/path/to/repo")
|
|
16
|
+
branch = session.get("git.branch")
|
|
17
|
+
status = session.get("git")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import io
|
|
23
|
+
import socket
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from typing import Any, Generator, List, Optional
|
|
26
|
+
|
|
27
|
+
from .discovery import discover_socket_path
|
|
28
|
+
from .exceptions import DaemonNotRunning, ProtocolError
|
|
29
|
+
from .protocol import (
|
|
30
|
+
build_context_request,
|
|
31
|
+
build_get_request,
|
|
32
|
+
build_list_request,
|
|
33
|
+
build_poke_request,
|
|
34
|
+
build_status_request,
|
|
35
|
+
decode_response,
|
|
36
|
+
)
|
|
37
|
+
from .result import CombResult
|
|
38
|
+
|
|
39
|
+
# Default socket timeout in seconds (matches Rust client: 100 ms).
|
|
40
|
+
_DEFAULT_TIMEOUT: float = 0.1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _connect(socket_path: str, timeout: float) -> socket.socket:
|
|
44
|
+
"""Open a Unix domain socket connection to the daemon.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
socket_path: Absolute path to the Unix domain socket.
|
|
48
|
+
timeout: Read/write timeout in seconds.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Connected :class:`socket.socket`.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
DaemonNotRunning: If the connection is refused or the socket does
|
|
55
|
+
not exist.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
59
|
+
sock.settimeout(timeout)
|
|
60
|
+
sock.connect(socket_path)
|
|
61
|
+
return sock
|
|
62
|
+
except (ConnectionRefusedError, FileNotFoundError, OSError) as exc:
|
|
63
|
+
raise DaemonNotRunning(socket_path) from exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _send_recv(sock: socket.socket, request: bytes) -> dict[str, Any]:
|
|
67
|
+
"""Send a request and read one response line.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
sock: Connected socket.
|
|
71
|
+
request: Encoded request bytes (must include trailing newline).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Parsed response dict (``ok`` has already been verified to be
|
|
75
|
+
``True``).
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ProtocolError: On I/O or parse failure.
|
|
79
|
+
ServerError: If the daemon returns ``ok: false``.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
sock.sendall(request)
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
raise ProtocolError(f"failed to send request: {exc}") from exc
|
|
85
|
+
|
|
86
|
+
# Read until newline using a file-like wrapper.
|
|
87
|
+
reader = sock.makefile("r", encoding="utf-8")
|
|
88
|
+
try:
|
|
89
|
+
line = reader.readline()
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
raise ProtocolError(f"failed to read response: {exc}") from exc
|
|
92
|
+
finally:
|
|
93
|
+
reader.detach() # Do not close the underlying socket.
|
|
94
|
+
|
|
95
|
+
return decode_response(line)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _result_from_response(resp: dict[str, Any]) -> CombResult:
|
|
99
|
+
"""Build a :class:`CombResult` from a parsed ``get`` response dict."""
|
|
100
|
+
data = resp.get("data")
|
|
101
|
+
age_ms = int(resp.get("age_ms", 0) or 0)
|
|
102
|
+
stale = bool(resp.get("stale", False))
|
|
103
|
+
return CombResult(ok=True, data=data, age_ms=age_ms, stale=stale)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Client:
|
|
107
|
+
"""One-shot client for the beachcomber daemon.
|
|
108
|
+
|
|
109
|
+
Each method opens a new socket connection, sends one request, reads
|
|
110
|
+
the response, then closes the connection. This is simple and safe
|
|
111
|
+
for occasional queries.
|
|
112
|
+
|
|
113
|
+
For repeated queries (e.g. populating a shell prompt) prefer
|
|
114
|
+
:meth:`session` which reuses the connection.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
socket_path: Explicit path to the daemon socket. If ``None`` the
|
|
118
|
+
path is auto-discovered via
|
|
119
|
+
:func:`~beachcomber.discovery.discover_socket_path`.
|
|
120
|
+
timeout: Socket read/write timeout in seconds. Default ``0.1``.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
socket_path: Optional[str] = None,
|
|
126
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
127
|
+
) -> None:
|
|
128
|
+
self._socket_path = socket_path
|
|
129
|
+
self._timeout = timeout
|
|
130
|
+
|
|
131
|
+
def _resolve_path(self) -> str:
|
|
132
|
+
return self._socket_path or discover_socket_path()
|
|
133
|
+
|
|
134
|
+
def get(self, key: str, path: Optional[str] = None) -> CombResult:
|
|
135
|
+
"""Read a cached value from the daemon.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
key: Provider key. Use ``"provider.field"`` for a single
|
|
139
|
+
scalar (e.g. ``"git.branch"``) or ``"provider"`` for the
|
|
140
|
+
full provider object (e.g. ``"git"``).
|
|
141
|
+
path: Working-directory path for per-directory providers.
|
|
142
|
+
Global providers (e.g. ``"hostname"``) ignore this.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
:class:`~beachcomber.result.CombResult` with ``is_hit``
|
|
146
|
+
``True`` when a cached value exists.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
DaemonNotRunning: If the socket cannot be reached.
|
|
150
|
+
ServerError: If the daemon returns an error response.
|
|
151
|
+
ProtocolError: On I/O or JSON parse failure.
|
|
152
|
+
"""
|
|
153
|
+
sock = _connect(self._resolve_path(), self._timeout)
|
|
154
|
+
try:
|
|
155
|
+
resp = _send_recv(sock, build_get_request(key, path))
|
|
156
|
+
finally:
|
|
157
|
+
sock.close()
|
|
158
|
+
return _result_from_response(resp)
|
|
159
|
+
|
|
160
|
+
def poke(self, key: str, path: Optional[str] = None) -> None:
|
|
161
|
+
"""Trigger recomputation of a provider.
|
|
162
|
+
|
|
163
|
+
The daemon will recompute the value in the background. This is
|
|
164
|
+
fire-and-forget — the method returns once the daemon acknowledges
|
|
165
|
+
the poke.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
key: Provider key to recompute.
|
|
169
|
+
path: Working-directory path for per-directory providers.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
DaemonNotRunning: If the socket cannot be reached.
|
|
173
|
+
ServerError: If the daemon returns an error response.
|
|
174
|
+
ProtocolError: On I/O or JSON parse failure.
|
|
175
|
+
"""
|
|
176
|
+
sock = _connect(self._resolve_path(), self._timeout)
|
|
177
|
+
try:
|
|
178
|
+
_send_recv(sock, build_poke_request(key, path))
|
|
179
|
+
finally:
|
|
180
|
+
sock.close()
|
|
181
|
+
|
|
182
|
+
def list(self) -> List[dict[str, Any]]:
|
|
183
|
+
"""Return available providers from the daemon.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
List of provider dicts. Each dict has at least ``"name"``,
|
|
187
|
+
``"global"``, and ``"fields"`` keys.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
DaemonNotRunning: If the socket cannot be reached.
|
|
191
|
+
ServerError: If the daemon returns an error response.
|
|
192
|
+
ProtocolError: On I/O or JSON parse failure.
|
|
193
|
+
"""
|
|
194
|
+
sock = _connect(self._resolve_path(), self._timeout)
|
|
195
|
+
try:
|
|
196
|
+
resp = _send_recv(sock, build_list_request())
|
|
197
|
+
finally:
|
|
198
|
+
sock.close()
|
|
199
|
+
return resp.get("data", [])
|
|
200
|
+
|
|
201
|
+
def status(self) -> dict[str, Any]:
|
|
202
|
+
"""Return daemon scheduler and cache status.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Status dict as returned by the daemon.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
DaemonNotRunning: If the socket cannot be reached.
|
|
209
|
+
ServerError: If the daemon returns an error response.
|
|
210
|
+
ProtocolError: On I/O or JSON parse failure.
|
|
211
|
+
"""
|
|
212
|
+
sock = _connect(self._resolve_path(), self._timeout)
|
|
213
|
+
try:
|
|
214
|
+
resp = _send_recv(sock, build_status_request())
|
|
215
|
+
finally:
|
|
216
|
+
sock.close()
|
|
217
|
+
return resp.get("data", {})
|
|
218
|
+
|
|
219
|
+
@contextmanager
|
|
220
|
+
def session(self) -> Generator[Session, None, None]:
|
|
221
|
+
"""Open a persistent connection as a context manager.
|
|
222
|
+
|
|
223
|
+
Yields a :class:`Session` that reuses a single socket for all
|
|
224
|
+
operations within the ``with`` block.
|
|
225
|
+
|
|
226
|
+
Example::
|
|
227
|
+
|
|
228
|
+
with client.session() as session:
|
|
229
|
+
session.set_context("/my/repo")
|
|
230
|
+
result = session.get("git.branch")
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
DaemonNotRunning: If the socket cannot be reached.
|
|
234
|
+
"""
|
|
235
|
+
sock = _connect(self._resolve_path(), self._timeout)
|
|
236
|
+
session = Session(sock)
|
|
237
|
+
try:
|
|
238
|
+
yield session
|
|
239
|
+
finally:
|
|
240
|
+
sock.close()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Session:
|
|
244
|
+
"""Persistent connection to the beachcomber daemon.
|
|
245
|
+
|
|
246
|
+
Reuses a single Unix domain socket across multiple operations.
|
|
247
|
+
Create via :meth:`Client.session` rather than directly.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
sock: Already-connected :class:`socket.socket`.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def __init__(self, sock: socket.socket) -> None:
|
|
254
|
+
self._sock = sock
|
|
255
|
+
|
|
256
|
+
def set_context(self, path: str) -> None:
|
|
257
|
+
"""Set the default working-directory path for this connection.
|
|
258
|
+
|
|
259
|
+
After calling this, :meth:`get` and :meth:`poke` calls do not
|
|
260
|
+
need an explicit ``path`` argument.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
path: Absolute path to set as the session context.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
ServerError: If the daemon returns an error response.
|
|
267
|
+
ProtocolError: On I/O or JSON parse failure.
|
|
268
|
+
"""
|
|
269
|
+
_send_recv(self._sock, build_context_request(path))
|
|
270
|
+
|
|
271
|
+
def get(self, key: str, path: Optional[str] = None) -> CombResult:
|
|
272
|
+
"""Read a cached value using the persistent connection.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
key: Provider key (``"git.branch"``, ``"git"``, etc.).
|
|
276
|
+
path: Optional path override. If omitted and
|
|
277
|
+
:meth:`set_context` has been called, the session context
|
|
278
|
+
is used by the daemon.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
:class:`~beachcomber.result.CombResult`.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
ServerError: If the daemon returns an error response.
|
|
285
|
+
ProtocolError: On I/O or JSON parse failure.
|
|
286
|
+
"""
|
|
287
|
+
resp = _send_recv(self._sock, build_get_request(key, path))
|
|
288
|
+
return _result_from_response(resp)
|
|
289
|
+
|
|
290
|
+
def poke(self, key: str, path: Optional[str] = None) -> None:
|
|
291
|
+
"""Trigger recomputation via the persistent connection.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
key: Provider key to recompute.
|
|
295
|
+
path: Optional path override.
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
ServerError: If the daemon returns an error response.
|
|
299
|
+
ProtocolError: On I/O or JSON parse failure.
|
|
300
|
+
"""
|
|
301
|
+
_send_recv(self._sock, build_poke_request(key, path))
|
|
302
|
+
|
|
303
|
+
def list(self) -> List[dict[str, Any]]:
|
|
304
|
+
"""Return available providers via the persistent connection.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
List of provider dicts.
|
|
308
|
+
"""
|
|
309
|
+
resp = _send_recv(self._sock, build_list_request())
|
|
310
|
+
return resp.get("data", [])
|
|
311
|
+
|
|
312
|
+
def status(self) -> dict[str, Any]:
|
|
313
|
+
"""Return daemon status via the persistent connection.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Status dict as returned by the daemon.
|
|
317
|
+
"""
|
|
318
|
+
resp = _send_recv(self._sock, build_status_request())
|
|
319
|
+
return resp.get("data", {})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Socket path discovery for the beachcomber daemon.
|
|
2
|
+
|
|
3
|
+
Discovery order:
|
|
4
|
+
1. ``$XDG_RUNTIME_DIR/beachcomber/sock``
|
|
5
|
+
2. ``$TMPDIR/beachcomber-<uid>/sock``
|
|
6
|
+
3. ``/tmp/beachcomber-<uid>/sock``
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_uid() -> int:
|
|
13
|
+
"""Return the effective user ID of the current process."""
|
|
14
|
+
return os.geteuid()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def discover_socket_path() -> str:
|
|
18
|
+
"""Return the expected socket path for the running daemon.
|
|
19
|
+
|
|
20
|
+
Checks the standard locations in order. Returns the first path that
|
|
21
|
+
*should* exist according to the discovery rules — callers are
|
|
22
|
+
responsible for verifying the socket is reachable.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Absolute path string for the Unix domain socket.
|
|
26
|
+
"""
|
|
27
|
+
xdg_runtime = os.environ.get("XDG_RUNTIME_DIR", "")
|
|
28
|
+
if xdg_runtime:
|
|
29
|
+
candidate = os.path.join(xdg_runtime, "beachcomber", "sock")
|
|
30
|
+
if os.path.exists(candidate):
|
|
31
|
+
return candidate
|
|
32
|
+
|
|
33
|
+
uid = get_uid()
|
|
34
|
+
tmpdir = os.environ.get("TMPDIR", "/tmp").rstrip("/")
|
|
35
|
+
return os.path.join(tmpdir, f"beachcomber-{uid}", "sock")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Exceptions raised by the beachcomber client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CombError(Exception):
|
|
5
|
+
"""Base exception for all beachcomber client errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DaemonNotRunning(CombError):
|
|
9
|
+
"""Raised when the beachcomber daemon socket cannot be connected to.
|
|
10
|
+
|
|
11
|
+
This usually means ``comb daemon`` is not running. Start it before
|
|
12
|
+
using the client.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, socket_path: str) -> None:
|
|
16
|
+
self.socket_path = socket_path
|
|
17
|
+
super().__init__(
|
|
18
|
+
f"beachcomber daemon is not running (tried {socket_path})"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ServerError(CombError):
|
|
23
|
+
"""Raised when the daemon returns ``ok: false`` in its response.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
message: The error string from the daemon.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str) -> None:
|
|
30
|
+
self.message = message
|
|
31
|
+
super().__init__(f"daemon error: {message}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProtocolError(CombError):
|
|
35
|
+
"""Raised when a response cannot be parsed as valid JSON or is malformed."""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Newline-delimited JSON protocol helpers for the beachcomber daemon.
|
|
2
|
+
|
|
3
|
+
All requests are JSON objects with an ``"op"`` field. All responses are
|
|
4
|
+
JSON objects with an ``"ok"`` field that is ``true`` or ``false``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from .exceptions import ProtocolError, ServerError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def encode_request(op: str, **fields: Any) -> bytes:
|
|
16
|
+
"""Serialise a request object to a newline-terminated JSON bytes value.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
op: The operation name (``"get"``, ``"poke"``, ``"context"``,
|
|
20
|
+
``"list"``, or ``"status"``).
|
|
21
|
+
**fields: Additional key/value pairs to include in the request.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
UTF-8 encoded JSON followed by a newline byte.
|
|
25
|
+
"""
|
|
26
|
+
payload: dict[str, Any] = {"op": op}
|
|
27
|
+
payload.update(fields)
|
|
28
|
+
return (json.dumps(payload, separators=(",", ":")) + "\n").encode()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def decode_response(line: str) -> dict[str, Any]:
|
|
32
|
+
"""Parse a single newline-delimited JSON response line.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
line: A single text line received from the daemon.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Parsed response dict.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ProtocolError: If the line is not valid JSON or is missing the
|
|
42
|
+
``"ok"`` field.
|
|
43
|
+
ServerError: If the response contains ``"ok": false``.
|
|
44
|
+
"""
|
|
45
|
+
line = line.strip()
|
|
46
|
+
if not line:
|
|
47
|
+
raise ProtocolError("received empty response from daemon")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
data: dict[str, Any] = json.loads(line)
|
|
51
|
+
except json.JSONDecodeError as exc:
|
|
52
|
+
raise ProtocolError(f"invalid JSON from daemon: {exc}") from exc
|
|
53
|
+
|
|
54
|
+
if not isinstance(data, dict):
|
|
55
|
+
raise ProtocolError(f"expected JSON object, got {type(data).__name__}")
|
|
56
|
+
|
|
57
|
+
ok = data.get("ok")
|
|
58
|
+
if ok is None:
|
|
59
|
+
raise ProtocolError("response missing 'ok' field")
|
|
60
|
+
|
|
61
|
+
if ok is False:
|
|
62
|
+
error_msg = data.get("error", "unknown error")
|
|
63
|
+
raise ServerError(str(error_msg))
|
|
64
|
+
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_get_request(key: str, path: Optional[str] = None) -> bytes:
|
|
69
|
+
"""Build a ``get`` request.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
key: Provider key, e.g. ``"git.branch"`` or ``"git"``.
|
|
73
|
+
path: Optional working-directory path for per-directory providers.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Encoded request bytes.
|
|
77
|
+
"""
|
|
78
|
+
kwargs: dict[str, Any] = {"key": key}
|
|
79
|
+
if path is not None:
|
|
80
|
+
kwargs["path"] = path
|
|
81
|
+
return encode_request("get", **kwargs)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_poke_request(key: str, path: Optional[str] = None) -> bytes:
|
|
85
|
+
"""Build a ``poke`` request.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
key: Provider key to recompute.
|
|
89
|
+
path: Optional working-directory path.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Encoded request bytes.
|
|
93
|
+
"""
|
|
94
|
+
kwargs: dict[str, Any] = {"key": key}
|
|
95
|
+
if path is not None:
|
|
96
|
+
kwargs["path"] = path
|
|
97
|
+
return encode_request("poke", **kwargs)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_context_request(path: str) -> bytes:
|
|
101
|
+
"""Build a ``context`` request to set the default path for a session.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
path: Directory path to set as the connection context.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Encoded request bytes.
|
|
108
|
+
"""
|
|
109
|
+
return encode_request("context", path=path)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_list_request() -> bytes:
|
|
113
|
+
"""Build a ``list`` request."""
|
|
114
|
+
return encode_request("list")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build_status_request() -> bytes:
|
|
118
|
+
"""Build a ``status`` request."""
|
|
119
|
+
return encode_request("status")
|
libbeachcomber/result.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""CombResult dataclass returned by client get() calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CombResult:
|
|
11
|
+
"""Result of a ``get`` query to the beachcomber daemon.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
ok: Whether the daemon responded without error (always ``True``
|
|
15
|
+
when returned from the client — errors raise exceptions).
|
|
16
|
+
data: The cached value. ``None`` on a cache miss. May be a string,
|
|
17
|
+
number, bool, or dict depending on the provider and key.
|
|
18
|
+
age_ms: How many milliseconds old the cached value is. ``0`` on a
|
|
19
|
+
miss.
|
|
20
|
+
stale: ``True`` when the value is past its TTL but no fresh value
|
|
21
|
+
is available yet.
|
|
22
|
+
error: Error message string when ``ok`` is ``False``. The client
|
|
23
|
+
raises :class:`~beachcomber.exceptions.ServerError` for these,
|
|
24
|
+
so callers typically do not see this field set.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
ok: bool = True
|
|
28
|
+
data: Any = None
|
|
29
|
+
age_ms: int = 0
|
|
30
|
+
stale: bool = False
|
|
31
|
+
error: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_hit(self) -> bool:
|
|
35
|
+
"""``True`` when the cache contains a value for this key."""
|
|
36
|
+
return self.data is not None
|
|
37
|
+
|
|
38
|
+
def __getitem__(self, key: str) -> Any:
|
|
39
|
+
"""Access a field from dict data by subscript.
|
|
40
|
+
|
|
41
|
+
Useful when querying a full provider that returns an object,
|
|
42
|
+
e.g. ``result["branch"]`` after ``client.get("git")``.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
key: Field name to look up in the data dict.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The field value.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
TypeError: If ``data`` is not a dict.
|
|
52
|
+
KeyError: If ``key`` is not present in the dict.
|
|
53
|
+
"""
|
|
54
|
+
if not isinstance(self.data, dict):
|
|
55
|
+
raise TypeError(
|
|
56
|
+
f"CombResult.data is {type(self.data).__name__}, not dict; "
|
|
57
|
+
"subscript access is only valid for object responses"
|
|
58
|
+
)
|
|
59
|
+
return self.data[key]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: libbeachcomber
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client SDK for the beachcomber (comb) shell-state daemon
|
|
5
|
+
Project-URL: Homepage, https://github.com/NavistAu/beachcomber
|
|
6
|
+
Project-URL: Repository, https://github.com/NavistAu/beachcomber
|
|
7
|
+
Project-URL: Issues, https://github.com/NavistAu/beachcomber/issues
|
|
8
|
+
Author: NavistAu
|
|
9
|
+
License: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: System :: Shells
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# beachcomber Python SDK
|
|
26
|
+
|
|
27
|
+
Python client for the [beachcomber](https://github.com/jhogendorn/beachcomber) (`comb`) shell-state daemon.
|
|
28
|
+
|
|
29
|
+
## Requirements
|
|
30
|
+
|
|
31
|
+
- Python 3.9+
|
|
32
|
+
- No external dependencies (stdlib only)
|
|
33
|
+
- A running `comb` daemon
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pip install beachcomber
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or with `uv`:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
uv add beachcomber
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from beachcomber import Client
|
|
51
|
+
|
|
52
|
+
client = Client()
|
|
53
|
+
|
|
54
|
+
# Read a single field
|
|
55
|
+
result = client.get("git.branch", path="/path/to/repo")
|
|
56
|
+
if result.is_hit:
|
|
57
|
+
print(result.data) # "main"
|
|
58
|
+
print(result.age_ms) # 234
|
|
59
|
+
print(result.stale) # False
|
|
60
|
+
|
|
61
|
+
# Read a full provider (returns dict)
|
|
62
|
+
result = client.get("git", path="/path/to/repo")
|
|
63
|
+
if result.is_hit:
|
|
64
|
+
print(result["branch"]) # "main"
|
|
65
|
+
print(result["dirty"]) # False
|
|
66
|
+
|
|
67
|
+
# Force recomputation
|
|
68
|
+
client.poke("git", path="/path/to/repo")
|
|
69
|
+
|
|
70
|
+
# List available providers
|
|
71
|
+
providers = client.list()
|
|
72
|
+
|
|
73
|
+
# Daemon status
|
|
74
|
+
status = client.status()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Sessions
|
|
78
|
+
|
|
79
|
+
For multiple queries use a session to reuse a single connection:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
with client.session() as session:
|
|
83
|
+
session.set_context("/path/to/repo")
|
|
84
|
+
branch = session.get("git.branch")
|
|
85
|
+
dirty = session.get("git.dirty")
|
|
86
|
+
hostname = session.get("hostname")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Custom socket path
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
client = Client(socket_path="/run/user/1000/beachcomber/sock")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Socket discovery
|
|
96
|
+
|
|
97
|
+
The SDK discovers the daemon socket at:
|
|
98
|
+
|
|
99
|
+
1. `$XDG_RUNTIME_DIR/beachcomber/sock`
|
|
100
|
+
2. `$TMPDIR/beachcomber-<uid>/sock`
|
|
101
|
+
3. `/tmp/beachcomber-<uid>/sock`
|
|
102
|
+
|
|
103
|
+
## Exceptions
|
|
104
|
+
|
|
105
|
+
| Exception | When raised |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `DaemonNotRunning` | Cannot connect to the socket |
|
|
108
|
+
| `ServerError` | Daemon returns `ok: false` |
|
|
109
|
+
| `ProtocolError` | Malformed JSON or I/O failure |
|
|
110
|
+
| `CombError` | Base class for all SDK errors |
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
libbeachcomber/__init__.py,sha256=1peyIUg6de5QRlmQpSNzMlyZJ0Pj3iAavHhFLrArHCg,1219
|
|
2
|
+
libbeachcomber/client.py,sha256=NI7KTwVZ66Klq-bhIpsbgFmKTMrNaOHEhKfVV8ek2co,10323
|
|
3
|
+
libbeachcomber/discovery.py,sha256=4vKonS5cLHo4tPiio6LAWWWMI4CCbs3ENOxf7op1v_U,1039
|
|
4
|
+
libbeachcomber/exceptions.py,sha256=u-4UOMQvXX3NJHVFFC3tjy8HdGTLQGbo9Ou_oFAQ6YM,973
|
|
5
|
+
libbeachcomber/protocol.py,sha256=JDLcdp9uwHC3hEjWM8jqE2En89g6A1zWvZNek2_xDHI,3296
|
|
6
|
+
libbeachcomber/result.py,sha256=kQiCdRXfRyG4jODbJ_Z6BCEUt8b-40Th61zFbAIMncE,2003
|
|
7
|
+
libbeachcomber-0.1.0.dist-info/METADATA,sha256=VYtD3PqBeXaXSQ5TNMxtO-potxCm6wk8XUNvQ7Xa858,2715
|
|
8
|
+
libbeachcomber-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
libbeachcomber-0.1.0.dist-info/RECORD,,
|