silverhamu-service-client 0.2.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,20 @@
1
+ /target/
2
+ /artifacts/
3
+ /.secrets/
4
+ /data/
5
+ .env
6
+ *.ndb
7
+ *.catbak
8
+ *.accounts
9
+ *.accounts.owner
10
+ *.account-owner
11
+ *.wal
12
+ __pycache__/
13
+ *.pyc
14
+ /apps/**/bin/
15
+ /apps/**/obj/
16
+ /apps/.vs/
17
+ /dotnet/**/bin/
18
+ /dotnet/**/obj/
19
+ **/TestResults/
20
+ /AGENTS.md
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dhq_boiler and contributors
4
+ Copyright (c) 2026 NewDBMS contributors (imported SQL, desktop and online backup components)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.5
2
+ Name: silverhamu-service-client
3
+ Version: 0.2.0
4
+ Summary: Python client for the silverhamu TCP service with durable writes, transactions and TLS account authentication
5
+ Project-URL: Repository, https://github.com/dhq-boiler/SilverHamu
6
+ Project-URL: Documentation, https://github.com/dhq-boiler/SilverHamu/blob/main/docs/service.md
7
+ Author: dhq_boiler and contributors
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+
13
+ # silverhamu for Python
14
+
15
+ Install the package in a Python 3.10 or newer environment. The client uses the Python standard library at runtime.
16
+
17
+ ```sh
18
+ python -m pip install silverhamu-service-client==0.2.0
19
+ ```
20
+
21
+ Connect to a running silverhamu service and set `CATDB_TOKEN` to its shared token. [Starting a server](https://github.com/dhq-boiler/SilverHamu#quick-start)
22
+
23
+ ```python
24
+ import os
25
+ from silverhamu_client import Client
26
+
27
+ with Client("127.0.0.1", 7400, os.environ["CATDB_TOKEN"]) as db:
28
+ version = db.put_commit(b"hello", b"world")
29
+ assert db.get_latest(b"hello") == b"world"
30
+ ```
31
+
32
+ For TLS account authentication, pass `username=`, `password=` and optionally `database=` and `cafile=` to the constructor. The hostname must match the certificate. A missing key returns `None`. An empty stored value returns `b""`. Commit versions are decimal strings.
33
+
34
+ Use `begin()`, `get()`, `put()`, `delete()`, `commit()` and `rollback()` for a transaction. Use `begin(read_only=True)` for a pinned snapshot. The `request(op=..., **fields)` method also supports the wire operations, including paginated scans. Server rejections raise `SilverHamuError` with a `code` attribute. After a lost reply, open a new client and inspect the result before retrying a write.
35
+
36
+ For a replicated service, supply its cluster ID, client token and the configured **client** endpoints. The helper authenticates to these addresses and probes for an elected leader.
37
+
38
+ ```python
39
+ seeds = [("127.0.0.1", 7401), ("127.0.0.1", 7402), ("127.0.0.1", 7403)]
40
+
41
+ def connect():
42
+ return Client.connect_leader(seeds, os.environ["CATDB_TOKEN"],
43
+ cluster_id="books", discovery_timeout=10)
44
+
45
+ with connect() as db:
46
+ value = db.get_latest(b"book/42")
47
+ ```
48
+
49
+ Following an election or connection failure, call `connect()` again. A new connection has a new transaction context. Discovery verifies the cluster ID and confirms the reported leader can reach a quorum with a read-only ping. If a write reply is lost or reports `commit_uncertain`, inspect the application's committed state on the new leader before deciding whether to resubmit it.
@@ -0,0 +1,37 @@
1
+ # silverhamu for Python
2
+
3
+ Install the package in a Python 3.10 or newer environment. The client uses the Python standard library at runtime.
4
+
5
+ ```sh
6
+ python -m pip install silverhamu-service-client==0.2.0
7
+ ```
8
+
9
+ Connect to a running silverhamu service and set `CATDB_TOKEN` to its shared token. [Starting a server](https://github.com/dhq-boiler/SilverHamu#quick-start)
10
+
11
+ ```python
12
+ import os
13
+ from silverhamu_client import Client
14
+
15
+ with Client("127.0.0.1", 7400, os.environ["CATDB_TOKEN"]) as db:
16
+ version = db.put_commit(b"hello", b"world")
17
+ assert db.get_latest(b"hello") == b"world"
18
+ ```
19
+
20
+ For TLS account authentication, pass `username=`, `password=` and optionally `database=` and `cafile=` to the constructor. The hostname must match the certificate. A missing key returns `None`. An empty stored value returns `b""`. Commit versions are decimal strings.
21
+
22
+ Use `begin()`, `get()`, `put()`, `delete()`, `commit()` and `rollback()` for a transaction. Use `begin(read_only=True)` for a pinned snapshot. The `request(op=..., **fields)` method also supports the wire operations, including paginated scans. Server rejections raise `SilverHamuError` with a `code` attribute. After a lost reply, open a new client and inspect the result before retrying a write.
23
+
24
+ For a replicated service, supply its cluster ID, client token and the configured **client** endpoints. The helper authenticates to these addresses and probes for an elected leader.
25
+
26
+ ```python
27
+ seeds = [("127.0.0.1", 7401), ("127.0.0.1", 7402), ("127.0.0.1", 7403)]
28
+
29
+ def connect():
30
+ return Client.connect_leader(seeds, os.environ["CATDB_TOKEN"],
31
+ cluster_id="books", discovery_timeout=10)
32
+
33
+ with connect() as db:
34
+ value = db.get_latest(b"book/42")
35
+ ```
36
+
37
+ Following an election or connection failure, call `connect()` again. A new connection has a new transaction context. Discovery verifies the cluster ID and confirms the reported leader can reach a quorum with a read-only ping. If a write reply is lost or reports `commit_uncertain`, inspect the application's committed state on the new leader before deciding whether to resubmit it.
@@ -0,0 +1,108 @@
1
+ """Synchronous client for the silverhamu service."""
2
+
3
+ from binascii import unhexlify
4
+ from threading import RLock
5
+
6
+ from ._wire import Client as _WireClient
7
+
8
+ __all__ = ["Client", "SilverHamuError"]
9
+
10
+
11
+ class SilverHamuError(Exception):
12
+ """Server rejection. The code is the error identifier from the wire."""
13
+
14
+ def __init__(self, code):
15
+ self.code = code
16
+ super().__init__(code)
17
+
18
+
19
+ def _hex(value):
20
+ if not isinstance(value, (bytes, bytearray, memoryview)):
21
+ raise TypeError("expected a bytes-like key or value")
22
+ return value.hex()
23
+
24
+
25
+ class Client(_WireClient):
26
+ """One connection and transaction context. Use a separate client per transaction."""
27
+
28
+ def __init__(self, host, port, token=None, timeout=10, *, username=None, password=None,
29
+ database="main", cafile=None, _discovery_deadline=None):
30
+ if username is not None and password is None:
31
+ raise ValueError("account authentication requires a password")
32
+ self._lock = RLock()
33
+ self._closed = False
34
+ super().__init__(host, port, token, timeout, username=username, password=password,
35
+ database=database, cafile=cafile, _discovery_deadline=_discovery_deadline)
36
+
37
+ def request(self, **fields):
38
+ """Send a wire operation. Versions in responses are decimal strings."""
39
+ body = self._encode(fields)
40
+ with self._lock:
41
+ if self._closed:
42
+ raise ConnectionError("silverhamu client is closed")
43
+ try:
44
+ response = self._exchange(body)
45
+ if not isinstance(response, dict) or type(response.get("ok")) is not bool:
46
+ raise ValueError("invalid response envelope")
47
+ if not response["ok"]:
48
+ if not isinstance(response.get("error"), str):
49
+ raise ValueError("invalid error response")
50
+ if response["error"] == "unauthorized":
51
+ self.close()
52
+ raise SilverHamuError(response["error"])
53
+ if fields.get("op") == "auth" and response.get("protocol") != fields["version"]:
54
+ raise ValueError("unexpected authentication protocol")
55
+ if fields.get("op") in {"ping", "begin", "begin_read", "commit", "put_commit", "delete_commit"}:
56
+ version = response.get("version")
57
+ if not isinstance(version, str) or not version.isascii() or not version.isdecimal():
58
+ raise ValueError("invalid silverhamu version")
59
+ return response
60
+ except (OSError, ValueError) as error:
61
+ self.close()
62
+ raise ConnectionError("silverhamu connection discarded; a submitted write may have committed") from error
63
+
64
+ def close(self):
65
+ with self._lock:
66
+ if not self._closed:
67
+ self._closed = True
68
+ super().close()
69
+
70
+ def _get(self, op, key):
71
+ with self._lock:
72
+ response = self.request(op=op, key=_hex(key))
73
+ try:
74
+ value = response["value"]
75
+ return None if value is None else unhexlify(value)
76
+ except (KeyError, TypeError, ValueError) as error:
77
+ self.close()
78
+ raise ConnectionError("invalid silverhamu value; connection discarded") from error
79
+
80
+ def ping(self):
81
+ return self.request(op="ping")["version"]
82
+
83
+ def get_latest(self, key):
84
+ return self._get("get_latest", key)
85
+
86
+ def put_commit(self, key, value):
87
+ return self.request(op="put_commit", key=_hex(key), value=_hex(value))["version"]
88
+
89
+ def delete_commit(self, key):
90
+ return self.request(op="delete_commit", key=_hex(key))["version"]
91
+
92
+ def begin(self, *, read_only=False):
93
+ return self.request(op="begin_read" if read_only else "begin")["version"]
94
+
95
+ def get(self, key):
96
+ return self._get("get", key)
97
+
98
+ def put(self, key, value):
99
+ self.request(op="put", key=_hex(key), value=_hex(value))
100
+
101
+ def delete(self, key):
102
+ self.request(op="delete", key=_hex(key))
103
+
104
+ def commit(self):
105
+ return self.request(op="commit")["version"]
106
+
107
+ def rollback(self):
108
+ self.request(op="rollback")
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "silverhamu-service-client"
7
+ version = "0.2.0"
8
+ description = "Python client for the silverhamu TCP service with durable writes, transactions and TLS account authentication"
9
+ authors = [{ name = "dhq_boiler and contributors" }]
10
+ readme = "clients/python/README.md"
11
+ requires-python = ">=3.10"
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/dhq-boiler/SilverHamu"
17
+ Documentation = "https://github.com/dhq-boiler/SilverHamu/blob/main/docs/service.md"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["clients/python/src/silverhamu_client"]
21
+
22
+ [tool.hatch.build.targets.wheel.force-include]
23
+ "scripts/silverhamu-client.py" = "silverhamu_client/_wire.py"
24
+
25
+ [tool.hatch.build.targets.sdist]
26
+ include = ["/clients/python/src/silverhamu_client", "/clients/python/README.md", "/scripts/silverhamu-client.py", "/LICENSE", "/pyproject.toml"]
@@ -0,0 +1,196 @@
1
+ """Experimental silverhamu v1 client. Requests are JSON lines on stdin; values are hex."""
2
+ import argparse
3
+ import getpass
4
+ import json
5
+ import math
6
+ import pathlib
7
+ import socket
8
+ import ssl
9
+ import struct
10
+ import sys
11
+ import time
12
+
13
+
14
+ class Client:
15
+ _discovery_deadline = None
16
+
17
+ @classmethod
18
+ def connect_leader(cls, seeds, token, *, cluster_id, timeout=10, discovery_timeout=10):
19
+ """Probe configured client endpoints for a leader. Never submits or replays a write."""
20
+ if not isinstance(cluster_id, str) or not cluster_id:
21
+ raise ValueError("cluster_id must be a nonempty string")
22
+ if not math.isfinite(timeout) or timeout <= 0 or not math.isfinite(discovery_timeout) or discovery_timeout <= 0:
23
+ raise ValueError("timeouts must be positive and finite")
24
+ endpoints = list(dict.fromkeys((host, port) for host, port in seeds))
25
+ if not endpoints or any(not isinstance(host, str) or not host or type(port) is not int
26
+ or not 0 < port <= 65535 for host, port in endpoints):
27
+ raise ValueError("seeds must contain (host, port) client endpoints")
28
+ deadline = time.monotonic() + discovery_timeout
29
+ while time.monotonic() < deadline:
30
+ for host, port in endpoints:
31
+ remaining = deadline - time.monotonic()
32
+ if remaining <= 0:
33
+ break
34
+ client = None
35
+ try:
36
+ # Leave time for the other configured nodes when one address is unreachable.
37
+ probe_time = min(timeout, remaining, max(0.1, discovery_timeout / len(endpoints)))
38
+ client = cls(host, port, token, timeout=probe_time, _discovery_deadline=time.monotonic() + probe_time)
39
+ status = client.request(op="cluster_status")
40
+ if not isinstance(status, dict) or status.get("ok") is not True:
41
+ raise RuntimeError("endpoint rejected cluster_status")
42
+ if status.get("cluster_id") != cluster_id:
43
+ raise ValueError("seed belongs to a different silverhamu cluster")
44
+ role = status.get("role")
45
+ if role not in ("candidate", "follower", "leader"):
46
+ raise ValueError("invalid cluster_status role")
47
+ if role == "leader":
48
+ if not isinstance(status.get("node_id"), str) or status["node_id"] != status.get("leader_id"):
49
+ raise ValueError("inconsistent leader identity")
50
+ # An isolated former leader can still report its old role. A ping needs a quorum.
51
+ health = client._exchange(client._encode({"op": "ping"}))
52
+ if isinstance(health, dict) and health.get("ok") is False and health.get("error") in (
53
+ "not_leader", "quorum_unavailable", "resource_exhausted", "request_timeout"):
54
+ continue
55
+ version = health.get("version") if isinstance(health, dict) and health.get("ok") is True else None
56
+ if not isinstance(version, str) or not version.isascii() or not version.isdecimal():
57
+ raise ValueError("invalid leader ping")
58
+ client._discovery_deadline = None
59
+ client.socket.settimeout(timeout)
60
+ leader, client = client, None
61
+ return leader
62
+ except OSError:
63
+ pass
64
+ finally:
65
+ if client is not None:
66
+ client.close()
67
+ remaining = deadline - time.monotonic()
68
+ if remaining > 0:
69
+ time.sleep(min(0.1, remaining))
70
+ raise TimeoutError("no leader found among the configured silverhamu endpoints")
71
+
72
+ def __init__(self, host, port, token=None, timeout=10, *, username=None, password=None,
73
+ database="main", cafile=None, _discovery_deadline=None):
74
+ if (token is None) == (username is None):
75
+ raise ValueError("choose token or account authentication")
76
+ if token is not None and (password is not None or cafile is not None or database != "main"):
77
+ raise ValueError("TLS account options cannot be used with a token")
78
+ self._discovery_deadline = _discovery_deadline
79
+ self.socket = socket.create_connection((host, port), timeout)
80
+ try:
81
+ if username is not None:
82
+ context = ssl.create_default_context(cafile=cafile)
83
+ context.minimum_version = ssl.TLSVersion.TLSv1_3
84
+ self.socket = context.wrap_socket(self.socket, server_hostname=host)
85
+ response = self.request(op="auth", version=2, database=database,
86
+ username=username, password=password)
87
+ else:
88
+ response = self.request(op="auth", version=1, token=token)
89
+ if not response.get("ok"):
90
+ raise RuntimeError("silverhamu authentication failed")
91
+ self.account_id = response.get("account_id")
92
+ except BaseException:
93
+ self.close()
94
+ raise
95
+
96
+ def receive_exact(self, count):
97
+ chunks = bytearray()
98
+ while len(chunks) < count:
99
+ self._limit_discovery_wait()
100
+ chunk = self.socket.recv(count - len(chunks))
101
+ if not chunk:
102
+ raise ConnectionError("connection closed; a submitted commit may have completed")
103
+ chunks.extend(chunk)
104
+ return chunks
105
+
106
+ def _limit_discovery_wait(self):
107
+ if self._discovery_deadline is not None:
108
+ remaining = self._discovery_deadline - time.monotonic()
109
+ if remaining <= 0:
110
+ raise TimeoutError("silverhamu discovery probe timed out")
111
+ self.socket.settimeout(remaining)
112
+
113
+ @staticmethod
114
+ def _encode(request):
115
+ body = json.dumps(request, separators=(",", ":")).encode()
116
+ if len(body) > 1024 * 1024:
117
+ raise ValueError("request exceeds 1 MiB")
118
+ return body
119
+
120
+ def request(self, **request):
121
+ return self._exchange(self._encode(request))
122
+
123
+ def _exchange(self, body):
124
+ self._limit_discovery_wait()
125
+ self.socket.sendall(struct.pack("!I", len(body)) + body)
126
+ size = struct.unpack("!I", self.receive_exact(4))[0]
127
+ if not 0 < size <= 2 * 1024 * 1024 + 8192:
128
+ raise ValueError("invalid response size")
129
+ return json.loads(self.receive_exact(size).decode("utf-8"))
130
+
131
+ def close(self):
132
+ self.socket.close()
133
+
134
+ def __enter__(self):
135
+ return self
136
+
137
+ def __exit__(self, *_):
138
+ self.close()
139
+
140
+
141
+ def main():
142
+ parser = argparse.ArgumentParser(description=__doc__)
143
+ parser.add_argument("--host", default="127.0.0.1")
144
+ parser.add_argument("--port", type=int, default=7400)
145
+ auth = parser.add_mutually_exclusive_group(required=True)
146
+ auth.add_argument("--token-file", type=pathlib.Path)
147
+ auth.add_argument("--user")
148
+ parser.add_argument("--password-file", type=pathlib.Path)
149
+ parser.add_argument("--ca-file")
150
+ parser.add_argument("--database", default="main")
151
+ parser.add_argument("--create-user")
152
+ parser.add_argument("--role", choices=("reader","writer","admin"))
153
+ args = parser.parse_args()
154
+ if args.password_file and not args.user:
155
+ parser.error("--password-file requires --user")
156
+ if bool(args.create_user) != bool(args.role) or (args.create_user and not args.user):
157
+ parser.error("--create-user and --role require account authentication and each other")
158
+ token = args.token_file.read_text(encoding="utf-8").rstrip("\r\n") if args.token_file else None
159
+ password = None
160
+ if args.user:
161
+ if args.password_file:
162
+ with args.password_file.open("rb") as source:
163
+ secret = source.read(1027)
164
+ if len(secret) > 1026:
165
+ raise ValueError("password file too large")
166
+ password = secret.decode("utf-8")
167
+ if password.endswith("\n"):
168
+ password = password[:-1].removesuffix("\r")
169
+ else:
170
+ password = getpass.getpass("Account password: ")
171
+ with Client(args.host, args.port, token, username=args.user, password=password,
172
+ database=args.database, cafile=args.ca_file) as client:
173
+ if args.create_user:
174
+ new_password = getpass.getpass("New user's password: ")
175
+ if new_password != getpass.getpass("Confirm new password: "):
176
+ raise ValueError("passwords differ")
177
+ response = client.request(op="create_user", username=args.create_user,
178
+ password=new_password, role=args.role)
179
+ print(json.dumps(response))
180
+ return 0 if response.get("ok") else 1
181
+ for line in sys.stdin:
182
+ if not line.strip():
183
+ continue
184
+ response = client.request(**json.loads(line))
185
+ print(json.dumps(response, ensure_ascii=False), flush=True)
186
+ if not response.get("ok"):
187
+ return 1
188
+ return 0
189
+
190
+
191
+ if __name__ == "__main__":
192
+ try:
193
+ sys.exit(main())
194
+ except (OSError, ValueError, RuntimeError) as error:
195
+ print(f"silverhamu-client: {error}", file=sys.stderr)
196
+ sys.exit(1)