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.
- labtrail/__init__.py +22 -0
- labtrail/__main__.py +6 -0
- labtrail/_version.py +24 -0
- labtrail/check/__init__.py +73 -0
- labtrail/check/_assertions.py +276 -0
- labtrail/check/_normalize.py +121 -0
- labtrail/check/_run.py +136 -0
- labtrail/cli.py +101 -0
- labtrail/collect.py +157 -0
- labtrail/commands/__init__.py +0 -0
- labtrail/commands/_common.py +66 -0
- labtrail/commands/assert_.py +103 -0
- labtrail/commands/check.py +124 -0
- labtrail/commands/collect.py +47 -0
- labtrail/commands/doctor.py +137 -0
- labtrail/commands/list_.py +102 -0
- labtrail/commands/lock.py +186 -0
- labtrail/commands/open_.py +185 -0
- labtrail/commands/propagate.py +178 -0
- labtrail/commands/roster.py +96 -0
- labtrail/commands/status.py +146 -0
- labtrail/commands/validate.py +87 -0
- labtrail/dev/__init__.py +0 -0
- labtrail/dev/__main__.py +71 -0
- labtrail/dev/gen_cli_docs.py +118 -0
- labtrail/dev/gen_skill.py +455 -0
- labtrail/discovery.py +203 -0
- labtrail/doctor.py +358 -0
- labtrail/errors.py +105 -0
- labtrail/execute.py +231 -0
- labtrail/git.py +316 -0
- labtrail/metadata.py +403 -0
- labtrail/model.py +213 -0
- labtrail/propagate.py +543 -0
- labtrail/report/__init__.py +0 -0
- labtrail/report/console.py +173 -0
- labtrail/report/ctrf.py +274 -0
- labtrail/share/skills/labtrail/SKILL.md +218 -0
- labtrail/share/skills/labtrail-authoring/SKILL.md +295 -0
- labtrail/share/skills/labtrail-checks/SKILL.md +302 -0
- labtrail/share/skills/labtrail-learner/SKILL.md +173 -0
- labtrail/share/templates/course-SKILL.md +47 -0
- labtrail/share/templates/lab-SKILL.md +38 -0
- labtrail/share/templates/lab.yaml +29 -0
- labtrail/share/templates/trail.yaml +35 -0
- labtrail/skills.py +76 -0
- labtrail/trail.py +450 -0
- labtrail/validate.py +307 -0
- labtrail/verdict.py +107 -0
- labtrail/worktree.py +176 -0
- labtrail-0.1.0.dist-info/METADATA +185 -0
- labtrail-0.1.0.dist-info/RECORD +56 -0
- labtrail-0.1.0.dist-info/WHEEL +5 -0
- labtrail-0.1.0.dist-info/entry_points.txt +9 -0
- labtrail-0.1.0.dist-info/licenses/LICENSE +73 -0
- 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
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)
|