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.
- {driverclient-0.2.0 → driverclient-0.2.1}/PKG-INFO +27 -1
- {driverclient-0.2.0 → driverclient-0.2.1}/README.md +26 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/pyproject.toml +1 -1
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/__init__.py +1 -1
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/automate.py +19 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/install.py +52 -20
- {driverclient-0.2.0 → driverclient-0.2.1}/.gitignore +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/__main__.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/config.json +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/config.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/core/__init__.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/core/hardware.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/core/http.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/events.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/main.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/__init__.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/capture.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/resolve.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.1}/src/driverclient/ops/scan.py +0 -0
- {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.
|
|
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.
|
|
@@ -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:
|
|
40
|
-
success:
|
|
41
|
-
error:
|
|
42
|
-
duration_ms:
|
|
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
|
-
|
|
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,
|
|
181
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|