driverclient 0.2.26__tar.gz → 0.2.28__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 (26) hide show
  1. {driverclient-0.2.26 → driverclient-0.2.28}/PKG-INFO +1 -1
  2. driverclient-0.2.28/pyproject.toml +123 -0
  3. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/__init__.py +3 -2
  4. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/__main__.py +1 -0
  5. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/config.py +22 -35
  6. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/core/hardware.py +79 -45
  7. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/core/http.py +57 -34
  8. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/core/hwid.py +15 -5
  9. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/core/proc.py +2 -0
  10. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/events.py +44 -29
  11. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/main.py +14 -11
  12. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/ops/__init__.py +10 -5
  13. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/ops/automate.py +83 -54
  14. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/ops/capture.py +391 -247
  15. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/ops/diagnose.py +2 -1
  16. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/ops/download.py +10 -11
  17. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/ops/install.py +156 -98
  18. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/ops/offline.py +29 -18
  19. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/ops/resolve.py +46 -36
  20. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/ops/scan.py +63 -50
  21. driverclient-0.2.26/pyproject.toml +0 -42
  22. {driverclient-0.2.26 → driverclient-0.2.28}/.gitignore +0 -0
  23. {driverclient-0.2.26 → driverclient-0.2.28}/README.md +0 -0
  24. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/config.json +0 -0
  25. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/core/__init__.py +0 -0
  26. {driverclient-0.2.26 → driverclient-0.2.28}/src/driverclient/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driverclient
3
- Version: 0.2.26
3
+ Version: 0.2.28
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>
@@ -0,0 +1,123 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "driverclient"
7
+ version = "0.2.28"
8
+ description = "Driver Server client with an embeddable connector for config injection at runtime."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Raja Sanaullah", email = "sanaullah@99technologies.com" },
14
+ ]
15
+ keywords = ["driver", "windows", "pnputil", "deployment", "pxe"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Operating System :: Microsoft :: Windows",
20
+ "Intended Audience :: System Administrators",
21
+ ]
22
+ dependencies = [
23
+ "zstandard>=0.22.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://example.com/driverclient"
28
+
29
+ # ── Dependency groups (PEP 735) ───────────────────────────────────────────────
30
+ # Tooling only — never shipped in the wheel (dependency groups are not part of
31
+ # the built distribution). ruff is pinned to the same version as the repo-root
32
+ # .pre-commit-config.yaml so a local commit and a manual run apply identical rules.
33
+ [dependency-groups]
34
+ dev = [
35
+ "ruff==0.15.22",
36
+ "mypy",
37
+ ]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/driverclient"]
41
+ # Ship non-Python package data (config template + type marker) inside the wheel.
42
+ artifacts = [
43
+ "src/driverclient/config.json",
44
+ "src/driverclient/py.typed",
45
+ ]
46
+
47
+ [tool.hatch.build.targets.sdist]
48
+ include = [
49
+ "src/driverclient",
50
+ "README.md",
51
+ "pyproject.toml",
52
+ ]
53
+
54
+ # ── Ruff (lint + format) ──────────────────────────────────────────────────────
55
+ # Mirrors backend/pyproject.toml so both halves of the repo read the same, with
56
+ # two deliberate differences: target-version follows this package's
57
+ # requires-python (>=3.10, not the backend's 3.12), and the ASYNC/FastAPI rules
58
+ # are dropped — the client is synchronous (threads + concurrent.futures).
59
+ [tool.ruff]
60
+ target-version = "py310" # floor of requires-python; the client runs on stock Windows Pythons
61
+ line-length = 100
62
+ extend-exclude = [".venv", ".venv-smoke", "dist", "build"]
63
+
64
+ [tool.ruff.lint]
65
+ select = [
66
+ "E", "W", # pycodestyle
67
+ "F", # pyflakes (unused imports/names)
68
+ "I", # isort (sorts imports on --fix)
69
+ "N", # pep8-naming
70
+ "B", # flake8-bugbear (common bug patterns)
71
+ "UP", # pyupgrade (modernize syntax)
72
+ "SIM", # flake8-simplify
73
+ "C4", # flake8-comprehensions
74
+ "RUF", # ruff-specific
75
+ "LOG", # flake8-logging — logging hygiene
76
+ ]
77
+ ignore = [
78
+ "E501", # line length is enforced by the formatter, not the linter
79
+ "RUF002", # ambiguous unicode (×, –) in docstrings — intentional typography
80
+ "RUF003", # ambiguous unicode (×, –) in comments — intentional typography
81
+ # ── Parked: promote to enforced after a dedicated cleanup pass ──
82
+ "N818", # exception *Error suffix — renaming exception classes is cross-cutting churn
83
+ "UP042", # str-Enum -> StrEnum — behavior-sensitive under JSON serialization
84
+ "UP046", # PEP 695 generic class
85
+ "UP047", # PEP 695 generic function
86
+ ]
87
+
88
+ [tool.ruff.lint.per-file-ignores]
89
+ "src/driverclient/__init__.py" = ["F401"] # re-exports are intentional in the package entry point
90
+ "examples/run_client.py" = ["E402"] # stdout/stderr must be reconfigured before the driverclient import
91
+
92
+ [tool.ruff.lint.isort]
93
+ known-first-party = ["driverclient"]
94
+ combine-as-imports = true
95
+
96
+ [tool.ruff.format]
97
+ quote-style = "double"
98
+ indent-style = "space"
99
+
100
+ # ── Mypy (non-strict baseline; ratchet strictness per-module later) ───────────
101
+ # The package ships py.typed, so these annotations are part of its public
102
+ # contract for embedding apps — worth tightening (disallow_untyped_defs) once
103
+ # the current signatures are annotated.
104
+ [tool.mypy]
105
+ python_version = "3.10"
106
+ # src layout: the import root is src/, not the package directory. mypy_path lets
107
+ # `import driverclient` resolve from examples/ and the root scripts without an
108
+ # editable install. (mypy accepts only ONE of packages/modules/files — so the
109
+ # package is listed here as a path, not via `packages`.)
110
+ mypy_path = "src"
111
+ files = ["src/driverclient", "examples", "smoke_test.py", "bump_version.py"]
112
+ # Analyse as Windows. Without this, mypy assumes the host platform, treats every
113
+ # `if sys.platform == "win32":` block as unreachable, and silently skips the bulk
114
+ # of the client's real logic — and `import winreg` fails to resolve on macOS/Linux.
115
+ platform = "win32"
116
+ warn_redundant_casts = true
117
+ warn_unused_ignores = true
118
+ warn_unused_configs = true
119
+
120
+ [[tool.mypy.overrides]]
121
+ # Third-party packages without type stubs.
122
+ module = ["zstandard.*"]
123
+ ignore_missing_imports = true
@@ -19,13 +19,14 @@ Typical use from an embedding app (e.g. a PyQt front-end):
19
19
  Config merge priority (highest wins):
20
20
  DEFAULTS < DS_CLIENT_CONFIG env file < config_path file < kwargs
21
21
  """
22
+
22
23
  from driverclient import config as _config
23
24
  from driverclient.events import ClientEvent, using_sink
24
25
  from driverclient.main import run as _run
25
26
 
26
- __all__ = ["DriverClient", "ClientEvent"]
27
+ __all__ = ["ClientEvent", "DriverClient"]
27
28
 
28
- __version__ = "0.2.26"
29
+ __version__ = "0.2.28"
29
30
 
30
31
 
31
32
  class DriverClient:
@@ -1,4 +1,5 @@
1
1
  """Allow: python -m driverclient"""
2
+
2
3
  from driverclient.main import run
3
4
 
4
5
  run()
@@ -4,15 +4,15 @@ driverclient/config.py — Load client settings.
4
4
  Merge priority (highest wins):
5
5
  DEFAULTS < DS_CLIENT_CONFIG env file < file passed to load() < overrides
6
6
  """
7
+
7
8
  import json
8
9
  import os
9
10
  from pathlib import Path
10
11
 
11
12
  DEFAULTS: dict = {
12
13
  # ── Server ────────────────────────────────────────────────────────────
13
- "local_repo_url": "http://localhost:8000",
14
- "node_key": "",
15
-
14
+ "local_repo_url": "http://localhost:8000",
15
+ "node_key": "",
16
16
  # ── Mode ──────────────────────────────────────────────────────────────
17
17
  # "live" = SA / first-boot: batched pnputil install + reboot loop + WU +
18
18
  # capture (today's behaviour; handles the post-boot device tail).
@@ -20,36 +20,29 @@ DEFAULTS: dict = {
20
20
  # offline_driver_dir. No install, no reboot, no WU, no capture —
21
21
  # win11.bat.j2's DISM step injects the files into the image.
22
22
  "mode": "live",
23
-
24
23
  # ── Offline (pre-boot / WinPE) ────────────────────────────────────────
25
24
  # Where offline mode writes the decompressed driver files. DISM
26
25
  # (win11.bat.j2) injects them into the applied image at W:\ afterward — the
27
26
  # client only downloads; it never runs DISM itself.
28
27
  "offline_driver_dir": "W:\\drivers",
29
-
30
28
  # Request zstd-compressed downloads (decompressed on-the-fly in core/http.py):
31
29
  # GPU ≈ 450 MB instead of 1.3 GB. True everywhere by default; set False only
32
30
  # as a WinPE fallback if zstd ever fails to load there. Gates the
33
31
  # Accept-Encoding: zstd request header sent by http_download.
34
32
  "compression": True,
35
-
36
33
  # ── Command ───────────────────────────────────────────────────────────
37
- "default_command": "automate",
38
-
34
+ "default_command": "automate",
39
35
  # ── Parallelism ───────────────────────────────────────────────────────
40
36
  "parallel_downloads": 6,
41
- "parallel_uploads": 8,
42
- "parallel_exports": 4,
43
-
37
+ "parallel_uploads": 8,
38
+ "parallel_exports": 4,
44
39
  # ── Paths ─────────────────────────────────────────────────────────────
45
40
  "driver_root": "C:\\DriverServer",
46
- "cache_dir": "C:\\DriverServer\\client",
47
- "work_dir": "C:\\DriverServer\\client\\staging",
48
-
41
+ "cache_dir": "C:\\DriverServer\\client",
42
+ "work_dir": "C:\\DriverServer\\client\\staging",
49
43
  # ── Cache TTL ─────────────────────────────────────────────────────────
50
44
  # ds_scan.json / ds_resolve.json are reused if younger than this many minutes
51
45
  "cache_ttl_minutes": 15,
52
-
53
46
  # ── Capture ───────────────────────────────────────────────────────────
54
47
  # Skip packages larger than this (MB) during capture — keeps the repo light.
55
48
  # A complete driver = its full size; a few (Intel graphics ~1 GB) are huge and
@@ -60,60 +53,54 @@ DEFAULTS: dict = {
60
53
  # non-blocking loop that retries transient failures, so big packages don't
61
54
  # block or get lost). Set >0 only if you want a lighter repo.
62
55
  "capture_max_package_mb": 0,
63
-
64
56
  # [".inf", ".sys", ".cat"] — minimum for server to redistribute and install
65
57
  # [".inf", ".sys", ".cat", ".dll"] — adds co-installer DLLs
66
58
  # ["*"] — all files in the driver package directory
67
- "dump_extensions": [".inf", ".sys", ".cat"],
68
-
59
+ "dump_extensions": [".inf", ".sys", ".cat"],
69
60
  # Per-category extension overrides — key = class_name from pnputil (lowercase)
70
61
  # Categories not listed here use dump_extensions above
71
62
  "dump_extensions_overrides": {
72
- "system": [".inf", ".sys", ".cat", ".dll"],
73
- "usb": [".inf", ".sys", ".cat", ".dll"]
63
+ "system": [".inf", ".sys", ".cat", ".dll"],
64
+ "usb": [".inf", ".sys", ".cat", ".dll"],
74
65
  },
75
-
76
66
  # ── Windows Update ────────────────────────────────────────────────────
77
67
  # ABSOLUTE ceiling for the WU wait — a safety backstop, NOT the expected
78
68
  # wait. The loop stops as soon as WU actually goes idle (see below); this
79
69
  # only bounds a stuck/hanging WU so SA can never stall.
80
- "wu_wait_minutes": 30,
70
+ "wu_wait_minutes": 30,
81
71
  "wu_poll_interval_seconds": 30,
82
72
  # Empty polls required AFTER Windows Update has gone idle before we stop.
83
73
  # While WU is still working (install process alive), empty polls are NOT
84
74
  # counted — so a scan/download that takes 2 min or 8 min both get exactly
85
75
  # the time they need. This is what makes the wait adaptive instead of a
86
76
  # fixed timer.
87
- "wu_quiescent_polls": 2,
77
+ "wu_quiescent_polls": 2,
88
78
  # Classes to skip when deciding which not-found HWIDs trigger WU.
89
79
  # Empty list = no filtering (default — make no assumptions).
90
80
  # Use lowercase class names as reported by pnputil, e.g. "hidclass", "bluetooth".
91
- "wu_ignore_classes": ["hidclass"],
92
-
81
+ "wu_ignore_classes": ["hidclass"],
93
82
  # ── Automate flags ────────────────────────────────────────────────────
94
83
  "automate_wu_fallback": True,
95
- "automate_re_resolve": False,
84
+ "automate_re_resolve": False,
96
85
  # Extra same-boot scan→resolve→install passes after the first install, to
97
86
  # pick up devices exposed only once a parent driver binds (chained deps).
98
87
  # 0 disables the loop (single install pass). Stops early when a pass finds
99
88
  # no new repo drivers or installs nothing.
100
89
  "automate_rescan_passes": 3,
101
- "force_resolve": False,
90
+ "force_resolve": False,
102
91
  # The server now offers every driver matching each device (primary + extensions
103
92
  # + companions). Before downloading each, skip it if its INF is already in the
104
93
  # Windows driver store at the same-or-newer version — so a still-current big
105
94
  # driver (e.g. graphics) is never re-downloaded, while missing/extension drivers
106
95
  # still install. Set False to force-reinstall everything the server offers.
107
96
  "skip_installed_drivers": True,
108
-
109
97
  # ── HTTP timeouts (seconds) ───────────────────────────────────────────
110
- "timeout_tiny": 10,
111
- "timeout_short": 30,
112
- "timeout_medium": 60,
113
- "timeout_long": 120,
114
- "timeout_install": 600,
115
- "timeout_wmi_slow": 90,
116
-
98
+ "timeout_tiny": 10,
99
+ "timeout_short": 30,
100
+ "timeout_medium": 60,
101
+ "timeout_long": 120,
102
+ "timeout_install": 600,
103
+ "timeout_wmi_slow": 90,
117
104
  # Package-upload timeout scales with archive size instead of a flat 60s so a
118
105
  # ~1 GB graphics package never times out mid-transfer. This is the assumed
119
106
  # worst-case sustained upload throughput (MB/s) used to derive the socket
@@ -8,6 +8,7 @@ Strategy:
8
8
  Still works on Win10/11; deprecated in Win11 22H2+ but warnings
9
9
  go to stderr which is captured and discarded.
10
10
  """
11
+
11
12
  import hashlib
12
13
  import json
13
14
  import uuid
@@ -60,32 +61,34 @@ Get-CimInstance -ClassName Win32_PnPSignedDriver |
60
61
 
61
62
  # ── Public API ─────────────────────────────────────────────────────────────────
62
63
 
64
+
63
65
  def compute_fingerprint() -> str:
64
66
  """Stable machine UUID derived from MB serial + CPU ID + first NIC MAC."""
65
67
  data = _get_profile_data()
66
68
  if data:
67
- mb = data.get("serial_board", "") or data.get("serial_bios", "")
69
+ mb = data.get("serial_board", "") or data.get("serial_bios", "")
68
70
  cpu = data.get("cpu_id", "")
69
71
  mac = data.get("mac", "")
70
72
  else:
71
73
  with ThreadPoolExecutor(max_workers=3) as ex:
72
- mb_f = ex.submit(_wmic, "baseboard get SerialNumber")
74
+ mb_f = ex.submit(_wmic, "baseboard get SerialNumber")
73
75
  cpu_f = ex.submit(_wmic, "cpu get ProcessorId")
74
76
  mac_f = ex.submit(_wmic, "nic where AdapterTypeId=0 get MACAddress")
75
77
  mb, cpu, mac = mb_f.result(), cpu_f.result(), mac_f.result()
76
- raw = f"{mb}|{cpu}|{mac}"
78
+ raw = f"{mb}|{cpu}|{mac}"
77
79
  digest = hashlib.sha256(raw.encode()).digest()
78
80
  return str(uuid.UUID(bytes=digest[:16]))
79
81
 
80
82
 
81
83
  def get_hwids() -> list[str]:
82
84
  """Return all connected device HWIDs via pnputil."""
83
- cfg = get()
85
+ cfg = get()
84
86
  hwids = []
85
87
  try:
86
88
  r = proc.run(
87
89
  ["pnputil", "/enum-devices", "/connected", "/ids"],
88
- capture_output=True, text=True,
90
+ capture_output=True,
91
+ text=True,
89
92
  timeout=cfg["timeout_short"],
90
93
  )
91
94
  for line in r.stdout.splitlines():
@@ -114,9 +117,9 @@ def get_installed_versions(hwids: list[str]) -> dict[str, str]:
114
117
  Tries PowerShell first, falls back to wmic CSV.
115
118
  Caches result keyed by sorted HWID list hash.
116
119
  """
117
- cfg = get()
120
+ cfg = get()
118
121
  hwid_hash = hashlib.sha256("|".join(sorted(hwids)).encode()).hexdigest()[:16]
119
- cache = _load_versions_cache(cfg, hwid_hash)
122
+ cache = _load_versions_cache(cfg, hwid_hash)
120
123
  if cache is not None:
121
124
  return cache
122
125
 
@@ -131,13 +134,23 @@ def get_installed_versions(hwids: list[str]) -> dict[str, str]:
131
134
 
132
135
  # ── PowerShell helpers ─────────────────────────────────────────────────────────
133
136
 
137
+
134
138
  def _run_ps(script: str, timeout: int) -> str:
135
139
  """Run a PowerShell script inline. Returns stdout or '' on failure."""
136
140
  try:
137
141
  r = proc.run(
138
- ["powershell", "-NoProfile", "-NonInteractive",
139
- "-ExecutionPolicy", "Bypass", "-Command", script],
140
- capture_output=True, text=True, timeout=timeout,
142
+ [
143
+ "powershell",
144
+ "-NoProfile",
145
+ "-NonInteractive",
146
+ "-ExecutionPolicy",
147
+ "Bypass",
148
+ "-Command",
149
+ script,
150
+ ],
151
+ capture_output=True,
152
+ text=True,
153
+ timeout=timeout,
141
154
  )
142
155
  return r.stdout.strip() if r.returncode == 0 else ""
143
156
  except Exception:
@@ -146,8 +159,8 @@ def _run_ps(script: str, timeout: int) -> str:
146
159
 
147
160
  def _get_profile_data() -> dict:
148
161
  """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))
162
+ cfg = get()
163
+ out = _run_ps(_PROFILE_PS, cfg.get("timeout_wmi_slow", 90))
151
164
  if not out:
152
165
  return {}
153
166
  try:
@@ -163,7 +176,11 @@ def _profile_from_ps(data: dict) -> dict:
163
176
  # human name in ComputerSystemProduct.Version / SystemFamily (e.g. "ThinkPad T460s").
164
177
  # Prefer the first meaningful candidate that isn't just the machine-type code.
165
178
  friendly = ""
166
- for cand in (data.get("product_version"), data.get("system_family"), data.get("friendly_model")):
179
+ for cand in (
180
+ data.get("product_version"),
181
+ data.get("system_family"),
182
+ data.get("friendly_model"),
183
+ ):
167
184
  cand = _clean(cand or "")
168
185
  if _is_meaningful(cand) and cand.lower() != model.lower():
169
186
  friendly = cand
@@ -171,21 +188,21 @@ def _profile_from_ps(data: dict) -> dict:
171
188
  if not friendly:
172
189
  friendly = _clean(data.get("friendly_model", "") or "")
173
190
  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",
191
+ "vendor": _clean(data.get("vendor", "Unknown")),
192
+ "model": model,
193
+ "friendly_model": _clean(friendly),
194
+ "serial": _clean(data.get("serial_bios") or data.get("serial_board", "")),
195
+ "chassis_type": _parse_chassis(data.get("chassis", "")),
196
+ "os_build": _safe_int(data.get("os_build", "0")),
197
+ "arch": "x64",
181
198
  "firmware_version": _clean(data.get("firmware", "")),
182
199
  }
183
200
 
184
201
 
185
202
  def _installed_versions_ps() -> dict[str, str]:
186
203
  """Get installed driver versions via PowerShell. Returns {} on failure."""
187
- cfg = get()
188
- out = _run_ps(_INSTALLED_PS, cfg.get("timeout_wmi_slow", 90))
204
+ cfg = get()
205
+ out = _run_ps(_INSTALLED_PS, cfg.get("timeout_wmi_slow", 90))
189
206
  if not out:
190
207
  return {}
191
208
  try:
@@ -203,23 +220,24 @@ def _installed_versions_ps() -> dict[str, str]:
203
220
 
204
221
  # ── wmic fallback helpers ──────────────────────────────────────────────────────
205
222
 
223
+
206
224
  def _profile_from_wmic() -> dict:
207
225
  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")
226
+ vendor_f = ex.submit(_wmic, "computersystem get Manufacturer")
227
+ model_f = ex.submit(_wmic, "computersystem get Model")
210
228
  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")
229
+ serial_f = ex.submit(_get_serial_wmic)
230
+ chassis_f = ex.submit(_get_chassis_wmic)
231
+ os_f = ex.submit(_wmic, "os get BuildNumber")
232
+ fw_f = ex.submit(_wmic, "bios get SMBIOSBIOSVersion")
215
233
  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",
234
+ "vendor": vendor_f.result() or "Unknown",
235
+ "model": model_f.result() or "Unknown",
236
+ "friendly_model": friendly_f.result(),
237
+ "serial": serial_f.result(),
238
+ "chassis_type": chassis_f.result(),
239
+ "os_build": _safe_int(os_f.result()),
240
+ "arch": "x64",
223
241
  "firmware_version": fw_f.result() or "",
224
242
  }
225
243
 
@@ -228,16 +246,23 @@ def _installed_versions_wmic(cfg: dict) -> dict[str, str]:
228
246
  versions: dict[str, str] = {}
229
247
  try:
230
248
  r = proc.run(
231
- ["wmic", "path", "Win32_PnPSignedDriver",
232
- "get", "HardWareID,DriverVersion", "/format:csv"],
233
- capture_output=True, text=True,
249
+ [
250
+ "wmic",
251
+ "path",
252
+ "Win32_PnPSignedDriver",
253
+ "get",
254
+ "HardWareID,DriverVersion",
255
+ "/format:csv",
256
+ ],
257
+ capture_output=True,
258
+ text=True,
234
259
  timeout=cfg.get("timeout_wmi_slow", 90),
235
260
  )
236
261
  for line in r.stdout.splitlines()[1:]:
237
262
  parts = line.strip().split(",")
238
263
  if len(parts) >= 3:
239
264
  hwid = parts[1].strip().upper()
240
- ver = parts[2].strip()
265
+ ver = parts[2].strip()
241
266
  if hwid and ver:
242
267
  versions[hwid] = ver
243
268
  except Exception as e:
@@ -267,8 +292,9 @@ def _wmic(query: str) -> str:
267
292
  cfg = get()
268
293
  try:
269
294
  r = proc.run(
270
- ["wmic"] + query.split() + ["/format:value"],
271
- capture_output=True, text=True,
295
+ ["wmic", *query.split(), "/format:value"],
296
+ capture_output=True,
297
+ text=True,
272
298
  timeout=cfg.get("timeout_tiny", 10),
273
299
  )
274
300
  for line in r.stdout.splitlines():
@@ -283,7 +309,7 @@ def _wmic(query: str) -> str:
283
309
 
284
310
  # ── Chassis parsing ────────────────────────────────────────────────────────────
285
311
 
286
- _LAPTOP_CHASSIS = {8, 9, 10, 11, 12, 14, 30, 31, 32}
312
+ _LAPTOP_CHASSIS = {8, 9, 10, 11, 12, 14, 30, 31, 32}
287
313
  _DESKTOP_CHASSIS = {3, 4, 5, 6, 7, 13, 15, 16, 35, 36}
288
314
 
289
315
 
@@ -292,7 +318,7 @@ def _parse_chassis(raw: str) -> str:
292
318
  try:
293
319
  cleaned = raw.replace("{", "").replace("}", "").replace(" ", "")
294
320
  nums = {int(x) for x in cleaned.split(",") if x.strip().isdigit()}
295
- if any(n in _LAPTOP_CHASSIS for n in nums):
321
+ if any(n in _LAPTOP_CHASSIS for n in nums):
296
322
  return "laptop"
297
323
  if any(n in _DESKTOP_CHASSIS for n in nums):
298
324
  return "desktop"
@@ -303,8 +329,16 @@ def _parse_chassis(raw: str) -> str:
303
329
 
304
330
  # ── Shared utilities ───────────────────────────────────────────────────────────
305
331
 
306
- _JUNK_VALUES = {"", "none", "unknown", "to be filled by o.e.m.",
307
- "not specified", "name", "default string", "system product name"}
332
+ _JUNK_VALUES = {
333
+ "",
334
+ "none",
335
+ "unknown",
336
+ "to be filled by o.e.m.",
337
+ "not specified",
338
+ "name",
339
+ "default string",
340
+ "system product name",
341
+ }
308
342
 
309
343
 
310
344
  def _is_meaningful(val: str) -> bool: