diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index ce8af9c9..5e464bda 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -108,7 +108,9 @@ SummarizePdfTextPayload, TiffPdfRestPayload, TranslatePdfTextPayload, + UnzipPayload, UploadURLs, + ZipPayload, ) from .types import ( ALL_PDF_INFO_QUERIES, @@ -2602,6 +2604,58 @@ def merge_pdfs( timeout=timeout, ) + def zip_files( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Compress one or more files into a zip archive.""" + + payload: dict[str, Any] = {"files": files} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/zip", + payload=payload, + payload_model=ZipPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def unzip_file( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + password: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Extract files from a zip archive.""" + + payload: dict[str, Any] = {"files": file} + if password is not None: + payload["password"] = password + + return self._post_file_operation( + endpoint="/unzip", + payload=payload, + payload_model=UnzipPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_excel( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -3889,6 +3943,58 @@ async def merge_pdfs( timeout=timeout, ) + async def zip_files( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously compress one or more files into a zip archive.""" + + payload: dict[str, Any] = {"files": files} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/zip", + payload=payload, + payload_model=ZipPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def unzip_file( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + password: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously extract files from a zip archive.""" + + payload: dict[str, Any] = {"files": file} + if password is not None: + payload["password"] = password + + return await self._post_file_operation( + endpoint="/unzip", + payload=payload, + payload_model=UnzipPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def convert_to_excel( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 121e7eab..72a8b392 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -122,6 +122,10 @@ def _serialize_file_ids(value: list[PdfRestFile]) -> str: return ",".join(str(file.id) for file in value) +def _serialize_file_id_list(value: list[PdfRestFile]) -> list[str]: + return [str(file.id) for file in value] + + def _bool_to_on_off(value: Any) -> Any: if isinstance(value, bool): return "on" if value else "off" @@ -239,6 +243,49 @@ class DeletePayload(BaseModel): ] +class ZipPayload(BaseModel): + """Adapt caller options into a pdfRest-ready zip request payload.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + PlainSerializer(_serialize_file_id_list), + ] + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + +class UnzipPayload(BaseModel): + """Adapt caller options into a pdfRest-ready unzip request payload.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/zip", error_msg="Must be a ZIP file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + password: Annotated[ + str | None, + Field(default=None, min_length=1), + ] = None + + PageNumber = Annotated[int, Field(ge=1), PlainSerializer(lambda x: str(x))] @@ -1365,7 +1412,7 @@ class PdfRestRawUploadedFile(BaseModel): name: Annotated[str, Field(description="The name of the file")] id: Annotated[PdfRestFileID, Field(description="The id of the file")] output_url: Annotated[ - str | None, + list[HttpUrl] | HttpUrl | None, Field(description="The url of the unzipped file", alias="outputUrl"), BeforeValidator(_ensure_list), ] = None @@ -1384,7 +1431,7 @@ class PdfRestRawFileResponse(BaseModel): BeforeValidator(_ensure_list), ] output_urls: Annotated[ - list[HttpUrl] | None, + list[HttpUrl] | HttpUrl | None, Field(alias="outputUrl", description="The url of the file"), BeforeValidator(_ensure_list), ] = None diff --git a/tests/live/test_live_unzip.py b/tests/live/test_live_unzip.py new file mode 100644 index 00000000..4c12fc8c --- /dev/null +++ b/tests/live/test_live_unzip.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import zipfile +from pathlib import Path + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +def _build_zip_payload(tmp_path: Path) -> Path: + zip_path = tmp_path / "sample.zip" + with zipfile.ZipFile(zip_path, "w") as bundle: + report_path = get_test_resource_path("report.pdf") + bundle.write(report_path, arcname="report.pdf") + return zip_path + + +@pytest.fixture(scope="module") +def uploaded_zip_file( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + tmp_path_factory: pytest.TempPathFactory, +) -> PdfRestFile: + zip_path = _build_zip_payload(tmp_path_factory.mktemp("zip-input")) + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([zip_path])[0] + + +def test_live_unzip_file( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zip_file: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.unzip_file(uploaded_zip_file) + + assert response.output_files + assert all(file.size > 0 for file in response.output_files) + assert all(file.name for file in response.output_files) + assert str(response.input_id) == str(uploaded_zip_file.id) + + +def test_live_unzip_invalid_override( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zip_file: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match="id"), + ): + client.unzip_file( + uploaded_zip_file, + extra_body={"id": "not-a-uuid"}, + ) + + +@pytest.mark.asyncio +async def test_live_unzip_file_async( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zip_file: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.unzip_file( + uploaded_zip_file, + extra_query={"trace": "async"}, + ) + + assert response.output_files + assert all(file.size > 0 for file in response.output_files) + assert all(file.name for file in response.output_files) + assert str(response.input_id) == str(uploaded_zip_file.id) diff --git a/tests/live/test_live_zip.py b/tests/live/test_live_zip.py new file mode 100644 index 00000000..233289d8 --- /dev/null +++ b/tests/live/test_live_zip.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +@pytest.fixture(scope="module") +def uploaded_zip_inputs( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> list[PdfRestFile]: + paths = [ + get_test_resource_path("report.pdf"), + get_test_resource_path("report.docx"), + ] + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths(paths) + + +def test_live_zip_files( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zip_inputs: list[PdfRestFile], +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.zip_files( + uploaded_zip_inputs, + output="live-zip", + ) + + assert response.output_file.name.startswith("live-zip") + assert response.output_file.name.endswith(".zip") + assert response.output_file.type == "application/zip" + assert response.output_file.size > 0 + assert {str(file.id) for file in uploaded_zip_inputs} == { + str(file_id) for file_id in response.input_ids + } + + +def test_live_zip_files_invalid_id_override( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zip_inputs: list[PdfRestFile], +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match="id"), + ): + client.zip_files( + uploaded_zip_inputs, + extra_body={"id": "not-a-uuid"}, + ) + + +@pytest.mark.asyncio +async def test_live_zip_files_async( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zip_inputs: list[PdfRestFile], +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.zip_files( + uploaded_zip_inputs, + output="live-zip-async", + extra_query={"trace": "async"}, + ) + + assert response.output_file.name.startswith("live-zip-async") + assert response.output_file.name.endswith(".zip") + assert response.output_file.type == "application/zip" + assert response.output_file.size > 0 + assert {str(file.id) for file in uploaded_zip_inputs} == { + str(file_id) for file_id in response.input_ids + } diff --git a/tests/test_unzip_file.py b/tests/test_unzip_file.py new file mode 100644 index 00000000..25d5a6af --- /dev/null +++ b/tests/test_unzip_file.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import UnzipPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, +) + + +def make_zip_file(file_id: str, name: str = "archive.zip") -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload(file_id, name, "application/zip") + ) + + +def test_unzip_file_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + zip_file = make_zip_file(str(PdfRestFileID.generate())) + output_id = str(PdfRestFileID.generate()) + + payload_dump = UnzipPayload.model_validate({"files": zip_file}).model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/unzip": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [zip_file.id], + "files": [ + { + "name": "inner.txt", + "id": output_id, + "outputUrl": f"https://api.pdfrest.com/resource/{output_id}", + } + ], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "inner.txt", + "text/plain", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.unzip_file(zip_file) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "inner.txt" + assert response.output_file.type == "text/plain" + assert str(response.input_id) == str(zip_file.id) + + +def test_unzip_file_with_password_and_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + zip_file = make_zip_file(str(PdfRestFileID.generate())) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + password = "Secret123" # noqa: S105 - test-only password fixture + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/unzip": + assert request.url.params["trace"] == "sync" + assert request.headers["X-Debug"] == "sync" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == str(zip_file.id) + assert payload["password"] == password + assert payload["diagnostics"] == "on" + return httpx.Response( + 200, + json={ + "inputId": [zip_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "sync" + assert request.headers["X-Debug"] == "sync" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "custom-unzip.txt", + "text/plain", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.unzip_file( + zip_file, + password=password, + extra_query={"trace": "sync"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"diagnostics": "on"}, + timeout=0.25, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "custom-unzip.txt" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all(pytest.approx(0.25) == value for value in timeout_value.values()) + else: + assert timeout_value == pytest.approx(0.25) + + +def test_unzip_file_requires_zip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + non_zip = PdfRestFile.model_validate( + build_file_info_payload( + str(PdfRestFileID.generate()), "document.pdf", "application/pdf" + ) + ) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Must be a ZIP file"), + ): + client.unzip_file(non_zip) + + +def test_unzip_file_single_input(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + first = make_zip_file(str(PdfRestFileID.generate())) + second = make_zip_file(str(PdfRestFileID.generate())) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="at most 1 item"), + ): + client.unzip_file([first, second]) + + +@pytest.mark.asyncio +async def test_async_unzip_file(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + zip_file = make_zip_file(str(PdfRestFileID.generate())) + output_id = str(PdfRestFileID.generate()) + + payload_dump = UnzipPayload.model_validate({"files": zip_file}).model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/unzip": + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [zip_file.id], + "files": [ + { + "name": "async.txt", + "id": output_id, + "outputUrl": f"https://api.pdfrest.com/resource/{output_id}", + } + ], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "async.txt", + "text/plain", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.unzip_file(zip_file) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async.txt" + assert str(response.input_id) == str(zip_file.id) + + +@pytest.mark.asyncio +async def test_async_unzip_file_with_password_and_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + zip_file = make_zip_file(str(PdfRestFileID.generate())) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + password = "Secret123" # noqa: S105 - test-only password fixture + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/unzip": + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == str(zip_file.id) + assert payload["password"] == password + assert payload["diagnostics"] == "on" + return httpx.Response( + 200, + json={ + "inputId": [zip_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "async-custom-unzip.txt", + "text/plain", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.unzip_file( + zip_file, + password=password, + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"diagnostics": "on"}, + timeout=0.25, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-custom-unzip.txt" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all(pytest.approx(0.25) == value for value in timeout_value.values()) + else: + assert timeout_value == pytest.approx(0.25) + + +@pytest.mark.asyncio +async def test_async_unzip_file_requires_zip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + non_zip = PdfRestFile.model_validate( + build_file_info_payload( + str(PdfRestFileID.generate()), "document.pdf", "application/pdf" + ) + ) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a ZIP file"): + await client.unzip_file(non_zip) + + +@pytest.mark.asyncio +async def test_async_unzip_file_single_input(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + first = make_zip_file(str(PdfRestFileID.generate())) + second = make_zip_file(str(PdfRestFileID.generate())) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="at most 1 item"): + await client.unzip_file([first, second]) diff --git a/tests/test_zip_files.py b/tests/test_zip_files.py new file mode 100644 index 00000000..dab31815 --- /dev/null +++ b/tests/test_zip_files.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import ZipPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_zip_files_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + first = make_pdf_file(PdfRestFileID.generate(1), name="first.pdf") + second = make_pdf_file(PdfRestFileID.generate(1), name="second.pdf") + output_id = str(PdfRestFileID.generate()) + + payload_dump = ZipPayload.model_validate( + {"files": [first, second], "output": "bundle"} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/zip": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [first.id, second.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "bundle.zip", + "application/zip", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.zip_files([first, second], output="bundle") + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "bundle.zip" + assert response.output_file.type == "application/zip" + assert {str(file_id) for file_id in response.input_ids} == { + str(first.id), + str(second.id), + } + assert response.warning is None + + +def test_zip_files_request_customization(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/zip": + assert request.url.params["trace"] == "sync" + assert request.headers["X-Debug"] == "sync" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == [str(source.id)] + assert payload["output"] == "custom-zip" + assert payload["diagnostics"] == "on" + return httpx.Response( + 200, + json={ + "inputId": [source.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + assert request.url.params["trace"] == "sync" + assert request.headers["X-Debug"] == "sync" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "custom-zip.zip", + "application/zip", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.zip_files( + source, + output="custom-zip", + extra_query={"trace": "sync"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"diagnostics": "on"}, + timeout=0.5, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "custom-zip.zip" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all(pytest.approx(0.5) == value for value in timeout_value.values()) + else: + assert timeout_value == pytest.approx(0.5) + + +def test_zip_files_requires_input(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="at least 1 item"), + ): + client.zip_files([]) + + +def test_zip_files_rejects_invalid_output(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="must not start with a `\\.`"), + ): + client.zip_files(source, output=".hidden") + + +@pytest.mark.asyncio +async def test_async_zip_files(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + payload_dump = ZipPayload.model_validate({"files": source}).model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/zip": + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [source.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "async.zip", + "application/zip", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.zip_files(source) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async.zip" + assert str(response.input_id) == str(source.id) + + +@pytest.mark.asyncio +async def test_async_zip_files_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/zip": + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == [str(source.id)] + assert payload["output"] == "async-custom-zip" + assert payload["diagnostics"] == "on" + return httpx.Response( + 200, + json={ + "inputId": [source.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "async-custom-zip.zip", + "application/zip", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.zip_files( + source, + output="async-custom-zip", + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"diagnostics": "on"}, + timeout=0.5, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-custom-zip.zip" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all(pytest.approx(0.5) == value for value in timeout_value.values()) + else: + assert timeout_value == pytest.approx(0.5) + + +@pytest.mark.asyncio +async def test_async_zip_files_requires_input(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="at least 1 item"): + await client.zip_files([]) + + +@pytest.mark.asyncio +async def test_async_zip_files_rejects_invalid_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="must not start with a `\\.`"): + await client.zip_files(source, output=".hidden")