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,276 @@
1
+ """
2
+ client/ops/install.py — Resolve then download + install drivers from local repo.
3
+
4
+ Download pipeline:
5
+ ALL driver downloads across ALL category levels are submitted to the thread
6
+ pool simultaneously at t=0. Installation proceeds level-by-level in
7
+ dependency order; within each level every driver installs as soon as its
8
+ own download finishes — no waiting for the slowest download in that level.
9
+
10
+ Chipset installs at t=2s while firmware/storage/network are still
11
+ downloading in parallel.
12
+
13
+ Public API:
14
+ resolve_and_install(force=False) -> InstallResult
15
+ """
16
+ import shutil
17
+ import subprocess
18
+ import time
19
+ import uuid
20
+ from collections import defaultdict
21
+ from concurrent.futures import Future, ThreadPoolExecutor, as_completed
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ from driverclient import events
26
+ from driverclient.config import get
27
+ from driverclient.core.http import http_download_retry, http_post_retry
28
+ from driverclient.ops.resolve import ResolveResult, resolve
29
+
30
+ # Install order: lower number first (hardware dependency)
31
+ _CATEGORY_ORDER: dict[str, int] = {
32
+ "chipset": 0, "firmware": 1, "storage": 2,
33
+ "network": 3, "display": 5, "audio": 6, "other": 10,
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class DriverResult:
39
+ binding_id: str
40
+ success: bool
41
+ error: str = ""
42
+ duration_ms: int = 0
43
+
44
+
45
+ @dataclass
46
+ class InstallResult:
47
+ installed_ok: list[DriverResult] = field(default_factory=list)
48
+ install_failed: list[DriverResult] = field(default_factory=list)
49
+ trace_id: str = ""
50
+
51
+ @property
52
+ def ok_count(self) -> int: return len(self.installed_ok)
53
+ @property
54
+ def fail_count(self) -> int: return len(self.install_failed)
55
+
56
+
57
+ def resolve_and_install(force: bool = False) -> InstallResult:
58
+ """
59
+ Run resolve (or load cache) then download + install all available drivers.
60
+ Returns InstallResult.
61
+ """
62
+ cfg = get()
63
+ result = resolve(force=force)
64
+
65
+ if not result.has_drivers:
66
+ events.done("install", "[install] Nothing to install — all drivers up to date",
67
+ total=0)
68
+ return InstallResult(trace_id=result.trace_id)
69
+
70
+ session_id = str(uuid.uuid4())
71
+ work_dir = Path(cfg.get("work_dir", "C:\\DriverServer\\client\\staging")) / session_id
72
+ work_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ events.start("install",
75
+ f"[install] {len(result.drivers)} driver(s) — starting parallel downloads…",
76
+ total=len(result.drivers))
77
+ all_results = _stream_install(result.drivers, work_dir, cfg)
78
+
79
+ shutil.rmtree(work_dir, ignore_errors=True)
80
+
81
+ installed_ok = [r for r in all_results if r.success]
82
+ install_failed = [r for r in all_results if not r.success]
83
+
84
+ _confirm(result.trace_id, installed_ok, install_failed, cfg)
85
+
86
+ events.done("install",
87
+ f"[install] Done — {len(installed_ok)} ok {len(install_failed)} failed",
88
+ total=len(all_results), ok=len(installed_ok), failed=len(install_failed))
89
+ for r in install_failed:
90
+ events.error("install", f" ✗ {r.binding_id}: {r.error}",
91
+ binding_id=r.binding_id, error=r.error)
92
+
93
+ return InstallResult(
94
+ installed_ok=installed_ok,
95
+ install_failed=install_failed,
96
+ trace_id=result.trace_id,
97
+ )
98
+
99
+
100
+ # ── Streaming pipeline ──────────────────────────────────────────────────────────
101
+
102
+ def _stream_install(drivers: list[dict], base: Path, cfg: dict) -> list[DriverResult]:
103
+ """
104
+ Submit ALL downloads (all levels) simultaneously, then install level-by-level.
105
+ Each driver installs the moment its own download Future completes.
106
+ """
107
+ repo_url = cfg["local_repo_url"]
108
+ parallel = cfg.get("parallel_downloads", 6)
109
+
110
+ by_level = _dedup_and_group(drivers)
111
+ executor = ThreadPoolExecutor(max_workers=parallel)
112
+ future_info, level_futs = _submit_all_downloads(by_level, base, repo_url, cfg, executor)
113
+
114
+ all_results: list[DriverResult] = []
115
+ for level in sorted(by_level):
116
+ events.progress("install",
117
+ f"[install] {_level_label(level)} ({len(level_futs[level])} driver(s))…",
118
+ category=_level_label(level), count=len(level_futs[level]))
119
+ for fut in as_completed(level_futs[level]):
120
+ all_results.append(_process_completed(fut, future_info, cfg))
121
+
122
+ executor.shutdown(wait=False)
123
+ return all_results
124
+
125
+
126
+ def _dedup_and_group(drivers: list[dict]) -> dict[int, list[dict]]:
127
+ """Deduplicate by binding_id, then bucket by category level."""
128
+ seen: set[str] = set()
129
+ by_level: dict[int, list[dict]] = defaultdict(list)
130
+ for d in drivers:
131
+ bid = d.get("binding_id") or d.get("package_id", "")
132
+ if bid and bid not in seen:
133
+ seen.add(bid)
134
+ level = _CATEGORY_ORDER.get(d.get("category", "other"), 10)
135
+ by_level[level].append(d)
136
+ return by_level
137
+
138
+
139
+ def _submit_all_downloads(
140
+ by_level: dict[int, list[dict]],
141
+ base: Path,
142
+ repo_url: str,
143
+ cfg: dict,
144
+ executor: ThreadPoolExecutor,
145
+ ) -> tuple[dict[Future, tuple[int, dict]], dict[int, list[Future]]]:
146
+ """Submit every driver download to the pool at once. Returns lookup maps."""
147
+ future_info: dict[Future, tuple[int, dict]] = {}
148
+ level_futs: dict[int, list[Future]] = defaultdict(list)
149
+ for level in sorted(by_level):
150
+ for driver in by_level[level]:
151
+ fut = executor.submit(_download_driver, driver, base, repo_url, cfg)
152
+ future_info[fut] = (level, driver)
153
+ level_futs[level].append(fut)
154
+ return future_info, level_futs
155
+
156
+
157
+ def _process_completed(
158
+ fut: Future,
159
+ future_info: dict[Future, tuple[int, dict]],
160
+ cfg: dict,
161
+ ) -> DriverResult:
162
+ """Handle one completed download Future: install if ok, return DriverResult."""
163
+ _, driver = future_info[fut]
164
+ bid = driver.get("binding_id", "")
165
+ cat = driver.get("category", "other")
166
+
167
+ try:
168
+ dest, dl_ok = fut.result()
169
+ except Exception as e:
170
+ return DriverResult(binding_id=bid, success=False, error=str(e))
171
+
172
+ if not dl_ok:
173
+ return DriverResult(binding_id=bid, success=False, error="download_failed")
174
+
175
+ t0 = time.monotonic()
176
+ ok, err = _install_driver(driver, dest, cfg)
177
+ dur = int((time.monotonic() - t0) * 1000)
178
+ line = f" {'✓' if ok else '✗'} [{cat}] {bid} ({dur}ms)"
179
+ emit = events.ok if ok else events.error
180
+ emit("install", line, binding_id=bid, category=cat, duration_ms=dur, error=err)
181
+ return DriverResult(binding_id=bid, success=ok, error=err, duration_ms=dur)
182
+
183
+
184
+ def _download_driver(driver: dict, base: Path, repo_url: str,
185
+ cfg: dict) -> tuple[Path, bool]:
186
+ """Download all files for one driver. Returns (dest_dir, success)."""
187
+ bid = driver.get("binding_id") or driver.get("package_id", "")
188
+ pkg_id = driver["package_id"]
189
+ dest = base / bid
190
+ dest.mkdir(parents=True, exist_ok=True)
191
+
192
+ ok = True
193
+ for f in driver.get("files", []):
194
+ url = f"{repo_url}/files/{pkg_id}/{f['filename']}"
195
+ path = dest / f["filename"]
196
+ if not http_download_retry(url, path, f["sha256"],
197
+ timeout=cfg["timeout_long"]):
198
+ ok = False
199
+
200
+ return dest, ok
201
+
202
+
203
+ def _level_label(level: int) -> str:
204
+ labels = {0: "chipset", 1: "firmware", 2: "storage",
205
+ 3: "network", 5: "display", 6: "audio", 10: "other"}
206
+ return labels.get(level, f"level-{level}")
207
+
208
+
209
+ # ── Driver installation ─────────────────────────────────────────────────────────
210
+
211
+ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, str]:
212
+ install_type = driver.get("install_type", "inf_silent")
213
+
214
+ try:
215
+ if install_type == "inf_silent":
216
+ inf_files = list(driver_dir.glob("*.inf"))
217
+ if not inf_files:
218
+ return False, "No INF file found"
219
+ r = subprocess.run(
220
+ ["pnputil", "/add-driver", str(inf_files[0]), "/install", "/subdirs"],
221
+ capture_output=True, text=True,
222
+ timeout=cfg["timeout_long"],
223
+ )
224
+ return r.returncode == 0, r.stdout[:200] if r.returncode != 0 else ""
225
+
226
+ if install_type == "exe_silent":
227
+ exe_files = list(driver_dir.glob("*.exe"))
228
+ if not exe_files:
229
+ return False, "No EXE file found"
230
+ flags = driver.get("install_flags", "/S")
231
+ r = subprocess.run(
232
+ [str(exe_files[0])] + flags.split(),
233
+ capture_output=True,
234
+ timeout=cfg["timeout_install"],
235
+ )
236
+ return r.returncode == 0, ""
237
+
238
+ if install_type == "firmware_flash":
239
+ inf_files = list(driver_dir.glob("*.inf"))
240
+ if not inf_files:
241
+ return False, "No firmware INF"
242
+ r = subprocess.run(
243
+ ["pnputil", "/add-driver", str(inf_files[0]), "/install"],
244
+ capture_output=True, text=True,
245
+ timeout=cfg["timeout_long"],
246
+ )
247
+ return r.returncode == 0, ""
248
+
249
+ except subprocess.TimeoutExpired:
250
+ return False, "Install timed out"
251
+ except Exception as e:
252
+ return False, str(e)
253
+
254
+ return False, f"Unknown install_type: {install_type}"
255
+
256
+
257
+ # ── Confirm ─────────────────────────────────────────────────────────────────────
258
+
259
+ def _confirm(trace_id: str, ok: list[DriverResult], fail: list[DriverResult],
260
+ cfg: dict) -> None:
261
+ try:
262
+ results = (
263
+ [{"binding_id": r.binding_id, "success": True} for r in ok]
264
+ + [{"binding_id": r.binding_id, "success": False,
265
+ "error": getattr(r, "error", None)} for r in fail]
266
+ )
267
+ http_post_retry(
268
+ f"{cfg['local_repo_url']}/submit/confirm/{trace_id}",
269
+ {
270
+ "machine_id": cfg.get("machine_id", ""),
271
+ "results": results,
272
+ },
273
+ timeout=cfg["timeout_short"],
274
+ )
275
+ except Exception:
276
+ pass
@@ -0,0 +1,147 @@
1
+ """
2
+ client/ops/resolve.py — Scan hardware then query local repo.
3
+
4
+ Identifies which drivers are available in the repo (ready to install)
5
+ and which HWIDs the repo has no package for (not_found).
6
+
7
+ Results saved to ds_resolve.json so install / capture ops can consume
8
+ them without re-running this step.
9
+
10
+ Public API:
11
+ resolve(force=False) -> ResolveResult
12
+ """
13
+ import json
14
+ import time
15
+ import uuid
16
+ from dataclasses import asdict, dataclass, field
17
+ from datetime import datetime, timedelta, timezone
18
+ from pathlib import Path
19
+
20
+ from driverclient import events
21
+ from driverclient.config import get
22
+ from driverclient.core.http import http_post_retry
23
+ from driverclient.ops.scan import ScanResult, scan
24
+
25
+
26
+ @dataclass
27
+ class ResolveResult:
28
+ drivers: list[dict] = field(default_factory=list)
29
+ not_found: list[str] = field(default_factory=list)
30
+ trace_id: str = ""
31
+ machine_id: str = ""
32
+ timestamp: str = ""
33
+
34
+ @property
35
+ def has_drivers(self) -> bool:
36
+ return bool(self.drivers)
37
+
38
+ @property
39
+ def has_missing(self) -> bool:
40
+ return bool(self.not_found)
41
+
42
+
43
+ def resolve(force: bool = False) -> ResolveResult:
44
+ """
45
+ Load (or run) a hardware scan, then POST to /resolve.
46
+ Returns ResolveResult with drivers[] and not_found[].
47
+ Saves result to ds_resolve.json.
48
+ """
49
+ cfg = get()
50
+
51
+ if not force:
52
+ cached = _load_cache(cfg)
53
+ if cached:
54
+ _print_summary(cached)
55
+ return cached
56
+
57
+ scan_result: ScanResult = scan(force=force)
58
+ return _query_repo(scan_result, cfg, force)
59
+
60
+
61
+ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResult:
62
+ repo_url = cfg["local_repo_url"]
63
+ session_id = str(uuid.uuid4())
64
+
65
+ payload = {
66
+ "machine_id": scan_result.machine_id,
67
+ "session_id": session_id,
68
+ "vendor": scan_result.vendor,
69
+ "model": scan_result.model,
70
+ "os_build": scan_result.os_build,
71
+ "arch": scan_result.arch,
72
+ "firmware_version": scan_result.firmware_version,
73
+ "hwids": scan_result.all_hwids,
74
+ "installed_versions": scan_result.installed_versions,
75
+ "force": force or cfg.get("force_resolve", False),
76
+ }
77
+
78
+ events.start("resolve", f"[resolve] Querying {repo_url}/resolve …",
79
+ total=len(scan_result.all_hwids))
80
+ t0 = time.monotonic()
81
+
82
+ resp = http_post_retry(f"{repo_url}/resolve", payload,
83
+ timeout=cfg["timeout_medium"])
84
+ elapsed = int((time.monotonic() - t0) * 1000)
85
+
86
+ result = ResolveResult(
87
+ drivers=resp.get("drivers", []),
88
+ not_found=resp.get("not_found", []),
89
+ trace_id=resp.get("trace_id", ""),
90
+ machine_id=scan_result.machine_id,
91
+ timestamp=datetime.now(timezone.utc).isoformat(),
92
+ )
93
+
94
+ total_hwids = resp.get("total_hwids", len(scan_result.all_hwids))
95
+ already_current = resp.get("already_current", 0)
96
+
97
+ events.done(
98
+ "resolve",
99
+ f"[resolve] HWIDs: {total_hwids} | "
100
+ f"Already current: {already_current} | "
101
+ f"To install: {len(result.drivers)} | "
102
+ f"Not found: {len(result.not_found)} ({elapsed}ms)",
103
+ total=total_hwids, already_current=already_current,
104
+ to_install=len(result.drivers), not_found=len(result.not_found),
105
+ elapsed_ms=elapsed,
106
+ )
107
+
108
+ _save_cache(result, cfg)
109
+ return result
110
+
111
+
112
+ # ── Cache ──────────────────────────────────────────────────────────────────────
113
+
114
+ def _cache_path(cfg: dict) -> Path:
115
+ return Path(cfg.get("cache_dir", "C:\\ProgramData\\DriverServer")) / "ds_resolve.json"
116
+
117
+
118
+ def _load_cache(cfg: dict) -> ResolveResult | None:
119
+ path = _cache_path(cfg)
120
+ if not path.exists():
121
+ return None
122
+ try:
123
+ data = json.loads(path.read_text())
124
+ ts = datetime.fromisoformat(data.get("timestamp", "2000-01-01T00:00:00+00:00"))
125
+ ttl = cfg.get("cache_ttl_minutes", 15)
126
+ if datetime.now(timezone.utc) - ts > timedelta(minutes=ttl):
127
+ return None
128
+ return ResolveResult(**data)
129
+ except Exception:
130
+ return None
131
+
132
+
133
+ def _save_cache(result: ResolveResult, cfg: dict) -> None:
134
+ path = _cache_path(cfg)
135
+ try:
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ path.write_text(json.dumps(asdict(result), indent=2))
138
+ except Exception:
139
+ pass
140
+
141
+
142
+ def _print_summary(r: ResolveResult) -> None:
143
+ events.done(
144
+ "resolve",
145
+ f"[resolve] (cached) To install: {len(r.drivers)} | Not found: {len(r.not_found)}",
146
+ to_install=len(r.drivers), not_found=len(r.not_found), cached=True,
147
+ )