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,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("."))