driverclient 0.2.0__tar.gz → 0.2.1__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.1}/PKG-INFO +27 -1
  2. {driverclient-0.2.0 → driverclient-0.2.1}/README.md +26 -0
  3. {driverclient-0.2.0 → driverclient-0.2.1}/pyproject.toml +1 -1
  4. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/__init__.py +1 -1
  5. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/automate.py +19 -0
  6. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/install.py +52 -20
  7. {driverclient-0.2.0 → driverclient-0.2.1}/.gitignore +0 -0
  8. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/__main__.py +0 -0
  9. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/config.json +0 -0
  10. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/config.py +0 -0
  11. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/core/__init__.py +0 -0
  12. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/core/hardware.py +0 -0
  13. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/core/http.py +0 -0
  14. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/events.py +0 -0
  15. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/main.py +0 -0
  16. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/__init__.py +0 -0
  17. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/capture.py +0 -0
  18. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/resolve.py +0 -0
  19. {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/scan.py +0 -0
  20. {driverclient-0.2.0 → driverclient-0.2.1}/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.1
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.1"
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.1"
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",
@@ -33,13 +33,19 @@ _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
+
36
41
 
37
42
  @dataclass
38
43
  class DriverResult:
39
- binding_id: str
40
- success: bool
41
- error: str = ""
42
- duration_ms: int = 0
44
+ binding_id: str
45
+ success: bool
46
+ error: str = ""
47
+ duration_ms: int = 0
48
+ reboot_required: bool = False # installed OK but needs a reboot (pnputil 3010)
43
49
 
44
50
 
45
51
  @dataclass
@@ -53,6 +59,11 @@ class InstallResult:
53
59
  @property
54
60
  def fail_count(self) -> int: return len(self.install_failed)
55
61
 
62
+ @property
63
+ def reboot_required(self) -> bool:
64
+ """True if any successfully-installed driver reported reboot-required."""
65
+ return any(r.reboot_required for r in self.installed_ok)
66
+
56
67
 
57
68
  def resolve_and_install(force: bool = False) -> InstallResult:
58
69
  """
@@ -83,9 +94,12 @@ def resolve_and_install(force: bool = False) -> InstallResult:
83
94
 
84
95
  _confirm(result.trace_id, installed_ok, install_failed, cfg)
85
96
 
97
+ reboot_required = any(r.reboot_required for r in installed_ok)
98
+ reboot_tag = " — reboot required" if reboot_required else ""
86
99
  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))
100
+ f"[install] Done — {len(installed_ok)} ok {len(install_failed)} failed{reboot_tag}",
101
+ total=len(all_results), ok=len(installed_ok), failed=len(install_failed),
102
+ reboot_required=reboot_required)
89
103
  for r in install_failed:
90
104
  events.error("install", f" ✗ {r.binding_id}: {r.error}",
91
105
  binding_id=r.binding_id, error=r.error)
@@ -173,12 +187,15 @@ def _process_completed(
173
187
  return DriverResult(binding_id=bid, success=False, error="download_failed")
174
188
 
175
189
  t0 = time.monotonic()
176
- ok, err = _install_driver(driver, dest, cfg)
190
+ ok, err, reboot = _install_driver(driver, dest, cfg)
177
191
  dur = int((time.monotonic() - t0) * 1000)
178
- line = f" {'✓' if ok else '✗'} [{cat}] {bid} ({dur}ms)"
192
+ tag = " (reboot required)" if reboot else ""
193
+ line = f" {'✓' if ok else '✗'} [{cat}] {bid} ({dur}ms){tag}"
179
194
  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)
195
+ emit("install", line, binding_id=bid, category=cat, duration_ms=dur,
196
+ error=err, reboot_required=reboot)
197
+ return DriverResult(binding_id=bid, success=ok, error=err,
198
+ duration_ms=dur, reboot_required=reboot)
182
199
 
183
200
 
184
201
  def _download_driver(driver: dict, base: Path, repo_url: str,
@@ -208,50 +225,65 @@ def _level_label(level: int) -> str:
208
225
 
209
226
  # ── Driver installation ─────────────────────────────────────────────────────────
210
227
 
211
- def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, str]:
228
+ def _classify(returncode: int, err_text: str = "") -> tuple[bool, str, bool]:
229
+ """
230
+ Map an installer exit code to (success, error, reboot_required).
231
+
232
+ 0 -> success, no reboot
233
+ 3010 / 1641 -> success, reboot required (NOT a failure)
234
+ anything else -> failure, carry err_text
235
+ """
236
+ if returncode == 0:
237
+ return True, "", False
238
+ if returncode in _REBOOT_CODES:
239
+ return True, "", True
240
+ return False, err_text, False
241
+
242
+
243
+ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, str, bool]:
212
244
  install_type = driver.get("install_type", "inf_silent")
213
245
 
214
246
  try:
215
247
  if install_type == "inf_silent":
216
248
  inf_files = list(driver_dir.glob("*.inf"))
217
249
  if not inf_files:
218
- return False, "No INF file found"
250
+ return False, "No INF file found", False
219
251
  r = subprocess.run(
220
252
  ["pnputil", "/add-driver", str(inf_files[0]), "/install", "/subdirs"],
221
253
  capture_output=True, text=True,
222
254
  timeout=cfg["timeout_long"],
223
255
  )
224
- return r.returncode == 0, r.stdout[:200] if r.returncode != 0 else ""
256
+ return _classify(r.returncode, r.stdout[:200])
225
257
 
226
258
  if install_type == "exe_silent":
227
259
  exe_files = list(driver_dir.glob("*.exe"))
228
260
  if not exe_files:
229
- return False, "No EXE file found"
261
+ return False, "No EXE file found", False
230
262
  flags = driver.get("install_flags", "/S")
231
263
  r = subprocess.run(
232
264
  [str(exe_files[0])] + flags.split(),
233
265
  capture_output=True,
234
266
  timeout=cfg["timeout_install"],
235
267
  )
236
- return r.returncode == 0, ""
268
+ return _classify(r.returncode)
237
269
 
238
270
  if install_type == "firmware_flash":
239
271
  inf_files = list(driver_dir.glob("*.inf"))
240
272
  if not inf_files:
241
- return False, "No firmware INF"
273
+ return False, "No firmware INF", False
242
274
  r = subprocess.run(
243
275
  ["pnputil", "/add-driver", str(inf_files[0]), "/install"],
244
276
  capture_output=True, text=True,
245
277
  timeout=cfg["timeout_long"],
246
278
  )
247
- return r.returncode == 0, ""
279
+ return _classify(r.returncode)
248
280
 
249
281
  except subprocess.TimeoutExpired:
250
- return False, "Install timed out"
282
+ return False, "Install timed out", False
251
283
  except Exception as e:
252
- return False, str(e)
284
+ return False, str(e), False
253
285
 
254
- return False, f"Unknown install_type: {install_type}"
286
+ return False, f"Unknown install_type: {install_type}", False
255
287
 
256
288
 
257
289
  # ── Confirm ─────────────────────────────────────────────────────────────────────
File without changes