Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 3 additions & 4 deletions ilovepdf/file.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Module for managing files with the iLovePDF API."""

import tempfile
import uuid

from ilovepdf.validators import IntValidator, StringValidator

Expand Down Expand Up @@ -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):
Expand Down
35 changes: 31 additions & 4 deletions ilovepdf/ilovepdf_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion ilovepdf/merge_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion ilovepdf/pagenumbers_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion ilovepdf/pdfmarkdown_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion ilovepdf/pdfocr_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion ilovepdf/pdftopdfa_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion ilovepdf/protect_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion ilovepdf/repair_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion ilovepdf/rotate_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 9 additions & 4 deletions ilovepdf/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion ilovepdf/watermark_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/test_ilovepdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
33 changes: 33 additions & 0 deletions tests/unit/test_rotate_task.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"]
Loading