Commit initial

This commit is contained in:
2026-04-15 17:57:12 +02:00
parent 005d8e797e
commit 55516ee10f
269 changed files with 26854 additions and 0 deletions
@@ -0,0 +1,213 @@
---
phase: 02-driver-management
plan: "01"
type: tdd
wave: 1
depends_on: []
files_modified:
- imptune/services/__init__.py
- imptune/services/inf_parser.py
- tests/test_inf_parser.py
- tests/fixtures/sample.inf
- tests/fixtures/sample_utf16.inf
- tests/fixtures/sample_multi_model.inf
autonomous: true
requirements: [DRV-02, DRV-05]
must_haves:
truths:
- "parse_inf extracts DriverDesc values from a simple INF with literal names"
- "parse_inf resolves %TOKEN% references via the [Strings] section"
- "parse_inf handles UTF-16 LE BOM, UTF-8 BOM, and ANSI (cp1252) encoded INF files"
- "parse_inf deduplicates driver names from multi-model INFs (NTamd64 + undecorated)"
- "parse_inf returns a list of unused files not referenced in the INF text"
- "parse_inf detects architecture from section decorations (x64, x86, arm64)"
- "parse_inf detects presence of .cat file in ZIP member list"
artifacts:
- path: "imptune/services/inf_parser.py"
provides: "ParsedInf dataclass and parse_inf() + _detect_encoding() functions"
exports: ["ParsedInf", "parse_inf", "_detect_encoding"]
- path: "tests/test_inf_parser.py"
provides: "Unit tests covering all DRV-02 and DRV-05 behaviors"
min_lines: 80
- path: "tests/fixtures/sample.inf"
provides: "Minimal valid INF with %TOKEN% values and [Strings] section"
- path: "tests/fixtures/sample_utf16.inf"
provides: "UTF-16 LE encoded INF for encoding detection test"
- path: "tests/fixtures/sample_multi_model.inf"
provides: "INF with NTamd64 and undecorated Models sections"
key_links:
- from: "imptune/services/inf_parser.py"
to: "configparser.RawConfigParser"
via: "stdlib import"
pattern: "RawConfigParser.*strict=False"
- from: "imptune/services/inf_parser.py"
to: "[Strings] section"
via: "_resolve_tokens regex expansion"
pattern: "re\\.sub.*%([^%]+)%"
---
<objective>
Create the INF parser service that extracts driver names (DriverDesc) from Windows INF files, with encoding auto-detection, %TOKEN% resolution, multi-model support, and unused-file detection.
Purpose: This is the core novel logic of Phase 2. The INF parser is a pure function with defined I/O — ideal for TDD. All other Phase 2 work (upload endpoint, UI) consumes this parser's output.
Output: `imptune/services/inf_parser.py` with `ParsedInf` dataclass and `parse_inf()` function, plus comprehensive unit tests and INF fixture files.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-driver-management/02-RESEARCH.md
<interfaces>
<!-- Existing codebase interfaces this plan needs -->
From imptune/config.py:
```python
DATA_DIR = os.environ.get("DATA_DIR", "/data")
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
```
From imptune/storage/driver_store.py:
```python
class DriverStore:
def save(self, data: bytes) -> str: ... # returns SHA256 hex digest
def get_path(self, sha256: str) -> Path: ...
def exists(self, sha256: str) -> bool: ...
```
From imptune/db/models.py:
```python
class Driver(BaseModel):
sha256 = CharField(unique=True, index=True)
original_filename = CharField()
size_bytes = IntegerField()
uploaded_at = DateTimeField(default=datetime.utcnow)
driver_desc = CharField(null=True) # Store json.dumps(list) for multi-model
inf_filename = CharField(null=True)
architecture = CharField(null=True) # 'x64', 'x86', 'arm64', or None
has_cat_file = BooleanField(default=False)
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: INF parser with TDD (RED then GREEN)</name>
<files>imptune/services/__init__.py, imptune/services/inf_parser.py, tests/test_inf_parser.py, tests/fixtures/sample.inf, tests/fixtures/sample_utf16.inf, tests/fixtures/sample_multi_model.inf</files>
<behavior>
- test_detect_encoding_utf16le: _detect_encoding(b'\xff\xfe...') returns 'utf-16'
- test_detect_encoding_utf8bom: _detect_encoding(b'\xef\xbb\xbf...') returns 'utf-8-sig'
- test_detect_encoding_ansi: _detect_encoding(b'[Version]...') returns 'cp1252'
- test_simple_driver_desc: parse_inf with literal DriverDesc in [Models] section returns those names in driver_names
- test_token_resolution: parse_inf with %HP_DRIVER% in [Models] and HP_DRIVER="HP LaserJet" in [Strings] resolves to "HP LaserJet"
- test_utf16_encoding: Reading a UTF-16 LE BOM fixture, decoding with _detect_encoding, and passing to parse_inf produces correct driver_names
- test_multi_model_inf: INF with both [Mfg.NTamd64] and [Mfg] sections returns deduplicated driver_names; architecture='x64' when NTamd64 is present alone
- test_architecture_detection: NTamd64 -> 'x64', NTarm64 -> 'arm64', undecorated only -> 'x86', mixed -> None
- test_cat_file_detection: zip_names containing 'driver.cat' -> has_cat_file=True; without -> False
- test_unused_files: ZIP members ['driver.inf', 'driver.dll', 'readme.txt'] where INF text mentions 'driver.inf' and 'driver.dll' but not 'readme.txt' -> unused_files=['readme.txt']
- test_empty_models_section: INF with [Manufacturer] but empty Models section returns empty driver_names list
</behavior>
<action>
**Phase: RED**
1. Create `tests/fixtures/` directory if it does not exist.
2. Create `tests/fixtures/sample.inf` — minimal valid INF with %TOKEN% values:
```ini
[Version]
Signature="$Windows NT$"
Class=Printer
Provider=%MFG%
[Manufacturer]
%MFG%=Models,NTamd64
[Models.NTamd64]
%DRIVER_NAME%=Install,{GUID}
[Strings]
MFG="Test Manufacturer"
DRIVER_NAME="Test LaserJet Pro"
```
3. Create `tests/fixtures/sample_utf16.inf` — same content as sample.inf but encoded as UTF-16 LE with BOM. Write using Python: `content.encode('utf-16-le')` prepended with `b'\xff\xfe'`. Actually, create this fixture programmatically within the test (or as a conftest fixture) since writing binary fixtures from plan text is fragile.
4. Create `tests/fixtures/sample_multi_model.inf` — INF with both decorated and undecorated sections:
```ini
[Version]
Signature="$Windows NT$"
Class=Printer
[Manufacturer]
%MFG%=Models,Models.NTamd64
[Models]
%DRIVER_A%=InstallA,{GUID1}
[Models.NTamd64]
%DRIVER_A%=InstallA,{GUID1}
%DRIVER_B%=InstallB,{GUID2}
[Strings]
MFG="Multi Corp"
DRIVER_A="Multi Printer 1000"
DRIVER_B="Multi Printer 2000"
```
5. Create `imptune/services/__init__.py` — empty package marker.
6. Create `tests/test_inf_parser.py` with all 11 test functions listed in behavior. Tests import from `imptune.services.inf_parser` and call `parse_inf()` / `_detect_encoding()`. Each test asserts specific expected outputs. For the UTF-16 test, generate the fixture bytes inline: `sample_text.encode('utf-16')`.
7. Run `pytest tests/test_inf_parser.py -x` — all tests MUST FAIL (ImportError or assertion errors). Commit: `test(02-01): add failing tests for INF parser`
**Phase: GREEN**
8. Create `imptune/services/inf_parser.py` implementing:
- `ParsedInf` dataclass with fields: `driver_names: list[str]`, `inf_filename: str`, `architecture: str | None`, `has_cat_file: bool`, `unused_files: list[str]`
- `_detect_encoding(raw: bytes) -> str` — BOM sniffing (UTF-16 BOM -> 'utf-16', UTF-8 BOM -> 'utf-8-sig', else -> 'cp1252')
- `_resolve_tokens(value: str, strings: dict[str, str]) -> str` — regex `%TOKEN%` expansion from strings dict
- `parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedInf` — uses `configparser.RawConfigParser(strict=False, comment_prefixes=(';', '#'), delimiters=('=',))`, reads [Manufacturer] to find Models section names, iterates all matching sections (decorated: .NTamd64, .NTarm64, .NTx86; undecorated), extracts left-hand keys as device-descriptions, resolves tokens, deduplicates with set(), detects architecture from section suffix, detects .cat in zip_names, computes unused files by checking if each zip member's basename appears in inf_text (case-insensitive)
Follow the exact code patterns from 02-RESEARCH.md "Pattern 2: INF DriverDesc Extraction". Key points:
- Use `RawConfigParser` (NOT `ConfigParser` — avoids %(interpolation)s interference)
- `strict=False` to handle duplicate keys in real INFs
- Strings dict keys must be lowercased (configparser lowercases keys by default)
- Strip surrounding double-quotes from [Strings] values
- Architecture: if exactly one arch hint in set -> return it; multiple -> None
- `sorted(driver_names)` for deterministic dropdown order
9. Run `pytest tests/test_inf_parser.py -x` — all tests MUST PASS. Commit: `feat(02-01): implement INF parser with encoding detection and token resolution`
</action>
<verify>
<automated>pytest tests/test_inf_parser.py -v</automated>
</verify>
<done>All 11 tests pass. ParsedInf dataclass and parse_inf() function correctly extract driver names from simple, tokenized, UTF-16, and multi-model INF files. Unused files detected. Architecture and .cat presence detected.</done>
</task>
</tasks>
<verification>
```bash
pytest tests/test_inf_parser.py -v
pytest tests/ -x -q # no regressions in existing tests
```
</verification>
<success_criteria>
- parse_inf() extracts DriverDesc from all 3 fixture types (simple, UTF-16, multi-model)
- %TOKEN% references resolved to human-readable names
- Unused files correctly identified
- All 11+ unit tests green, zero regressions in existing suite
</success_criteria>
<output>
After completion, create `.planning/phases/02-driver-management/02-01-SUMMARY.md`
</output>
@@ -0,0 +1,95 @@
---
phase: "02"
plan: "01"
subsystem: inf-parser
tags: [tdd, inf-parsing, encoding-detection, token-resolution, driver-management]
dependency_graph:
requires: []
provides: [inf-parser-service]
affects: [02-02-upload-endpoint, 02-03-drivers-ui]
tech_stack:
added: []
patterns: [RawConfigParser-strict-false, BOM-sniffing, optionxform-str, set-dedup-sorted]
key_files:
created:
- imptune/services/__init__.py
- imptune/services/inf_parser.py
- tests/test_inf_parser.py
- tests/fixtures/sample.inf
- tests/fixtures/sample_utf16.inf
- tests/fixtures/sample_multi_model.inf
modified: []
decisions:
- "optionxform=str on RawConfigParser to preserve DriverDesc key casing; strings dict still uses lowercased keys for case-insensitive %TOKEN% lookup"
- "configparser.RawConfigParser(strict=False) avoids DuplicateOptionError on real INFs with repeated model entries"
- "UTF-16 fixture written as binary via Python encode('utf-16') — not as a text file — to guarantee correct BOM bytes"
metrics:
duration: "~2.5 min"
completed: "2026-04-10"
tasks: 1
files: 6
requirements-completed: [DRV-02]
---
# Phase 02 Plan 01: INF Parser Service Summary
**One-liner:** stdlib configparser + BOM-sniffing INF parser with %TOKEN% resolution, multi-model deduplication, architecture detection, and unused-file flagging.
## What Was Built
`imptune/services/inf_parser.py` — a pure-function INF parser with:
- `ParsedInf` dataclass exposing `driver_names`, `inf_filename`, `architecture`, `has_cat_file`, `unused_files`
- `_detect_encoding(raw: bytes) -> str` — BOM-sniffing: `\xff\xfe`/`\xfe\xff` -> `utf-16`, `\xef\xbb\xbf` -> `utf-8-sig`, else `cp1252`
- `_resolve_tokens(value, strings)` — regex `%([^%]+)%` expansion
- `parse_inf(inf_text, inf_filename, zip_names) -> ParsedInf``RawConfigParser(strict=False, delimiters=('=',))` with `optionxform=str`; [Manufacturer] -> Models section discovery; NTamd64/NTarm64/NTx86/undecorated detection; set-based dedup; sorted output
Three fixture files support the test suite: `sample.inf` (ANSI with %TOKEN%), `sample_utf16.inf` (UTF-16 LE BOM binary), `sample_multi_model.inf` (NTamd64 + undecorated sections).
## Tasks
| # | Task | Status | Commit |
|---|------|--------|--------|
| 1 | INF parser with TDD (RED then GREEN) | Complete | 290106d (RED), 5056922 (GREEN) |
## Test Results
- 16 tests in `tests/test_inf_parser.py` — all pass
- Full suite: 40 tests pass, 0 failures, 0 regressions
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] configparser key lowercasing mangled DriverDesc literal names**
- **Found during:** Task 1, GREEN phase (first test run)
- **Issue:** configparser defaults `optionxform = str.lower`, so the literal key `Acme SuperPrint 9000` was returned as `acme superprint 9000`. The test `assert "Acme SuperPrint 9000" in result.driver_names` failed.
- **Fix:** Set `parser.optionxform = str` to preserve original casing of option keys. The [Strings] dict still explicitly lowercases keys (`strings[key.lower()]`) for case-insensitive token resolution.
- **Files modified:** `imptune/services/inf_parser.py`
- **Commit:** 5056922
**Note:** The plan specified `strict=False` and `RawConfigParser` correctly but did not mention `optionxform=str`. This is a real-INF edge case documented in the pitfalls section of 02-RESEARCH.md (implicitly — the note says "Strings dict keys must be lowercased" without clarifying that DriverDesc keys also get lowercased by default).
### Test Count Deviation
The plan specified 11 test functions; 16 were written. The extra 5 cover:
- `test_detect_encoding_utf16be` (UTF-16 BE BOM variant)
- `test_architecture_detection_amd64` (split from the combined architecture test)
- `test_architecture_detection_arm64`
- `test_architecture_detection_undecorated`
- `test_architecture_detection_mixed`
This provides more granular failure diagnosis and meets the `min_lines: 80` artifact requirement.
## Self-Check
- [x] `imptune/services/inf_parser.py` exists
- [x] `imptune/services/__init__.py` exists
- [x] `tests/test_inf_parser.py` exists (>80 lines)
- [x] `tests/fixtures/sample.inf` exists
- [x] `tests/fixtures/sample_utf16.inf` exists (UTF-16 LE BOM binary)
- [x] `tests/fixtures/sample_multi_model.inf` exists
- [x] RED commit: 290106d
- [x] GREEN commit: 5056922
## Self-Check: PASSED
@@ -0,0 +1,311 @@
---
phase: 02-driver-management
plan: "02"
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- imptune/api/drivers.py
- imptune/api/pages.py
- imptune/main.py
- imptune/templates/drivers.html
- imptune/templates/partials/driver_list.html
- tests/test_driver_upload.py
autonomous: true
requirements: [DRV-01, DRV-03, DRV-04, DRV-05]
must_haves:
truths:
- "User can upload a ZIP file via the /drivers page and receive a success response"
- "After upload, the response contains a populated select dropdown with driver names from the INF"
- "Uploading a non-ZIP file or a ZIP with no INF returns a 400 error displayed in-page"
- "Uploaded driver file is persisted to DRIVERS_DIR via DriverStore (survives restart)"
- "Re-uploading the same ZIP does not create a duplicate Driver record (SHA256 dedup)"
- "Upload response shows count of unused files not referenced by the INF"
- "GET /drivers renders the drivers page with upload form and existing driver list"
artifacts:
- path: "imptune/api/drivers.py"
provides: "POST /drivers/upload endpoint returning HTMX partial"
exports: ["router"]
- path: "imptune/templates/drivers.html"
provides: "Drivers page with upload form and driver list container"
contains: "hx-post"
- path: "imptune/templates/partials/driver_list.html"
provides: "HTMX partial fragment with driver table and select dropdown"
contains: "<select"
- path: "tests/test_driver_upload.py"
provides: "Integration tests for upload endpoint and drivers page"
min_lines: 80
key_links:
- from: "imptune/api/drivers.py"
to: "imptune/services/inf_parser.py"
via: "import parse_inf, _detect_encoding"
pattern: "from imptune\\.services\\.inf_parser import"
- from: "imptune/api/drivers.py"
to: "imptune/storage/driver_store.py"
via: "DriverStore(DRIVERS_DIR).save(data)"
pattern: "DriverStore.*save"
- from: "imptune/api/drivers.py"
to: "imptune/db/models.py"
via: "Driver.get_or_create(sha256=...)"
pattern: "Driver\\.get_or_create"
- from: "imptune/templates/drivers.html"
to: "/drivers/upload"
via: "hx-post with multipart/form-data"
pattern: "hx-post.*drivers/upload"
- from: "imptune/main.py"
to: "imptune/api/drivers.py"
via: "app.include_router(drivers.router)"
pattern: "include_router.*drivers"
---
<objective>
Create the driver upload endpoint, drivers page, and HTMX-driven UI that lets technicians upload driver ZIPs, see parsed driver names in a dropdown, and view unused-file hints.
Purpose: This wires the INF parser (from plan 02-01) into a working upload flow with persistence and UI feedback. After this plan, the full DRV-01 through DRV-05 feature set is functional.
Output: Upload API endpoint, drivers page template, HTMX partial for driver list, integration tests.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-driver-management/02-RESEARCH.md
@.planning/phases/02-driver-management/02-01-SUMMARY.md
<interfaces>
<!-- From Plan 02-01 (INF parser) -->
From imptune/services/inf_parser.py:
```python
from dataclasses import dataclass
@dataclass
class ParsedInf:
driver_names: list[str] # resolved DriverDesc values, deduplicated, sorted
inf_filename: str # which .inf file inside the ZIP
architecture: str | None # 'x64', 'x86', 'arm64', or None
has_cat_file: bool # whether a .cat file exists in the ZIP
unused_files: list[str] # ZIP members not referenced by the INF
def _detect_encoding(raw: bytes) -> str: ...
def parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedInf: ...
```
<!-- Existing Phase 1 interfaces -->
From imptune/config.py:
```python
DATA_DIR = os.environ.get("DATA_DIR", "/data")
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
```
From imptune/storage/driver_store.py:
```python
class DriverStore:
def __init__(self, base_dir: str) -> None: ...
def save(self, data: bytes) -> str: ... # returns SHA256 hex
```
From imptune/db/models.py:
```python
class Driver(BaseModel):
sha256 = CharField(unique=True, index=True)
original_filename = CharField()
size_bytes = IntegerField()
uploaded_at = DateTimeField(default=datetime.utcnow)
driver_desc = CharField(null=True) # json.dumps(list) for multi-model
inf_filename = CharField(null=True)
architecture = CharField(null=True)
has_cat_file = BooleanField(default=False)
```
From imptune/main.py:
```python
app = FastAPI(title="ImpTune", lifespan=lifespan)
app.include_router(health.router)
app.include_router(pages.router)
# Add: app.include_router(drivers.router)
```
From imptune/api/pages.py:
```python
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
```
From imptune/templates/base.html:
```html
<!-- Sidebar already has /drivers link -->
<li><a href="/drivers" ...>Drivers</a></li>
<!-- Content block: {% block content %}{% endblock %} -->
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Upload endpoint, drivers page route, and integration tests</name>
<files>imptune/api/drivers.py, imptune/api/pages.py, imptune/main.py, tests/test_driver_upload.py</files>
<behavior>
- test_drivers_page: GET /drivers returns 200 with HTML containing upload form (input type="file", hx-post="/drivers/upload")
- test_upload_valid_zip: POST /drivers/upload with a valid ZIP containing sample.inf returns 200, HTML contains driver name from INF
- test_upload_non_zip: POST /drivers/upload with a .txt file returns 400
- test_upload_no_inf: POST /drivers/upload with a ZIP containing no .inf returns 400
- test_upload_returns_select: POST /drivers/upload with valid ZIP returns HTML containing a select element with driver names as options
- test_driver_persisted: After upload, Driver.select().where(Driver.sha256==expected).count() == 1, and DriverStore file exists on disk
- test_dedup_upload: Uploading same ZIP twice creates only one Driver record
- test_unused_files_in_response: Upload a ZIP with an extra file not in INF text; response HTML contains "unused" or the count
</behavior>
<action>
**RED phase first:**
1. Create `tests/test_driver_upload.py` with all 8 integration tests. Tests use the `client` fixture from conftest.py. For test fixtures, create valid ZIP bytes in-memory using `zipfile.ZipFile(io.BytesIO(), 'w')`:
- Build a helper `_make_driver_zip(inf_content: str, extra_files: dict[str, bytes] = None) -> bytes` that creates a ZIP with the INF and optional extra files
- Use the same sample INF content from tests/fixtures/sample.inf (read it or inline it)
- For `test_upload_non_zip`, send raw text bytes with filename="test.zip"
- For `test_upload_no_inf`, create a ZIP with only a .txt file
- For `test_unused_files_in_response`, add a "readme.txt" to the ZIP that the INF does not reference
- All tests use `client.post("/drivers/upload", files={"file": ("driver.zip", zip_bytes, "application/zip")})`
- Import `Driver` from `imptune.db.models` and `init_db` from `imptune.db.database` for persistence checks. Call `init_db()` in tests that check DB state (the `client` fixture triggers lifespan which calls init_db).
2. Run `pytest tests/test_driver_upload.py -x` — all MUST FAIL. Commit: `test(02-02): add failing integration tests for driver upload`
**GREEN phase:**
3. Create `imptune/api/drivers.py`:
- `router = APIRouter(prefix="/drivers")`
- `templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))`
- `MAX_UPLOAD_BYTES = 100 * 1024 * 1024`
- `POST /upload` endpoint (sync def, not async — Peewee is sync):
- Read file bytes, validate size <= 100MB
- Validate filename ends with `.zip`
- Validate `zipfile.is_zipfile(io.BytesIO(data))`
- Open ZIP, validate no zip-slip paths (reject `..` or absolute paths)
- Find `.inf` files in namelist; raise 400 if none
- Prefer INF whose path contains `amd64`/`x64` if multiple exist; else first alphabetically
- Read INF bytes, detect encoding with `_detect_encoding()`, decode
- Call `parse_inf(inf_text, inf_filename, zip_names)`
- Save via `DriverStore(DRIVERS_DIR).save(data)`
- Upsert `Driver.get_or_create(sha256=sha256, defaults={...})` — store `json.dumps(parsed.driver_names)` in `driver_desc`
- Query all drivers: `Driver.select().order_by(Driver.uploaded_at.desc())`
- Return `templates.TemplateResponse(request=request, name="partials/driver_list.html", context={...})`
- On validation errors, return HTMX-friendly error: `HTMLResponse(content="<div id='driver-list' class='error'>Error message</div>", status_code=400)` — so HTMX can swap the error into the target area
4. Add GET /drivers route to `imptune/api/pages.py`:
```python
@router.get("/drivers", response_class=HTMLResponse)
def drivers_page(request: Request):
from imptune.db.models import Driver
drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
return templates.TemplateResponse(
request=request, name="drivers.html",
context={"drivers": drivers}
)
```
5. Register the drivers router in `imptune/main.py`:
- Add `from imptune.api import drivers` to imports
- Add `app.include_router(drivers.router)` after the pages router
6. Run `pytest tests/test_driver_upload.py -x` — all MUST PASS. Commit: `feat(02-02): add driver upload endpoint with INF parsing and dedup`
</action>
<verify>
<automated>pytest tests/test_driver_upload.py -v</automated>
</verify>
<done>All 8 integration tests pass. POST /drivers/upload accepts ZIPs, parses INFs, persists via DriverStore + Peewee, returns HTMX partial. GET /drivers renders the page. Error cases return 400.</done>
</task>
<task type="auto">
<name>Task 2: Drivers page template and HTMX partial</name>
<files>imptune/templates/drivers.html, imptune/templates/partials/driver_list.html</files>
<action>
1. Create `imptune/templates/partials/` directory (if not exists).
2. Create `imptune/templates/drivers.html` extending base.html:
```html
{% extends "base.html" %}
{% block content %}
<h1>Drivers</h1>
<section>
<h2>Upload Driver Package</h2>
<form
hx-post="/drivers/upload"
hx-encoding="multipart/form-data"
hx-target="#driver-list"
hx-swap="outerHTML"
hx-indicator="#upload-spinner"
>
<label for="driver-file">Driver Package (ZIP containing .inf + driver files)</label>
<input type="file" id="driver-file" name="file" accept=".zip" required>
<button type="submit">Upload</button>
<span id="upload-spinner" class="htmx-indicator" aria-busy="true">Uploading...</span>
</form>
</section>
<section>
<h2>Driver Library</h2>
<div id="driver-list">
{% include "partials/driver_list.html" %}
</div>
</section>
{% endblock %}
```
3. Create `imptune/templates/partials/driver_list.html`:
- Wrap everything in `<div id="driver-list">` (for HTMX outerHTML swap)
- If `drivers` list is empty, show "No drivers uploaded yet."
- If `drivers` exist, render a table with columns: Filename, Driver Name(s), Architecture, Uploaded, Unused Files
- For each driver, parse `driver.driver_desc` as JSON to get the list of driver names. Display as a `<select>` dropdown if multiple names, or plain text if single name. Use Jinja2: `{% set names = driver.driver_desc | tojson | default('[]') %}` — actually, since driver_desc is already a JSON string, parse it in template or pass parsed data from the route.
- Show unused files count if `parsed` context variable is available (on fresh upload): "N files may be unused" with a details/summary for the list
- For the "new_driver" highlight (if present in context), add a CSS class to indicate success
The partial must work both as an include (initial page load, no `parsed` variable) and as a standalone HTMX response (after upload, `parsed` available).
Template approach for driver names: In the route, pass `driver_names_map` — a dict mapping driver.id to the parsed list. Or simpler: add a property/method. Simplest approach for Jinja2: use a custom filter or pass a helper. Actually, simplest: in the route handler, build a list of dicts with pre-parsed data:
```python
import json
driver_data = []
for d in drivers:
names = json.loads(d.driver_desc) if d.driver_desc else []
driver_data.append({"driver": d, "names": names})
```
Pass `driver_data` to template. Template iterates `driver_data` and renders `item.names` as select options.
Update both the drivers.py upload endpoint AND the pages.py GET /drivers route to pass `driver_data` in this format.
4. Run full test suite to verify no regressions: `pytest tests/ -x -q`
</action>
<verify>
<automated>pytest tests/ -v</automated>
</verify>
<done>GET /drivers renders a page with upload form and driver table. After upload, HTMX swaps in updated driver list with select dropdown containing parsed driver names. Unused file count visible. All tests green.</done>
</task>
</tasks>
<verification>
```bash
pytest tests/ -v # Full suite green
pytest tests/test_driver_upload.py -v # All upload integration tests
pytest tests/test_inf_parser.py -v # All parser unit tests
```
</verification>
<success_criteria>
- POST /drivers/upload with valid driver ZIP returns 200 with HTML containing driver name dropdown
- POST /drivers/upload with invalid input returns 400 with clear error
- Driver records persisted in SQLite with json.dumps(driver_names) in driver_desc
- Driver files persisted in DRIVERS_DIR via SHA256 content-addressed storage
- Duplicate uploads produce no duplicate records
- GET /drivers renders upload form and existing driver list
- Unused files flagged in upload response
- All integration + unit tests pass, zero regressions
</success_criteria>
<output>
After completion, create `.planning/phases/02-driver-management/02-02-SUMMARY.md`
</output>
@@ -0,0 +1,148 @@
---
phase: 02-driver-management
plan: "02"
subsystem: api
tags: [fastapi, htmx, jinja2, peewee, zipfile, sha256, dedup, inf-parser]
# Dependency graph
requires:
- phase: 02-01
provides: INF parser service (parse_inf, ParsedInf, _detect_encoding)
- phase: 01-foundation
provides: FastAPI app shell, DriverStore, Driver model, init_db, base templates
provides:
- POST /drivers/upload endpoint with ZIP validation, INF parsing, SHA256 dedup, Peewee persistence
- GET /drivers page with HTMX upload form and driver library table
- HTMX partial (partials/driver_list.html) returned on upload with select dropdown and unused-file notice
- Integration test suite (8 tests) for driver upload flow
affects: [03-printer-management, 04-package-generation]
# Tech tracking
tech-stack:
added: []
patterns:
- HTMX outerHTML swap: upload endpoint returns partial HTML fragment replacing #driver-list div
- Dynamic config read: import imptune.config as _cfg and read _cfg.DRIVERS_DIR at call time for monkeypatch compatibility
- TDD workflow: RED (test commit) -> GREEN (impl commit) within same task
key-files:
created:
- imptune/api/drivers.py
- imptune/templates/drivers.html
- imptune/templates/partials/driver_list.html
- tests/test_driver_upload.py
modified:
- imptune/api/pages.py
- imptune/main.py
- tests/conftest.py
key-decisions:
- "Always render <select> even for single-model drivers — simplifies template logic and consistent UI"
- "Dynamic DRIVERS_DIR read (import config module, not top-level constant) so monkeypatch works in tests"
- "TestClient context manager in conftest client fixture — required for lifespan/init_db to trigger in integration tests"
- "HTMX-friendly 400 error: return HTMLResponse with <div id='driver-list'> wrapper so HTMX can swap error inline"
patterns-established:
- "HTMX partial pattern: upload returns <div id='driver-list'> fragment; page has matching hx-target; outerHTML swap replaces entire div"
- "driver_data pattern: routes build list of dicts with {'driver': orm_obj, 'names': list[str]} to pre-parse JSON in Python rather than Jinja2"
- "Config monkeypatch: endpoints import config module (not constants) so test fixtures can override DRIVERS_DIR/DB_PATH"
requirements-completed: [DRV-01, DRV-03, DRV-04, DRV-05]
# Metrics
duration: 3min
completed: 2026-04-10
---
# Phase 02 Plan 02: Driver Upload Endpoint Summary
**HTMX-driven driver ZIP upload with INF parsing, SHA256 dedup, Peewee persistence, and select dropdown returning 8/8 integration tests green**
## Performance
- **Duration:** ~3 min
- **Started:** 2026-04-10T10:18:52Z
- **Completed:** 2026-04-10T10:22:00Z
- **Tasks:** 2 (Task 1 TDD: RED + GREEN; Task 2 templates completed inline)
- **Files modified:** 7
## Accomplishments
- POST /drivers/upload: validates ZIP, finds INF, parses via INF parser service, saves via DriverStore (SHA256 content-addressed), upserts Driver record with json.dumps(driver_names) in driver_desc — full dedup on re-upload
- GET /drivers page renders upload form with HTMX attributes and existing driver library table
- HTMX partial (partials/driver_list.html): wraps content in `<div id="driver-list">` for outerHTML swap; shows unused-file notice with count and expandable list; renders driver names as `<select>` dropdown
- 8 integration tests written TDD-first (RED commit, then GREEN): page render, valid upload, non-ZIP 400, no-INF 400, select presence, DB persistence, dedup, unused files in response
## Task Commits
1. **Test RED phase: failing integration tests** - `8ecfbf2` (test)
2. **Task 1 + Task 2: upload endpoint, templates, pages route, router registration** - `c648fc5` (feat)
## Files Created/Modified
- `imptune/api/drivers.py` - POST /drivers/upload endpoint with full validation, INF parsing, DriverStore save, Peewee get_or_create
- `imptune/api/pages.py` - Added GET /drivers route with driver_data context
- `imptune/main.py` - Registered drivers.router
- `imptune/templates/drivers.html` - Drivers page extending base.html with HTMX upload form
- `imptune/templates/partials/driver_list.html` - HTMX swap target with table, select dropdown, unused-files notice
- `tests/test_driver_upload.py` - 8 integration tests covering all success and error paths
- `tests/conftest.py` - Fixed client fixture to use TestClient as context manager
## Decisions Made
- Always render `<select>` even for single driver name — uniform UI and simpler template logic
- Read `_cfg.DRIVERS_DIR` dynamically (not top-level constant import) so test monkeypatching works
- `TestClient(app)` must be used as a context manager for Starlette 0.46+ to trigger lifespan and run `init_db()`
- HTMX errors: return `HTMLResponse` with `<div id='driver-list'>` wrapper at status 400 so HTMX can swap error into target area
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] TestClient context manager required for lifespan trigger**
- **Found during:** Task 1 GREEN (first test run)
- **Issue:** `TestClient(app)` without context manager does not run lifespan in Starlette 0.46+, so `init_db()` never called; DB remained deferred (None), causing `InterfaceError` on all ORM queries
- **Fix:** Changed conftest `client` fixture from `return TestClient(app)` to `with TestClient(app) as c: yield c`
- **Files modified:** tests/conftest.py
- **Verification:** All 48 tests pass including pre-existing health, static, DB, and parser tests
- **Committed in:** c648fc5 (Task 1 feat commit)
**2. [Rule 1 - Bug] Dynamic DRIVERS_DIR read to support monkeypatch**
- **Found during:** Task 1 GREEN (test_driver_persisted failure)
- **Issue:** `from imptune.config import DRIVERS_DIR` captured the value at import time; tests patching `cfg.DRIVERS_DIR` had no effect — files written to `/data/drivers` (production path) not the tmp dir
- **Fix:** Changed to `import imptune.config as _cfg` and use `_cfg.DRIVERS_DIR` at call time
- **Files modified:** imptune/api/drivers.py
- **Verification:** test_driver_persisted passes; file found in tmp_data_dir/drivers/
- **Committed in:** c648fc5 (Task 1 feat commit)
**3. [Rule 1 - Bug] Template always renders `<select>` for any non-empty names list**
- **Found during:** Task 1 GREEN (test_upload_returns_select failure)
- **Issue:** Template only showed `<select>` for multiple names; sample INF has 1 driver name, so test failed
- **Fix:** Changed template condition from `{% if item.names | length > 1 %}` to `{% if item.names %}`
- **Files modified:** imptune/templates/partials/driver_list.html
- **Verification:** test_upload_returns_select passes
- **Committed in:** c648fc5 (Task 1 feat commit)
---
**Total deviations:** 3 auto-fixed (1 Rule 3 blocking, 2 Rule 1 bugs)
**Impact on plan:** All three fixes necessary for correct test isolation and behavior. No scope creep.
## Issues Encountered
None beyond the three auto-fixed deviations above.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Driver upload feature fully functional: upload, parse, persist, dedup, UI feedback
- Driver records in SQLite with driver_desc (JSON list), architecture, inf_filename, has_cat_file
- Phase 03 (printer management) can reference drivers via Driver model and driver select dropdowns
- Phase 04 (package generation) can read persisted driver ZIPs from DriverStore using sha256
---
*Phase: 02-driver-management*
*Completed: 2026-04-10*
@@ -0,0 +1,594 @@
# Phase 2: Driver Management - Research
**Researched:** 2026-04-10
**Domain:** ZIP upload handling, Windows INF parsing, content-addressed storage, HTMX-driven UI
**Confidence:** HIGH (FastAPI upload patterns, Python stdlib zipfile/configparser, HTMX encoding), MEDIUM (INF encoding edge-cases)
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|-----------------|
| DRV-01 | User can upload a driver package (ZIP containing INF + supporting files) | FastAPI `UploadFile` + `python-multipart`; `zipfile.ZipFile(io.BytesIO(...))` in-memory extraction |
| DRV-02 | System parses uploaded INF files and extracts valid driver names (DriverDesc) | Python `configparser` reading `[Manufacturer]` → Models sections; `[Strings]` token resolution; encoding auto-detect (ANSI / UTF-8 / UTF-16 LE) |
| DRV-03 | User can select driver name from parsed INF dropdown (no free-text) | HTMX `hx-post` + `hx-encoding="multipart/form-data"` + `hx-target` swap returning `<select>` fragment from server |
| DRV-04 | Driver packages are persisted on Docker volume across container restarts | `DriverStore` (SHA256 content-addressed) already built in Phase 1; `Driver` ORM model already in schema |
| DRV-05 | System flags unused files in driver packages to help reduce package size | Compare `zipfile.namelist()` against all `CopyFiles` / `SourceDisksFiles` references in the INF |
</phase_requirements>
---
## Summary
Phase 2 is three independent workstreams that converge into one UI flow: (1) a FastAPI endpoint that accepts a ZIP upload and stores it via the existing `DriverStore`, (2) a pure-Python INF parser that extracts driver names (`DriverDesc`) and detects unused files, and (3) an HTMX-powered Drivers page that shows an upload form and, after upload, replaces a placeholder with a populated `<select>` dropdown.
The storage infrastructure was completed in Phase 1. `DriverStore.save(data) -> sha256` and the `Driver` ORM model (with `driver_desc`, `inf_filename`, `architecture`, `has_cat_file` fields) are already in place. Phase 2 only needs to fill those fields by parsing the INF and register the driver record in SQLite.
INF parsing is the trickiest piece. Windows INF files use an INI-like format but have encoding variability (ANSI, UTF-8 with BOM, UTF-16 LE with BOM — all seen in real HP/Canon/Ricoh packages). Driver names (`DriverDesc`) are the left-hand values in the `[Models]` sections (e.g., `[Manufacturer.NTamd64]`), and they are frequently `%TOKEN%` references that must be resolved from the `[Strings]` section. Python's `configparser` handles this INI-like format well but needs an encoding sniff step and a `%`-token expander. Multi-model INF files (one INF with NTamd64 + NTarm64 + undecorated sections) must be deduplicated — extract all driver names, unique them, and present the merged list.
**Primary recommendation:** Use `configparser` with encoding auto-detection + a custom `%TOKEN%` resolver to extract DriverDesc values. Do NOT import the third-party `pyinf` library — its scope is too narrow and adds a dependency with no maintenance signal. All required INF parsing logic is achievable in ~60 lines of stdlib Python.
---
## Standard Stack
### Core (all already in requirements.txt from Phase 1)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| FastAPI | 0.115.x | Upload endpoint, HTMX fragment responses | Already installed; `UploadFile` built in |
| python-multipart | 0.0.9 | Required by FastAPI for `UploadFile` | Already installed |
| Peewee | 3.17.x | ORM — `Driver` record create/update | Already installed; schema already has all Phase 2 fields |
| Jinja2 | 3.1.x | Render drivers page + HTMX partial (driver list fragment) | Already installed |
| Python stdlib `zipfile` | 3.12 | Extract ZIP in-memory, list members | No new dependency |
| Python stdlib `configparser` | 3.12 | Parse INF (INI-like format) | No new dependency |
| Python stdlib `io` | 3.12 | `io.BytesIO` for in-memory ZIP | No new dependency |
### No New Dependencies Required
Phase 2 introduces zero new pip packages. Everything needed is either already installed (FastAPI/Peewee/Jinja2) or in the Python 3.12 stdlib (zipfile, configparser, io, hashlib).
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `configparser` + custom token resolver | `pyinf` (third-party) | `pyinf` is a rudimentary single-developer project with no recent activity; stdlib configparser handles the INI format correctly with 30 extra lines of token resolution |
| In-memory `io.BytesIO` extraction | `NamedTemporaryFile` on disk | In-memory is simpler for small driver ZIPs (< 50 MB typical); avoids temp file cleanup; adequate for this use case |
---
## Architecture Patterns
### Recommended File Layout for Phase 2
```
imptune/
├── api/
│ ├── pages.py # add GET /drivers page route
│ └── drivers.py # NEW: POST /drivers/upload endpoint
├── services/
│ └── inf_parser.py # NEW: parse_inf(zip_bytes) -> ParsedInf dataclass
├── templates/
│ ├── drivers.html # NEW: Drivers page (upload form + driver table)
│ └── partials/
│ └── driver_select.html # NEW: HTMX partial — <select> fragment
```
### Pattern 1: INF Encoding Auto-Detection
**What:** INF files from real vendors arrive as ANSI (cp1252), UTF-8 with BOM, or UTF-16 LE with BOM. Attempting to read a UTF-16 file as UTF-8 raises `UnicodeDecodeError`. Sniff the BOM before parsing.
**When to use:** Always — called at the top of `parse_inf()`.
```python
def _detect_encoding(raw: bytes) -> str:
"""Detect INF file encoding from BOM bytes.
Source: Microsoft docs — general-syntax-rules-for-inf-files
(INF files may be ASCII/ANSI, UTF-8, or UTF-16 Unicode)
"""
if raw[:2] in (b'\xff\xfe', b'\xfe\xff'):
return 'utf-16' # UTF-16 with BOM (LE or BE)
if raw[:3] == b'\xef\xbb\xbf':
return 'utf-8-sig' # UTF-8 with BOM
return 'cp1252' # ANSI / Windows-1252 (safe fallback for Latin chars)
```
### Pattern 2: INF DriverDesc Extraction via configparser + Token Resolution
**What:** The `[Manufacturer]` section lists manufacturer keys. Each key points to one or more `[ModelsSectionName]` or `[ModelsSectionName.NTamd64]` sections. Each entry in a Models section has the form:
```
device-description = install-section-name, hw-id[, compatible-id...]
```
The `device-description` (the left side) is the `DriverDesc` — the human-readable driver name. It is often a `%TOKEN%` reference that must be resolved from `[Strings]`.
**When to use:** Core of `parse_inf()`.
```python
import configparser
import re
from dataclasses import dataclass, field
@dataclass
class ParsedInf:
driver_names: list[str] # resolved DriverDesc values, deduplicated
inf_filename: str # which .inf file inside the ZIP
architecture: str | None # 'x64', 'x86', 'arm64', or None if ambiguous
has_cat_file: bool # whether a .cat file exists in the ZIP
unused_files: list[str] # ZIP members not referenced by the INF
def _resolve_tokens(value: str, strings: dict[str, str]) -> str:
"""Expand %TOKEN% placeholders using the [Strings] section.
Source: Microsoft docs — general-syntax-rules-for-inf-files
"""
def replacer(match):
key = match.group(1).lower()
return strings.get(key, match.group(0))
return re.sub(r'%([^%]+)%', replacer, value)
def parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedInf:
"""Parse a Windows INF file and extract driver names and metadata."""
parser = configparser.RawConfigParser(
comment_prefixes=(';', '#'),
strict=False, # allow duplicate keys (real INFs have them)
delimiters=('=',),
)
parser.read_string(inf_text)
# Build strings lookup (case-insensitive keys)
strings: dict[str, str] = {}
if parser.has_section('Strings'):
for key, val in parser.items('Strings'):
# Values may be quoted — strip surrounding quotes
strings[key.lower()] = val.strip('"')
# Find all Models sections (decorated and undecorated)
# Models sections are referenced from [Manufacturer] values
driver_names: set[str] = set()
arch_hints: set[str] = set()
# Gather manufacturer section names from [Manufacturer]
models_section_names: list[str] = []
if parser.has_section('Manufacturer'):
for _mfg_key, mfg_val in parser.items('Manufacturer'):
# Format: ModelsSection[,TargetOS,TargetOS...]
parts = [p.strip() for p in mfg_val.split(',')]
models_section_names.append(parts[0])
# For each referenced Models section (and its NTamd64/NTarm64 variants)
for base_name in models_section_names:
for section in parser.sections():
# Match base name, base.NTamd64, base.NTarm64, base.NTx86, etc.
if section.lower() == base_name.lower() or \
section.lower().startswith(base_name.lower() + '.nt'):
# Detect architecture from decoration
suffix = section[len(base_name):].lower()
if 'amd64' in suffix:
arch_hints.add('x64')
elif 'arm64' in suffix:
arch_hints.add('arm64')
elif 'x86' in suffix or suffix == '':
arch_hints.add('x86')
for key, _val in parser.items(section):
# key is the device-description (DriverDesc)
resolved = _resolve_tokens(key, strings)
# Filter out empty or purely numeric entries
if resolved and not resolved.isdigit():
driver_names.add(resolved)
arch = arch_hints.pop() if len(arch_hints) == 1 else None
# Detect .cat file
has_cat = any(n.lower().endswith('.cat') for n in zip_names)
# Find unused files (not referenced anywhere in the INF text)
inf_lower = inf_text.lower()
unused: list[str] = []
for member in zip_names:
basename = member.split('/')[-1].split('\\')[-1]
if basename.lower() not in inf_lower:
unused.append(member)
return ParsedInf(
driver_names=sorted(driver_names),
inf_filename=inf_filename,
architecture=arch,
has_cat_file=has_cat,
unused_files=unused,
)
```
### Pattern 3: FastAPI Upload Endpoint (sync handler, DriverStore + Peewee)
**What:** Receive a ZIP via `UploadFile`, store via `DriverStore`, parse the INF, write the `Driver` record.
**When to use:** `POST /drivers/upload` — called by the HTMX form.
```python
# imptune/api/drivers.py
from fastapi import APIRouter, UploadFile, File, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import zipfile, io
from pathlib import Path
from imptune.config import DRIVERS_DIR
from imptune.storage.driver_store import DriverStore
from imptune.db.models import Driver
from imptune.services.inf_parser import parse_inf, _detect_encoding
router = APIRouter(prefix="/drivers")
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB — generous for printer driver ZIPs
@router.post("/upload", response_class=HTMLResponse)
def upload_driver(request: Request, file: UploadFile = File(...)):
"""Accept ZIP upload, parse INF, store driver, return HTMX partial."""
data = file.file.read()
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(413, "Driver package exceeds 100 MB limit")
if not file.filename.lower().endswith('.zip'):
raise HTTPException(400, "Only ZIP files are accepted")
# Validate it's actually a ZIP
if not zipfile.is_zipfile(io.BytesIO(data)):
raise HTTPException(400, "Uploaded file is not a valid ZIP archive")
with zipfile.ZipFile(io.BytesIO(data)) as zf:
names = zf.namelist()
# Security: reject zip-slip paths
for name in names:
if name.startswith('/') or '..' in name:
raise HTTPException(400, f"Dangerous path in ZIP: {name}")
# Find INF file(s)
inf_names = [n for n in names if n.lower().endswith('.inf')]
if not inf_names:
raise HTTPException(400, "No .inf file found in ZIP")
# Use the first INF (most driver ZIPs have exactly one)
inf_bytes = zf.read(inf_names[0])
encoding = _detect_encoding(inf_bytes)
inf_text = inf_bytes.decode(encoding, errors='replace')
parsed = parse_inf(inf_text, inf_names[0], names)
# Store file (SHA256 content-addressed, deduplication built in)
store = DriverStore(DRIVERS_DIR)
sha256 = store.save(data)
# Upsert Driver record (idempotent on sha256)
driver, _created = Driver.get_or_create(
sha256=sha256,
defaults={
'original_filename': file.filename,
'size_bytes': len(data),
'driver_desc': parsed.driver_names[0] if parsed.driver_names else None,
'inf_filename': parsed.inf_filename,
'architecture': parsed.architecture,
'has_cat_file': parsed.has_cat_file,
}
)
# Return HTMX partial: driver list fragment
drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
return templates.TemplateResponse(
request=request,
name="partials/driver_list.html",
context={
"drivers": drivers,
"new_driver": driver,
"parsed": parsed,
},
)
```
### Pattern 4: HTMX Upload Form with Target Swap
**What:** The upload form posts to `/drivers/upload` and replaces the `#driver-list` section with the returned HTML fragment.
**When to use:** Drivers page — upload form section.
```html
<!-- templates/drivers.html (relevant fragment) -->
<form
hx-post="/drivers/upload"
hx-encoding="multipart/form-data"
hx-target="#driver-list"
hx-swap="outerHTML"
hx-indicator="#upload-spinner"
>
<label for="driver-file">Driver Package (ZIP)</label>
<input type="file" id="driver-file" name="file" accept=".zip" required>
<button type="submit">Upload</button>
<span id="upload-spinner" class="htmx-indicator">Uploading...</span>
</form>
<div id="driver-list">
{% include "partials/driver_list.html" %}
</div>
```
The server returns a replacement `<div id="driver-list">...</div>` containing the updated driver table plus any success/warning messages (unused files count, architecture, etc.).
### Anti-Patterns to Avoid
- **`zipfile.extractall()` without path validation:** Vulnerable to Zip Slip. Always iterate `zf.namelist()` and reject entries with `..` or absolute paths before reading.
- **`configparser` with `strict=True` for INF files:** Real INF files frequently contain duplicate keys across sections (multiple models with similar names). `strict=False` is required.
- **Using configparser's interpolation for `%TOKEN%` expansion:** configparser's built-in interpolation uses `%(key)s` syntax, not `%KEY%`. Use `RawConfigParser` and a separate regex-based `_resolve_tokens()` function.
- **Assuming one INF per ZIP:** Some vendor packages contain multiple INF files (x64 + x86 in different subdirectories). Parse the first `.inf` found; flag if multiples exist.
- **`async def` route for upload:** Since `DriverStore.save()` and `Driver.get_or_create()` are synchronous (Peewee), use a regular `def` route. FastAPI runs sync handlers in a thread pool automatically — no blocking.
- **Storing parsed driver names as a list in SQLite:** The existing `Driver.driver_desc` is a single `CharField`. For Phase 2, store the first (or primary) driver name. If multi-driver-name support is needed, that is a Phase 2+ schema change — but the current schema supports the DRV-03 requirement of a single dropdown choice per uploaded package.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| INF file parsing | Custom INI tokenizer from scratch | `configparser.RawConfigParser` + `_resolve_tokens()` | INI format edge cases: duplicate keys, inline comments, continuation lines, quoted strings |
| ZIP extraction | Custom byte-level ZIP reader | `zipfile.ZipFile(io.BytesIO(data))` | Handles all ZIP variants (ZIP64, deflate, stored); stdlib, no extra dep |
| Content-addressed file storage | New storage abstraction | `DriverStore` (already built in Phase 1) | SHA256 + dedup already implemented and tested |
| Driver ORM record | Raw SQL INSERT | `Driver.get_or_create(sha256=sha256, ...)` | Idempotent on re-upload; schema already has all Phase 2 fields |
| HTMX multipart upload form | Custom `fetch()` JavaScript | `hx-encoding="multipart/form-data"` on `<form>` | One attribute handles encoding; HTMX manages request + swap; no JS needed |
**Key insight:** Phase 1 already solved persistence and deduplication. Phase 2's only novel logic is the INF parser and the upload endpoint wiring.
---
## Common Pitfalls
### Pitfall 1: INF Encoding Not Detected — `UnicodeDecodeError`
**What goes wrong:** Reading a UTF-16 LE INF file as UTF-8 raises `UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0`. Typical for HP and Canon INF files, which ship as UTF-16 LE with BOM.
**Why it happens:** Windows uses UTF-16 internally; INF files signed for x64 are frequently written in UTF-16.
**How to avoid:** Always sniff the first 3 bytes before calling `decode()`. UTF-16 LE BOM is `\xff\xfe`; UTF-16 BE BOM is `\xfe\xff`; UTF-8 BOM is `\xef\xbb\xbf`. Fall back to `cp1252` (not `utf-8`) for BOM-less files — `cp1252` is a superset of Latin-1 and handles Western European printer names without errors.
**Warning signs:** `UnicodeDecodeError` on real vendor ZIP uploads.
### Pitfall 2: `configparser` `strict=True` Fails on Duplicate Keys
**What goes wrong:** `configparser.DuplicateOptionError` is raised when parsing INF files that list multiple models with the same base name but different decorations.
**Why it happens:** `configparser` defaults to `strict=True` which rejects duplicate keys within the same section. INF files frequently have this structure.
**How to avoid:** Instantiate with `configparser.RawConfigParser(strict=False)`.
### Pitfall 3: `%TOKEN%` Values Appear Literally in Dropdown
**What goes wrong:** Driver dropdown shows `%HP_LASERJET_P2055D%` instead of `HP LaserJet P2055d`.
**Why it happens:** `configparser` does not process `%...%` INF token syntax — only its own `%(key)s` interpolation, which is irrelevant here.
**How to avoid:** After parsing, apply `_resolve_tokens(raw_value, strings_dict)` to every extracted device-description. Build the `strings` dict from `[Strings]` section values (stripped of surrounding double-quotes).
### Pitfall 4: Zip Slip Vulnerability on Upload
**What goes wrong:** A malicious or malformed ZIP contains entries like `../../etc/passwd`. `zipfile.extractall()` writes those files to the host filesystem.
**Why it happens:** The default Python `zipfile.extractall()` does not check for traversal paths.
**How to avoid:** Never call `extractall()`. Read individual members with `zf.read(name)` after validating each `name` in `zf.namelist()` does not start with `/` or contain `..`. This is safe because the file is read into memory, not extracted to disk.
**Warning signs:** Any code that calls `zf.extractall(path)` without path filtering.
### Pitfall 5: No `.inf` in ZIP Returns a Confusing Error
**What goes wrong:** Technician uploads a ZIP that contains only drivers but not the INF (common when someone zips the wrong folder). The endpoint crashes with a `KeyError` or returns HTTP 500.
**Why it happens:** Code assumes at least one `.inf` member exists.
**How to avoid:** Explicit check: `if not inf_names: raise HTTPException(400, "No .inf file found in ZIP")`. Return the error as an HTMX response so it appears in-page without a full reload. Render the error inside the `#driver-list` target.
### Pitfall 6: Multi-INF ZIP — Wrong Driver Names Selected
**What goes wrong:** A ZIP with both x64 and x86 INF files (in subdirectories) produces duplicate driver names if both are parsed, or wrong names if the wrong file is parsed.
**Why it happens:** Some vendors ship a ZIP with `x64/printer.inf` and `x86/printer.inf` containing different model lists.
**How to avoid:** Prefer INF files whose path does not contain `x86` when an `amd64`/`x64` sibling exists. Sort candidates to prefer `amd64`/`NTamd64` variants. Log a warning when multiple INF files are found; surface the INF filename in the UI so the technician can verify.
### Pitfall 7: `Driver.driver_desc` Stores Only One Name (Schema Limitation)
**What goes wrong:** An INF file contains 15 different models. Only one is stored in `driver_desc`. DRV-03 requires a dropdown of all parsed names — but after page reload, only the stored name is shown.
**Why it happens:** The Phase 1 schema stores a single `driver_desc` CharField.
**How to avoid:** Store all parsed driver names as a JSON-encoded list in `driver_desc` (e.g., `json.dumps(driver_names)`). The dropdown is generated by parsing the stored JSON. Alternatively, store the INF text itself. The simplest solution that satisfies DRV-03 without schema changes: store `json.dumps(parsed.driver_names)` in `driver_desc` and decode at read time. This fits in one CharField with no migration.
---
## Code Examples
### INF Encoding Detection
```python
# imptune/services/inf_parser.py
def _detect_encoding(raw: bytes) -> str:
"""Sniff BOM bytes to determine INF file encoding.
Source: Microsoft WDK — general-syntax-rules-for-inf-files
"""
if raw[:2] in (b'\xff\xfe', b'\xfe\xff'):
return 'utf-16'
if raw[:3] == b'\xef\xbb\xbf':
return 'utf-8-sig'
return 'cp1252'
```
### Safe ZIP Member Reading (Zip Slip Prevention)
```python
# Before reading any member:
for name in zf.namelist():
if name.startswith('/') or '..' in name:
raise HTTPException(400, f"Dangerous path in ZIP: {name}")
# Then read safely:
inf_bytes = zf.read(inf_names[0])
```
### HTMX Upload Form (multipart)
```html
<!-- hx-encoding is the critical attribute for file upload -->
<form
hx-post="/drivers/upload"
hx-encoding="multipart/form-data"
hx-target="#driver-list"
hx-swap="outerHTML"
>
<input type="file" name="file" accept=".zip" required>
<button type="submit">Upload Driver Package</button>
</form>
```
### Driver Record Upsert (idempotent on SHA256)
```python
# Peewee get_or_create — safe for duplicate uploads
driver, created = Driver.get_or_create(
sha256=sha256,
defaults={
'original_filename': file.filename,
'size_bytes': len(data),
'driver_desc': json.dumps(parsed.driver_names),
'inf_filename': parsed.inf_filename,
'architecture': parsed.architecture,
'has_cat_file': parsed.has_cat_file,
}
)
```
### Unused Files Detection
```python
# Compare ZIP member basenames against INF text
# Source: DRV-05 requirement
inf_lower = inf_text.lower()
unused = []
for member in zip_names:
basename = member.rsplit('/', 1)[-1].rsplit('\\', 1)[-1]
if basename.lower() not in inf_lower:
unused.append(member)
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Free-text driver name entry | Dropdown from parsed INF DriverDesc | DRV-03 (this phase) | Eliminates typos; driver name matches exactly what pnputil expects |
| Manual INF file navigation | Automated DriverDesc extraction + `%TOKEN%` resolution | This phase | Technician never needs to open the INF |
| `pyinf` third-party library | Python stdlib `configparser` + custom resolver | This phase decision | Zero new dependency; full control over edge-case handling |
**Note on driver name storage:** The `driver_desc` column was designed in Phase 1 as a single `CharField`. Storing `json.dumps(list)` is the correct approach to preserve all model names without a schema migration. The Phase 3 printer configuration form reads this JSON to render the `<select>` dropdown.
---
## Open Questions
1. **Multi-INF ZIPs: which INF wins?**
- What we know: Some vendor packages (HP Universal Print Driver) contain multiple INF files for different architectures or print frameworks.
- What's unclear: Whether to parse all INFs and merge names, or to pick one based on file path heuristics.
- Recommendation: Parse the INF whose path contains `amd64` or `x64` if multiple exist; fall back to the first `.inf` alphabetically. Surface the selected INF filename in the UI and the driver list so the technician can verify.
2. **DriverDesc deduplication across Models sections**
- What we know: When an INF has both `[Mfg.NTamd64]` and `[Mfg.NTarm64]` sections, the same `%TOKEN%` key appears in both. After token resolution, values are identical.
- What's unclear: Whether any INFs intentionally list different names per architecture.
- Recommendation: Use a `set()` for deduplication during extraction, then `sorted()` for consistent dropdown order.
3. **Unused-file detection accuracy**
- What we know: The naive approach (check if basename appears in INF text) will produce false negatives for files referenced only by full path, and false positives for files referenced by registry entries or other INF directives not in `CopyFiles`.
- What's unclear: How accurate DRV-05 needs to be — is a "best-effort" count acceptable?
- Recommendation: Implement the naive basename-in-text approach for Phase 2. It handles the common case correctly (CopyFiles lists basenames). Mark it as best-effort in the UI: "X files may be unused". A more precise parser checking `CopyFiles` / `SourceDisksFiles` directives specifically is a Phase 2+ enhancement.
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | pytest (already installed in requirements-dev.txt from Phase 1) |
| Config file | None — uses pytest auto-discovery |
| Quick run command | `pytest tests/ -x -q` |
| Full suite command | `pytest tests/ -v` |
### Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| DRV-01 | POST /drivers/upload with valid ZIP returns 200 and HTML fragment | integration | `pytest tests/test_driver_upload.py::test_upload_valid_zip -x` | Wave 0 |
| DRV-01 | POST /drivers/upload with non-ZIP file returns 400 | unit | `pytest tests/test_driver_upload.py::test_upload_non_zip -x` | Wave 0 |
| DRV-01 | POST /drivers/upload with ZIP containing no INF returns 400 | unit | `pytest tests/test_driver_upload.py::test_upload_no_inf -x` | Wave 0 |
| DRV-02 | parse_inf extracts DriverDesc from simple INF (token-free) | unit | `pytest tests/test_inf_parser.py::test_simple_driver_desc -x` | Wave 0 |
| DRV-02 | parse_inf resolves %TOKEN% values from [Strings] section | unit | `pytest tests/test_inf_parser.py::test_token_resolution -x` | Wave 0 |
| DRV-02 | parse_inf handles UTF-16 LE BOM encoded INF | unit | `pytest tests/test_inf_parser.py::test_utf16_encoding -x` | Wave 0 |
| DRV-02 | parse_inf handles multi-model INF (NTamd64 + undecorated) | unit | `pytest tests/test_inf_parser.py::test_multi_model_inf -x` | Wave 0 |
| DRV-03 | Drivers page (GET /drivers) renders upload form | integration | `pytest tests/test_driver_upload.py::test_drivers_page -x` | Wave 0 |
| DRV-03 | Upload response contains populated `<select>` with driver names | integration | `pytest tests/test_driver_upload.py::test_upload_returns_select -x` | Wave 0 |
| DRV-04 | Uploaded driver file exists on disk under DRIVERS_DIR after upload | integration | `pytest tests/test_driver_upload.py::test_driver_persisted -x` | Wave 0 |
| DRV-04 | Re-uploading same ZIP does not create duplicate Driver record | integration | `pytest tests/test_driver_upload.py::test_dedup_upload -x` | Wave 0 |
| DRV-05 | parse_inf returns unused_files list for files not in INF text | unit | `pytest tests/test_inf_parser.py::test_unused_files -x` | Wave 0 |
| DRV-05 | Upload response includes unused-file count/list in HTML | integration | `pytest tests/test_driver_upload.py::test_unused_files_in_response -x` | Wave 0 |
### Sampling Rate
- **Per task commit:** `pytest tests/ -x -q`
- **Per wave merge:** `pytest tests/ -v`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `tests/test_inf_parser.py` — covers DRV-02 (INF parsing, token resolution, encoding, multi-model)
- [ ] `tests/test_driver_upload.py` — covers DRV-01, DRV-03, DRV-04, DRV-05 (upload endpoint integration tests)
- [ ] `tests/fixtures/sample.inf` — minimal valid INF fixture with `%TOKEN%` values
- [ ] `tests/fixtures/sample_utf16.inf` — UTF-16 LE encoded INF fixture
- [ ] `tests/fixtures/sample_multi_model.inf` — INF with NTamd64 and undecorated sections
- [ ] `imptune/services/__init__.py` — package marker (services/ directory exists in project structure but is empty)
*(Framework already installed; conftest.py with `tmp_data_dir` fixture already covers DB isolation)*
---
## Sources
### Primary (HIGH confidence)
- [Microsoft WDK — INF Models Section](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/inf-models-section) — device-description = install-section-name,hw-id format; architecture decorations (NTamd64, NTarm64)
- [Microsoft WDK — General Syntax Rules for INF Files](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/general-syntax-rules-for-inf-files) — encoding (ANSI/UTF-8/UTF-16), `%strkey%` token format, comment syntax, case-insensitivity
- [Microsoft WDK — Printer INF File Entries](https://learn.microsoft.com/en-us/windows-hardware/drivers/print/printer-inf-file-entries) — DriverFile, DataFile, ConfigFile, DriverDesc usage in Ntprint.dll
- [Microsoft WDK — Decorations in Printer INF Files](https://learn.microsoft.com/en-us/windows-hardware/drivers/print/decorations-in-printer-inf-files) — NTamd64 decoration mandatory for x64 since WS2003 SP1
- [FastAPI — Request Files](https://fastapi.tiangolo.com/tutorial/request-files/) — `UploadFile`, `File(...)`, reading file bytes
- [HTMX — hx-encoding attribute](https://htmx.org/attributes/hx-encoding/) — `multipart/form-data` required for file uploads
- [HTMX — File Upload example](https://htmx.org/examples/file-upload/) — progress tracking, server response swap
- Python 3.12 stdlib `zipfile``ZipFile(io.BytesIO())`, `namelist()`, `read()`, `is_zipfile()`
- Python 3.12 stdlib `configparser``RawConfigParser(strict=False)`, `read_string()`, `has_section()`, `items()`
### Secondary (MEDIUM confidence)
- [Snyk / Zip Slip Vulnerability](https://github.com/snyk/zip-slip-vulnerability) — path traversal attack pattern; prevention via `namelist()` validation
- [FastAPI file size limiting discussion](https://github.com/fastapi/fastapi/issues/362) — post-read size check pattern; no built-in pre-rejection mechanism
- [Microsoft WDK — Printer INF File Data Sections](https://learn.microsoft.com/en-us/windows-hardware/drivers/print/printer-inf-file-data-sections) — DataSection pattern; Previous Names section
### Tertiary (LOW confidence)
- [pyinf GitHub](https://github.com/tty72/pyinf) — reviewed and rejected: rudimentary, no recent activity, no benefit over stdlib
---
## Metadata
**Confidence breakdown:**
- FastAPI upload patterns: HIGH — official docs verified
- HTMX multipart form: HIGH — official docs verified
- INF format (overall): HIGH — Microsoft WDK official docs
- INF encoding handling (edge cases): MEDIUM — documented rule is clear but real-world INF corpus has variability; edge cases may surface during testing
- Unused-file detection accuracy: MEDIUM — naive approach is best-effort; accuracy depends on INF structure
**Research date:** 2026-04-10
**Valid until:** 2026-05-10 (stable ecosystem)
@@ -0,0 +1,112 @@
---
phase: 2
slug: driver-management
status: draft
nyquist_compliant: true
wave_0_complete: false
created: 2026-04-10
nyquist_audited: 2026-04-13
nyquist_auditor: Claude (gsd-executor, plan 08-02)
---
# Phase 2 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | pytest (already installed in requirements-dev.txt from Phase 1) |
| **Config file** | None — uses pytest auto-discovery |
| **Quick run command** | `pytest tests/ -x -q` |
| **Full suite command** | `pytest tests/ -v` |
| **Estimated runtime** | ~5 seconds |
---
## Sampling Rate
- **After every task commit:** Run `pytest tests/ -x -q`
- **After every plan wave:** Run `pytest tests/ -v`
- **Before `/gsd:verify-work`:** Full suite must be green
- **Max feedback latency:** 10 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
| 02-01-01 | 01 | 1 | DRV-01 | integration | `pytest tests/test_driver_upload.py::test_upload_valid_zip -x` | ❌ W0 | ⬜ pending |
| 02-01-02 | 01 | 1 | DRV-01 | unit | `pytest tests/test_driver_upload.py::test_upload_non_zip -x` | ❌ W0 | ⬜ pending |
| 02-01-03 | 01 | 1 | DRV-01 | unit | `pytest tests/test_driver_upload.py::test_upload_no_inf -x` | ❌ W0 | ⬜ pending |
| 02-02-01 | 02 | 1 | DRV-02 | unit | `pytest tests/test_inf_parser.py::test_simple_driver_desc -x` | ❌ W0 | ⬜ pending |
| 02-02-02 | 02 | 1 | DRV-02 | unit | `pytest tests/test_inf_parser.py::test_token_resolution -x` | ❌ W0 | ⬜ pending |
| 02-02-03 | 02 | 1 | DRV-02 | unit | `pytest tests/test_inf_parser.py::test_utf16_encoding -x` | ❌ W0 | ⬜ pending |
| 02-02-04 | 02 | 1 | DRV-02 | unit | `pytest tests/test_inf_parser.py::test_multi_model_inf -x` | ❌ W0 | ⬜ pending |
| 02-03-01 | 03 | 2 | DRV-03 | integration | `pytest tests/test_driver_upload.py::test_drivers_page -x` | ❌ W0 | ⬜ pending |
| 02-03-02 | 03 | 2 | DRV-03 | integration | `pytest tests/test_driver_upload.py::test_upload_returns_select -x` | ❌ W0 | ⬜ pending |
| 02-04-01 | 01 | 1 | DRV-04 | integration | `pytest tests/test_driver_upload.py::test_driver_persisted -x` | ❌ W0 | ⬜ pending |
| 02-04-02 | 01 | 1 | DRV-04 | integration | `pytest tests/test_driver_upload.py::test_dedup_upload -x` | ❌ W0 | ⬜ pending |
| 02-05-01 | 02 | 1 | DRV-05 | unit | `pytest tests/test_inf_parser.py::test_unused_files -x` | ❌ W0 | ⬜ pending |
| 02-05-02 | 03 | 2 | DRV-05 | integration | `pytest tests/test_driver_upload.py::test_unused_files_in_response -x` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `tests/test_inf_parser.py` — stubs for DRV-02 (INF parsing, token resolution, encoding, multi-model)
- [ ] `tests/test_driver_upload.py` — stubs for DRV-01, DRV-03, DRV-04, DRV-05 (upload endpoint integration tests)
- [ ] `tests/fixtures/sample.inf` — minimal valid INF fixture with `%TOKEN%` values
- [ ] `tests/fixtures/sample_utf16.inf` — UTF-16 LE encoded INF fixture
- [ ] `tests/fixtures/sample_multi_model.inf` — INF with NTamd64 and undecorated sections
- [ ] `imptune/services/__init__.py` — package marker (services/ directory)
*Framework already installed; conftest.py with `tmp_data_dir` fixture already covers DB isolation*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Upload form renders correctly in browser | DRV-03 | Visual layout verification | Open /drivers, verify file input and submit button visible |
| Dropdown populated after upload in browser | DRV-03 | HTMX swap visual verification | Upload sample ZIP, verify `<select>` appears with driver names |
---
## Nyquist Record
> Audited 2026-04-13 by Claude (gsd-executor, plan 08-02). One row per Phase 2 success criterion derived from `milestones/v1.0-ROADMAP.md` Phase 2 goal + plan outcomes (DRV-01..05), cross-checked against `02-VERIFICATION.md` (14/14 observable truths verified 2026-04-10) and `REQUIREMENTS.md` v1.0 DRV-0x block. Evidence cites committed tests, source lines, or the dated VERIFICATION report. Status values: `pass` / `fail-fix-v1.1` / `deferred-v1.2` / `wont-do`.
>
> **Phase 2 goal (v1.0-ROADMAP.md):** *"Technicians upload driver packages and select driver names from parsed INF data — no free-text entry."*
| # | Success Criterion | Observable Check | Evidence | Status | Notes |
|---|-------------------|------------------|----------|--------|-------|
| 1 | **DRV-01** — User can upload a driver package (ZIP containing INF + supporting files) via the web UI | `pytest tests/test_driver_upload.py::test_upload_valid_zip` returns 200 on POST /drivers/upload with a synthetic ZIP; `::test_upload_non_zip` and `::test_upload_no_inf` both return 400 | `tests/test_driver_upload.py::test_upload_valid_zip`, `::test_upload_non_zip`, `::test_upload_no_inf`; `imptune/api/drivers.py` POST `/drivers/upload` handler (commit c648fc5); 02-VERIFICATION.md rows 8 + 10 (2026-04-10) | pass | Three-path coverage (success, non-ZIP, ZIP without INF). |
| 2 | **DRV-02** — System parses uploaded INF files and extracts valid driver names (DriverDesc), resolving %TOKEN% references, handling UTF-16/UTF-8/ANSI encodings, and deduping multi-model entries | `pytest tests/test_inf_parser.py` — 16 tests covering `test_simple_driver_desc`, `test_token_resolution`, `test_detect_encoding_utf16le/be/utf8bom/ansi`, `test_utf16_encoding`, `test_multi_model_inf`, `test_architecture_detection_*`, `test_cat_file_detection_*` | `tests/test_inf_parser.py` (16 tests, 281 lines); `imptune/services/inf_parser.py``parse_inf`, `_detect_encoding`, `_resolve_tokens` (commits 290106d RED, 5056922 GREEN); 02-VERIFICATION.md rows 1-7 | pass | `RawConfigParser(strict=False)` + `optionxform=str` preserves DriverDesc casing; BOM-sniffing for encoding detection. |
| 3 | **DRV-03** — User can select a driver name from a parsed-INF dropdown on the drivers page (no free-text entry) | `pytest tests/test_driver_upload.py::test_drivers_page` (form present) and `::test_upload_returns_select` (response contains `<select` and a parsed driver name) | `tests/test_driver_upload.py::test_drivers_page`, `::test_upload_returns_select`; `imptune/templates/drivers.html` (`hx-post="/drivers/upload"`, `hx-target="#driver-list"`); `imptune/templates/partials/driver_list.html` (`<select aria-label="Driver names">`); 02-VERIFICATION.md rows 9 + 14 | pass | Template always renders `<select>` even for single-name drivers (decision in 02-02-SUMMARY). Real-browser HTMX swap covered by row 6. |
| 4 | **DRV-04** — Uploaded driver packages are persisted to the Docker volume (`DRIVERS_DIR`) under SHA256 content-addressed names and survive container restart; re-uploading the same ZIP does not duplicate the Driver record | `pytest tests/test_driver_upload.py::test_driver_persisted` (file lands on disk under `tmp_data_dir/drivers/`) and `::test_dedup_upload` (2 uploads → `Driver.select().where(sha256==...).count() == 1`) | `tests/test_driver_upload.py::test_driver_persisted`, `::test_dedup_upload`; `imptune/storage/driver_store.py::DriverStore.save` (SHA256-named files); `imptune/api/drivers.py` lines 85-99 (`DriverStore(_cfg.DRIVERS_DIR).save(data)``Driver.get_or_create(sha256=…)`); 02-VERIFICATION.md rows 11 + 12 | pass | Content-addressed storage gives dedup for free. `_cfg.DRIVERS_DIR` read dynamically at call time so monkeypatch works in tests (02-02-SUMMARY decision). |
| 5 | **DRV-05** — System flags unused files (files in ZIP not referenced by the INF) to help technicians reduce driver package size | `pytest tests/test_inf_parser.py::test_unused_files` (parser returns `unused_files` list) and `pytest tests/test_driver_upload.py::test_unused_files_in_response` (word "unused" present in response HTML) | `tests/test_inf_parser.py::test_unused_files`; `tests/test_driver_upload.py::test_unused_files_in_response`; `imptune/services/inf_parser.py` `ParsedInf.unused_files`; `imptune/templates/partials/driver_list.html` unused-files notice; 02-VERIFICATION.md rows 5 + 13 | pass | |
| 6 | **DRV-01 runtime gap**`POST /drivers/upload` must not return HTTP 500 on real driver ZIPs uploaded via the browser (reported 2026-04-13 during Phase 8 kickoff; parallel to the v1.1 UX-01 DriverDesc-refresh requirement) | `pytest tests/test_driver_upload.py::test_upload_500_regression` (two parametrized variants: plain UTF-8 and UTF-16 LE BOM) returns 200, never 500; plus OOB refresh covered by `::test_upload_oob_*` contract tests | Phase 9 commit `10ee09a` (fix handler: `caller: str = Form("")` + OOB branch in `imptune/api/drivers.py`); Phase 9 commit `d1de839` (regression + OOB RED tests); Phase 9 commit `72c6a98` (printer_form.html wiring); `.planning/phases/09-ux-tech-debt-closure/09-01-SUMMARY.md` (UX-01 complete 2026-04-13); REQUIREMENTS.md v1.1 UX-01 = Complete | pass | **Historical gap recorded per CONTEXT.md locked decision.** At Phase 8 kickoff this was slated as `fail-fix-v1.1` linked to Phase 9 / UX-01. Resolved 2026-04-13 in Phase 9 Plan 01 (commits d1de839 + 10ee09a + 72c6a98); 112 tests green post-fix. Closed as `pass` citing the fixing commits, consistent with the 08-01 precedent (row 14 Phase 1 spike → Phase 10 RTVAL-01). |
**Audit outcome:** 6/6 rows `pass`. No `fail-fix-v1.1`, `deferred-v1.2`, or `wont-do` rows. Phase 2 is Nyquist-compliant: every DRV-0x success criterion has exactly one observable check with cited, committed evidence. The Phase 8 kickoff-surfaced `POST /drivers/upload` 500 gap is captured as row 6 and closed via Phase 9 / UX-01 fixing commits — fully honoring the CONTEXT.md locked-decision mandate.
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 10s
- [x] `nyquist_compliant: true` set in frontmatter
- [x] Nyquist audit complete — 2026-04-13 — Sébastien QUEROL
**Approval:** Nyquist-audited 2026-04-13 by Claude (gsd-executor, plan 08-02) — 6/6 pass; signed off 2026-04-13 by Sébastien QUEROL (index: v1.0-VALIDATION-INDEX.md)
@@ -0,0 +1,150 @@
---
phase: 02-driver-management
verified: 2026-04-10T10:45:00Z
status: passed
score: 16/16 must-haves verified
re_verification: false
gaps: []
human_verification:
- test: "Upload a real-world vendor driver ZIP via browser at /drivers"
expected: "Driver names appear in the select dropdown; page updates inline without reload"
why_human: "HTMX swap behaviour and real-vendor INF edge cases cannot be verified programmatically"
- test: "Upload the same ZIP a second time"
expected: "No duplicate row appears in the driver table; response still returns 200"
why_human: "Dedup correctness is test-verified but visual confirmation in browser confirms UI consistency"
---
# Phase 02: Driver Management Verification Report
**Phase Goal:** Driver upload, INF parsing, and driver management for Windows driver packages
**Verified:** 2026-04-10T10:45:00Z
**Status:** PASSED
**Re-verification:** No — initial verification
---
## Goal Achievement
### Observable Truths (Plan 02-01)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | parse_inf extracts DriverDesc values from a simple INF with literal names | VERIFIED | `test_simple_driver_desc` passes; `parse_inf` returns `["Acme SuperPrint 9000"]` |
| 2 | parse_inf resolves %TOKEN% references via the [Strings] section | VERIFIED | `test_token_resolution` passes; `%HP_DRIVER%` resolves to `"HP LaserJet"` |
| 3 | parse_inf handles UTF-16 LE BOM, UTF-8 BOM, and ANSI (cp1252) encoded INF files | VERIFIED | `test_detect_encoding_utf16le`, `test_detect_encoding_utf16be`, `test_detect_encoding_utf8bom`, `test_detect_encoding_ansi`, `test_utf16_encoding` — all pass |
| 4 | parse_inf deduplicates driver names from multi-model INFs (NTamd64 + undecorated) | VERIFIED | `test_multi_model_inf` passes; `Multi Printer 1000` appears exactly once |
| 5 | parse_inf returns a list of unused files not referenced in the INF text | VERIFIED | `test_unused_files` passes; `readme.txt` in unused_files, `driver.dll` not in unused_files |
| 6 | parse_inf detects architecture from section decorations (x64, x86, arm64) | VERIFIED | `test_architecture_detection_amd64/arm64/undecorated/mixed` — all 4 pass |
| 7 | parse_inf detects presence of .cat file in ZIP member list | VERIFIED | `test_cat_file_detection_present` and `test_cat_file_detection_absent` pass |
### Observable Truths (Plan 02-02)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 8 | User can upload a ZIP file via the /drivers page and receive a success response | VERIFIED | `test_upload_valid_zip` passes; POST /drivers/upload returns 200 |
| 9 | After upload, the response contains a populated select dropdown with driver names from the INF | VERIFIED | `test_upload_returns_select` passes; `<select` and `"Test LaserJet Pro"` in response HTML |
| 10 | Uploading a non-ZIP file or a ZIP with no INF returns a 400 error displayed in-page | VERIFIED | `test_upload_non_zip` and `test_upload_no_inf` both return 400 |
| 11 | Uploaded driver file is persisted to DRIVERS_DIR via DriverStore (survives restart) | VERIFIED | `test_driver_persisted` passes; SHA256-named file exists on disk under tmp_data_dir/drivers/ |
| 12 | Re-uploading the same ZIP does not create a duplicate Driver record (SHA256 dedup) | VERIFIED | `test_dedup_upload` passes; Driver.select().where(sha256==...).count() == 1 after two uploads |
| 13 | Upload response shows count of unused files not referenced by the INF | VERIFIED | `test_unused_files_in_response` passes; word "unused" present in response HTML |
| 14 | GET /drivers renders the drivers page with upload form and existing driver list | VERIFIED | `test_drivers_page` passes; HTML contains `type="file"`, `hx-post`, `/drivers/upload` |
**Score: 14/14 truths verified** (16/16 counting plan artifacts below)
---
## Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `imptune/services/inf_parser.py` | ParsedInf dataclass and parse_inf() + _detect_encoding() | VERIFIED | 174 lines; exports ParsedInf, parse_inf, _detect_encoding, _resolve_tokens |
| `imptune/services/__init__.py` | Package marker | VERIFIED | Exists |
| `tests/test_inf_parser.py` | Unit tests covering DRV-02 and DRV-05 behaviors, min 80 lines | VERIFIED | 281 lines; 16 tests |
| `tests/fixtures/sample.inf` | Minimal valid INF with %TOKEN% values and [Strings] section | VERIFIED | Present; contains `%DRIVER_NAME%`, `%MFG%`, `[Strings]` section |
| `tests/fixtures/sample_utf16.inf` | UTF-16 LE encoded INF for encoding detection test | VERIFIED | Present as binary; `_detect_encoding` returns `utf-16` for it |
| `tests/fixtures/sample_multi_model.inf` | INF with NTamd64 and undecorated Models sections | VERIFIED | Present; contains `[Models]` and `[Models.NTamd64]` sections |
| `imptune/api/drivers.py` | POST /drivers/upload endpoint returning HTMX partial | VERIFIED | 116 lines; `router = APIRouter(prefix="/drivers")`; full validation + persistence |
| `imptune/templates/drivers.html` | Drivers page with upload form and driver list container | VERIFIED | Extends base.html; contains `hx-post="/drivers/upload"`, `hx-target="#driver-list"` |
| `imptune/templates/partials/driver_list.html` | HTMX partial fragment with driver table and select dropdown | VERIFIED | Contains `<select aria-label="Driver names">` and unused-files notice block |
| `tests/test_driver_upload.py` | Integration tests for upload endpoint and drivers page, min 80 lines | VERIFIED | 162 lines; 8 tests |
---
## Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `imptune/services/inf_parser.py` | `configparser.RawConfigParser` | stdlib import | VERIFIED | `RawConfigParser(... strict=False ...)` at line 85 |
| `imptune/services/inf_parser.py` | [Strings] section token expansion | `re.sub(%([^%]+)%)` regex | VERIFIED | `_resolve_tokens` uses `re.sub(r"%([^%]+)%", replacer, value)` at line 60 |
| `imptune/api/drivers.py` | `imptune/services/inf_parser.py` | `from imptune.services.inf_parser import` | VERIFIED | Line 15: `from imptune.services.inf_parser import _detect_encoding, parse_inf` |
| `imptune/api/drivers.py` | `imptune/storage/driver_store.py` | `DriverStore(...).save(data)` | VERIFIED | Lines 85-86: `store = DriverStore(_cfg.DRIVERS_DIR)` then `sha256 = store.save(data)` |
| `imptune/api/drivers.py` | `imptune/db/models.py` | `Driver.get_or_create(sha256=...)` | VERIFIED | Lines 89-99: full `Driver.get_or_create(sha256=sha256, defaults={...})` |
| `imptune/templates/drivers.html` | `/drivers/upload` | `hx-post` with multipart/form-data | VERIFIED | `hx-post="/drivers/upload"` and `hx-encoding="multipart/form-data"` present |
| `imptune/main.py` | `imptune/api/drivers.py` | `app.include_router(drivers.router)` | VERIFIED | Line 30: `app.include_router(drivers.router)` |
All 7 key links verified as WIRED.
---
## Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| DRV-01 | 02-02 | User can upload a driver package (ZIP containing INF + supporting files) | SATISFIED | POST /drivers/upload validated; `test_upload_valid_zip` passes |
| DRV-02 | 02-01 | System parses uploaded INF files and extracts valid driver names (DriverDesc) | SATISFIED | `parse_inf` extracts DriverDesc; 16 unit tests all pass |
| DRV-03 | 02-02 | User can select driver name from parsed INF dropdown (no free-text) | SATISFIED | `<select>` dropdown in `driver_list.html`; `test_upload_returns_select` passes |
| DRV-04 | 02-02 | Driver packages are persisted on Docker volume across container restarts | SATISFIED | `DriverStore.save()` writes SHA256-named file to `DRIVERS_DIR`; `test_driver_persisted` verifies file on disk |
| DRV-05 | 02-01, 02-02 | System flags unused files in driver packages to help reduce package size | SATISFIED | `parse_inf` returns `unused_files` list; partial template shows count; `test_unused_files_in_response` passes |
No orphaned requirements. All 5 DRV-0x requirements mapped to plans and verified in codebase.
---
## Anti-Patterns Found
No blockers or stubs detected.
| File | Pattern | Severity | Impact |
|------|---------|----------|--------|
| `imptune/db/models.py` (indirect) | `datetime.utcnow()` deprecated in Python 3.12+ | Info | DeprecationWarning in test output; does not affect correctness |
The deprecation warning is in the Peewee library's own call path (not in phase 02 code) and carries zero functional risk for the current Python 3.14 runtime target.
---
## Test Results Summary
| Test Suite | Tests | Passed | Failed |
|-----------|-------|--------|--------|
| `tests/test_inf_parser.py` | 16 | 16 | 0 |
| `tests/test_driver_upload.py` | 8 | 8 | 0 |
| Full suite (`tests/`) | 48 | 48 | 0 |
Zero regressions in pre-existing Phase 1 tests.
---
## Human Verification Required
### 1. Browser upload flow with HTMX swap
**Test:** Open `/drivers` in a browser, select a real vendor driver ZIP, click Upload.
**Expected:** Page updates in-place (no full reload); driver name appears in a `<select>` dropdown; unused files count shown if any.
**Why human:** HTMX swap behaviour (outerHTML targeting `#driver-list`) and real-vendor INF edge cases cannot be confirmed by automated HTTP tests.
### 2. Duplicate upload visual confirmation
**Test:** Upload the same ZIP twice via the browser.
**Expected:** Driver table shows exactly one row for that driver; no duplicate entry.
**Why human:** The dedup logic is verified by `test_dedup_upload` but the rendered table update on second upload benefits from a visual check.
---
## Gaps Summary
No gaps. All 14 observable truths verified, all 10 required artifacts present and substantive, all 7 key links wired, all 5 DRV-0x requirements satisfied.
---
_Verified: 2026-04-10T10:45:00Z_
_Verifier: Claude (gsd-verifier)_