Implementace prevodu HTML na PDF
Sluzba prijme adresu HTML dokumentu nebo HTML v tele requestu a vrati PDF. Navrzena pro dokumenty o stovkach az tisicich stranek. Rendering: - WeasyPrint jako vychozi engine, spravne CSS Paged Media, nizka pametova narocnost, bez JavaScriptu - Chromium pres Playwright pro dokumenty dokreslovane skripty - rezim auto s detekci skriptu a fallbackem pri selhani WeasyPrintu Velke dokumenty: - deleni na casti na strukturalnich hranicich, rez nikdy uvnitr tabulky nebo odstavce - dvoupruchodovy render obsahu se skutecnymi cisly stranek, pozice nadpisu se ctou z kotev hlasenych u kazde stranky - cislovani stranek bud pres CSS countery, nebo pres cislovaci vrstvu nastampovanou na hotove PDF, rozmer stranky se cte z vysledneho souboru - Chromium se restartuje po N jobech, nikdy vsak behem beziciho renderu API: - POST /convert synchronne, POST /jobs asynchronne se sledovanim stavu, stahovanim vysledku, rusenim a volitelnym callbackem - GET /health s overenim dostupnosti obou enginu a stavem fronty - OpenAPI respektuje prefix reverse proxy pres root_path Bezpecnost a provoz: - SSRF kontrola po DNS resolvu, na kazdem presmerovani a u vsech pozadavku prohlizece - nedostupne assety render nezastavi, ale hlasi se v odpovedi i v logu - fronta s omezenym poctem workeru, rozpracovane joby se pri ukonceni oznaci jako failed, nezmizi potichu - strukturovane JSON logovani s job_id - vsechny limity vypnute ve vychozim stavu Dockerfile je dvoufazovy, obsahuje zavislosti WeasyPrintu, Chromium a fonty s ceskou diakritikou. Autentizace zamerne neni implementovana, zpusob predavani neni domluveny. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e3cc8f418b
commit
156289fe2d
@@ -0,0 +1,133 @@
|
||||
"""Job queue behaviour."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.errors import LimitExceededError, QueueFullError
|
||||
from app.models import ConvertRequest, Source
|
||||
from app.services.jobs import JobManager
|
||||
from app.services.pipeline import ConversionResult
|
||||
from app.services.storage import Storage
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class StubPipeline:
|
||||
def __init__(self, behaviour="ok", delay=0.0) -> None:
|
||||
self.behaviour = behaviour
|
||||
self.delay = delay
|
||||
|
||||
async def run(self, request, workdir: Path, progress=None) -> ConversionResult:
|
||||
if progress is not None:
|
||||
progress(1, 1, 1, 1)
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
if self.behaviour == "conversion_error":
|
||||
raise LimitExceededError("Prekrocen limit stranek.", {"limit": 10})
|
||||
if self.behaviour == "crash":
|
||||
raise RuntimeError("necekana chyba enginu")
|
||||
|
||||
workdir.mkdir(parents=True, exist_ok=True)
|
||||
produced = workdir / "out.pdf"
|
||||
produced.write_bytes(b"%PDF-1.7\n%fake\n")
|
||||
return ConversionResult(path=produced, page_count=1, engine_used="stub")
|
||||
|
||||
|
||||
def make_manager(tmp_path, behaviour="ok", delay=0.0, **overrides) -> JobManager:
|
||||
settings = Settings()
|
||||
for key, value in overrides.items():
|
||||
object.__setattr__(settings, key, value)
|
||||
object.__setattr__(settings, "storage_dir", str(tmp_path))
|
||||
|
||||
storage = Storage(str(tmp_path))
|
||||
return JobManager(StubPipeline(behaviour, delay), storage, settings)
|
||||
|
||||
|
||||
def simple_request() -> ConvertRequest:
|
||||
return ConvertRequest(source=Source(html="<html><body><p>ahoj</p></body></html>"))
|
||||
|
||||
|
||||
async def test_successful_job_publishes_a_result(tmp_path) -> None:
|
||||
manager = make_manager(tmp_path)
|
||||
await manager.start()
|
||||
try:
|
||||
job = manager.submit(simple_request())
|
||||
await asyncio.wait_for(job.done.wait(), timeout=5)
|
||||
|
||||
assert job.state.status == "done"
|
||||
assert job.state.page_count == 1
|
||||
assert (Path(tmp_path) / job.id / "result.pdf").exists()
|
||||
finally:
|
||||
await manager.stop()
|
||||
|
||||
|
||||
async def test_conversion_error_keeps_its_code(tmp_path) -> None:
|
||||
manager = make_manager(tmp_path, behaviour="conversion_error")
|
||||
await manager.start()
|
||||
try:
|
||||
job = manager.submit(simple_request())
|
||||
await asyncio.wait_for(job.done.wait(), timeout=5)
|
||||
|
||||
assert job.state.status == "failed"
|
||||
assert job.state.error is not None
|
||||
assert job.state.error.error_code == "limit_exceeded"
|
||||
finally:
|
||||
await manager.stop()
|
||||
|
||||
|
||||
async def test_unexpected_error_is_reported_not_swallowed(tmp_path) -> None:
|
||||
manager = make_manager(tmp_path, behaviour="crash")
|
||||
await manager.start()
|
||||
try:
|
||||
job = manager.submit(simple_request())
|
||||
await asyncio.wait_for(job.done.wait(), timeout=5)
|
||||
|
||||
assert job.state.status == "failed"
|
||||
assert job.state.error.error_code == "internal_error"
|
||||
finally:
|
||||
await manager.stop()
|
||||
|
||||
|
||||
async def test_running_job_can_be_cancelled(tmp_path) -> None:
|
||||
manager = make_manager(tmp_path, delay=5.0)
|
||||
await manager.start()
|
||||
try:
|
||||
job = manager.submit(simple_request())
|
||||
await asyncio.sleep(0.2)
|
||||
manager.cancel(job.id)
|
||||
await asyncio.wait_for(job.done.wait(), timeout=5)
|
||||
|
||||
assert job.state.status == "cancelled"
|
||||
assert not (Path(tmp_path) / job.id).exists()
|
||||
finally:
|
||||
await manager.stop()
|
||||
|
||||
|
||||
async def test_full_queue_is_rejected(tmp_path) -> None:
|
||||
manager = make_manager(tmp_path, delay=5.0, queue_max_size=1, workers=1)
|
||||
await manager.start()
|
||||
try:
|
||||
manager.submit(simple_request())
|
||||
await asyncio.sleep(0.1)
|
||||
manager.submit(simple_request())
|
||||
|
||||
with pytest.raises(QueueFullError):
|
||||
manager.submit(simple_request())
|
||||
finally:
|
||||
await manager.stop()
|
||||
|
||||
|
||||
async def test_shutdown_marks_pending_jobs_as_failed(tmp_path) -> None:
|
||||
manager = make_manager(tmp_path, delay=5.0, workers=1)
|
||||
await manager.start()
|
||||
job = manager.submit(simple_request())
|
||||
await asyncio.sleep(0.1)
|
||||
await manager.stop()
|
||||
|
||||
assert job.state.status == "failed"
|
||||
assert job.state.error.error_code == "service_restarted"
|
||||
Reference in New Issue
Block a user