driverclient 0.2.18__tar.gz → 0.2.20__tar.gz

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 (23) hide show
  1. {driverclient-0.2.18 → driverclient-0.2.20}/PKG-INFO +1 -1
  2. {driverclient-0.2.18 → driverclient-0.2.20}/pyproject.toml +1 -1
  3. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/__init__.py +1 -1
  4. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/ops/install.py +134 -114
  5. {driverclient-0.2.18 → driverclient-0.2.20}/.gitignore +0 -0
  6. {driverclient-0.2.18 → driverclient-0.2.20}/README.md +0 -0
  7. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/__main__.py +0 -0
  8. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/config.json +0 -0
  9. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/config.py +0 -0
  10. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/core/__init__.py +0 -0
  11. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/core/hardware.py +0 -0
  12. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/core/http.py +0 -0
  13. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/core/hwid.py +0 -0
  14. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/core/proc.py +0 -0
  15. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/events.py +0 -0
  16. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/main.py +0 -0
  17. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/ops/__init__.py +0 -0
  18. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/ops/automate.py +0 -0
  19. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/ops/capture.py +0 -0
  20. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/ops/diagnose.py +0 -0
  21. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/ops/resolve.py +0 -0
  22. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/ops/scan.py +0 -0
  23. {driverclient-0.2.18 → driverclient-0.2.20}/src/driverclient/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driverclient
3
- Version: 0.2.18
3
+ Version: 0.2.20
4
4
  Summary: Driver Server client with an embeddable connector for config injection at runtime.
5
5
  Project-URL: Homepage, https://example.com/driverclient
6
6
  Author-email: Raja Sanaullah <sanaullah@99technologies.com>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "driverclient"
7
- version = "0.2.18"
7
+ version = "0.2.20"
8
8
  description = "Driver Server client with an embeddable connector for config injection at runtime."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -25,7 +25,7 @@ from driverclient.main import run as _run
25
25
 
26
26
  __all__ = ["DriverClient", "ClientEvent"]
27
27
 
28
- __version__ = "0.2.18"
28
+ __version__ = "0.2.20"
29
29
 
30
30
 
31
31
  class DriverClient:
@@ -19,7 +19,7 @@ import subprocess
19
19
  import time
20
20
  import uuid
21
21
  from collections import defaultdict
22
- from concurrent.futures import Future, ThreadPoolExecutor, as_completed
22
+ from concurrent.futures import ThreadPoolExecutor, as_completed
23
23
  from dataclasses import dataclass, field
24
24
  from pathlib import Path
25
25
 
@@ -68,6 +68,7 @@ class DriverResult:
68
68
  error: str = ""
69
69
  duration_ms: int = 0
70
70
  reboot_required: bool = False # installed OK but needs a reboot (pnputil 3010)
71
+ skipped: bool = False # already present in the driver store — not re-fetched/installed
71
72
 
72
73
 
73
74
  @dataclass
@@ -154,9 +155,18 @@ def resolve_and_install(force: bool = False) -> InstallResult:
154
155
  # at the same-or-newer version — so a still-current big driver (graphics) is not
155
156
  # re-downloaded, while missing/extension drivers still install. Toggle off with
156
157
  # skip_installed_drivers=false to force-reinstall everything.
158
+ # Driver-store index (INF name -> highest installed version), built once to
159
+ # FILTER the download list: skip any package the store already has at the
160
+ # same-or-newer version. This is skip-if-present (idempotency), NOT dedup —
161
+ # no repo package is removed; a newer repo version still installs over an
162
+ # older store version (store_ver >= repo_ver skips). Duplicate versions that
163
+ # survive the filter are resolved at install time by a single batched pnputil
164
+ # pass, where Windows picks the best driver per device.
165
+ skip_installed = cfg.get("skip_installed_drivers", True)
166
+ idx: dict = _installed_inf_index() if skip_installed else {}
167
+
157
168
  drivers = result.drivers
158
- if cfg.get("skip_installed_drivers", True):
159
- idx = _installed_inf_index()
169
+ if skip_installed:
160
170
  kept = [d for d in drivers if not _already_in_store(d, idx)]
161
171
  skipped = len(drivers) - len(kept)
162
172
  if skipped:
@@ -183,17 +193,20 @@ def resolve_and_install(force: bool = False) -> InstallResult:
183
193
 
184
194
  shutil.rmtree(work_dir, ignore_errors=True)
185
195
 
186
- installed_ok = [r for r in all_results if r.success]
196
+ installed_ok = [r for r in all_results if r.success and not r.skipped]
197
+ skipped_now = [r for r in all_results if r.skipped]
187
198
  install_failed = [r for r in all_results if not r.success]
188
199
 
189
- _confirm(result.trace_id, installed_ok, install_failed, cfg)
200
+ # Skipped drivers ARE satisfied on the machine — report them upstream as success.
201
+ _confirm(result.trace_id, installed_ok + skipped_now, install_failed, cfg)
190
202
 
191
203
  reboot_required = any(r.reboot_required for r in installed_ok)
192
204
  reboot_tag = " — reboot required" if reboot_required else ""
205
+ skip_tag = f" ({len(skipped_now)} already in store)" if skipped_now else ""
193
206
  events.done("install",
194
- f"[install] Done — {len(installed_ok)} ok {len(install_failed)} failed{reboot_tag}",
207
+ f"[install] Done — {len(installed_ok)} ok {len(install_failed)} failed{skip_tag}{reboot_tag}",
195
208
  total=len(all_results), ok=len(installed_ok), failed=len(install_failed),
196
- reboot_required=reboot_required)
209
+ skipped=len(skipped_now), reboot_required=reboot_required)
197
210
  for r in install_failed:
198
211
  events.error("install", f" ✗ {r.binding_id}: {r.error}",
199
212
  binding_id=r.binding_id, error=r.error)
@@ -209,26 +222,57 @@ def resolve_and_install(force: bool = False) -> InstallResult:
209
222
 
210
223
  def _stream_install(drivers: list[dict], base: Path, cfg: dict) -> list[DriverResult]:
211
224
  """
212
- Submit ALL downloads (all levels) simultaneously, then install level-by-level.
213
- Each driver installs the moment its own download Future completes.
225
+ Download every driver in parallel, then install in the fewest passes possible:
226
+
227
+ • all INF drivers -> ONE `pnputil /add-driver <base>\\*.inf /subdirs /install`
228
+ pass. Windows stages every package and installs the best/newest driver per
229
+ device in a single PnP reconciliation — far faster than one pnputil call
230
+ per driver, and it resolves duplicate versions itself (so "no dedup" is
231
+ handled at install time here, not by dropping packages).
232
+ • EXE-installer drivers -> run their silent installer individually (EXEs
233
+ cannot be batched).
234
+
235
+ Downloads stay fully parallel; only the install strategy changed from one
236
+ pnputil call per driver to a single batched call.
214
237
  """
215
238
  repo_url = cfg["local_repo_url"]
216
239
  parallel = cfg.get("parallel_downloads", 6)
217
240
 
218
- by_level = _dedup_and_group(drivers)
219
- executor = ThreadPoolExecutor(max_workers=parallel)
220
- future_info, level_futs = _submit_all_downloads(by_level, base, repo_url, cfg, executor)
221
-
222
- all_results: list[DriverResult] = []
223
- for level in sorted(by_level):
224
- events.progress("install",
225
- f"[install] {_level_label(level)} ({len(level_futs[level])} driver(s))…",
226
- category=_level_label(level), count=len(level_futs[level]))
227
- for fut in as_completed(level_futs[level]):
228
- all_results.append(_process_completed(fut, future_info, cfg))
229
-
230
- executor.shutdown(wait=False)
231
- return all_results
241
+ by_level = _dedup_and_group(drivers)
242
+ ordered = [d for level in sorted(by_level) for d in by_level[level]]
243
+
244
+ events.progress("install",
245
+ f"[install] Downloading {len(ordered)} driver(s) in parallel…",
246
+ total=len(ordered))
247
+
248
+ results: list[DriverResult] = []
249
+ downloaded: list[tuple[dict, Path]] = []
250
+ with ThreadPoolExecutor(max_workers=parallel) as executor:
251
+ fut_map = {executor.submit(_download_driver, d, base, repo_url, cfg): d
252
+ for d in ordered}
253
+ for fut in as_completed(fut_map):
254
+ driver = fut_map[fut]
255
+ bid = driver.get("binding_id") or driver.get("package_id", "")
256
+ try:
257
+ dest, dl_ok = fut.result()
258
+ except Exception as e:
259
+ results.append(DriverResult(binding_id=bid, success=False, error=str(e)))
260
+ continue
261
+ if dl_ok:
262
+ downloaded.append((driver, dest))
263
+ else:
264
+ results.append(DriverResult(binding_id=bid, success=False,
265
+ error="download_failed"))
266
+
267
+ inf_drivers = [(d, p) for d, p in downloaded
268
+ if d.get("install_type", "inf_silent") != "exe_silent"]
269
+ exe_drivers = [(d, p) for d, p in downloaded
270
+ if d.get("install_type", "inf_silent") == "exe_silent"]
271
+
272
+ results += _batch_install_infs(inf_drivers, base, cfg)
273
+ for driver, dest in exe_drivers:
274
+ results.append(_install_one_exe(driver, dest, cfg))
275
+ return results
232
276
 
233
277
 
234
278
  def _dedup_and_group(drivers: list[dict]) -> dict[int, list[dict]]:
@@ -244,50 +288,78 @@ def _dedup_and_group(drivers: list[dict]) -> dict[int, list[dict]]:
244
288
  return by_level
245
289
 
246
290
 
247
- def _submit_all_downloads(
248
- by_level: dict[int, list[dict]],
249
- base: Path,
250
- repo_url: str,
251
- cfg: dict,
252
- executor: ThreadPoolExecutor,
253
- ) -> tuple[dict[Future, tuple[int, dict]], dict[int, list[Future]]]:
254
- """Submit every driver download to the pool at once. Returns lookup maps."""
255
- future_info: dict[Future, tuple[int, dict]] = {}
256
- level_futs: dict[int, list[Future]] = defaultdict(list)
257
- for level in sorted(by_level):
258
- for driver in by_level[level]:
259
- fut = executor.submit(_download_driver, driver, base, repo_url, cfg)
260
- future_info[fut] = (level, driver)
261
- level_futs[level].append(fut)
262
- return future_info, level_futs
263
-
264
-
265
- def _process_completed(
266
- fut: Future,
267
- future_info: dict[Future, tuple[int, dict]],
268
- cfg: dict,
269
- ) -> DriverResult:
270
- """Handle one completed download Future: install if ok, return DriverResult."""
271
- _, driver = future_info[fut]
272
- bid = driver.get("binding_id", "")
273
- cat = driver.get("category", "other")
274
-
291
+ def _batch_install_infs(inf_drivers: list[tuple[dict, Path]], base: Path,
292
+ cfg: dict) -> list[DriverResult]:
293
+ """Install every downloaded INF driver in ONE pnputil pass.
294
+
295
+ `pnputil /add-driver <base>\\*.inf /subdirs /install` recurses the staging tree
296
+ (each driver lives in its own subdir), adds every package to the driver store
297
+ and installs the best match onto each device in a single PnP reconciliation —
298
+ the fast path the old driverserver used (launcher.bat). pnputil does not report
299
+ a clean per-package status, so the batch's outcome is attributed to each driver
300
+ and pnputil's own output lines are surfaced so nothing is hidden by batching."""
301
+ if not inf_drivers:
302
+ return []
303
+
304
+ n = len(inf_drivers)
305
+ events.progress("install",
306
+ f"[install] Installing {n} INF driver(s) in one batched pnputil pass…",
307
+ total=n)
308
+ t0 = time.monotonic()
309
+ stdout = ""
275
310
  try:
276
- dest, dl_ok = fut.result()
311
+ r = proc.run(
312
+ ["pnputil", "/add-driver", str(base / "*.inf"), "/subdirs", "/install"],
313
+ capture_output=True, text=True,
314
+ timeout=cfg.get("timeout_install", 600),
315
+ )
316
+ stdout = r.stdout or ""
317
+ ok, err, reboot = _classify(r.returncode, r.stdout, r.stderr)
318
+ except subprocess.TimeoutExpired:
319
+ ok, err, reboot = False, "Batch install timed out", False
277
320
  except Exception as e:
278
- return DriverResult(binding_id=bid, success=False, error=str(e))
321
+ ok, err, reboot = False, str(e), False
322
+ dur = int((time.monotonic() - t0) * 1000)
279
323
 
280
- if not dl_ok:
281
- return DriverResult(binding_id=bid, success=False, error="download_failed")
324
+ for line in stdout.splitlines():
325
+ s = line.strip()
326
+ if s:
327
+ events.progress("install", f" {s}")
282
328
 
283
- t0 = time.monotonic()
284
- ok, err, reboot = _install_driver(driver, dest, cfg)
285
- dur = int((time.monotonic() - t0) * 1000)
286
329
  tag = " (reboot required)" if reboot else ""
287
- line = f" {'✓' if ok else '✗'} [{cat}] {bid} ({dur}ms){tag}"
288
- emit = events.ok if ok else events.error
289
- emit("install", line, binding_id=bid, category=cat, duration_ms=dur,
290
- error=err, reboot_required=reboot)
330
+ (events.ok if ok else events.error)(
331
+ "install",
332
+ f" {'✓' if ok else '✗'} batch-installed {n} INF driver(s) ({dur}ms){tag}",
333
+ count=n, duration_ms=dur, reboot_required=reboot, error=err)
334
+
335
+ return [
336
+ DriverResult(binding_id=(d.get("binding_id") or d.get("package_id", "")),
337
+ success=ok, error="" if ok else err,
338
+ duration_ms=dur, reboot_required=reboot)
339
+ for d, _ in inf_drivers
340
+ ]
341
+
342
+
343
+ def _install_one_exe(driver: dict, driver_dir: Path, cfg: dict) -> DriverResult:
344
+ """Run one EXE-installer driver's silent setup (EXEs cannot be batched)."""
345
+ bid = driver.get("binding_id") or driver.get("package_id", "")
346
+ t0 = time.monotonic()
347
+ try:
348
+ exe_files = list(driver_dir.glob("*.exe"))
349
+ if not exe_files:
350
+ return DriverResult(binding_id=bid, success=False, error="No EXE file found")
351
+ flags = driver.get("install_flags", "/S")
352
+ r = proc.run([str(exe_files[0])] + flags.split(),
353
+ capture_output=True, timeout=cfg["timeout_install"])
354
+ ok, err, reboot = _classify(r.returncode)
355
+ except subprocess.TimeoutExpired:
356
+ ok, err, reboot = False, "Install timed out", False
357
+ except Exception as e:
358
+ ok, err, reboot = False, str(e), False
359
+ dur = int((time.monotonic() - t0) * 1000)
360
+ (events.ok if ok else events.error)(
361
+ "install", f" {'✓' if ok else '✗'} [exe] {bid} ({dur}ms)",
362
+ binding_id=bid, duration_ms=dur, error=err, reboot_required=reboot)
291
363
  return DriverResult(binding_id=bid, success=ok, error=err,
292
364
  duration_ms=dur, reboot_required=reboot)
293
365
 
@@ -311,12 +383,6 @@ def _download_driver(driver: dict, base: Path, repo_url: str,
311
383
  return dest, ok
312
384
 
313
385
 
314
- def _level_label(level: int) -> str:
315
- labels = {0: "chipset", 1: "firmware", 2: "storage",
316
- 3: "network", 5: "display", 6: "audio", 10: "other"}
317
- return labels.get(level, f"level-{level}")
318
-
319
-
320
386
  # ── Driver installation ─────────────────────────────────────────────────────────
321
387
 
322
388
  def _classify(returncode: int, stdout: str = "", stderr: str = "") -> tuple[bool, str, bool]:
@@ -352,52 +418,6 @@ def _classify(returncode: int, stdout: str = "", stderr: str = "") -> tuple[bool
352
418
  return False, (stderr.strip() or stdout.strip() or f"exit {returncode}")[:200], False
353
419
 
354
420
 
355
- def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, str, bool]:
356
- install_type = driver.get("install_type", "inf_silent")
357
-
358
- try:
359
- if install_type == "inf_silent":
360
- inf_files = list(driver_dir.glob("*.inf"))
361
- if not inf_files:
362
- return False, "No INF file found", False
363
- r = proc.run(
364
- ["pnputil", "/add-driver", str(inf_files[0]), "/install", "/subdirs"],
365
- capture_output=True, text=True,
366
- timeout=cfg["timeout_long"],
367
- )
368
- return _classify(r.returncode, r.stdout, r.stderr)
369
-
370
- if install_type == "exe_silent":
371
- exe_files = list(driver_dir.glob("*.exe"))
372
- if not exe_files:
373
- return False, "No EXE file found", False
374
- flags = driver.get("install_flags", "/S")
375
- r = proc.run(
376
- [str(exe_files[0])] + flags.split(),
377
- capture_output=True,
378
- timeout=cfg["timeout_install"],
379
- )
380
- return _classify(r.returncode)
381
-
382
- if install_type == "firmware_flash":
383
- inf_files = list(driver_dir.glob("*.inf"))
384
- if not inf_files:
385
- return False, "No firmware INF", False
386
- r = proc.run(
387
- ["pnputil", "/add-driver", str(inf_files[0]), "/install"],
388
- capture_output=True, text=True,
389
- timeout=cfg["timeout_long"],
390
- )
391
- return _classify(r.returncode, r.stdout, r.stderr)
392
-
393
- except subprocess.TimeoutExpired:
394
- return False, "Install timed out", False
395
- except Exception as e:
396
- return False, str(e), False
397
-
398
- return False, f"Unknown install_type: {install_type}", False
399
-
400
-
401
421
  # ── Confirm ─────────────────────────────────────────────────────────────────────
402
422
 
403
423
  def _confirm(trace_id: str, ok: list[DriverResult], fail: list[DriverResult],
File without changes
File without changes