fherma-runner 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """A FHERMA runner: takes measurement jobs and reports what happened.
2
+
3
+ Nothing here imports anything that does not ship with Python, and docker is
4
+ called as a command rather than through a library. Installing this on a server
5
+ is copying a directory.
6
+ """
7
+
8
+ __version__ = "0.2.0"
@@ -0,0 +1,129 @@
1
+ """The command line, and the loop that outlives everything under it.
2
+
3
+ fherma-runner --api https://fherma.io --name bench-01
4
+ fherma-runner --once one assignment, then stop
5
+ fherma-runner --re-register forget the key and join again
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+ import time
14
+ from importlib import metadata
15
+
16
+ from . import job, machine
17
+ from .api import Api, Refused, Unreachable
18
+ from .session import KEYFILE, describe, identify
19
+
20
+
21
+ def version() -> str:
22
+ """What is installed, asked of the installation rather than hardcoded."""
23
+ try:
24
+ return metadata.version("fherma-runner")
25
+ except metadata.PackageNotFoundError:
26
+ return "unknown (running from a checkout)"
27
+
28
+
29
+ def parse(argv: list[str] | None = None) -> argparse.Namespace:
30
+ parser = argparse.ArgumentParser(
31
+ prog="fherma-runner",
32
+ description="Takes measurement jobs from a FHERMA platform and reports what happened.",
33
+ )
34
+ parser.add_argument("--api", default=os.environ.get("FHERMA_API", "http://localhost:4000"))
35
+ parser.add_argument("--name", default=os.environ.get("FHERMA_RUNNER_NAME", "runner"))
36
+ parser.add_argument("--interval", type=float, default=3.0, help="seconds between polls")
37
+ parser.add_argument("--once", action="store_true", help="one assignment, then stop")
38
+ parser.add_argument("--token", help="an invitation, exchanged once for this machine's key")
39
+ parser.add_argument("--re-register", action="store_true", help="forget the key and join again")
40
+ parser.add_argument("--describe", action="store_true", help="print what this machine is, and stop")
41
+ # The first question anybody asks after an install that may not have worked.
42
+ # One line rather than three, because argparse reflows the text it prints
43
+ # and a newline here comes out as a space.
44
+ parser.add_argument(
45
+ "--version",
46
+ action="version",
47
+ version=f"fherma-runner {version()} · python {sys.version.split()[0]}",
48
+ )
49
+
50
+ limits = parser.add_argument_group(
51
+ "limits",
52
+ "What a solution's container may take. The platform sets the time limits; "
53
+ "these are the machine's own.",
54
+ )
55
+ limits.add_argument("--memory", default=os.environ.get("FHERMA_MEMORY", "8g"))
56
+ limits.add_argument("--cpus", default=os.environ.get("FHERMA_CPUS"))
57
+
58
+ return parser.parse_args(argv)
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ options = parse(argv)
63
+
64
+ if options.describe:
65
+ for name, value in machine.describe().items():
66
+ print(f"{name:12} {value}")
67
+ print(f"{'can':12} {', '.join(one['name'] for one in machine.capabilities()) or 'nothing'}")
68
+ return 0
69
+
70
+ if options.re_register and KEYFILE.exists():
71
+ KEYFILE.unlink()
72
+
73
+ api = Api(options.api)
74
+
75
+ while True:
76
+ try:
77
+ api.key = identify(api, options.name, options.token)
78
+ break
79
+ except Refused as failure:
80
+ print(f"could not register: {failure}", file=sys.stderr)
81
+ return 1
82
+ except Unreachable as failure:
83
+ # A runner boots when its machine boots, which may be before the
84
+ # platform is up. Waiting is the correct answer; exiting would need
85
+ # somebody to notice and start it again.
86
+ print(f"waiting for {options.api}: {failure}", file=sys.stderr)
87
+ time.sleep(options.interval)
88
+
89
+ try:
90
+ describe(api)
91
+ except Refused as failure:
92
+ if failure.status in (401, 403):
93
+ print("the key was refused — try --re-register", file=sys.stderr)
94
+ return 1
95
+ raise
96
+ except Unreachable as failure:
97
+ print(f"could not describe this machine: {failure}", file=sys.stderr)
98
+
99
+ print(f"polling {options.api} every {options.interval}s — ctrl-c to stop")
100
+
101
+ while True:
102
+ try:
103
+ did = job.take(api, options)
104
+ except Refused as failure:
105
+ # A lapsed lease is the ordinary case: stop working on it and ask
106
+ # again rather than reporting into a result somebody else now owns.
107
+ print(f" refused ({failure.status}) — dropping this one", file=sys.stderr)
108
+ did = False
109
+ except Unreachable as failure:
110
+ print(f" cannot reach {options.api}: {failure}", file=sys.stderr)
111
+ did = False
112
+
113
+ if did and options.once:
114
+ return 0
115
+ if not did:
116
+ print(".", end="", flush=True)
117
+ time.sleep(options.interval)
118
+
119
+
120
+ def run() -> None:
121
+ """The console entry point. Ctrl-C is an ending, not a crash."""
122
+ try:
123
+ raise SystemExit(main())
124
+ except KeyboardInterrupt:
125
+ print()
126
+
127
+
128
+ if __name__ == "__main__":
129
+ run()
fherma_runner/api.py ADDED
@@ -0,0 +1,80 @@
1
+ """Talking to the platform, and the three ways it can go wrong.
2
+
3
+ The runner is the only one of the three programs on a machine that speaks to
4
+ the platform at all. The bundle is handed a directory; the solution is handed a
5
+ directory; neither has a key and neither has a network.
6
+
7
+ Nothing here is imported that does not ship with Python. A machine that runs
8
+ this needs nothing installed beyond an interpreter and docker.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import urllib.error
15
+ import urllib.request
16
+
17
+
18
+ class Refused(Exception):
19
+ """The platform answered, and the answer was no."""
20
+
21
+ def __init__(self, status: int, detail: str) -> None:
22
+ super().__init__(f"{status}: {detail}")
23
+ self.status = status
24
+ self.detail = detail
25
+
26
+
27
+ class Unreachable(Exception):
28
+ """The platform did not answer. Says nothing about the work."""
29
+
30
+
31
+ class Dropped(Exception):
32
+ """This assignment is no longer ours. Stop, and send nothing."""
33
+
34
+
35
+ class Api:
36
+ """The calls a runner makes, and the one that gets it a key.
37
+
38
+ Errors are not swallowed. A runner that carries on after a refusal is a
39
+ runner reporting into somebody else's result, and the refusals here mean
40
+ exactly that: the lease lapsed, the assignment went elsewhere.
41
+ """
42
+
43
+ def __init__(self, base: str, key: str | None = None, timeout: float = 30) -> None:
44
+ self.base = base.rstrip("/")
45
+ self.key = key
46
+ self.timeout = timeout
47
+
48
+ def call(self, method: str, path: str, body: dict | None = None) -> dict:
49
+ raw = self._request(method, path, body)
50
+ return json.loads(raw) if raw else {}
51
+
52
+ def download(self, path: str) -> bytes:
53
+ """A file rather than a document — the bundle archive, and only that."""
54
+ return self._request("GET", path, None, decode=False)
55
+
56
+ def _request(self, method: str, path: str, body: dict | None, decode: bool = True):
57
+ request = urllib.request.Request(
58
+ f"{self.base}/api{path}",
59
+ method=method,
60
+ data=json.dumps(body).encode() if body is not None else None,
61
+ headers={
62
+ "content-type": "application/json",
63
+ **({"x-fherma-runner": self.key} if self.key else {}),
64
+ },
65
+ )
66
+
67
+ try:
68
+ with urllib.request.urlopen(request, timeout=self.timeout) as answer:
69
+ raw = answer.read()
70
+ return raw.decode() if decode else raw
71
+ except urllib.error.HTTPError as failure:
72
+ detail = failure.read().decode()[:300]
73
+ raise Refused(failure.code, detail) from None
74
+ except OSError as failure:
75
+ # Refused connections arrive as URLError; a read that times out
76
+ # half-way through arrives as a bare TimeoutError, and a platform
77
+ # restarting mid-request produces both. They mean one thing to a
78
+ # runner — nobody answered — and a machine meant to sit in a rack
79
+ # for weeks must not die of any of them.
80
+ raise Unreachable(str(getattr(failure, "reason", None) or failure)) from None
@@ -0,0 +1,146 @@
1
+ """Running containers, by calling the command rather than a library.
2
+
3
+ A library would be a dependency, and the point of having none is that putting
4
+ this on a server is copying a directory. `docker` is already there — a machine
5
+ that cannot run containers cannot be a runner at all.
6
+
7
+ Everything here is one container, started and finished. Nothing is left
8
+ running, and nothing is reused between points: a process that outlived its
9
+ point would be measuring the next one with the previous one's memory.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import shutil
15
+ import subprocess
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+
20
+ class DockerMissing(Exception):
21
+ """No docker on this machine, which is not something to work around."""
22
+
23
+
24
+ class Timeout(Exception):
25
+ """It ran past its limit and was killed."""
26
+
27
+
28
+ @dataclass
29
+ class Mount:
30
+ """One path from the host, and whether the container may write to it."""
31
+
32
+ source: Path
33
+ target: str
34
+ write: bool = False
35
+
36
+ def arg(self) -> str:
37
+ return f"{self.source}:{self.target}{'' if self.write else ':ro'}"
38
+
39
+
40
+ @dataclass
41
+ class Limits:
42
+ """What a container may take. None leaves docker's own default."""
43
+
44
+ memory: str | None = None
45
+ cpus: str | None = None
46
+ pids: int = 512
47
+
48
+
49
+ @dataclass
50
+ class Result:
51
+ code: int
52
+ out: str
53
+ err: str
54
+
55
+ @property
56
+ def ok(self) -> bool:
57
+ return self.code == 0
58
+
59
+
60
+ def available() -> bool:
61
+ return shutil.which("docker") is not None
62
+
63
+
64
+ def pull(reference: str, timeout: float = 900) -> Result:
65
+ """Fetch an image by digest.
66
+
67
+ By digest, not by tag: a tag is a pointer, and an image rebuilt under the
68
+ same tag would silently become what a past measurement claims to have run
69
+ in.
70
+ """
71
+ return _run(["docker", "pull", "--quiet", reference], timeout)
72
+
73
+
74
+ def run(
75
+ image: str,
76
+ command: list[str],
77
+ mounts: list[Mount] | None = None,
78
+ workdir: str | None = None,
79
+ limits: Limits | None = None,
80
+ network: bool = False,
81
+ timeout: float = 600,
82
+ name: str | None = None,
83
+ ) -> Result:
84
+ """One container, to completion.
85
+
86
+ Network is off unless asked for. The only stage that needs it is the build,
87
+ where dependencies are fetched; a measurement that could reach the network
88
+ could fetch its answer.
89
+ """
90
+ argv = ["docker", "run", "--rm"]
91
+
92
+ if name:
93
+ argv += ["--name", name]
94
+ if not network:
95
+ argv += ["--network", "none"]
96
+
97
+ limits = limits or Limits()
98
+ if limits.memory:
99
+ argv += ["--memory", limits.memory]
100
+ if limits.cpus:
101
+ argv += ["--cpus", limits.cpus]
102
+ if limits.pids:
103
+ argv += ["--pids-limit", str(limits.pids)]
104
+
105
+ # Author code, even when the fleet is ours.
106
+ argv += ["--cap-drop", "ALL", "--security-opt", "no-new-privileges"]
107
+
108
+ # The command is complete: the bundle's comes from its record, the
109
+ # solution's from its language. An image with an ENTRYPOINT would prepend
110
+ # to it — `python` in front of `python main.py` — so it is cleared.
111
+ argv += ["--entrypoint", ""]
112
+
113
+ for mount in mounts or []:
114
+ mount.source.mkdir(parents=True, exist_ok=True) if mount.write else None
115
+ argv += ["-v", mount.arg()]
116
+
117
+ if workdir:
118
+ argv += ["-w", workdir]
119
+
120
+ argv.append(image)
121
+ argv += command
122
+
123
+ try:
124
+ return _run(argv, timeout)
125
+ except Timeout:
126
+ if name:
127
+ # The container outlives the client that started it, so killing the
128
+ # subprocess is not killing the work.
129
+ _run(["docker", "kill", name], timeout=30)
130
+ raise
131
+
132
+
133
+ def _run(argv: list[str], timeout: float) -> Result:
134
+ if not available():
135
+ raise DockerMissing("docker is not on the path")
136
+
137
+ try:
138
+ done = subprocess.run(
139
+ argv, capture_output=True, text=True, timeout=timeout, check=False
140
+ )
141
+ except subprocess.TimeoutExpired as expired:
142
+ raise Timeout(f"ran past {timeout:.0f}s") from expired
143
+ except OSError as failure:
144
+ raise DockerMissing(str(failure)) from failure
145
+
146
+ return Result(code=done.returncode, out=done.stdout or "", err=done.stderr or "")
fherma_runner/job.py ADDED
@@ -0,0 +1,226 @@
1
+ """One assignment, start to finish.
2
+
3
+ Take the job, fetch what it names, build once, then a point at a time: make the
4
+ cases, answer them, judge the answers, report, delete. Report per point, so a
5
+ run that dies on the seventh of ten keeps the six it finished.
6
+
7
+ Send nothing at all if the assignment stopped being ours. Finishing the work
8
+ and reporting it would be reporting into a result somebody else now owns.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import shutil
15
+
16
+ from . import docker, measure, workspace
17
+ from .api import Api, Dropped, Refused
18
+ from .docker import Limits
19
+ from .log import Log
20
+ from .measure import Environment, Plan
21
+ from .session import beat
22
+ from .workspace import Failed, Workspace
23
+
24
+
25
+ def take(api: Api, options: argparse.Namespace) -> bool:
26
+ """One assignment. False when there was nothing to do."""
27
+ answer = api.call("GET", "/runners/me/work")
28
+ work = answer.get("work")
29
+ if not work:
30
+ return False
31
+
32
+ run = work["run"]
33
+ log = Log()
34
+ place = Workspace.make(run)
35
+
36
+ try:
37
+ return _do(api, work, place, log, options)
38
+ except Dropped as why:
39
+ log.warn(f"dropped: {why}")
40
+ return True
41
+ except Failed as why:
42
+ log.fail(str(why))
43
+ _give_up(api, run, str(why), log)
44
+ return True
45
+ except docker.DockerMissing as why:
46
+ log.fail(f"docker: {why}")
47
+ _give_up(api, run, f"this machine cannot run containers: {why}", log)
48
+ return True
49
+ finally:
50
+ place.clear()
51
+
52
+
53
+ def _do(api: Api, work: dict, place: Workspace, log: Log, options) -> bool:
54
+ run = work["run"]
55
+ points = [Plan(**{k: one[k] for k in ("id", "point", "seeds")}) for one in work.get("points") or []]
56
+ where = _environment(work)
57
+ limits = Limits(memory=options.memory, cpus=options.cpus)
58
+ seconds = work.get("limits") or {}
59
+
60
+ log.done(f"run {run[:8]} · {len(points)} point(s) · {sum(len(p.seeds) for p in points)} case(s)")
61
+ log.info(f"solution runs in {where.solution}")
62
+ log.info(f"bundle runs in {where.bundle}")
63
+
64
+ log.step("pulling images")
65
+ for image in {where.solution, where.bundle}:
66
+ pulled = docker.pull(image)
67
+ if not pulled.ok:
68
+ raise Failed(f"could not pull {image}: {pulled.err.strip()[:300]}")
69
+ log.ok("images ready")
70
+
71
+ log.step("fetching the bundle")
72
+ workspace.fetch_bundle(api, place, work.get("bundle") or {})
73
+ log.ok(f"bundle unpacked · {len(list(place.bundle.iterdir()))} file(s)")
74
+
75
+ log.step("cloning the solution")
76
+ source = work.get("source") or {}
77
+ workspace.clone(place, source)
78
+ log.ok(f"{source.get('repo')} at {(source.get('commit') or '')[:12]}")
79
+
80
+ laid = workspace.lay_harness(place, work.get("harness") or [])
81
+ if laid:
82
+ log.info(f"harness written over the clone: {', '.join(laid)}")
83
+
84
+ _build(place, work, where, float(seconds.get("build_s", 600)), log)
85
+
86
+ passed = counted = 0
87
+ for index, plan in enumerate(points, start=1):
88
+ beat(api, run, f"point {index} of {len(points)} · {plan.id}", "running")
89
+
90
+ measured = measure.one_point(
91
+ place, plan, where, limits, float(seconds.get("point_s", 300)), log
92
+ )
93
+ cases = measured.cases
94
+ _say(log, plan, cases)
95
+
96
+ passed += sum(1 for one in cases if one["passed"])
97
+ counted += len(cases)
98
+ last = index == len(points)
99
+
100
+ if last:
101
+ log.done(f"{passed} of {counted} seeds passed")
102
+
103
+ if not _report(api, run, cases, measured.init_s, log, done=last):
104
+ return True
105
+
106
+ # The point is reported; the data can be made again from the bundle,
107
+ # the point and the seed at any time. One point on disk, whatever the
108
+ # plan asked for.
109
+ shutil.rmtree(place.point(plan.id), ignore_errors=True)
110
+
111
+ return True
112
+
113
+
114
+ def _environment(work: dict) -> Environment:
115
+ image = work.get("image") or {}
116
+ bundle = work.get("bundle") or {}
117
+ bundle_image = bundle.get("image")
118
+
119
+ solution = image.get("reference") if isinstance(image, dict) else image
120
+ if not solution:
121
+ raise Failed("the job names no image for the solution")
122
+ if not bundle_image:
123
+ raise Failed("the job names no image for the bundle")
124
+
125
+ return Environment(
126
+ solution=solution,
127
+ bundle=bundle_image,
128
+ bundle_command=bundle.get("command") or "python main.py",
129
+ run_command=work.get("run_cmd") or "python main.py",
130
+ )
131
+
132
+
133
+ def _build(place: Workspace, work: dict, where: Environment, limit: float, log: Log) -> None:
134
+ """Once for the whole job. The same binary answers every point."""
135
+ command = work.get("build")
136
+ if not command:
137
+ log.info("nothing to build")
138
+ return
139
+
140
+ log.step(f"building · {command}")
141
+ try:
142
+ done = docker.run(
143
+ where.solution,
144
+ ["sh", "-c", command],
145
+ mounts=[docker.Mount(place.solution, "/solution", write=True)],
146
+ workdir="/solution",
147
+ # The one stage that may reach the network: a build fetches
148
+ # dependencies. Measurement never does.
149
+ network=True,
150
+ timeout=limit,
151
+ )
152
+ except docker.Timeout as expired:
153
+ raise Failed(f"the build ran past {limit:.0f}s") from expired
154
+
155
+ if not done.ok:
156
+ tail = (done.err or done.out).strip().splitlines()[-12:]
157
+ for line in tail:
158
+ log.fail(line[:200])
159
+ raise Failed(f"the build failed with {done.code}")
160
+
161
+ log.ok("built")
162
+
163
+
164
+ def _say(log: Log, plan: Plan, cases: list[dict]) -> None:
165
+ timed = [one["seconds"] for one in cases if one.get("seconds")]
166
+ good = sum(1 for one in cases if one["passed"])
167
+
168
+ if not timed:
169
+ log.fail(f"{plan.id}: nothing came back")
170
+ elif good == len(cases):
171
+ log.ok(f"{plan.id}: {good}/{len(cases)} · {_span(timed)}")
172
+ elif good == 0:
173
+ # Not a shade of partial success. Every seed was wrong, which is as
174
+ # failed as nothing coming back at all.
175
+ log.fail(f"{plan.id}: 0/{len(cases)} within tolerance · {_span(timed)}")
176
+ else:
177
+ log.warn(f"{plan.id}: {good}/{len(cases)} within tolerance · {_span(timed)}")
178
+
179
+
180
+ def _span(timed: list[float]) -> str:
181
+ """The range, in a unit that has digits in it.
182
+
183
+ Two decimals of milliseconds turn a fast implementation into `0.00 ms`,
184
+ which reads the same as a measurement that failed.
185
+ """
186
+ return f"{_time(min(timed))} – {_time(max(timed))}"
187
+
188
+
189
+ def _time(seconds: float) -> str:
190
+ for over, scale, name in ((60, 1 / 60, "min"), (1, 1, "s"), (1e-3, 1e3, "ms"),
191
+ (1e-6, 1e6, "µs"), (0, 1e9, "ns")):
192
+ if abs(seconds) >= over:
193
+ value = seconds * scale
194
+ digits = 0 if abs(value) >= 100 else 1 if abs(value) >= 10 else 2
195
+ return f"{round(value, digits):g} {name}"
196
+ return "0"
197
+
198
+
199
+ def _report(
200
+ api: Api, run: str, cases: list[dict], init_s: float | None, log: Log, done: bool
201
+ ) -> bool:
202
+ """One point's worth. False when the assignment was taken away."""
203
+ try:
204
+ api.call(
205
+ "POST",
206
+ f"/runners/me/runs/{run}/report",
207
+ {"cases": cases, "init_s": init_s, "build_log": log.text(), "done": done},
208
+ )
209
+ return True
210
+ except Refused as failure:
211
+ if failure.status in (401, 403, 404):
212
+ log.warn(f"taken away before the report landed: {failure.detail}")
213
+ return False
214
+ raise
215
+
216
+
217
+ def _give_up(api: Api, run: str, reason: str, log: Log) -> None:
218
+ """Say why there is nothing, so the page shows a reason and not a silence."""
219
+ try:
220
+ api.call(
221
+ "POST",
222
+ f"/runners/me/runs/{run}/fail",
223
+ {"reason": reason[:4000], "build_log": log.text()},
224
+ )
225
+ except (Refused, Exception): # noqa: BLE001 — already failing
226
+ pass
fherma_runner/log.py ADDED
@@ -0,0 +1,104 @@
1
+ """What the runner says while it works.
2
+
3
+ Two audiences, one source. A person watching a terminal wants to see where the
4
+ work has got to; the platform wants a transcript it can show on a page months
5
+ later. Both are written here at the same moment, so the page cannot disagree
6
+ with what the operator saw.
7
+
8
+ Colour goes to the terminal and never into the transcript: escape codes stored
9
+ in a database are a page rendering `[32m` at somebody.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import sys
16
+ import time
17
+
18
+ #: Off when the output is not a terminal, so a log file or a pipe stays plain.
19
+ COLOUR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
20
+
21
+ _CODES = {
22
+ "grey": "\033[90m",
23
+ "red": "\033[31m",
24
+ "green": "\033[32m",
25
+ "amber": "\033[33m",
26
+ "blue": "\033[34m",
27
+ "bold": "\033[1m",
28
+ "off": "\033[0m",
29
+ }
30
+
31
+ #: How each level is painted, and how wide the column is.
32
+ _LEVELS = {
33
+ "INFO": "grey",
34
+ "STEP": "blue",
35
+ "OK": "green",
36
+ "WARN": "amber",
37
+ "FAIL": "red",
38
+ "DONE": "bold",
39
+ }
40
+
41
+
42
+ def paint(text: str, colour: str) -> str:
43
+ if not COLOUR or colour not in _CODES:
44
+ return text
45
+ return f"{_CODES[colour]}{text}{_CODES['off']}"
46
+
47
+
48
+ class Log:
49
+ """A transcript that prints as it is written.
50
+
51
+ Kept because a run carries points and timings and not why the build took
52
+ four minutes or which seed the machine was on when it stopped. Written
53
+ while the work happens rather than assembled afterwards, so a run that dies
54
+ half way still leaves everything up to the moment it died.
55
+
56
+ One line per event: the clock, a level, the sentence. The page reads the
57
+ three parts into three columns.
58
+ """
59
+
60
+ def __init__(self, quiet: bool = False) -> None:
61
+ self.lines: list[str] = []
62
+ self.quiet = quiet
63
+
64
+ def say(self, level: str, text: str) -> None:
65
+ stamp = time.strftime("%H:%M:%S")
66
+ self.lines.append(f"{stamp} {level} {text}")
67
+
68
+ if self.quiet:
69
+ return
70
+
71
+ colour = _LEVELS.get(level, "grey")
72
+ print(
73
+ f"{paint(stamp, 'grey')} {paint(level.ljust(4), colour)} {text}",
74
+ flush=True,
75
+ )
76
+
77
+ def info(self, text: str) -> None:
78
+ self.say("INFO", text)
79
+
80
+ def step(self, text: str) -> None:
81
+ """A stage beginning. Printed apart so the shape of the work is visible."""
82
+ self.say("STEP", paint(text, "bold") if COLOUR else text)
83
+
84
+ def ok(self, text: str) -> None:
85
+ self.say("OK", text)
86
+
87
+ def warn(self, text: str) -> None:
88
+ self.say("WARN", text)
89
+
90
+ def fail(self, text: str) -> None:
91
+ self.say("FAIL", text)
92
+
93
+ def done(self, text: str) -> None:
94
+ self.say("DONE", text)
95
+
96
+ def text(self) -> str:
97
+ """The transcript, without colour. This is what the platform stores."""
98
+ return "\n".join(_plain(line) for line in self.lines)
99
+
100
+
101
+ def _plain(line: str) -> str:
102
+ for code in _CODES.values():
103
+ line = line.replace(code, "")
104
+ return line