dirigent-cli 0.9.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.
- dirigent_cli/__init__.py +5 -0
- dirigent_cli/aliases.py +41 -0
- dirigent_cli/commands.py +2525 -0
- dirigent_cli/context.py +136 -0
- dirigent_cli/formatters.py +158 -0
- dirigent_cli/graph.py +109 -0
- dirigent_cli/health.py +294 -0
- dirigent_cli/local.py +790 -0
- dirigent_cli/main.py +1169 -0
- dirigent_cli/output.py +543 -0
- dirigent_cli/params.py +389 -0
- dirigent_cli/profiles.py +221 -0
- dirigent_cli/project.py +643 -0
- dirigent_cli/py.typed +0 -0
- dirigent_cli/reaper.py +115 -0
- dirigent_cli/scaffold.py +63 -0
- dirigent_cli/schemas.py +85 -0
- dirigent_cli/sources.py +76 -0
- dirigent_cli/stream.py +180 -0
- dirigent_cli/summaries.py +420 -0
- dirigent_cli/templates/pack/README.md.tmpl +23 -0
- dirigent_cli/templates/pack/__init__.py.tmpl +24 -0
- dirigent_cli/templates/pack/operator.py.tmpl +34 -0
- dirigent_cli/templates/pack/pyproject.toml.tmpl +21 -0
- dirigent_cli/templates/pack/test_plugin.py.tmpl +21 -0
- dirigent_cli/timing.py +322 -0
- dirigent_cli/triggers.py +631 -0
- dirigent_cli-0.9.0.dist-info/METADATA +24 -0
- dirigent_cli-0.9.0.dist-info/RECORD +32 -0
- dirigent_cli-0.9.0.dist-info/WHEEL +4 -0
- dirigent_cli-0.9.0.dist-info/entry_points.txt +4 -0
- dirigent_cli-0.9.0.dist-info/licenses/LICENSE +18 -0
dirigent_cli/output.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""How the CLI prints: a rich table for a person, and exact JSON for a script.
|
|
2
|
+
|
|
3
|
+
``--json`` must emit the server's own response, not a rendering of the table.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from rich.console import Console, Group, RenderableType
|
|
17
|
+
from rich.markup import escape
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from dirigent_client.schemas.common import Problem
|
|
21
|
+
from dirigent_core.protocol import Format, Record, as_json, make
|
|
22
|
+
|
|
23
|
+
#: Whether the environment asked for no colour. Rich decides colour when a console is built,
|
|
24
|
+
#: so the answer is read once here and every console is built from it.
|
|
25
|
+
NO_COLOUR = "NO_COLOR" in os.environ
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_console(*, stderr: bool = False) -> Console:
|
|
29
|
+
"""Build a console that writes no escape sequence at all where NO_COLOR asked for none.
|
|
30
|
+
|
|
31
|
+
Rich's ``no_color`` drops colour and keeps every other attribute, so bold would still
|
|
32
|
+
reach a terminal that asked for a plain stream; naming no colour system drops all of it.
|
|
33
|
+
"""
|
|
34
|
+
return Console(stderr=stderr, no_color=NO_COLOUR, color_system=None if NO_COLOUR else "auto")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
console = build_console()
|
|
38
|
+
error_console = build_console(stderr=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Detail(StrEnum):
|
|
42
|
+
"""How much of a value the invocation asked to see."""
|
|
43
|
+
|
|
44
|
+
SUMMARY = "summary"
|
|
45
|
+
"""A container's shape rather than its content: the default view."""
|
|
46
|
+
|
|
47
|
+
VALUES = "values"
|
|
48
|
+
"""The values, each scalar cut short and each collection shown to a small depth."""
|
|
49
|
+
|
|
50
|
+
FULL = "full"
|
|
51
|
+
"""The whole value, pretty-printed and untruncated."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
#: The verbosity count at which ``-vv`` means the same thing as ``-d``.
|
|
55
|
+
FULL_VERBOSITY = 2
|
|
56
|
+
|
|
57
|
+
_output: "Format" = "json"
|
|
58
|
+
|
|
59
|
+
_detail: Detail = Detail.SUMMARY
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def configure(*, output: Format = "json", detail: Detail = Detail.SUMMARY) -> None:
|
|
63
|
+
"""Fix the spelling this invocation writes in, before any command runs.
|
|
64
|
+
|
|
65
|
+
Only the console rendering is decorated. Under ``json`` nothing may reach stdout that is
|
|
66
|
+
not one record per line, so the rich consoles are muted here rather than every call site
|
|
67
|
+
being asked to remember.
|
|
68
|
+
|
|
69
|
+
The detail level is fixed here too, because a formatter renders a record without being
|
|
70
|
+
handed the invocation that asked for it.
|
|
71
|
+
"""
|
|
72
|
+
global _output, _detail # noqa: PLW0603 - one process-wide output mode, set once per invocation
|
|
73
|
+
_output = output
|
|
74
|
+
_detail = detail
|
|
75
|
+
from dirigent_cli.stream import use_scratch_prefix
|
|
76
|
+
|
|
77
|
+
use_scratch_prefix(None)
|
|
78
|
+
console.quiet = output != "console"
|
|
79
|
+
console.no_color = NO_COLOUR or output != "console"
|
|
80
|
+
error_console.quiet = output != "console"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def json_mode() -> bool:
|
|
84
|
+
"""Report whether this invocation speaks a machine spelling rather than a rendered one."""
|
|
85
|
+
return _output != "console"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def output_mode() -> "Format":
|
|
89
|
+
"""Report which spelling this invocation writes in."""
|
|
90
|
+
return _output
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def detail_mode() -> Detail:
|
|
94
|
+
"""Report how much of a value this invocation asked to see."""
|
|
95
|
+
return _detail
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def emit_rendered(item: "RenderableType") -> None:
|
|
99
|
+
"""Print one rendered record, live from a run or read back by ``dg format``.
|
|
100
|
+
|
|
101
|
+
A line is soft-wrapped, so a wide value pushes the line out rather than being folded or
|
|
102
|
+
cut to the window it happened to be read in. A table measures itself against the
|
|
103
|
+
terminal instead, so a record that renders as both is printed a piece at a time.
|
|
104
|
+
"""
|
|
105
|
+
for part in item.renderables if isinstance(item, Group) else [item]:
|
|
106
|
+
if isinstance(part, str):
|
|
107
|
+
console.print(part, soft_wrap=True, highlight=False)
|
|
108
|
+
else:
|
|
109
|
+
console.print(part)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
STATUS_STYLES: Mapping[str, str] = {
|
|
113
|
+
"succeeded": "green",
|
|
114
|
+
"running": "cyan",
|
|
115
|
+
"queued": "blue",
|
|
116
|
+
"pending": "dim",
|
|
117
|
+
"waiting": "cyan",
|
|
118
|
+
"completed_with_errors": "yellow",
|
|
119
|
+
"failed": "red",
|
|
120
|
+
"cancelled": "magenta",
|
|
121
|
+
"skipped": "dim",
|
|
122
|
+
"create": "green",
|
|
123
|
+
"update": "yellow",
|
|
124
|
+
"unchanged": "dim",
|
|
125
|
+
"invalid": "red",
|
|
126
|
+
"healthy": "green",
|
|
127
|
+
"unhealthy": "red",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def emit_json(payload: object) -> None:
|
|
132
|
+
"""Print a response exactly as the server sent it, on one line, for a script to parse.
|
|
133
|
+
|
|
134
|
+
Writes to plain stdout, not through rich: rich wraps to the terminal width, and a
|
|
135
|
+
JSON document with a newline inserted mid-token is neither JSON nor NDJSON.
|
|
136
|
+
"""
|
|
137
|
+
sys.stdout.write(json.dumps(payload, default=str, separators=(",", ":")) + "\n")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def emit_record(kind: str, /, **fields: Any) -> None:
|
|
141
|
+
"""Write one record of a command's answer and flush it."""
|
|
142
|
+
sys.stdout.write(as_json(make(kind, at=datetime.now(UTC), **fields)) + "\n")
|
|
143
|
+
sys.stdout.flush()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def emit_one(kind: str, row: BaseModel) -> None:
|
|
147
|
+
"""Write one read as the record its listing would carry it in."""
|
|
148
|
+
emit_record(kind, fields=row.model_dump(mode="json"))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def emit_records(kind: str, rows: Iterable[BaseModel]) -> None:
|
|
152
|
+
"""Write a listing as NDJSON: one record per row, each carrying the row whole.
|
|
153
|
+
|
|
154
|
+
A listing is a record stream like every other command's output, so ``dg pipeline list``
|
|
155
|
+
and a run's events are read the same way: one object per line, each naming its kind.
|
|
156
|
+
|
|
157
|
+
The row rides under ``fields``, which the console line spreads under the row's own names:
|
|
158
|
+
a row of its own has a ``kind`` -- a schedule is cron or interval, a block is an operator
|
|
159
|
+
or a sensor -- and that is not the record kind a reader dispatches on.
|
|
160
|
+
"""
|
|
161
|
+
for row in rows:
|
|
162
|
+
emit_record(kind, fields=row.model_dump(mode="json"))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def emit_event(event: BaseModel) -> None:
|
|
166
|
+
"""Print one NDJSON record and flush it, so a reader sees it as it happens.
|
|
167
|
+
|
|
168
|
+
One object per line and no indentation: a record split across lines is not NDJSON.
|
|
169
|
+
"""
|
|
170
|
+
sys.stdout.write(json.dumps(event.model_dump(mode="json"), default=str, separators=(",", ":")) + "\n")
|
|
171
|
+
sys.stdout.flush()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def problem_record(problem: Problem) -> "Record":
|
|
175
|
+
"""Build the record one refusal is written as, carrying the problem body it came from.
|
|
176
|
+
|
|
177
|
+
The problem's own fields ride along, so a reader that wants the API's shape still has
|
|
178
|
+
all of it.
|
|
179
|
+
"""
|
|
180
|
+
body = problem.model_dump(mode="json")
|
|
181
|
+
# The detail is the message, so it is not also a field: a line does not say one thing twice.
|
|
182
|
+
detail = body.pop("detail", None)
|
|
183
|
+
return make(
|
|
184
|
+
"error",
|
|
185
|
+
at=datetime.now(UTC),
|
|
186
|
+
level="error",
|
|
187
|
+
message=str(detail or body.get("title") or "the command was refused"),
|
|
188
|
+
**body,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def emit_refusal(problem: Problem) -> None:
|
|
193
|
+
"""Write one refusal as a record, carrying the problem body it was built from.
|
|
194
|
+
|
|
195
|
+
A refusal is written to the same stream as everything else a command says, so it is a
|
|
196
|
+
record like everything else: ``dg runs list | dg format`` renders why it failed rather
|
|
197
|
+
than passing a line of JSON through as somebody else's text.
|
|
198
|
+
"""
|
|
199
|
+
sys.stdout.write(as_json(problem_record(problem)) + "\n")
|
|
200
|
+
sys.stdout.flush()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def emit_problem(detail_text: str, *, status: int = 1, title: str = "Error", problems: Sequence[str] = ()) -> None:
|
|
204
|
+
"""Print a refusal the CLI itself decided on, in the shape the server's own would take."""
|
|
205
|
+
emit_refusal(Problem(status=status, title=title, detail=detail_text, problems=list(problems)))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def emit_fact(kind: str, /, **fields: Any) -> None:
|
|
209
|
+
"""Write one record in the spelling this invocation asked for: NDJSON, or rendered.
|
|
210
|
+
|
|
211
|
+
A command states a fact once, as a record; whether that reaches the reader as a line of
|
|
212
|
+
JSON or as the formatter's drawing of it is the invocation's business and not the
|
|
213
|
+
command's.
|
|
214
|
+
"""
|
|
215
|
+
from dirigent_cli.stream import Sink
|
|
216
|
+
|
|
217
|
+
Sink(_output).event(kind, **fields)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def write_refusal(problem: Problem) -> None:
|
|
221
|
+
"""Write one refusal as a record, rendered where the invocation asked for a rendering."""
|
|
222
|
+
from dirigent_cli.stream import Sink
|
|
223
|
+
|
|
224
|
+
Sink(_output).write(problem_record(problem))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def refuse(detail_text: str, *, status: int = 1, title: str = "Error", problems: Sequence[str] = ()) -> None:
|
|
228
|
+
"""Write a refusal the CLI decided on as a record, rendered where one was asked for."""
|
|
229
|
+
write_refusal(Problem(status=status, title=title, detail=detail_text, problems=list(problems)))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
#: How long one field's value may be before the summary *table* renders its shape instead.
|
|
233
|
+
#: The stream never uses it: a line carries whole values or it is not a protocol.
|
|
234
|
+
FIELD_WIDTH = 40
|
|
235
|
+
|
|
236
|
+
#: How many elements of a collection ``-v`` shows before saying how many are left.
|
|
237
|
+
VALUE_ELEMENTS = 5
|
|
238
|
+
|
|
239
|
+
#: How far into a nested value ``-v`` descends before falling back to its shape.
|
|
240
|
+
VALUE_DEPTH = 2
|
|
241
|
+
|
|
242
|
+
#: Output fields worth nothing to a reader: the block produced no value there.
|
|
243
|
+
EMPTY: tuple[object, ...] = (None, "", [], {})
|
|
244
|
+
|
|
245
|
+
#: What separates two rendered fields on one line. Two spaces, so a parser splitting on
|
|
246
|
+
#: whitespace and a person reading columns both get what they came for.
|
|
247
|
+
SEPARATOR = " "
|
|
248
|
+
|
|
249
|
+
#: Supporting text is grey rather than rich's dim attribute, which many terminals render at
|
|
250
|
+
#: too little contrast to read on a dark background.
|
|
251
|
+
MUTED = "grey58"
|
|
252
|
+
|
|
253
|
+
#: A leading ``scheme://``, which is what makes a string a URI rather than prose.
|
|
254
|
+
URI_SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://")
|
|
255
|
+
|
|
256
|
+
#: A value holding any of these reads as more than one token unless it is quoted.
|
|
257
|
+
_NEEDS_QUOTES = re.compile(r'[\s"\\]')
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def is_uri(text: str) -> bool:
|
|
261
|
+
"""Report whether a value is a URI, and so has a prefix worth leaving out."""
|
|
262
|
+
return URI_SCHEME.match(text) is not None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def render_uri(uri: str) -> str:
|
|
266
|
+
"""Render a URI the way the stream spells it: relative to the run's shared prefix."""
|
|
267
|
+
from dirigent_cli.stream import relative
|
|
268
|
+
|
|
269
|
+
return relative(uri)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def stream_line(text: str, *, level: Detail = Detail.SUMMARY) -> None:
|
|
273
|
+
"""Print one line of a run's stream, whole.
|
|
274
|
+
|
|
275
|
+
A line wider than the terminal wraps, and that is the right trade: a stream that cuts at
|
|
276
|
+
the terminal's width is a stream whose content depends on the window it was read in, and
|
|
277
|
+
a value cut in half is a value nobody can parse or paste.
|
|
278
|
+
"""
|
|
279
|
+
console.print(text, highlight=False, soft_wrap=True)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def muted(text: str) -> str:
|
|
283
|
+
"""Render supporting text in a grey that stays legible where rich's dim does not."""
|
|
284
|
+
return f"[{MUTED}]{text}[/]"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _styled_part(part: str) -> str:
|
|
288
|
+
"""Colour one part: its name muted, its value at full contrast."""
|
|
289
|
+
name, separator, value = part.partition("=")
|
|
290
|
+
if not separator:
|
|
291
|
+
return escape(part)
|
|
292
|
+
return muted(escape(name) + separator) + escape(value)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _compact(value: object, depth: int) -> str:
|
|
296
|
+
"""Render one value with its content visible but bounded: short scalars, shallow collections."""
|
|
297
|
+
if isinstance(value, dict):
|
|
298
|
+
entries = list(cast("dict[str, Any]", value).items())
|
|
299
|
+
if depth <= 0 or not entries:
|
|
300
|
+
return f"{{{len(entries)} keys}}"
|
|
301
|
+
shown = ", ".join(f"{name}: {_compact(item, depth - 1)}" for name, item in entries[:VALUE_ELEMENTS])
|
|
302
|
+
return "{" + shown + _more(len(entries)) + "}"
|
|
303
|
+
if isinstance(value, list):
|
|
304
|
+
elements = cast("list[object]", value)
|
|
305
|
+
if depth <= 0 or not elements:
|
|
306
|
+
return f"[{len(elements)} items]"
|
|
307
|
+
shown = ", ".join(_compact(item, depth - 1) for item in elements[:VALUE_ELEMENTS])
|
|
308
|
+
return "[" + shown + _more(len(elements)) + "]"
|
|
309
|
+
if isinstance(value, str):
|
|
310
|
+
return render_uri(value) if is_uri(value) else value
|
|
311
|
+
return _shape(value)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _more(count: int) -> str:
|
|
315
|
+
"""Say how many elements a bounded rendering left out, or nothing when it left out none."""
|
|
316
|
+
return f", +{count - VALUE_ELEMENTS} more" if count > VALUE_ELEMENTS else ""
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def summarise(output: Mapping[str, Any]) -> str:
|
|
320
|
+
"""Render a step's output as one readable line.
|
|
321
|
+
|
|
322
|
+
A scalar is shown whole; a container is shown as its shape, because one response's
|
|
323
|
+
headers would otherwise fill the line and say nothing. A field the block left empty is
|
|
324
|
+
dropped, since "it produced nothing there" is not what a reader is looking for.
|
|
325
|
+
"""
|
|
326
|
+
parts = [f"{name}={_shape(value)}" for name, value in output.items() if value not in EMPTY]
|
|
327
|
+
return SEPARATOR.join(_styled_part(part) for part in parts) or "-"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def render_output(
|
|
331
|
+
output: Mapping[str, Any] | None,
|
|
332
|
+
level: Detail = Detail.SUMMARY,
|
|
333
|
+
*,
|
|
334
|
+
uri: str | None = None,
|
|
335
|
+
size_bytes: int | None = None,
|
|
336
|
+
) -> str:
|
|
337
|
+
"""Render a step's output or input at the level the invocation asked for.
|
|
338
|
+
|
|
339
|
+
An output too large to inline was written to storage, so the default and ``-v`` views
|
|
340
|
+
name the artifact rather than showing a value the reader cannot tell apart from an
|
|
341
|
+
inlined one; ``-d`` prints what was stored, which the help says and a table need not.
|
|
342
|
+
"""
|
|
343
|
+
if uri is not None:
|
|
344
|
+
note = f"{muted('artifact')} {escape(render_uri(uri))}{_size(size_bytes)}"
|
|
345
|
+
if level is not Detail.FULL or not output:
|
|
346
|
+
return note
|
|
347
|
+
return f"{note}\n{_rendered(output, level)}"
|
|
348
|
+
if not output:
|
|
349
|
+
return "-"
|
|
350
|
+
return _rendered(output, level)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _rendered(output: Mapping[str, Any], level: Detail) -> str:
|
|
354
|
+
"""Render an inlined value: its shape, its bounded values, or the whole of it."""
|
|
355
|
+
if level is Detail.FULL:
|
|
356
|
+
return escape(json.dumps(dict(output), indent=2, default=str))
|
|
357
|
+
if level is Detail.VALUES:
|
|
358
|
+
parts = [f"{name}={_compact(value, VALUE_DEPTH)}" for name, value in output.items() if value not in EMPTY]
|
|
359
|
+
return SEPARATOR.join(_styled_part(part) for part in parts) or "-"
|
|
360
|
+
return summarise(output)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _size(size_bytes: int | None) -> str:
|
|
364
|
+
"""Render an artifact's size, or nothing when it was not recorded."""
|
|
365
|
+
if size_bytes is None:
|
|
366
|
+
return ""
|
|
367
|
+
if size_bytes < 1024: # noqa: PLR2004 - the byte/kilobyte boundary
|
|
368
|
+
return " " + muted(f"({size_bytes} B)")
|
|
369
|
+
return " " + muted(f"({size_bytes / 1024:.1f} kB)")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _shape(value: object) -> str:
|
|
373
|
+
"""Render one output field: the value when it is small, its shape when it is not."""
|
|
374
|
+
if isinstance(value, dict):
|
|
375
|
+
return f"{{{len(cast('dict[str, Any]', value))} keys}}"
|
|
376
|
+
if isinstance(value, list):
|
|
377
|
+
return f"[{len(cast('list[object]', value))} items]"
|
|
378
|
+
if isinstance(value, str):
|
|
379
|
+
if is_uri(value):
|
|
380
|
+
return render_uri(value)
|
|
381
|
+
return value if len(value) <= FIELD_WIDTH else f"<{len(value)} chars>"
|
|
382
|
+
if isinstance(value, float):
|
|
383
|
+
return f"{value:.3f}".rstrip("0").rstrip(".")
|
|
384
|
+
return json.dumps(value, default=str)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def labelled(step: str, item: object) -> str:
|
|
388
|
+
"""Name a step, with the fan-out element it is working on when there is one.
|
|
389
|
+
|
|
390
|
+
Escaped, because rich reads square brackets as markup and would eat the label.
|
|
391
|
+
"""
|
|
392
|
+
return escape(f"{step}[{item}]") if item is not None else escape(step)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def styled(value: object) -> str:
|
|
396
|
+
"""Render a value, colouring the ones that are a status."""
|
|
397
|
+
text = "-" if value is None else str(value)
|
|
398
|
+
style = STATUS_STYLES.get(text)
|
|
399
|
+
return f"[{style}]{text}[/]" if style else text
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
STATUS_WIDTH = 15
|
|
403
|
+
|
|
404
|
+
#: How wide the step column is, so names line up down the page rather than floating with the
|
|
405
|
+
#: message before them.
|
|
406
|
+
STEP_WIDTH = 22
|
|
407
|
+
|
|
408
|
+
#: Colours a step is tracked by, assigned in document order and reused for every line it
|
|
409
|
+
#: writes. Colour is the fast path for the eye; the column is what makes the stream readable
|
|
410
|
+
#: where there is no colour at all.
|
|
411
|
+
STEP_COLOURS: tuple[str, ...] = ("cyan", "magenta", "green", "yellow", "blue", "bright_cyan", "bright_magenta")
|
|
412
|
+
|
|
413
|
+
#: What a level is called in a line, and how wide that column is. One word each, so the
|
|
414
|
+
#: column is a token and not a sentence.
|
|
415
|
+
LEVEL_NAMES: Mapping[str, str] = {"warning": "warn", "critical": "error"}
|
|
416
|
+
|
|
417
|
+
LEVEL_WIDTH = 5
|
|
418
|
+
|
|
419
|
+
#: Colour is decoration: it says the same thing the level word already says.
|
|
420
|
+
LEVEL_COLOURS: Mapping[str, str] = {"warning": "bold yellow", "error": "bold red", "critical": "bold red"}
|
|
421
|
+
|
|
422
|
+
#: How wide the event column is, and which event is worth a weight of its own.
|
|
423
|
+
EVENT_WIDTH = 6
|
|
424
|
+
|
|
425
|
+
EVENT_STYLE: Mapping[str, str] = {"step": "[bold]"}
|
|
426
|
+
|
|
427
|
+
#: What stands in an empty column, so a line never has fewer tokens than the grammar says.
|
|
428
|
+
NOTHING = "-"
|
|
429
|
+
|
|
430
|
+
_step_colours: dict[str, str] = {}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def flagged(warnings: int) -> str:
|
|
434
|
+
"""Mark a step that logged something worth reading, whatever it settled as.
|
|
435
|
+
|
|
436
|
+
Beside the name rather than in the outcome column: the outcome is the terminal status
|
|
437
|
+
and stays it, but a succeeded step that warned twice is the moment to decide to look.
|
|
438
|
+
"""
|
|
439
|
+
if warnings <= 0:
|
|
440
|
+
return ""
|
|
441
|
+
return f" [bold yellow]{warnings} warn[/]"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def prioritised(priority: object) -> str:
|
|
445
|
+
"""Mark a run the claim does not treat like every other one.
|
|
446
|
+
|
|
447
|
+
Almost every run is ``normal``, so a column of the word would say nothing; the mark is
|
|
448
|
+
drawn only where the answer is not the default.
|
|
449
|
+
"""
|
|
450
|
+
match str(priority):
|
|
451
|
+
case "high":
|
|
452
|
+
return " [bold red]![/]"
|
|
453
|
+
case "low":
|
|
454
|
+
return f" {muted('low')}"
|
|
455
|
+
case _:
|
|
456
|
+
return ""
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def status_cell(value: object, width: int = STATUS_WIDTH) -> str:
|
|
460
|
+
"""Render a status padded to a fixed width, then coloured.
|
|
461
|
+
|
|
462
|
+
Pad before colouring: rich markup counts as characters, so padding the marked-up
|
|
463
|
+
string makes every colour a different column width.
|
|
464
|
+
"""
|
|
465
|
+
text = "-" if value is None else str(value)
|
|
466
|
+
style = STATUS_STYLES.get(text)
|
|
467
|
+
padded = f"{text:<{width}}"
|
|
468
|
+
return f"[{style}]{padded}[/]" if style else padded
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def render_bool(value: object) -> str:
|
|
472
|
+
"""Render a boolean as a word rather than as Python's spelling of it."""
|
|
473
|
+
if value is None:
|
|
474
|
+
return "-"
|
|
475
|
+
return "[green]yes[/]" if value else "[dim]no[/]"
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def moment(value: object) -> str:
|
|
479
|
+
"""Render a timestamp compactly, in the local time an operator is reading it in."""
|
|
480
|
+
if not value:
|
|
481
|
+
return "-"
|
|
482
|
+
try:
|
|
483
|
+
parsed = datetime.fromisoformat(str(value))
|
|
484
|
+
except ValueError:
|
|
485
|
+
return str(value)
|
|
486
|
+
return parsed.astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def elapsed(value: object) -> str:
|
|
490
|
+
"""Render a duration in milliseconds the way a person reads it."""
|
|
491
|
+
if value is None:
|
|
492
|
+
return "-"
|
|
493
|
+
seconds = float(str(value)) / 1000
|
|
494
|
+
if seconds < 60:
|
|
495
|
+
return f"{seconds:.1f}s"
|
|
496
|
+
minutes, rest = divmod(seconds, 60)
|
|
497
|
+
if minutes < 60:
|
|
498
|
+
return f"{int(minutes)}m{int(rest)}s"
|
|
499
|
+
hours, minutes = divmod(minutes, 60)
|
|
500
|
+
return f"{int(hours)}h{int(minutes)}m"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def build_table(title: str, columns: Sequence[str], rows: Iterable[Sequence[str]]) -> RenderableType:
|
|
504
|
+
"""Build one rich table, or the quiet line that stands in for an empty one."""
|
|
505
|
+
built = Table(title=title, show_header=True, header_style="bold", title_justify="left")
|
|
506
|
+
for column in columns:
|
|
507
|
+
built.add_column(column)
|
|
508
|
+
count = 0
|
|
509
|
+
for row in rows:
|
|
510
|
+
built.add_row(*row)
|
|
511
|
+
count += 1
|
|
512
|
+
return built if count else f"[dim]{title}: nothing to show.[/]"
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def table(title: str, columns: Sequence[str], rows: Iterable[Sequence[str]]) -> None:
|
|
516
|
+
"""Print one rich table, or a quiet line when there is nothing in it."""
|
|
517
|
+
console.print(build_table(title, columns, rows))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def build_fields(title: str, values: Mapping[str, Any]) -> RenderableType:
|
|
521
|
+
"""Build one record as a two-column table."""
|
|
522
|
+
built = Table(title=title, show_header=False, box=None, title_justify="left", padding=(0, 2, 0, 0))
|
|
523
|
+
built.add_column(style="bold")
|
|
524
|
+
built.add_column()
|
|
525
|
+
for name, value in values.items():
|
|
526
|
+
built.add_row(name, styled(value))
|
|
527
|
+
return built
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def fields(title: str, values: Mapping[str, Any]) -> None:
|
|
531
|
+
"""Print one record as a two-column table."""
|
|
532
|
+
console.print(build_fields(title, values))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def age(value: object) -> str:
|
|
536
|
+
"""Render how long ago something happened."""
|
|
537
|
+
if not value:
|
|
538
|
+
return "-"
|
|
539
|
+
try:
|
|
540
|
+
parsed = datetime.fromisoformat(str(value))
|
|
541
|
+
except ValueError:
|
|
542
|
+
return str(value)
|
|
543
|
+
return elapsed((datetime.now(UTC) - parsed).total_seconds() * 1000) + " ago"
|