driverclient 0.2.20__tar.gz → 0.2.21__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 (25) hide show
  1. {driverclient-0.2.20 → driverclient-0.2.21}/PKG-INFO +1 -1
  2. {driverclient-0.2.20 → driverclient-0.2.21}/pyproject.toml +1 -1
  3. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/__init__.py +1 -1
  4. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/config.json +7 -0
  5. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/config.py +20 -0
  6. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/core/http.py +6 -1
  7. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/main.py +2 -0
  8. driverclient-0.2.21/src/driverclient/ops/download.py +63 -0
  9. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/ops/install.py +5 -19
  10. driverclient-0.2.21/src/driverclient/ops/offline.py +101 -0
  11. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/ops/resolve.py +15 -5
  12. {driverclient-0.2.20 → driverclient-0.2.21}/.gitignore +0 -0
  13. {driverclient-0.2.20 → driverclient-0.2.21}/README.md +0 -0
  14. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/__main__.py +0 -0
  15. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/core/__init__.py +0 -0
  16. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/core/hardware.py +0 -0
  17. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/core/hwid.py +0 -0
  18. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/core/proc.py +0 -0
  19. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/events.py +0 -0
  20. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/ops/__init__.py +0 -0
  21. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/ops/automate.py +0 -0
  22. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/ops/capture.py +0 -0
  23. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/ops/diagnose.py +0 -0
  24. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/ops/scan.py +0 -0
  25. {driverclient-0.2.20 → driverclient-0.2.21}/src/driverclient/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driverclient
3
- Version: 0.2.20
3
+ Version: 0.2.21
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>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "driverclient"
7
- version = "0.2.20"
7
+ version = "0.2.21"
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.20"
28
+ __version__ = "0.2.21"
29
29
 
30
30
 
31
31
  class DriverClient:
@@ -1,13 +1,20 @@
1
1
  {
2
+ "_comment_mode": "mode = 'live' (default): SA/first-boot install+reboot+WU+capture. mode = 'offline': WinPE download-only to offline_driver_dir, then win11.bat.j2 DISM-injects. See examples/config.json for the full prod-deploy note.",
3
+ "mode": "live",
2
4
  "local_repo_url": "http://localhost:8000",
3
5
  "node_key": "REPLACE_WITH_YOUR_NODE_KEY",
4
6
 
5
7
  "default_command": "automate",
6
8
 
9
+ "_comment_compression": "zstd downloads decompressed on-the-fly; keep true, set false only if zstd fails in WinPE.",
10
+ "compression": true,
7
11
  "parallel_downloads": 6,
8
12
  "parallel_uploads": 8,
9
13
  "parallel_exports": 4,
10
14
 
15
+ "_comment_offline_driver_dir": "offline mode writes files here (WinPE); DISM injects into the applied image W:\\. Ignored in live mode.",
16
+ "offline_driver_dir": "W:\\drivers",
17
+
11
18
  "driver_root": "C:\\DriverServer",
12
19
  "cache_dir": "C:\\DriverServer\\client",
13
20
  "work_dir": "C:\\DriverServer\\client\\staging",
@@ -13,6 +13,26 @@ DEFAULTS: dict = {
13
13
  "local_repo_url": "http://localhost:8000",
14
14
  "node_key": "",
15
15
 
16
+ # ── Mode ──────────────────────────────────────────────────────────────
17
+ # "live" = SA / first-boot: batched pnputil install + reboot loop + WU +
18
+ # capture (today's behaviour; handles the post-boot device tail).
19
+ # "offline" = pre-boot / WinPE: scan → resolve → DOWNLOAD ONLY to
20
+ # offline_driver_dir. No install, no reboot, no WU, no capture —
21
+ # win11.bat.j2's DISM step injects the files into the image.
22
+ "mode": "live",
23
+
24
+ # ── Offline (pre-boot / WinPE) ────────────────────────────────────────
25
+ # Where offline mode writes the decompressed driver files. DISM
26
+ # (win11.bat.j2) injects them into the applied image at W:\ afterward — the
27
+ # client only downloads; it never runs DISM itself.
28
+ "offline_driver_dir": "W:\\drivers",
29
+
30
+ # Request zstd-compressed downloads (decompressed on-the-fly in core/http.py):
31
+ # GPU ≈ 450 MB instead of 1.3 GB. True everywhere by default; set False only
32
+ # as a WinPE fallback if zstd ever fails to load there. Gates the
33
+ # Accept-Encoding: zstd request header sent by http_download.
34
+ "compression": True,
35
+
16
36
  # ── Command ───────────────────────────────────────────────────────────
17
37
  "default_command": "automate",
18
38
 
@@ -194,7 +194,12 @@ def http_download(url: str, dest: Path, sha256_expected: str,
194
194
  """
195
195
  cfg = get()
196
196
  t = timeout or cfg["timeout_long"]
197
- headers = {**_headers(), "Accept-Encoding": "zstd"}
197
+ # Only advertise zstd when compression is enabled. Offline/WinPE keeps it on
198
+ # (GPU ≈ 450 MB vs 1.3 GB); set compression=false as a fallback if zstd ever
199
+ # fails to load in WinPE, and the server serves the file uncompressed.
200
+ headers = dict(_headers())
201
+ if cfg.get("compression", True):
202
+ headers["Accept-Encoding"] = "zstd"
198
203
 
199
204
  req = urllib.request.Request(url, headers=headers)
200
205
  dest.parent.mkdir(parents=True, exist_ok=True)
@@ -28,6 +28,7 @@ from driverclient.ops.capture import (
28
28
  )
29
29
  from driverclient.ops.diagnose import run_diagnostics
30
30
  from driverclient.ops.install import resolve_and_install
31
+ from driverclient.ops.offline import resolve_and_download
31
32
  from driverclient.ops.resolve import resolve
32
33
  from driverclient.ops.scan import scan
33
34
 
@@ -35,6 +36,7 @@ _OPERATIONS: dict[str, callable] = {
35
36
  "scan": scan,
36
37
  "resolve": resolve,
37
38
  "resolve-and-install": resolve_and_install,
39
+ "offline": resolve_and_download,
38
40
  "capture-all": capture_all,
39
41
  "capture-missing": capture_missing,
40
42
  "capture-delta": capture_delta,
@@ -0,0 +1,63 @@
1
+ """
2
+ client/ops/download.py — Shared driver-file download primitives.
3
+
4
+ Both the LIVE install path (ops/install.py) and the OFFLINE / pre-boot path
5
+ (ops/offline.py) fetch the same package files from the repo:
6
+
7
+ GET {repo_url}/files/{package_id}/{filename} (streamed, zstd on-the-fly,
8
+ sha256-verified — see core/http.py)
9
+
10
+ Factored out here so the two modes share one download implementation:
11
+ • download_driver() — fetch every file for ONE driver into base/<binding_id>/
12
+ • download_drivers() — fan the above out across a driver list in parallel
13
+ """
14
+ from concurrent.futures import ThreadPoolExecutor, as_completed
15
+ from pathlib import Path
16
+
17
+ from driverclient.core.http import http_download_retry
18
+
19
+
20
+ def download_driver(driver: dict, base: Path, repo_url: str,
21
+ cfg: dict) -> tuple[Path, bool]:
22
+ """Download all files for one driver into base/<binding_id>/.
23
+ Returns (dest_dir, success). success is False if ANY file failed."""
24
+ bid = driver.get("binding_id") or driver.get("package_id", "")
25
+ pkg_id = driver["package_id"]
26
+ dest = base / bid
27
+ dest.mkdir(parents=True, exist_ok=True)
28
+
29
+ ok = True
30
+ for f in driver.get("files", []):
31
+ url = f"{repo_url}/files/{pkg_id}/{f['filename']}"
32
+ path = dest / f["filename"]
33
+ if not http_download_retry(url, path, f["sha256"],
34
+ timeout=cfg["timeout_long"]):
35
+ ok = False
36
+
37
+ return dest, ok
38
+
39
+
40
+ def download_drivers(drivers: list[dict], base: Path,
41
+ cfg: dict) -> list[tuple[dict, Path | None, bool]]:
42
+ """Download a list of drivers into `base` in parallel.
43
+
44
+ Returns a list of (driver, dest_dir | None, success) in completion order.
45
+ A driver that raised during download yields (driver, None, False) rather
46
+ than propagating — the caller decides how to report failures.
47
+ """
48
+ repo_url = cfg["local_repo_url"]
49
+ parallel = cfg.get("parallel_downloads", 6)
50
+
51
+ results: list[tuple[dict, Path | None, bool]] = []
52
+ with ThreadPoolExecutor(max_workers=parallel) as executor:
53
+ fut_map = {executor.submit(download_driver, d, base, repo_url, cfg): d
54
+ for d in drivers}
55
+ for fut in as_completed(fut_map):
56
+ driver = fut_map[fut]
57
+ try:
58
+ dest, ok = fut.result()
59
+ except Exception:
60
+ results.append((driver, None, False))
61
+ continue
62
+ results.append((driver, dest, ok))
63
+ return results
@@ -26,7 +26,8 @@ from pathlib import Path
26
26
  from driverclient import events
27
27
  from driverclient.config import get
28
28
  from driverclient.core import proc
29
- from driverclient.core.http import http_download_retry, http_post_retry
29
+ from driverclient.core.http import http_post_retry
30
+ from driverclient.ops.download import download_driver
30
31
  from driverclient.ops.resolve import ResolveResult, resolve
31
32
 
32
33
  # Install order: lower number first (hardware dependency)
@@ -248,7 +249,7 @@ def _stream_install(drivers: list[dict], base: Path, cfg: dict) -> list[DriverRe
248
249
  results: list[DriverResult] = []
249
250
  downloaded: list[tuple[dict, Path]] = []
250
251
  with ThreadPoolExecutor(max_workers=parallel) as executor:
251
- fut_map = {executor.submit(_download_driver, d, base, repo_url, cfg): d
252
+ fut_map = {executor.submit(download_driver, d, base, repo_url, cfg): d
252
253
  for d in ordered}
253
254
  for fut in as_completed(fut_map):
254
255
  driver = fut_map[fut]
@@ -364,23 +365,8 @@ def _install_one_exe(driver: dict, driver_dir: Path, cfg: dict) -> DriverResult:
364
365
  duration_ms=dur, reboot_required=reboot)
365
366
 
366
367
 
367
- def _download_driver(driver: dict, base: Path, repo_url: str,
368
- cfg: dict) -> tuple[Path, bool]:
369
- """Download all files for one driver. Returns (dest_dir, success)."""
370
- bid = driver.get("binding_id") or driver.get("package_id", "")
371
- pkg_id = driver["package_id"]
372
- dest = base / bid
373
- dest.mkdir(parents=True, exist_ok=True)
374
-
375
- ok = True
376
- for f in driver.get("files", []):
377
- url = f"{repo_url}/files/{pkg_id}/{f['filename']}"
378
- path = dest / f["filename"]
379
- if not http_download_retry(url, path, f["sha256"],
380
- timeout=cfg["timeout_long"]):
381
- ok = False
382
-
383
- return dest, ok
368
+ # Driver-file download lives in ops/download.py (download_driver), shared with
369
+ # the offline / pre-boot path.
384
370
 
385
371
 
386
372
  # ── Driver installation ─────────────────────────────────────────────────────────
@@ -0,0 +1,101 @@
1
+ """
2
+ client/ops/offline.py — Offline / pre-boot (WinPE) driver staging.
3
+
4
+ The FAST path, matching the old system: run inside WinPE against a freshly
5
+ applied Windows image, enumerate the machine's real HWIDs, resolve them against
6
+ the driverserver, and DOWNLOAD the matched driver files to offline_driver_dir
7
+ (default W:\\drivers). That's ALL this op does — it never installs.
8
+
9
+ win11.bat.j2 runs the injection afterward:
10
+
11
+ X:\\tools\\driverclient\\driverclient.exe (this op, mode=offline)
12
+ dism /image:W:\\ /add-driver /driver:W:\\drivers /recurse /forceunsigned
13
+
14
+ So there is NO live install, NO reboot loop, NO Windows Update, NO capture here —
15
+ everything is injected offline into the image and is present instantly at first
16
+ boot. The small post-boot device tail that WinPE can't see is handled later by
17
+ the LIVE mode (SA finalization).
18
+
19
+ Layout: each driver's files land in offline_driver_dir/<binding_id>/, so
20
+ duplicate INF filenames across packages never collide and DISM /recurse picks
21
+ them all up.
22
+
23
+ Public API:
24
+ resolve_and_download() -> OfflineResult
25
+ """
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ from driverclient import events
30
+ from driverclient.config import get
31
+ from driverclient.ops.download import download_drivers
32
+ from driverclient.ops.resolve import resolve
33
+
34
+
35
+ @dataclass
36
+ class OfflineResult:
37
+ downloaded_ok: list[str] = field(default_factory=list)
38
+ download_failed: list[str] = field(default_factory=list)
39
+ not_found: list[str] = field(default_factory=list)
40
+ trace_id: str = ""
41
+ target_dir: str = ""
42
+
43
+ @property
44
+ def ok_count(self) -> int: return len(self.downloaded_ok)
45
+ @property
46
+ def fail_count(self) -> int: return len(self.download_failed)
47
+
48
+
49
+ def resolve_and_download() -> OfflineResult:
50
+ """
51
+ Scan → resolve (empty installed_versions → full matched set) → download every
52
+ matched driver's files to offline_driver_dir. No install. Returns OfflineResult.
53
+ """
54
+ cfg = get()
55
+ target = Path(cfg.get("offline_driver_dir", "W:\\drivers"))
56
+
57
+ # force=True: fresh WinPE session, no meaningful cache to reuse — always scan
58
+ # the real hardware. offline=True: send empty installed_versions.
59
+ result = resolve(force=True, offline=True)
60
+
61
+ if not result.has_drivers:
62
+ events.done("offline",
63
+ "[offline] Nothing to download — no matched drivers",
64
+ total=0, not_found=len(result.not_found))
65
+ return OfflineResult(trace_id=result.trace_id,
66
+ not_found=result.not_found,
67
+ target_dir=str(target))
68
+
69
+ target.mkdir(parents=True, exist_ok=True)
70
+
71
+ drivers = result.drivers
72
+ events.start("offline",
73
+ f"[offline] {len(drivers)} driver(s) → downloading to {target} …",
74
+ total=len(drivers))
75
+
76
+ downloaded = download_drivers(drivers, target, cfg)
77
+
78
+ ok: list[str] = []
79
+ failed: list[str] = []
80
+ for driver, _dest, success in downloaded:
81
+ bid = driver.get("binding_id") or driver.get("package_id", "")
82
+ (ok if success else failed).append(bid)
83
+
84
+ for bid in failed:
85
+ events.error("offline", f" ✗ {bid}: download_failed", binding_id=bid)
86
+
87
+ events.done(
88
+ "offline",
89
+ f"[offline] Done — {len(ok)} downloaded {len(failed)} failed "
90
+ f"→ {target} (DISM injects offline next)",
91
+ total=len(downloaded), ok=len(ok), failed=len(failed),
92
+ target_dir=str(target),
93
+ )
94
+
95
+ return OfflineResult(
96
+ downloaded_ok=ok,
97
+ download_failed=failed,
98
+ not_found=result.not_found,
99
+ trace_id=result.trace_id,
100
+ target_dir=str(target),
101
+ )
@@ -54,11 +54,17 @@ class ResolveResult:
54
54
  return bool(self.not_found or self.stale)
55
55
 
56
56
 
57
- def resolve(force: bool = False) -> ResolveResult:
57
+ def resolve(force: bool = False, offline: bool = False) -> ResolveResult:
58
58
  """
59
59
  Load (or run) a hardware scan, then POST to /resolve.
60
60
  Returns ResolveResult with drivers[] and not_found[].
61
61
  Saves result to ds_resolve.json.
62
+
63
+ offline: pre-boot / WinPE mode. The scan runs against WinPE, not the applied
64
+ image, so any "installed" versions it sees are irrelevant to the target. Send
65
+ installed_versions empty (and no inbox hints) so the server offers the FULL
66
+ matched set — every physically-present device's driver, GPU included — to be
67
+ DISM-injected into the fresh image. See ops/offline.py.
62
68
  """
63
69
  cfg = get()
64
70
 
@@ -69,10 +75,11 @@ def resolve(force: bool = False) -> ResolveResult:
69
75
  return cached
70
76
 
71
77
  scan_result: ScanResult = scan(force=force)
72
- return _query_repo(scan_result, cfg, force)
78
+ return _query_repo(scan_result, cfg, force, offline)
73
79
 
74
80
 
75
- def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResult:
81
+ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool,
82
+ offline: bool = False) -> ResolveResult:
76
83
  repo_url = cfg["local_repo_url"]
77
84
  session_id = str(uuid.uuid4())
78
85
 
@@ -112,8 +119,11 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
112
119
  "firmware_version": scan_result.firmware_version,
113
120
  "hwids": scan_result.all_hwids,
114
121
  "hwid_groups": hwid_groups,
115
- "inbox_hwids": inbox_ids,
116
- "installed_versions": {**scan_result.installed_versions, **canon_versions},
122
+ # Offline: no inbox hints and empty installed_versions → server offers the
123
+ # full matched set for the fresh image (nothing counts as already-current).
124
+ "inbox_hwids": [] if offline else inbox_ids,
125
+ "installed_versions": {} if offline
126
+ else {**scan_result.installed_versions, **canon_versions},
117
127
  "force": force or cfg.get("force_resolve", False),
118
128
  }
119
129
 
File without changes
File without changes