feat: Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#102)#106
Conversation
User-approved design aus superpowers:brainstorming Session 2026-06-04. 4 Sections: Scope, Architektur, Komponenten+Datenmodell, Fehlerbehandlung+Tests. Atomic Failure Semantik (Option 1 — User-Entscheidung), API-Vertrag bleibt unverändert, QL out-of-scope (kein print_multi-Equivalent in brother_ql Lib). Referenziert Issues #101 (Umbrella), #102 (1k.2), #103 (1k.1 Auto-Scale Insight). Refs #102
12 Tasks via TDD: 1. default_print_images_loop helper 2. PrinterBackend.print_images Protocol method 3. PTouchBackend.print_images + _ptouch_print_multi 4. BrotherQLBackend.print_images (default loop) 5. MockBackend.print_images (default loop) 6. _PTPQueuePrinter adapter + bug-fix half_cut/last_page forwarding (PR #100 regression) 7. _QLQueuePrinter adapter 8. PrintQueue.BatchJob dataclass + enqueue_batch + worker isinstance dispatch 9. dispatch_batch refactor + MixedTapeSizesError 10. API route: MixedTapeSizesError -> 400 11. End-to-End integration test 12. Hardware-smoke script (manual PT-P750W verification) Bug-Fund waehrend Plan-Writing: _PTPQueuePrinter droppt half_cut + last_page beim Forward zum Backend — PR #100 fix erreichte ptouch lib nie. Task 6 fixt das mit. Refs #102
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request implements the Phase 1k.2 design for multi-label batch printing. By leveraging the ptouch.print_multi capability, the system now achieves efficient batch printing with proper tactile separation between labels. The changes include a new BatchJob queue architecture, a robust validation layer for tape consistency, and critical bug fixes in the printer adapter layer to ensure correct option propagation. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
|
Title updated from |
There was a problem hiding this comment.
Code Review
This pull request introduces a design spec and a detailed implementation plan for Phase 1k.2, which aims to implement multi-label batch printing via ptouch.print_multi() to achieve 5mm half-cuts between labels on PT-series printers. The code review identified three critical architectural and performance gaps in the proposed plan: first, a lack of in-memory job registration in enqueue_batch that would cause KeyErrors during status queries; second, missing in-memory state transitions and SSE notifications in _process_batch that would result in hanging waiters and a non-responsive UI; and third, synchronous CPU-intensive rendering in submit_batch_job that blocks the async event loop. The reviewer provided actionable code suggestions to resolve these issues by properly managing in-memory states and offloading rendering to threads concurrently.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async def enqueue_batch( | ||
| self, | ||
| *, | ||
| printer_id: UUID, | ||
| images: list[Image.Image], | ||
| job_ids: list[UUID], | ||
| tape_mm: int, | ||
| options: dict[str, Any], | ||
| ) -> UUID: | ||
| """Phase 1k.2: Submit N images as ONE BatchJob (atomic print_multi call). | ||
|
|
||
| Args: | ||
| printer_id: Target printer (must be registered in self._queues). | ||
| images: PIL Images in print order, len(images) >= 1. | ||
| job_ids: Pre-allocated job UUIDs, one per image. Must be len(images) long. | ||
| tape_mm: Shared tape width (12/18/24/62). | ||
| options: Collective options (auto_cut, high_resolution, half_cut). | ||
|
|
||
| Returns: | ||
| batch_id: New UUID identifying this batch. | ||
|
|
||
| Raises: | ||
| KeyError: unknown printer_id. | ||
| ValueError: len(images) != len(job_ids), or len(images) == 0. | ||
| """ | ||
| if printer_id not in self._queues: | ||
| raise KeyError(f"Unknown printer: {printer_id}") | ||
| if not images: | ||
| raise ValueError("enqueue_batch requires at least one image") | ||
| if len(images) != len(job_ids): | ||
| raise ValueError( | ||
| f"images and job_ids length mismatch: {len(images)} vs {len(job_ids)}" | ||
| ) | ||
|
|
||
| # Serialize images to PNG bytes (consistent with single-job storage) | ||
| payloads = [ | ||
| await asyncio.to_thread(_serialize_image_to_png, img) for img in images | ||
| ] | ||
| batch_id = uuid4() | ||
| batch = BatchJob( | ||
| batch_id=batch_id, | ||
| printer_id=printer_id, | ||
| image_payloads=payloads, | ||
| job_ids=list(job_ids), | ||
| tape_mm=tape_mm, | ||
| options=dict(options), | ||
| ) | ||
| await self._queues[printer_id].put(batch) | ||
| logger.info("Batch %s queued on %s with %d items", batch_id, printer_id, len(images)) | ||
| return batch_id | ||
| ``` |
There was a problem hiding this comment.
Architektur-Lücke: Fehlende In-Memory Job-Registrierung
In der vorgeschlagenen Implementierung von enqueue_batch werden die einzelnen Jobs des Batches niemals als In-Memory Job-Objekte instanziiert oder in self._jobs registriert.
Dies führt zu folgenden kritischen Problemen:
- KeyError bei Abfragen: Jeder spätere Aufruf von
get(job_id)oderwait_for_job(job_id)wird einenKeyErrorwerfen, da die Jobs inself._jobsnicht existieren. - Queue-Verwaltung: Methoden wie
list_queue()undclear_queue()ignorieren diese Batch-Jobs vollständig.
Um die Konsistenz mit der bestehenden Single-Job-Architektur zu wahren, sollten wir für jede job_id im Batch ein Job-Objekt instanziieren und in self._jobs registrieren. Zudem können wir die PNG-Serialisierung der Bilder mittels asyncio.gather parallelisieren, um die Performance zu verbessern.
async def enqueue_batch(
self,
*,
printer_id: UUID,
images: list[Image.Image],
job_ids: list[UUID],
tape_mm: int,
options: dict[str, Any],
) -> UUID:
"""Phase 1k.2: Submit N images as ONE BatchJob (atomic print_multi call).
Args:
printer_id: Target printer (must be registered in self._queues).
images: PIL Images in print order, len(images) >= 1.
job_ids: Pre-allocated job UUIDs, one per image. Must be len(images) long.
tape_mm: Shared tape width (12/18/24/62).
options: Collective options (auto_cut, high_resolution, half_cut).
Returns:
batch_id: New UUID identifying this batch.
Raises:
KeyError: unknown printer_id.
ValueError: len(images) != len(job_ids), or len(images) == 0.
"""
if printer_id not in self._queues:
raise KeyError(f"Unknown printer: {printer_id}")
if not images:
raise ValueError("enqueue_batch requires at least one image")
if len(images) != len(job_ids):
raise ValueError(
f"images and job_ids length mismatch: {len(images)} vs {len(job_ids)}"
)
# Serialize images to PNG bytes concurrently
payloads = await asyncio.gather(*(
asyncio.to_thread(_serialize_image_to_png, img) for img in images
))
batch_id = uuid4()
batch = BatchJob(
batch_id=batch_id,
printer_id=printer_id,
image_payloads=payloads,
job_ids=list(job_ids),
tape_mm=tape_mm,
options=dict(options),
)
# Register individual jobs in-memory to support status queries and wait_for_job
for jid, payload in zip(job_ids, payloads, strict=True):
job = Job(
id=str(jid),
printer_id=printer_id,
image_payload=payload,
tape_mm=tape_mm,
options=dict(options),
)
self._jobs[str(jid)] = job
await self._queues[printer_id].put(batch)
logger.info("Batch %s queued on %s with %d items", batch_id, printer_id, len(images))
return batch_id| async def _process_batch( | ||
| self, | ||
| printer: _PrinterLike, | ||
| printer_id: UUID, | ||
| batch: BatchJob, | ||
| ) -> None: | ||
| """Phase 1k.2: Handle BatchJob — atomic success/failure for all job_ids. | ||
|
|
||
| Decodes payloads, calls printer.print_images() once. On exception, | ||
| marks all job_ids as failed with a shared error_message. | ||
| """ | ||
| # Wait while paused (mirror _worker semantics) | ||
| while self._worker_states[printer_id] == PrinterWorkerState.PAUSED: | ||
| if self._stopping: | ||
| return | ||
| await self._worker_resume_events[printer_id].wait() | ||
|
|
||
| # Persist transitions: all → PRINTING | ||
| for jid in batch.job_ids: | ||
| await self._store.mark_printing(jid) | ||
|
|
||
| # Decode PNG payloads back to PIL Images | ||
| images = [ | ||
| await asyncio.to_thread(Image.open, BytesIO(p)) | ||
| for p in batch.image_payloads | ||
| ] | ||
|
|
||
| try: | ||
| await printer.print_images( | ||
| images, | ||
| tape_mm=batch.tape_mm, | ||
| **batch.options, | ||
| ) | ||
| for jid in batch.job_ids: | ||
| await self._store.mark_done(jid) | ||
| logger.info("Batch %s completed on %s", batch.batch_id, printer_id) | ||
| except asyncio.CancelledError: | ||
| raise | ||
| except Exception as exc: | ||
| error_msg = f"batch print failed: {exc}" | ||
| for jid in batch.job_ids: | ||
| await self._store.mark_failed(jid, error_msg) | ||
| logger.exception( | ||
| "Batch %s failed on %s — all %d items marked failed", | ||
| batch.batch_id, printer_id, len(batch.job_ids), | ||
| ) | ||
| ``` |
There was a problem hiding this comment.
Architektur-Lücke: Fehlende In-Memory Zustandsübergänge und SSE-Benachrichtigungen
In der vorgeschlagenen Implementierung von _process_batch werden zwar die Datenbank-Zustände über self._store aktualisiert, aber die In-Memory Job-Objekte werden niemals übergeleitet (z. B. via JobStateMachine.transition) und _notify_state_change wird nicht aufgerufen.
Dies führt zu folgenden Problemen:
- Fehlende SSE-Benachrichtigungen: Das Frontend (Hangar) verlässt sich auf
_notify_state_change, um Echtzeit-Updates über den SSE-Event-Bus zu erhalten. Ohne diese Aufrufe wird die UI niemals anzeigen, dass Batch-Jobs gedruckt werden, abgeschlossen sind oder fehlgeschlagen sind. - Hängende Waiter: Jeder Aufruf von
wait_for_jobwird unendlich lange hängen, da das_done_eventdes Jobs niemals gesetzt wird (was intern vonJobStateMachine.transitionbei terminalen Zuständen erledigt wird).
Wir sollten _process_batch so anpassen, dass die entsprechenden In-Memory-Zustandsübergänge durchgeführt und Benachrichtigungen für jeden Job im Batch ausgelöst werden. Zudem können wir das Laden der Bilder mittels asyncio.gather parallelisieren.
async def _process_batch(
self,
printer: _PrinterLike,
printer_id: UUID,
batch: BatchJob,
) -> None:
"""Phase 1k.2: Handle BatchJob — atomic success/failure for all job_ids.
Decodes payloads, calls printer.print_images() once. On exception,
marks all job_ids as failed with a shared error_message.
"""
# Wait while paused (mirror _worker semantics)
while self._worker_states[printer_id] == PrinterWorkerState.PAUSED:
if self._stopping:
return
await self._worker_resume_events[printer_id].wait()
# Persist transitions: all → PRINTING
for jid in batch.job_ids:
await self._store.mark_printing(jid)
job = self._jobs.get(str(jid))
if job:
_from = job.state
JobStateMachine.transition(job, JobState.PRINTING)
self._notify_state_change(
job,
_from,
JobState.PRINTING,
queue_depth=self._queue_depth(printer_id),
)
# Decode PNG payloads back to PIL Images concurrently
images = await asyncio.gather(*(
asyncio.to_thread(Image.open, BytesIO(p))
for p in batch.image_payloads
))
try:
await printer.print_images(
images,
tape_mm=batch.tape_mm,
**batch.options,
)
for jid in batch.job_ids:
await self._store.mark_done(jid)
job = self._jobs.get(str(jid))
if job:
_from = job.state
JobStateMachine.transition(job, JobState.COMPLETED)
self._notify_state_change(
job,
_from,
JobState.COMPLETED,
queue_depth=self._queue_depth(printer_id),
)
logger.info("Batch %s completed on %s", batch.batch_id, printer_id)
except asyncio.CancelledError:
raise
except Exception as exc:
error_msg = f"batch print failed: {exc}"
for jid in batch.job_ids:
await self._store.mark_failed(jid, error_msg)
job = self._jobs.get(str(jid))
if job:
job.error_code = "print_failed"
job.error_message = error_msg
job.error_msg = error_msg
_from = job.state
try:
JobStateMachine.transition(job, JobState.FAILED)
self._notify_state_change(
job,
_from,
JobState.FAILED,
queue_depth=self._queue_depth(printer_id),
)
except InvalidStateTransitionError:
pass
logger.exception(
"Batch %s failed on %s — all %d items marked failed",
batch.batch_id, printer_id, len(batch.job_ids),
)| async def submit_batch_job( | ||
| self, | ||
| requests: list[PrintRequest], | ||
| *, | ||
| half_cut: bool, | ||
| ) -> list[UUID]: | ||
| """Phase 1k.2: Render N items, submit ONE BatchJob to PrintQueue. | ||
|
|
||
| Atomic: alle job_ids werden gemeinsam als completed/failed markiert. | ||
| Preflight + tape-mismatch werden 1x am Anfang fuer alle items geprueft. | ||
| """ | ||
| if not requests: | ||
| raise ValueError("submit_batch_job requires at least one request") | ||
|
|
||
| # 1. Load templates (alle muessen existieren — TemplateNotFoundError vorher abgefangen) | ||
| templates = [self._loader.get(r.template_id) for r in requests] | ||
| tape_mm = templates[0].tape_mm # alle gleich (mixed-tape-check vorher) | ||
|
|
||
| # 2. Preflight + tape-mismatch (1x fuer alle) | ||
| preflight = await self._backend.preflight_check() | ||
| if preflight.loaded_tape_mm != tape_mm: | ||
| raise TapeMismatchError( | ||
| expected_mm=tape_mm, | ||
| loaded_mm=preflight.loaded_tape_mm, | ||
| ) | ||
|
|
||
| # 3. Resolve LabelData + Render | ||
| images: list[Image.Image] = [] | ||
| for request, template in zip(requests, templates, strict=True): | ||
| label_data = await self._resolve_label_data(request) | ||
| image = self._renderer.render(template, label_data) | ||
| images.append(image) | ||
|
|
||
| # 4. Pre-allocate job UUIDs + persist in JobStore (analog submit_print_job) | ||
| job_ids: list[UUID] = [] | ||
| for i, (request, template) in enumerate(zip(requests, templates, strict=True)): | ||
| job_id = uuid4() | ||
| await self._store.save_queued( | ||
| job_id=job_id, | ||
| printer_id=self._printer_id, | ||
| template_key=request.template_id, | ||
| payload={ | ||
| "tape_mm": tape_mm, | ||
| "options": request.options.model_dump(), | ||
| "label_data": (await self._resolve_label_data(request)).model_dump(), | ||
| }, | ||
| ) | ||
| job_ids.append(job_id) | ||
|
|
||
| # 5. Enqueue as BatchJob | ||
| await self._queue.enqueue_batch( | ||
| printer_id=self._printer_id, | ||
| images=images, | ||
| job_ids=job_ids, | ||
| tape_mm=tape_mm, | ||
| options={ | ||
| "auto_cut": True, # Sammelt | ||
| "high_resolution": False, | ||
| "half_cut": half_cut, | ||
| }, | ||
| ) | ||
|
|
||
| return job_ids | ||
| ``` |
There was a problem hiding this comment.
Performance & Event-Loop-Responsiveness: Nebenläufiges und nicht-blockierendes Rendering
In der vorgeschlagenen Implementierung von submit_batch_job wird self._renderer.render (eine CPU-intensive Pillow-Operation) synchron innerhalb der async-Schleife aufgerufen:
# 3. Resolve LabelData + Render
images: list[Image.Image] = []
for request, template in zip(requests, templates, strict=True):
label_data = await self._resolve_label_data(request)
image = self._renderer.render(template, label_data)
images.append(image)Dies hat zwei Nachteile:
- Blockieren des Event-Loops: Der synchrone Aufruf blockiert den Haupt-Thread des Event-Loops, was die Reaktionsfähigkeit der gesamten FastAPI-Anwendung beeinträchtigt.
- Sequenzielle Ausführung: Die Batch-Elemente werden nacheinander gerendert, was die Gesamtantwortzeit bei großen Batches unnötig verlängert.
Wir sollten das Rendering in asyncio.to_thread auslagern und mittels asyncio.gather parallelisieren.
async def submit_batch_job(
self,
requests: list[PrintRequest],
*,
half_cut: bool,
) -> list[UUID]:
"""Phase 1k.2: Render N items, submit ONE BatchJob to PrintQueue.
Atomic: alle job_ids werden gemeinsam als completed/failed markiert.
Preflight + tape-mismatch werden 1x am Anfang fuer alle items geprueft.
"""
if not requests:
raise ValueError("submit_batch_job requires at least one request")
# 1. Load templates (alle muessen existieren — TemplateNotFoundError vorher abgefangen)
templates = [self._loader.get(r.template_id) for r in requests]
tape_mm = templates[0].tape_mm # alle gleich (mixed-tape-check vorher)
# 2. Preflight + tape-mismatch (1x fuer alle)
preflight = await self._backend.preflight_check()
if preflight.loaded_tape_mm != tape_mm:
raise TapeMismatchError(
expected_mm=tape_mm,
loaded_mm=preflight.loaded_tape_mm,
)
# 3. Resolve LabelData + Render concurrently in threads
async def _resolve_and_render(req, temp):
ld = await self._resolve_label_data(req)
return await asyncio.to_thread(self._renderer.render, temp, ld)
images = await asyncio.gather(*(
_resolve_and_render(r, t)
for r, t in zip(requests, templates, strict=True)
))
# 4. Pre-allocate job UUIDs + persist in JobStore (analog submit_print_job)
job_ids: list[UUID] = []
for i, (request, template) in enumerate(zip(requests, templates, strict=True)):
job_id = uuid4()
await self._store.save_queued(
job_id=job_id,
printer_id=self._printer_id,
template_key=request.template_id,
payload={
"tape_mm": tape_mm,
"options": request.options.model_dump(),
"label_data": (await self._resolve_label_data(request)).model_dump(),
},
)
job_ids.append(job_id)
# 5. Enqueue as BatchJob
await self._queue.enqueue_batch(
printer_id=self._printer_id,
images=images,
job_ids=job_ids,
tape_mm=tape_mm,
options={
"auto_cut": True, # Sammelt
"high_resolution": False,
"half_cut": half_cut,
},
)
return job_ids
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #106 +/- ##
==========================================
- Coverage 89.57% 89.48% -0.10%
==========================================
Files 95 96 +1
Lines 4316 4478 +162
Branches 368 388 +20
==========================================
+ Hits 3866 4007 +141
- Misses 358 369 +11
- Partials 92 102 +10
... and 2 files with indirect coverage changes
Flags with carried forward coverage won't be shown. Click here to find out more. Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
3 medium-priority architektur-Findings aus Gemini-Code-Assist: G1 — Task 8 enqueue_batch: In-Memory Job-Registrierung pro item ergaenzt. Ohne registrierte Job-Objekte in self._jobs wuerde get(job_id) und wait_for_job(job_id) KeyError werfen, list_queue/clear_queue ignorieren Batch-Items. Plus: PNG-Serialisierung parallelisiert via asyncio.gather. G2 — Task 8 _process_batch: JobStateMachine.transition + _notify_state_change pro Job ergaenzt. Ohne transitions: - _done_event wird nie gesetzt → wait_for_job haengt unendlich - SSE-Frontend (Hangar) bekommt keine Events → UI zeigt Jobs als ewig 'queued' - started_at/finished_at Timestamps bleiben None G3 — Task 9 submit_batch_job: asyncio.to_thread + asyncio.gather fuer Renders. Synchroner Pillow-Call in async-Loop blockierte Event-Loop + serialisierte unnoetig. _resolve_label_data Duplikat-Call entfernt. Plus: Plan-Header bekommt 'Plan Revisions' Tabelle als Audit-Trail. Refs #102, #106 Gemini-Review
|
@gemini-code-assist Alle 3 medium-priority Findings adressiert in Commit G1 (enqueue_batch In-Memory Job-Registrierung): G2 (_process_batch State-Transitions + SSE-Events):
Damit: G3 (submit_batch_job CPU-Block): Plan-Header hat jetzt Wartet noch auf Copilot-Review. Bei Bedarf weiter iterieren — der Branch ist nicht gemerged. |
|
Hallo strausmann, danke für das Update und die detaillierte Aufschlüsselung der Änderungen in Commit Da du bereits die Tests für die neuen Komponenten und die Integration implementiert hast, scheint der Plan gut auf Kurs zu sein. Ich habe keine weiteren Anmerkungen zum Design oder zur Implementierung. Viel Erfolg bei der weiteren Umsetzung und dem Hardware-Smoke-Test! |
There was a problem hiding this comment.
Pull request overview
This review-only PR adds the Phase 1k.2 design spec and a detailed TDD-oriented implementation plan for introducing PT-Series multi-label batching via ptouch.print_multi(), while keeping the public batch API contract unchanged.
Changes:
- Adds a design spec describing the target behavior (single connection per batch, ~5mm half-cut between items, full cut at end) and the proposed queue/backend architecture.
- Adds a step-by-step implementation plan (tasks, test strategy, and code snippets) covering backend protocol changes, queue batching, and adapter updates.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
| docs/superpowers/specs/2026-06-04-multi-label-batch-design.md | Design spec for Phase 1k.2 batching architecture, failure semantics, and test strategy. |
| docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md | Detailed task plan with TDD steps, proposed APIs, and implementation snippets for Phase 1k.2. |
| ## Referenzen | ||
|
|
||
| - Phase 1i Smoke-Test Empirie: [docs/site/operations/protokolle/2026-06-04-phase1i-smoke-test-empirie.md](https://docs.strausmann.cloud/operations/protokolle/2026-06-04-phase1i-smoke-test-empirie/) | ||
| - Hub PR #100 (last_page → feed groundwork): [https://github.com/strausmann/Label-Printer-Hub/pull/100](https://github.com/strausmann/Label-Printer-Hub/pull/100) |
| # NEU — Default-Impl in Base, Backend überschreibt für native batching | ||
| async def print_images( | ||
| self, | ||
| images: list[Image], | ||
| tape_spec: TapeSpec, | ||
| *, | ||
| auto_cut: bool = True, | ||
| high_resolution: bool = False, | ||
| half_cut: bool = True, # Standard: Half-Cut zwischen Batch-Items | ||
| ) -> None: | ||
| """Print N images as a batch. Default loops over print_image.""" |
| **Hangar-Sicht:** Wenn ein Item-Job-Status `failed` zeigt, weiss Hangar nicht ob NUR dieses Item gescheitert ist oder der ganze Batch. Lösung: Job-Record bekommt zusätzliches Feld `batch_failure_mode: atomic | individual` damit Hangar die UI entsprechend gestalten kann ("Batch failed — 4 items affected"). | ||
|
|
| - [ ] **Step 5: Lint + mypy + commit** | ||
|
|
||
| ```bash | ||
| backend/.venv/bin/ruff check backend/app/printer_backends/batch_helper.py backend/tests/unit/printer_backends/test_batch_helper.py | ||
| backend/.venv/bin/ruff format --check backend/app/printer_backends/batch_helper.py backend/tests/unit/printer_backends/test_batch_helper.py | ||
| backend/.venv/bin/mypy backend/app/printer_backends/batch_helper.py 2>&1 | tail -3 | ||
|
|
||
| git add backend/app/printer_backends/batch_helper.py backend/tests/unit/printer_backends/test_batch_helper.py | ||
| git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(printer_backends): default_print_images_loop helper (Phase 1k.2 Task 1) |
| self, | ||
| images: list[Image.Image], | ||
| tape_spec: TapeSpec, |
|
|
||
| _PER_ITEM_ERRORS: dict[type[Exception], str] = { | ||
| TemplateNotFoundError: "template_not_found", | ||
| LookupFailedError: "integration_lookup_failed", | ||
| TapeEmptyError: "tape_empty", | ||
| } | ||
|
|
||
| _BATCH_FATAL_ERRORS: tuple[type[Exception], ...] = ( | ||
| PrinterCoverOpenError, |
| valid_items.append((index, item, tape_mm)) | ||
| except _BATCH_FATAL_ERRORS: | ||
| raise | ||
| except tuple(_PER_ITEM_ERRORS) as exc: | ||
| code = _PER_ITEM_ERRORS[type(exc)] | ||
| errors.append( | ||
| BatchError(index=index, error_code=code, error_message=str(exc)) | ||
| ) | ||
| except Exception as exc: # unknown sync failure | ||
| _log.exception("unexpected error validating batch item %d", index) | ||
| errors.append( | ||
| BatchError(index=index, error_code="internal_error", error_message=str(exc)) | ||
| ) | ||
|
|
||
| if not valid_items: | ||
| return [], errors | ||
|
|
||
| # 2. Mixed tape_mm check | ||
| tape_mm_set = {tm for _, _, tm in valid_items} | ||
| if len(tape_mm_set) > 1: | ||
| raise MixedTapeSizesError([tm for _, _, tm in valid_items]) | ||
|
|
||
| # 3. Backend half_cut capability |
|
|
||
| **Rationale:** Verifiziert full flow: HTTP POST → batch_dispatch → submit_batch_job → enqueue_batch → worker → printer.print_images → atomic completion. | ||
|
|
||
| - [ ] **Step 1: Write end-to-end test** |
| - _done_event wird nie gesetzt → wait_for_job(job_id) haengt unendlich | ||
| - _notify_state_change wird nie gerufen → SSE-Frontend (Hangar) bekommt | ||
| keine Updates und zeigt Jobs als ewig 'queued' an | ||
| - started_at/finished_at Timestamps bleiben None (UI-Probleme) | ||
| """ | ||
| # Wait while paused (mirror _worker semantics) | ||
| while self._worker_states[printer_id] == PrinterWorkerState.PAUSED: | ||
| if self._stopping: | ||
| return | ||
| await self._worker_resume_events[printer_id].wait() | ||
|
|
9 Copilot-Findings adressiert: C1 Spec: PrinterBackend Protocol-default-method-Approach klargestellt — Python typing.Protocol vererbt keine Methoden-Bodies. Alle Backends MUESSEN print_images explicit haben; helper macht den Loop. C2 Spec: batch_failure_mode UI-Anpassung in Hangar als Out-of-1k.2-Scope verschoben. 1k.2 bleibt Backward-Compat-only. C3 Spec: Privacy-Konformitaet — personalisierte Doku-Links durch Repo-Hinweise + Kurzfassung ersetzt. C4 Plan: Real name+email in 'git -c user.*' commit examples durch plain 'git commit' ersetzt (12 Stellen). C5 Plan: print_images() docstring praeziser — 'atomic' nur fuer PT-Series via print_multi, default-loop kann partial-succeed. C6 Plan: _process_batch nutzt _printer_error_to_record + pause_printer bei recoverable PrinterErrors. Vorher generic Exception-Handling. Damit Konsistenz mit existing _worker loop. C7 Plan: _validate_item_get_tape_mm nutzt public PrintService.get_template_tape_mm statt service._loader (private). Neuer Step 4a fuer den public helper. C8 Plan: label_data wird in submit_batch_job nur einmal pro Item resolved (via _prepare_one helper). Vorher wurde es 2x gerufen. C9 Plan: Smoke-Script ohne hardcoded API-Key-Default 'lh_pat_demo'. Exit mit error wenn weder CLI-Arg noch Env-Var gesetzt. Plus Privacy-Fix: Final-Verification grep umformuliert (literal-Strings entfernt die den CI Privacy-Scan getriggert haben). Refs #102, #106 Gemini+Copilot Reviews
|
@copilot Alle 9 Findings adressiert in
Privacy-Scan-Fix: Final-Verification grep-Beispiel enthielt Literal-Strings die der CI-Privacy-Scan blockt — umformuliert zu Repo-Verweis. @gemini-code-assist — G1+G2+G3 wurden in Wartet auf CI-Re-Run. Bei No-Findings: ready for implementation start. |
There was a problem hiding this comment.
Code Review
This pull request introduces a design spec and implementation plan for Phase 1k.2, enabling Multi-Label-Batch printing via ptouch.print_multi() to produce 5mm half-cuts between labels on PT-series printers. The review feedback identifies three critical API and logic issues within the plan's code snippets: a missing media_type argument in the lookup_ql call, a state inconsistency vulnerability when handling cancelled jobs during batch transitions, and an incorrect signature usage for JobStore.save_queued.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
|
||
| QL-Series unterstuetzt kein half_cut — Backend forciert intern False. | ||
| """ | ||
| tape_spec = self._tape_registry.lookup_ql(tape_mm) |
There was a problem hiding this comment.
The lookup_ql method on TapeRegistry requires both tape_mm and media_type arguments (as seen in _QLQueuePrinter.print_image). Calling it with only tape_mm will raise a TypeError at runtime.
We should pop the media_type from options (defaulting to self._default_media_type) and pass it to lookup_ql.
| tape_spec = self._tape_registry.lookup_ql(tape_mm) | |
| media_type = options.pop("media_type", self._default_media_type) | |
| tape_spec = self._tape_registry.lookup_ql(tape_mm, media_type) |
| # Gemini-Review G2: in-memory transitions + SSE-Events + DB persist. | ||
| # QUEUED -> PRINTING fuer jeden Job. | ||
| for job in jobs: | ||
| try: | ||
| JobStateMachine.transition(job, JobState.PRINTING) | ||
| self._notify_state_change( | ||
| job, JobState.QUEUED, JobState.PRINTING, | ||
| queue_depth=self._queue_depth(printer_id), | ||
| ) | ||
| except InvalidStateTransitionError: | ||
| logger.warning( | ||
| "Batch %s: job %s skipped — state already %s", | ||
| batch.batch_id, job.id, job.state, | ||
| ) | ||
| await self._store.mark_printing(UUID(job.id)) |
There was a problem hiding this comment.
If a job in the batch was cancelled mid-flight (its state is CANCELLED), JobStateMachine.transition(job, JobState.PRINTING) will raise an InvalidStateTransitionError. The current code catches this exception, logs a warning, but then still calls await self._store.mark_printing(UUID(job.id)) and later await self._store.mark_done(UUID(job.id)) or await self._store.mark_failed(UUID(job.id)). This causes a severe state inconsistency where the job is CANCELLED in-memory but marked as COMPLETED or FAILED in the database.
To fix this, we should only perform database updates and subsequent transitions for jobs that successfully transitioned to PRINTING. We can collect these in an active_jobs list and use it throughout the rest of the method.
| # Gemini-Review G2: in-memory transitions + SSE-Events + DB persist. | |
| # QUEUED -> PRINTING fuer jeden Job. | |
| for job in jobs: | |
| try: | |
| JobStateMachine.transition(job, JobState.PRINTING) | |
| self._notify_state_change( | |
| job, JobState.QUEUED, JobState.PRINTING, | |
| queue_depth=self._queue_depth(printer_id), | |
| ) | |
| except InvalidStateTransitionError: | |
| logger.warning( | |
| "Batch %s: job %s skipped — state already %s", | |
| batch.batch_id, job.id, job.state, | |
| ) | |
| await self._store.mark_printing(UUID(job.id)) | |
| # Gemini-Review G2: in-memory transitions + SSE-Events + DB persist. | |
| # QUEUED -> PRINTING fuer jeden Job. | |
| active_jobs: list[Job] = [] | |
| for job in jobs: | |
| try: | |
| JobStateMachine.transition(job, JobState.PRINTING) | |
| self._notify_state_change( | |
| job, JobState.QUEUED, JobState.PRINTING, | |
| queue_depth=self._queue_depth(printer_id), | |
| ) | |
| await self._store.mark_printing(UUID(job.id)) | |
| active_jobs.append(job) | |
| except InvalidStateTransitionError: | |
| logger.warning( | |
| "Batch %s: job %s skipped — state already %s", | |
| batch.batch_id, job.id, job.state, | |
| ) |
| await self._store.save_queued( | ||
| job_id=job_id, | ||
| printer_id=self._printer_id, | ||
| template_key=request.template_id, | ||
| payload={ | ||
| "tape_mm": tape_mm, | ||
| "options": request.options.model_dump(), | ||
| "label_data": ld_dump, | ||
| }, | ||
| ) |
There was a problem hiding this comment.
The JobStore.save_queued method expects a single Job model instance (as seen in submit_print_job), but the proposed code calls it with individual keyword arguments (job_id, printer_id, etc.). This will raise a TypeError at runtime.
We should instantiate the Job model first and pass it to save_queued.
| await self._store.save_queued( | |
| job_id=job_id, | |
| printer_id=self._printer_id, | |
| template_key=request.template_id, | |
| payload={ | |
| "tape_mm": tape_mm, | |
| "options": request.options.model_dump(), | |
| "label_data": ld_dump, | |
| }, | |
| ) | |
| db_job = Job( | |
| id=job_id, | |
| printer_id=self._printer_id, | |
| template_key=request.template_id, | |
| payload={ | |
| "tape_mm": tape_mm, | |
| "options": request.options.model_dump(), | |
| "label_data": ld_dump, | |
| }, | |
| api_key_id=None, | |
| source_ip=None, | |
| ) | |
| await self._store.save_queued(db_job) |
3 Runtime-Crashes im Plan-Code gefixt: G-R2-1 (_QLQueuePrinter.print_images): lookup_ql benoetigt tape_mm UND media_type. Mein Plan rief lookup_ql(tape_mm) ohne media_type -> TypeError. Fix: pop media_type aus options + pass through (analog _PTPQueuePrinter). G-R2-2 (_process_batch state inconsistency): Wenn job mid-flight cancelled ist, wirft JobStateMachine.transition InvalidStateTransitionError. Mein Plan loggte Warning aber rief trotzdem _store.mark_printing + mark_done -> DB sagt COMPLETED, in-memory CANCELLED. Fix: active_jobs[] sammelt nur erfolgreich transitioned jobs. Alle folgenden DB-Calls + post-print transitions nutzen active_jobs[]. G-R2-3 (submit_batch_job save_queued signature): Mein Plan rief save_queued mit kwargs (job_id=..., printer_id=..., template_key=..., payload=...) aber JobStore.save_queued erwartet eine DbJob-Instanz. Fix: DbJob model instanziieren + an save_queued uebergeben (konsistent mit submit_print_job). Import 'from app.models.job import Job as DbJob' ergaenzt. Refs #102, #106 Gemini R2 Review
|
@gemini-code-assist Alle 3 Round-2 Findings adressiert in
Vielen Dank für die R2 — alle 3 hätten erst im Subagent-Implementation-Step gecrasht. Wartet auf weitere Reviews. Bei No-Findings: ready for implementation start. |
|
Hallo strausmann, danke für die detaillierte Aufschlüsselung der Round-2-Fixes. Die Korrekturen (insbesondere die Typ-Sicherheit bei |
…Task 1) Backends ohne native batch-support (BrotherQLBackend, MockBackend) loopen per-item — Helper zentralisiert die half_cut + last_page Semantik analog Brother iOS App. Refs #102
…Phase 1k.2 Task 2) Backend-Contract erweitert um batch-print Method. PTouchBackend ueberschreibt fuer ptouch.print_multi (Task 3), andere Backends delegieren an default_print_images_loop helper (Tasks 4, 5). Refs #102
…regressions (Phase 1k.2 Task 2 fixup) Code-quality review on commit e93f945 found 3 test failures: adding print_images to the Protocol broke isinstance(obj, PrinterBackend) for backends that did not yet implement it. Fixes: - _Compliant test fixture (test_base.py): minimal pass stub - MockBackend + PTouchBackend: print_images that raises NotImplementedError (real impl follows in Task 5 and Task 3 respectively) - base.py: docstring notes half_cut=True default vs print_image's False - base.py: module docstring 'two-method' -> 'three-method' surface Refs #102
PTouchBackend.print_images() ueberschreibt Protocol-Default mit echtem batch-printing via ptouch.LabelPrinter.print_multi: 1 Connection, 1 Pre-Roll, 5mm Half-Cut zwischen Labels statt 22.5mm zwischen jedem. Replaces Task 2 stub (NotImplementedError) with real implementation. TypeError-Fallback: Aelterer ptouch-Lib ohne print_multi degradet zu per-Item-Loop (Pre-Phase-1k.2 Verhalten). Refs #102
…k.2 Task 4) QL-Series ist Endless-Tape ohne Half-Cut-Konzept. BrotherQLBackend.print_images delegiert an default_print_images_loop mit half_cut=False (capability-flag erzwingt). Phase 1k.2 Architektur-Konsistenz: alle Backends bieten print_images. Refs #102
…hase 1k.2 Task 5) Replaces Task 2 fix-up stub (NotImplementedError) mit echter Delegation an default_print_images_loop helper. Test-friendly impl: print_image() Recording-Verhalten bleibt erhalten. Refs #102
…forwarding (Phase 1k.2 Task 6) Bug discovered during Phase 1k.2 plan-writing: _PTPQueuePrinter.print_image forwarded only auto_cut + high_resolution. half_cut und last_page wurden aus options dict geholt aber NIE an backend.print_image() weitergeleitet. PR #100 fix landete nie bei ptouch lib. Fix: explicit options.pop für half_cut + last_page. Plus neue print_images() Adapter-Methode für Queue-BatchJob-Pfad. Refs #102, regression-fixes PR #100 (silent drop in adapter layer)
…hase 1k.2 Task 8) Neuer BatchJob dataclass mit batch_id, printer_id, image_payloads (PNG bytes), job_ids[], tape_mm, options. PrintQueue.enqueue_batch validiert + serialisiert plus In-Memory Job-Registrierung (G1). Worker dispatched BatchJob → _process_batch → printer.print_images. Atomic: auf success markiert alle active_jobs als done, auf failure alle als failed mit gemeinsamer Error-Message. Adresses Gemini-Reviews: - G1: In-Memory Job-Registrierung in enqueue_batch (sonst KeyError bei get/wait_for_job) - G2: JobStateMachine.transition + _notify_state_change (sonst SSE-Stille + hängende Waiter) - G-R2-2: active_jobs[] für state consistency (cancel-mid-flight) - C6: _printer_error_to_record + pause_printer für recoverable errors _PrinterLike Protocol erweitert um print_images. mypy fix: list[ImageFile] → list[Image.Image] cast für invariante List. Refs #102, PR #106 reviews
… tests (Phase 1k.2 Task 8 follow-up) Code-quality review on c08da56 identified: - MEDIUM: _process_batch decoded ALL payloads even if some jobs were cancelled mid-flight, causing phantom prints. Fix: track active_indices[] alongside active_jobs[] and filter payloads via index. Collapsed the previous two-stage lookup+transition into a single enumerate loop. - LOW: 2 success/failure tests only asserted call counts — strengthened to verify JobState.COMPLETED / JobState.FAILED + error_message. - LOW: Missing test for empty images list rejection — added. - Optional: test_batchjob_cancelled_job_skipped_in_active verifies the phantom-print fix end-to-end (cancel mid-job, assert 2 images not 3). Refs #102, PR #106
…h_job (Phase 1k.2 Task 9) dispatch_batch sammelt valide items (per-item template_not_found etc. weiter in errors[]), prueft mixed tape_mm vor Queue, ruft service.submit_batch_job fuer den happy path. PrintService.submit_batch_job rendert N images parallel, persistiert N JobRecords (DbJob instances), queued ONE BatchJob via enqueue_batch. MixedTapeSizesError als neue Fatal-Error (Batch → 400 im Route-Layer). TapeMismatchError jetzt ebenfalls fatal (nicht mehr per-item best-effort). Route-Layer faengt TapeMismatchError → 409, MixedTapeSizesError → 400. Adresses Reviews: - C7: get_template_tape_mm public helper (statt service._loader private) - C8: label_data ONCE per item (via _prepare_one) - G3: asyncio.to_thread + gather fuer parallele Renders - G-R2-3: save_queued mit Job instance (statt kwargs) Integration-Tests angepasst: printer_offline + tape_mismatch patchen jetzt submit_batch_job statt submit_print_job. test_batch_tape_mismatch_per_item zeigt neues atomares Verhalten (409 statt per-item 202+errors). Refs #102, PR #106 reviews
…ions (Phase 1k.2 Task 9 follow-up) Code-quality review on a2b6a98 found submit_batch_job hardcoded auto_cut=True + high_resolution=False, silently dropping caller-provided values from request.options. Fix: read from requests[0].options (all batch items share collective options since ptouch.print_multi takes batch-wide settings). Refs #102
…2 Task 11) Verifies the full flow: HTTP POST → batch_dispatch → submit_batch_job → enqueue_batch → worker → backend.print_images → atomic job state updates. CRITICAL assertions: - 4-item batch calls print_images() ONCE (not 4x print_image) - All 4 job_ids reach state='done' on success - On failure: all 4 job_ids reach state='failed' with shared 'batch print failed' msg Fixtures: ml_batch_client (mirrors batch_client from test_batch_endpoint_happy.py). Backend mock: patch.object(MockPrinterBackend, 'print_images', AsyncMock) via inner_app.state.backend_router.get(slug) — no conftest change needed. Refs #102
…are-smoke (Phase 1k.2 Task 12) Verify 5mm Half-Cut between batch items vs Brother iOS App output. Copilot-Review C9: no hardcoded API-Key default — exit(2) wenn weder CLI-Arg noch Env-Var gesetzt ist. Refs #102
…uch_print_multi (Phase 1k.2 Task 8 coverage) codecov/patch on PR #106 reported 79.89% (target 80%) — 0.11pp gap. Coverage fixes: - _ptouch_print_multi: # pragma: no cover already present (no change needed) - Test PrinterOfflineError → pause_printer + failed jobs (C6 path) - Test generic Exception fallback path (batch_failed error_code) Refs #102
## 0.9.0 (2026-06-05) * feat: Phase 1i — Label-Quality + Multi-Printer + Samla + Preview (#98) ([a33dda9](a33dda9)), closes [#98](#98) * feat: Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#106) ([a9726a9](a9726a9)), closes [#106](#106) [#101](#101) [#103](#103) * feat(api): GET /api/templates/{key}/preview-svg + Dashboard-Modal (Phase 1i D) ([c2cbf3c](c2cbf3c)) * feat(api): GET /api/templates/{key}/preview.png (Diagnose-Tool, Phase 1i A) ([27dd325](27dd325)), closes [#5](#5) * feat(batch): dispatch_batch refactor to atomic BatchJob + submit_batch_job (Phase 1k.2 Task 9) ([a2b6a98](a2b6a98)), closes [#102](#102) [#106](#106) * feat(batch): printer_slug + half_cut_override + BackendRouter-Lookup (Phase 1i C) ([0561504](0561504)), closes [#6](#6) * feat(brother_ql): print_images via default_print_images_loop (Phase 1k.2 Task 4) ([f56e44c](f56e44c)), closes [#102](#102) * feat(config): Clean-Break Settings + upsert_runtime_printers (Phase 1i H CA-1 7a) ([64c38b3](64c38b3)) * feat(lifespan): Multi-Printer-Wiring + service_for pro Drucker (Phase 1i H CA-1 7b) ([3661a4e](3661a4e)) * feat(mock): MockBackend.print_images via default_print_images_loop (Phase 1k.2 Task 5) ([4ebdbee](4ebdbee)), closes [#102](#102) * feat(printer_backends): BrotherQLBackend + QLSeriesModel (Phase 1i G) ([ad52009](ad52009)) * feat(printer_backends): default_print_images_loop helper (Phase 1k.2 Task 1) ([54e1123](54e1123)), closes [#102](#102) * feat(printer_backends): half_cut Parameter + half_cut_supported Capability (Phase 1i C) ([d41c246](d41c246)) * feat(printer_backends): PrinterBackend.print_images Protocol method (Phase 1k.2 Task 2) ([e93f945](e93f945)), closes [#102](#102) * feat(pt): _PTPQueuePrinter.print_images + bug-fix half_cut/last_page forwarding (Phase 1k.2 Task 6) ([15ef310](15ef310)), closes [#100](#100) [#100](#100) * feat(ptouch): print_images via ptouch.print_multi (Phase 1k.2 Task 3) ([c0d15f0](c0d15f0)), closes [#102](#102) * feat(ql): _QLQueuePrinter.print_images adapter (Phase 1k.2 Task 7) ([cb1e317](cb1e317)), closes [#102](#102) [#106](#106) * feat(queue): BatchJob + enqueue_batch + worker isinstance dispatch (Phase 1k.2 Task 8) ([c08da56](c08da56)), closes [#102](#102) [#106](#106) * feat(schemas): PrinterYAMLConfig + PrintersFile (Phase 1i H) ([7779a94](7779a94)) * feat(scripts): smoke_first_print_batch.py — manual 4-item batch hardware-smoke (Phase 1k.2 Task 12) ([d2ce28e](d2ce28e)), closes [#102](#102) * feat(services): BackendRouter mit slug->Backend-Mapping (Phase 1i H CA-2) ([a735a34](a735a34)) * feat(services): PrinterConfigLoader + printers.yaml.example (Phase 1i H) ([f065575](f065575)) * feat(templates): DPI-Fix + Samla-Templates (Phase 1i B) ([af7bce8](af7bce8)) * test(batch): end-to-end multi-label batch integration test (Phase 1k.2 Task 11) ([bcc1d04](bcc1d04)), closes [#102](#102) * test(queue): coverage for _process_batch error paths + pragma on _ptouch_print_multi (Phase 1k.2 Tas ([0b6250d](0b6250d)), closes [#106](#106) [#102](#102) * style: ruff format + lint cleanup (Phase 1i) ([6cffe4c](6cffe4c)) * style: ruff format Task 11 integration test (Phase 1k.2 cleanup) ([ad47f7e](ad47f7e)), closes [#102](#102) * fix: TTF font + last_page→feed (Phase 1i smoke-test bugs) (#100) ([3998575](3998575)), closes [#100](#100) * fix(api): preview-png statt preview.png (OpenAPI kebab-Konvention) ([b420722](b420722)) * fix(ci): mypy types + privacy scan (Phase 1i) ([541502e](541502e)) * fix(ci): SnmpQueryError __all__ export + privacy test IP ([99c1946](99c1946)), closes [#98](#98) [#98](#98) * fix(ci): UUID-Continuity-Runbook entfernen (privacy) ([b027cff](b027cff)) * fix(lifespan): col() for mypy-safe SQLModel where-clause in slug collision check ([6f9d840](6f9d840)), closes [#98](#98) * fix(lifespan): session.flush() + slug-collision-detection in upsert_runtime_printers ([e7d81e6](e7d81e6)), closes [#98](#98) * fix(print_service): forward auto_cut/high_resolution from request options (Phase 1k.2 Task 9 follow- ([51539b5](51539b5)), closes [#102](#102) * fix(printer_backends): BrotherQLBackend.preflight_check (Phase 1i Task 8b) ([dba9b6c](dba9b6c)) * fix(printer_backends): print_images stubs to fix Protocol isinstance regressions (Phase 1k.2 Task 2 ([5cbf955](5cbf955)), closes [#102](#102) * fix(ptouch): propagate last_page→feed to ptouch lib (short half-cut between batch items) ([05f780d](05f780d)) * fix(queue): align image payloads with active_jobs + strengthen Task 8 tests (Phase 1k.2 Task 8 follo ([c658e87](c658e87)), closes [#102](#102) [#106](#106) * fix(renderer): install fonts-dejavu-core in Dockerfile (font_size honored) ([9b0cd84](9b0cd84)) * fix(reviews): adresse Gemini + Copilot PR #100 findings ([9507432](9507432)), closes [#100](#100) [#100](#100) * fix(schemas): half_cut_override docstring matches actual force-to-false behavior ([a7b84b9](a7b84b9)), closes [#98](#98) * docs: pydantic-settings honesty + DPI-decouple + openapi range + preview-png drift ([3d8857e](3d8857e)), closes [PR#98-Copilot](https://github.com/PR/issues/98-Copilot) [PR#98-Copilot](https://github.com/PR/issues/98-Copilot) [PR#98-Copilot](https://github.com/PR/issues/98-Copilot) [PR#98-Copilot](https://github.com/PR/issues/98-Copilot) [#98](#98) * docs(plan): adresse Gemini Round-2 Review G-R2-1/2/3 (PR #106) ([968b693](968b693)), closes [#106](#106) [#102](#102) [#106](#106) * docs(plan): adresse Gemini-Review G1+G2+G3 (PR #106) ([ad3aec5](ad3aec5)), closes [#106](#106) [#102](#102) [#106](#106) * docs(plan): Phase 1k.2 Multi-Label-Batch implementation plan (#102) ([8917d0e](8917d0e)), closes [#102](#102) [#100](#100) [#102](#102) * docs(research): PT-P750W Layout-Diagnose Befund (Phase 1i A) ([06e8fa9](06e8fa9)) * docs(runbook): UUID-Continuity-Check Phase 1i (MA-2-Fix) ([0ef3861](0ef3861)) * docs(spec): Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#102) ([96e53c9](96e53c9)), closes [#102](#102) [#101](#101) [#102](#102) [#103](#103) [#102](#102) * docs(spec+plan): adresse Copilot-Review C1-C9 + Privacy-Fixes (PR #106) ([4f56a2a](4f56a2a)), closes [#106](#106) [#102](#102) [#106](#106) * Merge pull request #99 from strausmann/fix/phase1i-ql-preflight-check ([8df1be3](8df1be3)), closes [#99](#99) [skip ci]
REVIEW-ONLY PR — keine Code-Änderungen
Diese PR enthaelt nur Spec + Plan fuer Phase 1k.2. Ziel: AI-Reviews (Gemini, Copilot) bekommen BEVOR Implementation startet.
Per Memory
feedback_ai_reviews_pflicht_vor_merge: AI-Reviews vor Merge lesen + adressieren. Hier nutzen wir das aktiv um Design-Probleme zu finden BEVOR sie zu Bugs werden.Closes nichts. Wird nach Adressierung der Reviews und User-Approval gemerged oder geclosed (je nachdem ob die Spec/Plan-Approach passt).
Was hier drin ist
1. Spec (committed
96e53c9)docs/superpowers/specs/2026-06-04-multi-label-batch-design.md(282 Zeilen)Aus superpowers:brainstorming Session 2026-06-04. 4 Sections:
User-Entscheidungen die im Spec festgehalten sind:
2. Plan (committed
8917d0e)docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md(2252 Zeilen, 12 Tasks)Aus superpowers:writing-plans skill. TDD-strukturiert: failing test → impl → verify → commit pro Step. 12 Tasks von helper-Function bis Hardware-Smoke-Script.
Bonus-Bug-Fund waehrend Plan-Writing:
_PTPQueuePrinter.print_imageAdapter (backend/app/printer_models/pt.py:256-264) droppt aktuellhalf_cutundlast_pageaus options dict statt sie ans Backend weiterzureichen. PR #100 last_page→feed Fix erreichte die ptouch-Lib nie. Wird in Task 6 mit-gefixt.Was zur Pruefung steht
Bitte AI-Reviewer (Gemini + Copilot), prueft:
print_imagesinPrinterBackendProtocol mit Helper-delegate ist tragfaehig oder lieber ABC?Background
LabelPrinter.print_multi(labels, half_cut=True)— exakt was wir brauchen.Refs Phase 1k.2 #102 + #101