driverclient 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,316 @@
1
+ """
2
+ client/ops/scan.py — Local hardware scan.
3
+
4
+ Enumerates all connected devices via pnputil (two parallel calls — one for
5
+ HWIDs, one for class names), collects model profile via WMI, and resolves
6
+ installed driver versions. Results are cached to ds_scan.json.
7
+
8
+ Public API:
9
+ scan(force=False) -> ScanResult
10
+ """
11
+ import json
12
+ import subprocess
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from dataclasses import asdict, dataclass, field
15
+ from datetime import datetime, timedelta, timezone
16
+ from pathlib import Path
17
+
18
+ from driverclient import events
19
+ from driverclient.config import get
20
+ from driverclient.core.hardware import (
21
+ compute_fingerprint,
22
+ get_installed_versions,
23
+ get_model_profile,
24
+ )
25
+
26
+
27
+ @dataclass
28
+ class Device:
29
+ instance_id: str
30
+ device_name: str
31
+ hwids: list[str]
32
+ has_driver: bool
33
+ installed_version: str = ""
34
+ class_name: str = "" # e.g. "System", "Net", "Display adapters"
35
+
36
+
37
+ @dataclass
38
+ class ScanResult:
39
+ machine_id: str
40
+ vendor: str
41
+ model: str
42
+ friendly_model: str
43
+ serial: str
44
+ chassis_type: str
45
+ os_build: int
46
+ arch: str
47
+ firmware_version: str
48
+ devices: list[Device] = field(default_factory=list)
49
+ installed_versions: dict[str, str] = field(default_factory=dict)
50
+ timestamp: str = ""
51
+
52
+ @property
53
+ def all_hwids(self) -> list[str]:
54
+ return [h for d in self.devices for h in d.hwids]
55
+
56
+ @property
57
+ def missing_driver(self) -> list[Device]:
58
+ return [d for d in self.devices if not d.has_driver]
59
+
60
+ @property
61
+ def has_driver(self) -> list[Device]:
62
+ return [d for d in self.devices if d.has_driver]
63
+
64
+
65
+ def scan(force: bool = False) -> ScanResult:
66
+ """
67
+ Run a full hardware scan. Loads from ds_scan.json cache if fresh
68
+ (< cache_ttl_minutes old) unless force=True.
69
+ """
70
+ cfg = get()
71
+
72
+ if not force:
73
+ cached = _load_cache(cfg)
74
+ if cached:
75
+ return cached
76
+
77
+ events.start("scan", "[scan] Reading machine profile…")
78
+ with ThreadPoolExecutor(max_workers=2) as ex:
79
+ profile_f = ex.submit(get_model_profile)
80
+ fp_f = ex.submit(compute_fingerprint)
81
+ profile = profile_f.result()
82
+ machine_id = fp_f.result()
83
+
84
+ events.progress("scan", "[scan] Enumerating connected devices…")
85
+ devices = _enumerate_devices(cfg)
86
+
87
+ all_hwids = [h for d in devices for h in d.hwids]
88
+ installed_versions: dict[str, str] = {}
89
+ if all_hwids:
90
+ events.progress("scan", "[scan] Querying installed driver versions…",
91
+ total=len(all_hwids))
92
+ installed_versions = get_installed_versions(all_hwids)
93
+
94
+ for dev in devices:
95
+ ver = next(
96
+ (installed_versions[h] for h in dev.hwids if h in installed_versions),
97
+ "",
98
+ )
99
+ dev.installed_version = ver
100
+
101
+ result = ScanResult(
102
+ machine_id=machine_id,
103
+ vendor=profile["vendor"],
104
+ model=profile["model"],
105
+ friendly_model=profile.get("friendly_model", ""),
106
+ serial=profile.get("serial", ""),
107
+ chassis_type=profile.get("chassis_type", "unknown"),
108
+ os_build=profile["os_build"],
109
+ arch=profile.get("arch", "x64"),
110
+ firmware_version=profile.get("firmware_version", ""),
111
+ devices=devices,
112
+ installed_versions=installed_versions,
113
+ timestamp=datetime.now(timezone.utc).isoformat(),
114
+ )
115
+
116
+ _save_cache(result, cfg)
117
+ _print_summary(result)
118
+ return result
119
+
120
+
121
+ # ── Cache ──────────────────────────────────────────────────────────────────────
122
+
123
+ def _cache_path(cfg: dict) -> Path:
124
+ return Path(cfg.get("cache_dir", "C:\\DriverServer\\client")) / "ds_scan.json"
125
+
126
+
127
+ def _load_cache(cfg: dict) -> ScanResult | None:
128
+ path = _cache_path(cfg)
129
+ if not path.exists():
130
+ return None
131
+ try:
132
+ data = json.loads(path.read_text())
133
+ ts = datetime.fromisoformat(data.get("timestamp", "2000-01-01T00:00:00+00:00"))
134
+ if datetime.now(timezone.utc) - ts > timedelta(minutes=cfg.get("cache_ttl_minutes", 15)):
135
+ return None
136
+ # class_name defaults to "" so old caches without it still load correctly
137
+ devices = [Device(**d) for d in data.pop("devices", [])]
138
+ return ScanResult(**{**data, "devices": devices})
139
+ except Exception:
140
+ return None
141
+
142
+
143
+ def _save_cache(result: ScanResult, cfg: dict) -> None:
144
+ path = _cache_path(cfg)
145
+ try:
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+ path.write_text(json.dumps(asdict(result), indent=2))
148
+ except Exception:
149
+ pass
150
+
151
+
152
+ # ── Device enumeration ─────────────────────────────────────────────────────────
153
+
154
+ def _enumerate_devices(cfg: dict) -> list[Device]:
155
+ """
156
+ Run two pnputil calls in parallel:
157
+ /ids → HWIDs + driver_name
158
+ (none) → class_name per device
159
+ Merge results by instance_id.
160
+ """
161
+ timeout = cfg.get("timeout_short", 30)
162
+
163
+ with ThreadPoolExecutor(max_workers=2) as ex:
164
+ ids_f = ex.submit(_run_pnputil_ids, timeout)
165
+ class_f = ex.submit(_run_pnputil_classes, timeout)
166
+ ids_out = ids_f.result()
167
+ class_out = class_f.result()
168
+
169
+ if ids_out is None:
170
+ return []
171
+
172
+ devices = _parse_hwid_output(ids_out)
173
+ class_map = _parse_class_output(class_out) if class_out else {}
174
+
175
+ for dev in devices:
176
+ dev.class_name = class_map.get(dev.instance_id, "")
177
+
178
+ return devices
179
+
180
+
181
+ def _run_pnputil_ids(timeout: int) -> str | None:
182
+ """pnputil /enum-devices /connected /ids — HWIDs + driver_name."""
183
+ try:
184
+ r = subprocess.run(
185
+ ["pnputil", "/enum-devices", "/connected", "/ids"],
186
+ capture_output=True, text=True, timeout=timeout,
187
+ )
188
+ return r.stdout
189
+ except FileNotFoundError:
190
+ events.error("scan", "[scan] pnputil not found — must run on Windows",
191
+ error="pnputil_not_found")
192
+ return None
193
+ except Exception as e:
194
+ events.error("scan", f"[scan] pnputil /ids failed: {e}", error=str(e))
195
+ return None
196
+
197
+
198
+ def _run_pnputil_classes(timeout: int) -> str | None:
199
+ """pnputil /enum-devices /connected — class_name per device."""
200
+ try:
201
+ r = subprocess.run(
202
+ ["pnputil", "/enum-devices", "/connected"],
203
+ capture_output=True, text=True, timeout=timeout,
204
+ )
205
+ return r.stdout
206
+ except Exception:
207
+ return None
208
+
209
+
210
+ # ── pnputil output parsers ─────────────────────────────────────────────────────
211
+
212
+ def _parse_hwid_output(output: str) -> list[Device]:
213
+ """Parse /enum-devices /connected /ids output into Device list."""
214
+ devices: list[Device] = []
215
+ current: dict = {}
216
+ in_hwids: bool = False
217
+
218
+ for line in output.splitlines():
219
+ stripped = line.strip()
220
+ if not stripped:
221
+ _flush_device(current, devices)
222
+ current = {}
223
+ in_hwids = False
224
+ else:
225
+ in_hwids = _apply_hwid_line(line, stripped, current, in_hwids)
226
+
227
+ _flush_device(current, devices)
228
+ return devices
229
+
230
+
231
+ def _apply_hwid_line(line: str, stripped: str, current: dict, in_hwids: bool) -> bool:
232
+ """Process one non-empty pnputil /ids output line. Returns updated in_hwids flag."""
233
+ low = stripped.lower()
234
+ if low.startswith("instance id") and ":" in stripped:
235
+ current["instance_id"] = stripped.split(":", 1)[1].strip()
236
+ return False
237
+ if low.startswith("device description") and ":" in stripped:
238
+ current["name"] = stripped.split(":", 1)[1].strip()
239
+ return False
240
+ if low.startswith("driver name") and ":" in stripped:
241
+ val = stripped.split(":", 1)[1].strip()
242
+ if val:
243
+ current["driver_name"] = val
244
+ return False
245
+ if low.startswith(("hardware ids", "compatible ids")):
246
+ current.setdefault("hwids", [])
247
+ return True
248
+ if in_hwids and "\\" in stripped and not stripped.endswith(":"):
249
+ current.setdefault("hwids", []).append(stripped.upper())
250
+ return True
251
+ if ":" in stripped and not line.startswith(" "):
252
+ return False
253
+ return in_hwids
254
+
255
+
256
+ def _flush_device(current: dict, devices: list[Device]) -> None:
257
+ if current.get("instance_id"):
258
+ devices.append(Device(
259
+ instance_id=current.get("instance_id", ""),
260
+ device_name=current.get("name", ""),
261
+ hwids=list(dict.fromkeys(current.get("hwids", []))),
262
+ has_driver=bool(current.get("driver_name")),
263
+ ))
264
+
265
+
266
+ def _parse_class_output(output: str) -> dict[str, str]:
267
+ """
268
+ Parse /enum-devices /connected output into {instance_id: class_name}.
269
+ Sample lines:
270
+ Instance ID: PCI\\VEN_8086...
271
+ Class Name: Display adapters
272
+ """
273
+ mapping: dict[str, str] = {}
274
+ current_id = ""
275
+
276
+ for line in output.splitlines():
277
+ stripped = line.strip()
278
+ low = stripped.lower()
279
+
280
+ if not stripped:
281
+ current_id = ""
282
+ continue
283
+
284
+ if low.startswith("instance id") and ":" in stripped:
285
+ current_id = stripped.split(":", 1)[1].strip()
286
+ elif low.startswith("class name") and ":" in stripped and current_id:
287
+ mapping[current_id] = stripped.split(":", 1)[1].strip()
288
+
289
+ return mapping
290
+
291
+
292
+ # ── Output ─────────────────────────────────────────────────────────────────────
293
+
294
+ def _print_summary(r: ScanResult) -> None:
295
+ total = len(r.devices)
296
+ events.progress("scan", "")
297
+ events.progress(
298
+ "scan",
299
+ f"[scan] {r.vendor} {r.model} | {r.chassis_type} | OS {r.os_build}",
300
+ vendor=r.vendor, model=r.model, chassis_type=r.chassis_type,
301
+ os_build=r.os_build,
302
+ )
303
+ events.done(
304
+ "scan",
305
+ f"[scan] {total} device(s) — {len(r.has_driver)} with driver, "
306
+ f"{len(r.missing_driver)} missing",
307
+ total=total,
308
+ with_driver=len(r.has_driver), missing=len(r.missing_driver),
309
+ )
310
+ if r.missing_driver:
311
+ events.progress("scan", "[scan] Missing drivers:")
312
+ for d in r.missing_driver:
313
+ name = d.device_name or (d.hwids[0] if d.hwids else d.instance_id)
314
+ cls = f" [{d.class_name}]" if d.class_name else ""
315
+ events.progress("scan", f" ✗ {name}{cls}",
316
+ device_name=name, class_name=d.class_name)
driverclient/py.typed ADDED
File without changes
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: driverclient
3
+ Version: 0.2.0
4
+ Summary: Driver Server client with an embeddable connector for config injection at runtime.
5
+ Project-URL: Homepage, https://example.com/driverclient
6
+ Author-email: Raja Sanaullah <sanaullah@99technologies.com>
7
+ License-Expression: MIT
8
+ Keywords: deployment,driver,pnputil,pxe,windows
9
+ Classifier: Intended Audience :: System Administrators
10
+ Classifier: Operating System :: Microsoft :: Windows
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: zstandard>=0.22.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ # driverclient
18
+
19
+ Embeddable client for a Driver Server local repo. Scans a Windows machine's
20
+ hardware, resolves and installs drivers from the repo, and captures drivers
21
+ back up to it. Designed to be driven programmatically from a host application
22
+ (for example a PyQt front-end) via a small connector class.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install driverclient
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ Create one `DriverClient` per process and call `.run()`:
33
+
34
+ ```python
35
+ from driverclient import DriverClient
36
+
37
+ client = DriverClient(
38
+ local_repo_url="http://REPO_HOST:8000",
39
+ node_key="YOUR_NODE_KEY",
40
+ )
41
+ result = client.run("capture-all") # or client.run() for the configured default
42
+ ```
43
+
44
+ ### Configuration
45
+
46
+ Config can be supplied three ways and is merged with this priority
47
+ (**highest wins**):
48
+
49
+ ```
50
+ DEFAULTS < DS_CLIENT_CONFIG env file < config_path file < keyword args
51
+ ```
52
+
53
+ - **Args only** — pass keys directly:
54
+ ```python
55
+ DriverClient(local_repo_url="http://x:1", node_key="k")
56
+ ```
57
+ - **File only** — pass a JSON file used as the base:
58
+ ```python
59
+ DriverClient(config_path="/etc/driverclient.json")
60
+ ```
61
+ - **Both** — file as the base, individual keys overridden by kwargs:
62
+ ```python
63
+ DriverClient(config_path="/etc/driverclient.json", node_key="override")
64
+ ```
65
+
66
+ Passing nothing preserves the default behavior (env file → packaged
67
+ `config.json` → built-in defaults).
68
+
69
+ A dummy `config.json` ships inside the package as a template. Replace
70
+ `REPLACE_WITH_YOUR_NODE_KEY` and point `local_repo_url` at your repo — or, better,
71
+ supply real values at runtime through the connector.
72
+
73
+ ### Commands
74
+
75
+ `scan`, `resolve`, `resolve-and-install`, `capture-all`, `capture-missing`,
76
+ `wu-update`, `wu-full`, `automate`.
77
+
78
+ `client.run()` with no argument uses `DS_CLIENT_COMMAND`, then the
79
+ `default_command` from config.
80
+
81
+ ### Progress events
82
+
83
+ Every operation reports progress in real time through a structured event stream
84
+ (added in **0.2.0**). Pass an `on_event` callback to `run()` and it is invoked
85
+ synchronously for each step:
86
+
87
+ ```python
88
+ def on_event(ev): # ev is a driverclient.ClientEvent
89
+ print(ev.phase, ev.status, ev.message)
90
+
91
+ client.run("automate", on_event=on_event)
92
+ ```
93
+
94
+ When no callback is given, events fall back to `print` + the
95
+ `driverclient` logger, so the terminal/log experience is unchanged.
96
+
97
+ Each `ClientEvent` (frozen dataclass, `ev.to_dict()` for transport) has:
98
+
99
+ | field | type | meaning |
100
+ |-----------|-----------------|-----------------------------------------------------|
101
+ | `phase` | str | `scan` `resolve` `install` `capture` `windows_update` `dump` `upload` `pipeline` `done` |
102
+ | `status` | str | `start` `progress` `ok` `warn` `error` `done` |
103
+ | `message` | str | human-readable line (what the fallback prints) |
104
+ | `current` | int \| None | counter position (e.g. driver 3 of 12) |
105
+ | `total` | int \| None | counter total |
106
+ | `percent` | float \| None | 0–100 for long single operations |
107
+ | `data` | dict | structured specifics (`driver_name`, `hwid`, `path`, `error`, …) |
108
+ | `ts` | float | `time.time()` at emit |
109
+
110
+ `phase` and `status` are a fixed, documented vocabulary — this is the interface
111
+ a GUI binds to; treat changes to it as an API change.
112
+
113
+ > Events are emitted on whatever thread the op is running on — and the parallel
114
+ > download/export/upload pools mean some events arrive on **worker threads**. The
115
+ > Qt pattern below (queued cross-thread signals) is safe regardless.
116
+
117
+ #### PyQt consumer pattern
118
+
119
+ Run the (minutes-long, blocking) `client.run(...)` on a **QThread**, never the UI
120
+ thread. The `on_event` callback must **not** touch widgets — it emits a Qt signal
121
+ carrying `event.to_dict()`; a slot on the UI thread updates the widgets. Qt
122
+ delivers cross-thread signals via the receiver's event loop (queued), which is
123
+ the thread-safe way to drive the UI.
124
+
125
+ ```python
126
+ from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
127
+ from driverclient import DriverClient
128
+
129
+
130
+ class Worker(QObject):
131
+ event = pyqtSignal(dict) # carries ClientEvent.to_dict()
132
+ finished = pyqtSignal(object) # carries the op's result
133
+
134
+ def __init__(self, command):
135
+ super().__init__()
136
+ self.command = command
137
+
138
+ @pyqtSlot()
139
+ def run(self):
140
+ client = DriverClient(local_repo_url="http://REPO_HOST:8000",
141
+ node_key="YOUR_NODE_KEY")
142
+ # Called on THIS worker thread — only emit a signal, never touch widgets.
143
+ result = client.run(self.command, on_event=lambda ev: self.event.emit(ev.to_dict()))
144
+ self.finished.emit(result)
145
+
146
+
147
+ class Panel:
148
+ """Wire a worker onto a thread and bind its signals to UI-thread slots."""
149
+ def start(self, command):
150
+ self.thread = QThread()
151
+ self.worker = Worker(command)
152
+ self.worker.moveToThread(self.thread)
153
+ self.thread.started.connect(self.worker.run)
154
+ self.worker.event.connect(self.on_event) # queued → runs on UI thread
155
+ self.worker.finished.connect(self.on_finished)
156
+ self.thread.start()
157
+
158
+ @pyqtSlot(dict)
159
+ def on_event(self, ev):
160
+ # Safe: this runs on the UI thread. Update widgets here.
161
+ self.status_label.setText(ev["message"])
162
+ if ev["phase"] == "install" and ev["status"] in ("ok", "error"):
163
+ self.step_list.addItem(ev["message"])
164
+ if ev["total"] and ev["current"] is not None:
165
+ self.progress_bar.setMaximum(ev["total"])
166
+ self.progress_bar.setValue(ev["current"])
167
+ elif ev["percent"] is not None:
168
+ self.progress_bar.setValue(int(ev["percent"]))
169
+
170
+ @pyqtSlot(object)
171
+ def on_finished(self, result):
172
+ self.thread.quit()
173
+ self.thread.wait()
174
+ ```
175
+
176
+ ## Requirements
177
+
178
+ - Python >= 3.10
179
+ - Windows target for the actual driver operations (`pnputil`, WMI, DISM).
@@ -0,0 +1,19 @@
1
+ driverclient/__init__.py,sha256=yO95efsaJ6BG_w1XM4rI6jFsnLIHmENoCzAPHt9thBE,2854
2
+ driverclient/__main__.py,sha256=hcKwtvsidQC_6fyd9nLE7POmobRXEeZYMTLh1IM9Swg,77
3
+ driverclient/config.json,sha256=-3zVtbU7LxoLgvLNSiYWfwTI3MwxVkmz4vNk4k0Oamc,926
4
+ driverclient/config.py,sha256=lRLxh9sVKtGkGuY53iCUHhljA78jpJuBnqD2qnvUG7g,4289
5
+ driverclient/events.py,sha256=I-01cl-Ojbtxb02ujpAo-VoS4wRHYvawbJWyPfBW5y4,4986
6
+ driverclient/main.py,sha256=qRjF9-GkaWmwVj1O-GU4oFX6SGzXDd8luo2nYPsQB74,2363
7
+ driverclient/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ driverclient/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ driverclient/core/hardware.py,sha256=ut4hRaY88ilDZxJbxiiHqYuhYY3cEisLAaQkWQPapCY,13067
10
+ driverclient/core/http.py,sha256=cqW-pLyvpKvdZ198tYgxkvCcEDRtlQhCTTO6dEHMSJI,10830
11
+ driverclient/ops/__init__.py,sha256=hfZvkgOiNRJkPLu_mlIwxSWhurgxdceHtyDJvNyGLV8,619
12
+ driverclient/ops/automate.py,sha256=Ly8bA-GkxqmzGFZHdT6FiHdqVLi8vGdX3L3tmAoZl7g,5233
13
+ driverclient/ops/capture.py,sha256=1Z8x_Bmf1VmThCf00qqXJV3kIvBUErw_7IVEYw8LJQs,25889
14
+ driverclient/ops/install.py,sha256=AsVprqQHAWtnCLfWWVIzguTcIa65uSOOdrB23BPuovk,10432
15
+ driverclient/ops/resolve.py,sha256=njo94WPt9PV0j2sRuzC1SGbwQ1uW1BLORaaAKFzj3VY,4842
16
+ driverclient/ops/scan.py,sha256=hYkOIEd8CLCL2pjj6wTtwcxJybM-qT5klZDZfayXQT4,10922
17
+ driverclient-0.2.0.dist-info/METADATA,sha256=vXwEUhp8cBxgGAG0nHvhu3z8fXhuD57ZlssmRSzAHxo,6659
18
+ driverclient-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
19
+ driverclient-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any