libre-devops-helpers 0.4.1__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.
- libre_devops_helpers/__init__.py +23 -0
- libre_devops_helpers/__main__.py +5 -0
- libre_devops_helpers/cli/__init__.py +5 -0
- libre_devops_helpers/cli/app.py +147 -0
- libre_devops_helpers/cli/commands/__init__.py +1 -0
- libre_devops_helpers/cli/commands/automation.py +321 -0
- libre_devops_helpers/cli/commands/az.py +93 -0
- libre_devops_helpers/cli/commands/azure.py +258 -0
- libre_devops_helpers/cli/commands/config.py +54 -0
- libre_devops_helpers/cli/commands/devices.py +548 -0
- libre_devops_helpers/cli/commands/entra.py +554 -0
- libre_devops_helpers/cli/commands/graph.py +358 -0
- libre_devops_helpers/cli/commands/incidents.py +420 -0
- libre_devops_helpers/cli/commands/intune.py +79 -0
- libre_devops_helpers/cli/commands/keyvault.py +142 -0
- libre_devops_helpers/cli/commands/logicapp.py +489 -0
- libre_devops_helpers/cli/commands/logs.py +69 -0
- libre_devops_helpers/cli/commands/pim.py +381 -0
- libre_devops_helpers/cli/commands/pretty.py +141 -0
- libre_devops_helpers/cli/commands/profiles.py +153 -0
- libre_devops_helpers/cli/commands/snow.py +268 -0
- libre_devops_helpers/cli/commands/token.py +222 -0
- libre_devops_helpers/cli/commands/welcome.py +43 -0
- libre_devops_helpers/cli/commands/xdr.py +353 -0
- libre_devops_helpers/cli/exits.py +12 -0
- libre_devops_helpers/cli/options.py +146 -0
- libre_devops_helpers/cli/render.py +360 -0
- libre_devops_helpers/cli/runtime.py +348 -0
- libre_devops_helpers/cli/servicenow_runtime.py +170 -0
- libre_devops_helpers/core/__init__.py +94 -0
- libre_devops_helpers/core/auth.py +103 -0
- libre_devops_helpers/core/brand.py +67 -0
- libre_devops_helpers/core/browser.py +21 -0
- libre_devops_helpers/core/config.py +159 -0
- libre_devops_helpers/core/dpapi.py +60 -0
- libre_devops_helpers/core/errors.py +90 -0
- libre_devops_helpers/core/http.py +412 -0
- libre_devops_helpers/core/inputs.py +193 -0
- libre_devops_helpers/core/log.py +246 -0
- libre_devops_helpers/core/poll.py +88 -0
- libre_devops_helpers/core/process.py +106 -0
- libre_devops_helpers/core/sheets.py +330 -0
- libre_devops_helpers/core/tables.py +49 -0
- libre_devops_helpers/core/timewindow.py +127 -0
- libre_devops_helpers/core/token_store.py +266 -0
- libre_devops_helpers/core/util.py +120 -0
- libre_devops_helpers/core/yaml_text.py +142 -0
- libre_devops_helpers/microsoft/__init__.py +94 -0
- libre_devops_helpers/microsoft/auth/__init__.py +43 -0
- libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
- libre_devops_helpers/microsoft/auth/delegated.py +391 -0
- libre_devops_helpers/microsoft/auth/entra.py +241 -0
- libre_devops_helpers/microsoft/auth/factory.py +114 -0
- libre_devops_helpers/microsoft/auth/lapse.py +42 -0
- libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
- libre_devops_helpers/microsoft/automation/__init__.py +24 -0
- libre_devops_helpers/microsoft/automation/client.py +241 -0
- libre_devops_helpers/microsoft/automation/models.py +131 -0
- libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
- libre_devops_helpers/microsoft/azcli/client.py +81 -0
- libre_devops_helpers/microsoft/azcli/context.py +95 -0
- libre_devops_helpers/microsoft/azure/__init__.py +34 -0
- libre_devops_helpers/microsoft/azure/client.py +260 -0
- libre_devops_helpers/microsoft/azure/models.py +198 -0
- libre_devops_helpers/microsoft/clouds.py +83 -0
- libre_devops_helpers/microsoft/config.py +244 -0
- libre_devops_helpers/microsoft/devices/__init__.py +45 -0
- libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
- libre_devops_helpers/microsoft/devices/check.py +286 -0
- libre_devops_helpers/microsoft/devices/inspect.py +146 -0
- libre_devops_helpers/microsoft/devices/models.py +148 -0
- libre_devops_helpers/microsoft/entra/__init__.py +41 -0
- libre_devops_helpers/microsoft/entra/client.py +422 -0
- libre_devops_helpers/microsoft/entra/models.py +334 -0
- libre_devops_helpers/microsoft/entra/permissions.py +51 -0
- libre_devops_helpers/microsoft/graph/__init__.py +38 -0
- libre_devops_helpers/microsoft/graph/client.py +292 -0
- libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
- libre_devops_helpers/microsoft/incidents/client.py +217 -0
- libre_devops_helpers/microsoft/incidents/models.py +165 -0
- libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
- libre_devops_helpers/microsoft/intune/__init__.py +18 -0
- libre_devops_helpers/microsoft/intune/client.py +94 -0
- libre_devops_helpers/microsoft/intune/models.py +63 -0
- libre_devops_helpers/microsoft/intune/permissions.py +16 -0
- libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
- libre_devops_helpers/microsoft/keyvault/client.py +185 -0
- libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
- libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
- libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
- libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
- libre_devops_helpers/microsoft/logicapps/client.py +162 -0
- libre_devops_helpers/microsoft/logicapps/document.py +202 -0
- libre_devops_helpers/microsoft/pim/__init__.py +39 -0
- libre_devops_helpers/microsoft/pim/azure.py +238 -0
- libre_devops_helpers/microsoft/pim/entra.py +294 -0
- libre_devops_helpers/microsoft/pim/models.py +93 -0
- libre_devops_helpers/microsoft/pim/permissions.py +98 -0
- libre_devops_helpers/microsoft/pim/rules.py +70 -0
- libre_devops_helpers/microsoft/process.py +70 -0
- libre_devops_helpers/microsoft/resources.py +137 -0
- libre_devops_helpers/microsoft/tokens.py +269 -0
- libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
- libre_devops_helpers/microsoft/xdr/client.py +246 -0
- libre_devops_helpers/microsoft/xdr/models.py +181 -0
- libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
- libre_devops_helpers/py.typed +0 -0
- libre_devops_helpers/servicenow/__init__.py +50 -0
- libre_devops_helpers/servicenow/auth.py +409 -0
- libre_devops_helpers/servicenow/config.py +261 -0
- libre_devops_helpers/servicenow/instance/__init__.py +32 -0
- libre_devops_helpers/servicenow/instance/client.py +91 -0
- libre_devops_helpers/servicenow/instance/models.py +131 -0
- libre_devops_helpers/servicenow/roles.py +23 -0
- libre_devops_helpers/servicenow/tables.py +133 -0
- libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
- libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
- libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
- libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
- libre_devops_helpers-0.4.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Output. Data goes to stdout; notes and warnings go to stderr.
|
|
2
|
+
|
|
3
|
+
Every data command offers three shapes through ``-o``: an aligned table for people, JSON
|
|
4
|
+
(the services' full records) for jq and scripts, and CSV for spreadsheets. Tables are
|
|
5
|
+
aligned with ``str.ljust`` and coloured with ``typer.style`` after padding, so colour
|
|
6
|
+
codes never upset the alignment. Click strips colour when the output is not a
|
|
7
|
+
terminal, so piped output stays plain.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
19
|
+
from dataclasses import asdict
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from libre_devops_helpers import __version__
|
|
27
|
+
from libre_devops_helpers.core import brand
|
|
28
|
+
from libre_devops_helpers.core.tables import QueryResult
|
|
29
|
+
from libre_devops_helpers.core.util import format_duration
|
|
30
|
+
from libre_devops_helpers.microsoft.tokens import Check
|
|
31
|
+
|
|
32
|
+
# A cell is plain text, or (text, colour) where colour is a typer/click colour name.
|
|
33
|
+
Cell = str | tuple[str, str | None]
|
|
34
|
+
|
|
35
|
+
# The banner's colours (256-colour codes, red through to pink), laid in diagonal bands that
|
|
36
|
+
# follow the unicorn's slant. Any brand's art gets the same sweep.
|
|
37
|
+
_RAINBOW = (196, 208, 226, 46, 51, 33, 129, 201)
|
|
38
|
+
_BAND = 6
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Output(StrEnum):
|
|
42
|
+
"""The shapes a data command can write."""
|
|
43
|
+
|
|
44
|
+
TABLE = "table"
|
|
45
|
+
JSON = "json"
|
|
46
|
+
CSV = "csv"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_CHECK_COLOURS = {"pass": "green", "warn": "yellow", "fail": "red"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def echo(text: str = "", *, err: bool = False) -> None:
|
|
53
|
+
typer.echo(text, err=err)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Mode:
|
|
60
|
+
"""Whether the log format is structured (json or otlp): set once, by the app."""
|
|
61
|
+
|
|
62
|
+
structured = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def structured_output(enabled: bool) -> None:
|
|
66
|
+
"""With a json or otlp log format, stderr carries log records only, so it is clean
|
|
67
|
+
JSON Lines for a collector: notes, warnings and errors become records too."""
|
|
68
|
+
_Mode.structured = enabled
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def note(text: str) -> None:
|
|
72
|
+
"""A dim informational line on stderr (an INFO record in a structured log format)."""
|
|
73
|
+
if _Mode.structured:
|
|
74
|
+
_log.info(text)
|
|
75
|
+
return
|
|
76
|
+
typer.secho(text, fg="bright_black", err=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def warn(text: str) -> None:
|
|
80
|
+
if _Mode.structured:
|
|
81
|
+
_log.warning(text)
|
|
82
|
+
return
|
|
83
|
+
typer.secho(f"warning: {text}", fg="yellow", err=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def error(text: str, hint: str | None = None) -> None:
|
|
87
|
+
"""An error, and what to do about it, on stderr (an ERROR record, hint attached)."""
|
|
88
|
+
if _Mode.structured:
|
|
89
|
+
_log.error(text, extra={"hint": hint} if hint else None)
|
|
90
|
+
return
|
|
91
|
+
typer.secho(f"error: {text}", fg="red", err=True)
|
|
92
|
+
if hint:
|
|
93
|
+
typer.secho(f"hint: {hint}", fg="yellow", err=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def notify(text: str) -> None:
|
|
97
|
+
"""Something the person must see now, such as a sign-in code: never filtered out."""
|
|
98
|
+
if _Mode.structured:
|
|
99
|
+
_log.warning(text)
|
|
100
|
+
return
|
|
101
|
+
typer.secho(text, fg="cyan", err=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def checks_to_stderr(checks: Iterable[Check]) -> None:
|
|
105
|
+
"""A token's checks on stderr: the table, or one record each in a structured format."""
|
|
106
|
+
if not _Mode.structured:
|
|
107
|
+
echo(checks_table(checks), err=True)
|
|
108
|
+
return
|
|
109
|
+
levels = {"fail": logging.ERROR, "warn": logging.WARNING}
|
|
110
|
+
for check in checks:
|
|
111
|
+
_log.log(levels.get(check.status, logging.INFO), f"{check.name}: {check.detail}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def banner(*, force: bool = False) -> None:
|
|
115
|
+
"""The brand's welcome banner, on stderr so it never mixes with data.
|
|
116
|
+
|
|
117
|
+
Shown only on a terminal, and never when the brand's ``NO_BANNER`` variable is set,
|
|
118
|
+
unless ``force`` (someone asked for it). ``NO_COLOR`` keeps it, without colour.
|
|
119
|
+
"""
|
|
120
|
+
if _Mode.structured and not force:
|
|
121
|
+
return
|
|
122
|
+
if not force and (not sys.stderr.isatty() or os.environ.get(brand.env_var("NO_BANNER"))):
|
|
123
|
+
return
|
|
124
|
+
plain = bool(os.environ.get("NO_COLOR"))
|
|
125
|
+
for row, line in enumerate(brand.BANNER.strip("\n").splitlines()):
|
|
126
|
+
typer.echo(line if plain else _diagonal(line, row), err=True)
|
|
127
|
+
typer.secho(f"{brand.DISPLAY_NAME} {brand.COMMAND} {__version__}", dim=True, err=True)
|
|
128
|
+
typer.echo(err=True)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _diagonal(line: str, row: int) -> str:
|
|
132
|
+
"""Colour ``line`` in bands that run along the diagonal, one style call per run."""
|
|
133
|
+
parts: list[str] = []
|
|
134
|
+
run: list[str] = []
|
|
135
|
+
colour: int | None = None
|
|
136
|
+
for col, char in enumerate(line):
|
|
137
|
+
band = _RAINBOW[((col + 2 * row) // _BAND) % len(_RAINBOW)]
|
|
138
|
+
if band != colour and run:
|
|
139
|
+
parts.append(typer.style("".join(run), fg=colour, bold=True))
|
|
140
|
+
run = []
|
|
141
|
+
colour = band
|
|
142
|
+
run.append(char)
|
|
143
|
+
if run:
|
|
144
|
+
parts.append(typer.style("".join(run), fg=colour, bold=True))
|
|
145
|
+
return "".join(parts)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def title(text: str) -> str:
|
|
149
|
+
return typer.style(text, bold=True)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def print_json(data: Any) -> None:
|
|
153
|
+
"""JSON on stdout: coloured on a terminal, plain when piped into jq or a script."""
|
|
154
|
+
if colour_wanted():
|
|
155
|
+
typer.echo(colour_json(_plain(data)), color=True)
|
|
156
|
+
else:
|
|
157
|
+
typer.echo(json.dumps(data, indent=2, default=_json_default))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def colour_wanted(stream: Any = None) -> bool:
|
|
161
|
+
"""Whether ``stream`` (stdout by default) is a terminal that wants colour."""
|
|
162
|
+
stream = stream or sys.stdout
|
|
163
|
+
return bool(getattr(stream, "isatty", lambda: False)()) and not os.environ.get("NO_COLOR")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# JSON's colours (256-colour codes): brackets take the banner's rainbow by nesting depth.
|
|
167
|
+
_JSON_KEY = 75
|
|
168
|
+
_JSON_STRING = 114
|
|
169
|
+
_JSON_NUMBER = 215
|
|
170
|
+
_JSON_BOOL = 176
|
|
171
|
+
_JSON_NULL = 244
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def colour_json(
|
|
175
|
+
data: Any, *, indent: int | None = 2, sort_keys: bool = False, ensure_ascii: bool = False
|
|
176
|
+
) -> str:
|
|
177
|
+
"""``data`` as JSON laid out as ``json.dumps`` would, in colour.
|
|
178
|
+
|
|
179
|
+
Keys, strings, numbers, booleans and null each have a colour, and brackets are
|
|
180
|
+
coloured by how deeply they nest, in the banner's rainbow, so matching pairs share
|
|
181
|
+
one. Strip the colour and the text is exactly ``json.dumps(data, indent=indent)``.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def paint(text: str, colour: int, *, bold: bool = False) -> str:
|
|
185
|
+
return typer.style(text, fg=colour, bold=bold)
|
|
186
|
+
|
|
187
|
+
def bracket(char: str, depth: int) -> str:
|
|
188
|
+
return paint(char, _RAINBOW[depth % len(_RAINBOW)], bold=True)
|
|
189
|
+
|
|
190
|
+
def scalar(value: Any) -> str:
|
|
191
|
+
text = json.dumps(value, ensure_ascii=ensure_ascii)
|
|
192
|
+
if value is None:
|
|
193
|
+
return paint(text, _JSON_NULL)
|
|
194
|
+
if isinstance(value, bool):
|
|
195
|
+
return paint(text, _JSON_BOOL)
|
|
196
|
+
if isinstance(value, int | float):
|
|
197
|
+
return paint(text, _JSON_NUMBER)
|
|
198
|
+
return paint(text, _JSON_STRING)
|
|
199
|
+
|
|
200
|
+
def render(value: Any, depth: int) -> str:
|
|
201
|
+
if isinstance(value, dict):
|
|
202
|
+
items = sorted(value.items()) if sort_keys else list(value.items())
|
|
203
|
+
if not items:
|
|
204
|
+
return bracket("{", depth) + bracket("}", depth)
|
|
205
|
+
parts = [
|
|
206
|
+
paint(json.dumps(str(key), ensure_ascii=ensure_ascii), _JSON_KEY, bold=True)
|
|
207
|
+
+ (": " if indent is not None else ":")
|
|
208
|
+
+ render(item, depth + 1)
|
|
209
|
+
for key, item in items
|
|
210
|
+
]
|
|
211
|
+
return bracket("{", depth) + join(parts, depth) + bracket("}", depth)
|
|
212
|
+
if isinstance(value, list):
|
|
213
|
+
if not value:
|
|
214
|
+
return bracket("[", depth) + bracket("]", depth)
|
|
215
|
+
parts = [render(item, depth + 1) for item in value]
|
|
216
|
+
return bracket("[", depth) + join(parts, depth) + bracket("]", depth)
|
|
217
|
+
return scalar(value)
|
|
218
|
+
|
|
219
|
+
def join(parts: list[str], depth: int) -> str:
|
|
220
|
+
if indent is None:
|
|
221
|
+
return ",".join(parts)
|
|
222
|
+
inner = "\n" + " " * (indent * (depth + 1))
|
|
223
|
+
return inner + ("," + inner).join(parts) + "\n" + " " * (indent * depth)
|
|
224
|
+
|
|
225
|
+
return render(data, 0)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _plain(data: Any) -> Any:
|
|
229
|
+
"""``data`` as plain JSON values: datetimes, mappings and the rest turned as for -o json."""
|
|
230
|
+
return json.loads(json.dumps(data, default=_json_default))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def emit(
|
|
234
|
+
output: Output,
|
|
235
|
+
headers: Sequence[str],
|
|
236
|
+
rows: Iterable[Sequence[Cell]],
|
|
237
|
+
records: Any,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Write data in the chosen shape: ``rows`` for a table or CSV, ``records`` for JSON."""
|
|
240
|
+
if output is Output.JSON:
|
|
241
|
+
print_json(records)
|
|
242
|
+
elif output is Output.CSV:
|
|
243
|
+
typer.echo(csv_text(headers, rows), nl=False)
|
|
244
|
+
else:
|
|
245
|
+
typer.echo(table(headers, rows))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def csv_text(headers: Sequence[str], rows: Iterable[Sequence[Cell]]) -> str:
|
|
249
|
+
"""CSV with a header row, and colours dropped."""
|
|
250
|
+
buffer = io.StringIO()
|
|
251
|
+
writer = csv.writer(buffer, lineterminator="\n")
|
|
252
|
+
writer.writerow(headers)
|
|
253
|
+
for row in rows:
|
|
254
|
+
writer.writerow([value[0] if isinstance(value, tuple) else value for value in row])
|
|
255
|
+
return buffer.getvalue()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def query_result(result: QueryResult, output: Output) -> None:
|
|
259
|
+
"""A query result from Advanced Hunting, Resource Graph or Log Analytics."""
|
|
260
|
+
if output is Output.JSON:
|
|
261
|
+
print_json(list(result.rows))
|
|
262
|
+
else:
|
|
263
|
+
emit(
|
|
264
|
+
output,
|
|
265
|
+
result.columns,
|
|
266
|
+
([_query_cell(row.get(column)) for column in result.columns] for row in result.rows),
|
|
267
|
+
None,
|
|
268
|
+
)
|
|
269
|
+
for warning in result.warnings:
|
|
270
|
+
warn(warning)
|
|
271
|
+
if result.truncated:
|
|
272
|
+
warn(f"stopped after {len(result.rows)} rows; raise --limit to fetch more")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def table(headers: Sequence[str], rows: Iterable[Sequence[Cell]]) -> str:
|
|
276
|
+
"""Left-aligned columns separated by two spaces, with a rule under the header."""
|
|
277
|
+
body = [[_cell(value) for value in row] for row in rows]
|
|
278
|
+
widths = [len(header) for header in headers]
|
|
279
|
+
for row in body:
|
|
280
|
+
for index, (text, _) in enumerate(row):
|
|
281
|
+
widths[index] = max(widths[index], len(text))
|
|
282
|
+
lines = [
|
|
283
|
+
_line([(header, None) for header in headers], widths, bold=True),
|
|
284
|
+
_line([("-" * width, None) for width in widths], widths),
|
|
285
|
+
]
|
|
286
|
+
lines.extend(_line(row, widths) for row in body)
|
|
287
|
+
return "\n".join(lines)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def pairs(items: Iterable[tuple[str, str]]) -> str:
|
|
291
|
+
"""Aligned ``label value`` lines; empty values show as ``-``."""
|
|
292
|
+
materialised = list(items)
|
|
293
|
+
width = max((len(label) for label, _ in materialised), default=0)
|
|
294
|
+
return "\n".join(
|
|
295
|
+
f"{typer.style(label.ljust(width), bold=True)} {value or '-'}"
|
|
296
|
+
for label, value in materialised
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def checks_table(checks: Iterable[Check]) -> str:
|
|
301
|
+
return table(
|
|
302
|
+
["RESULT", "CHECK", "DETAIL"],
|
|
303
|
+
[
|
|
304
|
+
[(check.status.upper(), _CHECK_COLOURS[check.status]), check.name, check.detail]
|
|
305
|
+
for check in checks
|
|
306
|
+
],
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def when(value: datetime | None, *, now: datetime | None = None) -> str:
|
|
311
|
+
"""Local time plus a relative age: ``2026-09-24 14:05 (3h 02m ago)``."""
|
|
312
|
+
if value is None:
|
|
313
|
+
return "-"
|
|
314
|
+
now = now or datetime.now(UTC)
|
|
315
|
+
if value > now:
|
|
316
|
+
relative = f"in {format_duration(value - now)}"
|
|
317
|
+
else:
|
|
318
|
+
relative = f"{format_duration(now - value)} ago"
|
|
319
|
+
return f"{value.astimezone():%Y-%m-%d %H:%M} ({relative})"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def yes_no(value: bool | None) -> str:
|
|
323
|
+
return "-" if value is None else "yes" if value else "no"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _cell(value: Cell) -> tuple[str, str | None]:
|
|
327
|
+
if isinstance(value, tuple):
|
|
328
|
+
return (value[0] or "-", value[1])
|
|
329
|
+
return (value or "-", None)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _line(
|
|
333
|
+
cells: Sequence[tuple[str, str | None]], widths: Sequence[int], bold: bool = False
|
|
334
|
+
) -> str:
|
|
335
|
+
parts = []
|
|
336
|
+
last = len(cells) - 1
|
|
337
|
+
for index, ((text, colour), width) in enumerate(zip(cells, widths, strict=True)):
|
|
338
|
+
padded = text if index == last else text.ljust(width)
|
|
339
|
+
parts.append(typer.style(padded, fg=colour, bold=bold) if colour or bold else padded)
|
|
340
|
+
return " ".join(parts)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _query_cell(value: object) -> str:
|
|
344
|
+
if value is None:
|
|
345
|
+
return ""
|
|
346
|
+
if isinstance(value, str):
|
|
347
|
+
return value
|
|
348
|
+
if isinstance(value, bool | int | float):
|
|
349
|
+
return str(value)
|
|
350
|
+
return json.dumps(value, default=_json_default)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _json_default(value: object) -> Any:
|
|
354
|
+
if isinstance(value, datetime):
|
|
355
|
+
return value.isoformat()
|
|
356
|
+
if isinstance(value, Mapping):
|
|
357
|
+
return dict(value)
|
|
358
|
+
if isinstance(value, Check):
|
|
359
|
+
return asdict(value)
|
|
360
|
+
return str(value)
|