diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 505f1288..16ff8d5b 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -93,6 +93,7 @@ PdfAddTextPayload, PdfBlankPayload, PdfCompressPayload, + PdfConvertColorsPayload, PdfDecryptPayload, PdfEncryptPayload, PdfFlattenAnnotationsPayload, @@ -146,6 +147,7 @@ PdfPageOrientation, PdfPageSelection, PdfPageSize, + PdfPresetColorProfile, PdfRedactionInstruction, PdfRestriction, PdfRGBColor, @@ -3173,6 +3175,38 @@ def blank_pdf( timeout=timeout, ) + def convert_colors( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + color_profile: PdfPresetColorProfile | PdfRestFile | Sequence[PdfRestFile], + preserve_black: bool = False, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Convert PDF colors using presets or a custom uploaded ICC profile.""" + + payload: dict[str, Any] = { + "files": file, + "color_profile": color_profile, + "preserve_black": preserve_black, + } + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-converted-colors", + payload=payload, + payload_model=PdfConvertColorsPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def flatten_transparencies( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -4876,6 +4910,38 @@ async def blank_pdf( timeout=timeout, ) + async def convert_colors( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + color_profile: PdfPresetColorProfile | PdfRestFile | Sequence[PdfRestFile], + preserve_black: bool = False, + 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 convert PDF colors using presets or a custom ICC profile.""" + + payload: dict[str, Any] = { + "files": file, + "color_profile": color_profile, + "preserve_black": preserve_black, + } + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-converted-colors", + payload=payload, + payload_model=PdfConvertColorsPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def flatten_transparencies( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 65f65197..fd266eb9 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -34,6 +34,7 @@ PdfInfoQuery, PdfPageOrientation, PdfPageSize, + PdfPresetColorProfile, PdfRestriction, PdfXType, SummaryFormat, @@ -43,6 +44,8 @@ ) from .public import PdfRestFile, PdfRestFileID +PdfConvertColorProfile = PdfPresetColorProfile | Literal["custom"] + def _ensure_list(value: Any) -> Any: if value is None: @@ -54,6 +57,14 @@ def _ensure_list(value: Any) -> Any: return [value] +def _is_uploaded_file_value(value: Any) -> bool: + if isinstance(value, PdfRestFile): + return True + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return all(isinstance(item, PdfRestFile) for item in value) + return False + + def _list_of_strings(value: list[Any]) -> list[str]: return [str(e) for e in value] @@ -144,6 +155,12 @@ def _bool_to_on_off(value: Any) -> Any: return value +def _bool_to_true_false(value: Any) -> Any: + if isinstance(value, bool): + return "true" if value else "false" + return value + + def _serialize_page_ranges(value: list[str | int | tuple[str | int, ...]]) -> str: def join_tuple(value: str | int | tuple[str | int, ...]) -> str: if isinstance(value, tuple): @@ -1780,6 +1797,96 @@ def _validate_page_configuration(self) -> PdfBlankPayload: return self +class PdfConvertColorsPayload(BaseModel): + """Adapt caller options into a pdfRest-ready convert-colors 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/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + color_profile: Annotated[ + PdfConvertColorProfile, + Field(serialization_alias="color_profile"), + ] + custom_profile: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + serialization_alias="profile_id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types( + "application/vnd.iccprofile", + "application/octet-stream", + error_msg="Profile must be an ICC file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + preserve_black: Annotated[ + Literal["true", "false"] | None, + Field(serialization_alias="preserve_black", default=None), + BeforeValidator(_bool_to_true_false), + ] = None + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + @model_validator(mode="before") + @classmethod + def _normalize_color_profile(cls, value: Any) -> Any: + if not isinstance(value, dict): + return value + + payload = cast(dict[str, Any], value).copy() + color_profile = payload.get("color_profile") + if not _is_uploaded_file_value(color_profile): + return payload + + if payload.get("custom_profile") is not None: + msg = ( + "Provide the custom profile file via color_profile only when " + "color_profile is a file." + ) + raise ValueError(msg) + + payload["color_profile"] = "custom" + payload["custom_profile"] = color_profile + return payload + + @model_validator(mode="after") + def _validate_profile_dependency(self) -> PdfConvertColorsPayload: + if self.color_profile == "custom": + if not self.custom_profile: + msg = ( + "A custom color profile requires an uploaded ICC file passed " + "as color_profile." + ) + raise ValueError(msg) + elif self.custom_profile: + msg = ( + "A profile can only be provided by passing an uploaded ICC file " + "as color_profile." + ) + raise ValueError(msg) + return self + + class PdfRestrictPayload(BaseModel): """Adapt caller options into a pdfRest-ready restrict-PDF request payload.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index b7db11ae..8f72d792 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -18,6 +18,7 @@ PdfAddTextObject, PdfAType, PdfCmykColor, + PdfColorProfile, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -28,6 +29,7 @@ PdfPageOrientation, PdfPageSelection, PdfPageSize, + PdfPresetColorProfile, PdfRedactionInstruction, PdfRedactionPreset, PdfRedactionType, @@ -60,6 +62,7 @@ "PdfAType", "PdfAddTextObject", "PdfCmykColor", + "PdfColorProfile", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", @@ -70,6 +73,7 @@ "PdfPageOrientation", "PdfPageSelection", "PdfPageSize", + "PdfPresetColorProfile", "PdfRGBColor", "PdfRedactionInstruction", "PdfRedactionPreset", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 20c73d97..3d252686 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -30,6 +30,7 @@ "PdfAType", "PdfAddTextObject", "PdfCmykColor", + "PdfColorProfile", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", @@ -40,6 +41,7 @@ "PdfPageOrientation", "PdfPageSelection", "PdfPageSize", + "PdfPresetColorProfile", "PdfRGBColor", "PdfRedactionInstruction", "PdfRedactionPreset", @@ -219,3 +221,21 @@ class PdfMergeSource(TypedDict, total=False): ) PdfPageSize = Literal["letter", "legal", "ledger", "A3", "A4", "A5"] | PdfCustomPageSize PdfPageOrientation = Literal["portrait", "landscape"] +PdfPresetColorProfile = Literal[ + "lab-d50", + "srgb", + "apple-rgb", + "color-match-rgb", + "gamma-18", + "gamma-22", + "dot-gain-10", + "dot-gain-15", + "dot-gain-20", + "dot-gain-25", + "dot-gain-30", + "monitor-rgb", + "acrobat5-cmyk", + "acrobat9-cmyk", +] + +PdfColorProfile = PdfPresetColorProfile diff --git a/tests/live/test_live_convert_colors.py b/tests/live/test_live_convert_colors.py new file mode 100644 index 00000000..9f9ebb2d --- /dev/null +++ b/tests/live/test_live_convert_colors.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +from typing import cast, get_args + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile +from pdfrest.types import PdfPresetColorProfile + +from ..resources import get_test_resource_path + +ALL_COLOR_PROFILES: tuple[PdfPresetColorProfile, ...] = cast( + tuple[PdfPresetColorProfile, ...], + get_args(PdfPresetColorProfile), +) + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_color_conversion( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module") +def uploaded_custom_profile_for_color_conversion( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("custom.icc") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.mark.parametrize( + "color_profile", + [ + pytest.param(color_profile, id=f"color-profile-{color_profile}") + for color_profile in ALL_COLOR_PROFILES + ], +) +def test_live_convert_colors_color_profiles_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_color_conversion: PdfRestFile, + color_profile: PdfPresetColorProfile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.convert_colors( + uploaded_pdf_for_color_conversion, + color_profile=color_profile, + ) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None + assert str(response.input_id) == str(uploaded_pdf_for_color_conversion.id) + + +def test_live_convert_colors_custom_profile_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + pdf_resource = get_test_resource_path("report.pdf") + profile_resource = get_test_resource_path("custom.icc") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded_pdf = client.files.create_from_paths([pdf_resource])[0] + uploaded_profile = client.files.create_from_paths([profile_resource])[0] + response = client.convert_colors( + uploaded_pdf, + color_profile=uploaded_profile, + ) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None + input_ids = {str(file_id) for file_id in response.input_ids} + assert str(uploaded_pdf.id) in input_ids + assert str(uploaded_profile.id) in input_ids + + +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("converted-colors", id="custom-output"), + ], +) +def test_live_convert_colors_output_prefix( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_color_conversion: PdfRestFile, + output_name: str | None, +) -> None: + kwargs: dict[str, str] = {"color_profile": "srgb"} + if output_name is not None: + kwargs["output"] = output_name + + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.convert_colors(uploaded_pdf_for_color_conversion, **kwargs) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None + assert str(response.input_id) == str(uploaded_pdf_for_color_conversion.id) + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pdf") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "color_profile", + [ + pytest.param(color_profile, id=f"color-profile-{color_profile}") + for color_profile in ALL_COLOR_PROFILES + ], +) +async def test_live_async_convert_colors_color_profiles_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_color_conversion: PdfRestFile, + color_profile: PdfPresetColorProfile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_colors( + uploaded_pdf_for_color_conversion, + color_profile=color_profile, + ) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None + assert str(response.input_id) == str(uploaded_pdf_for_color_conversion.id) + + +@pytest.mark.asyncio +async def test_live_async_convert_colors_custom_profile_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + pdf_resource = get_test_resource_path("report.pdf") + profile_resource = get_test_resource_path("custom.icc") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded_pdf = (await client.files.create_from_paths([pdf_resource]))[0] + uploaded_profile = (await client.files.create_from_paths([profile_resource]))[0] + response = await client.convert_colors( + uploaded_pdf, + color_profile=uploaded_profile, + ) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None + input_ids = {str(file_id) for file_id in response.input_ids} + assert str(uploaded_pdf.id) in input_ids + assert str(uploaded_profile.id) in input_ids + + +@pytest.mark.asyncio +async def test_live_async_convert_colors_output_prefix( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_color_conversion: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_colors( + uploaded_pdf_for_color_conversion, + color_profile="srgb", + output="async", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async") + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None + assert str(response.input_id) == str(uploaded_pdf_for_color_conversion.id) + + +def test_live_convert_colors_invalid_color_profile( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_color_conversion: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)(color|profile)"), + ): + client.convert_colors( + uploaded_pdf_for_color_conversion, + color_profile="srgb", + extra_body={"color_profile": "not-a-color-profile"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_colors_invalid_color_profile( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_color_conversion: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)(color|profile)"): + await client.convert_colors( + uploaded_pdf_for_color_conversion, + color_profile="srgb", + extra_body={"color_profile": "not-a-color-profile"}, + ) + + +def test_live_convert_colors_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_color_conversion: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"), + ): + client.convert_colors( + uploaded_pdf_for_color_conversion, + color_profile="srgb", + extra_body={"id": "00000000-0000-0000-0000-000000000000"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_colors_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_color_conversion: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"): + await client.convert_colors( + uploaded_pdf_for_color_conversion, + color_profile="srgb", + extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, + ) diff --git a/tests/resources/custom.icc b/tests/resources/custom.icc new file mode 100644 index 00000000..5e455a39 Binary files /dev/null and b/tests/resources/custom.icc differ diff --git a/tests/test_convert_colors.py b/tests/test_convert_colors.py new file mode 100644 index 00000000..c71d8311 --- /dev/null +++ b/tests/test_convert_colors.py @@ -0,0 +1,498 @@ +from __future__ import annotations + +import json +from typing import Any, cast, get_args + +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 PdfConvertColorsPayload +from pdfrest.types import PdfPresetColorProfile + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def _make_icc_file() -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload( + PdfRestFileID.generate(), + "profile.icc", + "application/vnd.iccprofile", + ) + ) + + +ALL_COLOR_PROFILES: tuple[PdfPresetColorProfile, ...] = cast( + tuple[PdfPresetColorProfile, ...], + get_args(PdfPresetColorProfile), +) + + +@pytest.mark.parametrize( + "color_profile", + [ + pytest.param(color_profile, id=f"color-profile-{color_profile}") + for color_profile in ALL_COLOR_PROFILES + ], +) +def test_convert_colors_success( + monkeypatch: pytest.MonkeyPatch, + color_profile: PdfPresetColorProfile, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + payload_options: dict[str, Any] = { + "files": [input_file], + "color_profile": color_profile, + "preserve_black": False, + "output": "converted", + } + client_options: dict[str, Any] = { + "color_profile": color_profile, + "output": "converted", + } + + payload_dump = PdfConvertColorsPayload.model_validate(payload_options).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 == "/pdf-with-converted-colors" + ): + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [input_file.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, + "converted.pdf", + "application/pdf", + ), + ) + 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.convert_colors(input_file, **client_options) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + output_file = response.output_file + assert output_file.name == "converted.pdf" + assert output_file.type == "application/pdf" + assert response.warning is None + assert str(response.input_id) == str(input_file.id) + + +def test_convert_colors_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + profile_file = _make_icc_file() + 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 == "/pdf-with-converted-colors" + ): + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "sync" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["debug"] == "yes" + assert payload["color_profile"] == "custom" + assert payload["profile_id"] == str(profile_file.id) + assert payload["preserve_black"] == "true" + return httpx.Response( + 200, + json={ + "inputId": [input_file.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"] == "true" + assert request.headers["X-Debug"] == "sync" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "custom.pdf", + "application/pdf", + ), + ) + 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.convert_colors( + input_file, + color_profile=profile_file, + preserve_black=True, + output="custom", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": "yes"}, + timeout=0.29, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "custom.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.29) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.29) + + +@pytest.mark.parametrize( + "color_profile", + [ + pytest.param(color_profile, id=f"color-profile-{color_profile}") + for color_profile in ALL_COLOR_PROFILES + ], +) +@pytest.mark.asyncio +async def test_async_convert_colors_success( + monkeypatch: pytest.MonkeyPatch, + color_profile: PdfPresetColorProfile, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + output_id = str(PdfRestFileID.generate()) + + payload_options: dict[str, Any] = { + "files": [input_file], + "color_profile": color_profile, + "preserve_black": False, + } + client_options: dict[str, Any] = {"color_profile": color_profile} + + payload_dump = PdfConvertColorsPayload.model_validate(payload_options).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 == "/pdf-with-converted-colors" + ): + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [input_file.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, + "async.pdf", + "application/pdf", + ), + ) + 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.convert_colors(input_file, **client_options) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async.pdf" + assert response.output_file.type == "application/pdf" + assert str(response.input_id) == str(input_file.id) + + +@pytest.mark.asyncio +async def test_async_convert_colors_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + profile_file = _make_icc_file() + 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 == "/pdf-with-converted-colors" + ): + 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["debug"] == "yes" + assert payload["color_profile"] == "custom" + assert payload["profile_id"] == str(profile_file.id) + assert payload["preserve_black"] == "false" + return httpx.Response( + 200, + json={ + "inputId": [input_file.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.pdf", + "application/pdf", + ), + ) + 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.convert_colors( + input_file, + color_profile=profile_file, + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": "yes"}, + timeout=0.52, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-custom.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.52) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.52) + + +def test_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + png_file = PdfRestFile.model_validate( + build_file_info_payload( + PdfRestFileID.generate(), + "example.png", + "image/png", + ) + ) + wrong_profile_file = PdfRestFile.model_validate( + build_file_info_payload( + PdfRestFileID.generate(), + "profile.txt", + "text/plain", + ) + ) + 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 PDF file"), + ): + client.convert_colors(png_file, color_profile="srgb") + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ), + ): + client.convert_colors( + [pdf_file, make_pdf_file(PdfRestFileID.generate())], + color_profile="srgb", + ) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValueError, + match="A custom color profile requires an uploaded ICC file", + ), + ): + client.convert_colors(pdf_file, color_profile=cast(Any, "custom")) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ), + ): + client.convert_colors( + pdf_file, + color_profile=[_make_icc_file(), _make_icc_file()], + ) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Profile must be an ICC file"), + ): + client.convert_colors( + pdf_file, + color_profile=wrong_profile_file, + ) + + +def test_convert_colors_payload_profile_dependency_validation() -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + + with pytest.raises( + ValueError, + match="A profile can only be provided by passing an uploaded ICC file", + ): + PdfConvertColorsPayload.model_validate( + { + "files": [pdf_file], + "color_profile": "srgb", + "custom_profile": _make_icc_file(), + } + ) + + with pytest.raises( + ValueError, + match=r"Provide the custom profile file via color_profile only", + ): + PdfConvertColorsPayload.model_validate( + { + "files": [pdf_file], + "color_profile": _make_icc_file(), + "custom_profile": _make_icc_file(), + } + ) + + +@pytest.mark.asyncio +async def test_async_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + png_file = PdfRestFile.model_validate( + build_file_info_payload( + PdfRestFileID.generate(), + "example.png", + "image/png", + ) + ) + wrong_profile_file = PdfRestFile.model_validate( + build_file_info_payload( + PdfRestFileID.generate(), + "profile.txt", + "text/plain", + ) + ) + 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 PDF file"): + await client.convert_colors(png_file, color_profile="srgb") + + with pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ): + await client.convert_colors( + [pdf_file, make_pdf_file(PdfRestFileID.generate())], + color_profile="srgb", + ) + + with pytest.raises( + ValueError, + match="A custom color profile requires an uploaded ICC file", + ): + await client.convert_colors(pdf_file, color_profile=cast(Any, "custom")) + + with pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ): + await client.convert_colors( + pdf_file, + color_profile=[_make_icc_file(), _make_icc_file()], + ) + + with pytest.raises(ValidationError, match="Profile must be an ICC file"): + await client.convert_colors( + pdf_file, + color_profile=wrong_profile_file, + ) + + +def test_convert_colors_rejects_invalid_color_profile( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + invalid_color_profile = cast(Any, "not-a-color-profile") + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Input should be"), + ): + client.convert_colors(pdf_file, color_profile=invalid_color_profile) + + +@pytest.mark.asyncio +async def test_async_convert_colors_rejects_invalid_color_profile( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + invalid_color_profile = cast(Any, "not-a-color-profile") + 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="Input should be"): + await client.convert_colors(pdf_file, color_profile=invalid_color_profile)