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,246 @@
1
+ """Logging setup for the CLI: human text, flat JSON, or the OpenTelemetry wire format.
2
+
3
+ Library modules only call ``logging.getLogger(__name__)``; they never configure
4
+ handlers. The CLI calls ``configure_logging`` once. Logs go to stderr so they never mix
5
+ with data written to stdout.
6
+
7
+ ``otlp`` writes the OpenTelemetry file format: JSON Lines, each line a complete OTLP/JSON
8
+ ``LogsData`` (the same object an ``ExportLogsServiceRequest`` is) holding one record, as
9
+ ``Write-LdoLog`` does in LibreDevOpsHelpers. An OpenTelemetry Collector reads it with the
10
+ ``otlp_json_file`` receiver, or, from a container's logs, with the ``file_log`` receiver
11
+ and the ``otlp_json`` connector, with no parsing rules either way. The two tools read the
12
+ same variables (``LDO_LOG_FORMAT``, ``LDO_LOG_LEVEL``, ``LDO_SERVICE_NAME`` and the trace
13
+ context), so one pipeline setting controls both. One difference is deliberate: with
14
+ nothing set, this CLI writes readable text.
15
+
16
+ https://opentelemetry.io/docs/specs/otel/protocol/file-exporter/
17
+ https://opentelemetry.io/docs/specs/otlp/#json-protobuf-encoding
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import sys
26
+ import traceback
27
+ from collections.abc import Mapping
28
+ from typing import Any
29
+ from urllib.parse import unquote
30
+
31
+ from libre_devops_helpers import __version__
32
+ from libre_devops_helpers.core import brand
33
+
34
+ LOG_FORMATS = ("text", "json", "otlp")
35
+
36
+ # LibreDevOpsHelpers' names for formats and levels, so its settings mean the same here.
37
+ _FORMAT_ALIASES = {"jsonindented": "json", "otlpindented": "otlp"}
38
+ _LEVELS = {
39
+ "trace": logging.DEBUG,
40
+ "debug": logging.DEBUG,
41
+ "info": logging.INFO,
42
+ "success": logging.INFO,
43
+ "warn": logging.WARNING,
44
+ "warning": logging.WARNING,
45
+ "error": logging.ERROR,
46
+ "fatal": logging.CRITICAL,
47
+ }
48
+ SERVICE_NAME = brand.COMMAND
49
+
50
+ # https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
51
+ _SEVERITY = {
52
+ logging.DEBUG: 5,
53
+ logging.INFO: 9,
54
+ logging.WARNING: 13,
55
+ logging.ERROR: 17,
56
+ logging.CRITICAL: 21,
57
+ }
58
+
59
+
60
+ class JsonFormatter(logging.Formatter):
61
+ """One flat JSON object per record."""
62
+
63
+ def format(self, record: logging.LogRecord) -> str:
64
+ data: dict[str, Any] = {
65
+ "time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S") + f".{int(record.msecs):03d}",
66
+ "level": record.levelname.lower(),
67
+ "logger": record.name,
68
+ "message": record.getMessage(),
69
+ }
70
+ if getattr(record, "hint", None):
71
+ data["hint"] = record.hint
72
+ if record.exc_info:
73
+ data["exception"] = self.formatException(record.exc_info)
74
+ return json.dumps(data)
75
+
76
+
77
+ class OtlpFormatter(logging.Formatter):
78
+ """One OTLP/JSON ``LogsData`` per record, on one line.
79
+
80
+ OTLP's JSON rules differ from plain proto3 JSON: 64-bit integers such as timestamps are
81
+ strings, ``severityNumber`` is the integer, ``traceId`` and ``spanId`` are lowercase
82
+ hex, and attribute values are typed. Attribute names follow the semantic conventions.
83
+
84
+ The resource describes what is logging: ``service.name`` (``LDO_SERVICE_NAME``, else
85
+ ``OTEL_SERVICE_NAME``, else the command), ``service.version`` and, with
86
+ ``LDO_DEPLOYMENT_ENVIRONMENT``, ``deployment.environment.name``; anything else comes
87
+ from ``OTEL_RESOURCE_ATTRIBUTES``. ``LDO_TRACE_ID``, ``LDO_SPAN_ID`` and
88
+ ``LDO_CORRELATION_ID`` put every record in a trace, so a CI run's logs join up; an id
89
+ that is not valid hex of the right width is left out rather than sent and rejected.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ service_name: str | None = None,
95
+ service_version: str | None = None,
96
+ *,
97
+ environ: Mapping[str, str] | None = None,
98
+ ):
99
+ super().__init__()
100
+ env = os.environ if environ is None else environ
101
+ self._resource = {"attributes": _resource_attributes(env, service_name, service_version)}
102
+ correlation = (env.get(brand.env_var("CORRELATION_ID")) or "").strip()
103
+ self._correlation_id = correlation
104
+ self._trace_id = otlp_hex_id(env.get(brand.env_var("TRACE_ID")), 32) or otlp_hex_id(
105
+ correlation, 32
106
+ )
107
+ self._span_id = otlp_hex_id(env.get(brand.env_var("SPAN_ID")), 16)
108
+
109
+ def format(self, record: logging.LogRecord) -> str:
110
+ nanos = str(int(record.created * 1_000_000_000))
111
+ attributes = [
112
+ _attribute("code.function.name", f"{record.name}.{record.funcName}"),
113
+ _attribute("code.line.number", record.lineno),
114
+ ]
115
+ if self._correlation_id:
116
+ attributes.append(_attribute("correlation_id", self._correlation_id))
117
+ if getattr(record, "hint", None):
118
+ attributes.append(_attribute("hint", str(record.hint)))
119
+ if record.exc_info and record.exc_info[0] is not None:
120
+ error_type, error, trace = record.exc_info
121
+ attributes += [
122
+ _attribute("exception.type", error_type.__name__),
123
+ _attribute("exception.message", str(error)),
124
+ _attribute(
125
+ "exception.stacktrace",
126
+ "".join(traceback.format_exception(error_type, error, trace)),
127
+ ),
128
+ ]
129
+ log_record: dict[str, Any] = {
130
+ "timeUnixNano": nanos,
131
+ "observedTimeUnixNano": nanos,
132
+ "severityNumber": _SEVERITY.get(record.levelno, 9),
133
+ "severityText": record.levelname,
134
+ "body": {"stringValue": record.getMessage()},
135
+ "attributes": attributes,
136
+ }
137
+ if self._trace_id:
138
+ log_record["traceId"] = self._trace_id
139
+ if self._span_id:
140
+ log_record["spanId"] = self._span_id
141
+ request = {
142
+ "resourceLogs": [
143
+ {
144
+ "resource": self._resource,
145
+ "scopeLogs": [{"scope": {"name": record.name}, "logRecords": [log_record]}],
146
+ }
147
+ ]
148
+ }
149
+ return json.dumps(request, separators=(",", ":"))
150
+
151
+
152
+ def otlp_hex_id(value: str | None, length: int) -> str:
153
+ """A trace (32) or span (16) id in OTLP's lowercase hex, or "" when ``value`` is not one.
154
+
155
+ Dashes are dropped, so a GUID, which is 16 bytes, becomes a trace id: a CI run's id
156
+ can join its logs up without reformatting. An all-zero id is invalid in W3C trace
157
+ context, so it is refused too. Refusing means leaving the field out: a collector
158
+ rejects a whole payload with a bad id, and logging must never lose the record.
159
+ """
160
+ candidate = (value or "").strip().replace("-", "").lower()
161
+ if len(candidate) != length or candidate.strip("0123456789abcdef") or not candidate.strip("0"):
162
+ return ""
163
+ return candidate
164
+
165
+
166
+ def _resource_attributes(
167
+ environ: Mapping[str, str], service_name: str | None, service_version: str | None
168
+ ) -> list[dict[str, Any]]:
169
+ extra = _parse_resource_attributes(environ.get("OTEL_RESOURCE_ATTRIBUTES", ""))
170
+ named = extra.pop("service.name", None)
171
+ versioned = extra.pop("service.version", None)
172
+ placed = extra.pop("deployment.environment.name", None)
173
+ name = (
174
+ service_name
175
+ or environ.get(brand.env_var("SERVICE_NAME"))
176
+ or environ.get("OTEL_SERVICE_NAME")
177
+ or named
178
+ or SERVICE_NAME
179
+ )
180
+ version = service_version or environ.get(brand.env_var("SERVICE_VERSION")) or versioned
181
+ environment = environ.get(brand.env_var("DEPLOYMENT_ENVIRONMENT")) or placed
182
+ attributes = [
183
+ _attribute("service.name", name),
184
+ _attribute("service.version", version or __version__),
185
+ ]
186
+ if environment:
187
+ attributes.append(_attribute("deployment.environment.name", environment))
188
+ attributes.extend(_attribute(key, value) for key, value in extra.items())
189
+ return attributes
190
+
191
+
192
+ def _parse_resource_attributes(text: str) -> dict[str, str]:
193
+ """``OTEL_RESOURCE_ATTRIBUTES``: ``key=value`` pairs, comma separated, values
194
+ percent-encoded. A malformed pair is skipped, as the specification allows."""
195
+ found: dict[str, str] = {}
196
+ for pair in text.split(","):
197
+ key, sep, value = pair.partition("=")
198
+ if sep and key.strip():
199
+ found[unquote(key.strip())] = unquote(value.strip())
200
+ return found
201
+
202
+
203
+ def _attribute(key: str, value: str | int) -> dict[str, Any]:
204
+ if isinstance(value, int):
205
+ return {"key": key, "value": {"intValue": str(value)}}
206
+ return {"key": key, "value": {"stringValue": value}}
207
+
208
+
209
+ def normalise_format(fmt: str | None) -> str:
210
+ """``text``, ``json`` or ``otlp`` for any spelling LibreDevOpsHelpers accepts."""
211
+ value = (fmt or "text").strip().lower()
212
+ value = _FORMAT_ALIASES.get(value, value)
213
+ return value if value in LOG_FORMATS else "text"
214
+
215
+
216
+ def resolve_level(verbosity: int = 0, level_name: str | None = None) -> int:
217
+ """-v (info) and -vv (debug) win; otherwise ``level_name`` if valid; otherwise warnings."""
218
+ if verbosity == 1:
219
+ return logging.INFO
220
+ if verbosity >= 2:
221
+ return logging.DEBUG
222
+ return _LEVELS.get((level_name or "").strip().lower(), logging.WARNING)
223
+
224
+
225
+ def configure_logging(
226
+ verbosity: int = 0, fmt: str | None = "text", level_name: str | None = None
227
+ ) -> None:
228
+ """Send logs to stderr at the resolved level, in the chosen format.
229
+
230
+ An unknown format or level falls back to the default, because a logging preference
231
+ must never stop the command it is logging about.
232
+ """
233
+ level = resolve_level(verbosity, level_name)
234
+ fmt = normalise_format(fmt)
235
+ handler = logging.StreamHandler(sys.stderr)
236
+ if fmt == "json":
237
+ handler.setFormatter(JsonFormatter())
238
+ elif fmt == "otlp":
239
+ handler.setFormatter(OtlpFormatter())
240
+ else:
241
+ handler.setFormatter(
242
+ logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s", "%H:%M:%S")
243
+ )
244
+ logging.basicConfig(level=level, handlers=[handler], force=True)
245
+ # urllib3 logs each connection at DEBUG; that is noise unless asked for twice over.
246
+ logging.getLogger("urllib3").setLevel(logging.DEBUG if verbosity >= 3 else logging.WARNING)
@@ -0,0 +1,88 @@
1
+ """Run a check repeatedly until it is complete or a limit is reached.
2
+
3
+ The clock and the sleep are injected, so a poll of hours runs in microseconds in tests.
4
+ The poller never sleeps past the timeout: the last wait is cut short so one final pass
5
+ runs at the deadline.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from typing import Generic, Literal, TypeVar
14
+
15
+ from libre_devops_helpers.core.errors import InputError
16
+
17
+ T = TypeVar("T")
18
+
19
+ Reason = Literal["complete", "timeout", "max-passes"]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PollLimits:
24
+ """How often to check, and when to give up. ``None`` means no limit of that kind."""
25
+
26
+ interval: float
27
+ timeout: float | None = None
28
+ max_passes: int | None = None
29
+
30
+ def __post_init__(self) -> None:
31
+ if self.interval <= 0:
32
+ raise InputError("the poll interval must be more than zero seconds")
33
+ if self.timeout is not None and self.timeout <= 0:
34
+ raise InputError("the poll timeout must be more than zero seconds")
35
+ if self.max_passes is not None and self.max_passes < 1:
36
+ raise InputError("the poll needs at least one pass")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class PollOutcome(Generic[T]):
41
+ """What the poll ended with: the last pass's result and why it stopped."""
42
+
43
+ result: T
44
+ passes: int
45
+ elapsed: float
46
+ reason: Reason
47
+
48
+ @property
49
+ def complete(self) -> bool:
50
+ return self.reason == "complete"
51
+
52
+
53
+ def poll(
54
+ run_pass: Callable[[int], T],
55
+ is_complete: Callable[[T], bool],
56
+ limits: PollLimits,
57
+ *,
58
+ clock: Callable[[], float] = time.monotonic,
59
+ sleep: Callable[[float], None] = time.sleep,
60
+ on_pass: Callable[[int, T], None] | None = None,
61
+ on_wait: Callable[[float], None] | None = None,
62
+ ) -> PollOutcome[T]:
63
+ """Call ``run_pass(n)`` for n = 1, 2, ... until ``is_complete`` or a limit stops it.
64
+
65
+ ``on_pass`` sees each result; ``on_wait`` is told how long the next wait is.
66
+ Exceptions from ``run_pass`` propagate: the caller decides which ones a pass may
67
+ absorb (a throttled request) and which end the poll (a rejected token).
68
+ """
69
+ started = clock()
70
+ passes = 0
71
+ while True:
72
+ passes += 1
73
+ result = run_pass(passes)
74
+ if on_pass is not None:
75
+ on_pass(passes, result)
76
+ elapsed = clock() - started
77
+ if is_complete(result):
78
+ return PollOutcome(result, passes, elapsed, "complete")
79
+ if limits.max_passes is not None and passes >= limits.max_passes:
80
+ return PollOutcome(result, passes, elapsed, "max-passes")
81
+ if limits.timeout is not None and elapsed >= limits.timeout:
82
+ return PollOutcome(result, passes, elapsed, "timeout")
83
+ wait = limits.interval
84
+ if limits.timeout is not None:
85
+ wait = min(wait, limits.timeout - elapsed)
86
+ if on_wait is not None:
87
+ on_wait(wait)
88
+ sleep(wait)
@@ -0,0 +1,106 @@
1
+ """Run an external command line tool: checked exit codes, a timeout, readable errors.
2
+
3
+ Vendor layers build on ``CommandRunner`` for their own tools (the Azure CLI today),
4
+ supplying how that tool's errors read and what to suggest. Output is returned to the
5
+ caller but never logged, because it can hold a token.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import itertools
11
+ import json
12
+ import logging
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ from collections.abc import Callable, Mapping
17
+ from typing import Any
18
+
19
+ from libre_devops_helpers.core.errors import CommandError
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ Runner = Callable[..., "subprocess.CompletedProcess[str]"]
24
+
25
+
26
+ class CommandRunner:
27
+ """Runs one executable, found on ``PATH`` by ``name`` unless ``executable`` is given.
28
+
29
+ ``runner`` defaults to ``subprocess.run``; tests pass a fake so nothing real runs.
30
+ Subclasses override ``clean_stderr`` and ``hint_for`` for their tool, and
31
+ ``error_type`` for a more specific error.
32
+ """
33
+
34
+ error_type: type[CommandError] = CommandError
35
+ # Non-interactive commands are bounded. Interactive ones wait on a person, so are not.
36
+ timeout_seconds = 120
37
+
38
+ def __init__(
39
+ self,
40
+ name: str,
41
+ executable: str | None = None,
42
+ *,
43
+ install_hint: str | None = None,
44
+ runner: Runner = subprocess.run,
45
+ ) -> None:
46
+ self.name = name
47
+ self._executable = executable
48
+ self._install_hint = install_hint
49
+ self._runner = runner
50
+
51
+ @property
52
+ def executable(self) -> str:
53
+ """Path to the tool, resolved on first use so importing never requires it."""
54
+ if self._executable is None:
55
+ found = shutil.which(self.name)
56
+ if found is None:
57
+ raise self.error_type(f"'{self.name}' is not on PATH", hint=self._install_hint)
58
+ self._executable = found
59
+ return self._executable
60
+
61
+ def run(
62
+ self, *args: str, interactive: bool = False, env: Mapping[str, str] | None = None
63
+ ) -> str:
64
+ """Run the tool with ``args`` and return stdout. Raises on a non-zero exit.
65
+
66
+ ``interactive`` connects the terminal instead of capturing output.
67
+ """
68
+ label = " ".join(
69
+ [self.name, *itertools.takewhile(lambda arg: not arg.startswith("-"), args)]
70
+ )
71
+ log.debug("running %s", " ".join([self.name, *args]))
72
+ try:
73
+ result = self._runner(
74
+ [self.executable, *args],
75
+ capture_output=not interactive,
76
+ text=True,
77
+ env={**os.environ, **env} if env else None,
78
+ timeout=None if interactive else self.timeout_seconds,
79
+ check=False,
80
+ )
81
+ except subprocess.TimeoutExpired:
82
+ raise self.error_type(f"{label} timed out after {self.timeout_seconds}s") from None
83
+ except OSError as exc:
84
+ raise self.error_type(f"cannot run {label}: {exc}") from None
85
+ if result.returncode != 0:
86
+ detail = self.clean_stderr(result.stderr) or f"exit code {result.returncode}"
87
+ raise self.error_type(f"{label} failed: {detail}", hint=self.hint_for(detail))
88
+ return result.stdout or ""
89
+
90
+ def run_json(self, *args: str) -> Any:
91
+ """Run the tool and parse its stdout as JSON (None for no output)."""
92
+ out = self.run(*args).strip()
93
+ if not out:
94
+ return None
95
+ try:
96
+ return json.loads(out)
97
+ except json.JSONDecodeError:
98
+ raise self.error_type(f"{self.name} {' '.join(args[:2])} did not return JSON") from None
99
+
100
+ def clean_stderr(self, stderr: str | None) -> str:
101
+ """The error message from stderr, on one line."""
102
+ return " ".join(line.strip() for line in (stderr or "").splitlines() if line.strip())[:1000]
103
+
104
+ def hint_for(self, detail: str) -> str | None:
105
+ """A next step for a failed command, when the message points to one."""
106
+ return None