From 9f5ee24811b05692ee8252db6c8212805cb3bac9 Mon Sep 17 00:00:00 2001 From: Kawa Date: Fri, 10 Apr 2026 11:48:02 +0200 Subject: [PATCH] docs(02-driver-management): create phase plan Two plans: INF parser TDD (wave 1), upload endpoint + UI (wave 2). Covers DRV-01 through DRV-05. Co-Authored-By: Claude Opus 4.6 (1M context) --- .planning/ROADMAP.md | 9 +- .../phases/02-driver-management/02-01-PLAN.md | 213 ++++++++++++ .../phases/02-driver-management/02-02-PLAN.md | 311 ++++++++++++++++++ 3 files changed, 528 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/02-driver-management/02-01-PLAN.md create mode 100644 .planning/phases/02-driver-management/02-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c219319..0a429be 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -45,12 +45,11 @@ Plans: 2. After upload, user sees a dropdown of driver names extracted from the INF (DriverDesc values), not a text field 3. Uploaded driver packages survive container restarts (persisted to Docker volume) 4. System flags files in the driver package that are not referenced by the INF, with a count or list -**Plans**: TBD +**Plans**: 2 plans Plans: -- [ ] 02-01: Driver upload endpoint and volume storage (SHA256-keyed, deduplication) -- [ ] 02-02: INF parser (DriverDesc extraction, multi-model INF support) -- [ ] 02-03: Driver library UI (upload form, driver list, unused-file hints) +- [ ] 02-01-PLAN.md — INF parser with TDD (encoding detection, token resolution, multi-model, unused files) +- [ ] 02-02-PLAN.md — Driver upload endpoint, persistence, drivers page UI with HTMX ### Phase 3: Printer Configuration **Goal**: Technicians can configure all printer parameters, assign printers to clients, and retrieve saved configs without re-uploading drivers @@ -109,7 +108,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Foundation | 3/3 | Complete | 2026-04-10 | -| 2. Driver Management | 0/3 | Not started | - | +| 2. Driver Management | 0/2 | Not started | - | | 3. Printer Configuration | 0/3 | Not started | - | | 4. Script Generation | 0/3 | Not started | - | | 5. Package Export | 0/3 | Not started | - | diff --git a/.planning/phases/02-driver-management/02-01-PLAN.md b/.planning/phases/02-driver-management/02-01-PLAN.md new file mode 100644 index 0000000..7fff48c --- /dev/null +++ b/.planning/phases/02-driver-management/02-01-PLAN.md @@ -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.*%([^%]+)%" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02-driver-management/02-RESEARCH.md + + + + +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) +``` + + + + + + + Task 1: INF parser with TDD (RED then GREEN) + 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 + + - 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 + + +**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` + + + pytest tests/test_inf_parser.py -v + + 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. + + + + + +```bash +pytest tests/test_inf_parser.py -v +pytest tests/ -x -q # no regressions in existing tests +``` + + + +- 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 + + + +After completion, create `.planning/phases/02-driver-management/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-driver-management/02-02-PLAN.md b/.planning/phases/02-driver-management/02-02-PLAN.md new file mode 100644 index 0000000..ca3e426 --- /dev/null +++ b/.planning/phases/02-driver-management/02-02-PLAN.md @@ -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: " +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +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: ... +``` + + +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 + +
  • Drivers
  • + +``` +
    +
    + + + + + Task 1: Upload endpoint, drivers page route, and integration tests + imptune/api/drivers.py, imptune/api/pages.py, imptune/main.py, tests/test_driver_upload.py + + - 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 + + +**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="
    Error message
    ", 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` +
    + + pytest tests/test_driver_upload.py -v + + 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. +
    + + + Task 2: Drivers page template and HTMX partial + imptune/templates/drivers.html, imptune/templates/partials/driver_list.html + +1. Create `imptune/templates/partials/` directory (if not exists). + +2. Create `imptune/templates/drivers.html` extending base.html: + ```html + {% extends "base.html" %} + {% block content %} +

    Drivers

    +
    +

    Upload Driver Package

    +
    + + + + Uploading... +
    +
    +
    +

    Driver Library

    +
    + {% include "partials/driver_list.html" %} +
    +
    + {% endblock %} + ``` + +3. Create `imptune/templates/partials/driver_list.html`: + - Wrap everything in `
    ` (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 `