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"{head}{body}
" + + +# -------------------------------------------------------------------- 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 " 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'
{tiles}
' + + +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'' + ) + + if len(names) > 1: + combined = _hist_svg(series, "Distribution of size reduction by profile") + boxes.append(f'') + options.append('') + picker = f'' + else: + picker = "" + + head = f'
{picker}
' + 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'' for v in variants + ) + profile_filter = ( + f'" + ) + + 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 ( + '
' + '
' + '

Per-file results

' + f"{profile_filter}" + '" + '' + '' + "
" + f'
{headers}' + f'{_initial_tbody(rows)}
' + f'
{initial_range}' + '' + f'Page 1 / {pages}' + f'
' + "
" + ) + + +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), + '

Nitro Optimize API Benchmark · ' + "results.csv and summary.csv accompany this report

", + ]) + + 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