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,73 @@
1
+ """
2
+ driverclient — Driver Server client, packaged for embedding.
3
+
4
+ Typical use from an embedding app (e.g. a PyQt front-end):
5
+
6
+ from driverclient import DriverClient
7
+
8
+ # args only
9
+ client = DriverClient(local_repo_url="http://REPO_HOST:8000", node_key="...")
10
+
11
+ # file only
12
+ client = DriverClient(config_path="/etc/driverclient.json")
13
+
14
+ # file as base, individual keys overridden by kwargs
15
+ client = DriverClient(config_path="/etc/driverclient.json", node_key="...")
16
+
17
+ result = client.run("capture-all") # or client.run() for default_command
18
+
19
+ Config merge priority (highest wins):
20
+ DEFAULTS < DS_CLIENT_CONFIG env file < config_path file < kwargs
21
+ """
22
+ from driverclient import config as _config
23
+ from driverclient.events import ClientEvent, using_sink
24
+ from driverclient.main import run as _run
25
+
26
+ __all__ = ["DriverClient", "ClientEvent"]
27
+
28
+ __version__ = "0.2.0"
29
+
30
+
31
+ class DriverClient:
32
+ """
33
+ Configures the process-global client config, then runs operations.
34
+
35
+ A single active client per process is supported: constructing a
36
+ DriverClient resets and reloads the shared global config singleton.
37
+ Construct one instance and reuse it; constructing another replaces
38
+ the active configuration.
39
+ """
40
+
41
+ def __init__(self, config_path: str | None = None, **kwargs) -> None:
42
+ """
43
+ Args:
44
+ config_path: optional path to a JSON config file used as the base.
45
+ If omitted, the DS_CLIENT_CONFIG env file or the
46
+ packaged config.json is used (existing behavior).
47
+ **kwargs: individual config keys that override everything else,
48
+ including values from config_path.
49
+ """
50
+ _config.reset()
51
+ self.config = _config.load(path=config_path, overrides=kwargs)
52
+
53
+ def run(self, command: str | None = None, on_event=None) -> object:
54
+ """
55
+ Run a client operation and return its result object.
56
+
57
+ Args:
58
+ command: one of the client operations (e.g. "scan", "capture-all",
59
+ "automate"). If None, falls back to DS_CLIENT_COMMAND then
60
+ the default_command from config.
61
+ on_event: optional callback ``Callable[[ClientEvent], None]`` invoked
62
+ synchronously, in real time, for every progress event the
63
+ operation emits. Installed only for the duration of this
64
+ run (see events.using_sink) and restored afterwards. When
65
+ omitted, events fall back to print + logging so the
66
+ terminal experience is unchanged.
67
+
68
+ Returns:
69
+ The operation's result object (ScanResult, InstallResult, ...).
70
+ """
71
+ self.on_event = on_event
72
+ with using_sink(on_event):
73
+ return _run(command)
@@ -0,0 +1,4 @@
1
+ """Allow: python -m driverclient"""
2
+ from driverclient.main import run
3
+
4
+ run()
@@ -0,0 +1,36 @@
1
+ {
2
+ "local_repo_url": "http://localhost:8000",
3
+ "node_key": "REPLACE_WITH_YOUR_NODE_KEY",
4
+
5
+ "default_command": "automate",
6
+
7
+ "parallel_downloads": 6,
8
+ "parallel_uploads": 8,
9
+ "parallel_exports": 4,
10
+
11
+ "driver_root": "C:\\DriverServer",
12
+ "cache_dir": "C:\\DriverServer\\client",
13
+ "work_dir": "C:\\DriverServer\\client\\staging",
14
+ "cache_ttl_minutes": 15,
15
+
16
+ "dump_extensions": [".inf", ".sys", ".cat"],
17
+ "dump_extensions_overrides": {
18
+ "system": [".inf", ".sys", ".cat", ".dll"],
19
+ "usb": [".inf", ".sys", ".cat", ".dll"]
20
+ },
21
+
22
+ "wu_wait_minutes": 10,
23
+ "wu_poll_interval_seconds": 30,
24
+ "wu_ignore_classes": ["hidclass", "bluetooth", "printer"],
25
+
26
+ "automate_wu_fallback": true,
27
+ "automate_re_resolve": false,
28
+ "force_resolve": false,
29
+
30
+ "timeout_tiny": 10,
31
+ "timeout_short": 30,
32
+ "timeout_medium": 60,
33
+ "timeout_long": 120,
34
+ "timeout_install": 600,
35
+ "timeout_wmi_slow": 90
36
+ }
driverclient/config.py ADDED
@@ -0,0 +1,98 @@
1
+ """
2
+ driverclient/config.py — Load client settings.
3
+
4
+ Merge priority (highest wins):
5
+ DEFAULTS < DS_CLIENT_CONFIG env file < file passed to load() < overrides
6
+ """
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+ DEFAULTS: dict = {
12
+ # ── Server ────────────────────────────────────────────────────────────
13
+ "local_repo_url": "http://localhost:8000",
14
+ "node_key": "",
15
+
16
+ # ── Command ───────────────────────────────────────────────────────────
17
+ "default_command": "automate",
18
+
19
+ # ── Parallelism ───────────────────────────────────────────────────────
20
+ "parallel_downloads": 6,
21
+ "parallel_uploads": 8,
22
+ "parallel_exports": 4,
23
+
24
+ # ── Paths ─────────────────────────────────────────────────────────────
25
+ "driver_root": "C:\\DriverServer",
26
+ "cache_dir": "C:\\DriverServer\\client",
27
+ "work_dir": "C:\\DriverServer\\client\\staging",
28
+
29
+ # ── Cache TTL ─────────────────────────────────────────────────────────
30
+ # ds_scan.json / ds_resolve.json are reused if younger than this many minutes
31
+ "cache_ttl_minutes": 15,
32
+
33
+ # ── Capture ───────────────────────────────────────────────────────────
34
+ # [".inf", ".sys", ".cat"] — minimum for server to redistribute and install
35
+ # [".inf", ".sys", ".cat", ".dll"] — adds co-installer DLLs
36
+ # ["*"] — all files in the driver package directory
37
+ "dump_extensions": [".inf", ".sys", ".cat"],
38
+
39
+ # Per-category extension overrides — key = class_name from pnputil (lowercase)
40
+ # Categories not listed here use dump_extensions above
41
+ "dump_extensions_overrides": {
42
+ "system": [".inf", ".sys", ".cat", ".dll"],
43
+ "usb": [".inf", ".sys", ".cat", ".dll"]
44
+ },
45
+
46
+ # ── Windows Update ────────────────────────────────────────────────────
47
+ "wu_wait_minutes": 10,
48
+ "wu_poll_interval_seconds": 30,
49
+ # Classes to skip when deciding which not-found HWIDs trigger WU.
50
+ # Empty list = no filtering (default — make no assumptions).
51
+ # Use lowercase class names as reported by pnputil, e.g. "hidclass", "bluetooth".
52
+ "wu_ignore_classes": ["hidclass"],
53
+
54
+ # ── Automate flags ────────────────────────────────────────────────────
55
+ "automate_wu_fallback": True,
56
+ "automate_re_resolve": False,
57
+ "force_resolve": False,
58
+
59
+ # ── HTTP timeouts (seconds) ───────────────────────────────────────────
60
+ "timeout_tiny": 10,
61
+ "timeout_short": 30,
62
+ "timeout_medium": 60,
63
+ "timeout_long": 120,
64
+ "timeout_install": 600,
65
+ "timeout_wmi_slow": 90,
66
+ }
67
+
68
+ _config: dict | None = None
69
+
70
+
71
+ def load(path: str | None = None, overrides: dict | None = None) -> dict:
72
+ global _config
73
+ if _config is not None:
74
+ return _config
75
+
76
+ if path:
77
+ config_path = Path(path)
78
+ elif "DS_CLIENT_CONFIG" in os.environ:
79
+ config_path = Path(os.environ["DS_CLIENT_CONFIG"])
80
+ else:
81
+ config_path = Path(__file__).parent / "config.json"
82
+
83
+ data: dict = {}
84
+ if config_path.exists():
85
+ with open(config_path) as fh:
86
+ data = json.load(fh)
87
+
88
+ _config = {**DEFAULTS, **data, **(overrides or {})}
89
+ return _config
90
+
91
+
92
+ def get() -> dict:
93
+ return load()
94
+
95
+
96
+ def reset() -> None:
97
+ global _config
98
+ _config = None
File without changes
@@ -0,0 +1,345 @@
1
+ """
2
+ client/core/hardware.py — Hardware enumeration for Windows target machines.
3
+
4
+ Strategy:
5
+ Primary — one PowerShell Get-CimInstance call returning JSON.
6
+ Works on Windows 10 and Windows 11 with no deprecation warnings.
7
+ Fallback — individual wmic queries (parallel).
8
+ Still works on Win10/11; deprecated in Win11 22H2+ but warnings
9
+ go to stderr which is captured and discarded.
10
+ """
11
+ import hashlib
12
+ import json
13
+ import subprocess
14
+ import uuid
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from pathlib import Path
17
+
18
+ from driverclient.config import get
19
+
20
+ # ── PowerShell profile script ──────────────────────────────────────────────────
21
+ # Runs in ONE subprocess and returns all system data as JSON.
22
+ _PROFILE_PS = """\
23
+ try {
24
+ $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
25
+ $bios = Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop
26
+ $board= Get-CimInstance -ClassName Win32_BaseBoard -ErrorAction Stop
27
+ $cpu = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop |
28
+ Select-Object -First 1
29
+ $nic = Get-CimInstance -ClassName Win32_NetworkAdapter `
30
+ -Filter 'AdapterTypeId=0' `
31
+ -ErrorAction SilentlyContinue |
32
+ Select-Object -First 1
33
+ $enc = Get-CimInstance -ClassName Win32_SystemEnclosure -ErrorAction Stop
34
+ $prod = Get-CimInstance -ClassName Win32_ComputerSystemProduct -ErrorAction SilentlyContinue
35
+ $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
36
+ [PSCustomObject]@{
37
+ vendor = [string]$cs.Manufacturer
38
+ model = [string]$cs.Model
39
+ system_family = [string]$cs.SystemFamily
40
+ friendly_model = [string]$prod.Name
41
+ product_version = [string]$prod.Version
42
+ serial_bios = [string]$bios.SerialNumber
43
+ serial_board = [string]$board.SerialNumber
44
+ cpu_id = [string]$cpu.ProcessorId
45
+ mac = [string]$nic.MACAddress
46
+ os_build = [string]$os.BuildNumber
47
+ firmware = [string]$bios.SMBIOSBIOSVersion
48
+ chassis = ($enc.ChassisTypes -join ',')
49
+ } | ConvertTo-Json -Compress
50
+ } catch { Write-Output '{}' }
51
+ """
52
+
53
+ _INSTALLED_PS = """\
54
+ Get-CimInstance -ClassName Win32_PnPSignedDriver |
55
+ Where-Object { $_.HardWareID -and $_.DriverVersion } |
56
+ Select-Object HardWareID, DriverVersion |
57
+ ConvertTo-Json -Compress
58
+ """
59
+
60
+
61
+ # ── Public API ─────────────────────────────────────────────────────────────────
62
+
63
+ def compute_fingerprint() -> str:
64
+ """Stable machine UUID derived from MB serial + CPU ID + first NIC MAC."""
65
+ data = _get_profile_data()
66
+ if data:
67
+ mb = data.get("serial_board", "") or data.get("serial_bios", "")
68
+ cpu = data.get("cpu_id", "")
69
+ mac = data.get("mac", "")
70
+ else:
71
+ with ThreadPoolExecutor(max_workers=3) as ex:
72
+ mb_f = ex.submit(_wmic, "baseboard get SerialNumber")
73
+ cpu_f = ex.submit(_wmic, "cpu get ProcessorId")
74
+ mac_f = ex.submit(_wmic, "nic where AdapterTypeId=0 get MACAddress")
75
+ mb, cpu, mac = mb_f.result(), cpu_f.result(), mac_f.result()
76
+ raw = f"{mb}|{cpu}|{mac}"
77
+ digest = hashlib.sha256(raw.encode()).digest()
78
+ return str(uuid.UUID(bytes=digest[:16]))
79
+
80
+
81
+ def get_hwids() -> list[str]:
82
+ """Return all connected device HWIDs via pnputil."""
83
+ cfg = get()
84
+ hwids = []
85
+ try:
86
+ r = subprocess.run(
87
+ ["pnputil", "/enum-devices", "/connected", "/ids"],
88
+ capture_output=True, text=True,
89
+ timeout=cfg["timeout_short"],
90
+ )
91
+ for line in r.stdout.splitlines():
92
+ line = line.strip()
93
+ if "\\" in line and not line.endswith(":"):
94
+ hwids.append(line.upper())
95
+ except Exception as e:
96
+ print(f"[warn] pnputil failed: {e}")
97
+ return list(set(hwids))
98
+
99
+
100
+ def get_model_profile() -> dict:
101
+ """
102
+ Return machine profile dict.
103
+ Tries a single PowerShell call first; falls back to parallel wmic queries.
104
+ """
105
+ data = _get_profile_data()
106
+ if data:
107
+ return _profile_from_ps(data)
108
+ return _profile_from_wmic()
109
+
110
+
111
+ def get_installed_versions(hwids: list[str]) -> dict[str, str]:
112
+ """
113
+ Return {hwid: version_string} for all installed drivers.
114
+ Tries PowerShell first, falls back to wmic CSV.
115
+ Caches result keyed by sorted HWID list hash.
116
+ """
117
+ cfg = get()
118
+ hwid_hash = hashlib.sha256("|".join(sorted(hwids)).encode()).hexdigest()[:16]
119
+ cache = _load_versions_cache(cfg, hwid_hash)
120
+ if cache is not None:
121
+ return cache
122
+
123
+ print("[scan] Querying installed driver versions…")
124
+ versions = _installed_versions_ps()
125
+ if not versions:
126
+ versions = _installed_versions_wmic(cfg)
127
+
128
+ _save_versions_cache(cfg, hwid_hash, versions)
129
+ return versions
130
+
131
+
132
+ # ── PowerShell helpers ─────────────────────────────────────────────────────────
133
+
134
+ def _run_ps(script: str, timeout: int) -> str:
135
+ """Run a PowerShell script inline. Returns stdout or '' on failure."""
136
+ try:
137
+ r = subprocess.run(
138
+ ["powershell", "-NoProfile", "-NonInteractive",
139
+ "-ExecutionPolicy", "Bypass", "-Command", script],
140
+ capture_output=True, text=True, timeout=timeout,
141
+ )
142
+ return r.stdout.strip() if r.returncode == 0 else ""
143
+ except Exception:
144
+ return ""
145
+
146
+
147
+ def _get_profile_data() -> dict:
148
+ """Run _PROFILE_PS and return parsed JSON dict, or {} on failure."""
149
+ cfg = get()
150
+ out = _run_ps(_PROFILE_PS, cfg.get("timeout_wmi_slow", 90))
151
+ if not out:
152
+ return {}
153
+ try:
154
+ return json.loads(out)
155
+ except Exception:
156
+ return {}
157
+
158
+
159
+ def _profile_from_ps(data: dict) -> dict:
160
+ model = _clean(data.get("model", "Unknown"))
161
+ # Marketing name: Lenovo (and some others) put the machine-type CODE in
162
+ # ComputerSystemProduct.Name / ComputerSystem.Model (e.g. "20F1001PUS") and the
163
+ # human name in ComputerSystemProduct.Version / SystemFamily (e.g. "ThinkPad T460s").
164
+ # Prefer the first meaningful candidate that isn't just the machine-type code.
165
+ friendly = ""
166
+ for cand in (data.get("product_version"), data.get("system_family"), data.get("friendly_model")):
167
+ cand = _clean(cand or "")
168
+ if _is_meaningful(cand) and cand.lower() != model.lower():
169
+ friendly = cand
170
+ break
171
+ if not friendly:
172
+ friendly = _clean(data.get("friendly_model", "") or "")
173
+ return {
174
+ "vendor": _clean(data.get("vendor", "Unknown")),
175
+ "model": model,
176
+ "friendly_model": _clean(friendly),
177
+ "serial": _clean(data.get("serial_bios") or data.get("serial_board", "")),
178
+ "chassis_type": _parse_chassis(data.get("chassis", "")),
179
+ "os_build": _safe_int(data.get("os_build", "0")),
180
+ "arch": "x64",
181
+ "firmware_version": _clean(data.get("firmware", "")),
182
+ }
183
+
184
+
185
+ def _installed_versions_ps() -> dict[str, str]:
186
+ """Get installed driver versions via PowerShell. Returns {} on failure."""
187
+ cfg = get()
188
+ out = _run_ps(_INSTALLED_PS, cfg.get("timeout_wmi_slow", 90))
189
+ if not out:
190
+ return {}
191
+ try:
192
+ rows = json.loads(out)
193
+ if isinstance(rows, dict):
194
+ rows = [rows]
195
+ return {
196
+ row["HardWareID"].upper(): row["DriverVersion"]
197
+ for row in rows
198
+ if row.get("HardWareID") and row.get("DriverVersion")
199
+ }
200
+ except Exception:
201
+ return {}
202
+
203
+
204
+ # ── wmic fallback helpers ──────────────────────────────────────────────────────
205
+
206
+ def _profile_from_wmic() -> dict:
207
+ with ThreadPoolExecutor(max_workers=6) as ex:
208
+ vendor_f = ex.submit(_wmic, "computersystem get Manufacturer")
209
+ model_f = ex.submit(_wmic, "computersystem get Model")
210
+ friendly_f = ex.submit(_get_friendly_model_wmic)
211
+ serial_f = ex.submit(_get_serial_wmic)
212
+ chassis_f = ex.submit(_get_chassis_wmic)
213
+ os_f = ex.submit(_wmic, "os get BuildNumber")
214
+ fw_f = ex.submit(_wmic, "bios get SMBIOSBIOSVersion")
215
+ return {
216
+ "vendor": vendor_f.result() or "Unknown",
217
+ "model": model_f.result() or "Unknown",
218
+ "friendly_model": friendly_f.result(),
219
+ "serial": serial_f.result(),
220
+ "chassis_type": chassis_f.result(),
221
+ "os_build": _safe_int(os_f.result()),
222
+ "arch": "x64",
223
+ "firmware_version": fw_f.result() or "",
224
+ }
225
+
226
+
227
+ def _installed_versions_wmic(cfg: dict) -> dict[str, str]:
228
+ versions: dict[str, str] = {}
229
+ try:
230
+ r = subprocess.run(
231
+ ["wmic", "path", "Win32_PnPSignedDriver",
232
+ "get", "HardWareID,DriverVersion", "/format:csv"],
233
+ capture_output=True, text=True,
234
+ timeout=cfg.get("timeout_wmi_slow", 90),
235
+ )
236
+ for line in r.stdout.splitlines()[1:]:
237
+ parts = line.strip().split(",")
238
+ if len(parts) >= 3:
239
+ hwid = parts[1].strip().upper()
240
+ ver = parts[2].strip()
241
+ if hwid and ver:
242
+ versions[hwid] = ver
243
+ except Exception as e:
244
+ print(f"[warn] WMI version query failed: {e}")
245
+ return versions
246
+
247
+
248
+ def _get_serial_wmic() -> str:
249
+ return _wmic("bios get SerialNumber") or _wmic("baseboard get SerialNumber") or ""
250
+
251
+
252
+ def _get_friendly_model_wmic() -> str:
253
+ name = _wmic("computersystemproduct get Name")
254
+ if _is_meaningful(name):
255
+ return name
256
+ family = _wmic("computersystem get SystemFamily")
257
+ return family if _is_meaningful(family) else ""
258
+
259
+
260
+ def _get_chassis_wmic() -> str:
261
+ raw = _wmic("systemenclosure get ChassisTypes")
262
+ return _parse_chassis(raw)
263
+
264
+
265
+ def _wmic(query: str) -> str:
266
+ """Run a wmic query and return the first non-empty value."""
267
+ cfg = get()
268
+ try:
269
+ r = subprocess.run(
270
+ ["wmic"] + query.split() + ["/format:value"],
271
+ capture_output=True, text=True,
272
+ timeout=cfg.get("timeout_tiny", 10),
273
+ )
274
+ for line in r.stdout.splitlines():
275
+ if "=" in line:
276
+ val = line.split("=", 1)[1].strip()
277
+ if val:
278
+ return val
279
+ except Exception:
280
+ pass
281
+ return ""
282
+
283
+
284
+ # ── Chassis parsing ────────────────────────────────────────────────────────────
285
+
286
+ _LAPTOP_CHASSIS = {8, 9, 10, 11, 12, 14, 30, 31, 32}
287
+ _DESKTOP_CHASSIS = {3, 4, 5, 6, 7, 13, 15, 16, 35, 36}
288
+
289
+
290
+ def _parse_chassis(raw: str) -> str:
291
+ """Parse chassis type string from either PS ('3,8') or wmic ('{3}') format."""
292
+ try:
293
+ cleaned = raw.replace("{", "").replace("}", "").replace(" ", "")
294
+ nums = {int(x) for x in cleaned.split(",") if x.strip().isdigit()}
295
+ if any(n in _LAPTOP_CHASSIS for n in nums):
296
+ return "laptop"
297
+ if any(n in _DESKTOP_CHASSIS for n in nums):
298
+ return "desktop"
299
+ except Exception:
300
+ pass
301
+ return "unknown"
302
+
303
+
304
+ # ── Shared utilities ───────────────────────────────────────────────────────────
305
+
306
+ _JUNK_VALUES = {"", "none", "unknown", "to be filled by o.e.m.",
307
+ "not specified", "name", "default string", "system product name"}
308
+
309
+
310
+ def _is_meaningful(val: str) -> bool:
311
+ return bool(val) and val.strip().lower() not in _JUNK_VALUES
312
+
313
+
314
+ def _clean(val: str) -> str:
315
+ v = (val or "").strip()
316
+ return v if _is_meaningful(v) else ""
317
+
318
+
319
+ def _safe_int(val: str) -> int:
320
+ try:
321
+ return int(val.strip())
322
+ except (ValueError, AttributeError):
323
+ return 0
324
+
325
+
326
+ def _load_versions_cache(cfg: dict, hwid_hash: str) -> dict | None:
327
+ path = Path(cfg.get("cache_dir", "C:\\DriverServer\\client")) / "ds_versions_cache.json"
328
+ if not path.exists():
329
+ return None
330
+ try:
331
+ cached = json.loads(path.read_text())
332
+ if cached.get("hwid_hash") == hwid_hash:
333
+ return cached.get("versions", {})
334
+ except Exception:
335
+ pass
336
+ return None
337
+
338
+
339
+ def _save_versions_cache(cfg: dict, hwid_hash: str, versions: dict) -> None:
340
+ path = Path(cfg.get("cache_dir", "C:\\DriverServer\\client")) / "ds_versions_cache.json"
341
+ try:
342
+ path.parent.mkdir(parents=True, exist_ok=True)
343
+ path.write_text(json.dumps({"hwid_hash": hwid_hash, "versions": versions}))
344
+ except Exception:
345
+ pass