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,409 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""What a pack declares, read strictly, because the engine trusts nothing else.
|
|
3
|
+
|
|
4
|
+
A pack is code a person chose to run and named on the command line (article 9).
|
|
5
|
+
Its manifest is the whole interface between that choice and the engine: the
|
|
6
|
+
engine special-cases nothing and carries no vocabulary of its own, so every
|
|
7
|
+
rule about what may be declared is enforced here, once, before anything is
|
|
8
|
+
wrapped.
|
|
9
|
+
|
|
10
|
+
What a pack directory must carry is the three files named below, and what it
|
|
11
|
+
may carry besides them is anything: the reader requires the three and ignores
|
|
12
|
+
the rest. Not laxity — a directory Python has imported anything from holds a
|
|
13
|
+
bytecode cache nobody put there, and a reader that refused a pack over one
|
|
14
|
+
would refuse for a reason that has nothing to do with what the pack declares.
|
|
15
|
+
|
|
16
|
+
Every refusal names the member and the rule it broke. A manifest rejected
|
|
17
|
+
without saying which line is wrong leaves the reader to guess, and a pack
|
|
18
|
+
silently not installed is the absence article 2 forbids rendering as a healthy
|
|
19
|
+
state — the program would run ungoverned and nothing would have said so.
|
|
20
|
+
|
|
21
|
+
**A capability is judged by two rules, and only one of them is this client's.**
|
|
22
|
+
|
|
23
|
+
The first is the contract's: what a capability may be SPELLED like is published
|
|
24
|
+
in the ask-request schema, because that is the document the daemon validates a
|
|
25
|
+
question against, and this file reads the rule off it through the contract's own
|
|
26
|
+
artefact loader rather than keeping a copy. A copy was kept here once, and it
|
|
27
|
+
was wider than the original in two ways at once — it admitted an underscore and
|
|
28
|
+
it demanded two segments — so a pack this distribution shipped declared a
|
|
29
|
+
capability the plane would refuse as malformed, and nothing in this repository
|
|
30
|
+
could see it: the pack read, the engine installed it, and the ask was rejected
|
|
31
|
+
inside somebody's program. A rule the contract owns is read, never restated; if
|
|
32
|
+
the schema stops carrying it, that is said as a refusal naming the schema
|
|
33
|
+
rather than guessed at.
|
|
34
|
+
|
|
35
|
+
The second is this client's own, and it is about naming rather than spelling: a
|
|
36
|
+
capability is a kind of effect and never a library name (article 4), so no
|
|
37
|
+
segment of a capability may be a segment of the module path it is declared on,
|
|
38
|
+
nor the attribute it wraps, compared with the case folded on both sides. « The
|
|
39
|
+
effect is whatever this library is called » cannot be declared at all. That rule
|
|
40
|
+
used to compare whole dotted spellings, and the review that found it said why
|
|
41
|
+
that could never work: a capability spelled as a one-segment module's own name,
|
|
42
|
+
or as the one attribute it wraps, was accepted whenever the module was a single
|
|
43
|
+
segment. It is segments now, and a capability that borrows the *last* segment of
|
|
44
|
+
a deeper module path is refused too — a pack author who wants that effect names
|
|
45
|
+
what HAPPENS (`parcel.dispatch` over a courier library's own name), which is the
|
|
46
|
+
rule working rather than the rule being awkward.
|
|
47
|
+
|
|
48
|
+
The two are independent and both are needed: the contract's shape admits a
|
|
49
|
+
single bare word, and the client's rule is what stops that word being the
|
|
50
|
+
library's own name.
|
|
51
|
+
|
|
52
|
+
(No paragraph in this FILE — not this one, not a docstring further down —
|
|
53
|
+
names a module or an attribute a shipped pack declares, on purpose:
|
|
54
|
+
`tests/test_engine_is_agnostic.py` binds this file too, and scans prose for
|
|
55
|
+
both halves of a shipped pack's vocabulary, because an illustration that
|
|
56
|
+
happened to spell out a pack's own attribute would be exactly the special case
|
|
57
|
+
article 4 forbids, arriving as prose instead of as code. Every example here is
|
|
58
|
+
invented, and the rule is the file's rather than the paragraph's — that
|
|
59
|
+
distinction is not pedantry: it was first written as a paragraph's promise, and
|
|
60
|
+
a review then found a real attribute spelled out eight screens below it.)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import keyword
|
|
66
|
+
import re
|
|
67
|
+
import tomllib
|
|
68
|
+
from dataclasses import dataclass
|
|
69
|
+
from datetime import date
|
|
70
|
+
from functools import cache
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
from typing import Final, NamedTuple
|
|
73
|
+
|
|
74
|
+
from sayfirst_contract.artifacts import domain_schema
|
|
75
|
+
|
|
76
|
+
#: The file that declares the pack.
|
|
77
|
+
MANIFEST_FILE: Final[str] = "pack.toml"
|
|
78
|
+
|
|
79
|
+
#: The local execution module, which produces the wrapper for a point.
|
|
80
|
+
EXECUTION_MODULE: Final[str] = "interpose.py"
|
|
81
|
+
|
|
82
|
+
#: The classification note: what kind of pack this is, why, and from when.
|
|
83
|
+
CLASSIFICATION_NOTE: Final[str] = "NOTE.md"
|
|
84
|
+
|
|
85
|
+
#: What a pack directory must carry (article 9). A directory missing any of the
|
|
86
|
+
#: three is not a pack, and the engine installs nothing from it. A directory
|
|
87
|
+
#: carrying more than the three still is one: `read_pack` requires these and
|
|
88
|
+
#: reads nothing else in the directory.
|
|
89
|
+
REQUIRED_FILES: Final[tuple[str, ...]] = (MANIFEST_FILE, EXECUTION_MODULE, CLASSIFICATION_NOTE)
|
|
90
|
+
|
|
91
|
+
#: The one classification this format admits. Article 9 ships convenience packs
|
|
92
|
+
#: — the ones a competent engineer would rebuild in a day — and nothing else.
|
|
93
|
+
CONVENIENCE: Final[str] = "convenience"
|
|
94
|
+
|
|
95
|
+
#: The members a pack declares about itself, and the members a point declares.
|
|
96
|
+
#: Read through a table seeded with `None`, so that « declared as the wrong
|
|
97
|
+
#: thing » and « not declared at all » take the same path to the same refusal —
|
|
98
|
+
#: two paths to one refusal is how one of them comes to be forgotten.
|
|
99
|
+
PACK_MEMBERS: Final[tuple[str, ...]] = ("name", "classification", "classified_on")
|
|
100
|
+
POINT_MEMBERS: Final[tuple[str, ...]] = (
|
|
101
|
+
"module",
|
|
102
|
+
"attribute",
|
|
103
|
+
"capability",
|
|
104
|
+
"digest",
|
|
105
|
+
"audit_event",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
_PACK_NAME: Final = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
109
|
+
|
|
110
|
+
#: The published schema whose `capability` member is the rule a capability is
|
|
111
|
+
#: judged by. Named here because it is the document the DAEMON validates a
|
|
112
|
+
#: question against: a spelling it refuses is a question that cannot be asked,
|
|
113
|
+
#: whatever this client thinks of it.
|
|
114
|
+
ASK_REQUEST_SCHEMA: Final[str] = "decision-ask-request"
|
|
115
|
+
|
|
116
|
+
#: The member of that schema this file reads.
|
|
117
|
+
CAPABILITY_MEMBER: Final[str] = "capability"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class CapabilityRule(NamedTuple):
|
|
121
|
+
"""The contract's rule for a capability spelling, as its own schema states it.
|
|
122
|
+
|
|
123
|
+
`stated` is the pattern's own source text, carried so that a refusal can
|
|
124
|
+
quote the rule rather than paraphrase it: a reader told « lower case,
|
|
125
|
+
dotted » has to trust the paraphrase, while a reader shown the pattern can
|
|
126
|
+
check their spelling against the same thing the daemon will.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
shape: re.Pattern[str]
|
|
130
|
+
stated: str
|
|
131
|
+
shortest: int
|
|
132
|
+
longest: int
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@cache
|
|
136
|
+
def capability_rule() -> CapabilityRule:
|
|
137
|
+
"""The rule a capability must satisfy, read off the contract's published schema.
|
|
138
|
+
|
|
139
|
+
Read through the contract's own artefact loader, once per process, and never
|
|
140
|
+
transcribed: a regular expression copied into this file is a second rule
|
|
141
|
+
that agrees with the first on the day it is written and drifts afterwards —
|
|
142
|
+
which is exactly what happened. The copy admitted an underscore and demanded
|
|
143
|
+
two segments, so a pack this distribution ships declared a capability the
|
|
144
|
+
plane refuses as malformed, and nothing here could see it.
|
|
145
|
+
|
|
146
|
+
A schema carrying no pattern for the member is a contract this client cannot
|
|
147
|
+
read a capability against, and it says so as a `PackInvalid` naming the
|
|
148
|
+
schema and the member; a contract distribution that ships no such schema at
|
|
149
|
+
all is outside what this reader guards and escapes as itself. That is the
|
|
150
|
+
honest ending for the member: no pack is installed, and
|
|
151
|
+
the code the caller sees already means « the invocation was wrong, or
|
|
152
|
+
something it named cannot be read » — where an escaping exception would
|
|
153
|
+
reach a shell as exit 1, this client's code for « denied ».
|
|
154
|
+
"""
|
|
155
|
+
properties = domain_schema(ASK_REQUEST_SCHEMA).get("properties")
|
|
156
|
+
member = properties.get(CAPABILITY_MEMBER) if isinstance(properties, dict) else None
|
|
157
|
+
stated = member.get("pattern") if isinstance(member, dict) else None
|
|
158
|
+
if not isinstance(member, dict) or not isinstance(stated, str):
|
|
159
|
+
raise PackInvalid(
|
|
160
|
+
f"the published {ASK_REQUEST_SCHEMA} schema states no pattern for its "
|
|
161
|
+
f"{CAPABILITY_MEMBER} member, so this client cannot read a capability "
|
|
162
|
+
f"against the rule the control plane publishes"
|
|
163
|
+
)
|
|
164
|
+
shortest = member.get("minLength")
|
|
165
|
+
longest = member.get("maxLength")
|
|
166
|
+
return CapabilityRule(
|
|
167
|
+
re.compile(stated),
|
|
168
|
+
stated,
|
|
169
|
+
shortest if isinstance(shortest, int) else 1,
|
|
170
|
+
longest if isinstance(longest, int) else 0,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class PackInvalid(ValueError):
|
|
175
|
+
"""A manifest that will not be installed, carrying the member and the rule."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class Point:
|
|
180
|
+
"""One interposition: what to wrap, and as which kind of effect."""
|
|
181
|
+
|
|
182
|
+
module: str
|
|
183
|
+
attribute: str
|
|
184
|
+
capability: str
|
|
185
|
+
digest: tuple[str, ...]
|
|
186
|
+
audit_event: str
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class Pack:
|
|
191
|
+
"""A pack as read off its own directory, and the directory it was read from."""
|
|
192
|
+
|
|
193
|
+
name: str
|
|
194
|
+
classification: str
|
|
195
|
+
classified_on: date
|
|
196
|
+
points: tuple[Point, ...]
|
|
197
|
+
directory: Path
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def execution_module(self) -> Path:
|
|
201
|
+
"""Where the wrapper comes from. The engine loads it by path, never by name."""
|
|
202
|
+
return self.directory / EXECUTION_MODULE
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def read_pack(path: Path) -> Pack:
|
|
206
|
+
"""Read one pack directory, or refuse it by naming the member and the rule.
|
|
207
|
+
|
|
208
|
+
The three required files are required; the directory's other entries, if it
|
|
209
|
+
has any, are not read and not refused.
|
|
210
|
+
"""
|
|
211
|
+
for required in REQUIRED_FILES:
|
|
212
|
+
if not (path / required).is_file():
|
|
213
|
+
raise PackInvalid(
|
|
214
|
+
f"{required} is missing from {path}: a pack is a manifest, a local "
|
|
215
|
+
f"execution module and a classification note, and a directory that is "
|
|
216
|
+
f"not all three declares nothing this engine will install"
|
|
217
|
+
)
|
|
218
|
+
try:
|
|
219
|
+
document = tomllib.loads((path / MANIFEST_FILE).read_text(encoding="utf-8"))
|
|
220
|
+
except RecursionError as too_deep:
|
|
221
|
+
# A document nested deeper than this reader's parser descends. Caught
|
|
222
|
+
# beside the three below rather than left to the interpreter, which
|
|
223
|
+
# renders it as a traceback and exits 1 — this client's published code
|
|
224
|
+
# for « the control plane answered deny », for a file the invocation
|
|
225
|
+
# merely NAMED and about which no question was ever put (articles 1 and
|
|
226
|
+
# 2). Both sibling readers of an untrusted document already catch it.
|
|
227
|
+
raise PackInvalid(
|
|
228
|
+
f"{MANIFEST_FILE} does not read as a manifest: the manifest nests deeper "
|
|
229
|
+
f"than this reader parses ({too_deep})"
|
|
230
|
+
) from too_deep
|
|
231
|
+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as unreadable:
|
|
232
|
+
raise PackInvalid(
|
|
233
|
+
f"{MANIFEST_FILE} does not read as a manifest: {unreadable}"
|
|
234
|
+
) from unreadable
|
|
235
|
+
if "pack" not in document or not isinstance(document["pack"], dict):
|
|
236
|
+
raise PackInvalid(
|
|
237
|
+
f"[pack] is missing: {MANIFEST_FILE} declares the pack itself in a [pack] "
|
|
238
|
+
f"table, before any point"
|
|
239
|
+
)
|
|
240
|
+
header = _declared(document["pack"], PACK_MEMBERS)
|
|
241
|
+
return Pack(
|
|
242
|
+
name=_a_pack_name(header["name"]),
|
|
243
|
+
classification=_a_classification(header["classification"]),
|
|
244
|
+
classified_on=_a_date(header["classified_on"]),
|
|
245
|
+
points=_the_points(document),
|
|
246
|
+
directory=path,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _declared(table: dict[str, object], members: tuple[str, ...]) -> dict[str, object]:
|
|
251
|
+
"""The table with every expected member present, the ones it omits as `None`."""
|
|
252
|
+
return {member: None for member in members} | table
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _a_pack_name(value: object) -> str:
|
|
256
|
+
if not isinstance(value, str) or _PACK_NAME.fullmatch(value) is None:
|
|
257
|
+
raise PackInvalid(
|
|
258
|
+
f"[pack].name is {value!r}: a pack name is lower-case letters, digits and "
|
|
259
|
+
f"hyphens, beginning with a letter, so that one pack has one spelling"
|
|
260
|
+
)
|
|
261
|
+
return value
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _a_classification(value: object) -> str:
|
|
265
|
+
if value != CONVENIENCE:
|
|
266
|
+
raise PackInvalid(
|
|
267
|
+
f"[pack].classification is {value!r}: this format admits {CONVENIENCE!r} "
|
|
268
|
+
f"alone, because article 9 puts packs for sophisticated frameworks outside "
|
|
269
|
+
f"the open core"
|
|
270
|
+
)
|
|
271
|
+
return CONVENIENCE
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _a_date(value: object) -> date:
|
|
275
|
+
"""The date the classification was made, refused unless it is one.
|
|
276
|
+
|
|
277
|
+
Article 9 wants the classification dated so that it can be re-examined, which
|
|
278
|
+
a date this reader could not parse would quietly prevent.
|
|
279
|
+
"""
|
|
280
|
+
if isinstance(value, date):
|
|
281
|
+
return date(value.year, value.month, value.day)
|
|
282
|
+
if isinstance(value, str):
|
|
283
|
+
try:
|
|
284
|
+
return date.fromisoformat(value)
|
|
285
|
+
except ValueError:
|
|
286
|
+
pass
|
|
287
|
+
raise PackInvalid(
|
|
288
|
+
f"[pack].classified_on is {value!r}: the classification carries the date it was "
|
|
289
|
+
f"made, as an ISO date, so that it can be re-examined rather than assumed current"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _the_points(document: dict[str, object]) -> tuple[Point, ...]:
|
|
294
|
+
value = _declared(document, ("point",))["point"]
|
|
295
|
+
if not isinstance(value, list) or not value:
|
|
296
|
+
raise PackInvalid(
|
|
297
|
+
"[[point]] is missing: a pack that declares no interposition point installs "
|
|
298
|
+
"nothing, and an engine that installed nothing must not report a pack as "
|
|
299
|
+
"installed (article 2)"
|
|
300
|
+
)
|
|
301
|
+
return tuple(_a_point(entry, index) for index, entry in enumerate(value))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _a_point(entry: object, index: int) -> Point:
|
|
305
|
+
where = f"[[point]] {index + 1}"
|
|
306
|
+
if not isinstance(entry, dict):
|
|
307
|
+
raise PackInvalid(f"{where} is {entry!r}: every point is declared as a table")
|
|
308
|
+
declared = _declared(entry, POINT_MEMBERS)
|
|
309
|
+
module = declared["module"]
|
|
310
|
+
if not isinstance(module, str) or not _is_dotted_identifier(module):
|
|
311
|
+
raise PackInvalid(
|
|
312
|
+
f"{where} module is {module!r}: a point names the module holding the "
|
|
313
|
+
f"operation to wrap, as a dotted identifier"
|
|
314
|
+
)
|
|
315
|
+
attribute = declared["attribute"]
|
|
316
|
+
if not isinstance(attribute, str) or not _is_identifier(attribute):
|
|
317
|
+
raise PackInvalid(
|
|
318
|
+
f"{where} attribute is {attribute!r}: a point names one attribute of that "
|
|
319
|
+
f"module, as a single identifier"
|
|
320
|
+
)
|
|
321
|
+
capability = _a_capability(declared["capability"], module, attribute, where)
|
|
322
|
+
digest = declared["digest"]
|
|
323
|
+
if (
|
|
324
|
+
not isinstance(digest, list)
|
|
325
|
+
or not digest
|
|
326
|
+
or not all(isinstance(member, str) and _is_identifier(member) for member in digest)
|
|
327
|
+
):
|
|
328
|
+
raise PackInvalid(
|
|
329
|
+
f"{where} digest is {digest!r}: a point names the call arguments that form "
|
|
330
|
+
f"its digest, as a non-empty list of identifiers (article 11: what is sent "
|
|
331
|
+
f"is a digest, and which arguments it covers is declared, never implicit)"
|
|
332
|
+
)
|
|
333
|
+
audit_event = declared["audit_event"]
|
|
334
|
+
if not isinstance(audit_event, str) or not audit_event:
|
|
335
|
+
raise PackInvalid(
|
|
336
|
+
f"{where} audit_event is {audit_event!r}: a point names the event a verifier "
|
|
337
|
+
f"watches for, as a non-empty string"
|
|
338
|
+
)
|
|
339
|
+
return Point(
|
|
340
|
+
module=module,
|
|
341
|
+
attribute=attribute,
|
|
342
|
+
capability=capability,
|
|
343
|
+
digest=tuple(digest),
|
|
344
|
+
audit_event=audit_event,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _a_capability(value: object, module: str, attribute: str, where: str) -> str:
|
|
349
|
+
"""One capability against both rules, the contract's shape first.
|
|
350
|
+
|
|
351
|
+
The contract's first because it is the one a question is validated against:
|
|
352
|
+
a spelling the daemon will refuse as malformed is not worth judging for
|
|
353
|
+
anything else, and a pack declaring one governed nothing while appearing to
|
|
354
|
+
(article 2).
|
|
355
|
+
"""
|
|
356
|
+
rule = capability_rule()
|
|
357
|
+
if (
|
|
358
|
+
not isinstance(value, str)
|
|
359
|
+
or rule.shape.fullmatch(value) is None
|
|
360
|
+
or len(value) < rule.shortest
|
|
361
|
+
or (rule.longest and len(value) > rule.longest)
|
|
362
|
+
):
|
|
363
|
+
raise PackInvalid(
|
|
364
|
+
f"{where} capability is {value!r}: the control plane publishes the rule for a "
|
|
365
|
+
f"capability in its {ASK_REQUEST_SCHEMA} schema, and this spelling is not one "
|
|
366
|
+
f"it admits — the schema states `{rule.stated}`, of {rule.shortest} to "
|
|
367
|
+
f"{rule.longest if rule.longest else 'any number of'} characters. Written out "
|
|
368
|
+
f"plainly rather than escaped, so it "
|
|
369
|
+
f"can be compared with the spelling above. A capability the plane refuses as "
|
|
370
|
+
f"malformed is a question this pack could never ask."
|
|
371
|
+
)
|
|
372
|
+
borrowed = _a_borrowed_segment(value, module, attribute)
|
|
373
|
+
if borrowed is not None:
|
|
374
|
+
raise PackInvalid(
|
|
375
|
+
f"{where} capability is {value!r}: a capability names a kind of effect, never "
|
|
376
|
+
f"a library name (segment {borrowed!r} is one) — article 4"
|
|
377
|
+
)
|
|
378
|
+
return value
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _a_borrowed_segment(capability: str, module: str, attribute: str) -> str | None:
|
|
382
|
+
"""The capability's first segment that is a name from the point, or `None`.
|
|
383
|
+
|
|
384
|
+
Compared segment by segment, against every segment of the module path and
|
|
385
|
+
against the attribute, with the case folded on both sides.
|
|
386
|
+
|
|
387
|
+
It is not the comparison that refuses an upper-case capability: the shape
|
|
388
|
+
above does, everywhere in a capability, so a spelling like `parcel.Dispatch`
|
|
389
|
+
cannot be declared at all. Comparing with the case kept therefore bought
|
|
390
|
+
nothing and cost the rule its whole reach over a class name — the only
|
|
391
|
+
spellings it could still catch were the ones already refused by the shape —
|
|
392
|
+
so `parcel.dispatch` declared on a `Dispatch` is the library name wearing a
|
|
393
|
+
capability's clothes that this rule exists for, and it is refused.
|
|
394
|
+
|
|
395
|
+
The pair is invented and has to stay invented, for the reason the module
|
|
396
|
+
docstring gives: the guard reads this file too.
|
|
397
|
+
"""
|
|
398
|
+
borrowed = {segment.casefold() for segment in (*module.split("."), attribute)}
|
|
399
|
+
return next(
|
|
400
|
+
(segment for segment in capability.split(".") if segment.casefold() in borrowed), None
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _is_identifier(text: str) -> bool:
|
|
405
|
+
return text.isidentifier() and not keyword.iskeyword(text)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _is_dotted_identifier(text: str) -> bool:
|
|
409
|
+
return bool(text) and all(_is_identifier(part) for part in text.split("."))
|