@arjunkhera/atlas 0.3.40 → 0.3.42

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,893 @@
1
+ """The contract library for Python (design section 8.1). It is the Python form of
2
+ contract.mjs, on the standard unittest runner, so a repo needs no new package.
3
+
4
+ # in test/scenarios/routing_score_test.py, with the kit copy in test/atlas/
5
+ import sys
6
+ from pathlib import Path
7
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "atlas"))
8
+ from contract import scenario
9
+
10
+ @scenario("../../scenarios/routing-score.md", adapter=adapter)
11
+ def RoutingScore(run, way):
12
+ owner = run.persona("owner", way)
13
+ # ... steps, through the actions of the area ...
14
+ with run.gate("routing-score/e1#e46b3c"):
15
+ assert first.exit == 0, first.stderr
16
+ with run.check("routing-score/e2#7d78e1") as proof:
17
+ assert "held-out: 3/3 routed correctly" in right.stdout
18
+ proof.value = right.stdout.strip()
19
+
20
+ What `scenario(path, adapter=...)` does
21
+ 1. It reads the scenario and atlas/tests.yaml through the bridge
22
+ (bridge.mjs), so the words, the fingerprints, the tests.yaml check and
23
+ the guard parts are read by the same code as the link check. It needs
24
+ node, as the link check does. It refuses to run when either file has a
25
+ fault: the import fails.
26
+ 2. It makes one unittest.TestCase class, with one test for each way in of
27
+ `through:`. Each test runs the body with (run, way).
28
+ 3. It writes the evidence file, schema `evidence: 1`, the same file as
29
+ evidence.mjs writes: at the start and after each assertion. So the run
30
+ half of the link check reads a Python run as it reads a JavaScript run.
31
+ 4. A test fails when any assertion is not `pass`. That holds for `fail`,
32
+ `not checked`, `not exercised`, `blocked` and `unsure`.
33
+ 5. When the scenario's `runs-in` excludes the environment, each result is
34
+ `not here` and the test passes.
35
+
36
+ A check is a `with` block. The block must hold an assert statement (the
37
+ Python lint of the link check refuses one that does not). A failed assert,
38
+ or any other error in the block, is a `fail`, and the run goes on. A failed
39
+ gate stops the run; later assertions read `not checked`. A block that is not
40
+ entered checks nothing, so its assertion stays `not checked`.
41
+
42
+ What this library does not do yet. Each one ends `blocked`, with the reason,
43
+ and never a pass: an environment that starts a product or a stand-in, run
44
+ secrets, a persona with a secret, `make:` or `sign-in:` that the adapter does
45
+ not give, `repeat`, judged assertions, model calls, and the drivers of the
46
+ kit. A way in gets its driver from the adapter; `child_env` here is the guard
47
+ that a driver gives its child process.
48
+
49
+ The adapter binds the library to one product. Every part is optional:
50
+ drivers {way or driver name: (ctx) -> driver}; ctx holds way, spec, record,
51
+ redactor, root, run_id and tests. driver.call(request, **opts) and
52
+ driver.close()
53
+ warm_up (env) -> None, once before the first test; raise Blocked when the
54
+ product cannot run on this machine
55
+ actions an object, or (run) -> object: the verbs of the area, on run.actions
56
+ data {rule: (run_id, count, rule) -> value}, a data rule of the repo
57
+ makers, sign_ins {name: (ctx) -> {credential, headers, cleanup}}
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import datetime as _dt
63
+ import hashlib
64
+ import inspect
65
+ import json
66
+ import os
67
+ import re
68
+ import secrets as _secrets
69
+ import signal
70
+ import subprocess
71
+ import sys
72
+ import threading
73
+ import time
74
+ import types
75
+ import unittest
76
+ from pathlib import Path
77
+
78
+ __all__ = [
79
+ "scenario", "define_adapter", "wait_until", "parse_limit", "child_env",
80
+ "Blocked", "GateFailed", "WaitFailed", "WaitTimeout", "Redactor", "RUN_ID",
81
+ "EVIDENCE_SCHEMA", "RESULTS",
82
+ ]
83
+
84
+ KIT = Path(__file__).resolve().parent
85
+ BRIDGE = KIT / "bridge.mjs"
86
+ FULL_ID = re.compile(r"^(.+)/(e\d+)#([0-9a-f]{6})$")
87
+ EVIDENCE_SCHEMA = 1
88
+ RESULTS = ("pass", "fail", "not checked", "not exercised", "blocked", "not here", "flaky", "unsure")
89
+ MAX_BODY = 8000
90
+
91
+
92
+ # ---------------------------------------------------------------- errors (errors.mjs)
93
+
94
+ class Blocked(Exception):
95
+ """A setup failed, a guard refused, or a precondition does not hold. The result is `blocked`."""
96
+
97
+
98
+ class GateFailed(Exception):
99
+ """A failed gate. It stops the body of the test."""
100
+
101
+
102
+ class WaitFailed(Exception):
103
+ """A wait met its fail state. This is a product result, so it is a `fail`."""
104
+
105
+ def __init__(self, reason, last=None):
106
+ super().__init__(reason)
107
+ self.last = last
108
+
109
+
110
+ class WaitTimeout(Exception):
111
+ """A wait ran out of time. This is a product result, so it is a `fail`."""
112
+
113
+ def __init__(self, reason, last=None):
114
+ super().__init__(reason)
115
+ self.last = last
116
+
117
+
118
+ class _OutOfTime(BaseException):
119
+ """Not an Exception, so neither a check block nor an `except Exception` of a test can swallow it."""
120
+
121
+
122
+ def define_adapter(**parts):
123
+ return types.SimpleNamespace(**parts)
124
+
125
+
126
+ # ---------------------------------------------------------------- the run id and fresh values (fresh.mjs)
127
+
128
+ def make_run_id(env=os.environ):
129
+ if env.get("ATLAS_RUN_ID"):
130
+ return env["ATLAS_RUN_ID"]
131
+ if env.get("GITHUB_RUN_ID"):
132
+ return f"ci{env['GITHUB_RUN_ID']}-{env.get('GITHUB_RUN_ATTEMPT', 1)}"
133
+ return f"run{_secrets.token_hex(3)}"
134
+
135
+
136
+ RUN_ID = make_run_id()
137
+
138
+ _JAN1 = _dt.date(2035, 1, 1)
139
+ _FIRST_MONDAY = _JAN1 + _dt.timedelta(days=((8 - (_JAN1.isoweekday() % 7)) % 7) or 7)
140
+ _SPREAD_WEEKS = 520
141
+
142
+
143
+ def monday_for(run_id, count):
144
+ digest = hashlib.sha256(f"{run_id}:{count}".encode()).digest()
145
+ weeks = int.from_bytes(digest[:4], "big") % _SPREAD_WEEKS
146
+ return (_FIRST_MONDAY + _dt.timedelta(weeks=weeks)).isoformat()
147
+
148
+
149
+ def make_fresh(run_id, rules, adapter_rules=None):
150
+ rules = rules or {}
151
+ adapter_rules = adapter_rules or {}
152
+ counts = {}
153
+ made = []
154
+
155
+ def fresh(rule):
156
+ if rule not in rules and rule not in adapter_rules:
157
+ raise ValueError(f'there is no data rule "{rule}" in tests.yaml')
158
+ count = counts.get(rule, 0) + 1
159
+ counts[rule] = count
160
+ if rule in adapter_rules:
161
+ value = adapter_rules[rule](run_id, count, rule)
162
+ elif re.search("monday", rule, re.I):
163
+ value = monday_for(run_id, f"{rule}:{count}")
164
+ else:
165
+ value = f"{re.sub('^fresh-', '', rule)}-{run_id}-{count}"
166
+ made.append({"rule": rule, "value": value})
167
+ return value
168
+
169
+ fresh.made = made
170
+ return fresh
171
+
172
+
173
+ # ---------------------------------------------------------------- redaction (redact.mjs)
174
+
175
+ MIN_SECRET_LENGTH = 4
176
+ _SHAPES = [
177
+ (re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"), "[redacted:jwt]"),
178
+ (re.compile(r"\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}", re.I), r"\1 [redacted]"),
179
+ (re.compile(r"([?&](?:X-Amz-Signature|X-Amz-Credential|X-Amz-Security-Token|Signature|sig|access_token)=)[^&\s\"']+", re.I), r"\1[redacted]"),
180
+ ]
181
+
182
+
183
+ class Redactor:
184
+ """Redaction by value. No value in the set reaches an evidence file or a log."""
185
+
186
+ def __init__(self, values=()):
187
+ self.values = set()
188
+ self.add(values)
189
+
190
+ def add(self, value):
191
+ for one in value if isinstance(value, (list, tuple, set)) else [value]:
192
+ if isinstance(one, str) and len(one) >= MIN_SECRET_LENGTH:
193
+ self.values.add(one)
194
+ return self
195
+
196
+ def text(self, value):
197
+ if not isinstance(value, str):
198
+ return value
199
+ out = value
200
+ for secret in sorted(self.values, key=len, reverse=True):
201
+ out = out.replace(secret, "[redacted]")
202
+ for shape, replacement in _SHAPES:
203
+ out = shape.sub(replacement, out)
204
+ return out
205
+
206
+ def deep(self, value):
207
+ if isinstance(value, str):
208
+ return self.text(value)
209
+ if isinstance(value, (list, tuple)):
210
+ return [self.deep(one) for one in value]
211
+ if isinstance(value, BaseException):
212
+ return self.text(str(value))
213
+ if isinstance(value, dict):
214
+ return {self.text(str(k)): self.deep(v) for k, v in value.items()}
215
+ return value
216
+
217
+
218
+ def clip(value, redactor=None):
219
+ """Redact first, then cut: a cut before the redaction could split a secret."""
220
+ safe = redactor.deep(value) if redactor else value
221
+ text = safe if isinstance(safe, str) else json.dumps(safe, separators=(",", ":"), default=str)
222
+ if len(text) <= MAX_BODY:
223
+ return safe
224
+ return f"{text[:MAX_BODY]}... [{len(text) - MAX_BODY} more characters]"
225
+
226
+
227
+ # ---------------------------------------------------------------- the child environment (guards.mjs)
228
+
229
+ _ALLOWED = re.compile(r"^(?:PATH|HOME|LANG|LC_[A-Z_]+|TMPDIR|TERM)$")
230
+ _CREDENTIAL_NAME = re.compile(r"TOKEN|AUTH|KEY|SECRET|PASSWORD|PASS|CRED", re.I)
231
+ _LOOPBACK_PROXY = re.compile(r"^http://(127\.0\.0\.1|localhost|\[::1\]):\d{1,5}/?$")
232
+
233
+
234
+ def child_env(parent=None, values=None):
235
+ """The environment of a child process: the kit's allow-list from `parent`, then `values` on top.
236
+ The rule is childEnv of guards.mjs, word for word."""
237
+ parent = os.environ if parent is None else parent
238
+ out = {}
239
+ for name, value in parent.items():
240
+ if value is None:
241
+ continue
242
+ if _ALLOWED.match(name) or (name.startswith("NODE_") and name != "NODE_TEST_CONTEXT" and not _CREDENTIAL_NAME.search(name)):
243
+ out[name] = value
244
+ if parent.get("CLAUDE_CODE_REMOTE") == "true":
245
+ proxies = [n for n in ("HTTPS_PROXY", "https_proxy") if parent.get(n) is not None]
246
+ if proxies and all(_LOOPBACK_PROXY.match(parent[n]) for n in proxies):
247
+ for name in proxies + ["NO_PROXY", "no_proxy"]:
248
+ if parent.get(name) is not None:
249
+ out[name] = parent[name]
250
+ for name, value in (values or {}).items():
251
+ if value is not None:
252
+ out[name] = str(value)
253
+ return out
254
+
255
+
256
+ # ---------------------------------------------------------------- waits (wait.mjs)
257
+
258
+ _UNITS = {"ms": 1, "millisecond": 1, "milliseconds": 1, "s": 1000, "second": 1000, "seconds": 1000,
259
+ "m": 60000, "min": 60000, "minute": 60000, "minutes": 60000, "h": 3600000, "hour": 3600000, "hours": 3600000}
260
+
261
+
262
+ def parse_limit(limit):
263
+ """"30s", "2m", "2 minutes" or a number of milliseconds. Returns milliseconds."""
264
+ if isinstance(limit, (int, float)) and not isinstance(limit, bool) and limit > 0:
265
+ return limit
266
+ m = re.match(r"^(\d+(?:\.\d+)?)\s*([a-z]+)$", str(limit if limit is not None else "").strip(), re.I)
267
+ if not m or m.group(2).lower() not in _UNITS:
268
+ raise ValueError(f'a limit is a number of milliseconds, or like "30s" or "2m"; found "{limit}"')
269
+ return float(m.group(1)) * _UNITS[m.group(2).lower()]
270
+
271
+
272
+ def _show(last):
273
+ body = last.get("body", last) if isinstance(last, dict) else getattr(last, "body", last)
274
+ return json.dumps(body, default=str)[:600]
275
+
276
+
277
+ def wait_until(read, until, fail=None, absent=None, limit=None, every="250ms", absent_limit=10):
278
+ """Wait for a state, never for a fixed time. A wait with no limit is refused."""
279
+ if not callable(read):
280
+ raise ValueError("wait_until needs a function that reads the state")
281
+ if not callable(until):
282
+ raise ValueError("wait_until needs an `until` state")
283
+ if limit is None:
284
+ raise ValueError("wait_until needs a `limit`: a wait with no time limit is refused")
285
+ deadline = time.monotonic() + parse_limit(limit) / 1000
286
+ absent_reads = 0
287
+ while True:
288
+ last = read()
289
+ if fail and fail(last):
290
+ raise WaitFailed(f"the wait met its fail state: {_show(last)}", last)
291
+ if until(last):
292
+ return last
293
+ absent_reads = absent_reads + 1 if absent and absent(last) else 0
294
+ if absent_reads >= absent_limit:
295
+ raise WaitFailed(f"the field that the wait reads was absent for {absent_reads} reads in a row", last)
296
+ if time.monotonic() >= deadline:
297
+ raise WaitTimeout(f"the wait ran out after {limit}; last state: {_show(last)}", last)
298
+ time.sleep(parse_limit(every) / 1000)
299
+
300
+
301
+ # ---------------------------------------------------------------- the evidence (evidence.mjs)
302
+
303
+ def _now():
304
+ return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
305
+
306
+
307
+ def _commit_of(root):
308
+ env = os.environ
309
+ if env.get("GITHUB_HEAD_SHA"):
310
+ return env["GITHUB_HEAD_SHA"]
311
+ if env.get("GITHUB_EVENT_PATH"):
312
+ try:
313
+ sha = json.loads(Path(env["GITHUB_EVENT_PATH"]).read_text()).get("pull_request", {}).get("head", {}).get("sha")
314
+ if sha:
315
+ return sha
316
+ except (OSError, ValueError, AttributeError):
317
+ pass
318
+ if env.get("GITHUB_SHA"):
319
+ return env["GITHUB_SHA"]
320
+ try:
321
+ r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, timeout=10)
322
+ return r.stdout.strip() if r.returncode == 0 else None
323
+ except (OSError, subprocess.SubprocessError):
324
+ return None
325
+
326
+
327
+ class Evidence:
328
+ """One evidence file for each run and scenario. It is claimed with an exclusive create, so no
329
+ earlier run is overwritten. Every string is redacted by value before it is written."""
330
+
331
+ def __init__(self, evidence_dir, run_id, environment, sc, root, redactor):
332
+ self.dir = Path(evidence_dir) / run_id
333
+ self.redactor = redactor
334
+ self.record = {
335
+ "evidence": EVIDENCE_SCHEMA, "run_id": run_id, "environment": environment,
336
+ "scenario": sc["id"], "title": sc["title"],
337
+ "source": os.path.relpath(sc["file"], root).replace(os.sep, "/"), "source_sha256": sc["sha256"],
338
+ "fingerprint_method": sc["method"],
339
+ "commit": _commit_of(root), "started": _now(), "ended": None, "verdict": "not finished",
340
+ "ways": {}, "assertions": [], "tables": {}, "fresh": [], "calls": [], "notes": [], "files": [],
341
+ "provided_secrets": [], "fault_run": os.environ.get("ATLAS_FAULT_RUN") or None,
342
+ "language": "python",
343
+ }
344
+ for way in sc["ways"]:
345
+ self.record["ways"][way] = {"verdict": "not finished", "reason": None, "started": None, "ended": None}
346
+ for a in sc["assertions"]:
347
+ for way in a["ways"]:
348
+ self.record["assertions"].append({"id": a["fullId"], "way": way, "result": "not checked", "proof": None})
349
+ self.dir.mkdir(parents=True, exist_ok=True)
350
+ self.path = self._claim(sc["id"])
351
+ self.write()
352
+
353
+ def _claim(self, scenario_id):
354
+ n = 1
355
+ while True:
356
+ path = self.dir / (f"{scenario_id}.json" if n == 1 else f"{scenario_id}.{n}.json")
357
+ try:
358
+ os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
359
+ return path
360
+ except FileExistsError:
361
+ n += 1
362
+
363
+ def entry(self, full_id, way):
364
+ for a in self.record["assertions"]:
365
+ if a["id"] == full_id and a["way"] == way:
366
+ return a
367
+ raise ValueError(f'the evidence has no assertion {full_id} for the way in "{way}"')
368
+
369
+ def set_result(self, full_id, way, result, proof=None):
370
+ if result not in RESULTS:
371
+ raise ValueError(f'"{result}" is not a result')
372
+ entry = self.entry(full_id, way)
373
+ entry["result"] = result
374
+ entry["proof"] = {k: v for k, v in (proof or {}).items() if v is not None} or None
375
+ self.write()
376
+
377
+ def set_way(self, way, **patch):
378
+ self.record["ways"][way].update(patch)
379
+ verdicts = [w["verdict"] for w in self.record["ways"].values()]
380
+ if "fail" in verdicts:
381
+ self.record["verdict"] = "fail"
382
+ elif "blocked" in verdicts:
383
+ self.record["verdict"] = "blocked"
384
+ elif all(v in ("pass", "not here") for v in verdicts):
385
+ self.record["verdict"] = "not here" if all(v == "not here" for v in verdicts) else "pass"
386
+ else:
387
+ self.record["verdict"] = "not finished"
388
+ if "not finished" not in verdicts:
389
+ self.record["ended"] = _now()
390
+ self.write()
391
+
392
+ def add_call(self, call):
393
+ n = len(self.record["calls"]) + 1
394
+ self.record["calls"].append({"n": n, **call})
395
+ return n
396
+
397
+ def add_file(self, path):
398
+ self.record["files"].append(os.path.relpath(path, self.dir).replace(os.sep, "/"))
399
+ self.write()
400
+
401
+ def note(self, text):
402
+ self.record["notes"].append(str(text))
403
+ self.write()
404
+
405
+ def write(self):
406
+ safe = self.redactor.deep(self.record)
407
+ temp = self.path.with_name(self.path.name + ".tmp")
408
+ temp.write_text(json.dumps(safe, indent=2, ensure_ascii=False) + "\n")
409
+ os.replace(temp, self.path)
410
+
411
+
412
+ # ---------------------------------------------------------------- reading the scenario
413
+
414
+ def _load(file):
415
+ try:
416
+ r = subprocess.run(["node", str(BRIDGE), "load", str(file)], capture_output=True, text=True, timeout=60)
417
+ except FileNotFoundError as error:
418
+ raise RuntimeError("the Python contract library needs node to read a scenario (the bridge, bridge.mjs)") from error
419
+ try:
420
+ data = json.loads(r.stdout)
421
+ except ValueError as error:
422
+ raise RuntimeError(f"the bridge gave no answer: {r.stderr.strip()[:400]}") from error
423
+ if "error" in data:
424
+ raise RuntimeError(data["error"])
425
+ return data
426
+
427
+
428
+ def _caller_dir():
429
+ for frame in inspect.stack()[2:]:
430
+ if Path(frame.filename).resolve() != Path(__file__).resolve():
431
+ return Path(frame.filename).resolve().parent
432
+ return Path.cwd()
433
+
434
+
435
+ def _as_list(x):
436
+ if isinstance(x, list):
437
+ return [str(v) for v in x]
438
+ return [] if x is None else [str(x)]
439
+
440
+
441
+ def _one_line(text):
442
+ return re.sub(r"\s+", " ", str(text or "")).strip()
443
+
444
+
445
+ # ---------------------------------------------------------------- the check block
446
+
447
+ class _Check:
448
+ def __init__(self, run, full, mode):
449
+ self.run = run
450
+ self.full = full
451
+ self.mode = mode
452
+ self.value = None
453
+
454
+ def __enter__(self):
455
+ self.run._open(f"run.{self.mode}")
456
+ self.found = self.run._find(self.full)
457
+ return self
458
+
459
+ def __exit__(self, kind, error, trace):
460
+ # A block or a stopped gate inside the block is no product result of this check: it goes on up.
461
+ if kind is not None and (not issubclass(kind, Exception) or issubclass(kind, (Blocked, GateFailed))):
462
+ return False
463
+ a, fp = self.found
464
+ failure = None
465
+ if fp != a["fp"]:
466
+ failure = f"the fingerprint {fp} is old: the words of {a['id']} now print {a['fp']}; convert the test again"
467
+ elif error is not None:
468
+ message = str(error)
469
+ if isinstance(error, AssertionError):
470
+ at = trace.tb_lineno if trace else "?"
471
+ while trace is not None and trace.tb_next is not None:
472
+ trace = trace.tb_next
473
+ at = trace.tb_lineno
474
+ failure = message or f"an assert statement failed (line {at})"
475
+ else:
476
+ failure = f"{type(error).__name__}: {message}" if message else type(error).__name__
477
+ self.run._settle_one(a, failure, self.value)
478
+ if failure and self.mode == "gate":
479
+ raise GateFailed(f"[{a['id']}] {failure}")
480
+ return True
481
+
482
+
483
+ # ---------------------------------------------------------------- the run object
484
+
485
+ class Run:
486
+ def __init__(self, ctx, state, way, ev, adapter):
487
+ self._ctx = ctx
488
+ self._state = state
489
+ self._ev = ev
490
+ self._adapter = adapter
491
+ self._closed = False
492
+ self._mark = len(ev.record["calls"])
493
+ sc = ctx["scenario"]
494
+ self.id = sc["id"]
495
+ self.title = sc["title"]
496
+ self.run_id = RUN_ID
497
+ self.way = way
498
+ self.scenario = sc
499
+ self.tests = ctx["tests"]
500
+ self.root = ctx["root"]
501
+ self.round = None
502
+ self.cleanups = []
503
+ if state.get("fresh") is None:
504
+ state["fresh"] = make_fresh(RUN_ID, self.tests.get("data"), getattr(adapter, "data", None))
505
+ actions = getattr(adapter, "actions", None)
506
+ actions = actions(self) if callable(actions) else (actions or {})
507
+ self.actions = types.SimpleNamespace(**actions) if isinstance(actions, dict) else actions
508
+
509
+ # -- internal
510
+
511
+ def _open(self, what):
512
+ if self._closed:
513
+ raise RuntimeError(f"the run is closed: {what} came after the scenario ended, so it changed nothing")
514
+
515
+ def _calls_since(self):
516
+ start = self._mark
517
+ self._mark = len(self._ev.record["calls"])
518
+ return [c["n"] for c in self._ev.record["calls"][start:]]
519
+
520
+ def _find(self, full):
521
+ sc = self.scenario
522
+ m = FULL_ID.match(str(full))
523
+ if not m:
524
+ raise ValueError(f'"{full}" is not a full assertion id like {sc["id"]}/e1#abc123')
525
+ if m.group(1) != sc["id"]:
526
+ raise ValueError(f'"{full}" names the scenario "{m.group(1)}"; this is "{sc["id"]}"')
527
+ for a in sc["assertions"]:
528
+ if a["id"] == m.group(2):
529
+ return a, m.group(3)
530
+ raise ValueError(f"the scenario has no assertion {m.group(2)}")
531
+
532
+ def _target(self, a):
533
+ return self.way if self.way in a["ways"] else a["ways"][0]
534
+
535
+ def _settle_one(self, a, failure, value):
536
+ if a["kind"] == "judged":
537
+ failure = failure or f"{a['id']} is a judged assertion; the Python library has no judge yet"
538
+ target = self._target(a)
539
+ entry = self._ev.entry(a["fullId"], target)
540
+ calls = self._calls_since()
541
+ earlier = entry if (entry.get("proof") or {}).get("settled_by") not in (None, self.way) else None
542
+ also = ([*earlier["proof"].get("also", []), {"run": earlier["proof"]["settled_by"], "result": earlier["result"]}]
543
+ if earlier else (entry.get("proof") or {}).get("also"))
544
+ if failure:
545
+ self._ev.set_result(a["fullId"], target, "fail", {"error": self.redact(failure), "calls": calls, "settled_by": self.way, "also": also})
546
+ elif entry["result"] != "fail":
547
+ self._ev.set_result(a["fullId"], target, "pass", {"value": None if value is None else clip(value, self._state["redactor"]), "calls": calls, "settled_by": self.way, "also": also})
548
+ else:
549
+ entry["proof"] = {**(entry.get("proof") or {}), "also": [*(entry["proof"] or {}).get("also", []), {"run": self.way, "result": "pass"}]}
550
+ self._ev.write()
551
+
552
+ def _record(self, call):
553
+ if self._closed:
554
+ return None
555
+ return self._ev.add_call(self._state["redactor"].deep(call))
556
+
557
+ def close(self):
558
+ self._closed = True
559
+
560
+ # -- the parts of section 8.1
561
+
562
+ def check(self, full):
563
+ """One assertion, as a `with` block. A failure is recorded and the run goes on."""
564
+ return _Check(self, full, "check")
565
+
566
+ def gate(self, full):
567
+ """The same, and a failure stops the run. Later assertions read `not checked`."""
568
+ return _Check(self, full, "gate")
569
+
570
+ def not_exercised(self, full, why):
571
+ """A "when" assertion whose situation never happened in this run."""
572
+ self._open("run.not_exercised")
573
+ a, fp = self._find(full)
574
+ target = self._target(a)
575
+ if fp != a["fp"]:
576
+ self._settle_one(a, f"the fingerprint {fp} is old: the words of {a['id']} now print {a['fp']}; convert the test again", None)
577
+ return
578
+ if self._ev.entry(a["fullId"], target)["result"] in ("pass", "fail"):
579
+ return
580
+ self._ev.set_result(a["fullId"], target, "not exercised", {"why": str(why), "calls": self._calls_since()})
581
+
582
+ def blocked(self, why):
583
+ """The test could not run. Never use it for a product result."""
584
+ raise Blocked(why)
585
+
586
+ def judge(self, full, evidence=None):
587
+ raise Blocked(f"{full}: the Python contract library has no judge yet; write this scenario with contract.mjs")
588
+
589
+ def _table(self, name, fingerprint=None):
590
+ t = self.scenario["tables"].get(name)
591
+ if not t:
592
+ raise ValueError(f"the scenario has no case table {name}")
593
+ self._ev.record["tables"][name] = t["fingerprint"]
594
+ if fingerprint and fingerprint != t["fingerprint"]:
595
+ raise ValueError(f"the table {name} now prints {t['fingerprint']}, not {fingerprint}; its rows changed")
596
+ return t
597
+
598
+ def table(self, name, fingerprint=None):
599
+ """The rows of a rule table, as dicts keyed by the header, read at run time."""
600
+ return [dict(row) for row in self._table(name, fingerprint)["rows"]]
601
+
602
+ def cells(self, name, fingerprint=None):
603
+ t = self._table(name, fingerprint)
604
+ if t["kind"] != "rule":
605
+ raise ValueError(f"{name} is a combination table; use run.cases")
606
+ first, *rest = t["header"]
607
+ return [{"row": row[first], "column": col, "value": row[col], "row_index": i, "label": f"{name}: {row[first]} / {col}"}
608
+ for i, row in enumerate(t["rows"]) for col in rest]
609
+
610
+ def cases(self, name, fingerprint=None):
611
+ t = self._table(name, fingerprint)
612
+ if t["kind"] != "combination":
613
+ raise ValueError(f"{name} is a rule table; use run.table or run.cells")
614
+ return [{**one, "label": f"{name} case {i + 1}"} for i, one in enumerate(t["cases"])]
615
+
616
+ def driver(self, way_name=None):
617
+ way_name = way_name or self.way
618
+ drivers = self._state["drivers"]
619
+ if way_name not in drivers:
620
+ spec = (self.tests.get("ways-in") or {}).get(way_name)
621
+ if not spec:
622
+ raise ValueError(f'there is no way in "{way_name}" in tests.yaml')
623
+ # A kit driver and the guard parts of a way in hold guards (hosts, the model meter, tools)
624
+ # that the Python library does not apply yet. An adapter driver must not stand in for them.
625
+ if spec.get("driver") in self._ctx["shippedDrivers"]:
626
+ raise Blocked(f'the way in "{way_name}" uses the kit driver "{spec["driver"]}", which has no Python form yet; write the test with contract.mjs')
627
+ guarded = [k for k in self._ctx["wayGuardParts"] if k in spec]
628
+ if guarded:
629
+ raise Blocked(f'the way in "{way_name}" sets {", ".join(guarded)}, guard parts that the Python library does not apply yet; write the test with contract.mjs')
630
+ given = getattr(self._adapter, "drivers", None) or {}
631
+ make = given.get(way_name) or given.get(spec.get("driver"))
632
+ if not make:
633
+ raise Blocked(f'the way in "{way_name}" uses the driver "{spec.get("driver")}", which has no Python form in the kit yet; give it in the adapter (drivers), or write the test with contract.mjs')
634
+ state = self._state
635
+ drivers[way_name] = make({
636
+ "way": way_name, "spec": spec, "root": self.root, "run_id": RUN_ID, "tests": self.tests,
637
+ "redactor": state["redactor"], "record": lambda call: state["live"]._record(call),
638
+ })
639
+ return drivers[way_name]
640
+
641
+ def persona(self, name, way_name=None):
642
+ """A test user from tests.yaml, made or loaded for this run."""
643
+ way_name = way_name or self.way
644
+ key = (name, way_name)
645
+ personas = self._state["personas"]
646
+ if key in personas:
647
+ return personas[key]
648
+ spec = (self.tests.get("personas") or {}).get(name)
649
+ if spec is None:
650
+ raise ValueError(f'there is no persona "{name}" in tests.yaml')
651
+ drv = self.driver(way_name)
652
+ extra = {}
653
+ if spec.get("make") or spec.get("sign-in"):
654
+ kind, made_by = ("makers", spec["make"]) if spec.get("make") else ("sign_ins", spec["sign-in"])
655
+ maker = (getattr(self._adapter, kind, None) or {}).get(made_by)
656
+ if not maker:
657
+ raise Blocked(f'persona "{name}" needs the {"maker" if kind == "makers" else "sign-in"} "{made_by}" and the adapter does not give it')
658
+ extra = maker({"run": self, "name": name, "spec": spec, "way": way_name, "driver": drv, "fresh": self.fresh}) or {}
659
+ if extra.get("cleanup"):
660
+ self.cleanups.append(extra["cleanup"])
661
+ elif spec.get("secret"):
662
+ raise Blocked(f'persona "{name}" needs the secret {spec["secret"]}; the Python library gives no run secrets yet')
663
+ credential = extra.get("credential")
664
+ self._state["redactor"].add(credential)
665
+ for value in (extra.get("headers") or {}).values():
666
+ self._state["redactor"].add(str(value))
667
+
668
+ def call(request=None, **opts):
669
+ return drv.call(request or {}, credential=credential, headers=extra.get("headers") or {}, persona=name, **opts)
670
+
671
+ persona = types.SimpleNamespace(**{k: v for k, v in extra.items() if k not in ("credential", "cleanup")},
672
+ name=name, role=spec.get("role"), way=way_name, credential=credential, call=call)
673
+ personas[key] = persona
674
+ return persona
675
+
676
+ def fresh(self, rule):
677
+ """A fresh value from a data rule. It carries the run id; a date is picked from it."""
678
+ self._open("run.fresh")
679
+ value = self._state["fresh"](rule)
680
+ self._ev.record["fresh"] = list(self._state["fresh"].made)
681
+ self._ev.write()
682
+ return value
683
+
684
+ def wait(self, read, **opts):
685
+ return wait_until(read, **opts)
686
+
687
+ def wait_for(self, full, read, **opts):
688
+ """A wait that serves one assertion: a wait that fails or runs out fails it, and stops the run."""
689
+ self._open("run.wait_for")
690
+ a, _ = self._find(full)
691
+ try:
692
+ return wait_until(read, **opts)
693
+ except (WaitFailed, WaitTimeout) as error:
694
+ self._ev.set_result(a["fullId"], self._target(a), "fail", {"error": str(error), "last": clip(error.last, self._state["redactor"])})
695
+ raise GateFailed(f"[{a['id']}] {error}") from error
696
+
697
+ def note(self, text):
698
+ self._open("run.note")
699
+ self._ev.note(self.redact(str(text)))
700
+
701
+ def attach(self, file_name):
702
+ """Keep a file as evidence. Returns the path to write to."""
703
+ self._open("run.attach")
704
+ folder = self._ev.dir / self.id
705
+ folder.mkdir(parents=True, exist_ok=True)
706
+ path = folder / file_name
707
+ self._ev.add_file(path)
708
+ return path
709
+
710
+ def on_cleanup(self, fn):
711
+ self.cleanups.append(fn)
712
+
713
+ def redact(self, text):
714
+ return self._state["redactor"].text(text)
715
+
716
+
717
+ # ---------------------------------------------------------------- settling a way in
718
+
719
+ def _settle(sc, way, ev, error):
720
+ """Turns the end of a body into results, a verdict and a list of problems (settle of contract.mjs)."""
721
+ mine = [a for a in ev.record["assertions"] if a["way"] == way or a["way"] not in sc["through"]]
722
+ problems = []
723
+ verdict, reason = "pass", None
724
+ if isinstance(error, Blocked):
725
+ verdict, reason = "blocked", str(error)
726
+ for a in mine:
727
+ if a["result"] == "not checked":
728
+ a["result"], a["proof"] = "blocked", {"why": reason}
729
+ elif isinstance(error, GateFailed):
730
+ verdict, reason = "fail", "a gate assertion failed, so the run stopped"
731
+ elif error is not None:
732
+ verdict, reason = "fail", f"{type(error).__name__}: {error}" if str(error) else type(error).__name__
733
+ for a in mine:
734
+ if a["result"] in ("pass", "not here"):
735
+ continue
736
+ if verdict == "pass":
737
+ verdict = "fail"
738
+ proof = a.get("proof") or {}
739
+ detail = proof.get("error") or proof.get("why")
740
+ problems.append(f"[{a['id']}] {a['result']}" + (f": {detail}" if detail else ""))
741
+ if verdict == "blocked":
742
+ problems.insert(0, f"BLOCKED: {reason}")
743
+ elif reason and not isinstance(error, GateFailed):
744
+ problems.append(f"the run stopped: {reason}")
745
+ ev.set_way(way, verdict=verdict, reason=reason, ended=_now())
746
+ for extra in [w for w in sc["ways"] if w not in sc["through"]]:
747
+ own = [a["result"] for a in ev.record["assertions"] if a["way"] == extra]
748
+ stopped = all(r in ("pass", "not here", "not checked", "blocked") for r in own) and any(r in ("not checked", "blocked") for r in own)
749
+ extra_reason = None
750
+ if "fail" in own:
751
+ extra_verdict, extra_reason = "fail", f"an assertion through {extra} failed"
752
+ elif stopped:
753
+ extra_verdict = "blocked"
754
+ extra_reason = ("the gate stopped the run" if isinstance(error, GateFailed) else reason if verdict == "blocked"
755
+ else "the run stopped before every assertion through this way in was checked")
756
+ elif all(r in ("pass", "not here") for r in own):
757
+ extra_verdict = "not here" if all(r == "not here" for r in own) else "pass"
758
+ else:
759
+ extra_verdict, extra_reason = "fail", f"an assertion through {extra} is not a pass"
760
+ ev.set_way(extra, verdict=extra_verdict, reason=extra_reason, started=ev.record["ways"][way]["started"], ended=_now())
761
+ return verdict, problems
762
+
763
+
764
+ class _Limit:
765
+ """The time limit of the scenario. It needs SIGALRM on the main thread; elsewhere no limit applies."""
766
+
767
+ def __init__(self, ms, text):
768
+ self.seconds = ms / 1000
769
+ self.text = text
770
+ self.armed = False
771
+
772
+ def __enter__(self):
773
+ if hasattr(signal, "setitimer") and threading.current_thread() is threading.main_thread():
774
+ def ring(signum, frame):
775
+ raise _OutOfTime(f"the scenario ran out of time: its limit is {self.text}")
776
+ self.before = signal.signal(signal.SIGALRM, ring)
777
+ signal.setitimer(signal.ITIMER_REAL, self.seconds)
778
+ self.armed = True
779
+ return self
780
+
781
+ def __exit__(self, *exc):
782
+ if self.armed:
783
+ signal.setitimer(signal.ITIMER_REAL, 0)
784
+ signal.signal(signal.SIGALRM, self.before)
785
+ return False
786
+
787
+
788
+ def _method_name(way):
789
+ return "test_" + re.sub(r"\W", "_", way)
790
+
791
+
792
+ def scenario(path, adapter=None):
793
+ """Decorates the body of a scenario test, (run, way) -> None, and returns a unittest.TestCase class.
794
+ A relative path is read from the folder of the test file."""
795
+ base = _caller_dir()
796
+ adapter = adapter or define_adapter()
797
+
798
+ def wrap(body):
799
+ file = Path(path) if Path(path).is_absolute() else (base / path)
800
+ ctx = _load(file.resolve())
801
+ sc = ctx["scenario"]
802
+ environment = ctx["environment"]
803
+ runs_here = environment in _as_list(sc["fields"].get("runs-in"))
804
+ limit_text = sc["fields"].get("limit")
805
+ limit_ms = parse_limit(limit_text)
806
+ state = {"redactor": Redactor(), "evidence": None, "drivers": {}, "personas": {}, "fresh": None, "warmed": False}
807
+
808
+ def evidence():
809
+ if state["evidence"] is None:
810
+ state["evidence"] = Evidence(ctx["evidenceDir"], RUN_ID, environment, sc, ctx["root"], state["redactor"])
811
+ return state["evidence"]
812
+
813
+ def ready():
814
+ if ctx["guardRefusal"]:
815
+ raise Blocked(ctx["guardRefusal"])
816
+ # Under -O or PYTHONOPTIMIZE, Python drops every assert statement, so every check would pass.
817
+ if sys.flags.optimize or not __debug__:
818
+ raise Blocked("Python runs with -O or PYTHONOPTIMIZE, which removes every assert statement; run it without them")
819
+ named = [n for n in sc.get("names", []) if n in (ctx["tests"].get("secrets") or {})]
820
+ if named:
821
+ raise Blocked(f"the scenario names the secret {', '.join(named)}; the Python library gives no run secrets yet")
822
+ spec = ctx["envSpec"] or {}
823
+ if spec.get("start") or ctx["tests"].get("stand-ins"):
824
+ raise Blocked("the environment starts a product or a stand-in; the Python contract library does not start one yet")
825
+ if sc.get("repeat"):
826
+ raise Blocked("the scenario has repeat; the Python contract library does not repeat a run yet")
827
+ # Each way in of the run is checked before the body runs, also one the body never reaches through run.driver.
828
+ for way in sc["through"]:
829
+ spec_way = (ctx["tests"].get("ways-in") or {}).get(way) or {}
830
+ if spec_way.get("driver") in ctx["shippedDrivers"]:
831
+ raise Blocked(f'the way in "{way}" uses the kit driver "{spec_way["driver"]}", which has no Python form yet; write the test with contract.mjs')
832
+ guarded = [k for k in ctx["wayGuardParts"] if k in spec_way]
833
+ if guarded:
834
+ raise Blocked(f'the way in "{way}" sets {", ".join(guarded)}, guard parts that the Python library does not apply yet; write the test with contract.mjs')
835
+ if not state["warmed"]:
836
+ warm = getattr(adapter, "warm_up", None)
837
+ if warm:
838
+ warm(spec)
839
+ state["warmed"] = True
840
+
841
+ def close_drivers():
842
+ for drv in state["drivers"].values():
843
+ try:
844
+ drv.close()
845
+ except Exception: # the stop goes on
846
+ pass
847
+ state["drivers"].clear()
848
+
849
+ def run_way(case, way):
850
+ ev = evidence()
851
+ ev.set_way(way, started=_now())
852
+ run, error = None, None
853
+ try:
854
+ with _Limit(limit_ms, limit_text):
855
+ ready()
856
+ run = Run(ctx, state, way, ev, adapter)
857
+ state["live"] = run
858
+ body(run, way)
859
+ except (Blocked, GateFailed) as thrown:
860
+ error = thrown
861
+ except _OutOfTime as thrown:
862
+ error = thrown
863
+ except Exception as thrown: # a stop of the body is a fail, with its reason
864
+ error = thrown
865
+ verdict, problems = _settle(sc, way, ev, error)
866
+ if run is not None:
867
+ run.close()
868
+ for fn in reversed(run.cleanups):
869
+ try:
870
+ fn()
871
+ except Exception as failed:
872
+ ev.note(f"clean-up failed: {failed}")
873
+ if problems:
874
+ case.fail(state["redactor"].text("\n".join(problems)))
875
+
876
+ def not_here(case):
877
+ ev = evidence()
878
+ for a in sc["assertions"]:
879
+ for way in a["ways"]:
880
+ ev.set_result(a["fullId"], way, "not here", {"why": f"runs-in does not list {environment}"})
881
+ for way in sc["ways"]:
882
+ ev.set_way(way, verdict="not here", started=_now(), ended=_now())
883
+
884
+ members = {"__doc__": f"{sc['id']}: {sc['title']}", "__module__": body.__module__,
885
+ "tearDownClass": classmethod(lambda cls: close_drivers())}
886
+ if not runs_here:
887
+ members["test_not_here"] = lambda case: not_here(case)
888
+ else:
889
+ for way in sc["through"]:
890
+ members[_method_name(way)] = (lambda w: lambda case: run_way(case, w))(way)
891
+ return type(body.__name__, (unittest.TestCase,), members)
892
+
893
+ return wrap