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
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`sayfirst instrument verify`: run the proof harness, and read its findings.
|
|
3
|
+
|
|
4
|
+
This is the command around `harness.py`. It reads the packs the way `run` does,
|
|
5
|
+
so a pack that will not read is the misuse it is before anything is spawned;
|
|
6
|
+
writes what it was asked into a configuration the harness reads; runs the
|
|
7
|
+
harness in a process of its own, because the hook that stops an ungoverned
|
|
8
|
+
effect can never be removed from an interpreter; and turns the report into
|
|
9
|
+
lines and into an exit code.
|
|
10
|
+
|
|
11
|
+
**The exit code is this command's and not the program's.** `run` passes the
|
|
12
|
+
governed program's ending through untouched, which is right for a launcher and
|
|
13
|
+
wrong for a proof: here the question is whether every named effect was decided,
|
|
14
|
+
so the program's own ending is reported as a fact (`target exit`) and the code a
|
|
15
|
+
shell sees comes from the verdicts. Zero means every point of every pack was
|
|
16
|
+
`governed` and no event on any of them went unjudged. `not-exercised` is never
|
|
17
|
+
zero — a path a run did not walk is not a path proven safe, and a verifier that
|
|
18
|
+
reported it green would be the false all-clear article 2 forbids. Neither is an
|
|
19
|
+
`unjudged` count, for the same reason read one step earlier: the harness
|
|
20
|
+
publishes, per point, how many events it could not judge at all, and a run
|
|
21
|
+
holding one of those has not finished proving anything about that point,
|
|
22
|
+
whatever the point's verdict says about the events it did judge.
|
|
23
|
+
|
|
24
|
+
**Where the program's own output goes, and why it is not stdout.** The verified
|
|
25
|
+
program keeps both its streams, and both of them arrive on THIS command's error
|
|
26
|
+
stream. It is the diagnostic stream for a verification run: the answer to
|
|
27
|
+
`verify` is the report, so `--json` has to be able to write an envelope a
|
|
28
|
+
machine reads, and a rule that moved the program's output depending on the
|
|
29
|
+
rendering asked for would be worse than a rule that is the same in both.
|
|
30
|
+
|
|
31
|
+
**`--json` answers in the envelope, and on a stream the program cannot reach.**
|
|
32
|
+
The verified program keeps both its streams and both arrive on this command's
|
|
33
|
+
error stream, which is right — the answer to `verify` is the report. It also
|
|
34
|
+
means a program can print `{"problem": …}` there, and this repository's own
|
|
35
|
+
tests parse an envelope from the first `{` they find. So the envelope goes to
|
|
36
|
+
stdout on every path, findings or none: on a no-answer path nothing else is
|
|
37
|
+
written to stdout, and on a reported path the envelope is the answer. The exit
|
|
38
|
+
code is the same either way — the rendering is not the verdict.
|
|
39
|
+
|
|
40
|
+
**How a no-answer path is told from the program's ending.** The harness writes
|
|
41
|
+
a file of its own saying which of its endings happened, and this command reads
|
|
42
|
+
that rather than the process's exit status. The status is the TARGET's: the
|
|
43
|
+
program runs inside the harness's interpreter, so `os._exit(64)` in the program
|
|
44
|
+
ends the harness with 64 — and 64 was read here as « the invocation was refused
|
|
45
|
+
before it began », a sentence about a program that had in fact run. One number
|
|
46
|
+
and two facts; `harness._write_outcome` carries the second, and says what the
|
|
47
|
+
channel is honest against.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import argparse
|
|
53
|
+
import json
|
|
54
|
+
import subprocess
|
|
55
|
+
import sys
|
|
56
|
+
import tempfile
|
|
57
|
+
from collections.abc import Mapping, Sequence
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from typing import Final, NamedTuple, TextIO
|
|
60
|
+
|
|
61
|
+
from sayfirst_contract.generation import CONTRACT_GENERATION
|
|
62
|
+
from sayfirst_contract.problems import (
|
|
63
|
+
Problem,
|
|
64
|
+
ProblemCode,
|
|
65
|
+
classes_by_code,
|
|
66
|
+
problem_retryable,
|
|
67
|
+
)
|
|
68
|
+
from sayfirst_contract.transport.socket_client import (
|
|
69
|
+
PER_USER,
|
|
70
|
+
SYSTEM,
|
|
71
|
+
ProfileMisuse,
|
|
72
|
+
SocketProfile,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
from .. import exit_codes, reads, render
|
|
76
|
+
from . import harness, manifest
|
|
77
|
+
|
|
78
|
+
#: The module the harness is run as. Named here so that the one place a second
|
|
79
|
+
#: interpreter is started names what it starts, and so a test can point it at
|
|
80
|
+
#: something that produces no report.
|
|
81
|
+
HARNESS_MODULE: Final[str] = "sayfirst_cli.instrument.harness"
|
|
82
|
+
|
|
83
|
+
#: What the configuration and the findings are called inside the directory this
|
|
84
|
+
#: command makes and removes. Neither outlives the run: a verification leaves
|
|
85
|
+
#: nothing on the machine it ran on.
|
|
86
|
+
CONFIGURATION_FILE: Final[str] = "verification-configuration"
|
|
87
|
+
REPORT_FILE: Final[str] = "verification-report"
|
|
88
|
+
|
|
89
|
+
#: Where the harness says which of ITS OWN endings happened, so this command
|
|
90
|
+
#: never has to read the target's exit status for it.
|
|
91
|
+
OUTCOME_FILE: Final[str] = "verification-outcome"
|
|
92
|
+
|
|
93
|
+
#: How long the harness may take, program included. A bound rather than none,
|
|
94
|
+
#: so a target that never ends is a verification that could not conclude rather
|
|
95
|
+
#: than a command that hangs for ever.
|
|
96
|
+
HARNESS_TIMEOUT: Final[float] = 900.0
|
|
97
|
+
|
|
98
|
+
#: What is said when a run concluded nothing, one sentence per reason. Each is
|
|
99
|
+
#: the prose a person reads and the `message` of the problem a machine reads,
|
|
100
|
+
#: so the two renderings cannot drift into saying different things.
|
|
101
|
+
NO_OUTCOME: Final[str] = (
|
|
102
|
+
"no findings: the verification did not run to a conclusion, so nothing about this "
|
|
103
|
+
"program is claimed either way (article 2)"
|
|
104
|
+
)
|
|
105
|
+
NOT_BEGUN: Final[str] = (
|
|
106
|
+
"no findings: the verification was refused before it began, and the harness said "
|
|
107
|
+
"why on this stream"
|
|
108
|
+
)
|
|
109
|
+
CHAIN_UNREADABLE_BEFORE_MESSAGE: Final[str] = (
|
|
110
|
+
"no findings: the chain could not be read before the program started, so nothing was "
|
|
111
|
+
"ever watched and nothing about this program is claimed"
|
|
112
|
+
)
|
|
113
|
+
CHAIN_UNREADABLE_DURING_MESSAGE: Final[str] = (
|
|
114
|
+
"no findings: the chain could not be read while the program ran, so the effect was "
|
|
115
|
+
"aborted on an unknown and nothing about this program is claimed"
|
|
116
|
+
)
|
|
117
|
+
UNREADABLE_FINDINGS: Final[str] = "the findings are not a report this client reads"
|
|
118
|
+
|
|
119
|
+
#: The sentence each of the harness's own endings is said with. Five endings and
|
|
120
|
+
#: five sentences, because « the chain could not be read » and « the gate never
|
|
121
|
+
#: opened » are different facts about different things and were one
|
|
122
|
+
#: `answer_unreadable` carrying one sentence between them — the cause surviving
|
|
123
|
+
#: only as prose the harness wrote on the error stream, which a machine reader
|
|
124
|
+
#: of the envelope never saw.
|
|
125
|
+
_SAID: Final[dict[str, str]] = {
|
|
126
|
+
harness.INVOCATION_REFUSED: NOT_BEGUN,
|
|
127
|
+
harness.CHAIN_UNREADABLE_BEFORE: CHAIN_UNREADABLE_BEFORE_MESSAGE,
|
|
128
|
+
harness.CHAIN_UNREADABLE_DURING: CHAIN_UNREADABLE_DURING_MESSAGE,
|
|
129
|
+
harness.GATE_NEVER_OPENED: harness.NEVER_STARTED,
|
|
130
|
+
harness.REPORTED: UNREADABLE_FINDINGS,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#: The code an ending answers with when THIS CLIENT is the one that knows what
|
|
134
|
+
#: happened: an invocation it refused, a gate it never saw open, findings it
|
|
135
|
+
#: cannot read. Three rows and not five — the two chain endings are a read the
|
|
136
|
+
#: contract classified, and their code travels in the outcome file rather than
|
|
137
|
+
#: being minted here. A client that re-minted one would be publishing its own
|
|
138
|
+
#: classification of a problem the control plane already named, which is
|
|
139
|
+
#: article 1's rule about deriving an answer nobody gave, applied to a problem;
|
|
140
|
+
#: the measured cost of doing it was `unreachable` for a daemon that was
|
|
141
|
+
#: reached, answered, and spoke a generation this client does not read.
|
|
142
|
+
_NO_ANSWER: Final[dict[str, ProblemCode]] = {
|
|
143
|
+
harness.INVOCATION_REFUSED: ProblemCode.REQUEST_MALFORMED,
|
|
144
|
+
harness.GATE_NEVER_OPENED: ProblemCode.ANSWER_UNREADABLE,
|
|
145
|
+
harness.REPORTED: ProblemCode.ANSWER_UNREADABLE,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#: What an ending with no code of its own and no row above answers with: the two
|
|
149
|
+
#: chain endings, when the outcome file carried no code the registry knows. « A
|
|
150
|
+
#: chain read did not answer and this client cannot say how it was classified »
|
|
151
|
+
#: is an answer it could not fully read, and it says that rather than guessing
|
|
152
|
+
#: at the transport's word for it.
|
|
153
|
+
UNCLASSIFIED: Final[ProblemCode] = ProblemCode.ANSWER_UNREADABLE
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Ending(NamedTuple):
|
|
157
|
+
"""What the harness said about its own ending, as this command reads it back.
|
|
158
|
+
|
|
159
|
+
`code` is the transport's own classification of the chain read that did not
|
|
160
|
+
answer, present only where there was one and the registry carries it.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
reason: str
|
|
164
|
+
detail: str
|
|
165
|
+
code: ProblemCode | None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
169
|
+
"""The options this verb reads, declared beside the code that reads them."""
|
|
170
|
+
parser.add_argument(
|
|
171
|
+
"--pack",
|
|
172
|
+
action="append",
|
|
173
|
+
required=True,
|
|
174
|
+
metavar="DIR",
|
|
175
|
+
help="a pack directory; repeat the option for each pack",
|
|
176
|
+
)
|
|
177
|
+
parser.add_argument("--scope", required=True, help="the scope the questions are asked in")
|
|
178
|
+
parser.add_argument("--socket", required=True, help="the path of the daemon's socket")
|
|
179
|
+
parser.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER)
|
|
180
|
+
parser.add_argument(
|
|
181
|
+
"--daemon-user",
|
|
182
|
+
default=None,
|
|
183
|
+
help="the account the daemon runs as; a system profile names it",
|
|
184
|
+
)
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"--principal",
|
|
187
|
+
default=None,
|
|
188
|
+
metavar="REF",
|
|
189
|
+
help="the principal reference to hold grants against; this account by default",
|
|
190
|
+
)
|
|
191
|
+
parser.add_argument(
|
|
192
|
+
"--ungoverned",
|
|
193
|
+
action="store_true",
|
|
194
|
+
help="run the program with nothing in front of it; the chain alone answers",
|
|
195
|
+
)
|
|
196
|
+
parser.add_argument("--json", action="store_true", help="write the envelope instead of prose")
|
|
197
|
+
parser.add_argument(
|
|
198
|
+
"target",
|
|
199
|
+
nargs="*",
|
|
200
|
+
metavar="TARGET",
|
|
201
|
+
help="after `--`: either -m MODULE [args] or SCRIPT [args]",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def run(arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO) -> int:
|
|
206
|
+
"""Prove the program, and answer with the code the verdicts imply."""
|
|
207
|
+
for named in arguments.pack:
|
|
208
|
+
try:
|
|
209
|
+
# Read and thrown away. The harness reads every pack again, because
|
|
210
|
+
# it shares no state with this process; reading them here is what
|
|
211
|
+
# makes a pack that will not read the misuse it is, rather than a
|
|
212
|
+
# verification that could not be obtained for a reason the caller
|
|
213
|
+
# would have to go and guess at.
|
|
214
|
+
manifest.read_pack(Path(named))
|
|
215
|
+
except manifest.PackInvalid as invalid:
|
|
216
|
+
# The path as it was TYPED, for the reason `commands.py` gives.
|
|
217
|
+
stderr.write(f"{named}: {invalid}\n")
|
|
218
|
+
return exit_codes.EXIT_MISUSE
|
|
219
|
+
try:
|
|
220
|
+
SocketProfile(
|
|
221
|
+
arguments.socket,
|
|
222
|
+
mode=arguments.mode,
|
|
223
|
+
daemon_user=arguments.daemon_user,
|
|
224
|
+
scope=arguments.scope,
|
|
225
|
+
)
|
|
226
|
+
except ProfileMisuse as invalid:
|
|
227
|
+
# Built and thrown away: the harness builds its own from the same
|
|
228
|
+
# members, and a profile that cannot say what it must verify is a
|
|
229
|
+
# mistake worth reporting before a second interpreter is started.
|
|
230
|
+
stderr.write(f"{invalid}\n")
|
|
231
|
+
return exit_codes.EXIT_MISUSE
|
|
232
|
+
with tempfile.TemporaryDirectory(prefix="sayfirst-verify-") as directory:
|
|
233
|
+
report = Path(directory) / REPORT_FILE
|
|
234
|
+
outcome_file = Path(directory) / OUTCOME_FILE
|
|
235
|
+
configuration = Path(directory) / CONFIGURATION_FILE
|
|
236
|
+
configuration.write_text(
|
|
237
|
+
json.dumps(
|
|
238
|
+
{
|
|
239
|
+
"packs": list(arguments.pack),
|
|
240
|
+
"socket": arguments.socket,
|
|
241
|
+
"mode": arguments.mode,
|
|
242
|
+
"daemon_user": arguments.daemon_user,
|
|
243
|
+
"scope": arguments.scope,
|
|
244
|
+
"principal": arguments.principal,
|
|
245
|
+
"governed": not arguments.ungoverned,
|
|
246
|
+
"report": str(report),
|
|
247
|
+
harness.OUTCOME_FILE_MEMBER: str(outcome_file),
|
|
248
|
+
},
|
|
249
|
+
indent=2,
|
|
250
|
+
sort_keys=True,
|
|
251
|
+
)
|
|
252
|
+
+ "\n",
|
|
253
|
+
encoding="utf-8",
|
|
254
|
+
)
|
|
255
|
+
_harness_output(_harness(configuration, list(arguments.target)), stderr)
|
|
256
|
+
document = _findings(report)
|
|
257
|
+
if document is None:
|
|
258
|
+
return _no_answer(outcome_file, arguments, stdout, stderr)
|
|
259
|
+
return _rendered(document, arguments, stdout, stderr)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _harness_output(finished: subprocess.CompletedProcess[str], stderr: TextIO) -> None:
|
|
263
|
+
"""Both of the program's streams, on the diagnostic stream, before the report:
|
|
264
|
+
what it printed comes before what was concluded about it."""
|
|
265
|
+
for written in (finished.stdout, finished.stderr):
|
|
266
|
+
if written:
|
|
267
|
+
stderr.write(written)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _no_answer(
|
|
271
|
+
outcome_file: Path, arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO
|
|
272
|
+
) -> int:
|
|
273
|
+
"""A run that wrote no findings this command reads, answered from the harness's
|
|
274
|
+
own account of how it ended rather than from the number a shell would see.
|
|
275
|
+
|
|
276
|
+
The number is the TARGET's — the program runs inside the harness's
|
|
277
|
+
interpreter — so a program calling `os._exit(64)` used to choose « refused
|
|
278
|
+
before it began » for a verification that had in fact run. Only
|
|
279
|
+
`INVOCATION_REFUSED` is the invocation's mistake, and only it answers 64,
|
|
280
|
+
which is the code `instrument run` gives the very same mistake; `_EXIT_FOR`
|
|
281
|
+
below says what the other endings answer, and why only one of them is not 4.
|
|
282
|
+
"""
|
|
283
|
+
said = _outcome(outcome_file)
|
|
284
|
+
if said is None:
|
|
285
|
+
# The harness wrote neither findings nor an outcome: it did not reach
|
|
286
|
+
# its own first statement, the interpreter never started, or the host
|
|
287
|
+
# killed it. Not a refused invocation and not a conclusion.
|
|
288
|
+
return _answer(
|
|
289
|
+
ProblemCode.ANSWER_UNREADABLE,
|
|
290
|
+
NO_OUTCOME,
|
|
291
|
+
arguments,
|
|
292
|
+
stdout,
|
|
293
|
+
stderr,
|
|
294
|
+
exit_codes.EXIT_COULD_NOT_ASK,
|
|
295
|
+
)
|
|
296
|
+
message = _SAID.get(said.reason, NO_OUTCOME)
|
|
297
|
+
return _answer(
|
|
298
|
+
# The transport's own code wherever the harness carried one, which is
|
|
299
|
+
# both chain endings; the table only for the endings this client is the
|
|
300
|
+
# one that knows about. The sentence is this command's either way — it
|
|
301
|
+
# says which of the harness's endings happened, which no problem code
|
|
302
|
+
# can say — and the detail behind it is the transport's own message.
|
|
303
|
+
said.code if said.code is not None else _NO_ANSWER.get(said.reason, UNCLASSIFIED),
|
|
304
|
+
f"{message}: {said.detail}" if said.detail else message,
|
|
305
|
+
arguments,
|
|
306
|
+
stdout,
|
|
307
|
+
stderr,
|
|
308
|
+
_EXIT_FOR.get(said.reason, exit_codes.EXIT_COULD_NOT_ASK),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
#: The code a shell reads for the two endings that are not « the verification
|
|
313
|
+
#: could not be obtained ». An invocation this client refused is the 64
|
|
314
|
+
#: `instrument run` gives the same mistake; findings that exist and will not
|
|
315
|
+
#: read are the 7 `_unreadable` answers for the very same sentence, and the one
|
|
316
|
+
#: `docs/PACKS.md` states — with the outcome file the client now KNOWS the
|
|
317
|
+
#: findings exist, which is what 7 is for. Every other ending is 4.
|
|
318
|
+
_EXIT_FOR: Final[dict[str, int]] = {
|
|
319
|
+
harness.INVOCATION_REFUSED: exit_codes.EXIT_MISUSE,
|
|
320
|
+
harness.REPORTED: exit_codes.EXIT_COULD_NOT_CHECK,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _outcome(path: Path) -> Ending | None:
|
|
325
|
+
"""Which of its own endings the harness said happened, its detail, and its code.
|
|
326
|
+
|
|
327
|
+
`None` for a file that is absent, will not read, or names an ending this
|
|
328
|
+
command does not know — all of which are « the harness said nothing this
|
|
329
|
+
command can use », which is what an absent file already means. A word
|
|
330
|
+
outside the closed vocabulary is not guessed at.
|
|
331
|
+
"""
|
|
332
|
+
try:
|
|
333
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
334
|
+
except (OSError, UnicodeDecodeError, ValueError, RecursionError):
|
|
335
|
+
return None
|
|
336
|
+
if not isinstance(document, Mapping):
|
|
337
|
+
return None
|
|
338
|
+
named = document.get("outcome")
|
|
339
|
+
detail = document.get("detail")
|
|
340
|
+
if named not in harness.OUTCOMES or not isinstance(named, str):
|
|
341
|
+
return None
|
|
342
|
+
return Ending(
|
|
343
|
+
named, detail if isinstance(detail, str) else "", _carried(document.get("problem_code"))
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _carried(value: object) -> ProblemCode | None:
|
|
348
|
+
"""The transport's own code for this ending, where the registry carries it.
|
|
349
|
+
|
|
350
|
+
A code the registry does not define is not promoted: an unknown is never
|
|
351
|
+
read as more precise than the fallback (article 3), and a code this client
|
|
352
|
+
cannot ask the registry about — for retryability, for its class — is one it
|
|
353
|
+
must not publish as the classification of anything. Asked of the registry's
|
|
354
|
+
own table rather than of a list kept here, so this file holds no codes but
|
|
355
|
+
the three it mints for itself.
|
|
356
|
+
"""
|
|
357
|
+
if not isinstance(value, str) or value not in classes_by_code():
|
|
358
|
+
return None
|
|
359
|
+
try:
|
|
360
|
+
return ProblemCode(value)
|
|
361
|
+
except ValueError: # pragma: no cover - the registry and the enum agree
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _harness(configuration: Path, target: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
|
366
|
+
"""Run the harness in an interpreter of its own, and wait for it.
|
|
367
|
+
|
|
368
|
+
`sys.executable` rather than a name looked up on the path: the harness is
|
|
369
|
+
this distribution's own module, and the interpreter that can import it is
|
|
370
|
+
the one running this command.
|
|
371
|
+
|
|
372
|
+
A timeout is not a verdict. It comes back as a run that wrote no findings,
|
|
373
|
+
which this command reports as a verification that did not conclude.
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
return subprocess.run(
|
|
377
|
+
[sys.executable, "-m", HARNESS_MODULE, str(configuration), "--", *target],
|
|
378
|
+
capture_output=True,
|
|
379
|
+
text=True,
|
|
380
|
+
timeout=HARNESS_TIMEOUT,
|
|
381
|
+
check=False,
|
|
382
|
+
)
|
|
383
|
+
except subprocess.TimeoutExpired as expired:
|
|
384
|
+
return subprocess.CompletedProcess(
|
|
385
|
+
expired.cmd,
|
|
386
|
+
returncode=exit_codes.EXIT_COULD_NOT_ASK,
|
|
387
|
+
stdout=_text(expired.stdout),
|
|
388
|
+
stderr=_text(expired.stderr)
|
|
389
|
+
+ f"the program had not ended after {HARNESS_TIMEOUT:g} seconds\n",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _text(value: object) -> str:
|
|
394
|
+
"""What a timeout kept of a stream, which may be bytes, text or nothing."""
|
|
395
|
+
if isinstance(value, bytes):
|
|
396
|
+
return value.decode(errors="replace")
|
|
397
|
+
return value if isinstance(value, str) else ""
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _findings(report: Path) -> Mapping[str, object] | None:
|
|
401
|
+
"""The harness's report, or `None` when it wrote none this command can read.
|
|
402
|
+
|
|
403
|
+
A report that is absent and a report that will not parse are the same fact
|
|
404
|
+
here — there are no findings — and neither is turned into a verdict.
|
|
405
|
+
"""
|
|
406
|
+
try:
|
|
407
|
+
document = json.loads(report.read_text(encoding="utf-8"))
|
|
408
|
+
except (OSError, UnicodeDecodeError, ValueError, RecursionError):
|
|
409
|
+
return None
|
|
410
|
+
if not isinstance(document, Mapping) or reads.too_deep(document):
|
|
411
|
+
return None
|
|
412
|
+
return document
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _rendered(
|
|
416
|
+
document: Mapping[str, object],
|
|
417
|
+
arguments: argparse.Namespace,
|
|
418
|
+
out: TextIO,
|
|
419
|
+
err: TextIO,
|
|
420
|
+
) -> int:
|
|
421
|
+
"""One line per point, then what was inspected and how the program ended.
|
|
422
|
+
|
|
423
|
+
A point that carries an `unjudged` count gets a second line of its own
|
|
424
|
+
rather than a word folded into its first. The verdict is about the events
|
|
425
|
+
the run judged and stays exactly that; the count is the effect the run
|
|
426
|
+
could NOT judge, and a reader has to be able to see both — which is why it
|
|
427
|
+
is a line and not a suffix, and why `_exit_for` reads it rather than
|
|
428
|
+
inferring it from the verdict.
|
|
429
|
+
"""
|
|
430
|
+
points = document.get("points")
|
|
431
|
+
if not isinstance(points, list):
|
|
432
|
+
return _unreadable(arguments, out, err)
|
|
433
|
+
verdicts: list[str] = []
|
|
434
|
+
unjudged: list[int] = []
|
|
435
|
+
lines: list[str] = []
|
|
436
|
+
for point in points:
|
|
437
|
+
if not isinstance(point, Mapping) or point.get("verdict") not in harness.VERDICTS:
|
|
438
|
+
return _unreadable(arguments, out, err)
|
|
439
|
+
counted = point.get(harness.UNJUDGED)
|
|
440
|
+
# Required and required to be a count. A report of this distribution's
|
|
441
|
+
# own harness always carries it; one that does not, or carries something
|
|
442
|
+
# that is not a number of events, is findings this client cannot read —
|
|
443
|
+
# which is answered as « could not check » and never as a verdict.
|
|
444
|
+
if not isinstance(counted, int) or isinstance(counted, bool) or counted < 0:
|
|
445
|
+
return _unreadable(arguments, out, err)
|
|
446
|
+
verdicts.append(str(point["verdict"]))
|
|
447
|
+
unjudged.append(counted)
|
|
448
|
+
lines.append(
|
|
449
|
+
f"{point['verdict']} {point.get('pack')} {point.get('module')}."
|
|
450
|
+
f"{point.get('attribute')} {point.get('capability')} "
|
|
451
|
+
f"events={point.get('events')}"
|
|
452
|
+
)
|
|
453
|
+
if counted:
|
|
454
|
+
lines.append(
|
|
455
|
+
f"{harness.UNJUDGED}: {counted} {point.get('pack')} {point.get('module')}."
|
|
456
|
+
f"{point.get('attribute')} {point.get('capability')} — an effect named the "
|
|
457
|
+
f"program's own start file after it had started, so this run could not "
|
|
458
|
+
f"judge it and is not a pass (article 2)"
|
|
459
|
+
)
|
|
460
|
+
if arguments.json:
|
|
461
|
+
render.write_json(
|
|
462
|
+
render.envelope(CONTRACT_GENERATION, _verification(document), result=dict(document)),
|
|
463
|
+
out,
|
|
464
|
+
)
|
|
465
|
+
else:
|
|
466
|
+
for line in lines:
|
|
467
|
+
out.write(f"{line}\n")
|
|
468
|
+
inspected = document.get("inspected")
|
|
469
|
+
named = " ".join(str(name) for name in inspected) if isinstance(inspected, list) else ""
|
|
470
|
+
out.write(f"inspected: {named or render.NOT_STATED}\n")
|
|
471
|
+
ending = document.get("target_exit")
|
|
472
|
+
out.write(f"target exit: {'none' if ending is None else ending}\n")
|
|
473
|
+
return _exit_for(verdicts, unjudged)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _verification(document: Mapping[str, object]) -> Mapping[str, object]:
|
|
477
|
+
"""What the harness verified about the far end, or that it verified nothing."""
|
|
478
|
+
verification = document.get("verification")
|
|
479
|
+
if isinstance(verification, Mapping):
|
|
480
|
+
return verification
|
|
481
|
+
return render.verification_document(None, None, False)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _unreadable(arguments: argparse.Namespace, out: TextIO, err: TextIO) -> int:
|
|
485
|
+
"""A report whose findings this command cannot read is no proof at all.
|
|
486
|
+
|
|
487
|
+
Never a verdict: the findings exist and this client could not read them,
|
|
488
|
+
which is a check that did not conclude rather than a program found wanting.
|
|
489
|
+
"""
|
|
490
|
+
return _answer(
|
|
491
|
+
ProblemCode.ANSWER_UNREADABLE,
|
|
492
|
+
UNREADABLE_FINDINGS,
|
|
493
|
+
arguments,
|
|
494
|
+
out,
|
|
495
|
+
err,
|
|
496
|
+
exit_codes.EXIT_COULD_NOT_CHECK,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _answer(
|
|
501
|
+
code: ProblemCode,
|
|
502
|
+
message: str,
|
|
503
|
+
arguments: argparse.Namespace,
|
|
504
|
+
out: TextIO,
|
|
505
|
+
err: TextIO,
|
|
506
|
+
exit_code: int,
|
|
507
|
+
) -> int:
|
|
508
|
+
"""A verification that concluded nothing, said the way this client says a problem.
|
|
509
|
+
|
|
510
|
+
Under `--json` it is the envelope every other read writes for a problem, on
|
|
511
|
+
**stdout** — the module docstring says why it is not the error stream here,
|
|
512
|
+
and nothing else is written to stdout on any of these paths. In prose it is
|
|
513
|
+
the sentence on the error stream, which is what a person reads beside the
|
|
514
|
+
program's own output. The verification document says nothing was verified
|
|
515
|
+
about a far end, because this process opened no connection: the harness's
|
|
516
|
+
own verification is carried in a report, and there is no report.
|
|
517
|
+
"""
|
|
518
|
+
document = Problem(code, message, problem_retryable(code), CONTRACT_GENERATION).to_document(
|
|
519
|
+
CONTRACT_GENERATION
|
|
520
|
+
)
|
|
521
|
+
if arguments.json:
|
|
522
|
+
render.write_json(
|
|
523
|
+
render.envelope(
|
|
524
|
+
CONTRACT_GENERATION,
|
|
525
|
+
render.verification_document(None, None, False),
|
|
526
|
+
problem=document,
|
|
527
|
+
),
|
|
528
|
+
out,
|
|
529
|
+
)
|
|
530
|
+
else:
|
|
531
|
+
err.write(f"{message}\n")
|
|
532
|
+
return exit_code
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _exit_for(verdicts: Sequence[str], unjudged: Sequence[int] = ()) -> int:
|
|
536
|
+
"""Zero only when every point was governed and nothing went unjudged.
|
|
537
|
+
|
|
538
|
+
A run with no point at all cannot conclude either: it would be the
|
|
539
|
+
verification that inspected nothing and reported green, which article 9
|
|
540
|
+
names as the thing the public gate has to fail on.
|
|
541
|
+
|
|
542
|
+
An `unjudged` count answers « could not check » and takes precedence over
|
|
543
|
+
zero, never over a finding: an effect this run could not judge leaves the
|
|
544
|
+
proof incomplete, which is what 7 says, while 6 stays reserved for a
|
|
545
|
+
finding this client actually made. A run cannot be a pass with one of
|
|
546
|
+
these outstanding — that is the whole of the rule, whose measured shape
|
|
547
|
+
before it existed was exit 0 and `governed` over an effect nobody decided.
|
|
548
|
+
"""
|
|
549
|
+
if harness.UNGOVERNED in verdicts:
|
|
550
|
+
return exit_codes.EXIT_CHECK_FAILED
|
|
551
|
+
if not verdicts or harness.NOT_EXERCISED in verdicts or any(unjudged):
|
|
552
|
+
return exit_codes.EXIT_COULD_NOT_CHECK
|
|
553
|
+
return exit_codes.EXIT_ALLOW
|
sayfirst_cli/main.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The `sayfirst` command tree, for asking and reading what was decided.
|
|
3
|
+
|
|
4
|
+
The constitution names `sayfirst` as one command with subcommands, and one
|
|
5
|
+
console script points at one module. This distribution is the product client
|
|
6
|
+
(article 14), so it installs the script and dispatches; a later slice adds a
|
|
7
|
+
subcommand by adding it to the dispatch, never by claiming a second script.
|
|
8
|
+
|
|
9
|
+
`--help` lists exactly the commands this distribution answers, because a help
|
|
10
|
+
text that advertises an absent command is a claim without evidence (article 2).
|
|
11
|
+
It is answered from the table below and imports nothing, so the claim can be
|
|
12
|
+
read on a machine where the control plane's contract distribution is not
|
|
13
|
+
installed — which is where it is most likely to be asked.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import importlib
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
from typing import Final, TextIO
|
|
23
|
+
|
|
24
|
+
#: The subcommands this distribution answers, each with the module that owns its
|
|
25
|
+
#: parser and its exit codes, and whose `main` is its entry point. A slice adds
|
|
26
|
+
#: a line here.
|
|
27
|
+
#
|
|
28
|
+
# Named rather than imported, because `--help` is a claim about what this tool
|
|
29
|
+
# answers and answering it must not need the control plane's contract
|
|
30
|
+
# distribution installed: importing every subcommand up front made
|
|
31
|
+
# `python -m sayfirst_cli.main --help` a traceback wherever the contract is
|
|
32
|
+
# absent, and turned a reduced run's honest « not run » into failures (article
|
|
33
|
+
# 2's rule about an absence rendered as a negative fact, inverted).
|
|
34
|
+
COMMANDS: Final[dict[str, str]] = {
|
|
35
|
+
"ask": "sayfirst_cli.ask",
|
|
36
|
+
"trace": "sayfirst_cli.trace",
|
|
37
|
+
"explain": "sayfirst_cli.explain",
|
|
38
|
+
"evidence": "sayfirst_cli.evidence",
|
|
39
|
+
"approvals": "sayfirst_cli.approvals",
|
|
40
|
+
"instrument": "sayfirst_cli.instrument.commands",
|
|
41
|
+
"packs": "sayfirst_cli.packs_cmd",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def entry_point(name: str) -> Callable[..., int]:
|
|
46
|
+
"""The `main` of one subcommand, imported when it is about to run and not before."""
|
|
47
|
+
return importlib.import_module(COMMANDS[name]).main
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
51
|
+
parser = argparse.ArgumentParser(
|
|
52
|
+
prog="sayfirst",
|
|
53
|
+
description=("Ask the sayfirst control plane before an effect, and read what it decided."),
|
|
54
|
+
)
|
|
55
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
56
|
+
for name in COMMANDS:
|
|
57
|
+
# Declared so `--help` names every subcommand this tool answers. Each
|
|
58
|
+
# subcommand's own parser reads its arguments, after the dispatch below.
|
|
59
|
+
commands.add_parser(name, add_help=False)
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(
|
|
64
|
+
argv: Sequence[str] | None = None, *, out: TextIO | None = None, err: TextIO | None = None
|
|
65
|
+
) -> int:
|
|
66
|
+
forwarded = list(sys.argv[1:] if argv is None else argv)
|
|
67
|
+
if forwarded and forwarded[0] in COMMANDS:
|
|
68
|
+
return entry_point(forwarded[0])(forwarded[1:], out=out, err=err)
|
|
69
|
+
build_parser().parse_args(forwarded)
|
|
70
|
+
raise AssertionError("argparse accepted a command that has no entry point")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run() -> None:
|
|
74
|
+
raise SystemExit(main())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__": # pragma: no cover - exercised as a subprocess
|
|
78
|
+
run()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Where the convenience packs this distribution ships live (article 9).
|
|
3
|
+
|
|
4
|
+
A directory of pack directories and nothing else: no loader here reads any of
|
|
5
|
+
them by name. `sayfirst instrument run --pack DIR` designates one by its path,
|
|
6
|
+
and `sayfirst packs list` reads this directory only to print that path back —
|
|
7
|
+
this file exists so `importlib.resources` resolves the directory as an
|
|
8
|
+
ordinary subpackage instead of a namespace package assembled from wherever it
|
|
9
|
+
is found on the path, which is one fewer thing a reader has to reason about.
|
|
10
|
+
"""
|