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.
- libbeachcomber-0.1.0/.gitignore +35 -0
- libbeachcomber-0.1.0/PKG-INFO +110 -0
- libbeachcomber-0.1.0/README.md +86 -0
- libbeachcomber-0.1.0/beachcomber/__init__.py +47 -0
- libbeachcomber-0.1.0/beachcomber/client.py +319 -0
- libbeachcomber-0.1.0/beachcomber/discovery.py +35 -0
- libbeachcomber-0.1.0/beachcomber/exceptions.py +35 -0
- libbeachcomber-0.1.0/beachcomber/protocol.py +119 -0
- libbeachcomber-0.1.0/beachcomber/result.py +59 -0
- libbeachcomber-0.1.0/libbeachcomber/__init__.py +47 -0
- libbeachcomber-0.1.0/libbeachcomber/client.py +319 -0
- libbeachcomber-0.1.0/libbeachcomber/discovery.py +35 -0
- libbeachcomber-0.1.0/libbeachcomber/exceptions.py +35 -0
- libbeachcomber-0.1.0/libbeachcomber/protocol.py +119 -0
- libbeachcomber-0.1.0/libbeachcomber/result.py +59 -0
- libbeachcomber-0.1.0/pyproject.toml +43 -0
- libbeachcomber-0.1.0/tests/__init__.py +0 -0
- libbeachcomber-0.1.0/tests/conftest.py +189 -0
- libbeachcomber-0.1.0/tests/test_client.py +354 -0
- libbeachcomber-0.1.0/tests/test_discovery.py +79 -0
- libbeachcomber-0.1.0/tests/test_protocol.py +123 -0
- libbeachcomber-0.1.0/tests/test_result.py +87 -0
|
@@ -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."""
|