driverclient 0.2.24__tar.gz → 0.2.26__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.24 → driverclient-0.2.26}/.gitignore +2 -0
  2. {driverclient-0.2.24 → driverclient-0.2.26}/PKG-INFO +1 -1
  3. {driverclient-0.2.24 → driverclient-0.2.26}/pyproject.toml +1 -1
  4. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/__init__.py +1 -1
  5. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/automate.py +1 -6
  6. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/install.py +127 -6
  7. {driverclient-0.2.24 → driverclient-0.2.26}/README.md +0 -0
  8. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/__main__.py +0 -0
  9. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/config.json +0 -0
  10. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/config.py +0 -0
  11. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/core/__init__.py +0 -0
  12. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/core/hardware.py +0 -0
  13. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/core/http.py +0 -0
  14. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/core/hwid.py +0 -0
  15. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/core/proc.py +0 -0
  16. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/events.py +0 -0
  17. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/main.py +0 -0
  18. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/__init__.py +0 -0
  19. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/capture.py +0 -0
  20. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/diagnose.py +0 -0
  21. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/download.py +0 -0
  22. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/offline.py +0 -0
  23. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/resolve.py +0 -0
  24. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/scan.py +0 -0
  25. {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/py.typed +0 -0
@@ -61,3 +61,5 @@ deploy/bundles/
61
61
 
62
62
  # Claude Code
63
63
  .claude/
64
+ *.log
65
+ *.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driverclient
3
- Version: 0.2.24
3
+ Version: 0.2.26
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.24"
7
+ version = "0.2.26"
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.24"
28
+ __version__ = "0.2.26"
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
@@ -26,9 +26,11 @@ from pathlib import Path
26
26
  from driverclient import events
27
27
  from driverclient.config import get
28
28
  from driverclient.core import proc
29
+ from driverclient.core import hwid as hwid_mod
29
30
  from driverclient.core.http import http_post_retry
30
31
  from driverclient.ops.download import download_driver
31
32
  from driverclient.ops.resolve import ResolveResult, resolve
33
+ from driverclient.ops.scan import scan
32
34
 
33
35
  # Install order: lower number first (hardware dependency)
34
36
  _CATEGORY_ORDER: dict[str, int] = {
@@ -62,6 +64,12 @@ _PNPUTIL_SUCCESS_MARKERS: tuple[str, ...] = (
62
64
  )
63
65
 
64
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
+
65
73
  @dataclass
66
74
  class DriverResult:
67
75
  binding_id: str
@@ -167,9 +175,12 @@ def resolve_and_install(force: bool = False) -> InstallResult:
167
175
  idx: dict = _installed_inf_index() if skip_installed else {}
168
176
 
169
177
  drivers = result.drivers
178
+ already_in_store: list[dict] = []
170
179
  if skip_installed:
171
- kept = [d for d in drivers if not _already_in_store(d, idx)]
172
- skipped = len(drivers) - len(kept)
180
+ kept = []
181
+ for d in drivers:
182
+ (already_in_store if _already_in_store(d, idx) else kept).append(d)
183
+ skipped = len(already_in_store)
173
184
  if skipped:
174
185
  events.progress(
175
186
  "install",
@@ -178,6 +189,15 @@ def resolve_and_install(force: bool = False) -> InstallResult:
178
189
  )
179
190
  drivers = kept
180
191
  if not drivers:
192
+ # A display driver can be sitting in the store (e.g. staged offline
193
+ # via DISM during imaging) without ever being BOUND to the device —
194
+ # the primary adapter doesn't reliably switch itself over. Refresh it
195
+ # now instead of leaving that gap for Windows Update to close later.
196
+ _refresh_bound_devices(
197
+ already_in_store,
198
+ {_bid(d) for d in already_in_store},
199
+ cfg,
200
+ )
181
201
  events.done("install",
182
202
  "[install] Nothing to install — all resolved drivers already current",
183
203
  total=0)
@@ -201,6 +221,16 @@ def resolve_and_install(force: bool = False) -> InstallResult:
201
221
  # Skipped drivers ARE satisfied on the machine — report them upstream as success.
202
222
  _confirm(result.trace_id, installed_ok + skipped_now, install_failed, cfg)
203
223
 
224
+ # Force-bind any display driver now confirmed in the store — freshly
225
+ # installed here, or already present from an earlier offline/DISM pass —
226
+ # so the device actually switches over instead of staying on whatever it
227
+ # booted with until Windows Update eventually forces a real reinstall.
228
+ ready_ids = (
229
+ {r.binding_id for r in installed_ok}
230
+ | {_bid(d) for d in already_in_store}
231
+ )
232
+ _refresh_bound_devices(result.drivers, ready_ids, cfg)
233
+
204
234
  reboot_required = any(r.reboot_required for r in installed_ok)
205
235
  reboot_tag = " — reboot required" if reboot_required else ""
206
236
  skip_tag = f" ({len(skipped_now)} already in store)" if skipped_now else ""
@@ -253,7 +283,7 @@ def _stream_install(drivers: list[dict], base: Path, cfg: dict) -> list[DriverRe
253
283
  for d in ordered}
254
284
  for fut in as_completed(fut_map):
255
285
  driver = fut_map[fut]
256
- bid = driver.get("binding_id") or driver.get("package_id", "")
286
+ bid = _bid(driver)
257
287
  try:
258
288
  dest, dl_ok = fut.result()
259
289
  except Exception as e:
@@ -281,7 +311,7 @@ def _dedup_and_group(drivers: list[dict]) -> dict[int, list[dict]]:
281
311
  seen: set[str] = set()
282
312
  by_level: dict[int, list[dict]] = defaultdict(list)
283
313
  for d in drivers:
284
- bid = d.get("binding_id") or d.get("package_id", "")
314
+ bid = _bid(d)
285
315
  if bid and bid not in seen:
286
316
  seen.add(bid)
287
317
  level = _CATEGORY_ORDER.get(d.get("category", "other"), 10)
@@ -334,7 +364,7 @@ def _batch_install_infs(inf_drivers: list[tuple[dict, Path]], base: Path,
334
364
  count=n, duration_ms=dur, reboot_required=reboot, error=err)
335
365
 
336
366
  return [
337
- DriverResult(binding_id=(d.get("binding_id") or d.get("package_id", "")),
367
+ DriverResult(binding_id=_bid(d),
338
368
  success=ok, error="" if ok else err,
339
369
  duration_ms=dur, reboot_required=reboot)
340
370
  for d, _ in inf_drivers
@@ -343,7 +373,7 @@ def _batch_install_infs(inf_drivers: list[tuple[dict, Path]], base: Path,
343
373
 
344
374
  def _install_one_exe(driver: dict, driver_dir: Path, cfg: dict) -> DriverResult:
345
375
  """Run one EXE-installer driver's silent setup (EXEs cannot be batched)."""
346
- bid = driver.get("binding_id") or driver.get("package_id", "")
376
+ bid = _bid(driver)
347
377
  t0 = time.monotonic()
348
378
  try:
349
379
  exe_files = list(driver_dir.glob("*.exe"))
@@ -369,6 +399,97 @@ def _install_one_exe(driver: dict, driver_dir: Path, cfg: dict) -> DriverResult:
369
399
  # the offline / pre-boot path.
370
400
 
371
401
 
402
+ # ── Device refresh (force-bind display drivers) ─────────────────────────────────
403
+
404
+ def _refresh_bound_devices(drivers: list[dict], ready_ids: set[str], cfg: dict) -> None:
405
+ """Force Windows to actually bind a display driver that's confirmed in the
406
+ driver store (just installed, or already there from an earlier offline/DISM
407
+ injection) but not necessarily applied to the device yet.
408
+
409
+ pnputil only stages a package into the store — the primary display adapter
410
+ is already rendering the current session with whatever driver it booted
411
+ with and doesn't reliably re-evaluate/switch itself over on its own. Left
412
+ alone, the device stays on the fallback driver until something forces a
413
+ real reinstall — which is what Windows' own automatic Windows Update
414
+ driver search eventually does, closing the gap our own pipeline left open.
415
+ Disabling + re-enabling the device here forces Windows to redo PnP install
416
+ for it immediately, picking up the driver that's already in the store.
417
+ """
418
+ display_hwids = {
419
+ d["hwid"] for d in drivers
420
+ if d.get("category") == "display" and d.get("hwid")
421
+ and _bid(d) in ready_ids
422
+ }
423
+ if not display_hwids:
424
+ return
425
+
426
+ canon_targets = {hwid_mod.canonicalize(h) for h in display_hwids}
427
+ scan_result = scan(force=False)
428
+ for dev in scan_result.devices:
429
+ if any(hwid_mod.canonicalize(h) in canon_targets for h in dev.hwids):
430
+ _refresh_device(dev.instance_id, cfg)
431
+
432
+
433
+ def _refresh_device(instance_id: str, cfg: dict) -> None:
434
+ """Disable then re-enable one device by instance ID — forces Windows to redo
435
+ PnP install for it, so a driver already in the store but not yet bound
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
+ """
448
+ events.progress("install",
449
+ f"[install] Refreshing device to bind new driver: {instance_id}",
450
+ instance_id=instance_id)
451
+ timeout = cfg.get("timeout_short", 30)
452
+ try:
453
+ r = proc.run(["pnputil", "/disable-device", instance_id],
454
+ capture_output=True, text=True, timeout=timeout)
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])
459
+ except Exception as e:
460
+ events.warn("install", f" [!] disable-device raised for {instance_id}: {e}",
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
491
+
492
+
372
493
  # ── Driver installation ─────────────────────────────────────────────────────────
373
494
 
374
495
  def _classify(returncode: int, stdout: str = "", stderr: str = "") -> tuple[bool, str, bool]:
File without changes