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.
- silverhamu_service_client-0.2.0/.gitignore +20 -0
- silverhamu_service_client-0.2.0/LICENSE +22 -0
- silverhamu_service_client-0.2.0/PKG-INFO +49 -0
- silverhamu_service_client-0.2.0/clients/python/README.md +37 -0
- silverhamu_service_client-0.2.0/clients/python/src/silverhamu_client/__init__.py +108 -0
- silverhamu_service_client-0.2.0/pyproject.toml +26 -0
- silverhamu_service_client-0.2.0/scripts/silverhamu-client.py +196 -0
|
@@ -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)
|