From 23bd94056b1cd8f434f5ff13c1e3e80ef02e515b Mon Sep 17 00:00:00 2001 From: Mud_Mos23 <65009893+mud-mos23@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:38:35 +0200 Subject: [PATCH 1/4] Fix API client bugs in token cache, upload and status handling --- ilovepdf/file.py | 7 +++---- ilovepdf/ilovepdf_api.py | 35 +++++++++++++++++++++++++++++++---- ilovepdf/rotate_task.py | 7 ++++++- ilovepdf/task.py | 13 +++++++++---- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/ilovepdf/file.py b/ilovepdf/file.py index 9e21334..a38d642 100644 --- a/ilovepdf/file.py +++ b/ilovepdf/file.py @@ -1,6 +1,6 @@ """Module for managing files with the iLovePDF API.""" -import tempfile +import uuid from ilovepdf.validators import IntValidator, StringValidator @@ -81,10 +81,9 @@ def get_temp_filename(extension: str = "") -> str: extension (str): The file extension to use. Returns: - str: The path to the temporary file. + str: A unique filename that does not collide with existing files. """ - with tempfile.NamedTemporaryFile(suffix=extension) as temp_file: - return temp_file.name + return uuid.uuid4().hex + extension class File(BaseFile): diff --git a/ilovepdf/ilovepdf_api.py b/ilovepdf/ilovepdf_api.py index 9032232..da53289 100644 --- a/ilovepdf/ilovepdf_api.py +++ b/ilovepdf/ilovepdf_api.py @@ -586,6 +586,7 @@ def set_api_keys(self, public_key: str, secret_key: str) -> None: """ self.auth.public_key = public_key self.auth.secret_key = secret_key + self._clear_token_cache() def get_secret_key(self) -> str: """Get the secret key. @@ -950,25 +951,51 @@ def get_status(self, server: str, task_id: str) -> dict[str, Any]: Returns: dict[str, Any]: Task status information. + + Raises: + ProcessException: If the response body is not valid JSON. """ original_worker_server = self.get_worker_server() self.set_worker_server(server) - response = self.send_request("get", f"task/{task_id}") - self.set_worker_server(original_worker_server) - return response.json() + try: + response = self.send_request("get", f"task/{task_id}") + return self._parse_response_body(response) + finally: + self.set_worker_server(original_worker_server) def get_updated_info(self) -> dict[str, Any]: """Get updated information about the account. Returns: dict[str, Any]: Account information including remaining credits. + + Raises: + ProcessException: If the response body is not valid JSON. """ data = {"v": self.VERSION} body = {"data": data} response = self.send_request("get", "info", body) - self.info = response.json() + self.info = self._parse_response_body(response) return self.info + @staticmethod + def _parse_response_body(response: requests.Response) -> dict[str, Any]: + """Parse the JSON body of a successful API response. + + Args: + response (requests.Response): The HTTP response object. + + Returns: + dict[str, Any]: The parsed JSON body. + + Raises: + ProcessException: If the response body is not valid JSON. + """ + try: + return response.json() + except Exception as exc: + raise ProcessException("Invalid response body") from exc + def get_info(self) -> dict[str, Any]: """Get information about the account. diff --git a/ilovepdf/rotate_task.py b/ilovepdf/rotate_task.py index 5b787b2..5df7ea5 100644 --- a/ilovepdf/rotate_task.py +++ b/ilovepdf/rotate_task.py @@ -66,11 +66,16 @@ def add_file(self, *args, **kwargs) -> RotateFile: """ Adds a file to the rotate task. + Args: + *args: Positional arguments forwarded to the parent method. + **kwargs: Keyword arguments forwarded to the parent method. + Supports the extra keyword "rotate" to set the rotation angle. + Returns: RotateFile: The file object added to the task. """ + rotate = kwargs.pop("rotate", None) file = super().add_file(*args, **kwargs) - rotate = kwargs.get("rotate") if rotate is not None: file.rotate = rotate diff --git a/ilovepdf/task.py b/ilovepdf/task.py index 53d8a3d..1e8aab0 100644 --- a/ilovepdf/task.py +++ b/ilovepdf/task.py @@ -4,7 +4,7 @@ import re from collections.abc import Callable from typing import Any, Generic, TypeVar, cast -from urllib.parse import unquote +from urllib.parse import unquote, urlsplit from .abstract_task_element import AbstractTaskElement from .exceptions import ( @@ -652,7 +652,9 @@ def _get_file_from_upload_response(self, response, file_path) -> T_FILE: response_body = response.json() except Exception as exc: raise UploadException("Upload response error") from exc - filename = os.path.basename(file_path) or cls_file.get_temp_filename() + filename = ( + os.path.basename(urlsplit(file_path).path) or cls_file.get_temp_filename() + ) file = cls_file(response_body["server_filename"], filename) if "pdf_pages" in response_body: file.pdf_pages = response_body["pdf_pages"] @@ -701,7 +703,10 @@ def delete(self) -> "Task": """ self._state_manager.validate_task_started() response = self.send_request("delete", f"task/{self.get_task_id()}") - self.result = response.json() + try: + self.result = response.json() + except ValueError: + self.result = None return self def download(self, path: str | None = None) -> None: @@ -756,7 +761,7 @@ def execute(self) -> "Task": body = self._payload_builder.build_body(self.VERSION) endpoint = self._endpoint_execute response = self.send_request("post", endpoint, body) - self.result = response.json() + self.result = self._parse_response_body(response) # Update status after execution if self.result is not None: From 711e2a69a3c7e7e35c220acc0e5081bd987d0bce Mon Sep 17 00:00:00 2001 From: Mud_Mos23 <65009893+mud-mos23@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:38:39 +0200 Subject: [PATCH 2/4] Add unit tests covering recent API client fixes --- tests/unit/test_file.py | 22 +++++++ tests/unit/test_ilovepdf.py | 27 +++++++++ tests/unit/test_rotate_task.py | 33 +++++++++++ tests/unit/test_task_methods.py | 101 +++++++++++++++++++++++++++++++- 4 files changed, 182 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_file.py b/tests/unit/test_file.py index e931eac..c3b73bf 100644 --- a/tests/unit/test_file.py +++ b/tests/unit/test_file.py @@ -10,6 +10,8 @@ All tests use pytest and custom abstract test bases for consistency. """ +import os + import pytest from ilovepdf.file import BaseFile, File @@ -100,6 +102,26 @@ def test_missing_required_fields_raises(self, my_task): my_task, ["server_filename", "filename"] ) + def test_get_temp_filename_returns_unique_name(self): + """ + Test that get_temp_filename returns a non-empty unique name. + """ + assert BaseFile.get_temp_filename() + assert BaseFile.get_temp_filename() != BaseFile.get_temp_filename() + + def test_get_temp_filename_appends_extension(self): + """ + Test that get_temp_filename appends the given extension. + """ + assert BaseFile.get_temp_filename(".pdf").endswith(".pdf") + assert not BaseFile.get_temp_filename().endswith(".pdf") + + def test_get_temp_filename_does_not_exist_on_disk(self): + """ + Test that the returned name does not point to an existing file. + """ + assert not os.path.exists(BaseFile.get_temp_filename(".pdf")) + class TestFile(AbstractUnitFileTest, TestBaseFile): """ diff --git a/tests/unit/test_ilovepdf.py b/tests/unit/test_ilovepdf.py index 09c6e88..4b739a0 100644 --- a/tests/unit/test_ilovepdf.py +++ b/tests/unit/test_ilovepdf.py @@ -5,6 +5,7 @@ from pytest_mock import MockerFixture from ilovepdf.exceptions.auth_exception import AuthException +from ilovepdf.exceptions.process_exception import ProcessException from ilovepdf.ilovepdf_api import Ilovepdf VALID_SECRET_KEY = "a" * 32 @@ -31,6 +32,19 @@ def test_set_api_keys_stores_credentials(self): assert ilovepdf.get_public_key() == "my_public_key" assert ilovepdf.get_secret_key() == "my_secret_key" + def test_set_api_keys_clears_token_cache(self): + """Check that changing API keys invalidates the cached JWT token.""" + ilovepdf = Ilovepdf(public_key="public_key", secret_key=VALID_SECRET_KEY) + token1 = ilovepdf.get_token() + assert ilovepdf._is_token_cache_valid() + + ilovepdf.set_api_keys("new_public_key", VALID_SECRET_KEY) + + assert ilovepdf.auth.token_cache is None + assert not ilovepdf._is_token_cache_valid() + token2 = ilovepdf.get_token() + assert token1 != token2 + def test_get_token_returns_local_jwt(self): """Check that get_token returns a self-signed JWT with correct claims.""" ilovepdf = Ilovepdf(public_key="public_key", secret_key=VALID_SECRET_KEY) @@ -143,3 +157,16 @@ def test_send_request_does_not_retry_non_signature_auth_error( assert mock_token.call_count == 1 assert mock_request.call_count == 1 + + def test_get_updated_info_with_invalid_json_raises_process_exception( + self, mocker: MockerFixture + ): + """Check that get_updated_info raises ProcessException on invalid JSON.""" + mock_response = mocker.MagicMock() + mock_response.json.side_effect = Exception("Invalid JSON") + ilovepdf = Ilovepdf(public_key="public", secret_key="secret") + mocker.patch.object(ilovepdf, "send_request", return_value=mock_response) + + with pytest.raises(ProcessException) as excinfo: + ilovepdf.get_updated_info() + assert "Invalid response body" in str(excinfo.value) diff --git a/tests/unit/test_rotate_task.py b/tests/unit/test_rotate_task.py index d18baa1..edc15d9 100644 --- a/tests/unit/test_rotate_task.py +++ b/tests/unit/test_rotate_task.py @@ -1,6 +1,9 @@ """Unit tests for the RotateTask class in the ilovepdf module.""" +from unittest.mock import MagicMock + import pytest +from pytest_mock import MockerFixture from ilovepdf import RotateTask from ilovepdf.exceptions import InvalidChoiceError @@ -63,3 +66,33 @@ class TestRotateTask(AbstractUnitTaskTest): def test_init(self): """Test RotateTask initialization and inheritance.""" + assert self._task_class._tool == "rotate" + assert self._task_class.cls_file == RotateFile + + def test_add_file_pops_rotate_kwarg(self, mocker: MockerFixture, tmp_path): + """Test that the rotate kwarg is not sent in the upload body.""" + task = RotateTask("public_key", "secret_key", make_start=False) + task.set_task("task_123") + + test_file = tmp_path / "test.pdf" + test_file.write_bytes(b"fake pdf data") + + mock_response = MagicMock() + mock_send = mocker.patch.object( + task, "send_request", return_value=mock_response + ) + mocker.patch.object( + task._file_manager, + "process_upload_response", + return_value=RotateFile( + filename="test.pdf", server_filename="server_test.pdf" + ), + ) + + result = task.add_file(str(test_file), rotate=90) + + assert isinstance(result, RotateFile) + assert result.rotate == 90 + mock_send.assert_called_once() + body = mock_send.call_args[0][2] + assert "rotate" not in body["data"] diff --git a/tests/unit/test_task_methods.py b/tests/unit/test_task_methods.py index 8b55257..5c18170 100644 --- a/tests/unit/test_task_methods.py +++ b/tests/unit/test_task_methods.py @@ -11,7 +11,7 @@ from pytest_mock import MockerFixture from ilovepdf import Task -from ilovepdf.exceptions import StartException +from ilovepdf.exceptions import ProcessException, StartException from ilovepdf.file import File @@ -274,6 +274,33 @@ def test_get_status_without_task_raises_error(self): with pytest.raises(ValueError): task.get_status() + def test_get_status_restores_worker_server_on_success(self, mocker: MockerFixture): + """Test get_status restores the original worker server after success.""" + task = DummyTask("public_key", "secret_key") + task.set_worker_server("https://example.com") + task.set_task("task_123") + + mock_response = MagicMock() + mock_response.json.return_value = {"status": "done"} + mocker.patch.object(task, "send_request", return_value=mock_response) + + task.get_status(server="https://custom.com") + + assert task.get_worker_server() == "https://example.com" + + def test_get_status_restores_worker_server_on_error(self, mocker: MockerFixture): + """Test get_status restores the original worker server after an error.""" + task = DummyTask("public_key", "secret_key") + task.set_worker_server("https://example.com") + task.set_task("task_123") + + mocker.patch.object(task, "send_request", side_effect=RuntimeError("boom")) + + with pytest.raises(RuntimeError): + task.get_status(server="https://custom.com") + + assert task.get_worker_server() == "https://example.com" + class TestTaskDownload: """Unit tests for Task.download() method.""" @@ -387,6 +414,78 @@ def test_execute_returns_self(self, mocker: MockerFixture): assert result is task + def test_execute_with_invalid_json_raises_process_exception( + self, mocker: MockerFixture + ): + """Test that execute raises ProcessException on invalid JSON response.""" + task = DummyTask("public_key", "secret_key") + task.set_task("task_123") + task.set_worker_server("https://example.com") + + mock_response = MagicMock() + mock_response.json.side_effect = Exception("Invalid JSON") + mocker.patch.object(task, "send_request", return_value=mock_response) + + with pytest.raises(ProcessException) as excinfo: + task.execute() + assert "Invalid response body" in str(excinfo.value) + + +class TestTaskDelete: + """Unit tests for Task.delete() method.""" + + def test_delete_success(self, mocker: MockerFixture): + """Test successful task deletion.""" + task = DummyTask("public_key", "secret_key") + task.set_task("task_123") + + mock_response = MagicMock() + mock_response.json.return_value = {"status": "deleted"} + mocker.patch.object(task, "send_request", return_value=mock_response) + + result = task.delete() + + assert result is task + assert task.result == {"status": "deleted"} + + def test_delete_with_empty_body_does_not_raise(self, mocker: MockerFixture): + """Test that delete tolerates a response without a JSON body.""" + task = DummyTask("public_key", "secret_key") + task.set_task("task_123") + + mock_response = MagicMock() + mock_response.json.side_effect = ValueError("No JSON body") + mocker.patch.object(task, "send_request", return_value=mock_response) + + result = task.delete() + + assert result is task + assert task.result is None + + def test_delete_without_task_raises_error(self): + """Test delete raises error when task not started.""" + task = DummyTask("public_key", "secret_key") + + with pytest.raises(ValueError): + task.delete() + + +class TestTaskUploadUrl: + """Unit tests for Task.upload_url() method.""" + + def test_upload_url_strips_query_string_from_filename(self, mocker: MockerFixture): + """Test that query strings are removed from the resulting filename.""" + task = DummyTask("public_key", "secret_key") + + mock_response = MagicMock() + mock_response.json.return_value = {"server_filename": "srv.pdf"} + mocker.patch.object(task, "send_request", return_value=mock_response) + + file = task.upload_url("task_123", "https://example.com/docs/doc.pdf?token=abc") + + assert file.filename == "doc.pdf" + assert file.server_filename == "srv.pdf" + class TestTaskSetTask: """Unit tests for Task.set_task() method.""" From 04cff99c58d777b87ba4bf47962c1cf8940dd111 Mon Sep 17 00:00:00 2001 From: Mud_Mos23 <65009893+mud-mos23@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:38:44 +0200 Subject: [PATCH 3/4] Fix download examples and version in docs --- DEVELOPMENT.md | 2 +- ilovepdf/merge_task.py | 2 +- ilovepdf/pagenumbers_task.py | 2 +- ilovepdf/pdfmarkdown_task.py | 2 +- ilovepdf/pdfocr_task.py | 2 +- ilovepdf/pdftopdfa_task.py | 2 +- ilovepdf/protect_task.py | 2 +- ilovepdf/repair_task.py | 2 +- ilovepdf/watermark_task.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e64acaf..8fa58b2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -139,7 +139,7 @@ The version is defined as a **single source of truth** in `ilovepdf/__init__.py`: ```python -__version__ = "1.0.0" +__version__ = "1.1.0" ``` `pyproject.toml` declares the version as dynamic and reads it from there: diff --git a/ilovepdf/merge_task.py b/ilovepdf/merge_task.py index 985a1a5..02f319b 100644 --- a/ilovepdf/merge_task.py +++ b/ilovepdf/merge_task.py @@ -22,7 +22,7 @@ class MergeTask(Task): merge_task.add_file('file1.pdf') merge_task.add_file('file2.pdf') merge_task.execute() - merge_task.download('merged.pdf') + merge_task.download('output_folder') """ _tool = "merge" diff --git a/ilovepdf/pagenumbers_task.py b/ilovepdf/pagenumbers_task.py index 077205a..b08f8e2 100644 --- a/ilovepdf/pagenumbers_task.py +++ b/ilovepdf/pagenumbers_task.py @@ -64,7 +64,7 @@ class PageNumbersTask(Task): task.font_size = 12 task.font_color = "#FF0000" task.execute() - task.download("/path/to/output.pdf") + task.download("/path/to/output_folder") """ _tool = "pagenumber" diff --git a/ilovepdf/pdfmarkdown_task.py b/ilovepdf/pdfmarkdown_task.py index c798e92..3af6cd0 100644 --- a/ilovepdf/pdfmarkdown_task.py +++ b/ilovepdf/pdfmarkdown_task.py @@ -18,7 +18,7 @@ class PdfMarkdownTask(Task): ) task.add_file("/path/to/document.pdf") task.execute() - task.download("/path/to/output.md") + task.download("/path/to/output_folder") """ _tool = "pdfmarkdown" diff --git a/ilovepdf/pdfocr_task.py b/ilovepdf/pdfocr_task.py index cb7f70c..d62e356 100644 --- a/ilovepdf/pdfocr_task.py +++ b/ilovepdf/pdfocr_task.py @@ -313,7 +313,7 @@ class PdfOcrTask(Task[OcrFile]): task = PdfOcrTask(public_key, secret_key) task.add_file("document.pdf") task.execute() - task.download("document_ocr.pdf") + task.download("output_folder") """ _tool = "pdfocr" diff --git a/ilovepdf/pdftopdfa_task.py b/ilovepdf/pdftopdfa_task.py index e87ae29..2845ab9 100644 --- a/ilovepdf/pdftopdfa_task.py +++ b/ilovepdf/pdftopdfa_task.py @@ -44,7 +44,7 @@ class PdfToPdfATask(Task): task.add_file("/path/to/document.pdf") task.conformance = "pdfa-1a" task.execute() - task.download("/path/to/output.pdf") + task.download("/path/to/output_folder") """ _tool = "pdfa" diff --git a/ilovepdf/protect_task.py b/ilovepdf/protect_task.py index f73612d..1ed5c9d 100644 --- a/ilovepdf/protect_task.py +++ b/ilovepdf/protect_task.py @@ -19,7 +19,7 @@ class ProtectTask(Task): task.add_file("/path/to/document.pdf") task.password = "mysecurepassword" task.execute() - task.download("/path/to/output.pdf") + task.download("/path/to/output_folder") """ _tool = "protect" diff --git a/ilovepdf/repair_task.py b/ilovepdf/repair_task.py index cfb5b06..31da656 100644 --- a/ilovepdf/repair_task.py +++ b/ilovepdf/repair_task.py @@ -16,7 +16,7 @@ class RepairTask(Task): task = RepairTask(public_key="your_public_key", secret_key="your_secret") task.add_file("/path/to/corrupted.pdf") task.execute() - task.download("/path/to/repaired.pdf") + task.download("/path/to/output_folder") """ _tool = "repair" diff --git a/ilovepdf/watermark_task.py b/ilovepdf/watermark_task.py index 3ee4ec3..76159e9 100644 --- a/ilovepdf/watermark_task.py +++ b/ilovepdf/watermark_task.py @@ -52,7 +52,7 @@ class WatermarkTask(Task): task.font_size = 28 task.transparency = 60 task.execute() - task.download("/path/to/output.pdf") + task.download("/path/to/output_folder") """ _tool = "watermark" From 9d52e8d4447758f4f772dc5f4ca45bdff87536a4 Mon Sep 17 00:00:00 2001 From: Mud_Mos23 <65009893+mud-mos23@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:40:10 +0200 Subject: [PATCH 4/4] Add Unreleased changelog entry for bug fixes --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4853e25..c7885d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- `rotate` keyword in `RotateTask.add_file` no longer leaks into the upload request body. +- `Ilovepdf.set_api_keys` now invalidates the cached JWT token. +- `File.get_temp_filename` returns a unique name instead of a path to a deleted temporary file. +- `delete`, `execute`, `get_status` and `get_updated_info` raise `ProcessException` on invalid JSON responses instead of a raw `JSONDecodeError`. +- `Ilovepdf.get_status` restores the original worker server even when the request fails. +- Cloud file uploads with a query string in the URL now produce clean filenames. +- Corrected `download` examples in docstrings (folder path instead of file path). + ## [1.1.0] - 2026-07-24 ### Added