labtrail 0.1.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.
Files changed (56) hide show
  1. labtrail/__init__.py +22 -0
  2. labtrail/__main__.py +6 -0
  3. labtrail/_version.py +24 -0
  4. labtrail/check/__init__.py +73 -0
  5. labtrail/check/_assertions.py +276 -0
  6. labtrail/check/_normalize.py +121 -0
  7. labtrail/check/_run.py +136 -0
  8. labtrail/cli.py +101 -0
  9. labtrail/collect.py +157 -0
  10. labtrail/commands/__init__.py +0 -0
  11. labtrail/commands/_common.py +66 -0
  12. labtrail/commands/assert_.py +103 -0
  13. labtrail/commands/check.py +124 -0
  14. labtrail/commands/collect.py +47 -0
  15. labtrail/commands/doctor.py +137 -0
  16. labtrail/commands/list_.py +102 -0
  17. labtrail/commands/lock.py +186 -0
  18. labtrail/commands/open_.py +185 -0
  19. labtrail/commands/propagate.py +178 -0
  20. labtrail/commands/roster.py +96 -0
  21. labtrail/commands/status.py +146 -0
  22. labtrail/commands/validate.py +87 -0
  23. labtrail/dev/__init__.py +0 -0
  24. labtrail/dev/__main__.py +71 -0
  25. labtrail/dev/gen_cli_docs.py +118 -0
  26. labtrail/dev/gen_skill.py +455 -0
  27. labtrail/discovery.py +203 -0
  28. labtrail/doctor.py +358 -0
  29. labtrail/errors.py +105 -0
  30. labtrail/execute.py +231 -0
  31. labtrail/git.py +316 -0
  32. labtrail/metadata.py +403 -0
  33. labtrail/model.py +213 -0
  34. labtrail/propagate.py +543 -0
  35. labtrail/report/__init__.py +0 -0
  36. labtrail/report/console.py +173 -0
  37. labtrail/report/ctrf.py +274 -0
  38. labtrail/share/skills/labtrail/SKILL.md +218 -0
  39. labtrail/share/skills/labtrail-authoring/SKILL.md +295 -0
  40. labtrail/share/skills/labtrail-checks/SKILL.md +302 -0
  41. labtrail/share/skills/labtrail-learner/SKILL.md +173 -0
  42. labtrail/share/templates/course-SKILL.md +47 -0
  43. labtrail/share/templates/lab-SKILL.md +38 -0
  44. labtrail/share/templates/lab.yaml +29 -0
  45. labtrail/share/templates/trail.yaml +35 -0
  46. labtrail/skills.py +76 -0
  47. labtrail/trail.py +450 -0
  48. labtrail/validate.py +307 -0
  49. labtrail/verdict.py +107 -0
  50. labtrail/worktree.py +176 -0
  51. labtrail-0.1.0.dist-info/METADATA +185 -0
  52. labtrail-0.1.0.dist-info/RECORD +56 -0
  53. labtrail-0.1.0.dist-info/WHEEL +5 -0
  54. labtrail-0.1.0.dist-info/entry_points.txt +9 -0
  55. labtrail-0.1.0.dist-info/licenses/LICENSE +73 -0
  56. labtrail-0.1.0.dist-info/top_level.txt +1 -0
labtrail/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """labtrail -- git-centric lab trails.
2
+
3
+ This module deliberately re-exports nothing (D-6). The public surface is
4
+ exactly ``labtrail.check``, the ``labtrail`` command line, and the collected
5
+ document schema. Everything else is private and may change in a minor
6
+ release (R5.7).
7
+ """
8
+
9
+ try:
10
+ from ._version import version as __version__
11
+ except ImportError: # pragma: no cover - only when not installed
12
+ try:
13
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
14
+
15
+ try:
16
+ __version__ = _pkg_version("labtrail")
17
+ except PackageNotFoundError:
18
+ __version__ = "0.0.0"
19
+ except ImportError:
20
+ __version__ = "0.0.0"
21
+
22
+ __all__ = ["__version__"]
labtrail/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
labtrail/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,73 @@
1
+ """The public helper API for check scripts.
2
+
3
+ **This module is labtrail's only public Python surface** (R5.5). Everything
4
+ else in the package is private and may change in a minor release. Check
5
+ scripts are committed to a trail's history and call into this module
6
+ forever, so a breaking change here is a major version bump (R5.7) -- which
7
+ is why the surface is deliberately small.
8
+
9
+ A check script is a plain executable (R5.4); importing this is optional and
10
+ changes nothing about how labtrail runs it. What it buys is the exit-code
11
+ contract of R2.16 for free, and failure messages that tell a learner what
12
+ was expected and what happened (R5.3)::
13
+
14
+ #!/usr/bin/env python3
15
+ from labtrail.check import main, assert_golden
16
+
17
+ def check():
18
+ out = run(["./build.sh"]).stdout
19
+ assert_golden(out, "golden/build.txt", normalizers=[timestamps()])
20
+
21
+ main(check)
22
+
23
+ ``main`` is the important part: a raised :class:`CheckFailure` exits 1
24
+ ("the work is wrong") and any other exception exits 2 ("the check could not
25
+ run"), which is the distinction the whole methodology rests on.
26
+ """
27
+
28
+ from ._assertions import (
29
+ CheckFailure,
30
+ assert_contains,
31
+ assert_equal,
32
+ assert_fails,
33
+ assert_golden,
34
+ assert_order,
35
+ assert_succeeds,
36
+ )
37
+ from ._normalize import (
38
+ addresses,
39
+ numbers,
40
+ paths,
41
+ regex_sub,
42
+ seeds,
43
+ timestamps,
44
+ trailing_ws,
45
+ )
46
+ from ._run import entrypoint, main, run
47
+
48
+ #: The frozen public surface (R5.5). A test asserts this list literally, so
49
+ #: adding a name here is a deliberate act with a compatibility obligation
50
+ #: attached, not a side effect of writing a new function.
51
+ __all__ = [
52
+ # Entry points
53
+ "main",
54
+ "entrypoint",
55
+ "run",
56
+ # Failure
57
+ "CheckFailure",
58
+ # Assertions (R5.1)
59
+ "assert_golden",
60
+ "assert_order",
61
+ "assert_fails",
62
+ "assert_succeeds",
63
+ "assert_equal",
64
+ "assert_contains",
65
+ # Normalizers for volatile fields (R5.1)
66
+ "timestamps",
67
+ "paths",
68
+ "addresses",
69
+ "seeds",
70
+ "numbers",
71
+ "trailing_ws",
72
+ "regex_sub",
73
+ ]
@@ -0,0 +1,276 @@
1
+ """Assertions for check scripts (R5.1, R5.3).
2
+
3
+ Every failure here produces the same shape of message: what was expected,
4
+ what happened, and -- where it helps -- a diff. That consistency is most of
5
+ the value. A learner reads these messages more often than they read the lab
6
+ text, and the difference between "assertion failed" and a labelled diff is
7
+ the difference between a check that teaches and a check that stonewalls.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Callable, Iterable, Sequence
16
+
17
+ #: A normalizer is just str -> str. Deliberately not a class: a course
18
+ #: author writing an ad-hoc one should not have to learn an interface.
19
+ Normalizer = Callable[[str], str]
20
+
21
+ MAX_DIFF_LINES = 60
22
+
23
+
24
+ class CheckFailure(AssertionError):
25
+ """The work is wrong.
26
+
27
+ Raised by every assertion in this module, and mapped to exit 1 by
28
+ :func:`labtrail.check.main` (R2.16). Subclasses ``AssertionError`` so a
29
+ check written with bare ``assert`` and a check written with these
30
+ helpers behave the same way under ``pytest``.
31
+ """
32
+
33
+ def __init__(self, summary: str, *, expected: object = None,
34
+ actual: object = None, detail: str | None = None,
35
+ hint: str | None = None):
36
+ self.summary = summary
37
+ self.expected = expected
38
+ self.actual = actual
39
+ self.detail = detail
40
+ self.hint = hint
41
+ super().__init__(self._format())
42
+
43
+ def _format(self) -> str:
44
+ parts = [self.summary]
45
+ if self.expected is not None or self.actual is not None:
46
+ parts.append(f" expected: {_brief(self.expected)}")
47
+ parts.append(f" actual: {_brief(self.actual)}")
48
+ if self.detail:
49
+ parts.append(self.detail)
50
+ if self.hint:
51
+ parts.append(f" hint: {self.hint}")
52
+ return "\n".join(parts)
53
+
54
+
55
+ def _brief(value: object, limit: int = 200) -> str:
56
+ text = value if isinstance(value, str) else repr(value)
57
+ text = text.replace("\n", "\\n")
58
+ return text if len(text) <= limit else text[:limit] + " ..."
59
+
60
+
61
+ def _apply(text: str, normalizers: Iterable[Normalizer] | None) -> str:
62
+ for norm in normalizers or ():
63
+ text = norm(text)
64
+ return text
65
+
66
+
67
+ def _diff(expected: str, actual: str, expected_label: str,
68
+ actual_label: str) -> str:
69
+ lines = list(difflib.unified_diff(
70
+ expected.splitlines(keepends=True), actual.splitlines(keepends=True),
71
+ fromfile=expected_label, tofile=actual_label, n=3,
72
+ ))
73
+ if len(lines) > MAX_DIFF_LINES:
74
+ dropped = len(lines) - MAX_DIFF_LINES
75
+ lines = lines[:MAX_DIFF_LINES] + [f"... {dropped} more diff lines ...\n"]
76
+ return "".join(lines).rstrip()
77
+
78
+
79
+ # -- golden comparison -------------------------------------------------
80
+
81
+
82
+ def assert_golden(actual: str, golden: str | os.PathLike[str], *,
83
+ normalizers: Sequence[Normalizer] = (),
84
+ message: str | None = None) -> None:
85
+ """Compare output against a golden file (R5.1).
86
+
87
+ ``normalizers`` are applied to *both* sides, so a golden file may be
88
+ recorded from a real run without hand-editing the timestamps out of it.
89
+
90
+ Set ``LABTRAIL_UPDATE_GOLDEN=1`` to rewrite the golden from the actual
91
+ output. That is an author's tool: it is off by default and it says what
92
+ it did, because a golden that silently updates proves nothing.
93
+ """
94
+ path = Path(golden)
95
+ if os.environ.get("LABTRAIL_UPDATE_GOLDEN"):
96
+ path.parent.mkdir(parents=True, exist_ok=True)
97
+ path.write_text(actual)
98
+ print(f"labtrail: updated golden {path}")
99
+ return
100
+ if not path.exists():
101
+ raise CheckFailure(
102
+ f"the golden file {path} does not exist",
103
+ hint="this is a defect in the lab; the check cannot compare "
104
+ "against a file that is not there",
105
+ )
106
+ expected = path.read_text()
107
+ if _apply(expected, normalizers) == _apply(actual, normalizers):
108
+ return
109
+ raise CheckFailure(
110
+ message or f"output does not match {path}",
111
+ detail=_diff(_apply(expected, normalizers), _apply(actual, normalizers),
112
+ f"expected ({path})", "actual"),
113
+ hint="the '-' lines are what was expected; the '+' lines are what "
114
+ "your program produced",
115
+ )
116
+
117
+
118
+ # -- ordering ----------------------------------------------------------
119
+
120
+
121
+ def assert_order(sequence: Sequence[str] | str, *items: str,
122
+ message: str | None = None) -> None:
123
+ """Assert that items appear in this order within a sequence (R5.1).
124
+
125
+ ``sequence`` may be a list of lines or a block of text. The items need
126
+ not be adjacent and need not be the only things present -- this checks
127
+ order, not equality, because "the reset came before the first write" is
128
+ the shape of the question.
129
+ """
130
+ lines = sequence.splitlines() if isinstance(sequence, str) else list(sequence)
131
+ positions: list[int] = []
132
+ for item in items:
133
+ start = positions[-1] + 1 if positions else 0
134
+ for i in range(start, len(lines)):
135
+ if item in lines[i]:
136
+ positions.append(i)
137
+ break
138
+ else:
139
+ if any(item in line for line in lines):
140
+ previous = items[len(positions) - 1]
141
+ raise CheckFailure(
142
+ message or f"{item!r} appears, but not after {previous!r}",
143
+ detail=_numbered(lines, items),
144
+ hint="the expected order is: " + " -> ".join(map(repr, items)),
145
+ )
146
+ raise CheckFailure(
147
+ message or f"{item!r} never appears",
148
+ detail=_numbered(lines, items),
149
+ hint="the expected order is: " + " -> ".join(map(repr, items)),
150
+ )
151
+
152
+
153
+ def _numbered(lines: Sequence[str], items: Sequence[str]) -> str:
154
+ """Show the input with the items of interest marked."""
155
+ out = []
156
+ for i, line in enumerate(lines[:40]):
157
+ mark = ">" if any(item in line for item in items) else " "
158
+ out.append(f" {mark} {i + 1:4d}| {line.rstrip()}")
159
+ if len(lines) > 40:
160
+ out.append(f" ... {len(lines) - 40} more lines ...")
161
+ return "\n".join(out)
162
+
163
+
164
+ # -- process outcome ---------------------------------------------------
165
+
166
+
167
+ def assert_fails(result_or_argv, *, exit_code: int | None = None,
168
+ stderr_contains: str | None = None,
169
+ message: str | None = None):
170
+ """Assert that a command fails as expected (R5.1).
171
+
172
+ Accepts either a :class:`labtrail.check.run` result or an argv to run.
173
+ "Does the tool reject bad input" is a common lab, and writing it by hand
174
+ invites the bug where the assertion passes because the command was never
175
+ found.
176
+ """
177
+ result = _resolve(result_or_argv)
178
+ if result.returncode == 0:
179
+ raise CheckFailure(
180
+ message or f"expected {_cmd(result)} to fail, but it succeeded",
181
+ expected="a non-zero exit", actual="exit 0",
182
+ detail=_streams(result),
183
+ )
184
+ if exit_code is not None and result.returncode != exit_code:
185
+ raise CheckFailure(
186
+ message or f"{_cmd(result)} failed with the wrong exit code",
187
+ expected=f"exit {exit_code}", actual=f"exit {result.returncode}",
188
+ detail=_streams(result),
189
+ )
190
+ if stderr_contains is not None and stderr_contains not in result.stderr:
191
+ raise CheckFailure(
192
+ message or f"{_cmd(result)} did not report the expected error",
193
+ expected=f"stderr containing {stderr_contains!r}",
194
+ actual=_brief(result.stderr),
195
+ detail=_streams(result),
196
+ )
197
+ return result
198
+
199
+
200
+ def assert_succeeds(result_or_argv, *, message: str | None = None):
201
+ """Assert a command exits 0, showing its output when it does not."""
202
+ result = _resolve(result_or_argv)
203
+ if result.returncode != 0:
204
+ raise CheckFailure(
205
+ message or f"{_cmd(result)} failed",
206
+ expected="exit 0", actual=f"exit {result.returncode}",
207
+ detail=_streams(result),
208
+ )
209
+ return result
210
+
211
+
212
+ def _resolve(result_or_argv):
213
+ from ._run import Completed, run
214
+
215
+ if isinstance(result_or_argv, Completed):
216
+ return result_or_argv
217
+ return run(result_or_argv)
218
+
219
+
220
+ def _cmd(result) -> str:
221
+ return " ".join(result.argv)
222
+
223
+
224
+ def _streams(result) -> str:
225
+ out = []
226
+ if result.stdout.strip():
227
+ out.append(" stdout:\n" + _indent(result.stdout))
228
+ if result.stderr.strip():
229
+ out.append(" stderr:\n" + _indent(result.stderr))
230
+ return "\n".join(out)
231
+
232
+
233
+ def _indent(text: str, prefix: str = " ") -> str:
234
+ return "\n".join(prefix + line for line in text.rstrip().splitlines())
235
+
236
+
237
+ # -- the two everyone writes by hand anyway ----------------------------
238
+
239
+
240
+ def assert_equal(actual, expected, *, message: str | None = None,
241
+ normalizers: Sequence[Normalizer] = ()) -> None:
242
+ """Equality, with a diff for multi-line strings."""
243
+ a, e = actual, expected
244
+ if isinstance(a, str) and isinstance(e, str):
245
+ a, e = _apply(a, normalizers), _apply(e, normalizers)
246
+ if a == e:
247
+ return
248
+ detail = None
249
+ if isinstance(a, str) and isinstance(e, str) and ("\n" in a or "\n" in e):
250
+ detail = _diff(e, a, "expected", "actual")
251
+ raise CheckFailure(message or "values differ", expected=e, actual=a,
252
+ detail=detail)
253
+
254
+
255
+ def assert_contains(haystack: str, needle: str, *,
256
+ message: str | None = None) -> None:
257
+ """Substring presence, showing the haystack when it is missing."""
258
+ if needle in haystack:
259
+ return
260
+ raise CheckFailure(
261
+ message or f"expected to find {needle!r}",
262
+ expected=needle, actual=_brief(haystack),
263
+ detail=" in:\n" + _indent(haystack[:2000]),
264
+ )
265
+
266
+
267
+ #: Exported for the ``labtrail assert`` shell entry point (R5.2). Keyed by
268
+ #: the name a shell script spells.
269
+ SHELL_ASSERTIONS = {
270
+ "golden": assert_golden,
271
+ "order": assert_order,
272
+ "fails": assert_fails,
273
+ "succeeds": assert_succeeds,
274
+ "equal": assert_equal,
275
+ "contains": assert_contains,
276
+ }
@@ -0,0 +1,121 @@
1
+ """Normalizers for volatile fields (R5.1).
2
+
3
+ A golden comparison is only useful if it fails when the work is wrong and
4
+ not when the clock moved. Each function here returns a ``str -> str``
5
+ callable, so they compose in the order given::
6
+
7
+ assert_golden(out, "golden.txt",
8
+ normalizers=[timestamps(), paths(repo_root), trailing_ws()])
9
+
10
+ Every one of them replaces the volatile text with a stable *label* rather
11
+ than deleting it. A golden that reads ``simulation finished at <TIME>``
12
+ still shows a reader that a time was printed; one with the time silently
13
+ removed does not, and the next author to edit the golden will not know to
14
+ put it back.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ from typing import Callable
22
+
23
+ Normalizer = Callable[[str], str]
24
+
25
+ # ISO-8601-ish, "Mon Jan 2 15:04:05 2006", and bare clock times.
26
+ _TIMESTAMP_PATTERNS = [
27
+ r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?",
28
+ r"\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+"
29
+ r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+"
30
+ r"\d{1,2}\s+\d{2}:\d{2}:\d{2}(?:\s+\d{4})?",
31
+ r"\b\d{2}:\d{2}:\d{2}(?:\.\d+)?\b",
32
+ ]
33
+
34
+ _DURATION = re.compile(
35
+ r"\b\d+(?:\.\d+)?\s?(?:ns|us|ms|s|sec|secs|seconds|min|minutes|h|hours)\b",
36
+ re.IGNORECASE)
37
+ _ADDRESS = re.compile(r"\b0x[0-9a-fA-F]{4,}\b")
38
+ _SEED = re.compile(r"(?i)\b(seed|random_seed|rng[_ ]?seed)\b(\s*[:=]\s*)(0x[0-9a-fA-F]+|\d+)")
39
+ _NUMBER = re.compile(r"-?\b\d+(?:\.\d+)?\b")
40
+
41
+
42
+ def timestamps(label: str = "<TIME>", *, durations: bool = True) -> Normalizer:
43
+ """Replace dates, clock times and (by default) durations.
44
+
45
+ Durations are included because "elapsed: 1.34s" is the single most
46
+ common reason a golden that should be stable is not.
47
+ """
48
+ compiled = [re.compile(p) for p in _TIMESTAMP_PATTERNS]
49
+
50
+ def normalize(text: str) -> str:
51
+ for pattern in compiled:
52
+ text = pattern.sub(label, text)
53
+ if durations:
54
+ text = _DURATION.sub("<DURATION>", text)
55
+ return text
56
+
57
+ return normalize
58
+
59
+
60
+ def paths(root: str | os.PathLike[str] | None = None,
61
+ label: str = "<ROOT>") -> Normalizer:
62
+ """Replace absolute paths under ``root`` with a label.
63
+
64
+ Defaults to the current working directory, which is the lab's worktree
65
+ when labtrail runs the check -- and that path is a temporary directory,
66
+ so a golden recorded with it in is worthless.
67
+ """
68
+ base = str(os.path.abspath(root if root is not None else os.getcwd()))
69
+
70
+ def normalize(text: str) -> str:
71
+ text = text.replace(base, label)
72
+ # Also catch the resolved form, which differs on macOS (/private/...)
73
+ real = os.path.realpath(base)
74
+ if real != base:
75
+ text = text.replace(real, label)
76
+ return text
77
+
78
+ return normalize
79
+
80
+
81
+ def addresses(label: str = "<ADDR>") -> Normalizer:
82
+ """Replace hexadecimal addresses, e.g. in a backtrace or a dump."""
83
+ return lambda text: _ADDRESS.sub(label, text)
84
+
85
+
86
+ def seeds(label: str = "<SEED>") -> Normalizer:
87
+ """Replace ``seed = 12345`` and its spellings, keeping the label."""
88
+ return lambda text: _SEED.sub(lambda m: f"{m.group(1)}{m.group(2)}{label}", text)
89
+
90
+
91
+ def numbers(label: str = "<N>") -> Normalizer:
92
+ """Replace every number.
93
+
94
+ A blunt instrument, and usually the wrong one -- a golden with no
95
+ numbers in it often no longer tests anything. Provided because there
96
+ are cases (a coverage percentage, a cycle count) where the number is
97
+ genuinely not the subject, and an author who needs it should not have
98
+ to write the regex.
99
+ """
100
+ return lambda text: _NUMBER.sub(label, text)
101
+
102
+
103
+ def trailing_ws() -> Normalizer:
104
+ """Strip trailing whitespace per line and normalise the final newline."""
105
+
106
+ def normalize(text: str) -> str:
107
+ lines = [line.rstrip() for line in text.splitlines()]
108
+ return "\n".join(lines) + ("\n" if text else "")
109
+
110
+ return normalize
111
+
112
+
113
+ def regex_sub(pattern: str, replacement: str = "<X>", *, flags: int = 0) -> Normalizer:
114
+ """The escape hatch: an arbitrary substitution.
115
+
116
+ Every normalizer above is a special case of this one. It exists so that
117
+ a course with a volatile field labtrail has never heard of does not have
118
+ to wait for a labtrail release.
119
+ """
120
+ compiled = re.compile(pattern, flags)
121
+ return lambda text: compiled.sub(replacement, text)
labtrail/check/_run.py ADDED
@@ -0,0 +1,136 @@
1
+ """Entry point and process helpers for check scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import traceback
9
+ from dataclasses import dataclass
10
+ from typing import Callable
11
+
12
+ from ._assertions import CheckFailure
13
+
14
+ EXIT_CORRECT = 0
15
+ EXIT_WRONG = 1
16
+ EXIT_COULD_NOT_RUN = 2
17
+
18
+
19
+ def main(fn: Callable[[], object] | None = None, *, argv: list[str] | None = None) -> int:
20
+ """Run a check function and exit with the code R2.16 requires.
21
+
22
+ ========================= ==== ===================================
23
+ what happened exit what labtrail concludes
24
+ ========================= ==== ===================================
25
+ returned normally 0 the work is correct
26
+ raised CheckFailure 1 the check ran; the work is wrong
27
+ raised a bare Assertion- 1 likewise
28
+ Error
29
+ raised anything else 2 the check could not run
30
+ ========================= ==== ===================================
31
+
32
+ That last row is the one worth being careful about, and it is why this
33
+ function exists rather than a bare ``assert``. A ``NameError`` in a
34
+ check script is *not* a learner's mistake; reporting it as one sends
35
+ them to debug their own correct work. So an unexpected exception exits
36
+ 2 and prints a traceback, which labtrail reports as a broken lab (R4.3)
37
+ and an author can act on.
38
+
39
+ ``AssertionError`` is treated as a check failure so that ``assert x ==
40
+ y`` and ``assert_equal(x, y)`` behave the same way -- R2.16 names exit 1
41
+ "assertion failed", and an author who writes the plain spelling should
42
+ get the plain meaning. The cost is that an ``assert`` inside a library
43
+ the check imports is misreported as the learner being wrong. That is
44
+ the rarer case, and the fix (use ``CheckFailure``, or catch it) is
45
+ available to an author who hits it.
46
+
47
+ Called with no argument, returns a decorator, so a script may write
48
+ either ``main(check)`` or ``@main`` above the function.
49
+ """
50
+ if fn is None:
51
+ return entrypoint # type: ignore[return-value]
52
+
53
+ try:
54
+ fn()
55
+ except CheckFailure as e:
56
+ print(str(e), file=sys.stderr)
57
+ sys.exit(EXIT_WRONG)
58
+ except AssertionError as e:
59
+ print(f"check failed: {e}" if str(e) else "check failed", file=sys.stderr)
60
+ traceback.print_exc()
61
+ sys.exit(EXIT_WRONG)
62
+ except KeyboardInterrupt:
63
+ print("check: interrupted", file=sys.stderr)
64
+ sys.exit(EXIT_COULD_NOT_RUN)
65
+ except SystemExit:
66
+ raise
67
+ except BaseException: # noqa: BLE001 - deliberately broad; see the docstring
68
+ print("check: the check itself failed to run. This is a defect in the "
69
+ "lab, not in your work.", file=sys.stderr)
70
+ traceback.print_exc()
71
+ sys.exit(EXIT_COULD_NOT_RUN)
72
+ sys.exit(EXIT_CORRECT)
73
+
74
+
75
+ def entrypoint(fn: Callable[[], object]) -> Callable[[], object]:
76
+ """Decorator form of :func:`main`.
77
+
78
+ ::
79
+
80
+ @entrypoint
81
+ def check():
82
+ ...
83
+ """
84
+ if os.environ.get("LABTRAIL_NO_AUTORUN"):
85
+ return fn
86
+ main(fn)
87
+ return fn
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Completed:
92
+ """The result of :func:`run`."""
93
+
94
+ argv: list[str]
95
+ returncode: int
96
+ stdout: str
97
+ stderr: str
98
+
99
+ @property
100
+ def ok(self) -> bool:
101
+ return self.returncode == 0
102
+
103
+ @property
104
+ def output(self) -> str:
105
+ """Both streams, in the order a terminal would have shown them."""
106
+ return self.stdout + self.stderr
107
+
108
+
109
+ def run(argv: list[str] | str, *, cwd: str | os.PathLike[str] | None = None,
110
+ env: dict[str, str] | None = None, timeout: float | None = None,
111
+ input: str | None = None) -> Completed:
112
+ """Run a command and capture it. No shell.
113
+
114
+ A string is split with ``shlex``; there is still no shell, so a pipeline
115
+ belongs in a script. That is the same rule the lab metadata follows, for
116
+ the same reason (R2.16).
117
+ """
118
+ import shlex
119
+
120
+ if isinstance(argv, str):
121
+ argv = shlex.split(argv)
122
+ full_env = {**os.environ, **(env or {})}
123
+ try:
124
+ proc = subprocess.run(
125
+ list(argv), cwd=cwd, env=full_env, capture_output=True, text=True,
126
+ timeout=timeout, input=input, errors="replace",
127
+ )
128
+ except FileNotFoundError as e:
129
+ # Missing tool: the check could not run. Raising (rather than
130
+ # returning 127) sends this to main()'s exit-2 path, which is
131
+ # where a missing tool belongs.
132
+ raise RuntimeError(f"{e.strerror}: {argv[0]}") from e
133
+ except subprocess.TimeoutExpired as e:
134
+ raise RuntimeError(f"timed out after {timeout}s: {' '.join(argv)}") from e
135
+ return Completed(argv=list(argv), returncode=proc.returncode,
136
+ stdout=proc.stdout, stderr=proc.stderr)