diff --git a/.gitignore b/.gitignore
index d1f3c83..54d2425 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Environment variables
.env
+.in
# Python
__pycache__/
diff --git a/README.md b/README.md
index 9c66250..a46fdb3 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,7 @@ This repository demonstrates complete business workflows through working code ex
- **redact_by_keyword.py** - Finds and permanently redacts specific keywords across documents
- **bulk_password_protect.py** - Adds password protection to multiple PDFs in batch
- **prepare_pdf_for_distribution.py** - Marketing workflow that converts Word to PDF, compresses files, and removes metadata for external sharing
+- **optimize_benchmark.py** - Evaluation workflow that runs a folder of PDFs through the Optimize API and reports per-file size reduction in CSVs and a shareable, self-contained HTML report
### eSignature Example
- **employee_policy_onboarding.py** - HR workflow that reads employee data from CSV, creates signature envelopes with policy documents, sends for signing, tracks status, and downloads signed documents organized by employee
diff --git a/samples/python/README.md b/samples/python/README.md
index e3ddfeb..17fc7d5 100644
--- a/samples/python/README.md
+++ b/samples/python/README.md
@@ -148,6 +148,7 @@ api/
- `batch_process.py` - Batch convert documents
- `bulk_password_protect.py` - Password protect multiple PDFs
- `prepare_pdf_for_distribution.py` - Prepare PDFs for external distribution (convert, compress, remove metadata)
+- `optimize_benchmark.py` - Benchmark the Optimize API on a folder of PDFs, with CSVs and a self-contained HTML report
### Sign API Tools (eSignature)
- `employee_policy_onboarding.py` - Complete HR workflow: send policy documents to employees for signature
@@ -199,6 +200,22 @@ uv run python batch_process.py ./input ./output pdf "*.docx"
task batch INPUT_DIR=./input OUTPUT_DIR=./output FORMAT=pdf PATTERN='*.docx'
```
+### Benchmark PDF Compression
+```bash
+# Run every PDF in a folder through the Optimize API (default profile: minimal-file-size)
+uv run python optimize_benchmark.py ./pdfs ./output
+
+# Benchmark several profiles side by side
+uv run python optimize_benchmark.py ./pdfs ./output -p minimal-file-size -p web
+
+# Or use Task command
+task optimize-benchmark INPUT_DIR=./pdfs OUTPUT_DIR=./output
+```
+
+The run writes the optimized PDFs, `results.csv`, `summary.csv` and a
+self-contained `report.html` (headline numbers, a size-reduction distribution
+chart and a filterable per-file table) that opens in your browser when done.
+
## Using the API Client
### Platform API Client
diff --git a/samples/python/Taskfile.yml b/samples/python/Taskfile.yml
index 3e34453..c968dca 100644
--- a/samples/python/Taskfile.yml
+++ b/samples/python/Taskfile.yml
@@ -48,6 +48,13 @@ tasks:
requires:
vars: [INPUT, OUTPUT, KEYWORDS]
+ optimize-benchmark:
+ desc: "Benchmark the Optimize API on a folder of PDFs, with an HTML report (e.g., task optimize-benchmark INPUT_DIR=./pdfs OUTPUT_DIR=./output)"
+ cmds:
+ - uv run ./optimize_benchmark.py "{{.INPUT_DIR}}" "{{.OUTPUT_DIR}}"
+ requires:
+ vars: [INPUT_DIR, OUTPUT_DIR]
+
install:
desc: "Install Python dependencies using uv"
cmds:
diff --git a/samples/python/api/__init__.py b/samples/python/api/__init__.py
index d88fe23..73fcb1b 100644
--- a/samples/python/api/__init__.py
+++ b/samples/python/api/__init__.py
@@ -1,7 +1,7 @@
"""API clients for Nitro Platform integrations."""
-from .base_client import BaseOAuthClient
-from .platform_api import PlatformAPIClient
+from .base_client import BaseOAuthClient, FatalError
+from .platform_api import JobFailedError, PlatformAPIClient
from .sign_api import SignAPIClient
-__all__ = ["BaseOAuthClient", "PlatformAPIClient", "SignAPIClient"]
+__all__ = ["BaseOAuthClient", "FatalError", "JobFailedError", "PlatformAPIClient", "SignAPIClient"]
diff --git a/samples/python/api/base_client.py b/samples/python/api/base_client.py
index bab76fd..ebce9f4 100644
--- a/samples/python/api/base_client.py
+++ b/samples/python/api/base_client.py
@@ -1,33 +1,45 @@
"""Base client for API authentication with OAuth2."""
-from __future__ import annotations
-
-import time
+import contextlib
+import json
+from collections.abc import Generator
from dataclasses import dataclass, field
-from typing import Protocol
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from typing import Self
-import httpx
-from pydantic import BaseModel, Field
+import httpx2
+import typer
+from pydantic import BaseModel, ConfigDict, Field, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
-
-TOKEN_EXPIRY_BUFFER_SECONDS = 60
+from rich.progress import (
+ BarColumn,
+ DownloadColumn,
+ Progress,
+ SpinnerColumn,
+ TextColumn,
+ TransferSpeedColumn,
+ wrap_file,
+)
class TokenResponse(BaseModel):
- """OAuth2 token response model."""
+ """OAuth2 token response model, with the absolute UTC instant it expires at.
- model_config = {"populate_by_name": True}
+ ``expiry`` isn't part of the wire response; it's derived from
+ ``expires_in`` right after parsing, using a ``buffer_seconds`` value
+ passed in via validation context (see ``NitroClientCredentialsAuth``),
+ so the access token and its expiry travel together as a single object.
+ """
access_token: str = Field(alias="accessToken")
expires_in: int = Field(default=3600, alias="expiresIn")
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
-
-class _SettingsProtocol(Protocol): # pylint: disable=too-few-public-methods
- """Protocol defining required settings for OAuth clients."""
-
- platform_client_id: str
- platform_client_secret: str
- platform_base_url: str
+ @computed_field
+ @property
+ def expiry(self) -> datetime:
+ return self.created_at + timedelta(seconds=self.expires_in)
class Settings(BaseSettings):
@@ -40,42 +52,183 @@ class Settings(BaseSettings):
platform_base_url: str = "https://api.gonitro.dev"
+@dataclass(slots=True)
+class NitroClientCredentialsAuthSettings:
+ """Tunables for the OAuth2 client-credentials auth flow."""
+
+ token_expiry_buffer: timedelta = timedelta(seconds=60)
+
+
+@dataclass(slots=True)
+class URLFile:
+ """A file referenced by a presigned download URL, ready to submit as a multipart part."""
+
+ url: str
+ content_type: str
+ name: str = "file"
+
+ def to_file_part(self) -> tuple[str, bytes, str]:
+ """Return the (name, content, content-type) multipart part for this file reference."""
+ payload = json.dumps({"URL": self.url, "contentType": self.content_type})
+ return self.name, payload.encode(), "application/vnd.gonitro.url+json"
+
+
+class _PresignedURLPair(BaseModel):
+ """One presigned URL pair for uploading a file and referencing it back by URL."""
+
+ model_config = ConfigDict(extra="ignore", frozen=True)
+
+ upload_url: str = Field(alias="uploadURL")
+ download_url: str = Field(alias="downloadURL")
+
+
+class _PresignedURLPairsResponse(BaseModel):
+ model_config = ConfigDict(extra="ignore", frozen=True)
+
+ urls: list[_PresignedURLPair]
+
+
@dataclass
-class BaseOAuthClient:
- """Base class for API clients with OAuth2 authentication."""
+class FatalError(SystemExit):
+ _reason: str
+
+ def __post_init__(self) -> None:
+ self.code = 1
+ typer.secho(f"\nError: {self._reason}", fg=typer.colors.RED, err=True)
+
- _settings: _SettingsProtocol = field(
- default_factory=lambda: Settings() # type: ignore[reportCallIssue] # pylint: disable=unnecessary-lambda
+@dataclass
+class NitroClientCredentialsAuth(httpx2.Auth):
+ """httpx2 auth flow for OAuth2 client-credentials.
+
+ Fetches and caches a bearer token on first use, and re-authenticates once
+ (fetching a fresh token and retrying) if a request comes back 401.
+ """
+
+ _client_id: str
+ _client_secret: str
+ _base_url: str
+ _settings: NitroClientCredentialsAuthSettings = field(
+ default_factory=NitroClientCredentialsAuthSettings
)
- _token: str | None = field(default=None, init=False)
- _token_expiry: float = field(default=0, init=False)
- _client: httpx.Client = field(default_factory=httpx.Client, init=False)
+ _cached_token: TokenResponse | None = None
+
+ def _fetch_token(self) -> str:
+ """Fetch, cache and return a fresh access token from the token endpoint."""
+ with httpx2.Client() as token_client:
+ response = token_client.post(
+ f"{self._base_url}/oauth/token",
+ json={"clientID": self._client_id, "clientSecret": self._client_secret},
+ )
+ if response.status_code != httpx2.codes.OK.value:
+ raise FatalError("Auth failed, check client credentials and try again")
+ response.raise_for_status()
+
+ token_data = TokenResponse.model_validate_json(response.content)
+ self._cached_token = token_data
+ return token_data.access_token
def _get_token(self) -> str:
- """Get or refresh OAuth2 access token."""
- if self._token and time.time() < self._token_expiry:
- return self._token
-
- response = self._client.post(
- f"{self._settings.platform_base_url}/oauth/token",
- json={
- "clientID": self._settings.platform_client_id,
- "clientSecret": self._settings.platform_client_secret,
- },
- )
+ """Return the cached token, fetching a fresh one if missing or expired."""
+ cached = self._cached_token
+ if cached and datetime.now(UTC) + self._settings.token_expiry_buffer < cached.expiry:
+ return cached.access_token
+ return self._fetch_token()
+
+ def auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, httpx2.Response]:
+ """Attach a bearer token, refreshing once and retrying on a 401."""
+ request.headers["Authorization"] = f"Bearer {self._get_token()}"
+ response = yield request
+ if response.status_code == httpx2.codes.UNAUTHORIZED.value:
+ request.headers["Authorization"] = f"Bearer {self._fetch_token()}" # Fetch a new token
+ yield request
+
+
+@dataclass
+class BaseOAuthClient:
+ """Base class for API clients with OAuth2 authentication."""
+
+ _client: httpx2.Client
+ _raw_client: httpx2.Client
+
+ def _upload_to_url(self, url: str, file_path: Path) -> None:
+ """Stream file_path to an arbitrary URL (e.g. a presigned upload URL), showing a
+ progress bar. No platform auth is attached."""
+ total = file_path.stat().st_size
+ with (
+ file_path.open("rb") as fp,
+ wrap_file(
+ fp, total, description=f"Uploading {file_path.name}...", transient=True
+ ) as stream,
+ ):
+ response = self._raw_client.put(
+ url, content=stream, headers={"Content-Length": str(total)}
+ )
response.raise_for_status()
- # Use Pydantic model for type-safe response parsing
- token_data = TokenResponse.model_validate_json(response.content)
+ @staticmethod
+ def _stream_with_progress(response: httpx2.Response, description: str) -> bytes:
+ """Consume an already-open streaming response into bytes, showing a progress bar."""
+ total = int(response.headers.get("content-length", 0)) or None
+ with Progress(
+ SpinnerColumn(),
+ TextColumn("[progress.description]{task.description}"),
+ BarColumn(),
+ DownloadColumn(),
+ TransferSpeedColumn(),
+ transient=True,
+ ) as progress:
+ task = progress.add_task(description, total=total)
+ buf = bytearray()
+ for chunk in response.iter_bytes():
+ buf += chunk
+ progress.update(task, advance=len(chunk))
+ return bytes(buf)
+
+ def _download_from_url(self, url: str, description: str = "Downloading...") -> bytes:
+ """GET an arbitrary URL (e.g. a presigned download URL) with no platform auth attached,
+ showing a progress bar."""
+ with self._raw_client.stream("GET", url) as response:
+ response.raise_for_status()
+ return self._stream_with_progress(response, description)
+
+ def _upload(
+ self, file_path: Path, content_type: str, name: str = "file"
+ ) -> tuple[str, bytes, str]:
+ """Upload file_path to a fresh presigned URL and return the resulting ``file`` multipart
+ part.
+
+ Presigned pairs are single-use and expire after 15 minutes, so a fresh
+ pair is minted right before each upload rather than cached or reused.
+ """
+ response = self._client.post("/presigned-file-urls", params={"n": 1})
+ response.raise_for_status()
+ presigned = _PresignedURLPairsResponse.model_validate_json(response.content).urls[0]
+ self._upload_to_url(presigned.upload_url, file_path)
+ url_file = URLFile(url=presigned.download_url, content_type=content_type, name=name)
+ return url_file.to_file_part()
- self._token = token_data.access_token
- self._token_expiry = time.time() + token_data.expires_in - TOKEN_EXPIRY_BUFFER_SECONDS
- return token_data.access_token
+ @classmethod
+ @contextlib.contextmanager
+ def build(cls) -> Generator[Self]:
+ """Build a client with two httpx2.Clients: one authenticated against the
+ platform, one plain for arbitrary URLs (e.g. presigned upload/download
+ URLs) that must never get the platform's bearer token attached.
- def get_token(self) -> str:
- """Public method to get authentication token.
+ Client credentials and base URL are loaded from the environment/.env
+ file.
Returns:
- OAuth2 access token for API authentication.
+ A ready-to-use client instance.
"""
- return self._get_token()
+ settings = Settings() # type: ignore[reportCallIssue]
+ auth = NitroClientCredentialsAuth(
+ settings.platform_client_id,
+ settings.platform_client_secret,
+ settings.platform_base_url,
+ )
+ with (
+ httpx2.Client(auth=auth, base_url=settings.platform_base_url) as client,
+ httpx2.Client() as raw_client,
+ ):
+ yield cls(client, raw_client)
diff --git a/samples/python/api/platform_api.py b/samples/python/api/platform_api.py
index 4fa13a1..bef8f28 100644
--- a/samples/python/api/platform_api.py
+++ b/samples/python/api/platform_api.py
@@ -1,24 +1,149 @@
#!/usr/bin/env python
"""Platform API client for Nitro Platform integrations."""
-from __future__ import annotations
-
import json
-import mimetypes
+import uuid
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Literal
+from typing import TYPE_CHECKING, Annotated, Any, Literal
-import httpx
+import httpx2
+from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
+from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
-from .base_client import BaseOAuthClient
+from .base_client import BaseOAuthClient, FatalError
if TYPE_CHECKING:
+ from collections.abc import Iterator
from pathlib import Path
+ from httpx2._types import RequestFiles as FilesParam
+
+
+class _JobEventBase(BaseModel):
+ """Fields shared by every job status-stream SSE event."""
+
+ model_config = ConfigDict(extra="ignore", frozen=True)
+
+
+class ProgressUpdate(_JobEventBase):
+ """The job is still running, with a progress fraction."""
+
+ event: Literal["progress-update"]
+ status: Literal["running"]
+ progress: float = Field(ge=0.0, le=1.0)
+
+
+class StatusUpdate(_JobEventBase):
+ """A plain status update; ``progress`` is only present while running."""
+
+ event: Literal["status-update"]
+ status: Literal["running", "completed", "failed"]
+ progress: float | None = Field(default=None, ge=0.0, le=1.0)
+
+
+class Redirect(_JobEventBase):
+ """The terminal event: where to fetch the result or the error detail."""
+
+ event: Literal["redirect"]
+ status: Literal["completed", "failed"]
+ location: str
+
+
+type JobEvent = Annotated[ProgressUpdate | StatusUpdate | Redirect, Field(discriminator="event")]
+
+
+class JobFailedError(RuntimeError):
+ """An asynchronous Platform API job failed.
+
+ Carries the detail needed to diagnose the failure: the HTTP status, the
+ error message returned by the API, and the request ID to quote to support.
+ """
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ status_code: int | None = None,
+ error_type: str | None = None,
+ request_id: str | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.message = message
+ self.status_code = status_code
+ self.error_type = error_type
+ self.request_id = request_id
+
+
+class ProblemDetail(BaseModel):
+ """A Platform API error body: either ``{type, title}`` directly, or nested under ``error``."""
+
+ model_config = ConfigDict(extra="ignore", frozen=True)
+
+ type: str | None = None
+ title: str | None = None
+ error: ProblemDetail | None = None
+
+
+def _problem_detail(body: bytes) -> tuple[str | None, str | None]:
+ """Pull (error type, human message) out of a Platform API error body."""
+ try:
+ problem = ProblemDetail.model_validate_json(body)
+ except ValidationError:
+ return None, None
+ if problem.error is not None:
+ return problem.error.type, problem.error.title
+ return problem.type, problem.title
+
+
+def _get_mime_type_from_path(path: Path) -> str:
+ """Look up the standard MIME type for a file's extension."""
+ extension_mime_type_map = {
+ "pdf": "application/pdf",
+ "doc": "application/msword",
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "xls": "application/vnd.ms-excel",
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "ppt": "application/vnd.ms-powerpoint",
+ "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "png": "image/png",
+ "jpg": "image/jpeg",
+ "jpeg": "image/jpeg",
+ "txt": "text/plain",
+ "csv": "text/csv",
+ "html": "text/html",
+ "htm": "text/html",
+ "zip": "application/zip",
+ }
+ extension = path.suffix.lower().removeprefix(".")
+ if not extension:
+ raise FatalError(
+ f"{path.name!r} has no file extension; give it a proper name — "
+ "we can't guess a content type without one."
+ )
+ mime_type = extension_mime_type_map.get(extension)
+ if mime_type is None:
+ raise FatalError(f"Unsupported file type: .{extension}")
+ return mime_type
+
@dataclass
class PlatformAPIClient(BaseOAuthClient):
- """Synchronous client for Nitro Platform API operations."""
+ """Client for Nitro Platform API operations.
+
+ Almost every operation goes through the async job flow (submit with
+ ``Prefer: respond-async``, follow the SSE status stream, fetch the
+ result) with the file uploaded via a presigned URL first — see
+ ``_submit_async_job``. That combination streams the upload instead of
+ buffering it in memory and won't time out on a slow job, so it's the
+ right default even for small files.
+
+ The synchronous, non-presigned ``_request`` path (used only by
+ ``extract_text``) is the deliberate exception: it's for operations you
+ know will always run against small, bounded documents — e.g. pulling
+ text out of a one-page expense report you know will always be a few KB
+ — where the extra round trip to mint a presigned URL and poll a job
+ isn't worth it.
+ """
def _request(
self,
@@ -27,96 +152,222 @@ def _request(
file_path: Path,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
- """Make API request with file upload."""
- headers = {"Authorization": f"Bearer {self._get_token()}"}
+ """Make a synchronous request, uploading the file directly rather than via a
+ presigned URL. See the class docstring for when this is (and isn't) appropriate.
+ """
+ response = self._client.post(
+ endpoint,
+ files={
+ "file": (
+ file_path.name,
+ file_path.read_bytes(),
+ _get_mime_type_from_path(file_path),
+ )
+ },
+ data={"method": method, "params": json.dumps(params or {})},
+ )
+ response.raise_for_status()
+ return response.json()
+
+ def _iter_job_events(
+ self, status_url: str, request_id: str, *, job_timeout_seconds: float = 300
+ ) -> Iterator[JobEvent]:
+ """Yield job events from the server-sent-events status stream."""
+ headers = {"X-Analytics-Session-Id": request_id}
+ type_adapter = TypeAdapter[JobEvent](JobEvent)
+ timeout = httpx2.Timeout(30.0, read=job_timeout_seconds)
+ with self._client.sse(status_url, headers=headers, timeout=timeout) as event_source:
+ response = event_source.response
+ if response.status_code != httpx2.codes.OK.value:
+ response.read()
+ error_type, title = _problem_detail(response.content)
+ raise JobFailedError(
+ title or f"Job status request failed with HTTP {response.status_code}",
+ status_code=response.status_code,
+ error_type=error_type,
+ request_id=request_id,
+ )
+ for sse in event_source:
+ yield type_adapter.validate_json(sse.data)
- # Detect MIME type or use octet-stream as fallback
- mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
+ def _await_job(
+ self, status_url: str, request_id: str, *, description: str = "Running..."
+ ) -> Redirect:
+ """Follow a job to completion, showing a progress bar. Returns the terminal event."""
+ with Progress(
+ SpinnerColumn(),
+ TextColumn("[progress.description]{task.description}"),
+ BarColumn(),
+ TaskProgressColumn(),
+ transient=True,
+ ) as progress:
+ task = progress.add_task(description, total=1.0)
+ for event in self._iter_job_events(status_url, request_id):
+ if isinstance(event, Redirect):
+ progress.update(task, completed=1.0)
+ return event
+ if event.progress is not None:
+ progress.update(task, completed=event.progress)
+ raise JobFailedError(
+ "The job status stream closed before the job finished.",
+ request_id=request_id,
+ )
+
+ def _submit_async_job(
+ self,
+ endpoint: Literal["conversions", "extractions", "transformations"],
+ method: str,
+ files: FilesParam,
+ params: dict[str, Any] | None = None,
+ *,
+ description: str,
+ ) -> dict[str, Any]:
+ """Submit an operation as an asynchronous job, follow it to completion, and return the
+ parsed JSON result.
- # Read file into memory and include content-type
- files = {"file": (file_path.name, file_path.read_bytes(), mime_type)}
- data = {"method": method, "params": json.dumps(params or {})}
+ Submits the work with ``Prefer: respond-async`` and follows the job to
+ completion via the SSE status stream, so documents large enough to
+ exceed the synchronous request window are processed successfully
+ instead of timing out on the client.
+ Raises:
+ JobFailedError: if the submission, the job, or the result fetch fails.
+ """
+ request_id = str(uuid.uuid7())
response = self._client.post(
- f"{self._settings.platform_base_url}/{endpoint}",
- headers=headers,
+ endpoint,
+ headers={"Prefer": "respond-async", "X-Analytics-Session-Id": request_id},
files=files,
- data=data,
+ data={"method": method, "params": json.dumps(params or {})},
)
+ if response.status_code != httpx2.codes.ACCEPTED.value:
+ error_type, title = _problem_detail(response.content)
+ raise JobFailedError(
+ title or f"Job submission failed with HTTP {response.status_code}",
+ status_code=response.status_code,
+ error_type=error_type,
+ request_id=request_id,
+ )
- response.raise_for_status()
- return response.json()
+ status_url = response.headers["Location"]
+ redirect = self._await_job(status_url, request_id, description=description)
+ analytics_header = {"X-Analytics-Session-Id": request_id}
- def _request_bytes(
+ if redirect.status == "failed":
+ error = self._client.get(redirect.location, headers=analytics_header)
+ error_type, title = _problem_detail(error.content)
+ raise JobFailedError(
+ title or "The job failed.",
+ status_code=error.status_code,
+ error_type=error_type,
+ request_id=request_id,
+ )
+
+ result = self._client.get(redirect.location, headers=analytics_header)
+ if result.status_code != httpx2.codes.OK.value:
+ error_type, title = _problem_detail(result.content)
+ raise JobFailedError(
+ title or f"Result fetch failed with HTTP {result.status_code}",
+ status_code=result.status_code,
+ error_type=error_type,
+ request_id=request_id,
+ )
+ return result.json()
+
+ def _request_async(
self,
endpoint: Literal["conversions", "extractions", "transformations"],
method: str,
file_path: Path,
params: dict[str, Any] | None = None,
- ) -> bytes:
- """Make API request and return raw bytes."""
- headers = {"Authorization": f"Bearer {self._get_token()}"}
+ ) -> dict[str, Any]:
+ """Run an operation as an asynchronous job and return the parsed JSON result."""
+ files = {
+ "file": self._upload(file_path, _get_mime_type_from_path(file_path), file_path.name)
+ }
+ return self._submit_async_job(
+ endpoint, method, files, params, description=f"Running {file_path.name}..."
+ )
- # Detect MIME type or use octet-stream as fallback
- mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
+ def _request_async_bytes(
+ self,
+ endpoint: Literal["conversions", "extractions", "transformations"],
+ method: str,
+ file_path: Path,
+ params: dict[str, Any] | None = None,
+ ) -> bytes:
+ """Run an operation as an asynchronous job and return the resulting bytes."""
+ files = {
+ "file": self._upload(file_path, _get_mime_type_from_path(file_path), file_path.name)
+ }
+ result = self._submit_async_job(
+ endpoint, method, files, params, description=f"Running {file_path.name}..."
+ )
+ download_url = result["result"]["file"]["URL"]
+ return self._download_from_url(
+ download_url, description=f"Downloading result for {file_path.name}..."
+ )
- # Read file into memory and include content-type
- files = {"file": (file_path.name, file_path.read_bytes(), mime_type)}
- data = {"method": method, "params": json.dumps(params or {})}
+ def optimize(self, file_path: Path, profile: str = "minimal-file-size") -> bytes:
+ """Optimize (compress) a PDF using an optimization profile.
- response = self._client.post(
- f"{self._settings.platform_base_url}/{endpoint}",
- headers=headers,
- files=files,
- data=data,
- )
+ Profiles are ``minimal-file-size``, ``web``, ``print``, ``archive`` and
+ ``mixed-raster-content``. This runs as an asynchronous job, so it works
+ for large documents as well as small ones.
- response.raise_for_status()
- result = response.json()
+ Args:
+ file_path: The PDF to optimize.
+ profile: The optimization profile to apply.
- # Download from S3 URL
- download_url = result["result"]["file"]["URL"]
- download_response = httpx.get(download_url)
- download_response.raise_for_status()
- return download_response.content
+ Returns:
+ The optimized PDF as bytes.
+ """
+ return self._request_async_bytes(
+ "transformations", "optimize", file_path, {"profile": profile}
+ )
def convert(self, file_path: Path, to_format: str) -> bytes:
"""Convert document to specified format."""
- return self._request_bytes("conversions", "convert", file_path, {"to": to_format})
+ return self._request_async_bytes("conversions", "convert", file_path, {"to": to_format})
def extract_text(self, file_path: Path) -> dict[str, Any]:
- """Extract text from document."""
+ """Extract text from document.
+
+ Runs synchronously without a presigned URL — see the class
+ docstring for why this operation is the exception to the async +
+ presigned-URL default.
+ """
return self._request("extractions", "extract-text", file_path)
def extract_forms(self, file_path: Path) -> dict[str, Any]:
"""Extract form data from PDF."""
- return self._request("extractions", "extract-forms", file_path)
+ return self._request_async("extractions", "extract-forms", file_path)
def extract_tables(self, file_path: Path) -> dict[str, Any]:
"""Extract table data from PDF."""
- return self._request("extractions", "extract-tables", file_path)
+ return self._request_async("extractions", "extract-tables", file_path)
def detect_pii(self, file_path: Path, language: str = "en") -> dict[str, Any]:
"""Detect PII and return bounding boxes."""
- return self._request(
+ return self._request_async(
"extractions", "extract-pii-bounding-boxes", file_path, {"language": language}
)
def find_text_boxes(self, file_path: Path, texts: list[str]) -> dict[str, Any]:
"""Find bounding boxes for specified text strings."""
- return self._request(
+ return self._request_async(
"extractions", "extract-text-bounding-boxes", file_path, {"texts": texts}
)
def redact(self, file_path: Path, redactions: list[dict[str, Any]]) -> bytes:
"""Redact specified bounding boxes."""
- return self._request_bytes(
+ return self._request_async_bytes(
"transformations", "redact", file_path, {"redactions": redactions}
)
def password_protect(self, file_path: Path, password: str) -> bytes:
"""Add password protection to PDF."""
- return self._request_bytes(
+ return self._request_async_bytes(
"transformations",
"protect",
file_path,
@@ -125,32 +376,30 @@ def password_protect(self, file_path: Path, password: str) -> bytes:
def compress(self, file_path: Path, level: int = 2) -> bytes:
"""Compress PDF (level 1-3)."""
- return self._request_bytes("transformations", "compress", file_path, {"level": level})
+ return self._request_async_bytes(
+ "transformations", "compress", file_path, {"level": level}
+ )
def set_properties(self, file_path: Path, properties: dict[str, str]) -> bytes:
"""Set or clear PDF metadata properties."""
- return self._request_bytes(
+ return self._request_async_bytes(
"transformations", "set-properties", file_path, properties
)
def merge(self, file_paths: list[Path]) -> bytes:
"""Merge multiple PDFs."""
- headers = {"Authorization": f"Bearer {self._get_token()}"}
-
- # Open files with context manager
- opened_files = [fp.open("rb") for fp in file_paths]
- try:
- files = [("file", (f.name, f)) for f in opened_files]
- data = {"method": "merge", "params": "{}"}
-
- response = self._client.post(
- f"{self._settings.platform_base_url}/transformations",
- headers=headers,
- files=files,
- data=data,
+ files = [
+ (
+ "file",
+ self._upload(file_path, _get_mime_type_from_path(file_path), file_path.name),
)
- response.raise_for_status()
- return response.content
- finally:
- for f in opened_files:
- f.close()
+ for file_path in file_paths
+ ]
+ result = self._submit_async_job(
+ "transformations",
+ "merge",
+ files,
+ description=f"Running merge of {len(file_paths)} files...",
+ )
+ download_url = result["result"]["file"]["URL"]
+ return self._download_from_url(download_url, description="Downloading merged file...")
diff --git a/samples/python/api/sign_api.py b/samples/python/api/sign_api.py
index f596222..b074b04 100644
--- a/samples/python/api/sign_api.py
+++ b/samples/python/api/sign_api.py
@@ -1,13 +1,11 @@
#!/usr/bin/env python
"""Sign API client for Nitro Sign integrations (eSignature operations)."""
-from __future__ import annotations
-
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
-import httpx
+import httpx2
from .base_client import BaseOAuthClient
@@ -27,19 +25,16 @@ def _request(
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Make authenticated API request returning JSON."""
- headers = {"Authorization": f"Bearer {self._get_token()}"}
-
response = self._client.request(
method=method,
- url=f"{self._settings.platform_base_url}{endpoint}",
- headers=headers,
+ url=endpoint,
json=json_data,
params=params,
)
try:
response.raise_for_status()
- except httpx.HTTPError:
+ except httpx2.HTTPError:
# Try to get error details from response
try:
error_detail = response.json()
@@ -54,12 +49,9 @@ def _request_bytes(
self, method: str, endpoint: str, params: dict[str, Any] | None = None
) -> bytes:
"""Make authenticated API request returning binary data."""
- headers = {"Authorization": f"Bearer {self._get_token()}"}
-
response = self._client.request(
method=method,
- url=f"{self._settings.platform_base_url}{endpoint}",
- headers=headers,
+ url=endpoint,
params=params,
)
@@ -128,10 +120,8 @@ def delete_envelope(self, envelope_id: str) -> None:
Args:
envelope_id: UUID of the envelope
"""
- headers = {"Authorization": f"Bearer {self._get_token()}"}
response = self._client.delete(
- f"{self._settings.platform_base_url}/sign/envelopes/{envelope_id}",
- headers=headers,
+ f"/sign/envelopes/{envelope_id}",
)
response.raise_for_status()
@@ -166,11 +156,8 @@ def create_document(
"payload": (file_path.name, pdf_binary, "application/pdf"),
}
- headers = {"Authorization": f"Bearer {self._get_token()}"}
-
response = self._client.post(
- f"{self._settings.platform_base_url}/sign/envelopes/{envelope_id}/documents",
- headers=headers,
+ f"/sign/envelopes/{envelope_id}/documents",
files=files,
)
diff --git a/samples/python/batch_process.py b/samples/python/batch_process.py
index 46cb55f..bb3884e 100644
--- a/samples/python/batch_process.py
+++ b/samples/python/batch_process.py
@@ -73,38 +73,37 @@ def main(
typer.echo(f"📋 Found {len(files)} file(s) matching '{pattern}'\n")
# Initialize API client (loads credentials from .env)
- client = PlatformAPIClient()
-
- # Process each document
- success_count = 0
- failed_count = 0
-
- for i, file_path in enumerate(files, 1):
- typer.echo(f"[{i}/{len(files)}] Processing: {file_path.name}")
-
- try:
- # Convert to target format
- typer.echo(f" 🔄 Converting to {to_format.value.upper()}...")
- converted = client.convert(file_path, to_format.value)
-
- # Save converted file
- output_file = output_folder / f"{file_path.stem}.{to_format.value}"
- output_file.write_bytes(converted)
-
- typer.echo(f" ✅ Converted: {output_file.name}\n")
- success_count += 1
-
- except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
- typer.echo(f" ❌ FAILED: {e}\n")
- failed_count += 1
-
- # Display summary
- typer.echo("=" * 60)
- typer.echo(f"✅ {success_count} file(s) converted to {to_format.value.upper()}")
- if failed_count > 0:
- typer.echo(f"⚠️ {failed_count} file(s) FAILED to convert!")
- typer.echo(f"📂 Output: {output_folder.absolute()}")
- typer.echo("=" * 60)
+ with PlatformAPIClient.build() as client:
+ # Process each document
+ success_count = 0
+ failed_count = 0
+
+ for i, file_path in enumerate(files, 1):
+ typer.echo(f"[{i}/{len(files)}] Processing: {file_path.name}")
+
+ try:
+ # Convert to target format
+ typer.echo(f" 🔄 Converting to {to_format.value.upper()}...")
+ converted = client.convert(file_path, to_format.value)
+
+ # Save converted file
+ output_file = output_folder / f"{file_path.stem}.{to_format.value}"
+ output_file.write_bytes(converted)
+
+ typer.echo(f" ✅ Converted: {output_file.name}\n")
+ success_count += 1
+
+ except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
+ typer.echo(f" ❌ FAILED: {e}\n")
+ failed_count += 1
+
+ # Display summary
+ typer.echo("=" * 60)
+ typer.echo(f"✅ {success_count} file(s) converted to {to_format.value.upper()}")
+ if failed_count > 0:
+ typer.echo(f"⚠️ {failed_count} file(s) FAILED to convert!")
+ typer.echo(f"📂 Output: {output_folder.absolute()}")
+ typer.echo("=" * 60)
if __name__ == "__main__":
diff --git a/samples/python/benchmark/__init__.py b/samples/python/benchmark/__init__.py
new file mode 100644
index 0000000..0fd5661
--- /dev/null
+++ b/samples/python/benchmark/__init__.py
@@ -0,0 +1,15 @@
+"""Optimize API benchmark: operations, and CSV/HTML reporting."""
+
+from .operations import OperationFailure, OperationResult, OperationSuccess, run_optimize
+from .report import summarise, write_csv, write_html, write_summary_csv
+
+__all__ = [
+ "OperationFailure",
+ "OperationResult",
+ "OperationSuccess",
+ "run_optimize",
+ "summarise",
+ "write_csv",
+ "write_html",
+ "write_summary_csv",
+]
diff --git a/samples/python/benchmark/assets/nitro_logo.png b/samples/python/benchmark/assets/nitro_logo.png
new file mode 100644
index 0000000..a9c5543
Binary files /dev/null and b/samples/python/benchmark/assets/nitro_logo.png differ
diff --git a/samples/python/benchmark/assets/report.css b/samples/python/benchmark/assets/report.css
new file mode 100644
index 0000000..0e9f797
--- /dev/null
+++ b/samples/python/benchmark/assets/report.css
@@ -0,0 +1,148 @@
+/* Styles for the benchmark report. Inlined into the generated HTML so the
+ report stays a single self-contained file. Colour tokens are substituted
+ from report.py. */
+
+:root {
+ --orange: __ORANGE__;
+ --orange-soft: __ORANGE_SOFT__;
+ --ink: __INK__;
+ --ink-soft: __INK_SOFT__;
+ --muted: __MUTED__;
+ --line: __LINE__;
+ --surface: __SURFACE__;
+ --canvas: __CANVAS__;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--canvas);
+ color: var(--ink);
+ font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ line-height: 1.45;
+}
+
+.hero {
+ background: var(--ink);
+ color: #fff;
+ padding: 24px 0 28px;
+ border-bottom: 4px solid var(--orange);
+}
+.hero .wrap { display: flex; align-items: center; gap: 18px; }
+.hero h1 { margin: 0; font-size: 22px; letter-spacing: -0.01em; }
+.hero .sub { margin: 3px 0 0; color: #c7cad6; font-size: 13px; }
+
+.logo { width: 52px; height: 52px; border-radius: 12px; background: #fff; padding: 6px; }
+.logo-fallback {
+ width: 52px;
+ height: 52px;
+ border-radius: 12px;
+ background: var(--orange);
+ color: #fff;
+ display: grid;
+ place-items: center;
+ font-weight: 800;
+ font-size: 26px;
+}
+
+.wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; }
+.content { padding: 22px 0 40px; }
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 20px 22px;
+ margin-bottom: 18px;
+ box-shadow: 0 1px 2px rgba(27, 31, 46, 0.04);
+}
+.card h2 { margin: 0 0 12px; font-size: 16px; }
+
+.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
+.kpi {
+ background: var(--canvas);
+ border-radius: 12px;
+ padding: 14px 16px;
+ border-left: 4px solid var(--line);
+}
+.kpi.hl { border-left-color: var(--orange); background: var(--orange-soft); }
+.kpi b { display: block; font-size: 26px; letter-spacing: -0.02em; }
+.kpi.hl b { color: var(--orange); }
+.kpi span { font-size: 12px; color: var(--muted); }
+
+table { border-collapse: collapse; width: 100%; font-size: 13px; }
+th, td {
+ padding: 9px 10px;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ white-space: nowrap;
+}
+th {
+ background: var(--canvas);
+ font-weight: 600;
+ color: var(--ink-soft);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+tr.fail td { background: #fff7f4; }
+.scroll { overflow-x: auto; }
+
+.pill {
+ display: inline-block;
+ padding: 2px 9px;
+ border-radius: 999px;
+ background: var(--orange-soft);
+ color: var(--orange);
+ font-weight: 600;
+ font-size: 12px;
+}
+.ok { color: #1f8f5f; font-weight: 600; }
+.bad { color: #c43d1e; font-weight: 600; }
+
+svg { max-width: 100%; height: auto; }
+
+.chart-head { display: flex; align-items: center; gap: 10px; margin: 0 0 6px; }
+.chart-head label { font-size: 13px; color: var(--muted); }
+
+.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 12px; }
+.toolbar .grow { flex: 1; }
+.toolbar label { font-size: 13px; color: var(--muted); }
+
+select, button {
+ font: inherit;
+ font-size: 13px;
+ padding: 6px 10px;
+ border-radius: 8px;
+ border: 1px solid var(--line);
+ background: #fff;
+ color: var(--ink);
+}
+button { cursor: pointer; }
+button:hover { border-color: #c9ccd6; }
+button.sm { font-size: 12px; padding: 4px 10px; border-radius: 6px; color: var(--ink-soft); }
+button:disabled { opacity: 0.45; cursor: default; }
+
+.pager {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: flex-end;
+ margin-top: 12px;
+ font-size: 13px;
+ color: var(--muted);
+}
+.foot { color: var(--muted); font-size: 12px; text-align: center; padding: 8px 0 24px; }
+
+@media print {
+ @page { margin: 14mm; }
+ body { background: #fff; }
+ .hero { padding: 14px 0 16px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+ .kpi, .pill, tr.fail td { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+ .toolbar label, .toolbar button, .pager button, .foot { display: none !important; }
+ .card { box-shadow: none; border-color: #ddd; break-inside: avoid; page-break-inside: avoid; }
+ .scroll { overflow: visible; }
+ table { font-size: 11px; }
+ th, td { white-space: normal; }
+}
diff --git a/samples/python/benchmark/assets/report.js b/samples/python/benchmark/assets/report.js
new file mode 100644
index 0000000..454c093
--- /dev/null
+++ b/samples/python/benchmark/assets/report.js
@@ -0,0 +1,148 @@
+/* Interactivity for the benchmark report: chart switching, and paging,
+ filtering and CSV export of the per-file table.
+
+ The first page of rows and every chart are rendered server-side, so the
+ report still shows its data if scripts are blocked. This script only
+ enhances what is already there. `__DATA__` is replaced by report.py with
+ the full result set as JSON. */
+
+const DATA = __DATA__;
+
+const $ = (id) => document.getElementById(id);
+
+const fmtBytes = (n) => {
+ if (n == null) return '-';
+ if (n >= 1048576) return (n / 1048576).toFixed(2) + ' MB';
+ if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
+ return n + ' B';
+};
+
+const fmtPct = (v) => (v == null ? '-' : v.toFixed(1) + '%');
+
+const esc = (s) =>
+ String(s ?? '').replace(/[&<>"]/g, (c) => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ }[c]));
+
+let page = 1;
+let savedPageSize = null;
+
+/* Chart picker: every profile's chart is pre-rendered, so switching is just
+ a matter of toggling which one is visible. */
+function initChartPicker() {
+ const picker = $('chartProfile');
+ if (!picker) return;
+ picker.onchange = () => {
+ document.querySelectorAll('.chartbox').forEach((el) => {
+ el.hidden = el.getAttribute('data-chart') !== picker.value;
+ });
+ };
+}
+
+function filteredRows() {
+ const profile = $('fVariant') ? $('fVariant').value : '';
+ const status = $('fStatus').value;
+ return DATA.filter((r) => (!profile || r.profile === profile) && (!status || r.status === status));
+}
+
+function rowHtml(r, index) {
+ const ok = r.status === 'success';
+ const status = ok
+ ? '● success '
+ : '● failed ';
+ const detail = ok
+ ? ''
+ : `HTTP ${r.http_status ?? '-'} · ${esc(r.error_type || '')} · ` +
+ `${esc(r.error_message || '')} · req ${esc(r.request_id || '-')}`;
+ const reduction = ok ? `${fmtPct(r.reduction_pct)} ` : '-';
+ return (
+ `
${index} ${esc(r.file)} ` +
+ `${esc(r.profile)} ${status} ` +
+ `${fmtBytes(r.input_bytes)} ${fmtBytes(r.output_bytes)} ` +
+ `${reduction} ${r.duration_s} s ${detail} `
+ );
+}
+
+function render() {
+ const rows = filteredRows();
+ const sizeValue = $('pageSize').value;
+ const size = sizeValue === 'all' ? rows.length || 1 : parseInt(sizeValue, 10);
+ const pages = Math.max(1, Math.ceil(rows.length / size));
+ page = Math.min(page, pages);
+
+ const start = (page - 1) * size;
+ const slice = rows.slice(start, start + size);
+
+ $('rows').innerHTML =
+ slice.map((r, i) => rowHtml(r, start + i + 1)).join('') ||
+ 'No rows match. ';
+
+ const shown = `Showing ${start + 1}-${Math.min(start + size, rows.length)} of ${rows.length}`;
+ $('range').textContent = rows.length ? shown : '0 rows';
+ $('pageInfo').textContent = `Page ${page} / ${pages}`;
+ $('prev').disabled = page <= 1;
+ $('next').disabled = page >= pages;
+}
+
+function toCsv(rows) {
+ const cols = Object.keys(rows[0]);
+ const quote = (v) => {
+ const s = v == null ? '' : String(v);
+ return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
+ };
+ const body = rows.map((r) => cols.map((c) => quote(r[c])).join(','));
+ return [cols.join(',')].concat(body).join('\n');
+}
+
+function initControls() {
+ $('prev').onclick = () => {
+ page--;
+ render();
+ };
+ $('next').onclick = () => {
+ page++;
+ render();
+ };
+ const reset = () => {
+ page = 1;
+ render();
+ };
+ $('pageSize').onchange = reset;
+ $('fStatus').onchange = reset;
+ if ($('fVariant')) $('fVariant').onchange = reset;
+
+ $('dl').onclick = () => {
+ const rows = filteredRows();
+ if (!rows.length) return;
+ const blob = new Blob([toCsv(rows)], { type: 'text/csv' });
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = 'nitro_optimize_results.csv';
+ link.click();
+ };
+}
+
+/* If the report is printed from the browser menu, show every row first so the
+ printed copy is complete, then put the page size back afterwards. */
+function initPrintHandlers() {
+ window.addEventListener('beforeprint', () => {
+ savedPageSize = $('pageSize').value;
+ $('pageSize').value = 'all';
+ page = 1;
+ render();
+ });
+ window.addEventListener('afterprint', () => {
+ if (savedPageSize === null) return;
+ $('pageSize').value = savedPageSize;
+ savedPageSize = null;
+ render();
+ });
+}
+
+initChartPicker();
+initControls();
+initPrintHandlers();
+render();
diff --git a/samples/python/benchmark/operations.py b/samples/python/benchmark/operations.py
new file mode 100644
index 0000000..1cc3410
--- /dev/null
+++ b/samples/python/benchmark/operations.py
@@ -0,0 +1,202 @@
+"""Running one optimization job and recording an honest result for it.
+
+The point of this module is that a result is only ever recorded as a success
+when the API actually returned a usable PDF for the profile that was asked for.
+Anything else, including an empty or non-PDF response, is recorded as a failure
+with the detail needed to diagnose it.
+"""
+
+import time
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Literal
+
+import httpx2
+
+from api.platform_api import JobFailedError
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from services import OptimizerService
+
+PDF_MAGIC = b"%PDF-"
+
+# Optimization profiles, with what each one is suited for.
+PROFILES: dict[str, str] = {
+ "minimal-file-size": (
+ "Aggressively downsamples images and strips redundant data to produce the "
+ "smallest possible file. Use when size is the priority."
+ ),
+ "web": (
+ "Balances file size against on-screen quality. Use for web publishing, "
+ "email and online viewing."
+ ),
+ "print": (
+ "Keeps print-resolution images, favouring fidelity over size. Use when the "
+ "document will be printed at high quality."
+ ),
+ "archive": (
+ "Reduces size while keeping the document suitable for long-term storage and "
+ "conversion to PDF/A. Use for archival and compliance."
+ ),
+ "mixed-raster-content": (
+ "MRC compression, which separates text from the background layer. Use for "
+ "scanned documents."
+ ),
+}
+
+
+@dataclass(slots=True, kw_only=True)
+class _OperationBase:
+ """Fields common to every operation result, success or failure."""
+
+ file: str
+ operation: str
+ variant: str # the profile that actually produced this row, never assumed
+ input_bytes: int
+ duration_ms: int
+
+
+@dataclass(slots=True, kw_only=True)
+class OperationSuccess(_OperationBase):
+ """A successful run: the API returned a usable PDF for the profile asked for."""
+
+ output_bytes: int
+ reduction_pct: float
+ output_path: str
+ status: Literal["success"] = "success"
+
+
+@dataclass(slots=True, kw_only=True)
+class OperationFailure(_OperationBase):
+ """A failed run, with the detail needed to diagnose it."""
+
+ http_status: int | None
+ error_type: str | None
+ error_message: str
+ request_id: str | None
+ job_id: str | None = None
+ status: Literal["failed"] = "failed"
+
+
+type OperationResult = OperationSuccess | OperationFailure
+
+
+def _is_pdf(content: bytes) -> bool:
+ """Report whether the bytes look like a real, non-empty PDF."""
+ return len(content) > 0 and content.lstrip()[:5] == PDF_MAGIC
+
+
+def _failure(
+ pdf_path: Path,
+ profile: str,
+ input_bytes: int,
+ duration_ms: int,
+ *,
+ http_status: int | None,
+ error_type: str | None,
+ error_message: str,
+ request_id: str | None,
+) -> OperationFailure:
+ """Build a failed result."""
+ return OperationFailure(
+ file=pdf_path.name,
+ operation="optimize",
+ variant=profile,
+ input_bytes=input_bytes,
+ duration_ms=duration_ms,
+ http_status=http_status,
+ error_type=error_type,
+ error_message=error_message,
+ request_id=request_id,
+ )
+
+
+def run_optimize(
+ optimizer_service: OptimizerService, pdf_path: Path, profile: str, output_dir: Path
+) -> OperationResult:
+ """Optimize one PDF with one profile and record what actually happened.
+
+ Args:
+ optimizer_service: The Optimizer service to run the job through.
+ pdf_path: The PDF to optimize.
+ profile: The optimization profile to apply.
+ output_dir: Where the optimized PDF is written.
+
+ Returns:
+ An OperationResult, marked failed unless a valid PDF came back.
+ """
+ input_bytes = pdf_path.stat().st_size
+ started = time.perf_counter()
+
+ try:
+ content = optimizer_service.optimize(pdf_path, profile)
+ except JobFailedError as exc:
+ return _failure(
+ pdf_path,
+ profile,
+ input_bytes,
+ int((time.perf_counter() - started) * 1000),
+ http_status=exc.status_code,
+ error_type=exc.error_type,
+ error_message=exc.message,
+ request_id=exc.request_id,
+ )
+ except httpx2.HTTPStatusError as exc:
+ # e.g. a 401 from the token endpoint: caught here so a failed run is
+ # recorded rather than the traceback (with its locals) being dumped.
+ return _failure(
+ pdf_path,
+ profile,
+ input_bytes,
+ int((time.perf_counter() - started) * 1000),
+ http_status=exc.response.status_code,
+ error_type="HTTPStatusError",
+ error_message=f"HTTP {exc.response.status_code} from {exc.request.url.path}",
+ request_id=None,
+ )
+ except httpx2.HTTPError as exc:
+ return _failure(
+ pdf_path,
+ profile,
+ input_bytes,
+ int((time.perf_counter() - started) * 1000),
+ http_status=None,
+ error_type=type(exc).__name__,
+ error_message=str(exc) or "The request could not be completed.",
+ request_id=None,
+ )
+
+ duration_ms = int((time.perf_counter() - started) * 1000)
+
+ # A 2xx is not a success on its own: the body has to be a usable PDF.
+ if not _is_pdf(content):
+ return _failure(
+ pdf_path,
+ profile,
+ input_bytes,
+ duration_ms,
+ http_status=200,
+ error_type="InvalidOutput",
+ error_message=f"The response was not a valid PDF ({len(content)} bytes).",
+ request_id=None,
+ )
+
+ target_dir = output_dir / "optimize" / profile
+ target_dir.mkdir(parents=True, exist_ok=True)
+ output_path = target_dir / pdf_path.name
+ output_path.write_bytes(content)
+
+ output_bytes = len(content)
+ reduction = (1.0 - output_bytes / input_bytes) * 100.0 if input_bytes else 0.0
+
+ return OperationSuccess(
+ file=pdf_path.name,
+ operation="optimize",
+ variant=profile,
+ input_bytes=input_bytes,
+ output_bytes=output_bytes,
+ reduction_pct=round(reduction, 2),
+ duration_ms=duration_ms,
+ output_path=str(output_path),
+ )
diff --git a/samples/python/benchmark/report.py b/samples/python/benchmark/report.py
new file mode 100644
index 0000000..5faea7b
--- /dev/null
+++ b/samples/python/benchmark/report.py
@@ -0,0 +1,673 @@
+"""Reporting: per-file CSV, aggregate summary, and a self-contained HTML report.
+
+The HTML report has no external dependencies. The stylesheet and script live
+alongside this module in ``assets/`` and are inlined at build time, as is the
+logo and every chart, so the finished report is one file that opens straight
+from disk and can be handed to someone else as-is.
+"""
+
+import base64
+import csv
+import html
+import json
+import math
+import statistics
+from collections import defaultdict
+from dataclasses import asdict
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from .operations import OperationResult, OperationSuccess
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+# Every field either variant can produce; a variant that lacks a given field
+# just leaves that CSV column blank (csv.DictWriter's default for missing keys).
+CSV_FIELDS = [
+ "file",
+ "operation",
+ "variant",
+ "status",
+ "input_bytes",
+ "output_bytes",
+ "reduction_pct",
+ "duration_ms",
+ "output_path",
+ "http_status",
+ "error_type",
+ "error_message",
+ "request_id",
+ "job_id",
+]
+HERE = Path(__file__).resolve().parent
+ASSETS = HERE / "assets"
+LOGO_PATH = ASSETS / "nitro_logo.png"
+
+# Nitro brand palette.
+ORANGE = "#f54811"
+ORANGE_SOFT = "#fde9e1"
+INK = "#1b1f2e"
+INK_SOFT = "#3d4257"
+MUTED = "#6b7084"
+LINE = "#e6e7ec"
+SURFACE = "#ffffff"
+CANVAS = "#f6f6f8"
+BAR = "#f3946e" # softer orange for chart bars; brand orange stays for accents
+BAD = "#b9bdcb"
+# Per-profile series colours, used when several profiles are charted together.
+SERIES_COLORS = ("#f3946e", "#4c72b0", "#55a868", "#8172b3", "#c9a227")
+
+BYTES_PER_MB = 1_048_576
+BYTES_PER_KB = 1024
+DEFAULT_PAGE_SIZE = 10
+MAX_COMPLEXITY_BINS = 100
+
+_TABLE_HEADERS = (
+ "#",
+ "File",
+ "Profile",
+ "Status",
+ "Input",
+ "Output",
+ "Reduction",
+ "Time",
+ "Failure detail",
+)
+
+
+# ----------------------------------------------------------------------- csv --
+def write_csv(results: list[OperationResult], path: Path) -> None:
+ """Write one CSV row per file and profile."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(handle, fieldnames=CSV_FIELDS)
+ writer.writeheader()
+ for result in results:
+ writer.writerow(asdict(result))
+
+
+def summarise(results: list[OperationResult]) -> list[dict[str, object]]:
+ """Aggregate the results into one row per profile."""
+ groups: dict[tuple[str, str], list[OperationResult]] = defaultdict(list)
+ for result in results:
+ groups[result.operation, result.variant].append(result)
+
+ rows: list[dict[str, object]] = []
+ for (operation, variant), items in sorted(groups.items()):
+ succeeded = [r for r in items if r.status == "success"]
+ reductions = [r.reduction_pct for r in succeeded]
+ total_in = sum(r.input_bytes for r in succeeded)
+ total_out = sum(r.output_bytes for r in succeeded)
+ overall = round((1 - total_out / total_in) * 100, 2) if total_in else None
+ mean_duration = (
+ round(statistics.mean(r.duration_ms for r in items) / 1000, 2) if items else None
+ )
+ rows.append({
+ "operation": operation,
+ "variant": variant,
+ "files": len(items),
+ "succeeded": len(succeeded),
+ "failed": len(items) - len(succeeded),
+ "mean_reduction_pct": round(statistics.mean(reductions), 2) if reductions else None,
+ "median_reduction_pct": (
+ round(statistics.median(reductions), 2) if reductions else None
+ ),
+ "total_input_mb": round(total_in / BYTES_PER_MB, 2),
+ "total_output_mb": round(total_out / BYTES_PER_MB, 2),
+ "overall_reduction_pct": overall,
+ "mean_duration_s": mean_duration,
+ })
+ return rows
+
+
+def write_summary_csv(summary: list[dict[str, object]], path: Path) -> None:
+ """Write the aggregate summary as CSV."""
+ if not summary:
+ return
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(handle, fieldnames=list(summary[0].keys()))
+ writer.writeheader()
+ writer.writerows(summary)
+
+
+# ------------------------------------------------------------------- helpers --
+def _logo_data_uri() -> str | None:
+ """Return the Nitro logo as a data URI, or None if it cannot be read."""
+ try:
+ encoded = base64.b64encode(LOGO_PATH.read_bytes()).decode("ascii")
+ except OSError:
+ return None
+ return f"data:image/png;base64,{encoded}"
+
+
+def _fmt_pct(value: object) -> str:
+ """Format a percentage, or a dash when there is nothing to show."""
+ return f"{value:.1f}%" if isinstance(value, (int, float)) else "-"
+
+
+def _fmt_bytes(value: object) -> str:
+ """Format a byte count in human-readable units."""
+ if not isinstance(value, (int, float)):
+ return "-"
+ if value >= BYTES_PER_MB:
+ return f"{value / BYTES_PER_MB:.2f} MB"
+ if value >= BYTES_PER_KB:
+ return f"{value / BYTES_PER_KB:.1f} KB"
+ return f"{int(value)} B"
+
+
+def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
+ """Render a plain HTML table."""
+ head = "".join(f"{html.escape(h)} " for h in headers)
+ body = "".join("" + "".join(f"{cell} " for cell in row) + " " for row in rows)
+ return f""
+
+
+# -------------------------------------------------------------------- charts --
+def _bin_labels(bin_width: float, n_bins: int, *, has_growth: bool) -> list[str]:
+ """Build the x-axis labels: an optional growth bin, then 0-100% in steps."""
+ steps = n_bins - (1 if has_growth else 0)
+ labels = ["< 0"] if has_growth else []
+ labels.extend(f"{int(i * bin_width)}-{int((i + 1) * bin_width)}" for i in range(steps))
+ return labels
+
+
+def _bin_counts(
+ values: list[float], bin_width: float, n_bins: int, *, has_growth: bool
+) -> list[int]:
+ """Count values into the fixed bins."""
+ offset = 1 if has_growth else 0
+ steps = n_bins - offset
+ counts = [0] * n_bins
+ for value in values:
+ if value < 0:
+ counts[0] += 1
+ else:
+ counts[min(steps - 1, int(value // bin_width)) + offset] += 1
+ return counts
+
+
+def _axis_and_grid(pad_l: int, pad_t: int, plot_w: int, plot_h: int, y_top: int) -> list[str]:
+ """Draw the horizontal gridlines and their y-axis labels."""
+ parts: list[str] = []
+ for step in range(5):
+ value = y_top * step / 4
+ y = pad_t + plot_h - (value / y_top) * plot_h
+ parts.append(
+ f' '
+ )
+ parts.append(
+ f'{value:.0f} '
+ )
+ return parts
+
+
+def _legend(names: Sequence[str]) -> list[str]:
+ """Draw a colour legend, one entry per profile."""
+ parts: list[str] = []
+ x = 0.0
+ for index, name in enumerate(names):
+ colour = SERIES_COLORS[index % len(SERIES_COLORS)]
+ parts.append(f' ')
+ parts.append(f'{html.escape(name)} ')
+ x += 16 + len(name) * 6.6 + 20
+ return parts
+
+
+def _hist_svg(series: dict[str, list[float]], title: str, bin_width: float = 10.0) -> str:
+ """Render the distribution of size reduction as an inline SVG histogram.
+
+ ``series`` maps profile name to that profile's per-file reduction
+ percentages. One profile gives a plain histogram; several gives grouped
+ bars with a legend, so the distributions can be compared side by side.
+
+ The x-axis is always 0-100%. A single leading "< 0" bin is added only if
+ some file actually came out larger than its input.
+ """
+ populated = {name: values for name, values in series.items() if values}
+ if not populated:
+ return ""
+
+ names = list(populated)
+ multi = len(names) > 1
+ all_values = [v for values in populated.values() for v in values]
+ has_growth = any(v < 0 for v in all_values)
+
+ steps = round(MAX_COMPLEXITY_BINS / bin_width)
+ n_bins = steps + (1 if has_growth else 0)
+ labels = _bin_labels(bin_width, n_bins, has_growth=has_growth)
+ binned = {
+ name: _bin_counts(values, bin_width, n_bins, has_growth=has_growth)
+ for name, values in populated.items()
+ }
+
+ max_count = max((c for counts in binned.values() for c in counts), default=0) or 1
+ # Headroom above the tallest bar so its count label never crowds the title.
+ y_top = max(4, math.ceil(max_count * 1.25))
+ y_top += -y_top % 4 # round up to a multiple of 4 for whole-number gridlines
+
+ legend_h = 22 if multi else 0
+ pad_l, pad_r, pad_t, pad_b = 46, 16, 66 + legend_h, 46
+ plot_w, plot_h = 720, 250
+ width, height = pad_l + plot_w + pad_r, pad_t + plot_h + pad_b
+ group_w = plot_w / n_bins
+
+ subtitle = (
+ f"{len(all_values)} results across {len(names)} profiles · bins of {bin_width:g}%"
+ if multi
+ else f"{len(all_values)} files · bins of {bin_width:g}%"
+ )
+ parts = [
+ f'',
+ f''
+ f"{html.escape(title)} ",
+ f'{subtitle} ',
+ ]
+ if multi:
+ parts.extend(_legend(names))
+ parts.extend(_axis_and_grid(pad_l, pad_t, plot_w, plot_h, y_top))
+ parts.extend(
+ _bars(
+ binned=binned,
+ labels=labels,
+ names=names,
+ geometry=(pad_l, pad_t, plot_h, group_w),
+ y_top=y_top,
+ multi=multi,
+ has_growth=has_growth,
+ )
+ )
+ centre_x = pad_l + plot_w / 2
+ centre_y = pad_t + plot_h / 2
+ parts.append(
+ f'size reduction (%) '
+ )
+ parts.append(
+ f'files '
+ )
+ parts.append(" ")
+ return "".join(parts)
+
+
+def _bars(
+ *,
+ binned: dict[str, list[int]],
+ labels: Sequence[str],
+ names: Sequence[str],
+ geometry: tuple[int, int, int, float],
+ y_top: int,
+ multi: bool,
+ has_growth: bool,
+) -> list[str]:
+ """Draw the bars and their x-axis labels."""
+ pad_l, pad_t, plot_h, group_w = geometry
+ inner_pad = 2.0
+ slot_w = (group_w - 2 * inner_pad) / len(names)
+ parts: list[str] = []
+
+ for index, label in enumerate(labels):
+ group_x = pad_l + index * group_w
+ for series_index, name in enumerate(names):
+ count = binned[name][index]
+ bar_h = (count / y_top) * plot_h
+ y = pad_t + plot_h - bar_h
+ x = group_x + inner_pad + series_index * slot_w
+ if multi:
+ colour = SERIES_COLORS[series_index % len(SERIES_COLORS)]
+ else:
+ colour = BAD if (has_growth and index == 0) else BAR
+ bar_w = max(slot_w - (1.5 if multi else 4), 1.5)
+ tooltip = f"{html.escape(name)}: {count} file(s), {html.escape(label)}%"
+ parts.append(
+ f'{tooltip} '
+ )
+ if count and not multi:
+ parts.append(
+ f'{count} '
+ )
+ parts.append(
+ f'{html.escape(label)} '
+ )
+ return parts
+
+
+# ---------------------------------------------------------------- html pieces --
+def _report_css() -> str:
+ """Load the stylesheet and substitute the palette."""
+ css = (ASSETS / "report.css").read_text(encoding="utf-8")
+ tokens = {
+ "__ORANGE__": ORANGE,
+ "__ORANGE_SOFT__": ORANGE_SOFT,
+ "__INK__": INK,
+ "__INK_SOFT__": INK_SOFT,
+ "__MUTED__": MUTED,
+ "__LINE__": LINE,
+ "__SURFACE__": SURFACE,
+ "__CANVAS__": CANVAS,
+ }
+ for token, colour in tokens.items():
+ css = css.replace(token, colour)
+ return css
+
+
+def _report_js(rows: list[dict[str, object]]) -> str:
+ """Load the script and embed the result data in it."""
+ script = (ASSETS / "report.js").read_text(encoding="utf-8")
+ # Escaping "" keeps a stray sequence in the data from closing the tag.
+ payload = json.dumps(rows).replace("", "<\\/")
+ return script.replace("__DATA__", payload)
+
+
+def _kpis(results: list[OperationResult]) -> str:
+ """Render the headline numbers."""
+ total = len(results)
+ succeeded = [r for r in results if r.status == "success"]
+ total_in = sum(r.input_bytes for r in succeeded)
+ total_out = sum(r.output_bytes for r in succeeded)
+ reductions = [r.reduction_pct for r in succeeded]
+
+ mean_reduction = f"{statistics.mean(reductions):.1f}%" if reductions else "-"
+ overall = f"{(1 - total_out / total_in) * 100:.1f}%" if total_in else "-"
+ saved_mb = (total_in - total_out) / BYTES_PER_MB if total_in else 0.0
+ failed = total - len(succeeded)
+
+ cells = [
+ ("hl", mean_reduction, "mean reduction per file"),
+ ("", overall, "overall size reduction"),
+ ("", f"{saved_mb:.1f} MB", "saved across successful files"),
+ ("", str(len({r.file for r in results})), "input files"),
+ ("", f"{len(succeeded)} / {total}", "successful runs"),
+ ("", str(failed), "failed runs"),
+ ]
+ tiles = "".join(
+ f'{value} {label}
'
+ for cls, value, label in cells
+ )
+ return f''
+
+
+def _summary_card(summary: list[dict[str, object]]) -> str:
+ """Render the per-profile summary table, shown only for multi-profile runs."""
+ if len(summary) <= 1:
+ return ""
+ rows = [
+ [
+ f'{html.escape(str(row["variant"]))} ',
+ str(row["files"]),
+ str(row["succeeded"]),
+ f'{row["failed"]} ',
+ f"{_fmt_pct(row['mean_reduction_pct'])} ",
+ _fmt_pct(row["median_reduction_pct"]),
+ f"{row['total_input_mb']} MB",
+ f"{row['total_output_mb']} MB",
+ f"{_fmt_pct(row['overall_reduction_pct'])} ",
+ f"{row['mean_duration_s']} s" if row["mean_duration_s"] is not None else "-",
+ ]
+ for row in summary
+ ]
+ headers = (
+ "Profile",
+ "Files",
+ "OK",
+ "Failed",
+ "Mean reduction",
+ "Median reduction",
+ "Total in",
+ "Total out",
+ "Overall reduction",
+ "Mean time / file",
+ )
+ table = _table(headers, rows)
+ return f'Summary by profile {table}
'
+
+
+def _chart_card(series: dict[str, list[float]], selected: str | None) -> str:
+ """Render one chart per profile plus a comparison view, with a picker."""
+ if not series:
+ return ""
+ names = list(series)
+ chosen = selected if selected in series else names[0]
+
+ boxes: list[str] = []
+ options: list[str] = []
+ for name in names:
+ svg = _hist_svg({name: series[name]}, f"Distribution of size reduction: {name}")
+ hidden = "" if name == chosen else " hidden"
+ boxes.append(f'{svg}
')
+ selected_attr = " selected" if name == chosen else ""
+ options.append(
+ f'{html.escape(name)} '
+ )
+
+ if len(names) > 1:
+ combined = _hist_svg(series, "Distribution of size reduction by profile")
+ boxes.append(f'{combined}
')
+ options.append('All profiles (compare) ')
+ picker = f'Profile {"".join(options)} '
+ else:
+ picker = ""
+
+ head = f''
+ return f'{head}{"".join(boxes)}
'
+
+
+def _rows_for_table(
+ results: list[OperationResult], preferred: str | None
+) -> list[dict[str, object]]:
+ """Flatten the results for the table, with the chosen profile listed first."""
+
+ def sort_key(result: OperationResult) -> tuple[bool, str, float, str]:
+ reduction = result.reduction_pct if isinstance(result, OperationSuccess) else -1e9
+ return (
+ result.variant != preferred,
+ result.variant,
+ -reduction,
+ result.file,
+ )
+
+ def to_row(r: OperationResult) -> dict[str, object]:
+ common: dict[str, object] = {
+ "file": r.file,
+ "profile": r.variant,
+ "status": r.status,
+ "input_bytes": r.input_bytes,
+ "duration_s": round(r.duration_ms / 1000, 1),
+ }
+ if isinstance(r, OperationSuccess):
+ return common | {
+ "output_bytes": r.output_bytes,
+ "reduction_pct": r.reduction_pct,
+ "http_status": None,
+ "error_type": None,
+ "error_message": None,
+ "request_id": None,
+ "job_id": None,
+ }
+ return common | {
+ "output_bytes": None,
+ "reduction_pct": None,
+ "http_status": r.http_status,
+ "error_type": r.error_type,
+ "error_message": r.error_message,
+ "request_id": r.request_id,
+ "job_id": r.job_id,
+ }
+
+ return [to_row(r) for r in sorted(results, key=sort_key)]
+
+
+def _initial_tbody(rows: list[dict[str, object]]) -> str:
+ """Render the first page of rows server-side.
+
+ The table therefore still shows data where scripts do not run, such as
+ preview panes and some mail clients. The script takes over from there.
+ """
+ out: list[str] = []
+ for index, row in enumerate(rows[:DEFAULT_PAGE_SIZE], start=1):
+ succeeded = row["status"] == "success"
+ status = (
+ '● success '
+ if succeeded
+ else '● failed '
+ )
+ detail = (
+ ""
+ if succeeded
+ else html.escape(
+ f"HTTP {row['http_status'] if row['http_status'] is not None else '-'} "
+ f"| {row['error_type'] or ''} | {row['error_message'] or ''} "
+ f"| req {row['request_id'] or '-'}"
+ )
+ )
+ reduction = f"{_fmt_pct(row['reduction_pct'])} " if succeeded else "-"
+ cls = "" if succeeded else ' class="fail"'
+ out.append(
+ f"{index} {html.escape(str(row['file']))} "
+ f'{html.escape(str(row["profile"]))} '
+ f"{status} {_fmt_bytes(row['input_bytes'])} "
+ f"{_fmt_bytes(row['output_bytes'])} {reduction} "
+ f"{row['duration_s']} s {detail} "
+ )
+ return "".join(out)
+
+
+def _table_card(rows: list[dict[str, object]], variants: Sequence[str]) -> str:
+ """Render the per-file table with its filters, pager and export button."""
+ profile_filter = ""
+ if len(variants) > 1:
+ options = "".join(
+ f'{html.escape(v)} ' for v in variants
+ )
+ profile_filter = (
+ f'Profile All '
+ f"{options} "
+ )
+
+ shown = min(DEFAULT_PAGE_SIZE, len(rows))
+ initial_range = f"Showing 1-{shown} of {len(rows)}" if rows else "0 rows"
+ pages = max(1, math.ceil(len(rows) / DEFAULT_PAGE_SIZE))
+ next_disabled = " disabled" if pages <= 1 else ""
+ headers = "".join(f"{html.escape(h)} " for h in _TABLE_HEADERS)
+
+ return (
+ '"
+ )
+
+
+def _hero(title: str, profiles: Sequence[str]) -> str:
+ """Render the header banner, naming every profile that was actually run."""
+ logo = _logo_data_uri()
+ logo_html = (
+ f' '
+ if logo
+ else 'N
'
+ )
+ generated = datetime.now(tz=UTC).astimezone().strftime("%d %b %Y, %H:%M")
+ label = "profile" if len(profiles) == 1 else "profiles"
+ names = ", ".join(profiles) if profiles else "-"
+ return (
+ f'{logo_html}'
+ f"
{html.escape(title)} "
+ f'
Generated {generated} · {label}: '
+ f"{html.escape(names)}
"
+ "
"
+ )
+
+
+def _profiles_in_run_order(results: list[OperationResult]) -> list[str]:
+ """List the profiles in the order they were run, for the header and chart."""
+ profiles: list[str] = []
+ for result in results:
+ if result.variant not in profiles:
+ profiles.append(result.variant)
+ return profiles
+
+
+def _reduction_series(
+ results: list[OperationResult], profiles: list[str]
+) -> dict[str, list[float]]:
+ """Collect each profile's successful reduction percentages for the chart."""
+ succeeded = [r for r in results if r.status == "success"]
+ series: dict[str, list[float]] = {}
+ for name in profiles:
+ values = [r.reduction_pct for r in succeeded if r.variant == name]
+ if values:
+ series[name] = values
+ return series
+
+
+# ----------------------------------------------------------------------- html --
+def write_html(
+ results: list[OperationResult],
+ summary: list[dict[str, object]],
+ path: Path,
+ *,
+ title: str = "Nitro Optimize API Benchmark Report",
+ default_variant: str | None = None,
+) -> None:
+ """Write the self-contained HTML report.
+
+ Args:
+ results: Every per-file result from the run.
+ summary: The aggregate rows from ``summarise``.
+ path: Where the report is written.
+ title: The report heading.
+ default_variant: The profile to show first in the chart and table.
+ """
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ variants = sorted({r.variant for r in results})
+ preferred = (
+ default_variant if default_variant in variants else (variants[0] if variants else None)
+ )
+
+ profiles_in_run = _profiles_in_run_order(results)
+ series = _reduction_series(results, profiles_in_run)
+ rows = _rows_for_table(results, preferred)
+ body = "".join([
+ _kpis(results),
+ _summary_card(summary),
+ _chart_card(series, preferred),
+ _table_card(rows, variants),
+ '",
+ ])
+
+ doc = (
+ ' '
+ ' '
+ f"{html.escape(title)} "
+ f""
+ f"{_hero(title, profiles_in_run)}"
+ f'{body}
'
+ f""
+ ""
+ )
+ path.write_text(doc, encoding="utf-8")
diff --git a/samples/python/bulk_password_protect.py b/samples/python/bulk_password_protect.py
index f296f59..d37f47a 100644
--- a/samples/python/bulk_password_protect.py
+++ b/samples/python/bulk_password_protect.py
@@ -41,6 +41,7 @@
import typer
+from api import FatalError
from api.platform_api import PlatformAPIClient
from helper_functions.document_helpers import validate_and_setup
@@ -56,47 +57,45 @@ def main(
"""Apply password protection to all PDF files in a directory."""
# Validate password strength
if len(password) < 6:
- print('❌ Error: Password must be at least 6 characters long')
- raise typer.Exit(code=1)
+ raise FatalError('Password must be at least 6 characters long')
# Validate and setup (only process PDF files)
files = validate_and_setup(input_folder, output_folder, file_patterns=["*.pdf"])
print(f"📋 Found {len(files)} PDF document(s) to protect\n")
# Initialize API client (loads credentials from .env)
- client = PlatformAPIClient()
-
- # Process each document
- success_count = 0
- failed_count = 0
-
- for i, pdf_file in enumerate(files, 1):
- print(f"[{i}/{len(files)}] Processing: {pdf_file.name}")
-
- try:
- # Apply password protection
- print(" 🔐 Applying password protection...")
- protected_pdf = client.password_protect(pdf_file, password)
-
- # Save protected PDF
- output_file = output_folder / pdf_file.name
- output_file.write_bytes(protected_pdf)
-
- print(f" ✅ Protected: {output_file.name}\n")
- success_count += 1
-
- except Exception as e: # noqa: BLE001
- print(f" ❌ FAILED: {e}\n")
- failed_count += 1
-
- # Display summary
- print("=" * 60)
- print(f"✅ {success_count} document(s) password protected")
- if failed_count > 0:
- print(f"⚠️ {failed_count} document(s) FAILED - remain unprotected!")
- print(f"📂 Output: {output_folder.absolute()}")
- print(f"🔑 Password: {'*' * len(password)} ({len(password)} characters)")
- print("=" * 60)
+ with PlatformAPIClient.build() as client:
+ # Process each document
+ success_count = 0
+ failed_count = 0
+
+ for i, pdf_file in enumerate(files, 1):
+ print(f"[{i}/{len(files)}] Processing: {pdf_file.name}")
+
+ try:
+ # Apply password protection
+ print(" 🔐 Applying password protection...")
+ protected_pdf = client.password_protect(pdf_file, password)
+
+ # Save protected PDF
+ output_file = output_folder / pdf_file.name
+ output_file.write_bytes(protected_pdf)
+
+ print(f" ✅ Protected: {output_file.name}\n")
+ success_count += 1
+
+ except Exception as e: # noqa: BLE001
+ print(f" ❌ FAILED: {e}\n")
+ failed_count += 1
+
+ # Display summary
+ print("=" * 60)
+ print(f"✅ {success_count} document(s) password protected")
+ if failed_count > 0:
+ print(f"⚠️ {failed_count} document(s) FAILED - remain unprotected!")
+ print(f"📂 Output: {output_folder.absolute()}")
+ print(f"🔑 Password: {'*' * len(password)} ({len(password)} characters)")
+ print("=" * 60)
if __name__ == '__main__':
diff --git a/samples/python/convert_cli.py b/samples/python/convert_cli.py
index 208e181..04f9076 100644
--- a/samples/python/convert_cli.py
+++ b/samples/python/convert_cli.py
@@ -39,6 +39,7 @@
import typer
+from api import FatalError
from api.platform_api import PlatformAPIClient
@@ -67,32 +68,29 @@ def main(
"""Convert a document from one format to another using the Platform API."""
# Validate input file exists
if not input_file.exists():
- print(f'❌ Error: Input file not found: {input_file}')
- raise typer.Exit(code=1)
+ raise FatalError(f'Input file not found: {input_file}')
# Create output directory if needed
output_file.parent.mkdir(parents=True, exist_ok=True)
# Initialize API client (loads credentials from .env)
- client = PlatformAPIClient()
-
- try:
- # Convert document
- print(f'🔄 Converting {input_file.name} to {to_format.value.upper()}...')
- converted = client.convert(input_file, to_format.value)
-
- # Save converted file
- output_file.write_bytes(converted)
-
- # Display success message
- print('✅ Conversion successful!')
- print(f'📄 Input: {input_file.name} ({input_file.stat().st_size:,} bytes)')
- print(f'📄 Output: {output_file.name} ({len(converted):,} bytes)')
- print(f'📂 Saved to: {output_file.absolute()}')
-
- except Exception as e: # noqa: BLE001
- print(f'❌ Conversion FAILED: {e}')
- raise typer.Exit(code=1) from None
+ with PlatformAPIClient.build() as client:
+ try:
+ # Convert document
+ print(f'🔄 Converting {input_file.name} to {to_format.value.upper()}...')
+ converted = client.convert(input_file, to_format.value)
+
+ # Save converted file
+ output_file.write_bytes(converted)
+
+ # Display success message
+ print('✅ Conversion successful!')
+ print(f'📄 Input: {input_file.name} ({input_file.stat().st_size:,} bytes)')
+ print(f'📄 Output: {output_file.name} ({len(converted):,} bytes)')
+ print(f'📂 Saved to: {output_file.absolute()}')
+
+ except Exception as e: # noqa: BLE001
+ raise FatalError(f'Conversion FAILED: {e}') from None
if __name__ == '__main__':
diff --git a/samples/python/employee_policy_onboarding.py b/samples/python/employee_policy_onboarding.py
index b4a48ce..484d263 100644
--- a/samples/python/employee_policy_onboarding.py
+++ b/samples/python/employee_policy_onboarding.py
@@ -59,6 +59,7 @@
import typer
+from api import FatalError
from api.sign_api import SignAPIClient
from helper_functions.sign_helpers import (
add_signature_fields_to_documents,
@@ -93,12 +94,10 @@ def _validate_and_setup_inputs(
"""
# Validate inputs
if not policies_folder.exists() or not policies_folder.is_dir():
- print(f'❌ Policies folder not found: {policies_folder}')
- raise typer.Exit(code=1)
+ raise FatalError(f'Policies folder not found: {policies_folder}')
if not employees_csv.exists():
- print(f'❌ Employees CSV not found: {employees_csv}')
- raise typer.Exit(code=1)
+ raise FatalError(f'Employees CSV not found: {employees_csv}')
# Display header
output_folder = Path('output')
@@ -200,46 +199,43 @@ def main(
)
# Initialize Sign API client
- sign_client = SignAPIClient()
-
- # Process each employee
- print("=" * 60)
- print(f"📤 PROCESSING {len(employees)} EMPLOYEE(S)")
- print("=" * 60)
-
- success_count = 0
- failed_count = 0
-
- for i, employee in enumerate(employees, 1):
- try:
- _process_employee_onboarding(
- sign_client,
- employee,
- documents,
- output_folder,
- employee_num=i,
- total_employees=len(employees),
- )
- success_count += 1
-
- except Exception as e: # noqa: BLE001
- print(f" ❌ FAILED: {e}\n")
- failed_count += 1
-
- # Display summary
- print("=" * 60)
- print(f"✅ {success_count} employee(s) completed")
- if failed_count > 0:
- print(f"❌ {failed_count} failed")
- print(f"📂 Output: {output_folder.absolute()}")
- print("=" * 60)
+ with SignAPIClient.build() as sign_client:
+ # Process each employee
+ print("=" * 60)
+ print(f"📤 PROCESSING {len(employees)} EMPLOYEE(S)")
+ print("=" * 60)
+
+ success_count = 0
+ failed_count = 0
+
+ for i, employee in enumerate(employees, 1):
+ try:
+ _process_employee_onboarding(
+ sign_client,
+ employee,
+ documents,
+ output_folder,
+ employee_num=i,
+ total_employees=len(employees),
+ )
+ success_count += 1
+
+ except Exception as e: # noqa: BLE001
+ print(f" ❌ FAILED: {e}\n")
+ failed_count += 1
+
+ # Display summary
+ print("=" * 60)
+ print(f"✅ {success_count} employee(s) completed")
+ if failed_count > 0:
+ print(f"❌ {failed_count} failed")
+ print(f"📂 Output: {output_folder.absolute()}")
+ print("=" * 60)
except KeyboardInterrupt:
- print('\n\n⚠️ Interrupted by user')
- raise typer.Exit(code=1) from None
+ raise FatalError('Interrupted by user') from None
except Exception as e: # noqa: BLE001
- print(f'\n❌ Error: {e}')
- raise typer.Exit(code=1) from None
+ raise FatalError(f'Error: {e}') from None
if __name__ == '__main__':
diff --git a/samples/python/extract_data.py b/samples/python/extract_data.py
index 3e83be2..1ab874e 100644
--- a/samples/python/extract_data.py
+++ b/samples/python/extract_data.py
@@ -45,6 +45,7 @@
import typer
+from api import FatalError
from api.platform_api import PlatformAPIClient
app = typer.Typer()
@@ -62,57 +63,52 @@ def main(
# Validate mode
if mode not in ['forms', 'tables']:
- print("❌ Error: Mode must be 'forms' or 'tables'")
- raise typer.Exit(code=1)
+ raise FatalError("Mode must be 'forms' or 'tables'")
# Validate input file exists
if not input_pdf.exists():
- print(f'❌ Error: Input file not found: {input_pdf}')
- raise typer.Exit(code=1)
+ raise FatalError(f'Input file not found: {input_pdf}')
# Validate input is a PDF
if input_pdf.suffix.lower() != '.pdf':
- print('❌ Error: Input must be a PDF file')
- raise typer.Exit(code=1)
+ raise FatalError('Input must be a PDF file')
# Create output directory if needed
output_json.parent.mkdir(parents=True, exist_ok=True)
# Initialize API client (loads credentials from .env)
- client = PlatformAPIClient()
-
- try:
- # Extract data based on mode
- if mode == 'forms':
- print(f'📋 Extracting form fields from {input_pdf.name}...')
- data = client.extract_forms(input_pdf)
- data_type = 'form fields'
-
- else: # mode == 'tables'
- print(f'📊 Extracting table data from {input_pdf.name}...')
- data = client.extract_tables(input_pdf)
- data_type = 'tables'
-
- # Count extracted items
- result = data.get('result', {})
- if mode == 'forms':
- item_count = len(result.get('fields', []))
- else:
- item_count = len(result.get('tables', []))
-
- # Save extracted data as JSON
- output_json.write_text(json.dumps(data, indent=2), encoding='utf-8')
-
- # Display success message
- print('✅ Extraction successful!')
- print(f'📊 Extracted: {item_count} {data_type}')
- print(f'📄 Input: {input_pdf.name}')
- print(f'📄 Output: {output_json.name}')
- print(f'📂 Saved to: {output_json.absolute()}')
-
- except Exception as e: # noqa: BLE001
- print(f'❌ Extraction FAILED: {e}')
- raise typer.Exit(code=1) from None
+ with PlatformAPIClient.build() as client:
+ try:
+ # Extract data based on mode
+ if mode == 'forms':
+ print(f'📋 Extracting form fields from {input_pdf.name}...')
+ data = client.extract_forms(input_pdf)
+ data_type = 'form fields'
+
+ else: # mode == 'tables'
+ print(f'📊 Extracting table data from {input_pdf.name}...')
+ data = client.extract_tables(input_pdf)
+ data_type = 'tables'
+
+ # Count extracted items
+ result = data.get('result', {})
+ if mode == 'forms':
+ item_count = len(result.get('fields', []))
+ else:
+ item_count = len(result.get('tables', []))
+
+ # Save extracted data as JSON
+ output_json.write_text(json.dumps(data, indent=2), encoding='utf-8')
+
+ # Display success message
+ print('✅ Extraction successful!')
+ print(f'📊 Extracted: {item_count} {data_type}')
+ print(f'📄 Input: {input_pdf.name}')
+ print(f'📄 Output: {output_json.name}')
+ print(f'📂 Saved to: {output_json.absolute()}')
+
+ except Exception as e: # noqa: BLE001
+ raise FatalError(f'Extraction FAILED: {e}') from None
if __name__ == '__main__':
diff --git a/samples/python/helper_functions/document_helpers.py b/samples/python/helper_functions/document_helpers.py
index 3be16a4..4c895f6 100644
--- a/samples/python/helper_functions/document_helpers.py
+++ b/samples/python/helper_functions/document_helpers.py
@@ -2,8 +2,6 @@
Common helper utilities for document processing scripts.
"""
-from __future__ import annotations
-
import sys
from pathlib import Path
diff --git a/samples/python/helper_functions/sign_helpers.py b/samples/python/helper_functions/sign_helpers.py
index 3cca988..5d719e4 100644
--- a/samples/python/helper_functions/sign_helpers.py
+++ b/samples/python/helper_functions/sign_helpers.py
@@ -2,8 +2,6 @@
Sign API helper utilities for envelope operations.
"""
-from __future__ import annotations
-
import csv
import json
import time
@@ -108,32 +106,8 @@ def _upload_documents_to_envelope(
document_ids: list[str] = []
for doc in documents:
- doc_name = doc["name"]
- doc_binary = doc["binary"]
-
- # Prepare metadata as JSON string
- metadata = json.dumps({"name": doc_name})
-
- # Prepare form-data with binary content
- files = {
- "metadata": ("metadata", metadata, "application/json"),
- "payload": (doc_name, doc_binary, "application/pdf"),
- }
-
- token = sign_client.get_token()
- headers = {"Authorization": f"Bearer {token}"}
-
- response = sign_client._client.post(
- f"{sign_client._settings.platform_base_url}/sign/envelopes/{envelope_id}/documents",
- headers=headers,
- files=files,
- )
-
- response.raise_for_status()
- document = response.json()
-
- document_id = document["ID"]
- document_ids.append(document_id)
+ document = sign_client.create_document(envelope_id, Path(doc["path"]), doc["name"])
+ document_ids.append(document["ID"])
return document_ids
diff --git a/samples/python/optimize_benchmark.py b/samples/python/optimize_benchmark.py
new file mode 100644
index 0000000..96b06fc
--- /dev/null
+++ b/samples/python/optimize_benchmark.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+"""
+📉 OPTIMIZE API BENCHMARK
+=========================
+
+This script shows how well the Nitro Optimize API compresses your own PDFs.
+
+When you are evaluating a PDF compression service, published numbers only go
+so far: what matters is how much smaller YOUR documents get. This script runs
+a folder of PDFs through the Optimize API and reports the size reduction for
+every file, so you can judge the results on your own content.
+
+Each PDF is submitted as an asynchronous optimization job (so large documents
+work too), the optimized file is saved to the output folder, and the run ends
+with three artefacts: a per-file CSV, a per-profile summary CSV, and a
+self-contained HTML report with the headline numbers, a distribution chart
+and a filterable per-file table. The report opens in your browser when done.
+
+BENCHMARK FEATURES:
+ ✓ Runs every optimization profile you select (default: minimal-file-size)
+ ✓ Per-file size reduction, timing and failure detail
+ ✓ Self-contained HTML report you can share as one file
+
+USAGE:
+ python optimize_benchmark.py [--profile ...]
+
+EXAMPLES:
+ python optimize_benchmark.py ./sample_pdfs ./output
+ python optimize_benchmark.py ./sample_pdfs ./output -p minimal-file-size -p web
+"""
+
+import webbrowser
+from pathlib import Path
+from typing import Annotated, Literal, cast, get_args
+
+import typer
+
+from benchmark import (
+ OperationResult,
+ run_optimize,
+ summarise,
+ write_csv,
+ write_html,
+ write_summary_csv,
+)
+from benchmark.operations import PROFILES
+from helper_functions.document_helpers import validate_and_setup
+from services import OptimizerService
+
+Profile = Literal[
+ "minimal-file-size",
+ "web",
+ "print",
+ "archive",
+ "mixed-raster-content",
+]
+
+
+def _validate_profiles(values: list[str]) -> list[str]:
+ """Reject any --profile value that isn't a supported Optimize profile.
+
+ typer can't type a repeatable option as list[Literal[...]] directly (it
+ only supports "complex" sub-types for single-value options), so the CLI
+ surface stays list[str] and this callback is the actual gate.
+ """
+ valid = set(get_args(Profile))
+ for value in values:
+ if value not in valid:
+ msg = f"{value!r} is not a valid profile; choose from {', '.join(sorted(valid))}"
+ raise typer.BadParameter(msg)
+ return values
+
+
+def _echo_profiles(profiles: list[Profile]) -> None:
+ """Show which profiles will run, with what each is suited for."""
+ typer.echo("Profiles to run:")
+ for profile in profiles:
+ typer.echo(f" • {profile} — {PROFILES[profile]}")
+ typer.echo("")
+
+
+def _run_all(
+ optimizer_service: OptimizerService,
+ files: list[Path],
+ profiles: list[Profile],
+ output_folder: Path,
+) -> list[OperationResult]:
+ """Run every file through every selected profile, echoing progress."""
+ results: list[OperationResult] = []
+ total_runs = len(files) * len(profiles)
+ run = 0
+ for chosen in profiles:
+ for file_path in files:
+ run += 1
+ result = run_optimize(optimizer_service, file_path, chosen, output_folder)
+ prefix = f"[{run}/{total_runs}] {file_path.name} ({chosen}) ..."
+ took = f"🚀 took ~{result.duration_ms / 1000:.1f}s"
+ if result.status == "success":
+ typer.echo(f"{prefix} ✅ {result.reduction_pct:.1f}% smaller, {took}")
+ else:
+ typer.echo(f"{prefix} ❌ FAILED: {result.error_message}, {took}")
+ results.append(result)
+ return results
+
+
+def main(
+ input_folder: Annotated[Path, typer.Argument(help="Folder containing the PDFs to benchmark")],
+ output_folder: Annotated[Path, typer.Argument(help="Folder for optimized PDFs and the report")],
+ profiles: Annotated[
+ tuple[str],
+ typer.Option(
+ "--profile",
+ "-p",
+ help="Optimization profile to run; repeat to benchmark several",
+ callback=_validate_profiles,
+ ),
+ ] = ("minimal-file-size",),
+ *,
+ open_report: Annotated[
+ bool, typer.Option(help="Open the HTML report in a browser when done")
+ ] = False,
+) -> None:
+ """Benchmark the Optimize API on a folder of PDFs and produce an HTML report."""
+ validated_profiles = cast(list[Profile], list(profiles))
+ files = sorted(validate_and_setup(input_folder, output_folder, file_patterns=["*.pdf"]))
+ typer.echo(f"📋 Found {len(files)} PDF(s) in {input_folder}\n")
+ _echo_profiles(validated_profiles)
+
+ # Initialize API client (loads credentials from .env)
+ with OptimizerService.build() as optimizer_service:
+ results = _run_all(optimizer_service, files, validated_profiles, output_folder)
+
+ summary = summarise(results)
+ report_path = output_folder / "report.html"
+ write_csv(results, output_folder / "results.csv")
+ write_summary_csv(summary, output_folder / "summary.csv")
+ write_html(results, summary, report_path, default_variant=validated_profiles[0])
+
+ succeeded = sum(1 for r in results if r.status == "success")
+ typer.echo("\n" + "=" * 60)
+ typer.echo(f"✅ {succeeded}/{len(results)} run(s) succeeded")
+ typer.echo(f"📊 Report: {report_path.absolute()}")
+ typer.echo("=" * 60)
+
+ if open_report:
+ webbrowser.open(report_path.absolute().as_uri())
+
+
+if __name__ == "__main__":
+ typer.run(main)
diff --git a/samples/python/prepare_pdf_for_distribution.py b/samples/python/prepare_pdf_for_distribution.py
index f023af0..9e40c38 100755
--- a/samples/python/prepare_pdf_for_distribution.py
+++ b/samples/python/prepare_pdf_for_distribution.py
@@ -64,56 +64,55 @@ def main(
print(f"📋 Found {len(files)} document(s) to process\n")
# Initialize API client (loads credentials from .env)
- client = PlatformAPIClient()
+ with PlatformAPIClient.build() as client:
+ # Process each document
+ success_count = 0
+ failed_count = 0
- # Process each document
- success_count = 0
- failed_count = 0
+ for i, doc in enumerate(files, 1):
+ print(f"[{i}/{len(files)}] Processing: {doc.name}")
- for i, doc in enumerate(files, 1):
- print(f"[{i}/{len(files)}] Processing: {doc.name}")
+ temp_pdf = None
+ try:
+ # Step 1: Convert to PDF
+ print(" 🔐 Converting to PDF...")
+ pdf_bytes = client.convert(doc, "pdf")
- temp_pdf = None
- try:
- # Step 1: Convert to PDF
- print(" 🔐 Converting to PDF...")
- pdf_bytes = client.convert(doc, "pdf")
+ temp_pdf = output_folder / f"{doc.stem}_temp.pdf"
+ temp_pdf.write_bytes(pdf_bytes)
- temp_pdf = output_folder / f"{doc.stem}_temp.pdf"
- temp_pdf.write_bytes(pdf_bytes)
+ # Step 2: Compress PDF
+ print(" 📦 Compressing...")
+ compressed_pdf = client.compress(temp_pdf, level=2)
- # Step 2: Compress PDF
- print(" 📦 Compressing...")
- compressed_pdf = client.compress(temp_pdf, level=2)
+ temp_pdf.write_bytes(compressed_pdf)
- temp_pdf.write_bytes(compressed_pdf)
+ # Step 3: Remove metadata properties
+ print(" 🔒 Removing metadata...")
+ properties_to_clear = dict.fromkeys(PROPERTIES_TO_REMOVE, "")
+ clean_pdf = client.set_properties(temp_pdf, properties_to_clear)
- # Step 3: Remove metadata properties
- print(" 🔒 Removing metadata...")
- properties_to_clear = dict.fromkeys(PROPERTIES_TO_REMOVE, "")
- clean_pdf = client.set_properties(temp_pdf, properties_to_clear)
-
- # Save final PDF
- final_pdf = output_folder / f"{doc.stem}.pdf"
- final_pdf.write_bytes(clean_pdf)
- temp_pdf.unlink()
-
- print(f" ✅ Secured: {final_pdf.name}\n")
- success_count += 1
-
- except Exception as e: # noqa: BLE001
- print(f" ❌ FAILED: {e}\n")
- failed_count += 1
- if temp_pdf and temp_pdf.exists():
+ # Save final PDF
+ final_pdf = output_folder / f"{doc.stem}.pdf"
+ final_pdf.write_bytes(clean_pdf)
temp_pdf.unlink()
- # Display summary
- print("=" * 60)
- print(f"✅ {success_count} document(s) secured")
- if failed_count > 0:
- print(f"⚠️ {failed_count} document(s) FAILED - do NOT distribute!")
- print(f"📂 Output: {output_folder.absolute()}")
- print("=" * 60)
+ print(f" ✅ Secured: {final_pdf.name}\n")
+ success_count += 1
+
+ except Exception as e: # noqa: BLE001
+ print(f" ❌ FAILED: {e}\n")
+ failed_count += 1
+ if temp_pdf and temp_pdf.exists():
+ temp_pdf.unlink()
+
+ # Display summary
+ print("=" * 60)
+ print(f"✅ {success_count} document(s) secured")
+ if failed_count > 0:
+ print(f"⚠️ {failed_count} document(s) FAILED - do NOT distribute!")
+ print(f"📂 Output: {output_folder.absolute()}")
+ print("=" * 60)
if __name__ == '__main__':
diff --git a/samples/python/pyproject.toml b/samples/python/pyproject.toml
index 2769f9f..7ed07e9 100644
--- a/samples/python/pyproject.toml
+++ b/samples/python/pyproject.toml
@@ -12,11 +12,12 @@ description = "Nitro Platform API samples and examples"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
- "httpx>=0.27.0",
+ "httpx2>=0.27.0",
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"python-dotenv>=1.0.0",
"reportlab>=4.0.0",
+ "rich>=14.2.0",
"typer>=0.9.0",
]
[tool.ruff]
@@ -52,15 +53,13 @@ lint.select = [
"SIM", # simplifications
"C90", # mccabe complexity
"TID", # tidy imports
- "TC", # typing under TYPE_CHECKING
"TD", # TODOs must be annotated
"UP", # pyupgrade / newer syntax
"W", # pycodestyle warnings
"YTT", # flake8-2020
"T10", # debugger statements
]
-lint.ignore = ["S101", "S311", "TRY003", "TC003", "TC006"]
-lint.flake8-type-checking.strict = true
+lint.ignore = ["S101", "S311", "TRY003"]
[tool.pyright]
typeCheckingMode = "strict"
@@ -112,4 +111,4 @@ dev = [
"pylint>=4.0.4",
"pyright>=1.1.407",
"ruff>=0.8.0",
-]
\ No newline at end of file
+]
diff --git a/samples/python/quickstart.py b/samples/python/quickstart.py
index cbc34fc..270958a 100644
--- a/samples/python/quickstart.py
+++ b/samples/python/quickstart.py
@@ -2,7 +2,7 @@
import os
-import httpx
+import httpx2
from dotenv import load_dotenv
load_dotenv()
@@ -17,7 +17,7 @@ def get_access_token() -> str:
url = f"{BASE_URL}/oauth/token"
data = {"clientID": CLIENT_ID, "clientSecret": CLIENT_SECRET}
- response = httpx.post(url, json=data)
+ response = httpx2.post(url, json=data)
response.raise_for_status()
return response.json()["accessToken"]
@@ -28,14 +28,14 @@ def test_connection(token: str) -> bool | None:
headers = {"Authorization": f"Bearer {token}"}
try:
- response = httpx.get(url, headers=headers)
+ response = httpx2.get(url, headers=headers)
# 404 is expected for non-existent job, but proves auth works
if response.status_code == 404:
print("✅ Authentication successful (404 expected for test job ID)")
return True
response.raise_for_status()
return True # noqa: TRY300
- except httpx.HTTPStatusError as e:
+ except httpx2.HTTPStatusError as e:
if e.response.status_code == 404:
print("✅ Authentication successful (404 expected for test job ID)")
return True
diff --git a/samples/python/redact_by_keyword.py b/samples/python/redact_by_keyword.py
index 14dc367..67f8ecb 100644
--- a/samples/python/redact_by_keyword.py
+++ b/samples/python/redact_by_keyword.py
@@ -42,6 +42,7 @@
import typer
+from api import FatalError
from api.platform_api import PlatformAPIClient
app = typer.Typer()
@@ -56,62 +57,59 @@ def main(
"""Redact specific keywords from PDF documents using text search."""
# Validate input file exists
if not input_pdf.exists():
- print(f'❌ Error: Input file not found: {input_pdf}')
- raise typer.Exit(code=1)
+ raise FatalError(f'Input file not found: {input_pdf}')
# Validate input is a PDF
if input_pdf.suffix.lower() != '.pdf':
- print('❌ Error: Input must be a PDF file')
- raise typer.Exit(code=1)
+ raise FatalError('Input must be a PDF file')
# Create output directory if needed
output_pdf.parent.mkdir(parents=True, exist_ok=True)
# Initialize API client (loads credentials from .env)
- client = PlatformAPIClient()
-
- try:
- # Step 1: Search for keywords in document
- print(f'🔍 Searching for {len(keywords)} keyword(s) in {input_pdf.name}...')
- print(f" Keywords: {', '.join(repr(k) for k in keywords)}")
-
- bbox_data = client.find_text_boxes(input_pdf, keywords)
-
- # Extract text box locations from response
- text_boxes = bbox_data.get('result', {}).get('textBoxes', [])
-
- if not text_boxes:
- print('ℹ️ No keyword matches found - copying original file') # noqa: RUF001
- # Copy original file to output if no keywords found
- output_pdf.write_bytes(input_pdf.read_bytes())
- print(f'✅ Saved: {output_pdf.name}')
- print(f'📂 Output: {output_pdf.absolute()}')
- return
-
- print(f'🎯 Found {len(text_boxes)} keyword instance(s) to redact')
-
- # Step 2: Prepare redaction coordinates
- print("🔒 Applying redactions...")
- redactions = [
- {"pageIndex": box["pageIndex"], "boundingBox": box["boundingBox"]} for box in text_boxes
- ]
-
- # Step 3: Apply redactions to document
- redacted_pdf = client.redact(input_pdf, redactions)
-
- # Save redacted PDF
- output_pdf.write_bytes(redacted_pdf)
-
- # Display success message
- print('✅ Redaction successful!')
- print(f'🔒 Redacted: {len(text_boxes)} instance(s)')
- print(f'📄 Input: {input_pdf.name}')
- print(f'📄 Output: {output_pdf.name}')
- print(f'📂 Saved to: {output_pdf.absolute()}')
-
- except Exception as e: # noqa: BLE001
- print(f'❌ Redaction FAILED: {e}')
- raise typer.Exit(code=1) from None
+ with PlatformAPIClient.build() as client:
+ try:
+ # Step 1: Search for keywords in document
+ print(f'🔍 Searching for {len(keywords)} keyword(s) in {input_pdf.name}...')
+ print(f" Keywords: {', '.join(repr(k) for k in keywords)}")
+
+ bbox_data = client.find_text_boxes(input_pdf, keywords)
+
+ # Extract text box locations from response
+ text_boxes = bbox_data.get('result', {}).get('textBoxes', [])
+
+ if not text_boxes:
+ print('ℹ️ No keyword matches found - copying original file') # noqa: RUF001
+ # Copy original file to output if no keywords found
+ output_pdf.write_bytes(input_pdf.read_bytes())
+ print(f'✅ Saved: {output_pdf.name}')
+ print(f'📂 Output: {output_pdf.absolute()}')
+ return
+
+ print(f'🎯 Found {len(text_boxes)} keyword instance(s) to redact')
+
+ # Step 2: Prepare redaction coordinates
+ print("🔒 Applying redactions...")
+ redactions = [
+ {"pageIndex": box["pageIndex"], "boundingBox": box["boundingBox"]}
+ for box in text_boxes
+ ]
+
+ # Step 3: Apply redactions to document
+ redacted_pdf = client.redact(input_pdf, redactions)
+
+ # Save redacted PDF
+ output_pdf.write_bytes(redacted_pdf)
+
+ # Display success message
+ print('✅ Redaction successful!')
+ print(f'🔒 Redacted: {len(text_boxes)} instance(s)')
+ print(f'📄 Input: {input_pdf.name}')
+ print(f'📄 Output: {output_pdf.name}')
+ print(f'📂 Saved to: {output_pdf.absolute()}')
+
+ except Exception as e: # noqa: BLE001
+ raise FatalError(f'Redaction FAILED: {e}') from None
if __name__ == '__main__':
diff --git a/samples/python/services/__init__.py b/samples/python/services/__init__.py
new file mode 100644
index 0000000..144e649
--- /dev/null
+++ b/samples/python/services/__init__.py
@@ -0,0 +1,5 @@
+"""Higher-level services built on top of the API clients."""
+
+from .optimizer_service import OptimizerService
+
+__all__ = ["OptimizerService"]
diff --git a/samples/python/services/optimizer_service.py b/samples/python/services/optimizer_service.py
new file mode 100644
index 0000000..49a6f58
--- /dev/null
+++ b/samples/python/services/optimizer_service.py
@@ -0,0 +1,43 @@
+"""Service for running Optimize API jobs against a PDF."""
+
+import contextlib
+from collections.abc import Generator
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Self
+
+from api.platform_api import PlatformAPIClient
+
+
+@dataclass
+class OptimizerService:
+ """Optimize one PDF with a profile and save the result to disk."""
+
+ _client: PlatformAPIClient
+
+ @classmethod
+ @contextlib.contextmanager
+ def build(cls) -> Generator[Self]:
+ """Build a service backed by an authenticated Platform API client."""
+ with PlatformAPIClient.build() as client:
+ yield cls(client)
+
+ def optimize(self, pdf_path: Path, profile: str) -> bytes:
+ """Optimize ``pdf_path`` with ``profile`` and return the resulting PDF bytes."""
+ return self._client.optimize(pdf_path, profile)
+
+ def run(self, pdf_path: Path, profile: str, output_dir: Path) -> Path:
+ """Optimize ``pdf_path`` with ``profile`` and write the result into ``output_dir``.
+
+ Args:
+ pdf_path: The PDF to optimize.
+ profile: The optimization profile to apply.
+ output_dir: Directory the optimized PDF is written into.
+
+ Returns:
+ Path to the optimized PDF that was written.
+ """
+ optimized = self._client.optimize(pdf_path, profile)
+ output_path = output_dir / pdf_path.name
+ output_path.write_bytes(optimized)
+ return output_path
diff --git a/samples/python/smart_redact_pii.py b/samples/python/smart_redact_pii.py
index c3c0718..1cbbb89 100644
--- a/samples/python/smart_redact_pii.py
+++ b/samples/python/smart_redact_pii.py
@@ -52,68 +52,67 @@ def main(
print(f"📋 Found {len(files)} PDF document(s) to process")
# Initialize API client
- client = PlatformAPIClient()
+ with PlatformAPIClient.build() as client:
+ # Process each document
+ success_count = 0
+ failed_count = 0
+ total_pii_count = 0
- # Process each document
- success_count = 0
- failed_count = 0
- total_pii_count = 0
+ for i, pdf_file in enumerate(files, 1):
+ print(f"[{i}/{len(files)}] Processing: {pdf_file.name}")
- for i, pdf_file in enumerate(files, 1):
- print(f"[{i}/{len(files)}] Processing: {pdf_file.name}")
+ try:
+ # Step 1: Detect PII in the document
+ print(" 🔍 Detecting PII...")
+ pii_data = client.detect_pii(pdf_file)
- try:
- # Step 1: Detect PII in the document
- print(" 🔍 Detecting PII...")
- pii_data = client.detect_pii(pdf_file)
+ # Extract PII bounding boxes from response
+ pii_boxes = pii_data.get("result", {}).get("PIIBoxes", [])
- # Extract PII bounding boxes from response
- pii_boxes = pii_data.get("result", {}).get("PIIBoxes", [])
+ if not pii_boxes:
+ print(" ℹ️ No PII detected - copying original file") # noqa: RUF001
- if not pii_boxes:
- print(" ℹ️ No PII detected - copying original file") # noqa: RUF001
+ # Copy original file to output if no PII found
+ output_file = output_folder / pdf_file.name
+ output_file.write_bytes(pdf_file.read_bytes())
- # Copy original file to output if no PII found
- output_file = output_folder / pdf_file.name
- output_file.write_bytes(pdf_file.read_bytes())
+ print(f" ✅ Saved: {output_file.name}")
+
+ success_count += 1
+ continue
+
+ print(f" 🎯 Found {len(pii_boxes)} PII instance(s)")
+ total_pii_count += len(pii_boxes)
+
+ # Step 2: Prepare redaction coordinates
+ print(" 🔒 Applying redactions...")
+ redactions = [
+ {"pageIndex": box["pageIndex"], "boundingBox": box["boundingBox"]}
+ for box in pii_boxes
+ ]
- print(f" ✅ Saved: {output_file.name}")
+ # Step 3: Apply redactions to document
+ redacted_pdf = client.redact(pdf_file, redactions)
+ # Save redacted PDF
+ output_file = output_folder / pdf_file.name
+ output_file.write_bytes(redacted_pdf)
+
+ print(f" ✅ Redacted: {output_file.name}")
success_count += 1
- continue
-
- print(f" 🎯 Found {len(pii_boxes)} PII instance(s)")
- total_pii_count += len(pii_boxes)
-
- # Step 2: Prepare redaction coordinates
- print(" 🔒 Applying redactions...")
- redactions = [
- {"pageIndex": box["pageIndex"], "boundingBox": box["boundingBox"]}
- for box in pii_boxes
- ]
-
- # Step 3: Apply redactions to document
- redacted_pdf = client.redact(pdf_file, redactions)
-
- # Save redacted PDF
- output_file = output_folder / pdf_file.name
- output_file.write_bytes(redacted_pdf)
-
- print(f" ✅ Redacted: {output_file.name}")
- success_count += 1
-
- except Exception as e: # noqa: BLE001
- print(f" ❌ FAILED: {e}")
- failed_count += 1
-
- # Display summary
- print("=" * 60)
- print(f"✅ {success_count} document(s) processed")
- print(f"🔒 {total_pii_count} total PII instance(s) redacted")
- if failed_count > 0:
- print(f"⚠️ {failed_count} document(s) FAILED - review manually!")
- print(f"📂 Output: {output_folder.absolute()}")
- print("=" * 60)
+
+ except Exception as e: # noqa: BLE001
+ print(f" ❌ FAILED: {e}")
+ failed_count += 1
+
+ # Display summary
+ print("=" * 60)
+ print(f"✅ {success_count} document(s) processed")
+ print(f"🔒 {total_pii_count} total PII instance(s) redacted")
+ if failed_count > 0:
+ print(f"⚠️ {failed_count} document(s) FAILED - review manually!")
+ print(f"📂 Output: {output_folder.absolute()}")
+ print("=" * 60)
if __name__ == '__main__':
diff --git a/samples/python/uv.lock b/samples/python/uv.lock
index b94dd7b..e63a271 100644
--- a/samples/python/uv.lock
+++ b/samples/python/uv.lock
@@ -1,81 +1,139 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.14"
+[[package]]
+name = "annotated-doc"
+version = "0.0.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
+]
+
[[package]]
name = "annotated-types"
-version = "0.7.0"
+version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
-version = "4.12.0"
+version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
+ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "astroid"
-version = "4.0.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b7/22/97df040e15d964e592d3a180598ace67e91b7c559d8298bdb3c949dc6e42/astroid-4.0.2.tar.gz", hash = "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070", size = 405714, upload-time = "2025-11-09T21:21:18.373Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/93/ac/a85b4bfb4cf53221513e27f33cc37ad158fce02ac291d18bee6b49ab477d/astroid-4.0.2-py3-none-any.whl", hash = "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b", size = 276354, upload-time = "2025-11-09T21:21:16.54Z" },
-]
-
-[[package]]
-name = "certifi"
-version = "2025.11.12"
+version = "4.0.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
]
[[package]]
name = "charset-normalizer"
-version = "3.4.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
- { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
- { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
- { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
- { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
- { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
- { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
- { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
- { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
- { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
- { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
- { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
- { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
- { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
- { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
- { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
- { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
-]
-
-[[package]]
-name = "click"
-version = "8.3.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+version = "3.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
+ { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
+ { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
+ { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
+ { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
+ { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
+ { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
+ { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
+ { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
+ { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
+ { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
+ { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
+ { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
+ { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
+ { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
+ { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
+ { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
+ { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
+ { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
+ { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
+ { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
+ { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
+ { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
+ { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
+ { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
+ { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
+ { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
+ { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
+ { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
]
[[package]]
@@ -89,11 +147,11 @@ wheels = [
[[package]]
name = "dill"
-version = "0.4.0"
+version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
]
[[package]]
@@ -106,61 +164,92 @@ wheels = [
]
[[package]]
-name = "httpcore"
-version = "1.0.9"
+name = "httpcore2"
+version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "certifi" },
{ name = "h11" },
+ { name = "truststore" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
]
[[package]]
-name = "httpx"
-version = "0.28.1"
+name = "httpx2"
+version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "anyio" },
- { name = "certifi" },
- { name = "httpcore" },
+ { name = "anyio", marker = "sys_platform != 'emscripten'" },
+ { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
+ { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
{ name = "idna" },
+ { name = "truststore", marker = "sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+
+[[package]]
+name = "httpx2-jsfetch"
+version = "1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]]
name = "idna"
-version = "3.11"
+version = "3.19"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
]
[[package]]
name = "isort"
-version = "7.0.0"
+version = "9.0.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" },
+dependencies = [
+ { name = "mypy-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e6/43/067e17bfa10b6486b408d5294105ac894149a9abb94b338568b1f53a73c9/isort-9.0.1.tar.gz", hash = "sha256:ba23db109e3e93ef1999f7209a651214994cd807801addd16ac485982eb4edd7", size = 667724, upload-time = "2026-08-27T20:54:26.699Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/42/d75a674a7fcbbf4374cd358b8529535d3c59e0ab6acc164b6862acb95058/isort-9.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7ea5f505b152fedd2b990b39d8b76108a48b355da874025aad4982e8ceeb0f3d", size = 1020950, upload-time = "2026-08-27T20:53:57.744Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/b6/04d74c7c42b9af1c0d47f34a79722dbbcff71bae3765c9250376ebd44ec5/isort-9.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:873cf1b6371d41e2a74d57d7c0176d311822f0415441abf8251ad074c9fe4a66", size = 1653678, upload-time = "2026-08-27T20:53:59.292Z" },
+ { url = "https://files.pythonhosted.org/packages/53/6c/621969c0cde272b7a183484589cc9f28132775d78fd708c231fd292c7abd/isort-9.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99b7bc28b1f05f7e3267629043a99c6c479a750df3689327a10324e396827f94", size = 1645589, upload-time = "2026-08-27T20:54:00.911Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/26/e3db1ae5d5fbe9e2dff0c6a0b2b9dda07a742ede20f8fbc7d78d5d73f472/isort-9.0.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:89ebbcdbdd9d66cc14909bbac36acb9db29f37325606113c9f270242f8a1f896", size = 457029, upload-time = "2026-08-27T20:54:02.551Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/f2/b9cf816c8dfd5d9066b65a2f738f8d03fb0f6001bcffa3d436cfdbe1b421/isort-9.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:2057236a764f31c78dac78f7343057621fcc2fd40461ce61061f34fd09066f46", size = 894580, upload-time = "2026-08-27T20:54:03.985Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/d7/e5e9e477ff7ec9f75b4c07b25aef10814775200c1e90bc9acbb788def54a/isort-9.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5e72a7063570f1d740f0284c7ae5739dc34c6a2d9f1049b13027a5bdadb56682", size = 1088685, upload-time = "2026-08-27T20:54:05.376Z" },
+ { url = "https://files.pythonhosted.org/packages/96/6f/915effa62cdcdc757bd0c567ee4d611c7a73ce7da097c820b3b2f340c2f7/isort-9.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2525606f62742fc4ed9f8ca89043b9522ac3e6f9c9892e6cb16f4870d937f38", size = 1863445, upload-time = "2026-08-27T20:54:06.851Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/d4/af84d711cda084ddcb7a01a3d6a2608542c16ae7d418d99169b7deb775a4/isort-9.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e3a2697ebcb54b51af4833de44447dbf31ddf081c5f163772092d21c0267483b", size = 1878639, upload-time = "2026-08-27T20:54:08.618Z" },
+ { url = "https://files.pythonhosted.org/packages/58/d0/d77ec7d1c648a0b92ea6a8da3591a342550404af98a338106278a872ea1a/isort-9.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd326823ddbe338357ba1823b7f96481d4421d54c83ebd43c92f1b51314a24ae", size = 919565, upload-time = "2026-08-27T20:54:10.094Z" },
+ { url = "https://files.pythonhosted.org/packages/72/d8/9963610d22fa55cbbd3c141a350f7011dd1023be9d3ba06212053c3639c6/isort-9.0.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:5022b332ac91ccb39dc28bb206d5ae96ae7f8d45e710b072cb039b2fcda6602a", size = 1020254, upload-time = "2026-08-27T20:54:11.732Z" },
+ { url = "https://files.pythonhosted.org/packages/11/20/9f22f2574d94cc2b86f7c1764a186843ed19e7215666b57b10935335614f/isort-9.0.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:930879e4cfab3264f1d7346abeec10726b5382dc4be9f4251c25ec7fa057926b", size = 1667500, upload-time = "2026-08-27T20:54:13.193Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/4a/5a46b814d7fa49fc778e65a6703e4bb4d60149e01574afd7366b5ac7a1dc/isort-9.0.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:825c05d2d63a1b9c608c352503c10b6411a3c6e12bcacc97b306774ee379786f", size = 1655914, upload-time = "2026-08-27T20:54:14.827Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/09/b575e1837f3b182c97f937cf39cb55b3942f44dedfec4303403e4aa9bbd0/isort-9.0.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:cc9814ce2ee42c17007d822455e4db55e32e589808ecfc2665d51c848d0bb30a", size = 457120, upload-time = "2026-08-27T20:54:16.145Z" },
+ { url = "https://files.pythonhosted.org/packages/65/60/3c73139b71b7031caf54aa098bc589b18374533b5f96f31c541b36112896/isort-9.0.1-cp315-cp315-win_amd64.whl", hash = "sha256:1b8d6c836fb83232f5f4c1c037d332caf743bb24dca63167bad9174ae13e150e", size = 894525, upload-time = "2026-08-27T20:54:17.895Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/27/5c17d4c8239a33a876f91e52426cc0d3fc75eb8438f34c40f403998057ac/isort-9.0.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2fb33e0c0f9f87821acf6d82c83f0a0c7e54680fdf3fe4131409d2b95901f00a", size = 1088576, upload-time = "2026-08-27T20:54:19.599Z" },
+ { url = "https://files.pythonhosted.org/packages/32/9b/9e80dee125b3d1208fb89afa380cae6fdfac660ad682eb0139a8fd8d790a/isort-9.0.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cdf765657edb2bcccbb1b20d26e710acbcb27379c0a407c6cb376e5619059a7b", size = 1853379, upload-time = "2026-08-27T20:54:21.119Z" },
+ { url = "https://files.pythonhosted.org/packages/34/43/f9f456c223ba34293ab28f736f00e26875b26913f24451e5a2938a8c4e02/isort-9.0.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23d3b6657763f9be1b15bb9664b016abfce34849d6215a46a42af7945d4acd68", size = 1871162, upload-time = "2026-08-27T20:54:22.593Z" },
+ { url = "https://files.pythonhosted.org/packages/38/5c/17fedc2bee564bb13b50ed3cb2ce7d66dd8e09184fb45d7a263ebaaaa97e/isort-9.0.1-cp315-cp315t-win_amd64.whl", hash = "sha256:8f490acc182253d07071cc8255b57a281855e2e027b929a89eaa7c797f7b213e", size = 918269, upload-time = "2026-08-27T20:54:23.996Z" },
+ { url = "https://files.pythonhosted.org/packages/af/6e/4ec84f19b008864656b9f158bad006aa6346e754694aff47a7fa9ee65a19/isort-9.0.1-py3-none-any.whl", hash = "sha256:5aac7263b7a7f9f647f94fb6df2761ff5b60a7168eb492ff39dd30443207fa19", size = 103487, upload-time = "2026-08-27T20:54:25.395Z" },
]
[[package]]
name = "markdown-it-py"
-version = "4.0.0"
+version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
@@ -181,16 +270,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
[[package]]
name = "nitro-platform-samples"
version = "0.1.0"
source = { editable = "." }
dependencies = [
- { name = "httpx" },
+ { name = "httpx2" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "reportlab" },
+ { name = "rich" },
{ name = "typer" },
]
@@ -204,7 +303,7 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "httpx", specifier = ">=0.27.0" },
+ { name = "httpx2", specifier = ">=0.27.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "pyenchant", marker = "extra == 'dev'", specifier = ">=3.3.0" },
@@ -212,6 +311,7 @@ requires-dist = [
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.407" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "reportlab", specifier = ">=4.0.0" },
+ { name = "rich", specifier = ">=14.2.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
{ name = "typer", specifier = ">=0.9.0" },
]
@@ -228,49 +328,66 @@ wheels = [
[[package]]
name = "pillow"
-version = "12.0.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
- { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
- { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
- { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
- { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
- { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
- { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
- { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
- { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
- { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
- { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
- { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
- { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
- { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
- { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
- { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
- { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
- { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
- { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
- { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
- { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
- { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
- { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
- { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
- { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
+version = "12.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
+ { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
+ { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
+ { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
+ { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
+ { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
+ { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
+ { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
+ { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
+ { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
+ { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
+ { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
+ { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
+ { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
]
[[package]]
name = "platformdirs"
-version = "4.5.1"
+version = "4.11.8"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/18/f3bb8ef0d3b930692343da8aa4d3cbcd6749477c053959395ac81965a6e9/platformdirs-4.11.8.tar.gz", hash = "sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc", size = 37182, upload-time = "2026-09-08T22:20:42.866Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/e1/5b7b8bbb55084d1425bcb9bc823ff519e1b2be05f6ebb0089e2eacc38413/platformdirs-4.11.8-py3-none-any.whl", hash = "sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081", size = 24027, upload-time = "2026-09-08T22:20:41.537Z" },
]
[[package]]
name = "pydantic"
-version = "2.12.5"
+version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -278,62 +395,64 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
-version = "2.41.5"
+version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
- { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
- { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
- { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
- { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
- { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
- { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
- { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
- { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
- { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
- { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
- { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
- { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
- { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
- { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
- { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
- { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
- { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
- { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
- { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
- { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
- { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
- { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
- { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
- { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
- { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
- { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
- { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
+ { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
+ { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
+ { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
+ { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
+ { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
+ { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
+ { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
+ { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
+ { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
+ { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
+ { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
+ { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
]
[[package]]
name = "pydantic-settings"
-version = "2.12.0"
+version = "2.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
]
[[package]]
@@ -349,16 +468,16 @@ wheels = [
[[package]]
name = "pygments"
-version = "2.19.2"
+version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pylint"
-version = "4.0.4"
+version = "4.0.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astroid" },
@@ -369,83 +488,82 @@ dependencies = [
{ name = "platformdirs" },
{ name = "tomlkit" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/2e/424cbfcb3af792a6b7da6e5c640de463b98c8d3a49fbd8882f38e83d4232/pylint-4.0.8.tar.gz", hash = "sha256:1c1b2128bde5ff5e966801413080b6384d42a5782718d528c906dbb6beab94ed", size = 1599177, upload-time = "2026-08-29T12:21:59.188Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/ea/9b601859afa055afe659ffe55abdfed6e5f17e17a840d9cd61c54ac89c02/pylint-4.0.8-py3-none-any.whl", hash = "sha256:3341c08c0aabaa4adc71516de0969f3ba5c692b56c75af4dcb4d242823fbe363", size = 540709, upload-time = "2026-08-29T12:21:57.392Z" },
]
[[package]]
name = "pyright"
-version = "1.1.407"
+version = "1.1.413"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4a/eb/343ee766d0a6a9259366b07dba28543753a53ae76dc6d660e5f97df56411/pyright-1.1.413.tar.gz", hash = "sha256:40357dc6bd967e87a62877574ae79195d44e00d02917c8230e1d4b94ea88fccb", size = 4127365, upload-time = "2026-09-09T18:36:21.337Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/0a/dad5520e95b00ff65fba1d0b538e4fe0e5a5e33117829043bf8f523ecfb5/pyright-1.1.413-py3-none-any.whl", hash = "sha256:1d74426e431b49f803735632910cc3db7611f904693b7775224ae752f389cf4f", size = 6197123, upload-time = "2026-09-09T18:36:19.23Z" },
]
[[package]]
name = "python-dotenv"
-version = "1.2.1"
+version = "1.2.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
]
[[package]]
name = "reportlab"
-version = "4.4.7"
+version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "pillow" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f8/a7/4600cb1cfc975a06552e8927844ddcb8fd90217e9a6068f5c7aa76c3f221/reportlab-4.4.7.tar.gz", hash = "sha256:41e8287af965e5996764933f3e75e7f363c3b6f252ba172f9429e81658d7b170", size = 3714000, upload-time = "2025-12-21T11:50:11.336Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4a/51/dbe28534ae12c852f61be91f039f343305fd1f34f1c66b8de75afae7a525/reportlab-5.0.1.tar.gz", hash = "sha256:ebd13154be1c8515e665de70bd2d303ae9ddc3ef47e44afd5116441ca0283a26", size = 3945711, upload-time = "2026-08-20T13:48:16.461Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e7/bf/a29507386366ab17306b187ad247dd78e4599be9032cb5f44c940f547fc0/reportlab-4.4.7-py3-none-any.whl", hash = "sha256:8fa05cbf468e0e76745caf2029a4770276edb3c8e86a0b71e0398926baf50673", size = 1954263, upload-time = "2025-12-21T11:50:08.93Z" },
+ { url = "https://files.pythonhosted.org/packages/db/cb/dacbc268cb68d0428ea2cbd85266195a9ab3e677449589ddae59bd7542ac/reportlab-5.0.1-py3-none-any.whl", hash = "sha256:1c36e6bb0e71780c72331eba60da7f602e8d4389a8723825af71342e49d791e8", size = 1957258, upload-time = "2026-08-20T13:48:14.026Z" },
]
[[package]]
name = "rich"
-version = "14.2.0"
+version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "ruff"
-version = "0.14.10"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
- { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
- { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
- { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
- { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
- { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
- { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
- { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
- { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
- { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
- { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
- { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
- { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
- { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
- { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
- { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
- { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
- { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
+version = "0.16.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" },
+ { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" },
+ { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" },
+ { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" },
+ { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" },
+ { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" },
+ { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" },
+ { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
]
[[package]]
@@ -459,45 +577,54 @@ wheels = [
[[package]]
name = "tomlkit"
-version = "0.13.3"
+version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" },
+ { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" },
+]
+
+[[package]]
+name = "truststore"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "typer"
-version = "0.21.0"
+version = "0.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click" },
+ { name = "annotated-doc" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "shellingham" },
- { name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/85/30/ff9ede605e3bd086b4dd842499814e128500621f7951ca1e5ce84bbf61b1/typer-0.21.0.tar.gz", hash = "sha256:c87c0d2b6eee3b49c5c64649ec92425492c14488096dfbc8a0c2799b2f6f9c53", size = 106781, upload-time = "2025-12-25T09:54:53.651Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e1/e4/5ebc1899d31d2b1601b32d21cfb4bba022ae6fce323d365f0448031b1660/typer-0.21.0-py3-none-any.whl", hash = "sha256:c79c01ca6b30af9fd48284058a7056ba0d3bf5cf10d0ff3d0c5b11b68c258ac6", size = 47109, upload-time = "2025-12-25T09:54:51.918Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" },
]
[[package]]
name = "typing-extensions"
-version = "4.15.0"
+version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
-version = "0.4.2"
+version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
diff --git a/test_files/optimize-benchmark/image-heavy.pdf b/test_files/optimize-benchmark/image-heavy.pdf
new file mode 100644
index 0000000..04fd94f
Binary files /dev/null and b/test_files/optimize-benchmark/image-heavy.pdf differ
diff --git a/test_files/optimize-benchmark/mixed.pdf b/test_files/optimize-benchmark/mixed.pdf
new file mode 100644
index 0000000..bd58ecb
Binary files /dev/null and b/test_files/optimize-benchmark/mixed.pdf differ
diff --git a/test_files/optimize-benchmark/text-heavy.pdf b/test_files/optimize-benchmark/text-heavy.pdf
new file mode 100644
index 0000000..2c6faea
Binary files /dev/null and b/test_files/optimize-benchmark/text-heavy.pdf differ