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,412 @@
1
+ """HTTP client for Microsoft APIs: bearer auth, bounded retries, Retry-After, paging.
2
+
3
+ Built on requests. Retries are decided on the HTTP status code (408, 429, 5xx) and on
4
+ connection errors or timeouts, never on the wording of an error message. The bearer
5
+ token is only ever sent to the client's own https host, and redirects are not followed.
6
+ Every POST this package makes is a read-only query or a token request, so retrying
7
+ one is safe. A 401 is retried once with a fresh token, when the token source can drop
8
+ the one it cached (``core.auth.BearerToken``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import email.utils
14
+ import ipaddress
15
+ import json
16
+ import logging
17
+ import random
18
+ import time
19
+ from collections.abc import Callable, Iterator, Mapping
20
+ from datetime import UTC, datetime
21
+ from typing import Any, Self
22
+ from urllib.parse import quote, urlencode, urlsplit
23
+
24
+ import requests
25
+
26
+ from libre_devops_helpers import __version__
27
+ from libre_devops_helpers.core import brand
28
+ from libre_devops_helpers.core.errors import ApiError
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ RETRY_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
33
+ USER_AGENT = f"{brand.COMMAND}/{__version__}"
34
+
35
+
36
+ class ApiClient:
37
+ """JSON client for one API base URL, usually authenticated with a bearer token.
38
+
39
+ ``token`` is called before every request, so a caching provider can refresh a token
40
+ close to expiry. If it also has a ``refresh()`` method, a 401 answer drops the cached
41
+ token and the request is sent once more with a new one: that covers a token revoked
42
+ or expired early. Tokens go in the Authorization header only and are never logged.
43
+ With ``token=None`` no Authorization header is sent; only then may ``allow_http``
44
+ permit a plain http base URL on a loopback or link-local host, which is what the
45
+ managed identity endpoints use.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ base_url: str,
51
+ token: Callable[[], str] | None,
52
+ *,
53
+ name: str = "API",
54
+ session: requests.Session | None = None,
55
+ verify: bool | str = True,
56
+ timeout: float = 30.0,
57
+ max_attempts: int = 4,
58
+ backoff: float = 1.0,
59
+ max_backoff: float = 30.0,
60
+ max_retry_after: float = 120.0,
61
+ sleep: Callable[[float], None] = time.sleep,
62
+ allow_http: bool = False,
63
+ auth_scheme: str = "Bearer",
64
+ ) -> None:
65
+ parts = urlsplit(base_url)
66
+ if not parts.netloc or parts.scheme not in {"https", "http"}:
67
+ raise ValueError(f"base_url must be an https URL, got {base_url!r}")
68
+ if parts.scheme == "http" and not (
69
+ allow_http and token is None and _is_local(parts.hostname or "")
70
+ ):
71
+ raise ValueError(f"base_url must be an https URL, got {base_url!r}")
72
+ if max_attempts < 1:
73
+ raise ValueError("max_attempts must be at least 1")
74
+ self.name = name
75
+ self.base_url = base_url.rstrip("/")
76
+ self._scheme = parts.scheme
77
+ self._host = parts.netloc.lower()
78
+ self._token = token
79
+ # "Bearer" for tokens; "Basic" when ``token`` returns base64 "user:password".
80
+ self._auth_scheme = auth_scheme
81
+ refresh = getattr(token, "refresh", None)
82
+ self._refresh_token: Callable[[], None] | None = refresh if callable(refresh) else None
83
+ self._session = session or requests.Session()
84
+ self._owns_session = session is None
85
+ self._verify = verify
86
+ self._timeout = timeout
87
+ self._max_attempts = max_attempts
88
+ self._backoff = backoff
89
+ self._max_backoff = max_backoff
90
+ self._max_retry_after = max_retry_after
91
+ self._sleep = sleep
92
+
93
+ def ensure_token(self) -> None:
94
+ """Get the token now, in this thread.
95
+
96
+ Call it on the main thread before fanning requests out to workers, so that a
97
+ credential which has to ask someone to sign in again asks there.
98
+ """
99
+ if self._token is not None:
100
+ self._token()
101
+
102
+ def close(self) -> None:
103
+ """Close the underlying session if this client created it."""
104
+ if self._owns_session:
105
+ self._session.close()
106
+
107
+ def __enter__(self) -> Self:
108
+ return self
109
+
110
+ def __exit__(self, *exc_info: object) -> None:
111
+ self.close()
112
+
113
+ def url(self, path: str, params: Mapping[str, str] | None = None) -> str:
114
+ """Absolute URL for ``path`` with ``params`` percent-encoded.
115
+
116
+ ``path`` may be a full URL (an ``@odata.nextLink``); it must use this client's
117
+ scheme and host, so a token never travels anywhere else.
118
+ """
119
+ if "://" in path:
120
+ parts = urlsplit(path)
121
+ if parts.scheme != self._scheme or parts.netloc.lower() != self._host:
122
+ raise ApiError(
123
+ f"{self.name}: refusing to send a token to {parts.scheme}://{parts.netloc}"
124
+ )
125
+ url = path
126
+ else:
127
+ url = f"{self.base_url}/{path.lstrip('/')}"
128
+ if params:
129
+ url += ("&" if "?" in url else "?") + urlencode(params, quote_via=quote)
130
+ return url
131
+
132
+ def request(
133
+ self,
134
+ method: str,
135
+ path: str,
136
+ *,
137
+ params: Mapping[str, str] | None = None,
138
+ headers: Mapping[str, str] | None = None,
139
+ json_body: Any = None,
140
+ form: Mapping[str, str] | None = None,
141
+ allow_empty: bool = False,
142
+ ) -> dict[str, Any]:
143
+ """Send one request and return the JSON object it responds with.
144
+
145
+ ``allow_empty`` accepts a success with no body (some actions answer 200 or 204
146
+ with nothing), returning ``{}`` for it.
147
+ """
148
+ url = self.url(path, params)
149
+ response = self._send(method, url, headers, json_body=json_body, form=form)
150
+ if allow_empty and not response.content.strip():
151
+ return {}
152
+ try:
153
+ body = response.json()
154
+ except ValueError:
155
+ raise ApiError(
156
+ f"{self.name}: {method} {_path(url)} did not return JSON",
157
+ status=response.status_code,
158
+ ) from None
159
+ if not isinstance(body, dict):
160
+ raise ApiError(f"{self.name}: {method} {_path(url)} did not return a JSON object")
161
+ return body
162
+
163
+ def get(
164
+ self,
165
+ path: str,
166
+ *,
167
+ params: Mapping[str, str] | None = None,
168
+ headers: Mapping[str, str] | None = None,
169
+ ) -> dict[str, Any]:
170
+ """GET ``path`` and return the JSON object it responds with."""
171
+ return self.request("GET", path, params=params, headers=headers)
172
+
173
+ def get_text(
174
+ self,
175
+ path: str,
176
+ *,
177
+ params: Mapping[str, str] | None = None,
178
+ headers: Mapping[str, str] | None = None,
179
+ ) -> str:
180
+ """GET ``path`` and return its body as text, for the few APIs that answer in text."""
181
+ response = self._send("GET", self.url(path, params), headers)
182
+ return response.text
183
+
184
+ def post(
185
+ self,
186
+ path: str,
187
+ body: Any,
188
+ *,
189
+ params: Mapping[str, str] | None = None,
190
+ headers: Mapping[str, str] | None = None,
191
+ ) -> dict[str, Any]:
192
+ """POST ``body`` as JSON to ``path`` and return the JSON object it responds with."""
193
+ return self.request("POST", path, params=params, headers=headers, json_body=body)
194
+
195
+ def get_all(
196
+ self,
197
+ path: str,
198
+ *,
199
+ params: Mapping[str, str] | None = None,
200
+ headers: Mapping[str, str] | None = None,
201
+ next_link: str = "@odata.nextLink",
202
+ ) -> Iterator[dict[str, Any]]:
203
+ """Yield every item of a paged collection, following ``next_link``.
204
+
205
+ Graph and Defender use ``@odata.nextLink``; Azure Resource Manager and Key Vault
206
+ use ``nextLink``. Pages are fetched only as items are consumed.
207
+ """
208
+ page = self.get(path, params=params, headers=headers)
209
+ while True:
210
+ items = page.get("value")
211
+ if not isinstance(items, list):
212
+ raise ApiError(f"{self.name}: response has no 'value' array")
213
+ yield from (item for item in items if isinstance(item, dict))
214
+ link = page.get(next_link)
215
+ if not isinstance(link, str) or not link:
216
+ return
217
+ page = self.get(link, headers=headers)
218
+
219
+ def _send(
220
+ self,
221
+ method: str,
222
+ url: str,
223
+ headers: Mapping[str, str] | None,
224
+ *,
225
+ json_body: Any = None,
226
+ form: Mapping[str, str] | None = None,
227
+ ) -> requests.Response:
228
+ attempt = 0
229
+ refreshed = False
230
+ while True:
231
+ attempt += 1
232
+ request_headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
233
+ if self._token is not None:
234
+ request_headers["Authorization"] = f"{self._auth_scheme} {self._token()}"
235
+ request_headers.update(headers or {})
236
+ try:
237
+ response = self._session.request(
238
+ method,
239
+ url,
240
+ headers=request_headers,
241
+ json=json_body,
242
+ data=form,
243
+ timeout=self._timeout,
244
+ verify=self._verify,
245
+ allow_redirects=False,
246
+ )
247
+ except (requests.ConnectionError, requests.Timeout) as exc:
248
+ if attempt >= self._max_attempts:
249
+ raise ApiError(
250
+ f"{self.name}: {method} {_path(url)} failed after {attempt} attempts: "
251
+ f"{type(exc).__name__}"
252
+ ) from None
253
+ self._wait(attempt, None, type(exc).__name__)
254
+ continue
255
+ except requests.RequestException as exc:
256
+ raise ApiError(f"{self.name}: {method} {_path(url)} failed: {exc}") from None
257
+
258
+ if 200 <= response.status_code < 300:
259
+ return response
260
+ if response.status_code == 401 and self._refresh_token and not refreshed:
261
+ # Once only: a second 401 means the token is not the problem.
262
+ refreshed = True
263
+ attempt -= 1
264
+ log.info("%s: HTTP 401, retrying once with a new token", self.name)
265
+ self._refresh_token()
266
+ continue
267
+ if response.status_code in RETRY_STATUSES and attempt < self._max_attempts:
268
+ self._wait(attempt, retry_after_seconds(response), f"HTTP {response.status_code}")
269
+ continue
270
+ raise error_from_response(self.name, response)
271
+
272
+ def _wait(self, attempt: int, retry_after: float | None, reason: str) -> None:
273
+ # A server-directed Retry-After wins over the backoff (retrying earlier just
274
+ # throttles again), capped so a broken server cannot stall the run.
275
+ if retry_after is not None:
276
+ delay = min(self._max_retry_after, retry_after)
277
+ else:
278
+ delay = min(self._max_backoff, self._backoff * 2 ** (attempt - 1))
279
+ delay += random.uniform(0, self._backoff / 2)
280
+ log.warning(
281
+ "%s: %s on attempt %d of %d, retrying in %.1fs",
282
+ self.name,
283
+ reason,
284
+ attempt,
285
+ self._max_attempts,
286
+ delay,
287
+ )
288
+ self._sleep(delay)
289
+
290
+
291
+ def retry_after_seconds(response: requests.Response) -> float | None:
292
+ """Seconds requested by a Retry-After header (delta-seconds or HTTP date), else None."""
293
+ value = response.headers.get("Retry-After", "").strip()
294
+ if not value:
295
+ return None
296
+ if value.isdigit():
297
+ return float(value)
298
+ try:
299
+ when = email.utils.parsedate_to_datetime(value)
300
+ except (TypeError, ValueError):
301
+ return None
302
+ if when.tzinfo is None:
303
+ when = when.replace(tzinfo=UTC)
304
+ return max(0.0, (when - datetime.now(UTC)).total_seconds())
305
+
306
+
307
+ def error_from_response(name: str, response: requests.Response) -> ApiError:
308
+ """Build an ApiError from a failed response, using the service's error body when present.
309
+
310
+ Graph, Defender, ARM and Key Vault reply ``{"error": {"code", "message"}}``; the Entra
311
+ token endpoint replies ``{"error": "<code>", "error_description": "..."}``.
312
+ """
313
+ code: str | None = None
314
+ message: str | None = None
315
+ request_id: str | None = None
316
+ try:
317
+ body = response.json()
318
+ except ValueError:
319
+ body = None
320
+ if isinstance(body, dict) and isinstance(body.get("error"), dict):
321
+ error = body["error"]
322
+ code = error.get("code") if isinstance(error.get("code"), str) else None
323
+ message = error.get("message") if isinstance(error.get("message"), str) else None
324
+ inner = error.get("innerError")
325
+ if isinstance(inner, dict) and isinstance(inner.get("request-id"), str):
326
+ request_id = inner["request-id"]
327
+ elif isinstance(body, dict) and isinstance(body.get("error"), str):
328
+ code = body["error"]
329
+ description = body.get("error_description")
330
+ if isinstance(description, str) and description.strip():
331
+ # The first line carries the AADSTS code and the reason; the rest is trace ids.
332
+ message = description.strip().splitlines()[0]
333
+ request_id = (
334
+ request_id or response.headers.get("request-id") or response.headers.get("x-ms-request-id")
335
+ )
336
+
337
+ status = response.status_code
338
+ detail = _readable(message) if message else ""
339
+ text = f"{name}: HTTP {status}"
340
+ if code:
341
+ text += f" {code}"
342
+ text += f": {detail or response.reason or 'request failed'}"
343
+ if request_id:
344
+ text += f" (request-id {request_id})"
345
+ challenge = response.headers.get("WWW-Authenticate", "").lower()
346
+ if status == 401 and "insufficient_claims" in challenge:
347
+ # Continuous access evaluation: the token was revoked, or a policy changed.
348
+ hint: str | None = (
349
+ "the service revoked the token or wants a sign-in that meets a Conditional "
350
+ "Access policy (a claims challenge); sign in again"
351
+ )
352
+ else:
353
+ hint = _hint(status, text.lower())
354
+ return ApiError(text, status=status, code=code, request_id=request_id, hint=hint)
355
+
356
+
357
+ def _readable(message: str) -> str:
358
+ """One readable line from a service error message.
359
+
360
+ Some services put a JSON document inside the message (Graph's PIM errors, Intune,
361
+ which nests two deep); those are unwrapped to their inner code and message. Line
362
+ breaks are flattened, and the result is capped so one error cannot flood a terminal.
363
+ """
364
+ codes: list[str] = []
365
+ text = message.strip()
366
+ for _ in range(3):
367
+ if not text.startswith("{"):
368
+ break
369
+ try:
370
+ inner = json.loads(text)
371
+ except ValueError:
372
+ break
373
+ if not isinstance(inner, dict):
374
+ break
375
+ code = inner.get("errorCode") or inner.get("ErrorCode") or inner.get("code")
376
+ if isinstance(code, str) and code:
377
+ codes.append(code)
378
+ found = inner.get("message") or inner.get("Message")
379
+ if not isinstance(found, str):
380
+ text = ""
381
+ break
382
+ text = found.strip()
383
+ flat = " ".join(text.split())
384
+ readable = ": ".join([*codes, flat] if flat else codes)
385
+ return readable if len(readable) <= 400 else readable[:397] + "..."
386
+
387
+
388
+ def _hint(status: int, text: str) -> str | None:
389
+ if status == 403 and "suspended" in text:
390
+ return "the service is suspended in this tenant, usually because its licence or trial ended"
391
+ if status == 403 and "client address is not authorized" in text:
392
+ return "the resource's firewall does not allow this machine's IP address"
393
+ if status in {401, 403} and ("forbidden" in text or "permission" in text):
394
+ return "the token was accepted but lacks the permission this call needs"
395
+ return {
396
+ 401: "the API rejected the token; check its audience, tenant and expiry",
397
+ 403: "the signed-in identity lacks a role or permission for this call",
398
+ }.get(status)
399
+
400
+
401
+ def _is_local(host: str) -> bool:
402
+ if host == "localhost":
403
+ return True
404
+ try:
405
+ address = ipaddress.ip_address(host)
406
+ except ValueError:
407
+ return False
408
+ return address.is_loopback or address.is_link_local
409
+
410
+
411
+ def _path(url: str) -> str:
412
+ return urlsplit(url).path
@@ -0,0 +1,193 @@
1
+ """Read a list of names from arguments, stdin, a text file, a CSV or an Excel workbook.
2
+
3
+ Commands that take devices, users or vaults accept them however they are to hand:
4
+ ``"a,b,c"``, several arguments, ``-`` for stdin, or a file. A text file holds names
5
+ separated by commas, spaces or new lines, with ``#`` comments. A CSV file (a ``.csv``
6
+ suffix, or any file when a column is named) and an Excel workbook (``.xlsx``, ``.xlsm``,
7
+ ``.xltx``, ``.xltm``) are read by column header, so a plan, an export from a portal or a
8
+ spreadsheet someone emailed works as it is, title rows above the header and all.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import io
15
+ import logging
16
+ from collections.abc import Iterable, Sequence
17
+ from itertools import islice
18
+ from pathlib import Path
19
+ from typing import TextIO
20
+
21
+ from libre_devops_helpers.core import sheets
22
+ from libre_devops_helpers.core.errors import InputError
23
+ from libre_devops_helpers.core.util import split_names
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ # With a column named, the header is the first of this many non-blank rows to have it.
28
+ HEADER_SEARCH_ROWS = 25
29
+
30
+ # Cells of one row, and whether the row is hidden (in Excel; a CSV row never is).
31
+ Row = tuple[Sequence[str], bool]
32
+
33
+
34
+ def read_names(
35
+ values: Sequence[str] = (),
36
+ *,
37
+ stdin: TextIO | None = None,
38
+ from_file: Path | None = None,
39
+ column: str | None = None,
40
+ sheet: str | None = None,
41
+ ) -> list[str]:
42
+ """Every name given, in order, with blanks and case-insensitive repeats dropped.
43
+
44
+ ``-`` among ``values`` reads ``stdin`` in its place. ``column`` picks a column (by
45
+ header, case-insensitively) of a CSV or workbook ``from_file``, or of CSV on stdin
46
+ when there is no file. ``sheet`` picks a workbook's sheet by tab name; without it,
47
+ the one visible sheet with that column is used, or the first visible sheet when no
48
+ column is named.
49
+ """
50
+ if sheet is not None and (from_file is None or not sheets.is_workbook(from_file)):
51
+ raise InputError("--sheet applies to an Excel workbook only")
52
+ collected: list[str] = []
53
+ for value in values:
54
+ if value == "-":
55
+ if stdin is None:
56
+ raise InputError("'-' reads names from stdin, but there is no stdin")
57
+ text = stdin.read()
58
+ collected.extend(_from_csv(text, column, "stdin") if column else _from_text(text))
59
+ else:
60
+ collected.extend(split_names([value]))
61
+ if from_file is not None:
62
+ collected.extend(_from_file(from_file, column, sheet))
63
+ return _dedupe(collected)
64
+
65
+
66
+ def _from_file(path: Path, column: str | None, sheet: str | None) -> list[str]:
67
+ sheets.check_readable(path)
68
+ if sheets.is_workbook(path):
69
+ return _from_workbook(path, column, sheet)
70
+ try:
71
+ text = path.read_text(encoding="utf-8-sig")
72
+ except UnicodeDecodeError:
73
+ raise InputError(
74
+ f"{path} is not a text file", hint="use a text file, a CSV or an Excel workbook"
75
+ ) from None
76
+ except OSError as exc:
77
+ raise InputError(f"cannot read {path}: {exc}") from None
78
+ if column or path.suffix.lower() == ".csv":
79
+ return _from_csv(text, column, str(path))
80
+ return _from_text(text)
81
+
82
+
83
+ def _from_text(text: str) -> list[str]:
84
+ return split_names(line.split("#", 1)[0] for line in text.splitlines())
85
+
86
+
87
+ def _dedupe(names: list[str]) -> list[str]:
88
+ seen: set[str] = set()
89
+ kept: list[str] = []
90
+ for name in names:
91
+ if name.casefold() not in seen:
92
+ seen.add(name.casefold())
93
+ kept.append(name)
94
+ return kept
95
+
96
+
97
+ def _from_csv(text: str, column: str | None, source: str) -> list[str]:
98
+ return _column(((cells, False) for cells in csv.reader(io.StringIO(text))), column, source)
99
+
100
+
101
+ def _from_workbook(path: Path, column: str | None, sheet: str | None) -> list[str]:
102
+ with sheets.open_workbook(path) as book:
103
+ chosen = book.sheet(sheet) if sheet is not None else _pick_sheet(book, column)
104
+ return _column(book.rows(chosen), column, f"sheet {chosen.name!r} of {path}")
105
+
106
+
107
+ def _pick_sheet(book: sheets.Workbook, column: str | None) -> sheets.Sheet:
108
+ """The one visible sheet with ``column`` in its header, or the first visible sheet."""
109
+ visible = [sheet for sheet in book.sheets if not sheet.hidden]
110
+ if not visible:
111
+ raise InputError(
112
+ f"{book.path} has only hidden sheets",
113
+ hint=f"name one with --sheet ({', '.join(s.name for s in book.sheets)})",
114
+ )
115
+ if column is None:
116
+ return visible[0]
117
+ having = [sheet for sheet in visible if _has_column(book.rows(sheet), column)]
118
+ if len(having) == 1:
119
+ return having[0]
120
+ names = ", ".join(sheet.name for sheet in having or visible)
121
+ if having:
122
+ raise InputError(
123
+ f"several sheets of {book.path} have a column {column!r}",
124
+ hint=f"pick one with --sheet ({names})",
125
+ )
126
+ raise InputError(
127
+ f"no sheet of {book.path} has a column {column!r}",
128
+ hint=f"check the header, or pick a sheet with --sheet ({names})",
129
+ )
130
+
131
+
132
+ def _has_column(rows: Iterable[Row], column: str) -> bool:
133
+ try:
134
+ _header(iter(rows), column, "")
135
+ except InputError:
136
+ return False
137
+ return True
138
+
139
+
140
+ def _column(rows: Iterable[Row], column: str | None, source: str) -> list[str]:
141
+ """The non-blank values under the header ``column``, or under the only header."""
142
+ remaining = iter(rows)
143
+ index = _header(remaining, column, source)
144
+ values: list[str] = []
145
+ hidden = 0
146
+ for cells, row_hidden in remaining:
147
+ # Cells are kept whole, so a column may hold names with spaces in them.
148
+ value = cells[index].strip() if index < len(cells) else ""
149
+ if value:
150
+ values.append(value)
151
+ hidden += row_hidden
152
+ if hidden:
153
+ log.warning(
154
+ "%d of the names in %s are in rows hidden or filtered out in Excel; they are included",
155
+ hidden,
156
+ source,
157
+ )
158
+ return values
159
+
160
+
161
+ def _header(rows: Iterable[Row], column: str | None, source: str) -> int:
162
+ """Find the header row, consuming rows up to it, and return the column's index.
163
+
164
+ Without ``column``, the header is the first non-blank row, and it must name one
165
+ column only. With it, the header is the first of the first few non-blank rows to
166
+ have that column, so title rows above the header do no harm.
167
+ """
168
+ wanted = column.strip().casefold() if column else None
169
+ first: list[str] | None = None
170
+ for row, _ in islice(_non_blank(rows), HEADER_SEARCH_ROWS):
171
+ cells = [cell.strip() for cell in row]
172
+ first = first or cells
173
+ if wanted is None:
174
+ named = [index for index, cell in enumerate(cells) if cell]
175
+ if len(named) == 1:
176
+ return named[0]
177
+ raise InputError(
178
+ f"{source} has several columns",
179
+ hint=f"pick one with --column ({', '.join(cell for cell in cells if cell)})",
180
+ )
181
+ for index, cell in enumerate(cells):
182
+ if cell.casefold() == wanted:
183
+ return index
184
+ if first is None:
185
+ raise InputError(f"{source} has no header row")
186
+ raise InputError(
187
+ f"{source} has no column {column!r}",
188
+ hint=f"columns: {', '.join(cell for cell in first if cell)}",
189
+ )
190
+
191
+
192
+ def _non_blank(rows: Iterable[Row]) -> Iterable[Row]:
193
+ return (row for row in rows if any(cell.strip() for cell in row[0]))