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.
- driverclient/__init__.py +73 -0
- driverclient/__main__.py +4 -0
- driverclient/config.json +36 -0
- driverclient/config.py +98 -0
- driverclient/core/__init__.py +0 -0
- driverclient/core/hardware.py +345 -0
- driverclient/core/http.py +304 -0
- driverclient/events.py +141 -0
- driverclient/main.py +71 -0
- driverclient/ops/__init__.py +15 -0
- driverclient/ops/automate.py +118 -0
- driverclient/ops/capture.py +679 -0
- driverclient/ops/install.py +276 -0
- driverclient/ops/resolve.py +147 -0
- driverclient/ops/scan.py +316 -0
- driverclient/py.typed +0 -0
- driverclient-0.2.0.dist-info/METADATA +179 -0
- driverclient-0.2.0.dist-info/RECORD +19 -0
- driverclient-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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)
|