sayfirst-cli 0.2.0__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.
- sayfirst_cli/__init__.py +2 -0
- sayfirst_cli/approvals.py +127 -0
- sayfirst_cli/ask.py +167 -0
- sayfirst_cli/evidence.py +668 -0
- sayfirst_cli/exit_codes.py +88 -0
- sayfirst_cli/explain.py +35 -0
- sayfirst_cli/instrument/__init__.py +14 -0
- sayfirst_cli/instrument/commands.py +191 -0
- sayfirst_cli/instrument/engine.py +396 -0
- sayfirst_cli/instrument/harness.py +1162 -0
- sayfirst_cli/instrument/launch.py +417 -0
- sayfirst_cli/instrument/manifest.py +409 -0
- sayfirst_cli/instrument/verify.py +553 -0
- sayfirst_cli/main.py +78 -0
- sayfirst_cli/packs/__init__.py +10 -0
- sayfirst_cli/packs/database/NOTE.md +4 -0
- sayfirst_cli/packs/database/interpose.py +85 -0
- sayfirst_cli/packs/database/pack.toml +13 -0
- sayfirst_cli/packs/http-client/NOTE.md +4 -0
- sayfirst_cli/packs/http-client/interpose.py +72 -0
- sayfirst_cli/packs/http-client/pack.toml +13 -0
- sayfirst_cli/packs/subprocess/NOTE.md +4 -0
- sayfirst_cli/packs/subprocess/interpose.py +90 -0
- sayfirst_cli/packs/subprocess/pack.toml +13 -0
- sayfirst_cli/packs_cmd.py +115 -0
- sayfirst_cli/pages.py +87 -0
- sayfirst_cli/reads.py +254 -0
- sayfirst_cli/render.py +138 -0
- sayfirst_cli/trace.py +88 -0
- sayfirst_cli-0.2.0.dist-info/METADATA +297 -0
- sayfirst_cli-0.2.0.dist-info/RECORD +35 -0
- sayfirst_cli-0.2.0.dist-info/WHEEL +4 -0
- sayfirst_cli-0.2.0.dist-info/entry_points.txt +2 -0
- sayfirst_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- sayfirst_cli-0.2.0.dist-info/licenses/NOTICE +13 -0
sayfirst_cli/reads.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The shared path for scoped reads: verify the connection and render its result.
|
|
3
|
+
|
|
4
|
+
A read acts for nobody and carries no delegation. Its success says a record
|
|
5
|
+
was read, never that the effect recorded there may be exercised (article 1).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from typing import Final, Protocol, TextIO
|
|
13
|
+
|
|
14
|
+
from sayfirst_contract.client import Answered, CouldNotAsk, Refused, Result
|
|
15
|
+
from sayfirst_contract.generation import CONTRACT_GENERATION
|
|
16
|
+
from sayfirst_contract.problems import Problem, ProblemCode, problem_retryable
|
|
17
|
+
from sayfirst_contract.transport.socket_client import (
|
|
18
|
+
PER_USER,
|
|
19
|
+
SYSTEM,
|
|
20
|
+
ProfileMisuse,
|
|
21
|
+
SocketClientProblem,
|
|
22
|
+
SocketProfile,
|
|
23
|
+
VerifiedConnection,
|
|
24
|
+
connect,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from . import exit_codes, render
|
|
28
|
+
|
|
29
|
+
INPUT_ERRORS: Final = (
|
|
30
|
+
AttributeError,
|
|
31
|
+
KeyError,
|
|
32
|
+
TypeError,
|
|
33
|
+
ValueError,
|
|
34
|
+
OverflowError,
|
|
35
|
+
RecursionError,
|
|
36
|
+
)
|
|
37
|
+
"""The exceptions an answer from the daemon can raise on its way into this client.
|
|
38
|
+
|
|
39
|
+
The contract's readers classify what they can and let the rest through: a `200`
|
|
40
|
+
whose body is JSON but not an object raises `AttributeError` inside
|
|
41
|
+
`read_decision`, a reply nested past the interpreter's limit raises
|
|
42
|
+
`RecursionError` inside `json.loads`, a timestamp outside the calendar raises
|
|
43
|
+
`OverflowError`. Every one of them is an answer this client could not read —
|
|
44
|
+
« could not ask », never « denied » (articles 1 and 2) — and an escape from here
|
|
45
|
+
ends the process with a traceback, exit 1, which is this client's code for deny.
|
|
46
|
+
One tuple, because five copies of it is how a sixth caller comes to spell none.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
DOCUMENT_DEPTH_LIMIT: Final = 64
|
|
50
|
+
"""How deeply nested a daemon document may be before this client declines to read it.
|
|
51
|
+
|
|
52
|
+
A real record, page or bundle nests a handful of levels (entries, bodies,
|
|
53
|
+
attached policies). A document nested thousands deep still parses, and then the
|
|
54
|
+
step that renders it back into an envelope exhausts the interpreter and ends the
|
|
55
|
+
process with a traceback — exit 1, deny again. A bound stated here is honest; an
|
|
56
|
+
escape is not.
|
|
57
|
+
|
|
58
|
+
**The bound is measured where the render happens**, which is `finish` below: it
|
|
59
|
+
is the whole answer a command composes, not one page of it, that the envelope is
|
|
60
|
+
built from. `PAGE_DEPTH_LIMIT` is the same rule read one step earlier, so that a
|
|
61
|
+
page a walk accepts is a page the command that walks it can render.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
PAGE_DEPTH_LIMIT: Final = DOCUMENT_DEPTH_LIMIT - 2
|
|
65
|
+
"""How deeply nested ONE page may be, which is two levels shallower, and why.
|
|
66
|
+
|
|
67
|
+
A paging read hands `finish` the pages it collected under a member of its own —
|
|
68
|
+
`{"pages": [page, …]}` — so every page sits three levels down in the answer that
|
|
69
|
+
is actually rendered, and its own deepest member two levels deeper than it
|
|
70
|
+
measured alone. A page validator that used the full bound therefore accepted
|
|
71
|
+
pages the render then refused, after the prose for the pages before it had
|
|
72
|
+
already reached the caller: half an answer and then « could not ask » is worse
|
|
73
|
+
than either answer alone.
|
|
74
|
+
|
|
75
|
+
**One rule, one number, subtracted once, and measured rather than argued.** The
|
|
76
|
+
`2` is not a margin: it is the exact difference between the two documents, and
|
|
77
|
+
`tests/test_evidence_history_and_audit.py`'s bound pair is what pins it — a page
|
|
78
|
+
at the full bound renders as an answer, and one level past it is refused before
|
|
79
|
+
a byte reaches stdout. A subtraction that drifted in either direction turns one
|
|
80
|
+
of those two red. A caller composing a different answer around a page passes its
|
|
81
|
+
own limit to `too_deep`, which is why that argument exists; nothing else is
|
|
82
|
+
bounded twice.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DocumentValue(Protocol):
|
|
87
|
+
"""A typed contract value that supplies its own document."""
|
|
88
|
+
|
|
89
|
+
def to_document(self) -> Mapping[str, object]: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def positive(value: str) -> int:
|
|
93
|
+
"""A bound a caller supplies: zero and below are misuse, not a small read.
|
|
94
|
+
|
|
95
|
+
One rule across the slice, because `--pages 0` claiming « not found within 0
|
|
96
|
+
page(s) » with exit 0 is a mistyped bound rendered as an established absence.
|
|
97
|
+
"""
|
|
98
|
+
number = int(value)
|
|
99
|
+
if number < 1:
|
|
100
|
+
raise argparse.ArgumentTypeError("must be a positive integer")
|
|
101
|
+
return number
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def too_deep(value: object, limit: int = DOCUMENT_DEPTH_LIMIT) -> bool:
|
|
105
|
+
"""Whether a document nests beyond `limit` — iteratively, so the check itself
|
|
106
|
+
can never be the recursion it guards against.
|
|
107
|
+
|
|
108
|
+
The default is the bound on a whole answer; a caller bounding one piece of
|
|
109
|
+
one passes `PAGE_DEPTH_LIMIT` and says which piece in its own refusal.
|
|
110
|
+
"""
|
|
111
|
+
pending: list[tuple[object, int]] = [(value, 1)]
|
|
112
|
+
while pending:
|
|
113
|
+
item, depth = pending.pop()
|
|
114
|
+
if depth > limit:
|
|
115
|
+
return True
|
|
116
|
+
if isinstance(item, Mapping):
|
|
117
|
+
pending.extend((child, depth + 1) for child in item.values())
|
|
118
|
+
elif isinstance(item, list):
|
|
119
|
+
pending.extend((child, depth + 1) for child in item)
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def unreadable(failure: Exception) -> CouldNotAsk:
|
|
124
|
+
"""An answer this client could not read, said as that and never as a refusal."""
|
|
125
|
+
return CouldNotAsk(
|
|
126
|
+
# Retryability is the registry's to state, never this client's: the
|
|
127
|
+
# contract's table says « not stated » for an unreadable answer, and the
|
|
128
|
+
# transport says the same when it is the one that classifies (article 2).
|
|
129
|
+
Problem(
|
|
130
|
+
ProblemCode.ANSWER_UNREADABLE,
|
|
131
|
+
str(failure),
|
|
132
|
+
problem_retryable(ProblemCode.ANSWER_UNREADABLE),
|
|
133
|
+
CONTRACT_GENERATION,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read[T](operation: Callable[[], Result[T]]) -> Result[T]:
|
|
139
|
+
"""Run one read of the daemon: the two ways an answer fails to be one, in one place.
|
|
140
|
+
|
|
141
|
+
A transport failure carries the contract's own classification — refused, or
|
|
142
|
+
could not ask. An answer that arrives and cannot be read is
|
|
143
|
+
`answer_unreadable`. Neither is a decision, and neither may reach the shell
|
|
144
|
+
as exit 1, the code for « deny ». Every read of this client goes through
|
|
145
|
+
here, a page walk included, so that a command cannot be the one that forgot.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
return operation()
|
|
149
|
+
except SocketClientProblem as failure:
|
|
150
|
+
return connection_problem(failure)
|
|
151
|
+
except INPUT_ERRORS as failure:
|
|
152
|
+
return unreadable(failure)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def add_connection_arguments(parser: argparse.ArgumentParser) -> None:
|
|
156
|
+
"""Every read names its scope explicitly, with the writer's connection options."""
|
|
157
|
+
parser.add_argument("--scope", required=True, help="the scope the question is asked in")
|
|
158
|
+
parser.add_argument("--socket", required=True, help="the path of the daemon's socket")
|
|
159
|
+
parser.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER)
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
"--daemon-user",
|
|
162
|
+
default=None,
|
|
163
|
+
help="the account the daemon runs as; a system profile names it",
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument("--json", action="store_true", help="write the envelope instead of prose")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def connection_problem(failure: SocketClientProblem) -> Refused | CouldNotAsk:
|
|
169
|
+
"""A transport exception is a non-answer, with the contract's classification."""
|
|
170
|
+
if failure.classification == "refused":
|
|
171
|
+
return Refused(failure.problem)
|
|
172
|
+
return CouldNotAsk(failure.problem)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def open_connection(arguments: argparse.Namespace, stderr: TextIO) -> VerifiedConnection | int:
|
|
176
|
+
"""Verify the daemon, or report why this invocation could not open a connection."""
|
|
177
|
+
try:
|
|
178
|
+
profile = SocketProfile(
|
|
179
|
+
arguments.socket,
|
|
180
|
+
mode=arguments.mode,
|
|
181
|
+
daemon_user=arguments.daemon_user,
|
|
182
|
+
scope=arguments.scope,
|
|
183
|
+
)
|
|
184
|
+
except ProfileMisuse as misuse:
|
|
185
|
+
stderr.write(f"{misuse}\n")
|
|
186
|
+
return exit_codes.EXIT_MISUSE
|
|
187
|
+
try:
|
|
188
|
+
return connect(profile)
|
|
189
|
+
except SocketClientProblem as failure:
|
|
190
|
+
return _write_problem(
|
|
191
|
+
connection_problem(failure),
|
|
192
|
+
arguments,
|
|
193
|
+
render.verification_document(None, None, False),
|
|
194
|
+
stderr,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def finish(
|
|
199
|
+
result: Result[DocumentValue | Mapping[str, object]],
|
|
200
|
+
arguments: argparse.Namespace,
|
|
201
|
+
connection: VerifiedConnection,
|
|
202
|
+
stdout: TextIO,
|
|
203
|
+
stderr: TextIO,
|
|
204
|
+
render_answer: Callable[[Mapping[str, object], TextIO], None],
|
|
205
|
+
) -> int:
|
|
206
|
+
"""Render the supplied record or problem; a record's outcome is only data."""
|
|
207
|
+
verification = render.verification_document(
|
|
208
|
+
connection.server_credential.uid, connection.expected_uid, connection.verified
|
|
209
|
+
)
|
|
210
|
+
# The document is materialised and bounded through the same helper the read
|
|
211
|
+
# used: `to_document` is itself a step an unreadable answer raises inside,
|
|
212
|
+
# and the render below is the step a document nested past the bound would
|
|
213
|
+
# overflow. Either way the answer is one this client could not read.
|
|
214
|
+
answer = read(lambda: _bounded(result)) if isinstance(result, Answered) else result
|
|
215
|
+
if isinstance(answer, Answered):
|
|
216
|
+
document = answer.value
|
|
217
|
+
if arguments.json:
|
|
218
|
+
render.write_json(
|
|
219
|
+
render.envelope(CONTRACT_GENERATION, verification, result=document), stdout
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
render_answer(document, stdout)
|
|
223
|
+
return 0
|
|
224
|
+
return _write_problem(answer, arguments, verification, stderr)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _bounded(
|
|
228
|
+
result: Answered[DocumentValue | Mapping[str, object]],
|
|
229
|
+
) -> Answered[Mapping[str, object]]:
|
|
230
|
+
"""The answer as the document about to be rendered, refused if it nests too deep."""
|
|
231
|
+
value = result.value
|
|
232
|
+
document = value if isinstance(value, Mapping) else value.to_document()
|
|
233
|
+
if too_deep(document):
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"nested deeper than {DOCUMENT_DEPTH_LIMIT} levels; not a document this client reads"
|
|
236
|
+
)
|
|
237
|
+
return Answered(document, result.contract_generation)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _write_problem(
|
|
241
|
+
result: Refused | CouldNotAsk,
|
|
242
|
+
arguments: argparse.Namespace,
|
|
243
|
+
verification: Mapping[str, object],
|
|
244
|
+
stderr: TextIO,
|
|
245
|
+
) -> int:
|
|
246
|
+
could_not_ask = not isinstance(result, Refused)
|
|
247
|
+
problem = result.problem.to_document(CONTRACT_GENERATION)
|
|
248
|
+
document = render.envelope(CONTRACT_GENERATION, verification, problem=problem)
|
|
249
|
+
if arguments.json:
|
|
250
|
+
render.write_json(document, stderr)
|
|
251
|
+
else:
|
|
252
|
+
render.write_verification(verification, stderr)
|
|
253
|
+
render.write_problem(problem, stderr, could_not_ask=could_not_ask)
|
|
254
|
+
return exit_codes.EXIT_COULD_NOT_ASK if could_not_ask else exit_codes.EXIT_REFUSED
|
sayfirst_cli/render.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""How an answer reaches a person, and what this client is not allowed to add.
|
|
3
|
+
|
|
4
|
+
Article 1: a client explains and invokes control semantics; it never derives an
|
|
5
|
+
answer the control plane did not give. So every member of the envelope below is
|
|
6
|
+
either something the control plane said, something the operating system said, or
|
|
7
|
+
something this process did — and each is labelled as which. Nothing is computed
|
|
8
|
+
from an answer and presented beside it as though it were part of it.
|
|
9
|
+
|
|
10
|
+
Article 2: an absence is never rendered as a negative fact, a zero or a healthy
|
|
11
|
+
state. Where the answer carries no policy version, no approval reference and no
|
|
12
|
+
reason this generation knows, the rendering says so in words rather than leaving
|
|
13
|
+
a blank the reader completes with a guess.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from typing import TextIO
|
|
21
|
+
|
|
22
|
+
#: What is written where the control plane said nothing. It is a sentence and
|
|
23
|
+
#: not an empty string, because a blank field reads as a value (article 2).
|
|
24
|
+
NOT_STATED = "not stated"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def verification_document(
|
|
28
|
+
server_uid: int | None, expected_uid: int | None, verified: bool
|
|
29
|
+
) -> dict[str, object]:
|
|
30
|
+
"""What this process verified about the far end before it wrote a byte.
|
|
31
|
+
|
|
32
|
+
This is the one part of the envelope the control plane did not say: the
|
|
33
|
+
client read the server's peer credential and compared it (article 6). It is
|
|
34
|
+
kept in a member of its own so that no reader can mistake it for something
|
|
35
|
+
the daemon claimed about itself.
|
|
36
|
+
"""
|
|
37
|
+
return {"server_uid": server_uid, "expected": expected_uid, "verified": verified}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def envelope(
|
|
41
|
+
contract_generation: int,
|
|
42
|
+
verification: Mapping[str, object],
|
|
43
|
+
*,
|
|
44
|
+
result: Mapping[str, object] | None = None,
|
|
45
|
+
problem: Mapping[str, object] | None = None,
|
|
46
|
+
) -> dict[str, object]:
|
|
47
|
+
"""The machine-readable form: the answer, or the problem, and never both."""
|
|
48
|
+
document: dict[str, object] = {
|
|
49
|
+
"contract_generation": contract_generation,
|
|
50
|
+
"verification": dict(verification),
|
|
51
|
+
}
|
|
52
|
+
if result is not None:
|
|
53
|
+
document["result"] = dict(result)
|
|
54
|
+
if problem is not None:
|
|
55
|
+
document["problem"] = dict(problem)
|
|
56
|
+
return document
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_json(document: Mapping[str, object], stream: TextIO) -> None:
|
|
60
|
+
"""The whole envelope, stable under sorting so a diff of two runs is readable."""
|
|
61
|
+
stream.write(json.dumps(document, indent=2, sort_keys=True))
|
|
62
|
+
stream.write("\n")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _stated(value: object) -> str:
|
|
66
|
+
"""A member as a person reads it, with absence said rather than shown."""
|
|
67
|
+
return NOT_STATED if value is None else str(value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_decision(document: Mapping[str, object], stream: TextIO) -> None:
|
|
71
|
+
"""A decision, in the words the control plane used for it.
|
|
72
|
+
|
|
73
|
+
The outcome is written as the control plane spelled it. This client holds no
|
|
74
|
+
table from `allow` to "approved" or from `deny` to "blocked": a second
|
|
75
|
+
vocabulary for the same three outcomes is a second control plane, without
|
|
76
|
+
the evidence (articles 1 and 4).
|
|
77
|
+
"""
|
|
78
|
+
stream.write(f"outcome: {document['outcome']}\n")
|
|
79
|
+
stream.write(f"reason: {_stated(document.get('reason'))}\n")
|
|
80
|
+
stream.write(f"capability: {document['capability']} in scope {document['scope']}\n")
|
|
81
|
+
stream.write(f"decision: {document['decision_ref']} at {document['decided_at']}\n")
|
|
82
|
+
stream.write(f"policy version: {_stated(document.get('policy_version'))}\n")
|
|
83
|
+
if document.get("approval_ref") is not None:
|
|
84
|
+
stream.write(f"approval: {document['approval_ref']}\n")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write_verification(document: Mapping[str, object], stream: TextIO) -> None:
|
|
88
|
+
"""What was verified about the far end, in the same words `whoami` uses."""
|
|
89
|
+
stream.write(
|
|
90
|
+
f"verified: {str(document['verified']).lower()} "
|
|
91
|
+
f"(server_uid {_stated(document['server_uid'])}, "
|
|
92
|
+
f"expected {_stated(document['expected'])})\n"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def write_record(document: Mapping[str, object], stream: TextIO) -> None:
|
|
97
|
+
"""Every supplied record member, with its reason and rule on their own lines."""
|
|
98
|
+
order = (
|
|
99
|
+
"decision_ref",
|
|
100
|
+
"scope",
|
|
101
|
+
"capability",
|
|
102
|
+
"outcome",
|
|
103
|
+
"reason",
|
|
104
|
+
"rule_id",
|
|
105
|
+
"policy_version",
|
|
106
|
+
"decided_at",
|
|
107
|
+
"correlation",
|
|
108
|
+
"grant_id",
|
|
109
|
+
"approval_ref",
|
|
110
|
+
)
|
|
111
|
+
for key in (*order, *sorted(document.keys() - set(order))):
|
|
112
|
+
if key in document:
|
|
113
|
+
stream.write(f"{key}: {_stated(document[key])}\n")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def write_chain_position(position: Mapping[str, object], stream: TextIO) -> None:
|
|
117
|
+
"""The position and grade the plane supplied, or the bound actually read."""
|
|
118
|
+
if position.get("found") is False:
|
|
119
|
+
stream.write(f"chain: not found within {position['pages']} page(s)\n")
|
|
120
|
+
else:
|
|
121
|
+
stream.write(
|
|
122
|
+
f"chain: sequence {position['sequence']}, entry_hash {position['entry_hash']}, "
|
|
123
|
+
f"grade {position['grade']}\n"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_problem(document: Mapping[str, object], stream: TextIO, *, could_not_ask: bool) -> None:
|
|
128
|
+
"""A refusal or an unanswerable question, each said as what it is.
|
|
129
|
+
|
|
130
|
+
Article 1: "could not ask" is never written as "denied" or as "allowed", and
|
|
131
|
+
article 2 adds that it is not a negative fact either. The line therefore
|
|
132
|
+
names which of the two happened before it names the code, because the code
|
|
133
|
+
alone does not tell a reader whether an answer exists somewhere.
|
|
134
|
+
"""
|
|
135
|
+
kind = "could not ask" if could_not_ask else "refused"
|
|
136
|
+
stream.write(f"{kind}: {document['code']}: {document['message']}\n")
|
|
137
|
+
retryable = document.get("retryable")
|
|
138
|
+
stream.write(f"retryable: {NOT_STATED if retryable is None else str(retryable).lower()}\n")
|
sayfirst_cli/trace.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Read a decision and locate its first effect entry within a bounded evidence read."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import TextIO
|
|
10
|
+
|
|
11
|
+
from sayfirst_contract.client import Answered, Result
|
|
12
|
+
from sayfirst_contract.generation import CONTRACT_GENERATION
|
|
13
|
+
|
|
14
|
+
from . import pages, reads, render
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="sayfirst trace", description="Read a decision and its position in scoped evidence."
|
|
20
|
+
)
|
|
21
|
+
reads.add_connection_arguments(parser)
|
|
22
|
+
parser.add_argument("--decision", required=True, help="the reference of the recorded decision")
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--pages", type=reads.positive, default=10, help="the maximum number of evidence pages"
|
|
25
|
+
)
|
|
26
|
+
return parser
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _write_trace(document: Mapping[str, object], stream: TextIO) -> None:
|
|
30
|
+
render.write_record({key: value for key, value in document.items() if key != "chain"}, stream)
|
|
31
|
+
render.write_chain_position(document["chain"], stream)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(
|
|
35
|
+
argv: Sequence[str] | None = None, *, out: TextIO | None = None, err: TextIO | None = None
|
|
36
|
+
) -> int:
|
|
37
|
+
stdout, stderr = out or sys.stdout, err or sys.stderr
|
|
38
|
+
arguments = build_parser().parse_args(argv)
|
|
39
|
+
connection = reads.open_connection(arguments, stderr)
|
|
40
|
+
if isinstance(connection, int):
|
|
41
|
+
return connection
|
|
42
|
+
|
|
43
|
+
def walk() -> Result[Mapping[str, object]]:
|
|
44
|
+
"""The record, then the pages, then where in the chain the decision sits."""
|
|
45
|
+
result = connection.read_decision(arguments.scope, arguments.decision)
|
|
46
|
+
if not isinstance(result, Answered):
|
|
47
|
+
return result
|
|
48
|
+
record = result.value.to_document()
|
|
49
|
+
from_sequence, read_pages, position = 1, 0, None
|
|
50
|
+
for _ in range(arguments.pages):
|
|
51
|
+
# The daemon closes the connection after a decision read (an adapter
|
|
52
|
+
# wrote that answer), and an evidence read may close its own. The
|
|
53
|
+
# transport never re-opens an address on a caller's behalf (rule
|
|
54
|
+
# C4), so every page is read on a connection reopened — and the far
|
|
55
|
+
# end verified again — here, exactly as `history` walks its pages.
|
|
56
|
+
connection.reconnect()
|
|
57
|
+
page = connection.read_evidence(arguments.scope, from_sequence, 100)
|
|
58
|
+
if not isinstance(page, Answered):
|
|
59
|
+
return page
|
|
60
|
+
read_pages += 1
|
|
61
|
+
entries, verification, next_from = pages.members(page.value, from_sequence)
|
|
62
|
+
for entry in entries:
|
|
63
|
+
if entry["kind"] == "effect" and entry["body"]["decision_id"] == arguments.decision:
|
|
64
|
+
grade = next(
|
|
65
|
+
(
|
|
66
|
+
item["grade"]
|
|
67
|
+
for item in verification["grades"]
|
|
68
|
+
if item["connection_id"] == entry["connection_id"]
|
|
69
|
+
),
|
|
70
|
+
"unknown",
|
|
71
|
+
)
|
|
72
|
+
position = {
|
|
73
|
+
"sequence": entry["sequence"],
|
|
74
|
+
"entry_hash": entry["entry_hash"],
|
|
75
|
+
"grade": grade,
|
|
76
|
+
}
|
|
77
|
+
break
|
|
78
|
+
if position is not None or next_from is None:
|
|
79
|
+
break
|
|
80
|
+
from_sequence = next_from
|
|
81
|
+
if position is None:
|
|
82
|
+
position = {"found": False, "pages": read_pages}
|
|
83
|
+
return Answered({**record, "chain": position}, CONTRACT_GENERATION)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
return reads.finish(reads.read(walk), arguments, connection, stdout, stderr, _write_trace)
|
|
87
|
+
finally:
|
|
88
|
+
connection.close()
|