driverclient 0.2.25__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.25 → driverclient-0.2.26}/PKG-INFO +1 -1
  2. {driverclient-0.2.25 → driverclient-0.2.26}/pyproject.toml +1 -1
  3. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/__init__.py +1 -1
  4. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/ops/automate.py +1 -6
  5. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/ops/install.py +63 -18
  6. {driverclient-0.2.25 → driverclient-0.2.26}/.gitignore +0 -0
  7. {driverclient-0.2.25 → driverclient-0.2.26}/README.md +0 -0
  8. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/__main__.py +0 -0
  9. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/config.json +0 -0
  10. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/config.py +0 -0
  11. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/core/__init__.py +0 -0
  12. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/core/hardware.py +0 -0
  13. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/core/http.py +0 -0
  14. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/core/hwid.py +0 -0
  15. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/core/proc.py +0 -0
  16. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/events.py +0 -0
  17. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/main.py +0 -0
  18. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/ops/__init__.py +0 -0
  19. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/ops/capture.py +0 -0
  20. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/ops/diagnose.py +0 -0
  21. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/ops/download.py +0 -0
  22. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/ops/offline.py +0 -0
  23. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/ops/resolve.py +0 -0
  24. {driverclient-0.2.25 → driverclient-0.2.26}/src/driverclient/ops/scan.py +0 -0
  25. {driverclient-0.2.25 → driverclient-0.2.26}/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.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.25"
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.25"
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
@@ -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