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,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
|