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,353 @@
1
+ """Defender for Endpoint commands: machines, stale machines, alerts, vulnerabilities, hunting."""
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from libre_devops_helpers.cli import render
9
+ from libre_devops_helpers.cli.exits import ATTENTION
10
+ from libre_devops_helpers.cli.options import (
11
+ ColumnOption,
12
+ FromFileOption,
13
+ NamesArgument,
14
+ OutputOption,
15
+ ProfileOption,
16
+ QueryFileOption,
17
+ SheetOption,
18
+ duration,
19
+ get_runtime,
20
+ names,
21
+ read_query,
22
+ )
23
+ from libre_devops_helpers.cli.render import Output
24
+ from libre_devops_helpers.core.errors import LdoError, NotFoundError
25
+ from libre_devops_helpers.core.util import format_duration
26
+ from libre_devops_helpers.microsoft.xdr import Machine, MachineLookup, parse_severity
27
+
28
+ xdr_app = typer.Typer(
29
+ rich_markup_mode="markdown",
30
+ help=(
31
+ "Defender XDR: incidents (Sentinel's included), and Defender for Endpoint "
32
+ "machines, alerts, vulnerabilities and hunting."
33
+ ),
34
+ no_args_is_help=True,
35
+ )
36
+
37
+ _SEVERITY_COLOURS = {"critical": "red", "high": "red", "medium": "yellow"}
38
+
39
+
40
+ def register(app: typer.Typer) -> None:
41
+ app.add_typer(xdr_app, name="xdr")
42
+
43
+
44
+ @xdr_app.command("machines")
45
+ def machines(
46
+ ctx: typer.Context,
47
+ devices: NamesArgument = None,
48
+ from_file: FromFileOption = None,
49
+ column: ColumnOption = None,
50
+ sheet: SheetOption = None,
51
+ profile: ProfileOption = None,
52
+ all_records: Annotated[
53
+ bool, typer.Option("--all-records", help="Also list older duplicate Defender records.")
54
+ ] = False,
55
+ output: OutputOption = Output.TABLE,
56
+ ) -> None:
57
+ """Look devices up in Defender: onboarding, health, last seen, tags and device group.
58
+
59
+ Each device is looked up by FQDN, then by short hostname. Exits 3 when any device
60
+ has no Defender record.
61
+ """
62
+ wanted = names(devices, from_file, column, sheet)
63
+ runtime = get_runtime(ctx).microsoft
64
+ selected = runtime.profile(profile)
65
+ lookups = runtime.xdr(selected).find_machines(wanted)
66
+ render.emit(
67
+ output,
68
+ [
69
+ "DEVICE",
70
+ "MATCHED",
71
+ "ONBOARDING",
72
+ "HEALTH",
73
+ "LAST SEEN",
74
+ "OS",
75
+ "TAGS",
76
+ "DEVICE GROUP",
77
+ "RECORDS",
78
+ "MACHINE ID",
79
+ ],
80
+ _machine_rows(lookups, all_records=all_records),
81
+ [
82
+ {
83
+ "query": lookup.query,
84
+ "matched_name": lookup.matched_name,
85
+ "found": lookup.found,
86
+ "records": [dict(record.raw) for record in lookup.records],
87
+ }
88
+ for lookup in lookups
89
+ ],
90
+ )
91
+ missing = [lookup.query for lookup in lookups if not lookup.found]
92
+ render.note(
93
+ f"{len(lookups) - len(missing)} of {len(lookups)} found in Defender "
94
+ f"(profile {selected.name})"
95
+ )
96
+ if missing:
97
+ raise typer.Exit(ATTENTION)
98
+
99
+
100
+ def _machine_rows(lookups: list[MachineLookup], *, all_records: bool) -> list[list[render.Cell]]:
101
+ rows: list[list[render.Cell]] = []
102
+ for lookup in lookups:
103
+ if lookup.machine is None:
104
+ rows.append([lookup.query, ("not found", "red"), "", "", "", "", "", "", "0", ""])
105
+ continue
106
+ matched = "fqdn" if lookup.matched_name == lookup.query.strip().rstrip(".") else "short"
107
+ rows.append(
108
+ [
109
+ lookup.query,
110
+ matched,
111
+ *_machine_cells(lookup.machine),
112
+ str(len(lookup.records)),
113
+ lookup.machine.id,
114
+ ]
115
+ )
116
+ if all_records:
117
+ for older in lookup.records[1:]:
118
+ rows.append(
119
+ [(" older record", "bright_black"), "", *_machine_cells(older), "", older.id]
120
+ )
121
+ return rows
122
+
123
+
124
+ def _machine_cells(machine: Machine) -> list[render.Cell]:
125
+ onboarded = machine.onboarding_status == "Onboarded"
126
+ health_colour = {"Active": "green", "Inactive": "yellow"}.get(machine.health_status, "red")
127
+ return [
128
+ (machine.onboarding_status, "green" if onboarded else "yellow"),
129
+ (machine.health_status, health_colour),
130
+ render.when(machine.last_seen),
131
+ f"{machine.os_platform} {machine.os_version}".strip(),
132
+ ",".join(machine.machine_tags),
133
+ machine.device_group,
134
+ ]
135
+
136
+
137
+ @xdr_app.command("stale")
138
+ def stale(
139
+ ctx: typer.Context,
140
+ older_than: Annotated[
141
+ str, typer.Option("--older-than", help="Not seen for at least this long, e.g. 30d.")
142
+ ] = "30d",
143
+ profile: ProfileOption = None,
144
+ output: OutputOption = Output.TABLE,
145
+ ) -> None:
146
+ """List machines Defender has not seen recently, longest silent first.
147
+
148
+ Exits 3 when any are found.
149
+ """
150
+ window = duration(older_than) or timedelta(days=30)
151
+ runtime = get_runtime(ctx).microsoft
152
+ found = runtime.xdr(runtime.profile(profile)).stale_machines(window)
153
+ render.emit(
154
+ output,
155
+ ["DEVICE", "LAST SEEN", "HEALTH", "ONBOARDING", "OS", "TAGS", "MACHINE ID"],
156
+ [
157
+ [
158
+ machine.computer_dns_name,
159
+ render.when(machine.last_seen),
160
+ machine.health_status,
161
+ machine.onboarding_status,
162
+ f"{machine.os_platform} {machine.os_version}".strip(),
163
+ ",".join(machine.machine_tags),
164
+ machine.id,
165
+ ]
166
+ for machine in found
167
+ ],
168
+ [dict(machine.raw) for machine in found],
169
+ )
170
+ render.note(f"{len(found)} machine(s) not seen for {format_duration(window)}")
171
+ if found:
172
+ raise typer.Exit(ATTENTION)
173
+
174
+
175
+ @xdr_app.command("alerts")
176
+ def alerts(
177
+ ctx: typer.Context,
178
+ device: Annotated[
179
+ str | None, typer.Option("--device", "-d", help="Only alerts for this device name.")
180
+ ] = None,
181
+ since: Annotated[str, typer.Option("--since", help="How far back, e.g. 24h, 7d.")] = "7d",
182
+ severity: Annotated[
183
+ str | None,
184
+ typer.Option("--severity", help="At least this severity: low, medium or high."),
185
+ ] = None,
186
+ include_resolved: Annotated[
187
+ bool, typer.Option("--include-resolved", help="Include resolved alerts.")
188
+ ] = False,
189
+ limit: Annotated[int, typer.Option("--limit", min=1, help="Most alerts to show.")] = 200,
190
+ profile: ProfileOption = None,
191
+ output: OutputOption = Output.TABLE,
192
+ ) -> None:
193
+ """List Defender alerts, newest first: open ones unless --include-resolved."""
194
+ if severity is not None:
195
+ parse_severity(severity)
196
+ window = duration(since) or timedelta(days=7)
197
+ runtime = get_runtime(ctx).microsoft
198
+ xdr = runtime.xdr(runtime.profile(profile))
199
+ machine_id = None
200
+ if device is not None:
201
+ lookup = xdr.find_machine(device)
202
+ if lookup.machine is None:
203
+ raise NotFoundError(f"no Defender record for {device!r}")
204
+ machine_id = lookup.machine.id
205
+ found = xdr.alerts(
206
+ machine_id=machine_id,
207
+ since=datetime.now(UTC) - window,
208
+ min_severity=severity,
209
+ include_resolved=include_resolved,
210
+ limit=limit,
211
+ )
212
+ render.emit(
213
+ output,
214
+ ["CREATED", "SEVERITY", "STATUS", "TITLE", "DEVICE", "CATEGORY", "SOURCE", "ALERT ID"],
215
+ [
216
+ [
217
+ render.when(alert.created),
218
+ (alert.severity, _SEVERITY_COLOURS.get(alert.severity.casefold())),
219
+ alert.status,
220
+ alert.title,
221
+ alert.computer_dns_name,
222
+ alert.category,
223
+ alert.detection_source,
224
+ alert.id,
225
+ ]
226
+ for alert in found
227
+ ],
228
+ [dict(alert.raw) for alert in found],
229
+ )
230
+ render.note(f"{len(found)} alert(s) in the last {format_duration(window)}")
231
+
232
+
233
+ @xdr_app.command("vulns")
234
+ def vulns(
235
+ ctx: typer.Context,
236
+ device: Annotated[str, typer.Argument(help="Device name: FQDN or short hostname.")],
237
+ severity: Annotated[
238
+ str | None,
239
+ typer.Option("--severity", help="At least this severity: low, medium, high, critical."),
240
+ ] = None,
241
+ profile: ProfileOption = None,
242
+ output: OutputOption = Output.TABLE,
243
+ ) -> None:
244
+ """List the vulnerabilities Defender reports on a device, most severe first."""
245
+ floor = parse_severity(severity) if severity else None
246
+ runtime = get_runtime(ctx).microsoft
247
+ xdr = runtime.xdr(runtime.profile(profile))
248
+ lookup = xdr.find_machine(device)
249
+ if lookup.machine is None:
250
+ raise NotFoundError(f"no Defender record for {device!r}")
251
+ found = xdr.vulnerabilities(lookup.machine.id)
252
+ if floor is not None:
253
+ found = [item for item in found if _rank(item.severity) >= floor]
254
+ render.emit(
255
+ output,
256
+ ["CVE", "SEVERITY", "CVSS", "EXPLOIT", "PUBLISHED", "NAME"],
257
+ [
258
+ [
259
+ item.id,
260
+ (item.severity, _SEVERITY_COLOURS.get(item.severity.casefold())),
261
+ "" if item.cvss is None else f"{item.cvss:.1f}",
262
+ "verified" if item.exploit_verified else "public" if item.public_exploit else "",
263
+ render.when(item.published),
264
+ item.name,
265
+ ]
266
+ for item in found
267
+ ],
268
+ [dict(item.raw) for item in found],
269
+ )
270
+ render.note(f"{len(found)} vulnerabilit{'y' if len(found) == 1 else 'ies'} on {device}")
271
+
272
+
273
+ def _rank(severity: str) -> int:
274
+ try:
275
+ return parse_severity(severity)
276
+ except LdoError:
277
+ return -1
278
+
279
+
280
+ @xdr_app.command("indicators")
281
+ def indicators(
282
+ ctx: typer.Context,
283
+ profile: ProfileOption = None,
284
+ output: OutputOption = Output.TABLE,
285
+ ) -> None:
286
+ """List custom indicators of compromise (hashes, IPs, URLs, domains, certificates)."""
287
+ runtime = get_runtime(ctx).microsoft
288
+ found = runtime.xdr(runtime.profile(profile)).indicators()
289
+ render.emit(
290
+ output,
291
+ ["TYPE", "VALUE", "ACTION", "SEVERITY", "TITLE", "EXPIRES", "CREATED BY"],
292
+ [
293
+ [
294
+ item.indicator_type,
295
+ item.value,
296
+ item.action,
297
+ item.severity,
298
+ item.title,
299
+ render.when(item.expires),
300
+ item.created_by,
301
+ ]
302
+ for item in found
303
+ ],
304
+ [dict(item.raw) for item in found],
305
+ )
306
+
307
+
308
+ @xdr_app.command("hunt")
309
+ def hunt(
310
+ ctx: typer.Context,
311
+ query: Annotated[
312
+ str | None,
313
+ typer.Argument(
314
+ help="KQL query. Omit, or pass -, to read it from stdin.", show_default=False
315
+ ),
316
+ ] = None,
317
+ file: QueryFileOption = None,
318
+ endpoint: Annotated[
319
+ bool,
320
+ typer.Option(
321
+ "--endpoint",
322
+ help=(
323
+ "Through the Defender for Endpoint API instead: device tables only, but the "
324
+ "Azure CLI's sign-in can use it."
325
+ ),
326
+ ),
327
+ ] = False,
328
+ timespan: Annotated[
329
+ str | None,
330
+ typer.Option("--timespan", help="How far back the data goes, e.g. 7d. Default: 30 days."),
331
+ ] = None,
332
+ profile: ProfileOption = None,
333
+ output: OutputOption = Output.TABLE,
334
+ ) -> None:
335
+ """Run an Advanced Hunting (KQL) query over Defender XDR.
336
+
337
+ Through Microsoft Graph, which covers every Defender XDR table: devices, email,
338
+ identity, cloud apps and alerts. It needs ThreatHunting.Read.All, which the Azure
339
+ CLI's token never has: use an interactive or device-code profile whose app has it,
340
+ or --endpoint for the device tables with the Azure CLI's sign-in.
341
+ """
342
+ text = read_query(query, file)
343
+ if not endpoint:
344
+ from libre_devops_helpers.cli.commands.graph import run_graph_hunt
345
+
346
+ run_graph_hunt(ctx, text, timespan, profile, output)
347
+ return
348
+ if timespan:
349
+ raise typer.BadParameter("--timespan is not available with --endpoint; put it in the query")
350
+ runtime = get_runtime(ctx).microsoft
351
+ result = runtime.xdr(runtime.profile(profile)).hunt(text)
352
+ render.query_result(result, output)
353
+ render.note(f"{len(result.rows)} row(s)")
@@ -0,0 +1,12 @@
1
+ """Exit codes, the same for every command, so scripts can branch on them."""
2
+
3
+ OK = 0
4
+ # An error, or a token check that failed.
5
+ ERROR = 1
6
+ # A usage error (bad option or argument); Click uses this code itself.
7
+ USAGE = 2
8
+ # The command ran, but found something that needs attention: a device missing or not in
9
+ # the expected state, or a credential or secret close to expiry.
10
+ ATTENTION = 3
11
+ # Stopped with Ctrl-C.
12
+ INTERRUPTED = 130
@@ -0,0 +1,146 @@
1
+ """Options shared by several commands, and access to the per-invocation Runtime."""
2
+
3
+ import sys
4
+ from datetime import timedelta
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from libre_devops_helpers.cli.render import Output
11
+ from libre_devops_helpers.cli.runtime import Runtime
12
+ from libre_devops_helpers.core import brand
13
+ from libre_devops_helpers.core.errors import InputError, LdoError
14
+ from libre_devops_helpers.core.inputs import read_names
15
+ from libre_devops_helpers.core.util import parse_duration
16
+ from libre_devops_helpers.microsoft.config import load_config
17
+
18
+
19
+ def get_runtime(ctx: typer.Context) -> Runtime:
20
+ """The Runtime the root command stored on the context."""
21
+ runtime = ctx.find_object(Runtime)
22
+ if runtime is None:
23
+ raise RuntimeError("the CLI runtime was not initialised")
24
+ return runtime
25
+
26
+
27
+ def complete_profile(incomplete: str) -> list[str]:
28
+ """Shell completion for profile names. Silent when there is no usable config."""
29
+ try:
30
+ names = load_config().profiles
31
+ except LdoError:
32
+ return []
33
+ return [name for name in sorted(names) if name.startswith(incomplete)]
34
+
35
+
36
+ def duration(value: str | None) -> timedelta | None:
37
+ """Parse a duration option (``15m``, ``2h``, ``7d``), reporting a bad one as usage."""
38
+ if value is None:
39
+ return None
40
+ try:
41
+ return parse_duration(value)
42
+ except InputError as exc:
43
+ raise typer.BadParameter(str(exc)) from None
44
+
45
+
46
+ def names(
47
+ values: list[str] | None,
48
+ from_file: Path | None,
49
+ column: str | None,
50
+ sheet: str | None = None,
51
+ ) -> list[str]:
52
+ """Names from arguments, ``-`` for stdin, and ``--from-file``; at least one is needed."""
53
+ found = read_names(
54
+ values or [], stdin=sys.stdin, from_file=from_file, column=column, sheet=sheet
55
+ )
56
+ if not found:
57
+ raise InputError("no names given", hint="pass names, '-' for stdin, or --from-file")
58
+ return found
59
+
60
+
61
+ def read_query(query: str | None, file: Path | None) -> str:
62
+ """A query from the argument, from ``--file``, or from stdin, in that order."""
63
+ if query and query != "-":
64
+ return query
65
+ if file is not None:
66
+ try:
67
+ return file.read_text(encoding="utf-8")
68
+ except OSError as exc:
69
+ raise InputError(f"cannot read {file}: {exc}") from None
70
+ if query == "-" or not sys.stdin.isatty():
71
+ text = sys.stdin.read()
72
+ if text.strip():
73
+ return text
74
+ raise InputError("no query given", hint="pass it as an argument, with --file, or on stdin")
75
+
76
+
77
+ ProfileOption = Annotated[
78
+ str | None,
79
+ typer.Option(
80
+ "--profile",
81
+ "-p",
82
+ envvar=brand.PROFILE_ENV,
83
+ help="Profile from the config file. Default: default_profile, else the az active account.",
84
+ autocompletion=complete_profile,
85
+ show_default=False,
86
+ ),
87
+ ]
88
+
89
+ OutputOption = Annotated[
90
+ Output,
91
+ typer.Option(
92
+ "--output", "-o", help="table for people, json for scripts, csv for spreadsheets."
93
+ ),
94
+ ]
95
+
96
+ NamesArgument = Annotated[
97
+ list[str] | None,
98
+ typer.Argument(
99
+ help='Names: "a,b,c", several arguments, or - to read stdin.', show_default=False
100
+ ),
101
+ ]
102
+
103
+ FromFileOption = Annotated[
104
+ Path | None,
105
+ typer.Option(
106
+ "--from-file",
107
+ "-f",
108
+ help=(
109
+ "Read names from a file: one per line, or a column of a CSV or Excel workbook "
110
+ "(.xlsx, .xlsm, .xltx, .xltm; see --column and --sheet)."
111
+ ),
112
+ dir_okay=False,
113
+ exists=True,
114
+ show_default=False,
115
+ ),
116
+ ]
117
+
118
+ ColumnOption = Annotated[
119
+ str | None,
120
+ typer.Option(
121
+ "--column",
122
+ help="Column holding the names, matched by header. Title rows above it are skipped.",
123
+ ),
124
+ ]
125
+
126
+ SheetOption = Annotated[
127
+ str | None,
128
+ typer.Option(
129
+ "--sheet",
130
+ help=(
131
+ "Workbook sheet (tab) to read. Default: the one visible sheet with --column, "
132
+ "or the first visible sheet."
133
+ ),
134
+ show_default=False,
135
+ ),
136
+ ]
137
+
138
+ QueryFileOption = Annotated[
139
+ Path | None,
140
+ typer.Option("--file", help="Read the query from a file.", dir_okay=False, exists=True),
141
+ ]
142
+
143
+ DirectOption = Annotated[
144
+ bool,
145
+ typer.Option("--direct", help="Direct memberships only. Default includes nested groups."),
146
+ ]