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.
@@ -0,0 +1,417 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Hand a program to the interpreter with the boundary already in front of it.
3
+
4
+ The order is the whole of it: the target, the connection, the boundary, the
5
+ engine, and only then the program. Whatever the program loads afterwards
6
+ arrives instrumented; a reference it bound before this ran does not, and that
7
+ limit belongs to the verifier rather than to a claim made here.
8
+
9
+ ## The line between this client's failures and the program's
10
+
11
+ It is drawn at the hand-off, and it is the only line that matters here.
12
+
13
+ **Before the hand-off every failure is this client's**, because the program has
14
+ not started: a pack that does not read, a point the engine refuses, an
15
+ execution module that will not load or whose wrapper cannot be made, a script
16
+ that is not there, a name `-m` cannot find. Each of those is a mistake in the
17
+ invocation, each is raised as `LaunchMisuse`, and the caller renders it as the
18
+ misuse it is. None of them is an outcome — no question was ever put — so
19
+ reporting one with a code that means « denied » would be the invented verdict
20
+ articles 1 and 2 forbid between them.
21
+
22
+ **After the hand-off every failure is the program's**, and nothing is
23
+ translated. A refusal raised at the boundary is the program's exception to
24
+ catch, and a program that catches none of them ends the way any Python program
25
+ ending on an exception ends. `SystemExit` is read exactly as the interpreter
26
+ reads it. Exactly one failure crosses the line, and it is named where it
27
+ happens: a point whose module is imported LATER, naming an attribute that
28
+ module has not got, is discovered inside the program's own import — `engine.py`
29
+ says why it cannot be found any sooner.
30
+
31
+ The program keeps this process's own streams, because they are the streams it
32
+ would have had, and it gets the `sys.path` entry and the `sys.argv` the
33
+ interpreter would have given it for the form it was named in. The two streams
34
+ this function is given are the launcher's; it writes to one of them only where
35
+ the interpreter itself would write.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import importlib.util
41
+ import os
42
+ import pwd
43
+ import runpy
44
+ import sys
45
+ from collections.abc import Callable, Sequence
46
+ from dataclasses import dataclass
47
+ from importlib.machinery import ModuleSpec
48
+ from pathlib import Path
49
+ from typing import Final, TextIO
50
+
51
+ from sayfirst_boundary import Boundary
52
+ from sayfirst_contract.binding.http_unix_socket.client import SocketClient
53
+ from sayfirst_contract.transport.socket_client import SocketProfile, expected_principal_uid
54
+
55
+ from .engine import Engine, EngineMisuse
56
+ from .manifest import Pack
57
+
58
+ #: How long one question may take. Longer than a read's, because a governed
59
+ #: program's first act waits for a decision with nobody watching the terminal.
60
+ ASK_TIMEOUT: Final[float] = 10.0
61
+
62
+ #: The spelling that names a module rather than a file, taken from the
63
+ #: interpreter rather than invented, so that one form is typed one way.
64
+ MODULE_FORM: Final[str] = "-m"
65
+
66
+ #: What a caller is told at the last instant before the hand-off: which files
67
+ #: are the program's OWN, which the hand-off reads to reach them, and which of
68
+ #: the second the import system derives further names from. Nothing else is
69
+ #: promised, and nothing is promised about what happens afterwards — see
70
+ #: `_hand_off`.
71
+ #:
72
+ #: Three facts rather than one, because they answer different questions. The
73
+ #: first is a moment: everything the interpreter does before it executes one of
74
+ #: the program's own files is this launcher locating and reading a program, and
75
+ #: no path names all of it. The second is a set of paths and is true whenever
76
+ #: it fires. The third exists because a bytecode cache is written under a name
77
+ #: the import system invents from the cache's own, which nothing here can know
78
+ #: in advance and only a derivation can match. An earlier revision held a
79
+ #: module `runpy` imports on first use, so that import would land before the
80
+ #: caller was told; the moment makes that unnecessary, because the import is
81
+ #: before it.
82
+ Starting = Callable[[tuple[str, ...], tuple[str, ...], tuple[str, ...]], None]
83
+
84
+
85
+ class LaunchMisuse(ValueError):
86
+ """This invocation cannot be launched, and the program has not started.
87
+
88
+ Raised only BEFORE the hand-off, which is the whole of its meaning: what
89
+ comes after belongs to the program. The caller renders it as a misuse and
90
+ never as an outcome, because there is no answer to report.
91
+
92
+ Its sentences name « this launcher » and never a verb. Two verbs hand a
93
+ program over through this file now, and a sentence that named one of them
94
+ would tell half the readers to go and fix a command they never typed.
95
+ """
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Program:
100
+ """The target as the interpreter would have received it.
101
+
102
+ `module` is set for the `-m` form and `None` otherwise, where `argv[0]` is
103
+ the script. One field rather than two, so there is no third state.
104
+ """
105
+
106
+ argv: tuple[str, ...]
107
+ #: What the interpreter puts at the head of the import path for this form.
108
+ first_on_the_path: str
109
+ module: str | None = None
110
+
111
+
112
+ def run(
113
+ packs: Sequence[Pack],
114
+ profile: SocketProfile,
115
+ target: Sequence[str],
116
+ *,
117
+ principal: str | None = None,
118
+ out: TextIO,
119
+ err: TextIO,
120
+ starting: Starting | None = None,
121
+ ) -> int:
122
+ """Install the boundary in front of the program, then run the program.
123
+
124
+ The client is the one that can HOLD what it is granted: article 10 binds a
125
+ grant to the connection the answer arrived on, and only the binding's own
126
+ client keeps that connection open. A transport that closes in its `finally`
127
+ answers the question correctly and kills the grant in the same call.
128
+
129
+ The target is resolved before the connection is arranged, so a mistyped
130
+ program name is reported without anything being asked of the daemon.
131
+
132
+ The profile's scope reaches the engine, which supplies it to every ask a
133
+ pack makes: a scope somebody typed and a scope nobody typed must not be the
134
+ same question on the wire.
135
+
136
+ `starting` is called once, at the last instant before the program runs and
137
+ after everything this launcher does for itself — the engine's load of each
138
+ pack's execution module included. A caller that has to tell its own work
139
+ from the program's cannot draw that line from outside.
140
+ """
141
+ program = _the_program(list(target))
142
+ client = SocketClient(
143
+ socket_path=Path(profile.socket_path),
144
+ expected_uid=expected_principal_uid(profile),
145
+ timeout=ASK_TIMEOUT,
146
+ )
147
+ boundary = Boundary(client=client, principal_reference=principal or _this_account())
148
+ try:
149
+ # The engine is deliberately not kept: the interposition's scope is this
150
+ # process, and `uninstall` exists for a caller that wants it back
151
+ # sooner — not for this one, which has nothing left to do afterwards.
152
+ Engine().install(list(packs), boundary, scope=profile.scope)
153
+ except EngineMisuse as refused:
154
+ # The pack was designated on the command line, so a pack the engine
155
+ # refuses is this invocation's mistake and not the program's failure.
156
+ raise LaunchMisuse(str(refused)) from refused
157
+ return _hand_off(program, err, starting)
158
+
159
+
160
+ def hand_over(target: Sequence[str], *, err: TextIO, starting: Starting | None = None) -> int:
161
+ """Run the program with nothing in front of it, as the interpreter would.
162
+
163
+ `run` is the governed hand-off; this is the same hand-off without the
164
+ boundary. The verifier needs it, because what a proof measures has to be
165
+ the program the interpreter would have run — every rule about `sys.path`,
166
+ `sys.argv` and the reading of `SystemExit` is the one `run` uses, since it
167
+ is the same code and not a second reading of the target written beside it.
168
+
169
+ Nothing is installed and no connection is arranged, so a target that names
170
+ no program is still `LaunchMisuse` and still arrives before anything runs.
171
+ """
172
+ return _hand_off(_the_program(list(target)), err, starting)
173
+
174
+
175
+ def _this_account() -> str:
176
+ """Who this process is, in the one spelling the boundary uses for a person.
177
+
178
+ Never sent as a credential: the daemon establishes identity from the peer of
179
+ the connection (article 6). This is what the holder compares a grant's own
180
+ condition against, locally.
181
+ """
182
+ return f"user:{pwd.getpwuid(os.geteuid()).pw_name}"
183
+
184
+
185
+ def _the_program(target: list[str]) -> Program:
186
+ """The target, resolved into what the interpreter would have been given.
187
+
188
+ A script is checked here, because the check needs nothing but the file
189
+ system. A name given to `-m` is checked in `_hand_off` instead, once the
190
+ import path is the one the program will have: looked up on any other path it
191
+ would be a different question with a different answer.
192
+ """
193
+ if not target:
194
+ raise LaunchMisuse(
195
+ "this launcher needs a program to run after `--`: either `-m MODULE` and "
196
+ "its arguments, or a script and its arguments"
197
+ )
198
+ if target[0] == MODULE_FORM:
199
+ if len(target) < 2:
200
+ raise LaunchMisuse(
201
+ f"this launcher was given `{MODULE_FORM}` with no module name after it"
202
+ )
203
+ named = target[1]
204
+ return Program(argv=(named, *target[2:]), first_on_the_path=os.getcwd(), module=named)
205
+ script = target[0]
206
+ if not Path(script).is_file():
207
+ raise LaunchMisuse(
208
+ f"this launcher found no file to run at {script!r}: a target that does not "
209
+ f"begin `{MODULE_FORM}` is a script this process can read"
210
+ )
211
+ return Program(argv=(script, *target[1:]), first_on_the_path=str(Path(script).resolve().parent))
212
+
213
+
214
+ def _hand_off(program: Program, err: TextIO, starting: Starting | None = None) -> int:
215
+ """Run the program as the main module, and read its ending as the interpreter does.
216
+
217
+ `sys.path` and `sys.argv` are set to what the interpreter would have given
218
+ the program for the form it was named in — the script's own directory, or
219
+ the working directory for `-m` — because a governed program is the same
220
+ program, and one that cannot import the module beside it is not being
221
+ governed, it is being broken. Both are put back afterwards, so nothing here
222
+ outlives the run.
223
+
224
+ `starting` is called after the name has been resolved and the import path
225
+ arranged, and immediately before the interpreter is handed the program. It
226
+ is told three things: every file that is the program's OWN — the script, or
227
+ the module's file, or, for a package, BOTH its `__init__` and its
228
+ `__main__` — and every file the hand-off reads to reach them, and which of
229
+ those are bytecode caches. The cache locations are asked of
230
+ `importlib.util.cache_from_source` rather than spelled here.
231
+
232
+ A package's `__init__` is the program's own code and it runs first, which
233
+ is why it is named as such and not merely as a file read on the way: a
234
+ caller told to start watching at the `__main__` would miss every effect the
235
+ `__init__` made. Measured before it was: a package whose `__init__` spawned
236
+ and whose `__main__` spawned, against a chain holding one decision, was
237
+ reported as governed with both processes run.
238
+
239
+ What no path can name is what the import system DERIVES twice over: it
240
+ writes a cache by creating a file named after that cache plus a number of
241
+ its own, and it writes through a bare descriptor that names no file at all.
242
+ The number is why the caches are named apart from the rest; the descriptor
243
+ is why the moment is reported as well as the paths.
244
+ """
245
+ restored_argv, restored_path = list(sys.argv), list(sys.path)
246
+ # Assigned as a slice, so a path with nothing on it is added to rather than
247
+ # subscripted. Every import this launcher needs is already done.
248
+ sys.path[:1] = [program.first_on_the_path]
249
+ try:
250
+ if program.module is not None:
251
+ origins = _refuse_a_name_that_names_no_module(program.module, starting)
252
+ sys.argv = list(program.argv)
253
+ # Every origin is the program's own: for a package, the `__init__`
254
+ # that runs on the way to the `__main__` is its code too.
255
+ _starting(starting, origins, *_the_hand_offs_own_files(origins))
256
+ runpy.run_module(program.module, run_name="__main__", alter_sys=True)
257
+ else:
258
+ sys.argv = list(program.argv)
259
+ _starting(starting, (program.argv[0],), (program.argv[0],), ())
260
+ runpy.run_path(program.argv[0], run_name="__main__")
261
+ except SystemExit as ending:
262
+ return _ending(ending.code, err)
263
+ finally:
264
+ sys.argv = restored_argv
265
+ sys.path[:] = restored_path
266
+ return 0
267
+
268
+
269
+ def _starting(
270
+ starting: Starting | None,
271
+ starts: Sequence[str],
272
+ own: Sequence[str],
273
+ caches: Sequence[str],
274
+ ) -> None:
275
+ """Say which files are the program's own, and which the hand-off's."""
276
+ if starting is not None:
277
+ starting(tuple(starts), tuple(own), tuple(caches))
278
+
279
+
280
+ def _the_hand_offs_own_files(origins: Sequence[str]) -> tuple[tuple[str, ...], tuple[str, ...]]:
281
+ """Each source the hand-off reads, and where the import system keeps its cache.
282
+
283
+ Answered as two sets, because the caller does two different things with
284
+ them: a source is matched by its own name, while a cache is matched by its
285
+ name AND by the name the import system derives from it to write through.
286
+
287
+ Asked of the import system rather than spelled: a cache path computed by
288
+ hand would be right for one interpreter, one optimisation level and one
289
+ layout, and wrong the first time any of the three changed.
290
+
291
+ A source the import system has no cache location for contributes only
292
+ itself — there is then no cache to name, which is an answer and not a
293
+ failure.
294
+ """
295
+ own: list[str] = []
296
+ caches: list[str] = []
297
+ for origin in origins:
298
+ own.append(origin)
299
+ try:
300
+ cache = importlib.util.cache_from_source(origin)
301
+ except (NotImplementedError, ValueError):
302
+ continue
303
+ own.append(cache)
304
+ caches.append(cache)
305
+ return tuple(own), tuple(caches)
306
+
307
+
308
+ def _refuse_a_name_that_names_no_module(
309
+ named: str, starting: Starting | None = None
310
+ ) -> tuple[str, ...]:
311
+ """Refuse a `-m` name this interpreter cannot find, before `runpy` reaches it.
312
+
313
+ `runpy` reports the same absence as an `ImportError` out of the middle of
314
+ this launcher, which reaches a shell as a traceback and exit 1 — this
315
+ client's published code for « denied ». A mistyped name is not a denial, so
316
+ it is found here and said as the misuse it is.
317
+
318
+ A lookup that RAISES is read exactly like one that finds nothing: a name
319
+ whose parent cannot even be consulted is not a program this run can hand
320
+ over, whatever the reason it could not be consulted.
321
+
322
+ It answers with the files the lookup found, because the lookup is the only
323
+ place they are known: `_hand_off` passes them on and nothing recomputes
324
+ them on a different import path, which would be a different question with
325
+ a different answer.
326
+
327
+ **Resolved one segment at a time, from the top, because looking a dotted
328
+ name up RUNS the packages above it.** `find_spec('a.b')` executes `a`'s own
329
+ `__init__`, and `find_spec('a.__main__')` executes `a`'s: that is the
330
+ program's code and not this launcher's preparation. So `starting` is told
331
+ what is known before every step that can run any of it, and told again
332
+ afterwards — the second call closes a watcher's gate over the part that is
333
+ this launcher's work again, which is why the two are not one. Measured
334
+ before this existed: a package whose `__init__` spawned and whose
335
+ `__main__` spawned, against a chain holding one decision, was reported as
336
+ `governed` with both processes run.
337
+
338
+ **The window this leaves, measured rather than stated.** Telling the caller
339
+ again is also what SHUTS its gate, because the file of the next segment is
340
+ not yet in the set it excludes — so between the end of one `__init__` and
341
+ the first statement of what comes after it, everything is attributed to the
342
+ hand-off: not judged, not counted, not aborted. What is inside the stretch
343
+ is this launcher's own lookup of the next segment and the import of its
344
+ file, plus anything the program's own code runs during them — a finder the
345
+ `__init__` installed, or a thread it started. It is entered once per
346
+ remaining segment and nowhere else.
347
+
348
+ Its width, measured on one machine with
349
+ `tests/test_instrument_verify.py::test_the_between_segments_window_is_measured_and_named`'s
350
+ own shape — a two-segment `-m` name, cold, thirty runs — was 57 to 88
351
+ microseconds, median 61. The number is a machine's and the shape is not,
352
+ which is why the test asserts the second and this paragraph records the
353
+ first.
354
+
355
+ It is measured rather than guarded on purpose. Keeping the gate open across
356
+ the remaining lookups would judge this launcher's own reading of files it
357
+ has not yet been able to name, and telling a program's effect from the
358
+ hand-off's inside the stretch needs the watch to know about threads it did
359
+ not start — which it cannot. The direction it fails in is the unsafe one:
360
+ an effect in the stretch is dropped rather than counted, so it leaves no
361
+ `unjudged` behind it. Recorded here, and named in the report as a limit, so
362
+ that it is a known cost rather than a surprise.
363
+ """
364
+ origins: list[str] = []
365
+ parts = named.split(".")
366
+ for depth, _ in enumerate(parts):
367
+ if depth:
368
+ # Looking this segment up runs the package above it.
369
+ _starting(starting, tuple(origins), *_the_hand_offs_own_files(origins))
370
+ found = _the_spec_of(".".join(parts[: depth + 1]), named)
371
+ if isinstance(found.origin, str):
372
+ origins.append(found.origin)
373
+ if found.submodule_search_locations is not None:
374
+ # A package runs through its `__main__`; one without is not a program,
375
+ # and `runpy` would say so as a traceback from the middle of this
376
+ # launcher. Looking it up runs the package's own `__init__`.
377
+ _starting(starting, tuple(origins), *_the_hand_offs_own_files(origins))
378
+ entry = _the_spec_of(f"{named}.__main__", named, in_the_package=named)
379
+ if isinstance(entry.origin, str):
380
+ origins.append(entry.origin)
381
+ return tuple(origins)
382
+
383
+
384
+ def _the_spec_of(prefix: str, named: str, *, in_the_package: str | None = None) -> ModuleSpec:
385
+ """What the interpreter finds for one name, or the misuse that it finds nothing."""
386
+ try:
387
+ found = importlib.util.find_spec(prefix)
388
+ except (ImportError, AttributeError, TypeError, ValueError) as unusable:
389
+ raise LaunchMisuse(
390
+ f"this launcher cannot look up {prefix!r} to run it: {unusable}"
391
+ ) from unusable
392
+ if found is None:
393
+ if in_the_package is not None:
394
+ raise LaunchMisuse(
395
+ f"this launcher found no `__main__` in the package {in_the_package!r}: a package "
396
+ f"runs through its `__main__` module, and this one has none"
397
+ )
398
+ raise LaunchMisuse(
399
+ f"this launcher found no module named {named!r} to run: `{MODULE_FORM}` names "
400
+ f"a module on the import path the program would have had"
401
+ )
402
+ return found
403
+
404
+
405
+ def _ending(code: object, err: TextIO) -> int:
406
+ """`SystemExit` as the interpreter reads it: absent is zero, a number is itself.
407
+
408
+ Anything else the interpreter prints and exits 1 on, so that is what happens
409
+ here too. It is the one line this launcher writes to a stream of its own, and
410
+ it writes what the program said rather than a sentence about it.
411
+ """
412
+ if code is None:
413
+ return 0
414
+ if isinstance(code, int):
415
+ return int(code)
416
+ err.write(f"{code}\n")
417
+ return 1