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.
Files changed (120) hide show
  1. libre_devops_helpers/__init__.py +23 -0
  2. libre_devops_helpers/__main__.py +5 -0
  3. libre_devops_helpers/cli/__init__.py +5 -0
  4. libre_devops_helpers/cli/app.py +147 -0
  5. libre_devops_helpers/cli/commands/__init__.py +1 -0
  6. libre_devops_helpers/cli/commands/automation.py +321 -0
  7. libre_devops_helpers/cli/commands/az.py +93 -0
  8. libre_devops_helpers/cli/commands/azure.py +258 -0
  9. libre_devops_helpers/cli/commands/config.py +54 -0
  10. libre_devops_helpers/cli/commands/devices.py +548 -0
  11. libre_devops_helpers/cli/commands/entra.py +554 -0
  12. libre_devops_helpers/cli/commands/graph.py +358 -0
  13. libre_devops_helpers/cli/commands/incidents.py +420 -0
  14. libre_devops_helpers/cli/commands/intune.py +79 -0
  15. libre_devops_helpers/cli/commands/keyvault.py +142 -0
  16. libre_devops_helpers/cli/commands/logicapp.py +489 -0
  17. libre_devops_helpers/cli/commands/logs.py +69 -0
  18. libre_devops_helpers/cli/commands/pim.py +381 -0
  19. libre_devops_helpers/cli/commands/pretty.py +141 -0
  20. libre_devops_helpers/cli/commands/profiles.py +153 -0
  21. libre_devops_helpers/cli/commands/snow.py +268 -0
  22. libre_devops_helpers/cli/commands/token.py +222 -0
  23. libre_devops_helpers/cli/commands/welcome.py +43 -0
  24. libre_devops_helpers/cli/commands/xdr.py +353 -0
  25. libre_devops_helpers/cli/exits.py +12 -0
  26. libre_devops_helpers/cli/options.py +146 -0
  27. libre_devops_helpers/cli/render.py +360 -0
  28. libre_devops_helpers/cli/runtime.py +348 -0
  29. libre_devops_helpers/cli/servicenow_runtime.py +170 -0
  30. libre_devops_helpers/core/__init__.py +94 -0
  31. libre_devops_helpers/core/auth.py +103 -0
  32. libre_devops_helpers/core/brand.py +67 -0
  33. libre_devops_helpers/core/browser.py +21 -0
  34. libre_devops_helpers/core/config.py +159 -0
  35. libre_devops_helpers/core/dpapi.py +60 -0
  36. libre_devops_helpers/core/errors.py +90 -0
  37. libre_devops_helpers/core/http.py +412 -0
  38. libre_devops_helpers/core/inputs.py +193 -0
  39. libre_devops_helpers/core/log.py +246 -0
  40. libre_devops_helpers/core/poll.py +88 -0
  41. libre_devops_helpers/core/process.py +106 -0
  42. libre_devops_helpers/core/sheets.py +330 -0
  43. libre_devops_helpers/core/tables.py +49 -0
  44. libre_devops_helpers/core/timewindow.py +127 -0
  45. libre_devops_helpers/core/token_store.py +266 -0
  46. libre_devops_helpers/core/util.py +120 -0
  47. libre_devops_helpers/core/yaml_text.py +142 -0
  48. libre_devops_helpers/microsoft/__init__.py +94 -0
  49. libre_devops_helpers/microsoft/auth/__init__.py +43 -0
  50. libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
  51. libre_devops_helpers/microsoft/auth/delegated.py +391 -0
  52. libre_devops_helpers/microsoft/auth/entra.py +241 -0
  53. libre_devops_helpers/microsoft/auth/factory.py +114 -0
  54. libre_devops_helpers/microsoft/auth/lapse.py +42 -0
  55. libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
  56. libre_devops_helpers/microsoft/automation/__init__.py +24 -0
  57. libre_devops_helpers/microsoft/automation/client.py +241 -0
  58. libre_devops_helpers/microsoft/automation/models.py +131 -0
  59. libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
  60. libre_devops_helpers/microsoft/azcli/client.py +81 -0
  61. libre_devops_helpers/microsoft/azcli/context.py +95 -0
  62. libre_devops_helpers/microsoft/azure/__init__.py +34 -0
  63. libre_devops_helpers/microsoft/azure/client.py +260 -0
  64. libre_devops_helpers/microsoft/azure/models.py +198 -0
  65. libre_devops_helpers/microsoft/clouds.py +83 -0
  66. libre_devops_helpers/microsoft/config.py +244 -0
  67. libre_devops_helpers/microsoft/devices/__init__.py +45 -0
  68. libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
  69. libre_devops_helpers/microsoft/devices/check.py +286 -0
  70. libre_devops_helpers/microsoft/devices/inspect.py +146 -0
  71. libre_devops_helpers/microsoft/devices/models.py +148 -0
  72. libre_devops_helpers/microsoft/entra/__init__.py +41 -0
  73. libre_devops_helpers/microsoft/entra/client.py +422 -0
  74. libre_devops_helpers/microsoft/entra/models.py +334 -0
  75. libre_devops_helpers/microsoft/entra/permissions.py +51 -0
  76. libre_devops_helpers/microsoft/graph/__init__.py +38 -0
  77. libre_devops_helpers/microsoft/graph/client.py +292 -0
  78. libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
  79. libre_devops_helpers/microsoft/incidents/client.py +217 -0
  80. libre_devops_helpers/microsoft/incidents/models.py +165 -0
  81. libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
  82. libre_devops_helpers/microsoft/intune/__init__.py +18 -0
  83. libre_devops_helpers/microsoft/intune/client.py +94 -0
  84. libre_devops_helpers/microsoft/intune/models.py +63 -0
  85. libre_devops_helpers/microsoft/intune/permissions.py +16 -0
  86. libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
  87. libre_devops_helpers/microsoft/keyvault/client.py +185 -0
  88. libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
  89. libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
  90. libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
  91. libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
  92. libre_devops_helpers/microsoft/logicapps/client.py +162 -0
  93. libre_devops_helpers/microsoft/logicapps/document.py +202 -0
  94. libre_devops_helpers/microsoft/pim/__init__.py +39 -0
  95. libre_devops_helpers/microsoft/pim/azure.py +238 -0
  96. libre_devops_helpers/microsoft/pim/entra.py +294 -0
  97. libre_devops_helpers/microsoft/pim/models.py +93 -0
  98. libre_devops_helpers/microsoft/pim/permissions.py +98 -0
  99. libre_devops_helpers/microsoft/pim/rules.py +70 -0
  100. libre_devops_helpers/microsoft/process.py +70 -0
  101. libre_devops_helpers/microsoft/resources.py +137 -0
  102. libre_devops_helpers/microsoft/tokens.py +269 -0
  103. libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
  104. libre_devops_helpers/microsoft/xdr/client.py +246 -0
  105. libre_devops_helpers/microsoft/xdr/models.py +181 -0
  106. libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
  107. libre_devops_helpers/py.typed +0 -0
  108. libre_devops_helpers/servicenow/__init__.py +50 -0
  109. libre_devops_helpers/servicenow/auth.py +409 -0
  110. libre_devops_helpers/servicenow/config.py +261 -0
  111. libre_devops_helpers/servicenow/instance/__init__.py +32 -0
  112. libre_devops_helpers/servicenow/instance/client.py +91 -0
  113. libre_devops_helpers/servicenow/instance/models.py +131 -0
  114. libre_devops_helpers/servicenow/roles.py +23 -0
  115. libre_devops_helpers/servicenow/tables.py +133 -0
  116. libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
  117. libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
  118. libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
  119. libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
  120. 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)