driverclient 0.2.25__tar.gz → 0.2.27__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 (25) hide show
  1. {driverclient-0.2.25 → driverclient-0.2.27}/PKG-INFO +1 -1
  2. {driverclient-0.2.25 → driverclient-0.2.27}/pyproject.toml +1 -1
  3. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/__init__.py +1 -1
  4. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/ops/automate.py +1 -6
  5. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/ops/capture.py +16 -3
  6. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/ops/install.py +63 -18
  7. {driverclient-0.2.25 → driverclient-0.2.27}/.gitignore +0 -0
  8. {driverclient-0.2.25 → driverclient-0.2.27}/README.md +0 -0
  9. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/__main__.py +0 -0
  10. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/config.json +0 -0
  11. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/config.py +0 -0
  12. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/core/__init__.py +0 -0
  13. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/core/hardware.py +0 -0
  14. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/core/http.py +0 -0
  15. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/core/hwid.py +0 -0
  16. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/core/proc.py +0 -0
  17. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/events.py +0 -0
  18. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/main.py +0 -0
  19. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/ops/__init__.py +0 -0
  20. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/ops/diagnose.py +0 -0
  21. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/ops/download.py +0 -0
  22. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/ops/offline.py +0 -0
  23. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/ops/resolve.py +0 -0
  24. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/ops/scan.py +0 -0
  25. {driverclient-0.2.25 → driverclient-0.2.27}/src/driverclient/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driverclient
3
- Version: 0.2.25
3
+ Version: 0.2.27
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.25"
7
+ version = "0.2.27"
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.25"
28
+ __version__ = "0.2.27"
29
29
 
30
30
 
31
31
  class DriverClient:
@@ -18,7 +18,7 @@ from datetime import datetime, timezone
18
18
  from driverclient import events
19
19
  from driverclient.config import get
20
20
  from driverclient.ops.capture import CaptureResult, classify_missing, wu_update
21
- from driverclient.ops.install import DriverResult, InstallResult, resolve_and_install
21
+ from driverclient.ops.install import DriverResult, InstallResult, _bid, resolve_and_install
22
22
  from driverclient.ops.resolve import ResolveResult, resolve
23
23
  from driverclient.ops.scan import ScanResult, scan
24
24
 
@@ -113,11 +113,6 @@ def _step(n: int, label: str) -> None:
113
113
  step=n, label=label)
114
114
 
115
115
 
116
- def _bid(driver: dict) -> str:
117
- """Stable identity for a repo driver entry (matches install's dedup key)."""
118
- return driver.get("binding_id") or driver.get("package_id", "")
119
-
120
-
121
116
  def _merge_install(combined: InstallResult, inst: InstallResult) -> None:
122
117
  """Fold one pass's results into the running total, deduped by binding_id so a
123
118
  same-boot re-attempt of an already-recorded driver is not double-counted; a
@@ -344,11 +344,22 @@ def wu_full() -> CaptureResult:
344
344
 
345
345
  # ══ Missing-HWID classification ═══════════════════════════════════════════════
346
346
 
347
+ _INBOX_INADEQUATE_CLASSES = {"display adapters"}
348
+
349
+
347
350
  def classify_missing(not_found: list[str], scan_result: ScanResult) -> dict[str, list[str]]:
348
351
  """Bucket repo-missing HWIDs by what the machine says about their device:
349
352
  inbox (Windows ships the driver — not applicable), oem_local (OEM driver
350
353
  installed locally — capturable), driverless (genuinely missing — WU
351
- candidate), unknown (HWID not in scan)."""
354
+ candidate), unknown (HWID not in scan).
355
+
356
+ Display adapters are excluded from the inbox bucket: Microsoft Basic
357
+ Display Adapter is Windows' inbox fallback for a GPU whose real driver
358
+ was never bound, and it leaves the device non-accelerated — unlike other
359
+ inbox drivers (HID, mass storage, ...) it is not adequate coverage. Such
360
+ a device is treated as driverless so it still reaches WU / gets captured
361
+ once a real driver shows up, instead of being silently written off.
362
+ """
352
363
  hwid_to_device: dict[str, Device] = {}
353
364
  for dev in scan_result.devices:
354
365
  for h in dev.hwids:
@@ -363,8 +374,10 @@ def classify_missing(not_found: list[str], scan_result: ScanResult) -> dict[str,
363
374
  buckets["unknown"].append(hwid)
364
375
  elif not dev.has_driver:
365
376
  buckets["driverless"].append(hwid)
366
- elif dev.is_inbox:
377
+ elif dev.is_inbox and dev.class_name.lower() not in _INBOX_INADEQUATE_CLASSES:
367
378
  buckets["inbox"].append(hwid)
379
+ elif dev.is_inbox:
380
+ buckets["driverless"].append(hwid)
368
381
  else:
369
382
  buckets["oem_local"].append(hwid)
370
383
  return buckets
@@ -1070,4 +1083,4 @@ def _upload_worker(upload_q: queue.Queue, counts: dict,
1070
1083
  # Extension selection was removed: capture now packs every file
1071
1084
  # pnputil /export-driver produces (see _do_export), so the dump_extensions /
1072
1085
  # dump_extensions_overrides config keys are no longer consulted — filtering by
1073
- # extension is what caused incomplete packages (broken link D).
1086
+ # extension is what caused incomplete packages (broken link D).
@@ -64,6 +64,12 @@ _PNPUTIL_SUCCESS_MARKERS: tuple[str, ...] = (
64
64
  )
65
65
 
66
66
 
67
+ def _bid(driver: dict) -> str:
68
+ """Stable identity for a repo driver entry — binding_id, falling back to
69
+ package_id. Shared dedup key: also imported by ops/automate.py."""
70
+ return driver.get("binding_id") or driver.get("package_id", "")
71
+
72
+
67
73
  @dataclass
68
74
  class DriverResult:
69
75
  binding_id: str
@@ -171,8 +177,9 @@ def resolve_and_install(force: bool = False) -> InstallResult:
171
177
  drivers = result.drivers
172
178
  already_in_store: list[dict] = []
173
179
  if skip_installed:
174
- already_in_store = [d for d in drivers if _already_in_store(d, idx)]
175
- kept = [d for d in drivers if not _already_in_store(d, idx)]
180
+ kept = []
181
+ for d in drivers:
182
+ (already_in_store if _already_in_store(d, idx) else kept).append(d)
176
183
  skipped = len(already_in_store)
177
184
  if skipped:
178
185
  events.progress(
@@ -188,7 +195,7 @@ def resolve_and_install(force: bool = False) -> InstallResult:
188
195
  # now instead of leaving that gap for Windows Update to close later.
189
196
  _refresh_bound_devices(
190
197
  already_in_store,
191
- {(d.get("binding_id") or d.get("package_id", "")) for d in already_in_store},
198
+ {_bid(d) for d in already_in_store},
192
199
  cfg,
193
200
  )
194
201
  events.done("install",
@@ -220,7 +227,7 @@ def resolve_and_install(force: bool = False) -> InstallResult:
220
227
  # booted with until Windows Update eventually forces a real reinstall.
221
228
  ready_ids = (
222
229
  {r.binding_id for r in installed_ok}
223
- | {(d.get("binding_id") or d.get("package_id", "")) for d in already_in_store}
230
+ | {_bid(d) for d in already_in_store}
224
231
  )
225
232
  _refresh_bound_devices(result.drivers, ready_ids, cfg)
226
233
 
@@ -276,7 +283,7 @@ def _stream_install(drivers: list[dict], base: Path, cfg: dict) -> list[DriverRe
276
283
  for d in ordered}
277
284
  for fut in as_completed(fut_map):
278
285
  driver = fut_map[fut]
279
- bid = driver.get("binding_id") or driver.get("package_id", "")
286
+ bid = _bid(driver)
280
287
  try:
281
288
  dest, dl_ok = fut.result()
282
289
  except Exception as e:
@@ -304,7 +311,7 @@ def _dedup_and_group(drivers: list[dict]) -> dict[int, list[dict]]:
304
311
  seen: set[str] = set()
305
312
  by_level: dict[int, list[dict]] = defaultdict(list)
306
313
  for d in drivers:
307
- bid = d.get("binding_id") or d.get("package_id", "")
314
+ bid = _bid(d)
308
315
  if bid and bid not in seen:
309
316
  seen.add(bid)
310
317
  level = _CATEGORY_ORDER.get(d.get("category", "other"), 10)
@@ -357,7 +364,7 @@ def _batch_install_infs(inf_drivers: list[tuple[dict, Path]], base: Path,
357
364
  count=n, duration_ms=dur, reboot_required=reboot, error=err)
358
365
 
359
366
  return [
360
- DriverResult(binding_id=(d.get("binding_id") or d.get("package_id", "")),
367
+ DriverResult(binding_id=_bid(d),
361
368
  success=ok, error="" if ok else err,
362
369
  duration_ms=dur, reboot_required=reboot)
363
370
  for d, _ in inf_drivers
@@ -366,7 +373,7 @@ def _batch_install_infs(inf_drivers: list[tuple[dict, Path]], base: Path,
366
373
 
367
374
  def _install_one_exe(driver: dict, driver_dir: Path, cfg: dict) -> DriverResult:
368
375
  """Run one EXE-installer driver's silent setup (EXEs cannot be batched)."""
369
- bid = driver.get("binding_id") or driver.get("package_id", "")
376
+ bid = _bid(driver)
370
377
  t0 = time.monotonic()
371
378
  try:
372
379
  exe_files = list(driver_dir.glob("*.exe"))
@@ -411,7 +418,7 @@ def _refresh_bound_devices(drivers: list[dict], ready_ids: set[str], cfg: dict)
411
418
  display_hwids = {
412
419
  d["hwid"] for d in drivers
413
420
  if d.get("category") == "display" and d.get("hwid")
414
- and (d.get("binding_id") or d.get("package_id", "")) in ready_ids
421
+ and _bid(d) in ready_ids
415
422
  }
416
423
  if not display_hwids:
417
424
  return
@@ -426,23 +433,61 @@ def _refresh_bound_devices(drivers: list[dict], ready_ids: set[str], cfg: dict)
426
433
  def _refresh_device(instance_id: str, cfg: dict) -> None:
427
434
  """Disable then re-enable one device by instance ID — forces Windows to redo
428
435
  PnP install for it, so a driver already in the store but not yet bound
429
- takes effect immediately, without a reboot."""
436
+ takes effect immediately, without a reboot.
437
+
438
+ A disable that succeeds but is never followed by a working re-enable is the
439
+ worst outcome here: unlike a failed install, a `pnputil /disable-device`
440
+ persists across reboot, so the device (the primary display adapter, in the
441
+ case this exists for) does not self-heal — it silently drops to Microsoft
442
+ Basic Display until someone notices and re-enables it by hand. So the
443
+ re-enable is attempted in `finally` regardless of how disable went (an
444
+ enable-device call on an already-enabled device is a harmless no-op),
445
+ retried a few times, and a persistent failure is escalated loudly rather
446
+ than swallowed.
447
+ """
430
448
  events.progress("install",
431
449
  f"[install] Refreshing device to bind new driver: {instance_id}",
432
450
  instance_id=instance_id)
433
451
  timeout = cfg.get("timeout_short", 30)
434
452
  try:
435
- proc.run(["pnputil", "/disable-device", instance_id],
436
- capture_output=True, text=True, timeout=timeout)
437
- r = proc.run(["pnputil", "/enable-device", instance_id],
453
+ r = proc.run(["pnputil", "/disable-device", instance_id],
438
454
  capture_output=True, text=True, timeout=timeout)
439
- ok = r.returncode == 0
440
- (events.ok if ok else events.error)(
441
- "install", f" {'[ok]' if ok else '[x]'} refreshed {instance_id}",
442
- instance_id=instance_id, error="" if ok else r.stdout.strip()[:200])
455
+ if r.returncode != 0:
456
+ events.warn("install",
457
+ f" [!] disable-device returned {r.returncode} for {instance_id}",
458
+ instance_id=instance_id, error=r.stdout.strip()[:200])
443
459
  except Exception as e:
444
- events.error("install", f" [x] refresh failed for {instance_id}: {e}",
460
+ events.warn("install", f" [!] disable-device raised for {instance_id}: {e}",
445
461
  instance_id=instance_id, error=str(e))
462
+ finally:
463
+ ok, err = _enable_with_retries(instance_id, timeout)
464
+ if ok:
465
+ events.ok("install", f" [ok] refreshed {instance_id}", instance_id=instance_id)
466
+ else:
467
+ events.error(
468
+ "install",
469
+ f" [!!] CRITICAL: {instance_id} may be left DISABLED after refresh — "
470
+ f"run `pnputil /enable-device \"{instance_id}\"` manually to recover",
471
+ instance_id=instance_id, error=err, critical=True)
472
+
473
+
474
+ def _enable_with_retries(instance_id: str, timeout: int, attempts: int = 3) -> tuple[bool, str]:
475
+ """Run `pnputil /enable-device`, retrying a couple of times before giving up —
476
+ a transient PnP busy state can fail the first attempt even though the device
477
+ would enable fine a moment later. Returns (success, last_error)."""
478
+ last_err = ""
479
+ for attempt in range(1, attempts + 1):
480
+ try:
481
+ r = proc.run(["pnputil", "/enable-device", instance_id],
482
+ capture_output=True, text=True, timeout=timeout)
483
+ if r.returncode == 0:
484
+ return True, ""
485
+ last_err = r.stdout.strip()[:200]
486
+ except Exception as e:
487
+ last_err = str(e)
488
+ if attempt < attempts:
489
+ time.sleep(2)
490
+ return False, last_err
446
491
 
447
492
 
448
493
  # ── Driver installation ─────────────────────────────────────────────────────────
File without changes
File without changes