driverclient 0.2.0__tar.gz → 0.2.2__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 (20) hide show
  1. {driverclient-0.2.0 → driverclient-0.2.2}/PKG-INFO +27 -1
  2. {driverclient-0.2.0 → driverclient-0.2.2}/README.md +26 -0
  3. {driverclient-0.2.0 → driverclient-0.2.2}/pyproject.toml +1 -1
  4. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/__init__.py +1 -1
  5. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/automate.py +19 -0
  6. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/capture.py +27 -5
  7. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/install.py +58 -20
  8. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/scan.py +62 -0
  9. {driverclient-0.2.0 → driverclient-0.2.2}/.gitignore +0 -0
  10. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/__main__.py +0 -0
  11. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/config.json +0 -0
  12. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/config.py +0 -0
  13. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/core/__init__.py +0 -0
  14. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/core/hardware.py +0 -0
  15. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/core/http.py +0 -0
  16. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/events.py +0 -0
  17. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/main.py +0 -0
  18. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/__init__.py +0 -0
  19. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/resolve.py +0 -0
  20. {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driverclient
3
- Version: 0.2.0
3
+ Version: 0.2.2
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>
@@ -110,6 +110,32 @@ Each `ClientEvent` (frozen dataclass, `ev.to_dict()` for transport) has:
110
110
  `phase` and `status` are a fixed, documented vocabulary — this is the interface
111
111
  a GUI binds to; treat changes to it as an API change.
112
112
 
113
+ #### Reboots (the client does not reboot — the caller decides)
114
+
115
+ The client is **stateless and single-shot**: it installs/captures in one pass and
116
+ returns. It never reboots and keeps no state across reboots. When a driver
117
+ installs but needs a reboot to take effect (installer exit code `3010`/`1641`),
118
+ that is reported as **success with a reboot flag**, not a failure:
119
+
120
+ - `InstallResult.reboot_required` / `AutomateResult.reboot_required` → `bool`
121
+ - the per-driver `install` event carries `data["reboot_required"]`
122
+
123
+ The embedding app owns the reboot + resume loop. Typical convergence:
124
+
125
+ ```python
126
+ result = client.run("automate", on_event=cb)
127
+ if result.reboot_required:
128
+ persist_state(); schedule_resume(); reboot() # then run "automate" again
129
+ elif not result.made_progress:
130
+ proceed_to_next_step() # nothing new + no reboot = converged
131
+ ```
132
+
133
+ `AutomateResult.made_progress` is `True` when the pass installed or captured
134
+ anything, so `not made_progress and not reboot_required` means the machine has
135
+ converged. Drivers for devices that only appear *after* a reboot are picked up on
136
+ the next pass; run a `capture-missing` / `wu-full` pass post-reboot to catch
137
+ packages Windows Update only stages during the reboot.
138
+
113
139
  > Events are emitted on whatever thread the op is running on — and the parallel
114
140
  > download/export/upload pools mean some events arrive on **worker threads**. The
115
141
  > Qt pattern below (queued cross-thread signals) is safe regardless.
@@ -94,6 +94,32 @@ Each `ClientEvent` (frozen dataclass, `ev.to_dict()` for transport) has:
94
94
  `phase` and `status` are a fixed, documented vocabulary — this is the interface
95
95
  a GUI binds to; treat changes to it as an API change.
96
96
 
97
+ #### Reboots (the client does not reboot — the caller decides)
98
+
99
+ The client is **stateless and single-shot**: it installs/captures in one pass and
100
+ returns. It never reboots and keeps no state across reboots. When a driver
101
+ installs but needs a reboot to take effect (installer exit code `3010`/`1641`),
102
+ that is reported as **success with a reboot flag**, not a failure:
103
+
104
+ - `InstallResult.reboot_required` / `AutomateResult.reboot_required` → `bool`
105
+ - the per-driver `install` event carries `data["reboot_required"]`
106
+
107
+ The embedding app owns the reboot + resume loop. Typical convergence:
108
+
109
+ ```python
110
+ result = client.run("automate", on_event=cb)
111
+ if result.reboot_required:
112
+ persist_state(); schedule_resume(); reboot() # then run "automate" again
113
+ elif not result.made_progress:
114
+ proceed_to_next_step() # nothing new + no reboot = converged
115
+ ```
116
+
117
+ `AutomateResult.made_progress` is `True` when the pass installed or captured
118
+ anything, so `not made_progress and not reboot_required` means the machine has
119
+ converged. Drivers for devices that only appear *after* a reboot are picked up on
120
+ the next pass; run a `capture-missing` / `wu-full` pass post-reboot to catch
121
+ packages Windows Update only stages during the reboot.
122
+
97
123
  > Events are emitted on whatever thread the op is running on — and the parallel
98
124
  > download/export/upload pools mean some events arrive on **worker threads**. The
99
125
  > Qt pattern below (queued cross-thread signals) is safe regardless.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "driverclient"
7
- version = "0.2.0"
7
+ version = "0.2.2"
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.0"
28
+ __version__ = "0.2.2"
29
29
 
30
30
 
31
31
  class DriverClient:
@@ -32,6 +32,22 @@ class AutomateResult:
32
32
  wu: CaptureResult | None = None
33
33
  duration_s: int = 0
34
34
 
35
+ @property
36
+ def reboot_required(self) -> bool:
37
+ """True if this pass installed a driver that needs a reboot to take effect."""
38
+ return bool(self.install and self.install.reboot_required)
39
+
40
+ @property
41
+ def made_progress(self) -> bool:
42
+ """
43
+ True if this pass installed and/or captured anything. When this is
44
+ False and reboot_required is False, the machine has converged — the
45
+ caller can stop looping and move to the next step.
46
+ """
47
+ installed = self.install.ok_count if self.install else 0
48
+ captured = self.wu.submitted if self.wu else 0
49
+ return bool(installed or captured)
50
+
35
51
 
36
52
  def run_automation() -> AutomateResult:
37
53
  cfg = get()
@@ -106,6 +122,9 @@ def _summary(r: AutomateResult) -> None:
106
122
  events.done("done", f" Duration : {r.duration_s}s", duration_s=r.duration_s)
107
123
  events.done("done", f" Installed: {inst.ok_count} ok {inst.fail_count} failed",
108
124
  installed_ok=inst.ok_count, installed_failed=inst.fail_count)
125
+ if inst.reboot_required:
126
+ events.done("done", " Reboot : REQUIRED (a driver needs a reboot to take effect)",
127
+ reboot_required=True)
109
128
  if r.wu and r.wu.wu_triggered:
110
129
  events.done("done",
111
130
  f" WU : {wu.submitted} captured {wu.still_missing} still missing",
@@ -345,15 +345,36 @@ def _enumerate_packages_since(cutoff: datetime, cfg: dict) -> list[dict]:
345
345
  return result
346
346
 
347
347
 
348
+ def _norm_hwid(hwid: str) -> str:
349
+ """Normalize an HWID for comparison: uppercase and strip the &REV_* tail.
350
+
351
+ The repo's `not_found` list carries the stripped HWID variants
352
+ (e.g. PCI\\VEN_8086&DEV_9D15&SUBSYS_222C17AA) while the IDs reported by the
353
+ live machine — both `installed_versions` keys and pnputil /enum-devices —
354
+ carry a &REV_xx suffix. Comparing the two forms verbatim never matches, so
355
+ both sides are normalized to the same REV-less, uppercase form.
356
+ """
357
+ up = hwid.upper()
358
+ idx = up.find("&REV_")
359
+ return up[:idx] if idx != -1 else up
360
+
361
+
348
362
  def _find_installed_packages_for_hwids(
349
363
  not_found_hwids: set[str],
350
364
  scan_result: ScanResult,
351
365
  cfg: dict,
352
366
  ) -> list[dict]:
353
- locally_installed = {
354
- hwid for hwid in not_found_hwids
355
- if hwid in scan_result.installed_versions
356
- }
367
+ # A missing HWID is capturable when it belongs to a scanned device that
368
+ # already has a driver bound. Match on the device's own HWID list (which
369
+ # includes every variant) using normalized HWIDs, rather than exact
370
+ # membership in installed_versions whose keys are the full &REV_xx form.
371
+ not_found_norm = {_norm_hwid(h) for h in not_found_hwids}
372
+ locally_installed: set[str] = set()
373
+ for dev in scan_result.devices:
374
+ if not dev.has_driver:
375
+ continue
376
+ if any(_norm_hwid(h) in not_found_norm for h in dev.hwids):
377
+ locally_installed.update(h.upper() for h in dev.hwids)
357
378
  if not locally_installed:
358
379
  return []
359
380
 
@@ -395,8 +416,9 @@ def _map_hwids_to_published(hwids: set[str], cfg: dict) -> dict[str, str]:
395
416
  def _commit_hwid_block(state: dict, hwids: set[str], mapping: dict) -> None:
396
417
  """Flush accumulated HWIDs for the current device into mapping."""
397
418
  if state["published"]:
419
+ targets = {_norm_hwid(h) for h in hwids}
398
420
  for h in state["hwids"]:
399
- if h in hwids:
421
+ if _norm_hwid(h) in targets:
400
422
  mapping[h] = state["published"]
401
423
  state["published"] = ""
402
424
  state["hwids"] = []
@@ -33,13 +33,24 @@ _CATEGORY_ORDER: dict[str, int] = {
33
33
  "network": 3, "display": 5, "audio": 6, "other": 10,
34
34
  }
35
35
 
36
+ # Installer exit codes that mean "succeeded, but a reboot is required".
37
+ # 3010 = ERROR_SUCCESS_REBOOT_REQUIRED (pnputil / MSI / most installers)
38
+ # 1641 = ERROR_SUCCESS_REBOOT_INITIATED
39
+ _REBOOT_CODES: frozenset[int] = frozenset({3010, 1641})
40
+
41
+ # Installer exit codes that mean "succeeded, nothing to do" (NOT a failure).
42
+ # 259 = ERROR_NO_MORE_ITEMS — pnputil added the package but no device needed
43
+ # updating (driver already present / "Already exists in the system").
44
+ _SUCCESS_NO_CHANGE_CODES: frozenset[int] = frozenset({259})
45
+
36
46
 
37
47
  @dataclass
38
48
  class DriverResult:
39
- binding_id: str
40
- success: bool
41
- error: str = ""
42
- duration_ms: int = 0
49
+ binding_id: str
50
+ success: bool
51
+ error: str = ""
52
+ duration_ms: int = 0
53
+ reboot_required: bool = False # installed OK but needs a reboot (pnputil 3010)
43
54
 
44
55
 
45
56
  @dataclass
@@ -53,6 +64,11 @@ class InstallResult:
53
64
  @property
54
65
  def fail_count(self) -> int: return len(self.install_failed)
55
66
 
67
+ @property
68
+ def reboot_required(self) -> bool:
69
+ """True if any successfully-installed driver reported reboot-required."""
70
+ return any(r.reboot_required for r in self.installed_ok)
71
+
56
72
 
57
73
  def resolve_and_install(force: bool = False) -> InstallResult:
58
74
  """
@@ -83,9 +99,12 @@ def resolve_and_install(force: bool = False) -> InstallResult:
83
99
 
84
100
  _confirm(result.trace_id, installed_ok, install_failed, cfg)
85
101
 
102
+ reboot_required = any(r.reboot_required for r in installed_ok)
103
+ reboot_tag = " — reboot required" if reboot_required else ""
86
104
  events.done("install",
87
- f"[install] Done — {len(installed_ok)} ok {len(install_failed)} failed",
88
- total=len(all_results), ok=len(installed_ok), failed=len(install_failed))
105
+ f"[install] Done — {len(installed_ok)} ok {len(install_failed)} failed{reboot_tag}",
106
+ total=len(all_results), ok=len(installed_ok), failed=len(install_failed),
107
+ reboot_required=reboot_required)
89
108
  for r in install_failed:
90
109
  events.error("install", f" ✗ {r.binding_id}: {r.error}",
91
110
  binding_id=r.binding_id, error=r.error)
@@ -173,12 +192,15 @@ def _process_completed(
173
192
  return DriverResult(binding_id=bid, success=False, error="download_failed")
174
193
 
175
194
  t0 = time.monotonic()
176
- ok, err = _install_driver(driver, dest, cfg)
195
+ ok, err, reboot = _install_driver(driver, dest, cfg)
177
196
  dur = int((time.monotonic() - t0) * 1000)
178
- line = f" {'✓' if ok else '✗'} [{cat}] {bid} ({dur}ms)"
197
+ tag = " (reboot required)" if reboot else ""
198
+ line = f" {'✓' if ok else '✗'} [{cat}] {bid} ({dur}ms){tag}"
179
199
  emit = events.ok if ok else events.error
180
- emit("install", line, binding_id=bid, category=cat, duration_ms=dur, error=err)
181
- return DriverResult(binding_id=bid, success=ok, error=err, duration_ms=dur)
200
+ emit("install", line, binding_id=bid, category=cat, duration_ms=dur,
201
+ error=err, reboot_required=reboot)
202
+ return DriverResult(binding_id=bid, success=ok, error=err,
203
+ duration_ms=dur, reboot_required=reboot)
182
204
 
183
205
 
184
206
  def _download_driver(driver: dict, base: Path, repo_url: str,
@@ -208,50 +230,66 @@ def _level_label(level: int) -> str:
208
230
 
209
231
  # ── Driver installation ─────────────────────────────────────────────────────────
210
232
 
211
- def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, str]:
233
+ def _classify(returncode: int, err_text: str = "") -> tuple[bool, str, bool]:
234
+ """
235
+ Map an installer exit code to (success, error, reboot_required).
236
+
237
+ 0 -> success, no reboot
238
+ 259 -> success, no reboot (package already present / no change)
239
+ 3010 / 1641 -> success, reboot required (NOT a failure)
240
+ anything else -> failure, carry err_text
241
+ """
242
+ if returncode == 0 or returncode in _SUCCESS_NO_CHANGE_CODES:
243
+ return True, "", False
244
+ if returncode in _REBOOT_CODES:
245
+ return True, "", True
246
+ return False, err_text, False
247
+
248
+
249
+ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, str, bool]:
212
250
  install_type = driver.get("install_type", "inf_silent")
213
251
 
214
252
  try:
215
253
  if install_type == "inf_silent":
216
254
  inf_files = list(driver_dir.glob("*.inf"))
217
255
  if not inf_files:
218
- return False, "No INF file found"
256
+ return False, "No INF file found", False
219
257
  r = subprocess.run(
220
258
  ["pnputil", "/add-driver", str(inf_files[0]), "/install", "/subdirs"],
221
259
  capture_output=True, text=True,
222
260
  timeout=cfg["timeout_long"],
223
261
  )
224
- return r.returncode == 0, r.stdout[:200] if r.returncode != 0 else ""
262
+ return _classify(r.returncode, r.stdout[:200])
225
263
 
226
264
  if install_type == "exe_silent":
227
265
  exe_files = list(driver_dir.glob("*.exe"))
228
266
  if not exe_files:
229
- return False, "No EXE file found"
267
+ return False, "No EXE file found", False
230
268
  flags = driver.get("install_flags", "/S")
231
269
  r = subprocess.run(
232
270
  [str(exe_files[0])] + flags.split(),
233
271
  capture_output=True,
234
272
  timeout=cfg["timeout_install"],
235
273
  )
236
- return r.returncode == 0, ""
274
+ return _classify(r.returncode)
237
275
 
238
276
  if install_type == "firmware_flash":
239
277
  inf_files = list(driver_dir.glob("*.inf"))
240
278
  if not inf_files:
241
- return False, "No firmware INF"
279
+ return False, "No firmware INF", False
242
280
  r = subprocess.run(
243
281
  ["pnputil", "/add-driver", str(inf_files[0]), "/install"],
244
282
  capture_output=True, text=True,
245
283
  timeout=cfg["timeout_long"],
246
284
  )
247
- return r.returncode == 0, ""
285
+ return _classify(r.returncode)
248
286
 
249
287
  except subprocess.TimeoutExpired:
250
- return False, "Install timed out"
288
+ return False, "Install timed out", False
251
289
  except Exception as e:
252
- return False, str(e)
290
+ return False, str(e), False
253
291
 
254
- return False, f"Unknown install_type: {install_type}"
292
+ return False, f"Unknown install_type: {install_type}", False
255
293
 
256
294
 
257
295
  # ── Confirm ─────────────────────────────────────────────────────────────────────
@@ -9,6 +9,7 @@ Public API:
9
9
  scan(force=False) -> ScanResult
10
10
  """
11
11
  import json
12
+ import re
12
13
  import subprocess
13
14
  from concurrent.futures import ThreadPoolExecutor
14
15
  from dataclasses import asdict, dataclass, field
@@ -62,6 +63,54 @@ class ScanResult:
62
63
  return [d for d in self.devices if d.has_driver]
63
64
 
64
65
 
66
+ _ACPI_VENDEV = re.compile(r"^ACPI\\VEN_([^&]+)&DEV_(.+)$")
67
+
68
+
69
+ def _canon_hwid(hwid: str) -> str:
70
+ """Fold the two HWID forms Windows reports for one device into one key.
71
+
72
+ `installed_versions` comes from Win32_PnPSignedDriver.HardWareID — the
73
+ canonical VEN_/DEV_ form with a &REV_xx tail, and ACPI IDs written as
74
+ ACPI\\VEN_INT&DEV_3394. The HWIDs sent to /resolve come from pnputil
75
+ /enum-devices — raw form, no &REV, ACPI as ACPI\\INT3394. Both collapse to
76
+ one comparable key: uppercase, &REV_* dropped, ACPI VEN_/DEV_ concatenated.
77
+ """
78
+ up = hwid.upper()
79
+ idx = up.find("&REV_")
80
+ if idx != -1:
81
+ up = up[:idx]
82
+ m = _ACPI_VENDEV.match(up)
83
+ if m:
84
+ up = f"ACPI\\{m.group(1)}{m.group(2)}"
85
+ return up
86
+
87
+
88
+ def _installed_prefix_index(installed_versions: dict[str, str]) -> dict[str, str]:
89
+ """Expand each canonical installed HWID into its &-boundary prefixes.
90
+
91
+ The installed key is the most-specific device ID
92
+ (PCI\\VEN_8086&DEV_9D15&SUBSYS_222C17AA&REV_F1) while /resolve frequently
93
+ matches on a shorter variant (PCI\\VEN_8086&DEV_9D15). Emitting every
94
+ prefix that still contains &DEV_ lets the shorter form find the version.
95
+ The bare bus/vendor root (PCI\\VEN_8086) is skipped so an over-generic ID
96
+ can't be mislabelled as already-current.
97
+ """
98
+ idx: dict[str, str] = {}
99
+ for key, ver in installed_versions.items():
100
+ canon = _canon_hwid(key)
101
+ parts = canon.split("&")
102
+ acc = parts[0]
103
+ prefixes = [acc]
104
+ for p in parts[1:]:
105
+ acc = f"{acc}&{p}"
106
+ prefixes.append(acc)
107
+ for pref in prefixes:
108
+ if "&" in canon and "&DEV_" not in pref:
109
+ continue # skip bare bus/vendor root
110
+ idx.setdefault(pref, ver)
111
+ return idx
112
+
113
+
65
114
  def scan(force: bool = False) -> ScanResult:
66
115
  """
67
116
  Run a full hardware scan. Loads from ds_scan.json cache if fresh
@@ -91,6 +140,19 @@ def scan(force: bool = False) -> ScanResult:
91
140
  total=len(all_hwids))
92
141
  installed_versions = get_installed_versions(all_hwids)
93
142
 
143
+ # Re-key installed_versions onto the raw HWID strings the client sends to
144
+ # /resolve. Win32_PnPSignedDriver reports the canonical VEN_/DEV_+REV form,
145
+ # which never matches the raw pnputil HWIDs verbatim — so without this the
146
+ # server's already-current check (and Device.installed_version below) miss
147
+ # every time and every repo-covered driver shows up as "to install".
148
+ if installed_versions:
149
+ prefix_idx = _installed_prefix_index(installed_versions)
150
+ for h in all_hwids:
151
+ if h not in installed_versions:
152
+ ver = prefix_idx.get(_canon_hwid(h))
153
+ if ver:
154
+ installed_versions[h] = ver
155
+
94
156
  for dev in devices:
95
157
  ver = next(
96
158
  (installed_versions[h] for h in dev.hwids if h in installed_versions),
File without changes