tallwright-cloud 1.0.0rc1__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.
@@ -0,0 +1,85 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """tallwright_cloud — Cloud Proof from Python, with the content kept where it is.
3
+
4
+ from tallwright_cloud import Tallwright
5
+
6
+ client = Tallwright(ingest_url, archive_key, "./tallwright-evidence")
7
+ client.submit("what the agent was told") # context it received
8
+ client.submit({"slot": "2026-11-03T09:00"}, action_type="appointment.booked") # an action it took
9
+
10
+ This package is the recorder: it commits, keeps the evidence, sends, sends again and reads the
11
+ receipt, and nothing built on it does any of that again (https://docs.tallwright.com/adapters). The content
12
+ never leaves the caller's machine: a salted address of it does, and the pair that opens the address is
13
+ kept in the caller's evidence store, in the form the public verifier reads. What the door answers is a
14
+ receipt — a confirmation, not a proof; the proof is the exhibit made once a witness has seen the log,
15
+ checked with the disclosure kept here.
16
+
17
+ - :class:`Tallwright` records into one Environment: ``capture`` waits for the receipt, ``submit``
18
+ only for the evidence (``client.py``, ``sender.py``);
19
+ - :class:`Environments` gives each end customer its own client, evidence and key (``environments.py``);
20
+ - :class:`LocalEvidence` and :class:`S3Evidence` keep the evidence (``store.py``);
21
+ - :class:`Account` makes and keeps the Environments themselves, with an account key (``account.py``);
22
+ - :class:`Exhibits` takes the exhibit of a record, with an export key;
23
+ - ``tallwright-cloud`` is the command line (``cli.py``).
24
+
25
+ See https://docs.tallwright.com/http for the same contract over plain HTTP, and
26
+ https://docs.tallwright.com/python/guarantees for what 1.0 promises and the tests that hold it to that.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from .account import Account, AccountError, Environment, EnvironmentExists, Made, Secret
32
+ from .client import Capture, Exhibits, Status, Tallwright
33
+ from .environments import Environments
34
+ from .errors import (
35
+ AccountClosed,
36
+ EvidenceError,
37
+ ExhibitError,
38
+ ExhibitNotReady,
39
+ ExportKeyRefused,
40
+ LinkSpent,
41
+ LostByTheDoor,
42
+ NotConfirmed,
43
+ RecordIdConflict,
44
+ Refused,
45
+ TallwrightError,
46
+ Unauthorized,
47
+ )
48
+ from .ids import gaps
49
+ from .receipt import Receipt
50
+ from .sender import Flushed
51
+ from .store import EvidenceStore, LocalEvidence, S3Evidence
52
+
53
+ __version__ = "1.0.0rc1"
54
+
55
+ __all__ = [
56
+ "Tallwright",
57
+ "Environments",
58
+ "Exhibits",
59
+ "Account",
60
+ "Capture",
61
+ "Receipt",
62
+ "Status",
63
+ "Flushed",
64
+ "EvidenceStore",
65
+ "LocalEvidence",
66
+ "S3Evidence",
67
+ "Environment",
68
+ "Made",
69
+ "Secret",
70
+ "gaps",
71
+ "TallwrightError",
72
+ "NotConfirmed",
73
+ "Unauthorized",
74
+ "AccountClosed",
75
+ "Refused",
76
+ "RecordIdConflict",
77
+ "LostByTheDoor",
78
+ "EvidenceError",
79
+ "ExhibitError",
80
+ "ExhibitNotReady",
81
+ "LinkSpent",
82
+ "ExportKeyRefused",
83
+ "AccountError",
84
+ "EnvironmentExists",
85
+ ]
@@ -0,0 +1,254 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """An account's Environments, made and kept by the vendor's own system (``/v1/environments``).
3
+
4
+ account = Account(app_url, account_key)
5
+ made = account.create("Bäckerei Müller", customer_ref="4711", deployer_label="acme-voice")
6
+ store_secret("4711", made.key.reveal()) # shown once, here and nowhere else
7
+
8
+ The account key (``ta_…``) is made by an Owner or Admin on the Settings screen and can do what a person
9
+ on the Environments screen can: make an Environment, list and find them, replace a recording key,
10
+ close one. It is NOT the key an agent records with, and it never belongs in the process that records:
11
+ that process holds one recording key per customer and nothing that makes more.
12
+
13
+ **A create is safe to repeat, and this package always sends one that is.** ``customer_ref`` is
14
+ required here, though the API takes a create without one: with it, a create the door already did is
15
+ answered 409 with the Environment it made, and nothing is made twice. Without it, an answer lost on
16
+ the way back would leave an Environment nobody knows about. What a lost answer still costs is the key,
17
+ which is shown once: :class:`EnvironmentExists` says whether an earlier attempt of THIS call may have
18
+ made it, and then :meth:`Account.new_key` gives the Environment a key somebody holds.
19
+
20
+ **Keys come back wrapped.** A recording key in a :class:`Secret`, whose repr shows its face and no
21
+ more, so a log line or an error tracker that records the object records no key.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import http.client
27
+ import json
28
+ import time
29
+ from collections.abc import Iterator
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+ from urllib.parse import quote, urlencode
33
+
34
+ from . import transport
35
+ from .errors import TallwrightError
36
+ from .ids import checked_ref
37
+
38
+ #: The answers that decide nothing: the same request is sent again, after the same waits as a capture.
39
+ RETRY = frozenset({408, 425, 429, 500, 502, 503, 504})
40
+ DELAYS = (0.5, 1.0, 2.0)
41
+ _pause = time.sleep
42
+
43
+
44
+ class AccountError(TallwrightError):
45
+ """The account API refused or could not complete a request, in its own sentence where it gave one."""
46
+
47
+
48
+ class EnvironmentExists(AccountError):
49
+ """409: an open Environment for this ``customer_ref`` exists, and nothing was made."""
50
+
51
+ def __init__(self, message: str, environment: Environment, *, maybe_ours: bool) -> None:
52
+ super().__init__(message, status=409)
53
+ self.environment = environment
54
+ #: True when an earlier attempt of the same call may have made it, and its key was lost with
55
+ #: the answer: then ``new_key`` gives it one somebody holds.
56
+ self.maybe_ours = maybe_ours
57
+
58
+
59
+ class Secret:
60
+ """A key shown once. ``reveal`` it into the secret store; nothing else shows more than its face."""
61
+
62
+ __slots__ = ("_value",)
63
+
64
+ def __init__(self, value: str) -> None:
65
+ self._value = transport.Key(value).reveal()
66
+
67
+ def reveal(self) -> str:
68
+ """The key itself, for the one place it is handed to; everything else is shown its first characters."""
69
+ return self._value
70
+
71
+ def __repr__(self) -> str:
72
+ return f"Secret({self._value[:11]}…)"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Environment:
77
+ """One Environment as the account API shows it."""
78
+
79
+ id: str
80
+ name: str
81
+ customer_ref: str | None
82
+ deployer_label: str
83
+ environment_id: str | None
84
+ status: str
85
+ reason: str | None = None
86
+ created_at: str | None = None
87
+ closed_at: str | None = None
88
+
89
+ @classmethod
90
+ def read(cls, given: object) -> Environment:
91
+ """An Environment from one entry of the account API's answer; :class:`AccountError` when it is not one."""
92
+ if not isinstance(given, dict) or not isinstance(given.get("id"), str) or not isinstance(given.get("status"), str):
93
+ raise AccountError("the account API answered with something that is not an Environment")
94
+ def text(key: str) -> str | None:
95
+ value = given.get(key)
96
+ return value if isinstance(value, str) else None
97
+
98
+ return cls(
99
+ id=given["id"],
100
+ name=text("name") or "",
101
+ customer_ref=text("customer_ref"),
102
+ deployer_label=text("deployer_label") or "",
103
+ environment_id=text("environment_id"),
104
+ status=given["status"],
105
+ reason=text("reason"),
106
+ created_at=text("created_at"),
107
+ closed_at=text("closed_at"),
108
+ )
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class Made:
113
+ """An Environment just made, or given a new key: the key is here once, and nowhere else."""
114
+
115
+ environment: Environment
116
+ ingest_url: str
117
+ key: Secret
118
+
119
+
120
+ class Account:
121
+ """The account API of one account, with its account key: it makes, finds, re-keys and closes Environments.
122
+
123
+ The account key never belongs in the process that records; that holds one recording key per customer.
124
+ """
125
+
126
+ def __init__(self, url: str, account_key: str, *, timeout: float = 90.0) -> None:
127
+ transport.checked_address(url, "the account API")
128
+ self.url = url.rstrip("/")
129
+ self._key = transport.Key(account_key)
130
+ self._timeout = timeout
131
+
132
+ def __repr__(self) -> str:
133
+ return f"Account(url={self.url!r}, account_key={self._key!r})"
134
+
135
+ def _call(self, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any], int]:
136
+ """The status, the JSON body and how many attempts it took; undecided answers are tried again."""
137
+ # A POST without a body still says so: Content-Length 0, which no proxy has to guess at.
138
+ data = (b"" if method == "POST" else None) if body is None else json.dumps(body).encode("utf-8")
139
+ kind = None if body is None else "application/json"
140
+ last = ""
141
+ attempts = 0
142
+ for wait in (0.0, *DELAYS):
143
+ if wait:
144
+ _pause(wait)
145
+ attempts += 1
146
+ try:
147
+ status, raw, _ = transport.exchange(
148
+ method, self.url + path, self._key, data, kind, self._timeout, transport.MAX_ANSWER
149
+ )
150
+ except (OSError, http.client.HTTPException) as failed:
151
+ last = f"{type(failed).__name__}: {getattr(failed, 'reason', failed)}"
152
+ continue
153
+ # Read as the door's answers are: an answer nested past any stack is no answer, not a crash.
154
+ answer = transport.object_in(raw) or {}
155
+ if status in RETRY:
156
+ last = f"it answered {status} ({answer.get('error') or 'no sentence'})"
157
+ continue
158
+ return status, answer, attempts
159
+ raise AccountError(f"{method} {path}: {attempts} attempts, the last: {last}. Nothing is known to have been done")
160
+
161
+ def _refused(self, method: str, path: str, status: int, answer: dict[str, Any]) -> AccountError:
162
+ said = answer.get("error")
163
+ return AccountError(f"{method} {path}: {status} ({said if isinstance(said, str) else 'without the API sentence'})", status=status)
164
+
165
+ def create(self, name: str, *, customer_ref: str, deployer_label: str) -> Made:
166
+ """Make an Environment for one end customer; its key is in the answer, once.
167
+
168
+ ``customer_ref`` is the one :class:`~tallwright_cloud.environments.Environments` keeps the
169
+ customer's evidence beneath, so it is held to the same rule (``ids.checked_ref``) here, and a
170
+ ref nothing could record under is refused before anything is made.
171
+ """
172
+ if not isinstance(customer_ref, str) or not customer_ref.strip():
173
+ raise ValueError("customer_ref is required: it is what makes a repeated create safe")
174
+ checked_ref(customer_ref)
175
+ body = {"name": name, "customer_ref": customer_ref, "deployer_label": deployer_label}
176
+ status, answer, attempts = self._call("POST", "/v1/environments", body)
177
+ if status == 409 and "environment" in answer:
178
+ existing = Environment.read(answer["environment"])
179
+ raise EnvironmentExists(
180
+ f"an Environment for customer_ref {customer_ref!r} exists already ({existing.status}); "
181
+ "nothing was made"
182
+ + (
183
+ ". An earlier attempt of this call may have made it and lost the answer, and with it "
184
+ "the key: new_key(...) gives it a key you then hold"
185
+ if attempts > 1
186
+ else ""
187
+ ),
188
+ existing,
189
+ maybe_ours=attempts > 1,
190
+ )
191
+ if status != 201:
192
+ raise self._refused("POST", "/v1/environments", status, answer)
193
+ return self._made(answer)
194
+
195
+ def _made(self, answer: dict[str, Any]) -> Made:
196
+ key, ingest = answer.get("key"), answer.get("ingest_url")
197
+ if not isinstance(key, str) or not isinstance(ingest, str):
198
+ raise AccountError("the account API answered without the key or the ingest address")
199
+ environment = answer.get("environment") or {}
200
+ if isinstance(environment, dict) and "status" not in environment:
201
+ environment = {**environment, "status": "live"}
202
+ return Made(Environment.read(environment), ingest, Secret(key))
203
+
204
+ def environments(self, *, status: str | None = None, customer_ref: str | None = None) -> Iterator[Environment]:
205
+ """Every Environment of the account, page by page, in the order the API keeps them.
206
+
207
+ A page goes on only to an offset past its own: a ``next_offset`` that repeats it, goes back or is
208
+ no number is an :class:`AccountError`, never a loop that asks forever or a listing cut short.
209
+ """
210
+ offset = 0
211
+ while True:
212
+ query: dict[str, str] = {"limit": "200", "offset": str(offset)}
213
+ if status is not None:
214
+ query["status"] = status
215
+ if customer_ref is not None:
216
+ query["customer_ref"] = customer_ref
217
+ path = "/v1/environments?" + urlencode(query)
218
+ code, answer, _ = self._call("GET", path)
219
+ if code != 200:
220
+ raise self._refused("GET", "/v1/environments", code, answer)
221
+ for given in answer.get("environments") or []:
222
+ yield Environment.read(given)
223
+ following = answer.get("next_offset")
224
+ if following is None:
225
+ return
226
+ if not isinstance(following, int) or isinstance(following, bool) or following <= offset:
227
+ raise AccountError(
228
+ f"GET /v1/environments: the API answered next_offset {repr(following)[:40]} to offset "
229
+ f"{offset}, which does not move forward, so the listing stops here instead of asking again"
230
+ )
231
+ offset = following
232
+
233
+ def find(self, customer_ref: str) -> Environment | None:
234
+ """The open Environment of ``customer_ref``, or None."""
235
+ for found in self.environments(customer_ref=customer_ref):
236
+ if found.status != "closed":
237
+ return found
238
+ return None
239
+
240
+ def new_key(self, environment: str) -> Made:
241
+ """Replace an Environment's recording key: the old one stops at once, the new one is here once."""
242
+ path = f"/v1/environments/{quote(environment, safe='')}/key"
243
+ status, answer, _ = self._call("POST", path)
244
+ if status != 200:
245
+ raise self._refused("POST", path, status, answer)
246
+ return self._made(answer)
247
+
248
+ def close(self, environment: str) -> Environment:
249
+ """Close an Environment when its customer leaves. Its log stays and still exports."""
250
+ path = f"/v1/environments/{quote(environment, safe='')}/close"
251
+ status, answer, _ = self._call("POST", path)
252
+ if status != 200:
253
+ raise self._refused("POST", path, status, answer)
254
+ return Environment.read(answer.get("environment") or {})
@@ -0,0 +1,333 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``tallwright-cloud``: look at the evidence and the door from a shell, and act on what is pending.
3
+
4
+ tallwright-cloud doctor what stands between this machine and evidence somebody can check
5
+ tallwright-cloud pending records sent and never confirmed or refused
6
+ tallwright-cloud resend [ID ...] send pending records again, byte for byte (all of this key's when none is named)
7
+ tallwright-cloud gaps numbers missing from a session's sequence
8
+ tallwright-cloud export ID DIR copy one record's evidence into a folder, for the verifier
9
+
10
+ The ingest address, the evidence and the key come from ``TALLWRIGHT_INGEST``, ``TALLWRIGHT_EVIDENCE``
11
+ and ``TALLWRIGHT_KEY``, or ``--ingest`` and ``--evidence``. The key is never an argument: an argument is
12
+ visible to every user of the machine in its process list. Evidence in a bucket is
13
+ ``s3://bucket/prefix``, read with boto3 and its usual configuration, when boto3 is installed.
14
+
15
+ The commands read the evidence a client kept and make none: a directory that is not there is said to
16
+ be missing, never created and reported empty. One Environment's evidence is what they read; the store
17
+ ``Environments`` writes into holds each customer's beneath its ``customer_ref``, and is said to be one.
18
+
19
+ Exit codes: 0 nothing stands in the way; 1 something does, and the output says what; 2 the command
20
+ itself was wrong.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import os
27
+ import sys
28
+ from collections.abc import Sequence
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from . import transport
33
+ from .client import Tallwright
34
+ from .errors import TallwrightError
35
+ from .evidence import DISCLOSURE, LINE, RECEIPT, REFUSED, Evidence
36
+ from .ids import checked_record_id, gaps
37
+ from .store import EvidenceStore, LocalEvidence, S3Evidence, _exclusive, walk
38
+
39
+ #: The interpreters this package is tested on, in order: its classifiers, and the CI's.
40
+ SUPPORTED = ((3, 11), (3, 12), (3, 13))
41
+
42
+
43
+ def _store(where: str | None, *, ephemeral_ok: bool = False) -> EvidenceStore:
44
+ """The evidence at ``where``, opened as it is: a directory that is not there is not made."""
45
+ if not where:
46
+ raise SystemExit("tallwright-cloud: no evidence: --evidence or TALLWRIGHT_EVIDENCE")
47
+ if where.startswith("s3://"):
48
+ bucket, _, prefix = where[len("s3://") :].partition("/")
49
+ try:
50
+ import boto3 # noqa: PLC0415 - optional, the integrator's
51
+ except ImportError:
52
+ raise SystemExit("tallwright-cloud: evidence in a bucket is read with boto3, which is not installed") from None
53
+ return S3Evidence(boto3.client("s3"), bucket, prefix=prefix)
54
+ if not Path(where).is_dir():
55
+ raise FileNotFoundError(
56
+ f"no evidence directory at {where}: a client makes it when it is made with this path, and these "
57
+ "commands only read what one kept. Is it the path the client was given?"
58
+ )
59
+ return LocalEvidence(where, ephemeral_ok=ephemeral_ok)
60
+
61
+
62
+ def _is_record(store: EvidenceStore, name: str) -> bool:
63
+ """Whether ``name`` is a record's folder: the SDK writes a disclosure and a line into nothing else."""
64
+ try:
65
+ return store.read(f"{name}/{DISCLOSURE}") is not None or store.read(f"{name}/{LINE}") is not None
66
+ except ValueError: # not a name a record could be kept under
67
+ return False
68
+
69
+
70
+ def _a_customer(store: EvidenceStore) -> str | None:
71
+ """A ``customer_ref`` whose evidence lies in ``store``, when the store holds customers and not records.
72
+
73
+ The first folder at the top that is either decides: a record, and this is one Environment's
74
+ evidence; a folder whose first entry is a record, and this is the store ``Environments`` writes
75
+ into, one folder per customer. Anything else is looked past.
76
+ """
77
+ for top in Evidence(store).records():
78
+ if _is_record(store, top):
79
+ return None
80
+ inside = [name for name in store.children(top) if not name.startswith(".")]
81
+ if inside and _is_record(store, f"{top}/{inside[0]}"):
82
+ return top
83
+ return None
84
+
85
+
86
+ def _records(store: EvidenceStore) -> Evidence:
87
+ """One Environment's evidence in ``store``; refused, with the folder to name instead, for every customer's."""
88
+ customer = _a_customer(store)
89
+ if customer is not None:
90
+ raise ValueError(
91
+ f"{store.locate('')} holds each customer's evidence beneath its customer_ref, not records: "
92
+ f"one customer's is --evidence {store.locate(customer)}"
93
+ )
94
+ return Evidence(store)
95
+
96
+
97
+ def _key() -> str:
98
+ key = os.environ.get("TALLWRIGHT_KEY", "")
99
+ if not key:
100
+ raise SystemExit("tallwright-cloud: no key: set TALLWRIGHT_KEY (never an argument)")
101
+ return key
102
+
103
+
104
+ def _client(args: argparse.Namespace) -> Tallwright:
105
+ if not args.ingest:
106
+ raise SystemExit("tallwright-cloud: no ingest address: --ingest or TALLWRIGHT_INGEST")
107
+ return Tallwright(args.ingest, _key(), _records(_store(args.evidence, ephemeral_ok=args.ephemeral_ok)).store)
108
+
109
+
110
+ def _say(line: str) -> None:
111
+ print(line) # noqa: T201
112
+
113
+
114
+ def doctor(args: argparse.Namespace) -> int:
115
+ trouble = 0
116
+ running = f"{sys.version_info.major}.{sys.version_info.minor}"
117
+ if (sys.version_info.major, sys.version_info.minor) in SUPPORTED:
118
+ _say(f"ok Python {running}")
119
+ else: # a note, not a problem: the checks below run on this interpreter and say whether it is in the way
120
+ lowest, highest = (".".join(map(str, version)) for version in (SUPPORTED[0], SUPPORTED[-1]))
121
+ _say(f"note Python {running}: this package is tested on {lowest} to {highest}, not on this one")
122
+ try:
123
+ store = _store(args.evidence, ephemeral_ok=args.ephemeral_ok)
124
+ problems = store.check()
125
+ except (OSError, ValueError) as failed:
126
+ problems = [str(failed)]
127
+ store = None
128
+ for problem in problems:
129
+ trouble += 1
130
+ _say(f"PROBLEM evidence: {problem}")
131
+ if store is not None and not problems:
132
+ _say(f"ok evidence at {store.locate('')} keeps what it is given and never replaces it")
133
+ customer = _a_customer(store)
134
+ if customer is not None:
135
+ _say(
136
+ f"note {store.locate('')} holds each customer's evidence beneath its customer_ref: what "
137
+ f"is pending is counted for one customer, with --evidence {store.locate(customer)}"
138
+ )
139
+ else:
140
+ pending = Evidence(store).pending()
141
+ _say(f"{'ok ' if not pending else 'note '} {len(pending)} record(s) pending in the evidence")
142
+ if not args.ingest:
143
+ trouble += 1
144
+ _say("PROBLEM no ingest address: --ingest or TALLWRIGHT_INGEST")
145
+ return 1
146
+ try:
147
+ endpoint = transport.endpoint(args.ingest)
148
+ except ValueError as wrong:
149
+ _say(f"PROBLEM ingest address: {wrong}")
150
+ return 1
151
+ try:
152
+ status, _ = transport.fetch(args.ingest.rstrip("/") + "/healthz", args.timeout, 4096)
153
+ except OSError as failed:
154
+ trouble += 1
155
+ _say(f"PROBLEM the door at {args.ingest} cannot be reached: {type(failed).__name__}: {getattr(failed, 'reason', failed)}")
156
+ return 1
157
+ if status == 200:
158
+ _say(f"ok the door at {args.ingest} answers, over a certificate this machine trusts")
159
+ else:
160
+ trouble += 1
161
+ _say(f"PROBLEM the door at {args.ingest} answered its liveness probe with {status}")
162
+ if args.check_key:
163
+ try:
164
+ answer = transport.post(endpoint, transport.Key(_key()), b"", args.timeout)
165
+ except (OSError, ValueError) as failed:
166
+ trouble += 1
167
+ _say(f"PROBLEM the key could not be checked: {type(failed).__name__}: {getattr(failed, 'reason', failed)}")
168
+ else:
169
+ if answer.status == 200:
170
+ _say("ok the door holds this key (an empty batch was sent: nothing was recorded)")
171
+ else:
172
+ trouble += 1
173
+ _say(f"PROBLEM the door answered an empty batch with {answer.status}: {transport.said(answer) or 'no sentence'}")
174
+ return 1 if trouble else 0
175
+
176
+
177
+ def pending(args: argparse.Namespace) -> int:
178
+ evidence = _records(_store(args.evidence, ephemeral_ok=args.ephemeral_ok))
179
+ names = evidence.pending() if args.all_keys or not os.environ.get("TALLWRIGHT_KEY") else evidence.pending(transport.Key(_key()).binding())
180
+ for name in names:
181
+ _say(name)
182
+ return 0
183
+
184
+
185
+ def resend(args: argparse.Namespace) -> int:
186
+ if args.adopt and not args.record_ids:
187
+ _say(
188
+ "tallwright-cloud resend --adopt: name each record to send with this key. Whether a record "
189
+ "pending under another key is this Environment's is yours to know, record by record; "
190
+ "tallwright-cloud pending --all-keys lists them"
191
+ )
192
+ return 2
193
+ client = _client(args)
194
+ names = args.record_ids or client.pending()
195
+ others = [] if args.record_ids else sorted(set(Evidence(client.evidence).pending()) - set(names))
196
+ failed = 0
197
+ for name in names:
198
+ try:
199
+ captured = client.resend(name, adopt=args.adopt)
200
+ except TallwrightError as problem:
201
+ failed += 1
202
+ _say(f"{name}: {type(problem).__name__}: {problem}")
203
+ else:
204
+ _say(f"{name}: {captured.receipt.status} at leaf {captured.receipt.leaf_index}")
205
+ if not names:
206
+ _say("nothing pending under this key")
207
+ if others:
208
+ _say(
209
+ f"{len(others)} record(s) pending under another key, not sent with this one: tallwright-cloud "
210
+ "pending --all-keys lists them, and if this key replaced that one for the same Environment, "
211
+ "tallwright-cloud resend --adopt ID sends each"
212
+ )
213
+ return 1 if failed or others else 0
214
+
215
+
216
+ def _short_of_the_log(store: EvidenceStore, name: str) -> str:
217
+ """Why the log is not shown to hold a record kept here, or "" when its receipt is kept."""
218
+ if store.read(f"{name}/{RECEIPT}") is not None:
219
+ return ""
220
+ if store.read(f"{name}/{REFUSED}") is not None:
221
+ return "refused"
222
+ return "pending" if store.read(f"{name}/{LINE}") is not None else "never sent"
223
+
224
+
225
+ def show_gaps(args: argparse.Namespace) -> int:
226
+ # A hole is a capture that was numbered and is not in the log. What shows a record in the log here
227
+ # is its receipt, so a number is a hole when no record of it is kept, and when the record kept was
228
+ # refused, is pending or was never sent; each is said as what it is.
229
+ evidence = _records(_store(args.evidence, ephemeral_ok=args.ephemeral_ok))
230
+ kept = evidence.records()
231
+ found = {session: dict.fromkeys(missing, "") for session, missing in gaps(kept).items()}
232
+ for name in kept:
233
+ for session in gaps([name]): # once for a name this client minted, <session>.<number>
234
+ if short := _short_of_the_log(evidence.store, name):
235
+ found[session][int(name.rpartition(".")[2])] = short
236
+ holes = 0
237
+ for session, missing in sorted(found.items()):
238
+ if missing:
239
+ holes += len(missing)
240
+ shown = ", ".join(f"{n} ({missing[n]})" if missing[n] else str(n) for n in sorted(missing)[:20])
241
+ _say(f"{session}: {len(missing)} missing: {shown}{' …' if len(missing) > 20 else ''}")
242
+ if holes:
243
+ _say(
244
+ "missing: not in the log as far as this evidence shows. A bare number has no record here; "
245
+ "(refused) the door refused it; (pending) the door has not confirmed it, and resend sends it "
246
+ "again; (never sent) only its pair was kept"
247
+ )
248
+ else:
249
+ _say(
250
+ "no hole: up to the highest number of every session, each record's receipt is kept here "
251
+ "(which says nothing about what was never captured)"
252
+ )
253
+ return 1 if holes else 0
254
+
255
+
256
+ def _folders(folder: Path) -> None:
257
+ """``folder`` and each folder above it that is missing, made the owner's alone (0700), as in the store."""
258
+ missing = []
259
+ while not folder.exists():
260
+ missing.append(folder)
261
+ folder = folder.parent
262
+ for made in reversed(missing):
263
+ made.mkdir(mode=0o700, exist_ok=True)
264
+
265
+
266
+ def export(args: argparse.Namespace) -> int:
267
+ store = _records(_store(args.evidence, ephemeral_ok=args.ephemeral_ok)).store
268
+ name = checked_record_id(args.record_id)
269
+ if not _is_record(store, name):
270
+ _say(f"{name}: no such record in {store.locate('')}")
271
+ return 1
272
+ target = Path(args.to)
273
+ copied, disclosed = 0, False
274
+ for inside in walk(store, name):
275
+ out = target.joinpath(*inside.split("/")[1:])
276
+ _folders(out.parent)
277
+ # The content and the salt, kept as the store keeps them: the owner's alone (0600), and never
278
+ # over a file already there. Sharing the copy is a person's act, done on purpose.
279
+ with os.fdopen(_exclusive(out), "wb") as written:
280
+ written.write(store.read(inside) or b"")
281
+ copied += 1
282
+ disclosed = disclosed or inside == f"{name}/{DISCLOSURE}"
283
+ opened = (
284
+ f"evidence-verify reads the disclosure at {target / DISCLOSURE}"
285
+ if disclosed
286
+ else f"it holds no {DISCLOSURE}, so nobody can open what it commits to"
287
+ )
288
+ _say(f"{name}: {copied} file(s) in {target}; {opened}")
289
+ return 0
290
+
291
+
292
+ def parser() -> argparse.ArgumentParser:
293
+ top = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
294
+ common: dict[str, Any] = {}
295
+ sub = top.add_subparsers(dest="command", required=True)
296
+ for name, run, help_ in (
297
+ ("doctor", doctor, "what stands between this machine and evidence somebody can check"),
298
+ ("pending", pending, "records sent and never confirmed or refused"),
299
+ ("resend", resend, "send pending records again, byte for byte"),
300
+ ("gaps", show_gaps, "numbers missing from a session's sequence"),
301
+ ("export", export, "copy one record's evidence into a folder"),
302
+ ):
303
+ command = sub.add_parser(name, help=help_, **common)
304
+ command.set_defaults(run=run)
305
+ command.add_argument("--evidence", default=os.environ.get("TALLWRIGHT_EVIDENCE"))
306
+ command.add_argument("--ephemeral-ok", action="store_true", help="accept evidence on a file system that ends with its container")
307
+ if name in ("doctor", "resend"):
308
+ command.add_argument("--ingest", default=os.environ.get("TALLWRIGHT_INGEST"))
309
+ if name == "doctor":
310
+ command.add_argument("--timeout", type=float, default=10.0)
311
+ command.add_argument("--check-key", action="store_true", help="send an empty batch to check the key; records nothing")
312
+ if name == "pending":
313
+ command.add_argument("--all-keys", action="store_true", help="also records pending under another key")
314
+ if name == "resend":
315
+ command.add_argument("record_ids", nargs="*")
316
+ command.add_argument("--adopt", action="store_true", help="send the records named, pending under a replaced key, with this one")
317
+ if name == "export":
318
+ command.add_argument("record_id")
319
+ command.add_argument("to")
320
+ return top
321
+
322
+
323
+ def main(argv: Sequence[str] | None = None) -> int:
324
+ args = parser().parse_args(argv)
325
+ try:
326
+ return int(args.run(args))
327
+ except (TallwrightError, OSError, ValueError) as failed:
328
+ _say(f"tallwright-cloud: {type(failed).__name__}: {failed}")
329
+ return 1
330
+
331
+
332
+ if __name__ == "__main__": # pragma: no cover
333
+ raise SystemExit(main())