driverclient 0.2.0__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.
@@ -0,0 +1,304 @@
1
+ """
2
+ client/http.py — HTTP helpers for Driver Server client.
3
+ Stdlib only — no external dependencies (works without venv on target machines).
4
+ Supports streaming downloads, retry with exponential backoff, SHA-256 verification,
5
+ and multipart driver package uploads.
6
+ """
7
+ import hashlib
8
+ import io
9
+ import json
10
+ import tarfile
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ import uuid
15
+ from pathlib import Path
16
+
17
+ from driverclient.config import get
18
+
19
+
20
+ # ── Sentinel for rate-limit responses ────────────────────────────────────────
21
+
22
+ class RateLimitedError(Exception):
23
+ """HTTP 429 received; retry_after is the suggested wait in seconds."""
24
+ def __init__(self, retry_after: float = 5.0):
25
+ self.retry_after = retry_after
26
+ super().__init__(f"Rate limited — retry after {retry_after:.0f}s")
27
+
28
+
29
+ class BadRequestError(Exception):
30
+ """4xx response from local_repo — permanent error, do not retry."""
31
+ def __init__(self, status_code: int, message: str = ""):
32
+ self.status_code = status_code
33
+ super().__init__(f"HTTP {status_code}: {message}")
34
+
35
+
36
+ def _headers() -> dict:
37
+ cfg = get()
38
+ h = {"Content-Type": "application/json"}
39
+ key = cfg.get("node_key", "")
40
+ if key:
41
+ h["X-Node-Key"] = key
42
+ return h
43
+
44
+
45
+ def _retry(fn, max_retries: int = 3):
46
+ """
47
+ Call fn(); on failure sleep and retry up to max_retries times.
48
+
49
+ Retry schedule:
50
+ - HTTP 429 (RateLimitedError): sleep the server-suggested Retry-After
51
+ (default 5 s) then retry. Uses a higher ceiling (10 attempts) so a
52
+ burst of drivers can all make it through without losing any.
53
+ - Other errors: exponential backoff 2^attempt seconds, up to max_retries.
54
+ """
55
+ rate_limit_retries = 0
56
+ MAX_RATE_LIMIT_RETRIES = 10
57
+ attempt = 0
58
+
59
+ while True:
60
+ try:
61
+ return fn()
62
+ except BadRequestError:
63
+ raise # never retry — local_repo itself rejected it
64
+ except RateLimitedError as e:
65
+ rate_limit_retries += 1
66
+ if rate_limit_retries > MAX_RATE_LIMIT_RETRIES:
67
+ raise
68
+ wait = e.retry_after
69
+ print(f" [rate-limit] server busy — waiting {wait:.0f}s then retrying "
70
+ f"(attempt {rate_limit_retries}/{MAX_RATE_LIMIT_RETRIES})…")
71
+ time.sleep(wait)
72
+ except Exception:
73
+ attempt += 1
74
+ if attempt >= max_retries:
75
+ raise
76
+ wait = 2 ** (attempt - 1)
77
+ time.sleep(wait)
78
+
79
+
80
+ def http_get(url: str, timeout: int | None = None) -> dict:
81
+ cfg = get()
82
+ t = timeout or cfg["timeout_short"]
83
+ req = urllib.request.Request(url, headers=_headers())
84
+ with urllib.request.urlopen(req, timeout=t) as resp:
85
+ return json.loads(resp.read())
86
+
87
+
88
+ def http_post(url: str, data: dict, timeout: int | None = None) -> dict:
89
+ cfg = get()
90
+ t = timeout or cfg["timeout_medium"]
91
+ body = json.dumps(data).encode()
92
+ req = urllib.request.Request(url, data=body, headers=_headers(), method="POST")
93
+ with urllib.request.urlopen(req, timeout=t) as resp:
94
+ return json.loads(resp.read())
95
+
96
+
97
+ def http_post_retry(url: str, data: dict, timeout: int | None = None,
98
+ max_retries: int = 3) -> dict:
99
+ return _retry(lambda: http_post(url, data, timeout), max_retries)
100
+
101
+
102
+ def http_upload_package(
103
+ url: str,
104
+ node_key: str,
105
+ metadata: dict,
106
+ archive_bytes: bytes,
107
+ archive_name: str,
108
+ content_encoding: str,
109
+ timeout: int | None = None,
110
+ max_retries: int = 3,
111
+ ) -> dict:
112
+ """
113
+ POST a driver package archive to /submit as multipart/form-data.
114
+ archive_bytes: compressed tar bytes (or plain tar if zstd unavailable).
115
+ content_encoding: "zstd" | "identity" — describes the archive compression.
116
+ Returns the parsed JSON response dict.
117
+ Retries up to max_retries times with exponential backoff.
118
+ """
119
+ cfg = get()
120
+ t = timeout or cfg["timeout_medium"]
121
+
122
+ boundary = uuid.uuid4().hex
123
+ meta_bytes = json.dumps({**metadata, "archive_encoding": content_encoding}).encode()
124
+
125
+ body = (
126
+ f"--{boundary}\r\n"
127
+ f'Content-Disposition: form-data; name="metadata"\r\n'
128
+ f"Content-Type: application/json\r\n\r\n"
129
+ ).encode() + meta_bytes + b"\r\n"
130
+
131
+ body += (
132
+ f"--{boundary}\r\n"
133
+ f'Content-Disposition: form-data; name="files"; filename="{archive_name}"\r\n'
134
+ f"Content-Type: application/octet-stream\r\n"
135
+ f"Content-Encoding: {content_encoding}\r\n\r\n"
136
+ ).encode() + archive_bytes + b"\r\n"
137
+
138
+ body += f"--{boundary}--\r\n".encode()
139
+
140
+ headers = {
141
+ "X-Node-Key": node_key,
142
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
143
+ "X-Archive-Format": "tar",
144
+ }
145
+
146
+ def _do_upload():
147
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
148
+ try:
149
+ with urllib.request.urlopen(req, timeout=t) as resp:
150
+ return json.loads(resp.read())
151
+ except urllib.error.HTTPError as e:
152
+ if e.code == 429:
153
+ # Parse Retry-After header if present (seconds or HTTP-date).
154
+ try:
155
+ retry_after = float(e.headers.get("Retry-After", "5"))
156
+ except ValueError:
157
+ retry_after = 5.0
158
+ raise RateLimitedError(retry_after) from e
159
+ if 400 <= e.code < 500:
160
+ raise BadRequestError(e.code, e.read().decode(errors="replace")) from e
161
+ raise
162
+
163
+ result = _retry(_do_upload, max_retries)
164
+
165
+ status = result.get("status", "")
166
+ if status in ("already_approved", "already_present"):
167
+ print(" [skip] already in library (SHA known-clean)")
168
+ else:
169
+ print(f" [submit] job_id={result.get('job_id', '?')} status={status}")
170
+
171
+ return result
172
+
173
+
174
+ def http_download(url: str, dest: Path, sha256_expected: str,
175
+ timeout: int | None = None) -> bool:
176
+ """
177
+ Download a file to dest, streaming in 64 KB chunks directly to disk.
178
+ SHA-256 is computed incrementally as data arrives — the entire file is never
179
+ held in RAM. Progress (bytes received) is printed to stdout.
180
+ Decompresses zstd on-the-fly if the server sends Content-Encoding: zstd.
181
+ Returns True on success, False on hash mismatch or error.
182
+ """
183
+ cfg = get()
184
+ t = timeout or cfg["timeout_long"]
185
+ headers = {**_headers(), "Accept-Encoding": "zstd"}
186
+
187
+ req = urllib.request.Request(url, headers=headers)
188
+ dest.parent.mkdir(parents=True, exist_ok=True)
189
+
190
+ tmp = dest.with_suffix(dest.suffix + ".part")
191
+ try:
192
+ with urllib.request.urlopen(req, timeout=t) as resp:
193
+ encoding = resp.headers.get("Content-Encoding", "")
194
+ content_length = resp.headers.get("Content-Length")
195
+ total = int(content_length) if content_length else None
196
+
197
+ hasher = hashlib.sha256()
198
+ received = 0
199
+ CHUNK = 65536
200
+
201
+ if encoding == "zstd":
202
+ try:
203
+ import zstandard as zstd
204
+ except ImportError:
205
+ print(
206
+ f" [ERROR] zstandard package required to decompress {dest.name} "
207
+ "— install it with: pip install zstandard"
208
+ )
209
+ return False
210
+
211
+ dctx = zstd.ZstdDecompressor()
212
+ with open(tmp, "wb") as fh:
213
+ with dctx.stream_writer(fh, closefd=False) as writer:
214
+ while chunk := resp.read(CHUNK):
215
+ writer.write(chunk)
216
+ received += len(chunk)
217
+ _print_progress(dest.name, received, total)
218
+
219
+ with open(tmp, "rb") as fh:
220
+ while chunk := fh.read(CHUNK):
221
+ hasher.update(chunk)
222
+ else:
223
+ with open(tmp, "wb") as fh:
224
+ while chunk := resp.read(CHUNK):
225
+ fh.write(chunk)
226
+ hasher.update(chunk)
227
+ received += len(chunk)
228
+ _print_progress(dest.name, received, total)
229
+
230
+ except Exception as e:
231
+ tmp.unlink(missing_ok=True)
232
+ print(f" [ERROR] Download failed {dest.name}: {e}")
233
+ return False
234
+
235
+ actual = hasher.hexdigest()
236
+ if actual != sha256_expected:
237
+ tmp.unlink(missing_ok=True)
238
+ print(f" [ERROR] Hash mismatch {dest.name}: "
239
+ f"expected {sha256_expected[:8]}… got {actual[:8]}…")
240
+ return False
241
+
242
+ tmp.replace(dest)
243
+ print(f" [done] {dest.name} ({received:,} bytes)")
244
+ return True
245
+
246
+
247
+ def http_download_retry(url: str, dest: Path, sha256_expected: str,
248
+ timeout: int | None = None, max_retries: int = 3) -> bool:
249
+ for attempt in range(max_retries):
250
+ if http_download(url, dest, sha256_expected, timeout):
251
+ return True
252
+ if attempt < max_retries - 1:
253
+ time.sleep(2 ** attempt)
254
+ return False
255
+
256
+
257
+ def pack_driver_archive(
258
+ package_dir: Path,
259
+ source_name: str,
260
+ allowed_extensions: list[str],
261
+ ) -> tuple[bytes, str, str]:
262
+ """
263
+ Pack files from package_dir into a tar archive, then compress with zstd.
264
+ allowed_extensions: [".inf"] | ["*"] | [".inf", ".sys", ".cat"] etc.
265
+ Returns (archive_bytes, archive_filename, content_encoding).
266
+ Falls back to uncompressed tar if zstandard is not installed.
267
+ """
268
+ filter_all = allowed_extensions == ["*"]
269
+ files = [
270
+ f for f in package_dir.rglob("*")
271
+ if f.is_file() and (filter_all or f.suffix.lower() in allowed_extensions)
272
+ ]
273
+
274
+ if not files:
275
+ # Nothing matched — fall back to all files so we don't submit empty packages
276
+ files = [f for f in package_dir.rglob("*") if f.is_file()]
277
+
278
+ tar_buf = io.BytesIO()
279
+ with tarfile.open(fileobj=tar_buf, mode="w") as tar:
280
+ for f in files:
281
+ tar.add(f, arcname=f.relative_to(package_dir))
282
+ tar_bytes = tar_buf.getvalue()
283
+
284
+ try:
285
+ import zstandard as zstd
286
+ cctx = zstd.ZstdCompressor(level=3)
287
+ archive_bytes = cctx.compress(tar_bytes)
288
+ archive_name = f"{source_name}.tar.zst"
289
+ encoding = "zstd"
290
+ except ImportError:
291
+ archive_bytes = tar_bytes
292
+ archive_name = f"{source_name}.tar"
293
+ encoding = "identity"
294
+
295
+ return archive_bytes, archive_name, encoding
296
+
297
+
298
+ def _print_progress(name: str, received: int, total: int | None):
299
+ if total:
300
+ pct = received * 100 // total
301
+ print(f"\r {name}: {pct:3d}% ({received:,}/{total:,} bytes)", end="", flush=True)
302
+ else:
303
+ if received % (512 * 1024) == 0:
304
+ print(f"\r {name}: {received:,} bytes…", end="", flush=True)
driverclient/events.py ADDED
@@ -0,0 +1,141 @@
1
+ """
2
+ driverclient/events.py — structured, real-time progress events.
3
+
4
+ Every op reports progress by calling emit()/start()/progress()/ok()/... with a
5
+ ClientEvent. By default an event is formatted to a line and printed (plus routed
6
+ through logging.getLogger("driverclient")), reproducing the pre-0.2.0 terminal
7
+ output so nothing regresses when no consumer is attached.
8
+
9
+ An embedding application (e.g. a PyQt front-end) installs its own sink for the
10
+ duration of a run via DriverClient.run(on_event=...) — see using_sink(). The sink
11
+ receives ClientEvent objects synchronously, in real time, on the calling thread.
12
+
13
+ `phase` and `status` are a FIXED, DOCUMENTED vocabulary — this is the interface a
14
+ GUI binds to. Treat any change to PHASES / STATUSES as an API change.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import time
20
+ from contextlib import contextmanager
21
+ from dataclasses import asdict, dataclass, field
22
+ from typing import Callable, Iterator
23
+
24
+ # ── Vocabulary (documented interface — see README events table) ──────────────
25
+ PHASES = (
26
+ "scan", "resolve", "install", "capture", "windows_update",
27
+ "dump", "upload", "pipeline", "done",
28
+ )
29
+ STATUSES = ("start", "progress", "ok", "warn", "error", "done")
30
+
31
+ _log = logging.getLogger("driverclient")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ClientEvent:
36
+ """One progress event. Immutable so a sink can safely stash/forward it."""
37
+ phase: str
38
+ status: str
39
+ message: str = ""
40
+ current: int | None = None
41
+ total: int | None = None
42
+ percent: float | None = None
43
+ data: dict = field(default_factory=dict)
44
+ ts: float = field(default_factory=time.time)
45
+
46
+ def to_dict(self) -> dict:
47
+ """Plain dict for transport across a Qt signal or into a log record."""
48
+ return asdict(self)
49
+
50
+
51
+ EventSink = Callable[[ClientEvent], None]
52
+
53
+ _sink: EventSink | None = None
54
+
55
+
56
+ # ── Sink management ──────────────────────────────────────────────────────────
57
+
58
+ def set_sink(sink: EventSink | None) -> None:
59
+ """Install a global sink (None restores the default print/log fallback)."""
60
+ global _sink
61
+ _sink = sink
62
+
63
+
64
+ def clear_sink() -> None:
65
+ """Remove any installed sink; the default print/log fallback takes over."""
66
+ global _sink
67
+ _sink = None
68
+
69
+
70
+ @contextmanager
71
+ def using_sink(sink: EventSink | None) -> Iterator[None]:
72
+ """
73
+ Install `sink` for the duration of the block, then restore the previous one.
74
+
75
+ If `sink` is None the currently-active sink (default print/log fallback) is
76
+ left in place. try/finally guarantees the previous sink is restored even on
77
+ error, so one run's callback never leaks into the next.
78
+ """
79
+ global _sink
80
+ prev = _sink
81
+ if sink is not None:
82
+ _sink = sink
83
+ try:
84
+ yield
85
+ finally:
86
+ _sink = prev
87
+
88
+
89
+ # ── Emit + default fallback ──────────────────────────────────────────────────
90
+
91
+ def emit(event: ClientEvent) -> None:
92
+ """Route one event to the installed sink, else to the default fallback."""
93
+ if _sink is not None:
94
+ _sink(event)
95
+ else:
96
+ _default_sink(event)
97
+
98
+
99
+ def _default_sink(event: ClientEvent) -> None:
100
+ """Reproduce today's terminal output: print the message, mirror to the log."""
101
+ print(event.message)
102
+ _log.debug("%s", event.message)
103
+
104
+
105
+ # ── Thin constructors (keep op call-sites terse) ─────────────────────────────
106
+
107
+ def _emit(phase, status, message, current, total, percent, data) -> None:
108
+ emit(ClientEvent(
109
+ phase=phase, status=status, message=message,
110
+ current=current, total=total, percent=percent, data=data,
111
+ ))
112
+
113
+
114
+ def start(phase: str, message: str = "", *, current=None, total=None,
115
+ percent=None, **data) -> None:
116
+ _emit(phase, "start", message, current, total, percent, data)
117
+
118
+
119
+ def progress(phase: str, message: str = "", *, current=None, total=None,
120
+ percent=None, **data) -> None:
121
+ _emit(phase, "progress", message, current, total, percent, data)
122
+
123
+
124
+ def ok(phase: str, message: str = "", *, current=None, total=None,
125
+ percent=None, **data) -> None:
126
+ _emit(phase, "ok", message, current, total, percent, data)
127
+
128
+
129
+ def warn(phase: str, message: str = "", *, current=None, total=None,
130
+ percent=None, **data) -> None:
131
+ _emit(phase, "warn", message, current, total, percent, data)
132
+
133
+
134
+ def error(phase: str, message: str = "", *, current=None, total=None,
135
+ percent=None, **data) -> None:
136
+ _emit(phase, "error", message, current, total, percent, data)
137
+
138
+
139
+ def done(phase: str, message: str = "", *, current=None, total=None,
140
+ percent=None, **data) -> None:
141
+ _emit(phase, "done", message, current, total, percent, data)
driverclient/main.py ADDED
@@ -0,0 +1,71 @@
1
+ """
2
+ driverclient/main.py — Driver Server client entry point.
3
+
4
+ Reads default_command from config.json and dispatches to the matching
5
+ operation. No CLI arguments — config.json is the sole source of truth.
6
+
7
+ Programmatic use (PyQt or other embedding): prefer the DriverClient
8
+ connector (``from driverclient import DriverClient``). The bare run()
9
+ helper below is also available:
10
+ from driverclient.main import run
11
+ run("capture-all")
12
+ run() # uses default_command from config.json
13
+ """
14
+ import os
15
+ import shutil
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from driverclient import events
20
+ from driverclient.config import get
21
+ from driverclient.ops.automate import run_automation
22
+ from driverclient.ops.capture import capture_all, capture_missing, wu_full, wu_update
23
+ from driverclient.ops.install import resolve_and_install
24
+ from driverclient.ops.resolve import resolve
25
+ from driverclient.ops.scan import scan
26
+
27
+ _OPERATIONS: dict[str, callable] = {
28
+ "scan": scan,
29
+ "resolve": resolve,
30
+ "resolve-and-install": resolve_and_install,
31
+ "capture-all": capture_all,
32
+ "capture-missing": capture_missing,
33
+ "wu-update": wu_update,
34
+ "wu-full": wu_full,
35
+ "automate": run_automation,
36
+ }
37
+
38
+
39
+ def run(command: str | None = None) -> object:
40
+ """
41
+ Run a client operation.
42
+
43
+ Args:
44
+ command: one of the keys in _OPERATIONS.
45
+ If None, checks DS_CLIENT_COMMAND env var, then
46
+ default_command from config.json, then "automate".
47
+
48
+ Returns:
49
+ The result object from the operation (ScanResult, InstallResult, etc.).
50
+
51
+ After the operation completes, cache_dir (C:\\DriverServer\\client\\ by default)
52
+ is deleted so no cached files persist between runs.
53
+ """
54
+ cfg = get()
55
+ cmd = command or os.environ.get("DS_CLIENT_COMMAND") or cfg.get("default_command", "automate")
56
+
57
+ fn = _OPERATIONS.get(cmd)
58
+ if fn is None:
59
+ events.error("pipeline", f"[error] Unknown command: '{cmd}'", command=cmd)
60
+ events.error("pipeline", f" Valid commands: {', '.join(_OPERATIONS)}")
61
+ sys.exit(1)
62
+
63
+ try:
64
+ return fn()
65
+ finally:
66
+ cache_dir = Path(cfg.get("cache_dir", "C:\\DriverServer\\client"))
67
+ shutil.rmtree(cache_dir, ignore_errors=True)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ run()
@@ -0,0 +1,15 @@
1
+ from driverclient.ops.automate import AutomateResult, run_automation
2
+ from driverclient.ops.capture import CaptureResult, capture_all, capture_missing, wu_update
3
+ from driverclient.ops.install import InstallResult, resolve_and_install
4
+ from driverclient.ops.resolve import ResolveResult, resolve
5
+ from driverclient.ops.scan import ScanResult, scan
6
+
7
+ __all__ = [
8
+ "scan", "ScanResult",
9
+ "resolve", "ResolveResult",
10
+ "resolve_and_install", "InstallResult",
11
+ "capture_all",
12
+ "capture_missing",
13
+ "wu_update", "CaptureResult",
14
+ "run_automation", "AutomateResult",
15
+ ]
@@ -0,0 +1,118 @@
1
+ """
2
+ client/ops/automate.py — Full end-to-end driver management pipeline.
3
+
4
+ Steps:
5
+ 1. Scan — enumerate hardware, build device + HWID list
6
+ 2. Resolve — query local repo: available drivers vs not-found HWIDs
7
+ 3. Install — download + install available drivers from repo
8
+ 4. WU — (if still missing) trigger Windows Update, wait, capture + submit
9
+
10
+ Public API:
11
+ run_automation() -> AutomateResult
12
+ """
13
+ import time
14
+ import uuid
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+
18
+ from driverclient import events
19
+ from driverclient.config import get
20
+ from driverclient.ops.capture import CaptureResult, wu_update
21
+ from driverclient.ops.install import InstallResult, resolve_and_install
22
+ from driverclient.ops.resolve import ResolveResult, resolve
23
+ from driverclient.ops.scan import ScanResult, scan
24
+
25
+
26
+ @dataclass
27
+ class AutomateResult:
28
+ run_id: str = field(default_factory=lambda: str(uuid.uuid4()))
29
+ scan: ScanResult | None = None
30
+ resolve: ResolveResult | None = None
31
+ install: InstallResult | None = None
32
+ wu: CaptureResult | None = None
33
+ duration_s: int = 0
34
+
35
+
36
+ def run_automation() -> AutomateResult:
37
+ cfg = get()
38
+ result = AutomateResult()
39
+ t0 = time.monotonic()
40
+
41
+ _header("Automate — full pipeline")
42
+
43
+ # ── Step 1: Scan ──────────────────────────────────────────────────────────
44
+ _step(1, "Scan")
45
+ result.scan = scan(force=True)
46
+
47
+ # ── Step 2: Resolve ───────────────────────────────────────────────────────
48
+ _step(2, "Resolve")
49
+ # Reuse the fresh scan just done — force=False reads the just-saved cache
50
+ result.resolve = resolve(force=False)
51
+
52
+ if not result.resolve.has_drivers and not result.resolve.has_missing:
53
+ events.done("pipeline", "[automate] Machine fully up to date — nothing to do")
54
+ result.duration_s = int(time.monotonic() - t0)
55
+ _summary(result)
56
+ return result
57
+
58
+ # ── Step 3: Install from repo ─────────────────────────────────────────────
59
+ _step(3, f"Install ({len(result.resolve.drivers)} driver(s) from repo)")
60
+ if result.resolve.has_drivers:
61
+ result.install = resolve_and_install(force=False)
62
+ else:
63
+ events.progress("pipeline", "[automate] No drivers available in repo — skipping install")
64
+ result.install = InstallResult(trace_id=result.resolve.trace_id)
65
+
66
+ # ── Step 4: Windows Update fallback ──────────────────────────────────────
67
+ wu_enabled = cfg.get("automate_wu_fallback", True)
68
+ still_missing = len(result.resolve.not_found)
69
+
70
+ if still_missing > 0 and wu_enabled:
71
+ _step(4, f"Windows Update ({still_missing} HWID(s) not in repo)")
72
+ result.wu = wu_update(force=False)
73
+ else:
74
+ reason = "disabled in config" if not wu_enabled else "nothing missing"
75
+ events.progress("pipeline", f"\n[automate] Step 4 — WU fallback skipped ({reason})",
76
+ reason=reason)
77
+ result.wu = CaptureResult(still_missing=still_missing)
78
+
79
+ result.duration_s = int(time.monotonic() - t0)
80
+ _summary(result)
81
+ return result
82
+
83
+
84
+ # ── Helpers ────────────────────────────────────────────────────────────────────
85
+
86
+ def _header(title: str) -> None:
87
+ events.start("pipeline", "")
88
+ events.start("pipeline", "=" * 60)
89
+ events.start("pipeline", f" {title}", title=title)
90
+ events.start("pipeline", f" {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
91
+ events.start("pipeline", "=" * 60)
92
+
93
+
94
+ def _step(n: int, label: str) -> None:
95
+ events.progress("pipeline", f"\n── Step {n}: {label} {'─' * max(0, 50 - len(label))}",
96
+ step=n, label=label)
97
+
98
+
99
+ def _summary(r: AutomateResult) -> None:
100
+ inst = r.install or InstallResult()
101
+ wu = r.wu or CaptureResult()
102
+
103
+ events.done("done", "")
104
+ events.done("done", "=" * 60)
105
+ events.done("done", " Summary")
106
+ events.done("done", f" Duration : {r.duration_s}s", duration_s=r.duration_s)
107
+ events.done("done", f" Installed: {inst.ok_count} ok {inst.fail_count} failed",
108
+ installed_ok=inst.ok_count, installed_failed=inst.fail_count)
109
+ if r.wu and r.wu.wu_triggered:
110
+ events.done("done",
111
+ f" WU : {wu.submitted} captured {wu.still_missing} still missing",
112
+ wu_submitted=wu.submitted, wu_still_missing=wu.still_missing)
113
+ else:
114
+ missing = len(r.resolve.not_found) if r.resolve else 0
115
+ if missing:
116
+ events.done("done", f" Missing : {missing} HWID(s) not in repo (WU not run)",
117
+ missing=missing)
118
+ events.done("done", "=" * 60)