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,266 @@
1
+ """Where a sign-in's refresh token is kept between commands: a profile's token_cache.
2
+
3
+ Three choices, from safest to least guarded (profiles use ``file`` unless they say
4
+ otherwise, since it works on a headless machine):
5
+
6
+ - ``memory``: nowhere. The sign-in lasts one command and is gone after.
7
+ - ``keychain``: the operating system's own store, locked to your account. Keychain on
8
+ macOS and the Secret Service (GNOME Keyring, KWallet) on Linux, through the optional
9
+ ``keyring`` package; on Windows, a file encrypted with DPAPI for your Windows account,
10
+ since Credential Manager's size limit is too small for a refresh token.
11
+ - ``file`` (the default for profiles): a plaintext JSON file that only your account may
12
+ read (mode 0600), as the Azure CLI keeps its own tokens on Linux. Anyone who can act as
13
+ you, or as root, or who gets a copy of the file (a backup, say), can use what is in it.
14
+
15
+ A refresh token is as good as a sign-in until it expires or is revoked, so a file that
16
+ other accounts can read is refused rather than used, as ssh refuses a readable key.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import os
24
+ import sys
25
+ import threading
26
+ from collections.abc import Callable, Mapping
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+ from types import ModuleType
30
+ from typing import Any, Protocol
31
+
32
+ from libre_devops_helpers.core import brand
33
+ from libre_devops_helpers.core.errors import AuthError, ConfigError
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+ TOKEN_CACHES = ("memory", "keychain", "file")
38
+ # Where a profile keeps its sign-in unless it says otherwise: a private file, which works
39
+ # on a headless machine as well as a desktop.
40
+ DEFAULT_TOKEN_CACHE = "file"
41
+ FILE_ENV = brand.env_var("TOKEN_CACHE")
42
+ KEYCHAIN_SERVICE = f"{brand.COMMAND} sign-in"
43
+ _KEYCHAIN_HINT = f"install the keychain extra: uv tool install '{brand.DISTRIBUTION}[keychain]'"
44
+
45
+
46
+ class TokenStore(Protocol):
47
+ """Named secrets that outlast one command (or, for MemoryStore, do not)."""
48
+
49
+ def load(self, key: str) -> str | None:
50
+ """The value kept under ``key``, or None when there is none."""
51
+
52
+ def save(self, key: str, value: str) -> None:
53
+ """Keep ``value`` under ``key``, replacing what was there."""
54
+
55
+ def delete(self, key: str) -> bool:
56
+ """Forget ``key``. True when there was something to forget."""
57
+
58
+
59
+ class MemoryStore:
60
+ """Kept in this process only: the default, and what every other store falls back to."""
61
+
62
+ def __init__(self) -> None:
63
+ self._values: dict[str, str] = {}
64
+ self._lock = threading.Lock()
65
+
66
+ def load(self, key: str) -> str | None:
67
+ with self._lock:
68
+ return self._values.get(key)
69
+
70
+ def save(self, key: str, value: str) -> None:
71
+ with self._lock:
72
+ self._values[key] = value
73
+
74
+ def delete(self, key: str) -> bool:
75
+ with self._lock:
76
+ return self._values.pop(key, None) is not None
77
+
78
+
79
+ class FileStore:
80
+ """Values in one JSON file, private to your account, replaced whole on every write.
81
+
82
+ The directory is created 0700 and the file 0600, through a temporary file renamed
83
+ into place, so a crash never leaves half a file. With ``protect`` and ``unprotect``
84
+ (DPAPI on Windows) the file holds their encrypted bytes rather than plain JSON.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ path: Path,
90
+ *,
91
+ protect: Callable[[bytes], bytes] | None = None,
92
+ unprotect: Callable[[bytes], bytes] | None = None,
93
+ ) -> None:
94
+ self.path = path
95
+ self._protect = protect
96
+ self._unprotect = unprotect
97
+ self._lock = threading.Lock()
98
+
99
+ def load(self, key: str) -> str | None:
100
+ with self._lock:
101
+ entry = self._read().get(key)
102
+ value = entry.get("value") if isinstance(entry, dict) else None
103
+ return value if isinstance(value, str) and value else None
104
+
105
+ def save(self, key: str, value: str) -> None:
106
+ with self._lock:
107
+ data = self._read(refuse_exposed=False)
108
+ data[key] = {"value": value, "saved": datetime.now(UTC).isoformat(timespec="seconds")}
109
+ self._write(data)
110
+
111
+ def delete(self, key: str) -> bool:
112
+ with self._lock:
113
+ data = self._read(refuse_exposed=False)
114
+ if key not in data:
115
+ return False
116
+ del data[key]
117
+ if data:
118
+ self._write(data)
119
+ else:
120
+ self.path.unlink(missing_ok=True)
121
+ return True
122
+
123
+ def _read(self, *, refuse_exposed: bool = True) -> dict[str, Any]:
124
+ try:
125
+ raw = self.path.read_bytes()
126
+ except FileNotFoundError:
127
+ return {}
128
+ except OSError as exc:
129
+ raise AuthError(f"cannot read the token cache {self.path}: {exc}") from None
130
+ if refuse_exposed and _exposed(self.path):
131
+ raise AuthError(
132
+ f"the token cache {self.path} can be read by other accounts, so it is not used",
133
+ hint=(
134
+ f"delete it (a new one is private): what it held may have been seen, so "
135
+ f"also run {brand.command('entra sign-out')} and sign out everywhere if "
136
+ "that matters"
137
+ ),
138
+ )
139
+ try:
140
+ if self._unprotect is not None:
141
+ raw = self._unprotect(raw)
142
+ data = json.loads(raw.decode("utf-8"))
143
+ except (OSError, ValueError) as exc:
144
+ # A damaged cache, or one encrypted for another account: start again.
145
+ log.warning("ignoring the token cache %s, which cannot be read: %s", self.path, exc)
146
+ return {}
147
+ return data if isinstance(data, dict) else {}
148
+
149
+ def _write(self, data: Mapping[str, Any]) -> None:
150
+ payload = json.dumps(data, indent=2, sort_keys=True).encode("utf-8")
151
+ if self._protect is not None:
152
+ payload = self._protect(payload)
153
+ folder = self.path.parent
154
+ try:
155
+ if not folder.exists():
156
+ folder.mkdir(parents=True, mode=0o700)
157
+ os.chmod(folder, 0o700) # mkdir's mode is narrowed by the umask, not widened
158
+ temporary = folder / f".{self.path.name}.{os.getpid()}.tmp"
159
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
160
+ try:
161
+ with os.fdopen(descriptor, "wb") as handle:
162
+ handle.write(payload)
163
+ handle.flush()
164
+ os.fsync(handle.fileno())
165
+ os.replace(temporary, self.path)
166
+ finally:
167
+ temporary.unlink(missing_ok=True)
168
+ except OSError as exc:
169
+ raise AuthError(f"cannot write the token cache {self.path}: {exc}") from None
170
+
171
+
172
+ class KeyringStore:
173
+ """Values in the operating system's keychain, through the ``keyring`` package."""
174
+
175
+ def __init__(self, service: str = KEYCHAIN_SERVICE, *, backend: ModuleType | None = None):
176
+ if backend is None:
177
+ try:
178
+ # Optional (the keychain extra), so imported only when asked for.
179
+ import keyring as backend
180
+ except ImportError:
181
+ raise ConfigError(
182
+ 'token_cache = "keychain" needs the keyring package', hint=_KEYCHAIN_HINT
183
+ ) from None
184
+ self.service = service
185
+ self._backend = backend
186
+ errors = getattr(backend, "errors", None)
187
+ self._error: type[Exception] = getattr(errors, "KeyringError", Exception)
188
+
189
+ def load(self, key: str) -> str | None:
190
+ try:
191
+ return self._backend.get_password(self.service, key) or None
192
+ except self._error as exc:
193
+ raise self._failure("read", exc) from None
194
+
195
+ def save(self, key: str, value: str) -> None:
196
+ try:
197
+ self._backend.set_password(self.service, key, value)
198
+ except self._error as exc:
199
+ raise self._failure("write to", exc) from None
200
+
201
+ def delete(self, key: str) -> bool:
202
+ try:
203
+ if self._backend.get_password(self.service, key) is None:
204
+ return False
205
+ self._backend.delete_password(self.service, key)
206
+ except self._error as exc:
207
+ raise self._failure("write to", exc) from None
208
+ return True
209
+
210
+ def _failure(self, action: str, exc: Exception) -> AuthError:
211
+ return AuthError(
212
+ f"cannot {action} the keychain: {exc}",
213
+ hint=(
214
+ "on Linux this needs a Secret Service (GNOME Keyring or KWallet) running; "
215
+ 'without one, use token_cache = "file" or "memory"'
216
+ ),
217
+ )
218
+
219
+
220
+ def default_path(
221
+ *, encrypted: bool = False, environ: Mapping[str, str] = os.environ, platform: str = ""
222
+ ) -> Path:
223
+ """Where the file stores live: per user, never beside a config file in a repository.
224
+
225
+ The brand's ``TOKEN_CACHE`` variable names a file instead. Otherwise it is
226
+ ``%LOCALAPPDATA%\\<tool>`` on Windows and ``$XDG_STATE_HOME/<tool>`` (by default
227
+ ``~/.local/state/<tool>``) elsewhere.
228
+ """
229
+ name = "refresh-tokens.dpapi" if encrypted else "refresh-tokens.json"
230
+ override = environ.get(FILE_ENV)
231
+ if override:
232
+ path = Path(override).expanduser()
233
+ return path.with_suffix(".dpapi") if encrypted else path
234
+ if (platform or sys.platform) == "win32":
235
+ base = Path(environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
236
+ else:
237
+ base = Path(environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
238
+ return base / brand.CONFIG_DIR / name
239
+
240
+
241
+ def open_store(
242
+ kind: str, *, environ: Mapping[str, str] = os.environ, platform: str = ""
243
+ ) -> TokenStore:
244
+ """The store a profile's ``token_cache`` names."""
245
+ if kind == "memory":
246
+ return MemoryStore()
247
+ if kind == "file":
248
+ return FileStore(default_path(environ=environ, platform=platform))
249
+ if kind == "keychain":
250
+ if (platform or sys.platform) == "win32":
251
+ from libre_devops_helpers.core import dpapi # Windows only
252
+
253
+ return FileStore(
254
+ default_path(encrypted=True, environ=environ, platform=platform),
255
+ protect=dpapi.protect,
256
+ unprotect=dpapi.unprotect,
257
+ )
258
+ return KeyringStore()
259
+ raise ConfigError(f"token_cache must be one of {', '.join(TOKEN_CACHES)}, not {kind!r}")
260
+
261
+
262
+ def _exposed(path: Path) -> bool:
263
+ """True when accounts other than the owner may read ``path`` (POSIX permissions)."""
264
+ if os.name != "posix":
265
+ return False # Windows: the per-user profile folder's ACL keeps others out
266
+ return bool(path.stat().st_mode & 0o077)
@@ -0,0 +1,120 @@
1
+ """Small helpers shared across the subpackages: names, OData literals, timestamps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from datetime import UTC, datetime, timedelta
8
+
9
+ from libre_devops_helpers.core.errors import InputError
10
+
11
+ _GUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
12
+ # Some Microsoft APIs return seven fractional digits; datetime accepts six.
13
+ _LONG_FRACTION = re.compile(r"\.(\d{6})\d+")
14
+
15
+
16
+ def is_guid(value: str) -> bool:
17
+ """True when ``value`` is a GUID in the 8-4-4-4-12 form."""
18
+ return bool(_GUID.match(value.strip()))
19
+
20
+
21
+ def short_name(name: str) -> str:
22
+ """The host part of a name: ``web01.corp.example.com`` -> ``web01``."""
23
+ return name.strip().rstrip(".").split(".", 1)[0]
24
+
25
+
26
+ def candidate_names(name: str) -> list[str]:
27
+ """Names to try for one device, most specific first: the FQDN, then the short hostname.
28
+
29
+ Entra and Defender do not record Linux host names consistently, so a lookup
30
+ that misses on the FQDN retries on the short name.
31
+ """
32
+ full = name.strip().rstrip(".")
33
+ short = short_name(full)
34
+ return [full] if short.casefold() == full.casefold() else [full, short]
35
+
36
+
37
+ def split_names(values: Iterable[str]) -> list[str]:
38
+ """Split comma- or whitespace-separated names, dropping blanks and repeats.
39
+
40
+ ``["a,b", "c d", "A"]`` -> ``["a", "b", "c", "d"]``: a command can take
41
+ ``"X,Y,Z"`` as one argument, as several arguments, or as lines from a file.
42
+ Repeats are matched case-insensitively and the first spelling is kept.
43
+ """
44
+ seen: set[str] = set()
45
+ names: list[str] = []
46
+ for value in values:
47
+ for name in re.split(r"[,\s]+", value):
48
+ if name and name.casefold() not in seen:
49
+ seen.add(name.casefold())
50
+ names.append(name)
51
+ return names
52
+
53
+
54
+ def odata_string(value: str) -> str:
55
+ """Quote ``value`` as an OData string literal, doubling embedded single quotes."""
56
+ return "'" + value.replace("'", "''") + "'"
57
+
58
+
59
+ def odata_datetime(value: datetime) -> str:
60
+ """A UTC OData datetime literal, ``2026-09-24T10:11:12Z``, which is never quoted."""
61
+ return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
62
+
63
+
64
+ def parse_datetime(value: object) -> datetime | None:
65
+ """Parse an ISO 8601 timestamp from an API into an aware UTC datetime.
66
+
67
+ Returns None for empty or unparseable values rather than raising, because one
68
+ odd record should not fail a whole listing.
69
+ """
70
+ if not isinstance(value, str) or not value.strip():
71
+ return None
72
+ text = _LONG_FRACTION.sub(r".\1", value.strip())
73
+ if text[-1] in "Zz":
74
+ text = text[:-1] + "+00:00"
75
+ try:
76
+ parsed = datetime.fromisoformat(text)
77
+ except ValueError:
78
+ return None
79
+ if parsed.tzinfo is None:
80
+ parsed = parsed.replace(tzinfo=UTC)
81
+ return parsed.astimezone(UTC)
82
+
83
+
84
+ _DURATION = re.compile(r"(\d+)\s*([dhms])", re.IGNORECASE)
85
+ _UNIT_SECONDS = {"d": 86400, "h": 3600, "m": 60, "s": 1}
86
+
87
+
88
+ def parse_duration(text: str) -> timedelta:
89
+ """Read ``90``, ``90s``, ``15m``, ``2h``, ``1h30m`` or ``7d`` as a duration.
90
+
91
+ A bare number is seconds. Raises InputError for anything else, including zero.
92
+ """
93
+ value = text.strip().lower().replace(" ", "")
94
+ if value.isdigit():
95
+ seconds = int(value)
96
+ else:
97
+ parts = _DURATION.findall(value)
98
+ if not parts or "".join(number + unit for number, unit in parts) != value:
99
+ raise InputError(
100
+ f"cannot read the duration {text!r}", hint="use e.g. 90, 90s, 15m, 2h, 1h30m or 7d"
101
+ )
102
+ seconds = sum(int(number) * _UNIT_SECONDS[unit] for number, unit in parts)
103
+ if seconds <= 0:
104
+ raise InputError(f"the duration {text!r} must be more than zero")
105
+ return timedelta(seconds=seconds)
106
+
107
+
108
+ def format_duration(delta: timedelta) -> str:
109
+ """Render a duration compactly: ``45s``, ``12m 05s``, ``3h 07m``, ``2d 04h``."""
110
+ seconds = int(abs(delta.total_seconds()))
111
+ days, seconds = divmod(seconds, 86400)
112
+ hours, seconds = divmod(seconds, 3600)
113
+ minutes, seconds = divmod(seconds, 60)
114
+ if days:
115
+ return f"{days}d {hours:02d}h"
116
+ if hours:
117
+ return f"{hours}h {minutes:02d}m"
118
+ if minutes:
119
+ return f"{minutes}m {seconds:02d}s"
120
+ return f"{seconds}s"
@@ -0,0 +1,142 @@
1
+ """JSON-shaped data as YAML, with the standard library alone.
2
+
3
+ Only what JSON can hold is written (objects, arrays, strings, numbers, booleans, null),
4
+ in block style. A string is left plain only when no YAML reader could take it for
5
+ anything else: ``yes``, ``no``, ``null``, ``1.0``, ``2026-09-24``, ``@odata.context`` and
6
+ ``a: b`` are all quoted, as JSON strings, which YAML reads the same way. A multi-line
7
+ string becomes a ``|`` block. There is no parser: reading YAML is not needed here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+
17
+ # kind ("key", "string", "number", "bool", "null", "punct") and text -> text to write.
18
+ Paint = Callable[[str, str], str]
19
+
20
+ # Plain only when it starts with a letter or underscore and holds nothing YAML reads
21
+ # specially; single spaces between words are fine.
22
+ _PLAIN = re.compile(r"[A-Za-z_][\w.\-/+@]*(?: [\w.\-/+@]+)*")
23
+ # Words YAML 1.1 or 1.2 readers take for booleans or null, in any case.
24
+ _WORDS = frozenset(
25
+ {"true", "false", "yes", "no", "on", "off", "y", "n", "null", "none", "nan", "inf"}
26
+ )
27
+ # Characters YAML will not take as they are inside a quoted string: C1 controls (U+0085
28
+ # is a line break to YAML), the Unicode line and paragraph separators, and non-characters.
29
+ _UNPRINTABLE = re.compile(r"[\x7f-\x9f\u2028\u2029\ufeff\ufffe\uffff\ud800-\udfff]")
30
+ # Characters that make a string unsafe for a literal block: controls other than tab.
31
+ _CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f\u0085\u2028\u2029\ufeff]")
32
+
33
+
34
+ def dumps(data: Any, *, indent: int = 2, paint: Paint | None = None) -> str:
35
+ """``data`` (as ``json.loads`` gives it) as a YAML document, ending in a newline.
36
+
37
+ The newline matters: a ``|`` block last in the document keeps its own final line
38
+ break only when the document ends with one.
39
+ """
40
+ painter = paint or (lambda _kind, text: text)
41
+ return "\n".join(_node(data, 0, max(indent, 1), painter)) + "\n"
42
+
43
+
44
+ def _node(value: Any, level: int, indent: int, paint: Paint) -> list[str]:
45
+ """The lines for ``value`` standing alone, at ``level`` spaces."""
46
+ pad = " " * level
47
+ if isinstance(value, dict) and value:
48
+ lines = []
49
+ for key, item in value.items():
50
+ head = f"{pad}{paint('key', _string(str(key)))}{paint('punct', ':')}"
51
+ lines.extend(_entry(head, item, level, indent, paint))
52
+ return lines
53
+ if isinstance(value, list) and value:
54
+ lines = []
55
+ for item in value:
56
+ lines.extend(_item(item, level, indent, paint))
57
+ return lines
58
+ first, *rest = _scalar(value, level, indent, paint)
59
+ return [pad + first, *_indented(rest, level + indent)]
60
+
61
+
62
+ def _entry(head: str, value: Any, level: int, indent: int, paint: Paint) -> list[str]:
63
+ """``key:`` followed by its value: on the same line, or nested below it."""
64
+ if _nests(value):
65
+ return [head, *_node(value, level + indent, indent, paint)]
66
+ first, *rest = _scalar(value, level + indent, indent, paint)
67
+ return [f"{head} {first}", *_indented(rest, level + indent)]
68
+
69
+
70
+ def _item(value: Any, level: int, indent: int, paint: Paint) -> list[str]:
71
+ """``- `` followed by an array item; a nested object or array starts on the same line."""
72
+ dash = " " * level + paint("punct", "-")
73
+ if _nests(value):
74
+ inner = _node(value, level + 2, indent, paint)
75
+ return [dash + " " + inner[0].lstrip(), *inner[1:]]
76
+ first, *rest = _scalar(value, level + 2, indent, paint)
77
+ return [f"{dash} {first}", *_indented(rest, level + 2)]
78
+
79
+
80
+ def _indented(lines: list[str], level: int) -> list[str]:
81
+ """A block's lines at ``level``; blank ones stay empty, with no trailing spaces."""
82
+ return [" " * level + line if line else "" for line in lines]
83
+
84
+
85
+ def _nests(value: Any) -> bool:
86
+ return isinstance(value, dict | list) and bool(value)
87
+
88
+
89
+ def _scalar(value: Any, level: int, indent: int, paint: Paint) -> list[str]:
90
+ """A scalar's lines: one, or a ``|`` header and a block, each without the indent."""
91
+ if value is None:
92
+ return [paint("null", "null")]
93
+ if isinstance(value, bool):
94
+ return [paint("bool", "true" if value else "false")]
95
+ if isinstance(value, float):
96
+ return [paint("number", _float(value))]
97
+ if isinstance(value, int):
98
+ return [paint("number", json.dumps(value))]
99
+ if isinstance(value, dict):
100
+ return [paint("punct", "{}")]
101
+ if isinstance(value, list):
102
+ return [paint("punct", "[]")]
103
+ text = str(value)
104
+ block = _block(text)
105
+ if block is not None:
106
+ return [paint("punct", block[0]), *(paint("string", line) for line in block[1:])]
107
+ return [paint("string", _string(text))]
108
+
109
+
110
+ def _float(value: float) -> str:
111
+ """A float every YAML reader takes as one: YAML 1.1 wants a point, so 1e-07 is 1.0e-07."""
112
+ text = json.dumps(value)
113
+ mantissa, marker, exponent = text.partition("e")
114
+ if marker and "." not in mantissa:
115
+ return f"{mantissa}.0e{exponent}"
116
+ return text
117
+
118
+
119
+ def _string(text: str) -> str:
120
+ """``text`` plain when that reads back as the same string, else JSON-quoted."""
121
+ # fullmatch, not match with $: in Python, $ also matches before a final newline.
122
+ if _PLAIN.fullmatch(text) and text.casefold() not in _WORDS:
123
+ return text
124
+ quoted = json.dumps(text, ensure_ascii=False)
125
+ return _UNPRINTABLE.sub(lambda found: f"\\u{ord(found.group()):04x}", quoted)
126
+
127
+
128
+ def _block(text: str) -> list[str] | None:
129
+ """A multi-line string as a literal block (``|``), or None when it cannot be one.
130
+
131
+ The lines come back unindented; the caller indents them. A block must not start with
132
+ a space (the reader would take that as the indent), and holds no control characters.
133
+ """
134
+ if "\n" not in text or _CONTROL.search(text) or text.startswith((" ", "\n")):
135
+ return None
136
+ body = text.rstrip("\n")
137
+ trailing = len(text) - len(body)
138
+ chomp = {0: "|-", 1: "|"}.get(trailing, "|+")
139
+ lines = body.split("\n") + [""] * (trailing - 1 if trailing > 1 else 0)
140
+ if any(line.endswith((" ", "\t")) for line in lines):
141
+ return None # trailing spaces would be invisible, so quote it instead
142
+ return [chomp, *lines]
@@ -0,0 +1,94 @@
1
+ """Microsoft: Azure, Entra ID, Defender, Intune, Key Vault and Log Analytics.
2
+
3
+ This package is the Microsoft vendor layer. Its top-level modules are shared by every
4
+ Microsoft feature: clouds and their endpoints, the APIs as tokens see them, token checks,
5
+ credentials, the Azure CLI runner and the ``[microsoft]`` profiles. The feature modules
6
+ (``azcli``, ``entra``, ``xdr``, ``intune``, ``azure``, ``keyvault``, ``loganalytics``)
7
+ depend on those and on ``core`` only; ``devices`` combines ``entra``, ``xdr`` and
8
+ ``intune``.
9
+
10
+ Public API::
11
+
12
+ from libre_devops_helpers.core import CachingTokenProvider
13
+ from libre_devops_helpers.microsoft import credential_for, load_config
14
+ from libre_devops_helpers.microsoft.entra import EntraClient
15
+
16
+ profile = load_config().get("prod-tenant")
17
+ tokens = CachingTokenProvider(credential_for(profile))
18
+ """
19
+
20
+ from libre_devops_helpers.microsoft.auth import (
21
+ AzureCliCredential,
22
+ ClientSecretCredential,
23
+ ManagedIdentityCredential,
24
+ WorkloadIdentityCredential,
25
+ credential_for,
26
+ )
27
+ from libre_devops_helpers.microsoft.clouds import CHINA, CLOUDS, PUBLIC, USGOV, Cloud, get_cloud
28
+ from libre_devops_helpers.microsoft.config import (
29
+ AUTH_METHODS,
30
+ CONFIG_TEMPLATE,
31
+ PLACEHOLDER_ID,
32
+ MicrosoftConfig,
33
+ Profile,
34
+ load_config,
35
+ parse_config,
36
+ )
37
+ from libre_devops_helpers.microsoft.process import AzCliError, AzureCliRunner
38
+ from libre_devops_helpers.microsoft.resources import (
39
+ ARM,
40
+ GRAPH,
41
+ KEY_VAULT,
42
+ LOG_ANALYTICS,
43
+ MDE,
44
+ RESOURCES,
45
+ Requirement,
46
+ Resource,
47
+ resolve_resource,
48
+ resources_for,
49
+ )
50
+ from libre_devops_helpers.microsoft.tokens import (
51
+ Check,
52
+ DecodedToken,
53
+ decode_token,
54
+ passed,
55
+ validate_token,
56
+ )
57
+
58
+ __all__ = [
59
+ "ARM",
60
+ "AUTH_METHODS",
61
+ "CHINA",
62
+ "CLOUDS",
63
+ "CONFIG_TEMPLATE",
64
+ "GRAPH",
65
+ "KEY_VAULT",
66
+ "LOG_ANALYTICS",
67
+ "MDE",
68
+ "PLACEHOLDER_ID",
69
+ "PUBLIC",
70
+ "RESOURCES",
71
+ "USGOV",
72
+ "AzCliError",
73
+ "AzureCliCredential",
74
+ "AzureCliRunner",
75
+ "Check",
76
+ "ClientSecretCredential",
77
+ "Cloud",
78
+ "DecodedToken",
79
+ "ManagedIdentityCredential",
80
+ "MicrosoftConfig",
81
+ "Profile",
82
+ "Requirement",
83
+ "Resource",
84
+ "WorkloadIdentityCredential",
85
+ "credential_for",
86
+ "decode_token",
87
+ "get_cloud",
88
+ "load_config",
89
+ "parse_config",
90
+ "passed",
91
+ "resolve_resource",
92
+ "resources_for",
93
+ "validate_token",
94
+ ]
@@ -0,0 +1,43 @@
1
+ """Microsoft credentials: the Azure CLI, app registrations and managed identities.
2
+
3
+ Every credential satisfies ``core.auth.TokenProvider``; the service modules take any
4
+ provider, and ``credential_for`` builds the one a profile's ``auth`` setting names.
5
+
6
+ Public API::
7
+
8
+ from libre_devops_helpers.core.auth import CachingTokenProvider
9
+ from libre_devops_helpers.microsoft.auth import credential_for
10
+
11
+ tokens = CachingTokenProvider(credential_for(profile))
12
+ """
13
+
14
+ from libre_devops_helpers.microsoft.auth.azure_cli import AzureCliCredential, Reauthenticate
15
+ from libre_devops_helpers.microsoft.auth.delegated import (
16
+ DeviceCodeCredential,
17
+ InteractiveCredential,
18
+ LoopbackReceiver,
19
+ )
20
+ from libre_devops_helpers.microsoft.auth.entra import (
21
+ ClientSecretCredential,
22
+ WorkloadIdentityCredential,
23
+ federated_token_file,
24
+ github_actions_assertion,
25
+ )
26
+ from libre_devops_helpers.microsoft.auth.factory import credential_for
27
+ from libre_devops_helpers.microsoft.auth.lapse import lapse_reason
28
+ from libre_devops_helpers.microsoft.auth.managed_identity import ManagedIdentityCredential
29
+
30
+ __all__ = [
31
+ "AzureCliCredential",
32
+ "ClientSecretCredential",
33
+ "DeviceCodeCredential",
34
+ "InteractiveCredential",
35
+ "LoopbackReceiver",
36
+ "ManagedIdentityCredential",
37
+ "Reauthenticate",
38
+ "WorkloadIdentityCredential",
39
+ "credential_for",
40
+ "federated_token_file",
41
+ "github_actions_assertion",
42
+ "lapse_reason",
43
+ ]