formatjet 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- formatjet/__init__.py +53 -0
- formatjet/_client.py +417 -0
- formatjet/_errors.py +75 -0
- formatjet/_transport.py +66 -0
- formatjet/_webhooks.py +93 -0
- formatjet/openapi/__init__.py +9 -0
- formatjet/openapi/api/__init__.py +1 -0
- formatjet/openapi/api/account/__init__.py +1 -0
- formatjet/openapi/api/account/get_account.py +144 -0
- formatjet/openapi/api/account/get_usage.py +197 -0
- formatjet/openapi/api/catalog/__init__.py +1 -0
- formatjet/openapi/api/catalog/get_pricing.py +128 -0
- formatjet/openapi/api/catalog/list_formats.py +128 -0
- formatjet/openapi/api/catalog/list_operations.py +193 -0
- formatjet/openapi/api/jobs/__init__.py +1 -0
- formatjet/openapi/api/jobs/cancel_job.py +220 -0
- formatjet/openapi/api/jobs/convert.py +396 -0
- formatjet/openapi/api/jobs/create_job.py +241 -0
- formatjet/openapi/api/jobs/create_upload.py +230 -0
- formatjet/openapi/api/jobs/delete_job.py +188 -0
- formatjet/openapi/api/jobs/estimate_job.py +239 -0
- formatjet/openapi/api/jobs/get_job.py +188 -0
- formatjet/openapi/api/jobs/list_jobs.py +208 -0
- formatjet/openapi/api/jobs/stream_job_events.py +194 -0
- formatjet/openapi/api/webhooks/__init__.py +1 -0
- formatjet/openapi/api/webhooks/create_webhook.py +225 -0
- formatjet/openapi/api/webhooks/delete_webhook.py +174 -0
- formatjet/openapi/api/webhooks/get_webhook.py +176 -0
- formatjet/openapi/api/webhooks/list_webhook_deliveries.py +234 -0
- formatjet/openapi/api/webhooks/list_webhooks.py +144 -0
- formatjet/openapi/api/webhooks/replay_webhook_event.py +242 -0
- formatjet/openapi/api/webhooks/rotate_webhook_secret.py +224 -0
- formatjet/openapi/api/webhooks/update_webhook.py +214 -0
- formatjet/openapi/client.py +280 -0
- formatjet/openapi/errors.py +16 -0
- formatjet/openapi/models/__init__.py +231 -0
- formatjet/openapi/models/account.py +114 -0
- formatjet/openapi/models/account_credits.py +102 -0
- formatjet/openapi/models/account_credits_monthly_type_0.py +78 -0
- formatjet/openapi/models/account_key.py +107 -0
- formatjet/openapi/models/account_key_mode.py +16 -0
- formatjet/openapi/models/account_key_scopes_item.py +17 -0
- formatjet/openapi/models/account_limits.py +111 -0
- formatjet/openapi/models/account_organization.py +69 -0
- formatjet/openapi/models/account_plan.py +69 -0
- formatjet/openapi/models/convert_files_body.py +75 -0
- formatjet/openapi/models/convert_from.py +95 -0
- formatjet/openapi/models/convert_to.py +95 -0
- formatjet/openapi/models/deletion_receipt_response.py +79 -0
- formatjet/openapi/models/deletion_receipt_response_receipt.py +50 -0
- formatjet/openapi/models/estimate.py +136 -0
- formatjet/openapi/models/estimate_credit_weight.py +70 -0
- formatjet/openapi/models/estimate_from.py +95 -0
- formatjet/openapi/models/estimate_request.py +79 -0
- formatjet/openapi/models/estimate_request_steps_item.py +124 -0
- formatjet/openapi/models/estimate_request_steps_item_from.py +95 -0
- formatjet/openapi/models/estimate_request_steps_item_op.py +15 -0
- formatjet/openapi/models/estimate_request_steps_item_options.py +47 -0
- formatjet/openapi/models/estimate_request_steps_item_to.py +95 -0
- formatjet/openapi/models/estimate_to.py +95 -0
- formatjet/openapi/models/format_.py +109 -0
- formatjet/openapi/models/job.py +297 -0
- formatjet/openapi/models/job_error_type_0.py +69 -0
- formatjet/openapi/models/job_estimate.py +61 -0
- formatjet/openapi/models/job_input.py +163 -0
- formatjet/openapi/models/job_input_format_type_1.py +95 -0
- formatjet/openapi/models/job_input_format_type_2_type_1.py +95 -0
- formatjet/openapi/models/job_input_format_type_3_type_1.py +95 -0
- formatjet/openapi/models/job_inputs_item.py +165 -0
- formatjet/openapi/models/job_inputs_item_format_type_1.py +95 -0
- formatjet/openapi/models/job_inputs_item_format_type_2_type_1.py +97 -0
- formatjet/openapi/models/job_inputs_item_format_type_3_type_1.py +97 -0
- formatjet/openapi/models/job_links.py +69 -0
- formatjet/openapi/models/job_metadata.py +45 -0
- formatjet/openapi/models/job_mode.py +14 -0
- formatjet/openapi/models/job_outputs_item.py +133 -0
- formatjet/openapi/models/job_request.py +213 -0
- formatjet/openapi/models/job_request_input_type_0.py +42 -0
- formatjet/openapi/models/job_request_input_type_1.py +53 -0
- formatjet/openapi/models/job_request_input_type_2.py +54 -0
- formatjet/openapi/models/job_request_inputs_item_type_0.py +42 -0
- formatjet/openapi/models/job_request_inputs_item_type_1.py +53 -0
- formatjet/openapi/models/job_request_metadata.py +47 -0
- formatjet/openapi/models/job_request_output.py +51 -0
- formatjet/openapi/models/job_request_steps_item.py +120 -0
- formatjet/openapi/models/job_request_steps_item_from.py +95 -0
- formatjet/openapi/models/job_request_steps_item_op.py +15 -0
- formatjet/openapi/models/job_request_steps_item_options.py +47 -0
- formatjet/openapi/models/job_request_steps_item_to.py +95 -0
- formatjet/openapi/models/job_status.py +29 -0
- formatjet/openapi/models/job_steps_item.py +234 -0
- formatjet/openapi/models/job_steps_item_from_type_1.py +95 -0
- formatjet/openapi/models/job_steps_item_from_type_2_type_1.py +95 -0
- formatjet/openapi/models/job_steps_item_from_type_3_type_1.py +95 -0
- formatjet/openapi/models/job_steps_item_op.py +15 -0
- formatjet/openapi/models/job_steps_item_options.py +45 -0
- formatjet/openapi/models/job_steps_item_status.py +29 -0
- formatjet/openapi/models/job_steps_item_to_type_1.py +95 -0
- formatjet/openapi/models/job_steps_item_to_type_2_type_1.py +95 -0
- formatjet/openapi/models/job_steps_item_to_type_3_type_1.py +95 -0
- formatjet/openapi/models/job_summary.py +160 -0
- formatjet/openapi/models/job_summary_mode.py +16 -0
- formatjet/openapi/models/job_summary_status.py +29 -0
- formatjet/openapi/models/list_formats_response_200.py +84 -0
- formatjet/openapi/models/list_jobs_response_200.py +95 -0
- formatjet/openapi/models/list_operations_from.py +95 -0
- formatjet/openapi/models/list_operations_response_200.py +82 -0
- formatjet/openapi/models/list_operations_to.py +95 -0
- formatjet/openapi/models/list_webhook_deliveries_response_200.py +95 -0
- formatjet/openapi/models/list_webhooks_response_200.py +81 -0
- formatjet/openapi/models/operation.py +148 -0
- formatjet/openapi/models/operation_credits.py +69 -0
- formatjet/openapi/models/operation_engines_item.py +87 -0
- formatjet/openapi/models/operation_engines_item_engines_item.py +69 -0
- formatjet/openapi/models/operation_files.py +70 -0
- formatjet/openapi/models/operation_kind.py +16 -0
- formatjet/openapi/models/operation_options_schema.py +45 -0
- formatjet/openapi/models/pricing.py +153 -0
- formatjet/openapi/models/pricing_credit_packs_item.py +77 -0
- formatjet/openapi/models/pricing_credit_weights_item.py +85 -0
- formatjet/openapi/models/pricing_plans_item.py +156 -0
- formatjet/openapi/models/pricing_plans_item_overage_type_1.py +61 -0
- formatjet/openapi/models/problem.py +155 -0
- formatjet/openapi/models/problem_code.py +47 -0
- formatjet/openapi/models/problem_errors_item.py +69 -0
- formatjet/openapi/models/replay_webhook_event_body.py +45 -0
- formatjet/openapi/models/upload.py +115 -0
- formatjet/openapi/models/upload_headers.py +47 -0
- formatjet/openapi/models/upload_method.py +15 -0
- formatjet/openapi/models/upload_request.py +56 -0
- formatjet/openapi/models/usage.py +93 -0
- formatjet/openapi/models/usage_days_item.py +93 -0
- formatjet/openapi/models/usage_totals.py +85 -0
- formatjet/openapi/models/webhook_delivery.py +204 -0
- formatjet/openapi/models/webhook_delivery_event_type.py +17 -0
- formatjet/openapi/models/webhook_delivery_mode.py +16 -0
- formatjet/openapi/models/webhook_endpoint.py +258 -0
- formatjet/openapi/models/webhook_endpoint_disabled_reason_type_1.py +20 -0
- formatjet/openapi/models/webhook_endpoint_disabled_reason_type_2_type_1.py +20 -0
- formatjet/openapi/models/webhook_endpoint_disabled_reason_type_3_type_1.py +20 -0
- formatjet/openapi/models/webhook_endpoint_events_item.py +17 -0
- formatjet/openapi/models/webhook_endpoint_mode.py +16 -0
- formatjet/openapi/models/webhook_endpoint_request.py +84 -0
- formatjet/openapi/models/webhook_endpoint_request_events_item.py +21 -0
- formatjet/openapi/models/webhook_endpoint_update.py +101 -0
- formatjet/openapi/models/webhook_endpoint_update_events_item.py +19 -0
- formatjet/openapi/models/webhook_endpoint_with_secret.py +267 -0
- formatjet/openapi/types.py +54 -0
- formatjet/py.typed +0 -0
- formatjet-0.1.0.dist-info/METADATA +126 -0
- formatjet-0.1.0.dist-info/RECORD +153 -0
- formatjet-0.1.0.dist-info/WHEEL +4 -0
- formatjet-0.1.0.dist-info/licenses/LICENSE +21 -0
formatjet/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""FormatJet: convert documents, images, data and PDFs with the FormatJet API.
|
|
2
|
+
|
|
3
|
+
from formatjet import FormatJet
|
|
4
|
+
|
|
5
|
+
formatjet = FormatJet() # reads FORMATJET_API_KEY
|
|
6
|
+
result = formatjet.convert("report.docx", conversion="word-to-pdf")
|
|
7
|
+
result.file.save("report.pdf")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._client import (
|
|
11
|
+
DEFAULT_BASE_URL,
|
|
12
|
+
ConvertResult,
|
|
13
|
+
FileInput,
|
|
14
|
+
FormatJet,
|
|
15
|
+
JobPage,
|
|
16
|
+
Jobs,
|
|
17
|
+
OutputFile,
|
|
18
|
+
filename_from,
|
|
19
|
+
)
|
|
20
|
+
from ._errors import FormatJetError
|
|
21
|
+
from ._webhooks import (
|
|
22
|
+
DEFAULT_TOLERANCE_SECONDS,
|
|
23
|
+
SIGNATURE_HEADER,
|
|
24
|
+
WebhookSignatureError,
|
|
25
|
+
WebhookVerification,
|
|
26
|
+
parse_webhook,
|
|
27
|
+
verify_webhook,
|
|
28
|
+
)
|
|
29
|
+
from .openapi.models import Job, JobSummary, Upload
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"DEFAULT_BASE_URL",
|
|
35
|
+
"DEFAULT_TOLERANCE_SECONDS",
|
|
36
|
+
"SIGNATURE_HEADER",
|
|
37
|
+
"ConvertResult",
|
|
38
|
+
"FileInput",
|
|
39
|
+
"FormatJet",
|
|
40
|
+
"FormatJetError",
|
|
41
|
+
"Job",
|
|
42
|
+
"JobPage",
|
|
43
|
+
"JobSummary",
|
|
44
|
+
"Jobs",
|
|
45
|
+
"OutputFile",
|
|
46
|
+
"Upload",
|
|
47
|
+
"WebhookSignatureError",
|
|
48
|
+
"WebhookVerification",
|
|
49
|
+
"__version__",
|
|
50
|
+
"filename_from",
|
|
51
|
+
"parse_webhook",
|
|
52
|
+
"verify_webhook",
|
|
53
|
+
]
|
formatjet/_client.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import IO, Any, Self, TypeAlias
|
|
13
|
+
from urllib.parse import unquote
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._errors import FormatJetError, error_from_response
|
|
18
|
+
from ._transport import RetryTransport
|
|
19
|
+
from ._webhooks import parse_webhook, verify_webhook
|
|
20
|
+
from .openapi import AuthenticatedClient
|
|
21
|
+
from .openapi.models import Job, JobOutputsItem, JobSummary, Upload
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.formatjet.com"
|
|
24
|
+
TERMINAL = frozenset({"completed", "failed", "canceled"})
|
|
25
|
+
DEFAULT_WAIT_SECONDS = 15 * 60.0
|
|
26
|
+
DOWNLOADS_AT_ONCE = 4
|
|
27
|
+
|
|
28
|
+
#: A file: a path, its bytes, a binary file object, or ``(filename, any of those)`` to name it.
|
|
29
|
+
FileInput: TypeAlias = str | os.PathLike[str] | bytes | bytearray | memoryview | IO[bytes] | tuple[str, Any]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class OutputFile:
|
|
34
|
+
#: The file's name, from the input's and the conversion's.
|
|
35
|
+
filename: str | None
|
|
36
|
+
mime: str
|
|
37
|
+
data: bytes
|
|
38
|
+
|
|
39
|
+
def save(self, path: str | os.PathLike[str] | None = None) -> Path:
|
|
40
|
+
"""Writes the file to ``path``, or under its own name in the current folder."""
|
|
41
|
+
target = Path(path) if path is not None else Path(Path(self.filename or "output").name)
|
|
42
|
+
target.write_bytes(self.data)
|
|
43
|
+
return target
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ConvertResult:
|
|
48
|
+
job_id: str
|
|
49
|
+
#: Every converted file in order: several for Split PDF, a PDF's pages as images and the like.
|
|
50
|
+
files: list[OutputFile]
|
|
51
|
+
#: Credits charged: none with a test key.
|
|
52
|
+
credits: float
|
|
53
|
+
#: The engine and its version, such as ``office libreoffice-25.8+runner-7``.
|
|
54
|
+
engine: str | None
|
|
55
|
+
#: The job, when the API answered with it (several files, or a wait past 30 seconds).
|
|
56
|
+
job: Job | None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def file(self) -> OutputFile:
|
|
60
|
+
"""The first (usually the only) converted file."""
|
|
61
|
+
return self.files[0]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class JobPage:
|
|
66
|
+
data: list[JobSummary]
|
|
67
|
+
#: Pass it back as ``cursor`` for the next page; ``None`` on the last.
|
|
68
|
+
next_cursor: str | None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class _Prepared:
|
|
73
|
+
filename: str | None
|
|
74
|
+
size: int
|
|
75
|
+
open: Callable[[], bytes | IO[bytes]]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _prepare(file: FileInput, filename: str | None = None) -> _Prepared:
|
|
79
|
+
if isinstance(file, tuple):
|
|
80
|
+
name, data = file
|
|
81
|
+
return _prepare(data, filename or name)
|
|
82
|
+
if isinstance(file, (bytes, bytearray, memoryview)):
|
|
83
|
+
data = bytes(file)
|
|
84
|
+
return _Prepared(filename, len(data), lambda: data)
|
|
85
|
+
if isinstance(file, (str, os.PathLike)):
|
|
86
|
+
path = Path(file)
|
|
87
|
+
return _Prepared(filename or path.name, path.stat().st_size, lambda: path.open("rb"))
|
|
88
|
+
if hasattr(file, "read"):
|
|
89
|
+
data = file.read()
|
|
90
|
+
own = getattr(file, "name", None)
|
|
91
|
+
name = filename or (Path(own).name if isinstance(own, str) else None)
|
|
92
|
+
return _Prepared(name, len(data), lambda: data)
|
|
93
|
+
raise TypeError("A file is a path, bytes, a binary file object, or (filename, one of those).")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _step(
|
|
97
|
+
conversion: str | None,
|
|
98
|
+
to: str | None,
|
|
99
|
+
from_: str | None,
|
|
100
|
+
options: Mapping[str, Any] | None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
if not conversion and not to:
|
|
103
|
+
raise ValueError("Name the conversion, or the format to make (`to`).")
|
|
104
|
+
step: dict[str, Any] = {"op": "convert"}
|
|
105
|
+
if conversion:
|
|
106
|
+
step["conversion"] = conversion
|
|
107
|
+
if from_:
|
|
108
|
+
step["from"] = from_
|
|
109
|
+
if to:
|
|
110
|
+
step["to"] = to
|
|
111
|
+
if options:
|
|
112
|
+
step["options"] = dict(options)
|
|
113
|
+
return step
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def filename_from(header: str | None) -> str | None:
|
|
117
|
+
"""The name in a ``Content-Disposition`` header (``filename*`` first, as RFC 6266 says)."""
|
|
118
|
+
if not header:
|
|
119
|
+
return None
|
|
120
|
+
extended = re.search(r"filename\*\s*=\s*UTF-8''([^;]+)", header, re.IGNORECASE)
|
|
121
|
+
if extended:
|
|
122
|
+
return unquote(extended.group(1).strip(), errors="strict") or None
|
|
123
|
+
plain = re.search(r'filename\s*=\s*"((?:[^"\\]|\\.)*)"|filename\s*=\s*([^;]+)', header, re.IGNORECASE)
|
|
124
|
+
if not plain:
|
|
125
|
+
return None
|
|
126
|
+
if plain.group(1) is not None:
|
|
127
|
+
return re.sub(r"\\(.)", r"\1", plain.group(1)) or None
|
|
128
|
+
return plain.group(2).strip() or None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _close(content: bytes | IO[bytes]) -> None:
|
|
132
|
+
if not isinstance(content, bytes):
|
|
133
|
+
content.close()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Jobs:
|
|
137
|
+
"""Jobs: make, read, list, wait for, cancel and delete them."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, http: httpx.Client, sleep: Callable[[float], None]) -> None:
|
|
140
|
+
self._http = http
|
|
141
|
+
self._sleep = sleep
|
|
142
|
+
|
|
143
|
+
def _json(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
144
|
+
response = self._http.request(method, url, **kwargs)
|
|
145
|
+
if not response.is_success:
|
|
146
|
+
raise error_from_response(response)
|
|
147
|
+
return response.json()
|
|
148
|
+
|
|
149
|
+
def create(self, request: Mapping[str, Any], *, idempotency_key: str | None = None) -> Job:
|
|
150
|
+
"""Makes a job (``POST /v1/jobs``) from a request as the API reference describes it. It
|
|
151
|
+
starts at once; wait for it with ``wait()``."""
|
|
152
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
153
|
+
return Job.from_dict(self._json("POST", "/v1/jobs", json=dict(request), headers=headers))
|
|
154
|
+
|
|
155
|
+
def get(self, job_id: str) -> Job:
|
|
156
|
+
return Job.from_dict(self._json("GET", f"/v1/jobs/{job_id}"))
|
|
157
|
+
|
|
158
|
+
def list(self, *, limit: int | None = None, cursor: str | None = None) -> JobPage:
|
|
159
|
+
"""One page of jobs, newest first."""
|
|
160
|
+
params = {key: value for key, value in (("limit", limit), ("cursor", cursor)) if value is not None}
|
|
161
|
+
page = self._json("GET", "/v1/jobs", params=params)
|
|
162
|
+
return JobPage([JobSummary.from_dict(item) for item in page["data"]], page["next_cursor"])
|
|
163
|
+
|
|
164
|
+
def iterate(self, *, limit: int | None = None) -> Iterator[JobSummary]:
|
|
165
|
+
"""Every job of the last 30 days, newest first, a page at a time."""
|
|
166
|
+
cursor: str | None = None
|
|
167
|
+
while True:
|
|
168
|
+
page = self.list(limit=limit, cursor=cursor)
|
|
169
|
+
yield from page.data
|
|
170
|
+
if not page.next_cursor:
|
|
171
|
+
return
|
|
172
|
+
cursor = page.next_cursor
|
|
173
|
+
|
|
174
|
+
def cancel(self, job_id: str) -> Job:
|
|
175
|
+
"""Cancels a job that hasn't finished."""
|
|
176
|
+
return Job.from_dict(self._json("POST", f"/v1/jobs/{job_id}/cancel"))
|
|
177
|
+
|
|
178
|
+
def delete(self, job_id: str) -> dict[str, Any]:
|
|
179
|
+
"""Deletes a job's files now and returns the signed deletion receipt."""
|
|
180
|
+
receipt: dict[str, Any] = self._json("DELETE", f"/v1/jobs/{job_id}")["receipt"]
|
|
181
|
+
return receipt
|
|
182
|
+
|
|
183
|
+
def wait(
|
|
184
|
+
self,
|
|
185
|
+
job_id: str,
|
|
186
|
+
*,
|
|
187
|
+
timeout: float = DEFAULT_WAIT_SECONDS,
|
|
188
|
+
on_progress: Callable[[Job], None] | None = None,
|
|
189
|
+
) -> Job:
|
|
190
|
+
"""Reads the job until it has ended (completed, failed or canceled) and returns it,
|
|
191
|
+
whatever the outcome. Raises ``FormatJetError`` with code ``timeout`` after ``timeout``
|
|
192
|
+
seconds; the job carries on."""
|
|
193
|
+
until = time.monotonic() + timeout
|
|
194
|
+
pause = 0.5
|
|
195
|
+
while True:
|
|
196
|
+
job = self.get(job_id)
|
|
197
|
+
if job.status in TERMINAL:
|
|
198
|
+
return job
|
|
199
|
+
if on_progress:
|
|
200
|
+
on_progress(job)
|
|
201
|
+
if time.monotonic() + pause > until:
|
|
202
|
+
raise FormatJetError(
|
|
203
|
+
"timeout",
|
|
204
|
+
f"Job {job_id} was still {job.status} when the wait ran out; it carries on.",
|
|
205
|
+
job_id=job_id,
|
|
206
|
+
)
|
|
207
|
+
self._sleep(pause)
|
|
208
|
+
pause = min(pause * 1.5, 5.0)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class FormatJet:
|
|
212
|
+
"""The FormatJet API.
|
|
213
|
+
|
|
214
|
+
``convert()`` sends a file and gets the result in one request (up to 25 MB);
|
|
215
|
+
``upload_and_convert()`` uploads it first, for files up to the plan's limit. ``jobs`` manages
|
|
216
|
+
jobs, ``verify_webhook()`` checks deliveries, and ``api`` is the generated client for every
|
|
217
|
+
endpoint (``formatjet.openapi.api``).
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
api_key: str | None = None,
|
|
223
|
+
*,
|
|
224
|
+
base_url: str | None = None,
|
|
225
|
+
max_retries: int = 2,
|
|
226
|
+
timeout: float = 300.0,
|
|
227
|
+
transport: httpx.BaseTransport | None = None,
|
|
228
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""``api_key`` defaults to the ``FORMATJET_API_KEY`` environment variable. Requests that
|
|
231
|
+
get a 429, a 5xx answer or a network error are retried ``max_retries`` times; each takes
|
|
232
|
+
up to ``timeout`` seconds. ``transport`` replaces httpx's (tests)."""
|
|
233
|
+
key = api_key or os.environ.get("FORMATJET_API_KEY")
|
|
234
|
+
if not key:
|
|
235
|
+
raise ValueError("No API key: pass api_key or set FORMATJET_API_KEY.")
|
|
236
|
+
self.base_url = (base_url or os.environ.get("FORMATJET_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
237
|
+
retrying = RetryTransport(transport or httpx.HTTPTransport(), max_retries, sleep)
|
|
238
|
+
limits = httpx.Timeout(timeout)
|
|
239
|
+
self._http = httpx.Client(
|
|
240
|
+
base_url=self.base_url,
|
|
241
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
242
|
+
transport=retrying,
|
|
243
|
+
timeout=limits,
|
|
244
|
+
)
|
|
245
|
+
# Never send the key with a file: upload and download URLs are signed, and may be
|
|
246
|
+
# our storage's rather than the API's.
|
|
247
|
+
self._files = httpx.Client(transport=retrying, timeout=limits)
|
|
248
|
+
self._sleep = sleep
|
|
249
|
+
self.jobs = Jobs(self._http, sleep)
|
|
250
|
+
#: The generated client: pass it as ``client=`` to ``formatjet.openapi.api`` functions.
|
|
251
|
+
self.api = AuthenticatedClient(base_url=self.base_url, token=key).set_httpx_client(self._http)
|
|
252
|
+
|
|
253
|
+
def close(self) -> None:
|
|
254
|
+
self._http.close()
|
|
255
|
+
self._files.close()
|
|
256
|
+
|
|
257
|
+
def __enter__(self) -> Self:
|
|
258
|
+
return self
|
|
259
|
+
|
|
260
|
+
def __exit__(self, *args: object) -> None:
|
|
261
|
+
self.close()
|
|
262
|
+
|
|
263
|
+
verify_webhook = staticmethod(verify_webhook)
|
|
264
|
+
parse_webhook = staticmethod(parse_webhook)
|
|
265
|
+
|
|
266
|
+
def convert(
|
|
267
|
+
self,
|
|
268
|
+
file: FileInput | Sequence[FileInput],
|
|
269
|
+
*,
|
|
270
|
+
conversion: str | None = None,
|
|
271
|
+
to: str | None = None,
|
|
272
|
+
from_: str | None = None,
|
|
273
|
+
options: Mapping[str, Any] | None = None,
|
|
274
|
+
filename: str | None = None,
|
|
275
|
+
output_filename: str | None = None,
|
|
276
|
+
ttl: int | None = None,
|
|
277
|
+
idempotency_key: str | None = None,
|
|
278
|
+
timeout: float = DEFAULT_WAIT_SECONDS,
|
|
279
|
+
) -> ConvertResult:
|
|
280
|
+
"""Converts one file (or the files a joining conversion takes, as a list) in one
|
|
281
|
+
request, up to 25 MB in all, and returns the converted files. A job that runs past 30
|
|
282
|
+
seconds is waited for, up to ``timeout`` seconds."""
|
|
283
|
+
files = [_prepare(item) for item in file] if isinstance(file, list) else [_prepare(file, filename)]
|
|
284
|
+
step = _step(conversion, to, from_, options)
|
|
285
|
+
params: dict[str, str] = {key: step[key] for key in ("conversion", "from", "to") if key in step}
|
|
286
|
+
if "options" in step:
|
|
287
|
+
params["options"] = json.dumps(step["options"], separators=(",", ":"))
|
|
288
|
+
if output_filename:
|
|
289
|
+
params["output_filename"] = output_filename
|
|
290
|
+
if ttl is not None:
|
|
291
|
+
params["ttl"] = str(ttl)
|
|
292
|
+
contents = [prepared.open() for prepared in files]
|
|
293
|
+
try:
|
|
294
|
+
parts = [
|
|
295
|
+
("file", (prepared.filename or "file", content, "application/octet-stream"))
|
|
296
|
+
for prepared, content in zip(files, contents, strict=True)
|
|
297
|
+
]
|
|
298
|
+
response = self._http.post(
|
|
299
|
+
"/v1/convert",
|
|
300
|
+
params=params,
|
|
301
|
+
files=parts,
|
|
302
|
+
headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())},
|
|
303
|
+
)
|
|
304
|
+
finally:
|
|
305
|
+
for content in contents:
|
|
306
|
+
_close(content)
|
|
307
|
+
if not response.is_success:
|
|
308
|
+
raise error_from_response(response)
|
|
309
|
+
mime = response.headers.get("Content-Type", "")
|
|
310
|
+
if "application/json" in mime:
|
|
311
|
+
# Several files, or still converting after 30 s: the job, which lists them or carries on.
|
|
312
|
+
job = Job.from_dict(response.json())
|
|
313
|
+
if job.status not in TERMINAL:
|
|
314
|
+
job = self.jobs.wait(job.id, timeout=timeout)
|
|
315
|
+
return self._result(job)
|
|
316
|
+
output = OutputFile(
|
|
317
|
+
filename_from(response.headers.get("Content-Disposition")),
|
|
318
|
+
mime or "application/octet-stream",
|
|
319
|
+
response.content,
|
|
320
|
+
)
|
|
321
|
+
return ConvertResult(
|
|
322
|
+
job_id=response.headers.get("FormatJet-Job-Id", ""),
|
|
323
|
+
files=[output],
|
|
324
|
+
credits=float(response.headers.get("FormatJet-Credits", "0")),
|
|
325
|
+
engine=response.headers.get("FormatJet-Engine"),
|
|
326
|
+
job=None,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def upload_and_convert(
|
|
330
|
+
self,
|
|
331
|
+
file: FileInput | Sequence[FileInput],
|
|
332
|
+
*,
|
|
333
|
+
conversion: str | None = None,
|
|
334
|
+
to: str | None = None,
|
|
335
|
+
from_: str | None = None,
|
|
336
|
+
options: Mapping[str, Any] | None = None,
|
|
337
|
+
filename: str | None = None,
|
|
338
|
+
output_filename: str | None = None,
|
|
339
|
+
ttl: int | None = None,
|
|
340
|
+
metadata: Mapping[str, str] | None = None,
|
|
341
|
+
webhook: str | None = None,
|
|
342
|
+
idempotency_key: str | None = None,
|
|
343
|
+
timeout: float = DEFAULT_WAIT_SECONDS,
|
|
344
|
+
on_progress: Callable[[Job], None] | None = None,
|
|
345
|
+
) -> ConvertResult:
|
|
346
|
+
"""Uploads the file (or the files to join, as a list), makes a job, waits for it and
|
|
347
|
+
downloads what it made. For files larger than ``convert()`` takes."""
|
|
348
|
+
files = [_prepare(item) for item in file] if isinstance(file, list) else [_prepare(file, filename)]
|
|
349
|
+
step = _step(conversion, to, from_, options)
|
|
350
|
+
refs = [{"upload_id": self._upload(prepared).id} for prepared in files]
|
|
351
|
+
request: dict[str, Any] = {"steps": [step]}
|
|
352
|
+
if len(refs) == 1:
|
|
353
|
+
request["input"] = refs[0]
|
|
354
|
+
else:
|
|
355
|
+
request["inputs"] = refs
|
|
356
|
+
output: dict[str, Any] = {}
|
|
357
|
+
if ttl is not None:
|
|
358
|
+
output["ttl"] = ttl
|
|
359
|
+
if output_filename:
|
|
360
|
+
output["filename"] = output_filename
|
|
361
|
+
if output:
|
|
362
|
+
request["output"] = output
|
|
363
|
+
if metadata:
|
|
364
|
+
request["metadata"] = dict(metadata)
|
|
365
|
+
if webhook:
|
|
366
|
+
request["webhook"] = webhook
|
|
367
|
+
created = self.jobs.create(request, idempotency_key=idempotency_key)
|
|
368
|
+
return self._result(self.jobs.wait(created.id, timeout=timeout, on_progress=on_progress))
|
|
369
|
+
|
|
370
|
+
def upload(self, file: FileInput, *, filename: str | None = None) -> Upload:
|
|
371
|
+
"""Makes an upload and PUTs the file to it. Name it in a job's ``input``
|
|
372
|
+
(``{"upload_id": upload.id}``) within the hour."""
|
|
373
|
+
return self._upload(_prepare(file, filename))
|
|
374
|
+
|
|
375
|
+
def download(self, output: JobOutputsItem) -> OutputFile:
|
|
376
|
+
"""Downloads one of a job's outputs. Its URL needs no key and works until ``expires_at``."""
|
|
377
|
+
if not output.url:
|
|
378
|
+
raise FormatJetError("not_found", f"Output {output.id} has been deleted.")
|
|
379
|
+
response = self._files.get(output.url)
|
|
380
|
+
if not response.is_success:
|
|
381
|
+
raise error_from_response(response)
|
|
382
|
+
return OutputFile(output.filename, output.mime, response.content)
|
|
383
|
+
|
|
384
|
+
def _upload(self, prepared: _Prepared) -> Upload:
|
|
385
|
+
body: dict[str, Any] = {"size": prepared.size}
|
|
386
|
+
if prepared.filename:
|
|
387
|
+
body["filename"] = prepared.filename
|
|
388
|
+
response = self._http.post("/v1/uploads", json=body)
|
|
389
|
+
if not response.is_success:
|
|
390
|
+
raise error_from_response(response)
|
|
391
|
+
upload = Upload.from_dict(response.json())
|
|
392
|
+
content = prepared.open()
|
|
393
|
+
try:
|
|
394
|
+
headers = {**upload.headers.additional_properties, "Content-Length": str(prepared.size)}
|
|
395
|
+
put = self._files.request(str(upload.method), upload.url, content=content, headers=headers)
|
|
396
|
+
finally:
|
|
397
|
+
_close(content)
|
|
398
|
+
if not put.is_success:
|
|
399
|
+
raise error_from_response(put)
|
|
400
|
+
return upload
|
|
401
|
+
|
|
402
|
+
def _result(self, job: Job) -> ConvertResult:
|
|
403
|
+
if job.status == "failed":
|
|
404
|
+
error = job.error
|
|
405
|
+
raise FormatJetError(
|
|
406
|
+
error.code if error else "engine_failed",
|
|
407
|
+
error.detail if error else "The conversion failed.",
|
|
408
|
+
job_id=job.id,
|
|
409
|
+
)
|
|
410
|
+
if job.status == "canceled":
|
|
411
|
+
raise FormatJetError("canceled", f"Job {job.id} was canceled.", job_id=job.id)
|
|
412
|
+
if not job.outputs:
|
|
413
|
+
raise FormatJetError("not_found", f"Job {job.id} has no outputs.", job_id=job.id)
|
|
414
|
+
with ThreadPoolExecutor(max_workers=DOWNLOADS_AT_ONCE) as pool:
|
|
415
|
+
files = list(pool.map(self.download, job.outputs))
|
|
416
|
+
engine = f"{job.engine} {job.engine_version or ''}".strip() if job.engine else None
|
|
417
|
+
return ConvertResult(job_id=job.id, files=files, credits=job.credits_, engine=engine, job=job)
|
formatjet/_errors.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FormatJetError(Exception):
|
|
10
|
+
"""An error from the API (its problem document), a job that failed, or a wait that ran out.
|
|
11
|
+
|
|
12
|
+
Branch on ``code``: it's stable across versions (https://formatjet.com/docs/errors), while
|
|
13
|
+
the message is written for people. Besides the API's codes, ``timeout`` means the SDK stopped
|
|
14
|
+
waiting for a job (the job carries on) and ``canceled`` a job canceled while it waited.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
code: str,
|
|
20
|
+
detail: str,
|
|
21
|
+
*,
|
|
22
|
+
status: int | None = None,
|
|
23
|
+
request_id: str | None = None,
|
|
24
|
+
job_id: str | None = None,
|
|
25
|
+
retry_after: int | None = None,
|
|
26
|
+
problem: dict[str, Any] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(f"{code}: {detail}")
|
|
29
|
+
self.code = code
|
|
30
|
+
self.detail = detail
|
|
31
|
+
#: The HTTP status, when the API answered with an error.
|
|
32
|
+
self.status = status
|
|
33
|
+
#: ``FormatJet-Request-Id``: quote it when you contact us.
|
|
34
|
+
self.request_id = request_id
|
|
35
|
+
#: The job the error belongs to, when there is one.
|
|
36
|
+
self.job_id = job_id
|
|
37
|
+
#: Seconds to wait before trying again (``Retry-After``).
|
|
38
|
+
self.retry_after = retry_after
|
|
39
|
+
#: The whole problem document, with ``errors`` for each field that failed validation.
|
|
40
|
+
self.problem = problem
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def retry_after_seconds(response: httpx.Response) -> int | None:
|
|
44
|
+
value = response.headers.get("Retry-After", "").strip()
|
|
45
|
+
return int(value) if value.isdigit() else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def error_from_response(response: httpx.Response) -> FormatJetError:
|
|
49
|
+
"""The error for an answer that wasn't a success."""
|
|
50
|
+
response.read()
|
|
51
|
+
request_id = response.headers.get("FormatJet-Request-Id")
|
|
52
|
+
retry_after = retry_after_seconds(response)
|
|
53
|
+
try:
|
|
54
|
+
body = response.json()
|
|
55
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
56
|
+
body = None
|
|
57
|
+
if isinstance(body, dict) and isinstance(body.get("code"), str) and isinstance(body.get("status"), int):
|
|
58
|
+
return FormatJetError(
|
|
59
|
+
body["code"],
|
|
60
|
+
str(body.get("detail") or body.get("title") or ""),
|
|
61
|
+
status=response.status_code,
|
|
62
|
+
request_id=body.get("request_id") or request_id,
|
|
63
|
+
job_id=body.get("job_id"),
|
|
64
|
+
retry_after=retry_after,
|
|
65
|
+
problem=body,
|
|
66
|
+
)
|
|
67
|
+
# Not one of ours: a proxy's page, or an answer cut short.
|
|
68
|
+
code = {401: "unauthorized", 404: "not_found", 429: "rate_limited"}.get(response.status_code, "internal_error")
|
|
69
|
+
return FormatJetError(
|
|
70
|
+
code,
|
|
71
|
+
f"The API answered {response.status_code} {response.reason_phrase}".strip(),
|
|
72
|
+
status=response.status_code,
|
|
73
|
+
request_id=request_id,
|
|
74
|
+
retry_after=retry_after,
|
|
75
|
+
)
|
formatjet/_transport.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._errors import retry_after_seconds
|
|
11
|
+
|
|
12
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
13
|
+
#: A longer ``Retry-After`` is answered as an error rather than slept through.
|
|
14
|
+
MAX_RETRY_AFTER_SECONDS = 60
|
|
15
|
+
#: Bodies up to this size are kept in memory so a retry can send them again; larger ones
|
|
16
|
+
#: (an upload from a file) are sent once.
|
|
17
|
+
MAX_RETRIED_BODY = 64 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def backoff(attempt: int) -> float:
|
|
21
|
+
"""Half a second, doubling, with up to a quarter of a second's jitter."""
|
|
22
|
+
return 0.5 * 2**attempt + random.random() * 0.25
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RetryTransport(httpx.BaseTransport):
|
|
26
|
+
"""Retries 429s, 5xx answers and network errors, and gives each POST an Idempotency-Key
|
|
27
|
+
(the same on every retry), so a retry never makes a second job."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
inner: httpx.BaseTransport,
|
|
32
|
+
max_retries: int,
|
|
33
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._inner = inner
|
|
36
|
+
self._max_retries = max_retries
|
|
37
|
+
self._sleep = sleep
|
|
38
|
+
|
|
39
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
40
|
+
if request.method == "POST" and "Idempotency-Key" not in request.headers:
|
|
41
|
+
request.headers["Idempotency-Key"] = str(uuid.uuid4())
|
|
42
|
+
length = int(request.headers.get("Content-Length") or 0)
|
|
43
|
+
retries = self._max_retries if length <= MAX_RETRIED_BODY else 0
|
|
44
|
+
if retries:
|
|
45
|
+
request.read()
|
|
46
|
+
attempt = 0
|
|
47
|
+
while True:
|
|
48
|
+
try:
|
|
49
|
+
response = self._inner.handle_request(request)
|
|
50
|
+
except httpx.TransportError:
|
|
51
|
+
if attempt >= retries:
|
|
52
|
+
raise
|
|
53
|
+
self._sleep(backoff(attempt))
|
|
54
|
+
attempt += 1
|
|
55
|
+
continue
|
|
56
|
+
if response.status_code not in RETRY_STATUSES or attempt >= retries:
|
|
57
|
+
return response
|
|
58
|
+
wait = retry_after_seconds(response)
|
|
59
|
+
if wait is not None and wait > MAX_RETRY_AFTER_SECONDS:
|
|
60
|
+
return response
|
|
61
|
+
response.close()
|
|
62
|
+
self._sleep(backoff(attempt) if wait is None else wait)
|
|
63
|
+
attempt += 1
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
self._inner.close()
|