From cce13a5c0f0c4ab894fc35939fb2c78f79ecdc90 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Mon, 12 Jan 2026 11:45:43 -0600 Subject: [PATCH 01/11] client.py: add pdf color conversion methods Assisted-by: Codex --- src/pdfrest/client.py | 72 ++++++ src/pdfrest/models/_internal.py | 70 +++++ src/pdfrest/types/__init__.py | 2 + src/pdfrest/types/public.py | 18 ++ tests/live/test_live_convert_colors.py | 118 +++++++++ tests/test_convert_colors.py | 340 +++++++++++++++++++++++++ 6 files changed, 620 insertions(+) create mode 100644 tests/live/test_live_convert_colors.py create mode 100644 tests/test_convert_colors.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 505f1288..900fdf4f 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -93,6 +93,7 @@ PdfAddTextPayload, PdfBlankPayload, PdfCompressPayload, + PdfConvertColorsPayload, PdfDecryptPayload, PdfEncryptPayload, PdfFlattenAnnotationsPayload, @@ -138,6 +139,7 @@ OcrLanguage, PdfAddTextObject, PdfAType, + PdfColorProfile, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -3173,6 +3175,41 @@ def blank_pdf( timeout=timeout, ) + def convert_colors( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + color_profile: PdfColorProfile, + profile: PdfRestFile | Sequence[PdfRestFile] | None = None, + 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 preset or custom ICC profiles.""" + + payload: dict[str, Any] = { + "files": file, + "color_profile": color_profile, + "preserve_black": preserve_black, + } + if profile is not None: + payload["profile"] = profile + 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 +4913,41 @@ async def blank_pdf( timeout=timeout, ) + async def convert_colors( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + color_profile: PdfColorProfile, + profile: PdfRestFile | Sequence[PdfRestFile] | None = None, + 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 preset or custom ICC profiles.""" + + payload: dict[str, Any] = { + "files": file, + "color_profile": color_profile, + "preserve_black": preserve_black, + } + if profile is not None: + payload["profile"] = profile + 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..590b7aca 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -28,6 +28,7 @@ HtmlWebLayout, OcrLanguage, PdfAType, + PdfColorProfile, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -144,6 +145,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 +1787,69 @@ 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[ + PdfColorProfile, + Field(serialization_alias="color_profile"), + ] + profile: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + validation_alias=AliasChoices("profile", "profiles"), + 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="after") + def _validate_profile_dependency(self) -> PdfConvertColorsPayload: + if self.color_profile == "custom": + if not self.profile: + msg = "color_profile 'custom' requires a profile to be provided." + raise ValueError(msg) + elif self.profile: + msg = "A profile can only be provided when color_profile is 'custom'." + 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..da4a5710 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -18,6 +18,7 @@ PdfAddTextObject, PdfAType, PdfCmykColor, + PdfColorProfile, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -60,6 +61,7 @@ "PdfAType", "PdfAddTextObject", "PdfCmykColor", + "PdfColorProfile", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 20c73d97..f61ec1cd 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -30,6 +30,7 @@ "PdfAType", "PdfAddTextObject", "PdfCmykColor", + "PdfColorProfile", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", @@ -219,3 +220,20 @@ class PdfMergeSource(TypedDict, total=False): ) PdfPageSize = Literal["letter", "legal", "ledger", "A3", "A4", "A5"] | PdfCustomPageSize PdfPageOrientation = Literal["portrait", "landscape"] +PdfColorProfile = 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", + "custom", +] diff --git a/tests/live/test_live_convert_colors.py b/tests/live/test_live_convert_colors.py new file mode 100644 index 00000000..20a1f480 --- /dev/null +++ b/tests/live/test_live_convert_colors.py @@ -0,0 +1,118 @@ +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_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.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("converted-colors", id="custom-output"), + ], +) +def test_live_convert_colors_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_color_conversion: PdfRestFile, + output_name: str | None, +) -> None: + kwargs: dict[str, str | bool] = {"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 +async def test_live_async_convert_colors_success( + 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_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/test_convert_colors.py b/tests/test_convert_colors.py new file mode 100644 index 00000000..bdc4ff82 --- /dev/null +++ b/tests/test_convert_colors.py @@ -0,0 +1,340 @@ +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 PdfConvertColorsPayload + +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", + ) + ) + + +def test_convert_colors_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfConvertColorsPayload.model_validate( + { + "files": [input_file], + "color_profile": "srgb", + "preserve_black": False, + "output": "converted", + } + ).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, color_profile="srgb", output="converted" + ) + + 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="custom", + 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.asyncio +async def test_async_convert_colors_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfConvertColorsPayload.model_validate( + {"files": [input_file], "color_profile": "srgb", "preserve_black": False} + ).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, color_profile="srgb") + + 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="custom", + 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="requires a profile"), + ): + client.convert_colors(pdf_file, color_profile="custom") + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValueError, match="only be provided when color_profile"), + ): + client.convert_colors(pdf_file, color_profile="srgb", profile=_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="custom", + profile=wrong_profile_file, + ) From c194caaa4c222f183607630638dacea038e89cb9 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 11 Feb 2026 09:48:52 -0600 Subject: [PATCH 02/11] tests: parameterize convert_colors color profiles and invalid literals - Add sync/async unit coverage for every PdfColorProfile literal via pytest.param IDs, including custom-profile payload handling. - Add explicit sync/async invalid color_profile tests to assert local ValidationError behavior without issuing HTTP requests. Assisted-by: Codex --- tests/test_convert_colors.py | 112 ++++++++++++++++++++++++++++++----- 1 file changed, 96 insertions(+), 16 deletions(-) diff --git a/tests/test_convert_colors.py b/tests/test_convert_colors.py index bdc4ff82..8181ea08 100644 --- a/tests/test_convert_colors.py +++ b/tests/test_convert_colors.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from typing import Any, cast, get_args import httpx import pytest @@ -9,6 +10,7 @@ from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID from pdfrest.models._internal import PdfConvertColorsPayload +from pdfrest.types import PdfColorProfile from .graphics_test_helpers import ( ASYNC_API_KEY, @@ -28,19 +30,48 @@ def _make_icc_file() -> PdfRestFile: ) -def test_convert_colors_success(monkeypatch: pytest.MonkeyPatch) -> None: +ALL_COLOR_PROFILES: tuple[PdfColorProfile, ...] = cast( + tuple[PdfColorProfile, ...], + get_args(PdfColorProfile), +) + + +@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: PdfColorProfile, +) -> 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()) - payload_dump = PdfConvertColorsPayload.model_validate( - { - "files": [input_file], - "color_profile": "srgb", - "preserve_black": False, - "output": "converted", - } - ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + 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", + } + if color_profile == "custom": + payload_options["profile"] = profile_file + client_options["profile"] = profile_file + + 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} @@ -75,9 +106,7 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: - response = client.convert_colors( - input_file, color_profile="srgb", output="converted" - ) + response = client.convert_colors(input_file, **client_options) assert seen == {"post": 1, "get": 1} assert isinstance(response, PdfRestFileBasedResponse) @@ -158,17 +187,39 @@ def handler(request: httpx.Request) -> httpx.Response: 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: PdfColorProfile, ) -> 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()) - payload_dump = PdfConvertColorsPayload.model_validate( - {"files": [input_file], "color_profile": "srgb", "preserve_black": False} - ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + payload_options: dict[str, Any] = { + "files": [input_file], + "color_profile": color_profile, + "preserve_black": False, + } + client_options: dict[str, Any] = {"color_profile": color_profile} + if color_profile == "custom": + payload_options["profile"] = profile_file + client_options["profile"] = profile_file + + 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} @@ -203,7 +254,7 @@ def handler(request: httpx.Request) -> httpx.Response: 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="srgb") + response = await client.convert_colors(input_file, **client_options) assert seen == {"post": 1, "get": 1} assert isinstance(response, PdfRestFileBasedResponse) @@ -338,3 +389,32 @@ def test_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) -> None: color_profile="custom", 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) From 634b4a90a09fa1183620e0fd7be233101b6dc638 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 11 Feb 2026 09:55:33 -0600 Subject: [PATCH 03/11] tests: add async convert_colors validation parity coverage Assisted-by: Codex --- tests/test_convert_colors.py | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_convert_colors.py b/tests/test_convert_colors.py index 8181ea08..d214d784 100644 --- a/tests/test_convert_colors.py +++ b/tests/test_convert_colors.py @@ -391,6 +391,56 @@ def test_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) -> None: ) +@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="requires a profile"): + await client.convert_colors(pdf_file, color_profile="custom") + + with pytest.raises(ValueError, match="only be provided when color_profile"): + await client.convert_colors( + pdf_file, + color_profile="srgb", + profile=_make_icc_file(), + ) + + with pytest.raises(ValidationError, match="Profile must be an ICC file"): + await client.convert_colors( + pdf_file, + color_profile="custom", + profile=wrong_profile_file, + ) + + def test_convert_colors_rejects_invalid_color_profile( monkeypatch: pytest.MonkeyPatch, ) -> None: From d6fab2389806e79d5c23a777cce3a3b47ae1822e Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 11 Feb 2026 10:03:05 -0600 Subject: [PATCH 04/11] tests: add convert_colors multi-profile guard coverage (sync and async) Assisted-by: Codex --- tests/test_convert_colors.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_convert_colors.py b/tests/test_convert_colors.py index d214d784..efebf147 100644 --- a/tests/test_convert_colors.py +++ b/tests/test_convert_colors.py @@ -380,6 +380,18 @@ def test_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) -> None: ): client.convert_colors(pdf_file, color_profile="srgb", profile=_make_icc_file()) + 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="custom", + 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"), @@ -433,6 +445,15 @@ async def test_async_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) profile=_make_icc_file(), ) + with pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ): + await client.convert_colors( + pdf_file, + color_profile="custom", + profile=[_make_icc_file(), _make_icc_file()], + ) + with pytest.raises(ValidationError, match="Profile must be an ICC file"): await client.convert_colors( pdf_file, From dbae0e2a143c428d51e9c37fcb5017f947958f61 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 11 Feb 2026 11:42:46 -0600 Subject: [PATCH 05/11] tests: expand live convert_colors profile coverage and invalid override checks Assisted-by: Codex --- tests/live/test_live_convert_colors.py | 115 ++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/tests/live/test_live_convert_colors.py b/tests/live/test_live_convert_colors.py index 20a1f480..ff502cbe 100644 --- a/tests/live/test_live_convert_colors.py +++ b/tests/live/test_live_convert_colors.py @@ -1,12 +1,23 @@ 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 PdfColorProfile from ..resources import get_test_resource_path +ALL_COLOR_PROFILES: tuple[PdfColorProfile, ...] = cast( + tuple[PdfColorProfile, ...], + get_args(PdfColorProfile), +) +PRESET_COLOR_PROFILES: tuple[PdfColorProfile, ...] = tuple( + color_profile for color_profile in ALL_COLOR_PROFILES if color_profile != "custom" +) + @pytest.fixture(scope="module") def uploaded_pdf_for_color_conversion( @@ -21,6 +32,36 @@ def uploaded_pdf_for_color_conversion( 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 PRESET_COLOR_PROFILES + ], +) +def test_live_convert_colors_presets_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_color_conversion: PdfRestFile, + color_profile: PdfColorProfile, +) -> 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) + + @pytest.mark.parametrize( "output_name", [ @@ -28,13 +69,13 @@ def uploaded_pdf_for_color_conversion( pytest.param("converted-colors", id="custom-output"), ], ) -def test_live_convert_colors_success( +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 | bool] = {"color_profile": "srgb"} + kwargs: dict[str, str] = {"color_profile": "srgb"} if output_name is not None: kwargs["output"] = output_name @@ -57,7 +98,38 @@ def test_live_convert_colors_success( @pytest.mark.asyncio -async def test_live_async_convert_colors_success( +@pytest.mark.parametrize( + "color_profile", + [ + pytest.param(color_profile, id=f"color-profile-{color_profile}") + for color_profile in PRESET_COLOR_PROFILES + ], +) +async def test_live_async_convert_colors_presets_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_color_conversion: PdfRestFile, + color_profile: PdfColorProfile, +) -> 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_output_prefix( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_color_conversion: PdfRestFile, @@ -81,6 +153,43 @@ async def test_live_async_convert_colors_success( 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, From 1240d88078e9774672a78605b85be16101c5aa7b Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 11 Feb 2026 12:02:07 -0600 Subject: [PATCH 06/11] tests: add custom color profile Assisted-by: Codex --- tests/live/test_live_convert_colors.py | 59 ++++++++++++++++++++----- tests/resources/custom.icc | Bin 0 -> 912 bytes 2 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 tests/resources/custom.icc diff --git a/tests/live/test_live_convert_colors.py b/tests/live/test_live_convert_colors.py index ff502cbe..aeefe9d0 100644 --- a/tests/live/test_live_convert_colors.py +++ b/tests/live/test_live_convert_colors.py @@ -14,9 +14,6 @@ tuple[PdfColorProfile, ...], get_args(PdfColorProfile), ) -PRESET_COLOR_PROFILES: tuple[PdfColorProfile, ...] = tuple( - color_profile for color_profile in ALL_COLOR_PROFILES if color_profile != "custom" -) @pytest.fixture(scope="module") @@ -32,26 +29,47 @@ def uploaded_pdf_for_color_conversion( return client.files.create_from_paths([resource])[0] +def _upload_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 PRESET_COLOR_PROFILES + for color_profile in ALL_COLOR_PROFILES ], ) -def test_live_convert_colors_presets_success( +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: PdfColorProfile, ) -> None: + kwargs: dict[str, PdfColorProfile | PdfRestFile] = {"color_profile": color_profile} + custom_profile: PdfRestFile | None = None + if color_profile == "custom": + custom_profile = _upload_custom_profile_for_color_conversion( + pdfrest_api_key, + pdfrest_live_base_url, + ) + kwargs["profile"] = custom_profile + 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, + **kwargs, ) assert response.output_files @@ -59,7 +77,12 @@ def test_live_convert_colors_presets_success( 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) + input_ids = {str(file_id) for file_id in response.input_ids} + assert str(uploaded_pdf_for_color_conversion.id) in input_ids + if custom_profile is not None: + assert str(custom_profile.id) in input_ids + else: + assert str(response.input_id) == str(uploaded_pdf_for_color_conversion.id) @pytest.mark.parametrize( @@ -102,22 +125,31 @@ def test_live_convert_colors_output_prefix( "color_profile", [ pytest.param(color_profile, id=f"color-profile-{color_profile}") - for color_profile in PRESET_COLOR_PROFILES + for color_profile in ALL_COLOR_PROFILES ], ) -async def test_live_async_convert_colors_presets_success( +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: PdfColorProfile, ) -> None: + kwargs: dict[str, PdfColorProfile | PdfRestFile] = {"color_profile": color_profile} + custom_profile: PdfRestFile | None = None + if color_profile == "custom": + custom_profile = _upload_custom_profile_for_color_conversion( + pdfrest_api_key, + pdfrest_live_base_url, + ) + kwargs["profile"] = custom_profile + 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, + **kwargs, ) assert response.output_files @@ -125,7 +157,12 @@ async def test_live_async_convert_colors_presets_success( 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) + input_ids = {str(file_id) for file_id in response.input_ids} + assert str(uploaded_pdf_for_color_conversion.id) in input_ids + if custom_profile is not None: + assert str(custom_profile.id) in input_ids + else: + assert str(response.input_id) == str(uploaded_pdf_for_color_conversion.id) @pytest.mark.asyncio diff --git a/tests/resources/custom.icc b/tests/resources/custom.icc new file mode 100644 index 0000000000000000000000000000000000000000..5e455a3976d588fd7ed2e38e8b2dd8112a9717d2 GIT binary patch literal 912 zcmZQzV4mRU;^fLCz`#&YR8r&~lnE?zElZy)+0|I4n%E+9EKf? ze2iI)XPHcy7Bh=5cd@XrG_o?Wwz2WBO=DMP-^}64ag(!vi<4_9w-fgxo)%sW-gA6q z{POu6v91#bv-3EK&O5m_yoA|@|(OMHezxTLt`4XHWO2{LLjZ)La2)ysP+h$%c# z+@#c`9HOG8@>BJk+7k6@jbKe3EjF!3+WT~7>sIMS>024d82mGQWOUqkjmc!wDzjvB z9}9C!B`ZFw-_|c}ZrGl%+iAbrVXosur&i}Gmjc&xw>bB3j{r|EFIR6zA3I-bKTCg$ z0E2;h``A3sQBpgn1a~KxaRo2gjtEpk~SwFOgW!=FYR6Wzf6HF z#cY!t_uS~b{QTB}S%n*mP82^V`B5fVu3h0;nNU?zJ)>r8?WMX84g8IIO@7V!EfZQd zwOwid-YMN>-<{mk*}JaqYX6^!DwBLBmrR*A^~AJKGh}Ca&MKX~aL)O;f97j1h+5dW zXy@WrOQo0jEU#O!apjZM5^H?cHm=*Y{>?_EO_7@?ZaJ}yal6%y;+<=Ez1X9)H*Vjo z{nri(9|}C&f8^{jp5xvpx=)@t&2z@@?1XcdE{I-?xHRYTgR3gnvaYYc@%5I??dChD z?g`zGez4@>+sEcl8lRqdF8(6v<+@kD-*~*8`tI=u!;cN0&VP~rn*VM84}qUazqb5g L`y26Z_5c3>1i~QA literal 0 HcmV?d00001 From d4954793764e8804db0eaa54acc54e3a681061c4 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 13 Feb 2026 16:56:10 -0600 Subject: [PATCH 07/11] client: Allow convert_colors color_profile to accept custom ICC files Assisted-by: Codex --- src/pdfrest/client.py | 6 ++--- src/pdfrest/models/_internal.py | 30 ++++++++++++++++++++++ src/pdfrest/types/__init__.py | 2 ++ src/pdfrest/types/public.py | 3 +++ tests/live/test_live_convert_colors.py | 4 +-- tests/test_convert_colors.py | 35 ++++++++++++++++++++------ 6 files changed, 67 insertions(+), 13 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 900fdf4f..b2cd923a 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -139,7 +139,7 @@ OcrLanguage, PdfAddTextObject, PdfAType, - PdfColorProfile, + PdfColorProfileInput, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -3179,7 +3179,7 @@ def convert_colors( self, file: PdfRestFile | Sequence[PdfRestFile], *, - color_profile: PdfColorProfile, + color_profile: PdfColorProfileInput, profile: PdfRestFile | Sequence[PdfRestFile] | None = None, preserve_black: bool = False, output: str | None = None, @@ -4917,7 +4917,7 @@ async def convert_colors( self, file: PdfRestFile | Sequence[PdfRestFile], *, - color_profile: PdfColorProfile, + color_profile: PdfColorProfileInput, profile: PdfRestFile | Sequence[PdfRestFile] | None = None, preserve_black: bool = False, output: str | None = None, diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 590b7aca..436a8200 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -55,6 +55,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] @@ -1838,6 +1846,28 @@ class PdfConvertColorsPayload(BaseModel): 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("profile") is not None or payload.get("profiles") 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["profile"] = color_profile + return payload + @model_validator(mode="after") def _validate_profile_dependency(self) -> PdfConvertColorsPayload: if self.color_profile == "custom": diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index da4a5710..0b76fde4 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -19,6 +19,7 @@ PdfAType, PdfCmykColor, PdfColorProfile, + PdfColorProfileInput, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -62,6 +63,7 @@ "PdfAddTextObject", "PdfCmykColor", "PdfColorProfile", + "PdfColorProfileInput", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index f61ec1cd..581c7b0a 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -31,6 +31,7 @@ "PdfAddTextObject", "PdfCmykColor", "PdfColorProfile", + "PdfColorProfileInput", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", @@ -237,3 +238,5 @@ class PdfMergeSource(TypedDict, total=False): "acrobat9-cmyk", "custom", ] + +PdfColorProfileInput = PdfColorProfile | PdfRestFile | Sequence[PdfRestFile] diff --git a/tests/live/test_live_convert_colors.py b/tests/live/test_live_convert_colors.py index aeefe9d0..bd10263b 100644 --- a/tests/live/test_live_convert_colors.py +++ b/tests/live/test_live_convert_colors.py @@ -61,7 +61,7 @@ def test_live_convert_colors_color_profiles_success( pdfrest_api_key, pdfrest_live_base_url, ) - kwargs["profile"] = custom_profile + kwargs["color_profile"] = custom_profile with PdfRestClient( api_key=pdfrest_api_key, @@ -141,7 +141,7 @@ async def test_live_async_convert_colors_color_profiles_success( pdfrest_api_key, pdfrest_live_base_url, ) - kwargs["profile"] = custom_profile + kwargs["color_profile"] = custom_profile async with AsyncPdfRestClient( api_key=pdfrest_api_key, diff --git a/tests/test_convert_colors.py b/tests/test_convert_colors.py index efebf147..e17d53b2 100644 --- a/tests/test_convert_colors.py +++ b/tests/test_convert_colors.py @@ -165,8 +165,7 @@ def handler(request: httpx.Request) -> httpx.Response: with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = client.convert_colors( input_file, - color_profile="custom", - profile=profile_file, + color_profile=profile_file, preserve_black=True, output="custom", extra_query={"trace": "true"}, @@ -312,8 +311,7 @@ def handler(request: httpx.Request) -> httpx.Response: async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: response = await client.convert_colors( input_file, - color_profile="custom", - profile=profile_file, + color_profile=profile_file, extra_query={"trace": "async"}, extra_headers={"X-Debug": "async"}, extra_body={"debug": "yes"}, @@ -398,8 +396,20 @@ def test_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) -> None: ): client.convert_colors( pdf_file, - color_profile="custom", - profile=wrong_profile_file, + color_profile=wrong_profile_file, + ) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValueError, + match=r"Provide the custom profile file via color_profile only", + ), + ): + client.convert_colors( + pdf_file, + color_profile=_make_icc_file(), + profile=_make_icc_file(), ) @@ -457,8 +467,17 @@ async def test_async_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) with pytest.raises(ValidationError, match="Profile must be an ICC file"): await client.convert_colors( pdf_file, - color_profile="custom", - profile=wrong_profile_file, + color_profile=wrong_profile_file, + ) + + with pytest.raises( + ValueError, + match=r"Provide the custom profile file via color_profile only", + ): + await client.convert_colors( + pdf_file, + color_profile=_make_icc_file(), + profile=_make_icc_file(), ) From 1d017544b7a5741660e9c03cd94ada266396a02b Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 13 Feb 2026 17:04:52 -0600 Subject: [PATCH 08/11] convert-colors: Remove `PdfColorProfileInput` layer Assisted-by: Codex --- src/pdfrest/client.py | 6 +++--- src/pdfrest/types/__init__.py | 2 -- src/pdfrest/types/public.py | 3 --- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index b2cd923a..3070cd24 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -139,7 +139,7 @@ OcrLanguage, PdfAddTextObject, PdfAType, - PdfColorProfileInput, + PdfColorProfile, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -3179,7 +3179,7 @@ def convert_colors( self, file: PdfRestFile | Sequence[PdfRestFile], *, - color_profile: PdfColorProfileInput, + color_profile: PdfColorProfile | PdfRestFile | Sequence[PdfRestFile], profile: PdfRestFile | Sequence[PdfRestFile] | None = None, preserve_black: bool = False, output: str | None = None, @@ -4917,7 +4917,7 @@ async def convert_colors( self, file: PdfRestFile | Sequence[PdfRestFile], *, - color_profile: PdfColorProfileInput, + color_profile: PdfColorProfile | PdfRestFile | Sequence[PdfRestFile], profile: PdfRestFile | Sequence[PdfRestFile] | None = None, preserve_black: bool = False, output: str | None = None, diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 0b76fde4..da4a5710 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -19,7 +19,6 @@ PdfAType, PdfCmykColor, PdfColorProfile, - PdfColorProfileInput, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -63,7 +62,6 @@ "PdfAddTextObject", "PdfCmykColor", "PdfColorProfile", - "PdfColorProfileInput", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 581c7b0a..f61ec1cd 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -31,7 +31,6 @@ "PdfAddTextObject", "PdfCmykColor", "PdfColorProfile", - "PdfColorProfileInput", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", @@ -238,5 +237,3 @@ class PdfMergeSource(TypedDict, total=False): "acrobat9-cmyk", "custom", ] - -PdfColorProfileInput = PdfColorProfile | PdfRestFile | Sequence[PdfRestFile] From 5e4ab9a101870b564175cb77cea457987009c35e Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 17 Feb 2026 14:38:50 -0600 Subject: [PATCH 09/11] convert-colors: Remove redundant `profile` param, exposed `"custom"` - Remove `profile` option that duplicated `color_profile` - Accept custom profile upload for `color_profile` without requiring `"custom"` value from user Assisted-by: Codex --- src/pdfrest/client.py | 16 ++--- src/pdfrest/models/_internal.py | 16 +++-- src/pdfrest/types/__init__.py | 2 + src/pdfrest/types/public.py | 6 +- tests/live/test_live_convert_colors.py | 86 ++++++++++++++-------- tests/test_convert_colors.py | 98 +++++++++++--------------- 6 files changed, 121 insertions(+), 103 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 3070cd24..16ff8d5b 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -139,7 +139,6 @@ OcrLanguage, PdfAddTextObject, PdfAType, - PdfColorProfile, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -148,6 +147,7 @@ PdfPageOrientation, PdfPageSelection, PdfPageSize, + PdfPresetColorProfile, PdfRedactionInstruction, PdfRestriction, PdfRGBColor, @@ -3179,8 +3179,7 @@ def convert_colors( self, file: PdfRestFile | Sequence[PdfRestFile], *, - color_profile: PdfColorProfile | PdfRestFile | Sequence[PdfRestFile], - profile: PdfRestFile | Sequence[PdfRestFile] | None = None, + color_profile: PdfPresetColorProfile | PdfRestFile | Sequence[PdfRestFile], preserve_black: bool = False, output: str | None = None, extra_query: Query | None = None, @@ -3188,15 +3187,13 @@ def convert_colors( extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - """Convert PDF colors using preset or custom ICC profiles.""" + """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 profile is not None: - payload["profile"] = profile if output is not None: payload["output"] = output @@ -4917,8 +4914,7 @@ async def convert_colors( self, file: PdfRestFile | Sequence[PdfRestFile], *, - color_profile: PdfColorProfile | PdfRestFile | Sequence[PdfRestFile], - profile: PdfRestFile | Sequence[PdfRestFile] | None = None, + color_profile: PdfPresetColorProfile | PdfRestFile | Sequence[PdfRestFile], preserve_black: bool = False, output: str | None = None, extra_query: Query | None = None, @@ -4926,15 +4922,13 @@ async def convert_colors( extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - """Asynchronously convert PDF colors using preset or custom ICC profiles.""" + """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 profile is not None: - payload["profile"] = profile if output is not None: payload["output"] = output diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 436a8200..90217437 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -28,13 +28,13 @@ HtmlWebLayout, OcrLanguage, PdfAType, - PdfColorProfile, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, PdfInfoQuery, PdfPageOrientation, PdfPageSize, + PdfPresetColorProfile, PdfRestriction, PdfXType, SummaryFormat, @@ -44,6 +44,8 @@ ) from .public import PdfRestFile, PdfRestFileID +PdfConvertColorProfile = PdfPresetColorProfile | Literal["custom"] + def _ensure_list(value: Any) -> Any: if value is None: @@ -1813,7 +1815,7 @@ class PdfConvertColorsPayload(BaseModel): PlainSerializer(_serialize_as_first_file_id), ] color_profile: Annotated[ - PdfColorProfile, + PdfConvertColorProfile, Field(serialization_alias="color_profile"), ] profile: Annotated[ @@ -1872,10 +1874,16 @@ def _normalize_color_profile(cls, value: Any) -> Any: def _validate_profile_dependency(self) -> PdfConvertColorsPayload: if self.color_profile == "custom": if not self.profile: - msg = "color_profile 'custom' requires a profile to be provided." + msg = ( + "A custom color profile requires an uploaded ICC file passed " + "as color_profile." + ) raise ValueError(msg) elif self.profile: - msg = "A profile can only be provided when color_profile is 'custom'." + msg = ( + "A profile can only be provided by passing an uploaded ICC file " + "as color_profile." + ) raise ValueError(msg) return self diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index da4a5710..8f72d792 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -29,6 +29,7 @@ PdfPageOrientation, PdfPageSelection, PdfPageSize, + PdfPresetColorProfile, PdfRedactionInstruction, PdfRedactionPreset, PdfRedactionType, @@ -72,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 f61ec1cd..3d252686 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -41,6 +41,7 @@ "PdfPageOrientation", "PdfPageSelection", "PdfPageSize", + "PdfPresetColorProfile", "PdfRGBColor", "PdfRedactionInstruction", "PdfRedactionPreset", @@ -220,7 +221,7 @@ class PdfMergeSource(TypedDict, total=False): ) PdfPageSize = Literal["letter", "legal", "ledger", "A3", "A4", "A5"] | PdfCustomPageSize PdfPageOrientation = Literal["portrait", "landscape"] -PdfColorProfile = Literal[ +PdfPresetColorProfile = Literal[ "lab-d50", "srgb", "apple-rgb", @@ -235,5 +236,6 @@ class PdfMergeSource(TypedDict, total=False): "monitor-rgb", "acrobat5-cmyk", "acrobat9-cmyk", - "custom", ] + +PdfColorProfile = PdfPresetColorProfile diff --git a/tests/live/test_live_convert_colors.py b/tests/live/test_live_convert_colors.py index bd10263b..79cdef01 100644 --- a/tests/live/test_live_convert_colors.py +++ b/tests/live/test_live_convert_colors.py @@ -6,13 +6,13 @@ from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile -from pdfrest.types import PdfColorProfile +from pdfrest.types import PdfPresetColorProfile from ..resources import get_test_resource_path -ALL_COLOR_PROFILES: tuple[PdfColorProfile, ...] = cast( - tuple[PdfColorProfile, ...], - get_args(PdfColorProfile), +ALL_COLOR_PROFILES: tuple[PdfPresetColorProfile, ...] = cast( + tuple[PdfPresetColorProfile, ...], + get_args(PdfPresetColorProfile), ) @@ -29,7 +29,8 @@ def uploaded_pdf_for_color_conversion( return client.files.create_from_paths([resource])[0] -def _upload_custom_profile_for_color_conversion( +@pytest.fixture(scope="module") +def uploaded_custom_profile_for_color_conversion( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> PdfRestFile: @@ -52,24 +53,38 @@ 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: PdfColorProfile, + color_profile: PdfPresetColorProfile, ) -> None: - kwargs: dict[str, PdfColorProfile | PdfRestFile] = {"color_profile": color_profile} - custom_profile: PdfRestFile | None = None - if color_profile == "custom": - custom_profile = _upload_custom_profile_for_color_conversion( - pdfrest_api_key, - pdfrest_live_base_url, + 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, ) - kwargs["color_profile"] = custom_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, + uploaded_pdf_for_color_conversion: PdfRestFile, + uploaded_custom_profile_for_color_conversion: PdfRestFile, +) -> 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, - **kwargs, + color_profile=uploaded_custom_profile_for_color_conversion, ) assert response.output_files @@ -79,10 +94,7 @@ def test_live_convert_colors_color_profiles_success( assert response.warning is None input_ids = {str(file_id) for file_id in response.input_ids} assert str(uploaded_pdf_for_color_conversion.id) in input_ids - if custom_profile is not None: - assert str(custom_profile.id) in input_ids - else: - assert str(response.input_id) == str(uploaded_pdf_for_color_conversion.id) + assert str(uploaded_custom_profile_for_color_conversion.id) in input_ids @pytest.mark.parametrize( @@ -132,24 +144,39 @@ 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: PdfColorProfile, + color_profile: PdfPresetColorProfile, ) -> None: - kwargs: dict[str, PdfColorProfile | PdfRestFile] = {"color_profile": color_profile} - custom_profile: PdfRestFile | None = None - if color_profile == "custom": - custom_profile = _upload_custom_profile_for_color_conversion( - pdfrest_api_key, - pdfrest_live_base_url, + 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, ) - kwargs["color_profile"] = custom_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, + uploaded_pdf_for_color_conversion: PdfRestFile, + uploaded_custom_profile_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, - **kwargs, + color_profile=uploaded_custom_profile_for_color_conversion, ) assert response.output_files @@ -159,10 +186,7 @@ async def test_live_async_convert_colors_color_profiles_success( assert response.warning is None input_ids = {str(file_id) for file_id in response.input_ids} assert str(uploaded_pdf_for_color_conversion.id) in input_ids - if custom_profile is not None: - assert str(custom_profile.id) in input_ids - else: - assert str(response.input_id) == str(uploaded_pdf_for_color_conversion.id) + assert str(uploaded_custom_profile_for_color_conversion.id) in input_ids @pytest.mark.asyncio diff --git a/tests/test_convert_colors.py b/tests/test_convert_colors.py index e17d53b2..fb17ba98 100644 --- a/tests/test_convert_colors.py +++ b/tests/test_convert_colors.py @@ -10,7 +10,7 @@ from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID from pdfrest.models._internal import PdfConvertColorsPayload -from pdfrest.types import PdfColorProfile +from pdfrest.types import PdfPresetColorProfile from .graphics_test_helpers import ( ASYNC_API_KEY, @@ -30,9 +30,9 @@ def _make_icc_file() -> PdfRestFile: ) -ALL_COLOR_PROFILES: tuple[PdfColorProfile, ...] = cast( - tuple[PdfColorProfile, ...], - get_args(PdfColorProfile), +ALL_COLOR_PROFILES: tuple[PdfPresetColorProfile, ...] = cast( + tuple[PdfPresetColorProfile, ...], + get_args(PdfPresetColorProfile), ) @@ -45,11 +45,10 @@ def _make_icc_file() -> PdfRestFile: ) def test_convert_colors_success( monkeypatch: pytest.MonkeyPatch, - color_profile: PdfColorProfile, + color_profile: PdfPresetColorProfile, ) -> 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()) payload_options: dict[str, Any] = { @@ -62,9 +61,6 @@ def test_convert_colors_success( "color_profile": color_profile, "output": "converted", } - if color_profile == "custom": - payload_options["profile"] = profile_file - client_options["profile"] = profile_file payload_dump = PdfConvertColorsPayload.model_validate(payload_options).model_dump( mode="json", @@ -196,11 +192,10 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_convert_colors_success( monkeypatch: pytest.MonkeyPatch, - color_profile: PdfColorProfile, + color_profile: PdfPresetColorProfile, ) -> 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()) payload_options: dict[str, Any] = { @@ -209,9 +204,6 @@ async def test_async_convert_colors_success( "preserve_black": False, } client_options: dict[str, Any] = {"color_profile": color_profile} - if color_profile == "custom": - payload_options["profile"] = profile_file - client_options["profile"] = profile_file payload_dump = PdfConvertColorsPayload.model_validate(payload_options).model_dump( mode="json", @@ -368,15 +360,12 @@ def test_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) -> None: with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValueError, match="requires a profile"), - ): - client.convert_colors(pdf_file, color_profile="custom") - - with ( - PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValueError, match="only be provided when color_profile"), + pytest.raises( + ValueError, + match="A custom color profile requires an uploaded ICC file", + ), ): - client.convert_colors(pdf_file, color_profile="srgb", profile=_make_icc_file()) + client.convert_colors(pdf_file, color_profile=cast(Any, "custom")) with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, @@ -386,8 +375,7 @@ def test_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) -> None: ): client.convert_colors( pdf_file, - color_profile="custom", - profile=[_make_icc_file(), _make_icc_file()], + color_profile=[_make_icc_file(), _make_icc_file()], ) with ( @@ -399,17 +387,32 @@ def test_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) -> None: color_profile=wrong_profile_file, ) - with ( - PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises( - ValueError, - match=r"Provide the custom profile file via color_profile only", - ), + +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", ): - client.convert_colors( - pdf_file, - color_profile=_make_icc_file(), - profile=_make_icc_file(), + PdfConvertColorsPayload.model_validate( + { + "files": [pdf_file], + "color_profile": "srgb", + "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(), + "profile": _make_icc_file(), + } ) @@ -445,23 +448,18 @@ async def test_async_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) color_profile="srgb", ) - with pytest.raises(ValueError, match="requires a profile"): - await client.convert_colors(pdf_file, color_profile="custom") - - with pytest.raises(ValueError, match="only be provided when color_profile"): - await client.convert_colors( - pdf_file, - color_profile="srgb", - profile=_make_icc_file(), - ) + 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="custom", - profile=[_make_icc_file(), _make_icc_file()], + color_profile=[_make_icc_file(), _make_icc_file()], ) with pytest.raises(ValidationError, match="Profile must be an ICC file"): @@ -470,16 +468,6 @@ async def test_async_convert_colors_validation(monkeypatch: pytest.MonkeyPatch) color_profile=wrong_profile_file, ) - with pytest.raises( - ValueError, - match=r"Provide the custom profile file via color_profile only", - ): - await client.convert_colors( - pdf_file, - color_profile=_make_icc_file(), - profile=_make_icc_file(), - ) - def test_convert_colors_rejects_invalid_color_profile( monkeypatch: pytest.MonkeyPatch, From 4337783ae06cae039448c54ed2bedb90ccf5baa5 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 17 Feb 2026 14:53:09 -0600 Subject: [PATCH 10/11] convert-colors: Remove additional, unnecessary instances of `profile` Assisted-by: Codex --- src/pdfrest/models/_internal.py | 11 +++++------ tests/test_convert_colors.py | 4 ++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 90217437..fd266eb9 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1818,13 +1818,12 @@ class PdfConvertColorsPayload(BaseModel): PdfConvertColorProfile, Field(serialization_alias="color_profile"), ] - profile: Annotated[ + custom_profile: Annotated[ list[PdfRestFile] | None, Field( default=None, min_length=1, max_length=1, - validation_alias=AliasChoices("profile", "profiles"), serialization_alias="profile_id", ), BeforeValidator(_ensure_list), @@ -1859,7 +1858,7 @@ def _normalize_color_profile(cls, value: Any) -> Any: if not _is_uploaded_file_value(color_profile): return payload - if payload.get("profile") is not None or payload.get("profiles") is not None: + if payload.get("custom_profile") is not None: msg = ( "Provide the custom profile file via color_profile only when " "color_profile is a file." @@ -1867,19 +1866,19 @@ def _normalize_color_profile(cls, value: Any) -> Any: raise ValueError(msg) payload["color_profile"] = "custom" - payload["profile"] = color_profile + 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.profile: + if not self.custom_profile: msg = ( "A custom color profile requires an uploaded ICC file passed " "as color_profile." ) raise ValueError(msg) - elif self.profile: + elif self.custom_profile: msg = ( "A profile can only be provided by passing an uploaded ICC file " "as color_profile." diff --git a/tests/test_convert_colors.py b/tests/test_convert_colors.py index fb17ba98..c71d8311 100644 --- a/tests/test_convert_colors.py +++ b/tests/test_convert_colors.py @@ -399,7 +399,7 @@ def test_convert_colors_payload_profile_dependency_validation() -> None: { "files": [pdf_file], "color_profile": "srgb", - "profile": _make_icc_file(), + "custom_profile": _make_icc_file(), } ) @@ -411,7 +411,7 @@ def test_convert_colors_payload_profile_dependency_validation() -> None: { "files": [pdf_file], "color_profile": _make_icc_file(), - "profile": _make_icc_file(), + "custom_profile": _make_icc_file(), } ) From ccafaf3421df64b9db5da4e528802ac54488e314 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 17 Feb 2026 15:38:05 -0600 Subject: [PATCH 11/11] tests: Stabilize live convert-colors custom-profile live tests Update sync and async custom-profile live tests to upload both the source PDF and ICC profile within the same client context that performs `convert_colors`. This avoids intermittent CI failures where reused uploaded IDs can be rejected by the server with "The ID is invalid." Assisted-by: Codex --- tests/live/test_live_convert_colors.py | 28 +++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/live/test_live_convert_colors.py b/tests/live/test_live_convert_colors.py index 79cdef01..9f9ebb2d 100644 --- a/tests/live/test_live_convert_colors.py +++ b/tests/live/test_live_convert_colors.py @@ -75,16 +75,18 @@ def test_live_convert_colors_color_profiles_success( def test_live_convert_colors_custom_profile_success( pdfrest_api_key: str, pdfrest_live_base_url: str, - uploaded_pdf_for_color_conversion: PdfRestFile, - uploaded_custom_profile_for_color_conversion: PdfRestFile, ) -> 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_for_color_conversion, - color_profile=uploaded_custom_profile_for_color_conversion, + uploaded_pdf, + color_profile=uploaded_profile, ) assert response.output_files @@ -93,8 +95,8 @@ def test_live_convert_colors_custom_profile_success( 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_for_color_conversion.id) in input_ids - assert str(uploaded_custom_profile_for_color_conversion.id) in input_ids + assert str(uploaded_pdf.id) in input_ids + assert str(uploaded_profile.id) in input_ids @pytest.mark.parametrize( @@ -167,16 +169,18 @@ async def test_live_async_convert_colors_color_profiles_success( async def test_live_async_convert_colors_custom_profile_success( pdfrest_api_key: str, pdfrest_live_base_url: str, - uploaded_pdf_for_color_conversion: PdfRestFile, - uploaded_custom_profile_for_color_conversion: PdfRestFile, ) -> 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_for_color_conversion, - color_profile=uploaded_custom_profile_for_color_conversion, + uploaded_pdf, + color_profile=uploaded_profile, ) assert response.output_files @@ -185,8 +189,8 @@ async def test_live_async_convert_colors_custom_profile_success( 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_for_color_conversion.id) in input_ids - assert str(uploaded_custom_profile_for_color_conversion.id) in input_ids + assert str(uploaded_pdf.id) in input_ids + assert str(uploaded_profile.id) in input_ids @pytest.mark.asyncio