libbeachcomber 0.1.0__tar.gz

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,35 @@
1
+ /target
2
+ /vendor
3
+
4
+ # IDE
5
+ .idea/
6
+ .vscode/
7
+ *.swp
8
+ *.swo
9
+ *~
10
+
11
+ # macOS
12
+ .DS_Store
13
+
14
+ # Claude Code working files
15
+ .claude/
16
+
17
+ # Internal planning docs (kept in repo but not published)
18
+ INIT.md
19
+ docs/superpowers/
20
+
21
+ # SDK build artifacts
22
+ sdks/python/__pycache__/
23
+ sdks/python/**/__pycache__/
24
+ sdks/python/.venv/
25
+ sdks/python/.pytest_cache/
26
+ sdks/python/uv.lock
27
+ sdks/python/dist/
28
+ sdks/node/node_modules/
29
+ sdks/node/dist/
30
+ sdks/ruby/*.gem
31
+ sdks/c/test_beachcomber
32
+ sdks/c/*.o
33
+ sdks/c/*.a
34
+ sdks/c/*.so
35
+ sdks/c/*.dylib
@@ -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,86 @@
1
+ # beachcomber Python SDK
2
+
3
+ Python client for the [beachcomber](https://github.com/jhogendorn/beachcomber) (`comb`) shell-state daemon.
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.9+
8
+ - No external dependencies (stdlib only)
9
+ - A running `comb` daemon
10
+
11
+ ## Installation
12
+
13
+ ```sh
14
+ pip install beachcomber
15
+ ```
16
+
17
+ Or with `uv`:
18
+
19
+ ```sh
20
+ uv add beachcomber
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```python
26
+ from beachcomber import Client
27
+
28
+ client = Client()
29
+
30
+ # Read a single field
31
+ result = client.get("git.branch", path="/path/to/repo")
32
+ if result.is_hit:
33
+ print(result.data) # "main"
34
+ print(result.age_ms) # 234
35
+ print(result.stale) # False
36
+
37
+ # Read a full provider (returns dict)
38
+ result = client.get("git", path="/path/to/repo")
39
+ if result.is_hit:
40
+ print(result["branch"]) # "main"
41
+ print(result["dirty"]) # False
42
+
43
+ # Force recomputation
44
+ client.poke("git", path="/path/to/repo")
45
+
46
+ # List available providers
47
+ providers = client.list()
48
+
49
+ # Daemon status
50
+ status = client.status()
51
+ ```
52
+
53
+ ## Sessions
54
+
55
+ For multiple queries use a session to reuse a single connection:
56
+
57
+ ```python
58
+ with client.session() as session:
59
+ session.set_context("/path/to/repo")
60
+ branch = session.get("git.branch")
61
+ dirty = session.get("git.dirty")
62
+ hostname = session.get("hostname")
63
+ ```
64
+
65
+ ## Custom socket path
66
+
67
+ ```python
68
+ client = Client(socket_path="/run/user/1000/beachcomber/sock")
69
+ ```
70
+
71
+ ## Socket discovery
72
+
73
+ The SDK discovers the daemon socket at:
74
+
75
+ 1. `$XDG_RUNTIME_DIR/beachcomber/sock`
76
+ 2. `$TMPDIR/beachcomber-<uid>/sock`
77
+ 3. `/tmp/beachcomber-<uid>/sock`
78
+
79
+ ## Exceptions
80
+
81
+ | Exception | When raised |
82
+ |---|---|
83
+ | `DaemonNotRunning` | Cannot connect to the socket |
84
+ | `ServerError` | Daemon returns `ok: false` |
85
+ | `ProtocolError` | Malformed JSON or I/O failure |
86
+ | `CombError` | Base class for all SDK errors |
@@ -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"
@@ -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."""