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.
- {driverclient-0.2.24 → driverclient-0.2.26}/.gitignore +2 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/PKG-INFO +1 -1
- {driverclient-0.2.24 → driverclient-0.2.26}/pyproject.toml +1 -1
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/__init__.py +1 -1
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/automate.py +1 -6
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/install.py +127 -6
- {driverclient-0.2.24 → driverclient-0.2.26}/README.md +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/__main__.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/config.json +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/config.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/core/__init__.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/core/hardware.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/core/http.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/core/hwid.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/core/proc.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/events.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/main.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/__init__.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/capture.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/diagnose.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/download.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/offline.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/resolve.py +0 -0
- {driverclient-0.2.24 → driverclient-0.2.26}/src/driverclient/ops/scan.py +0 -0
- {driverclient-0.2.24 → 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.
|
|
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.
|
|
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"
|
|
@@ -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 = [
|
|
172
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|