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.
Files changed (153) hide show
  1. formatjet/__init__.py +53 -0
  2. formatjet/_client.py +417 -0
  3. formatjet/_errors.py +75 -0
  4. formatjet/_transport.py +66 -0
  5. formatjet/_webhooks.py +93 -0
  6. formatjet/openapi/__init__.py +9 -0
  7. formatjet/openapi/api/__init__.py +1 -0
  8. formatjet/openapi/api/account/__init__.py +1 -0
  9. formatjet/openapi/api/account/get_account.py +144 -0
  10. formatjet/openapi/api/account/get_usage.py +197 -0
  11. formatjet/openapi/api/catalog/__init__.py +1 -0
  12. formatjet/openapi/api/catalog/get_pricing.py +128 -0
  13. formatjet/openapi/api/catalog/list_formats.py +128 -0
  14. formatjet/openapi/api/catalog/list_operations.py +193 -0
  15. formatjet/openapi/api/jobs/__init__.py +1 -0
  16. formatjet/openapi/api/jobs/cancel_job.py +220 -0
  17. formatjet/openapi/api/jobs/convert.py +396 -0
  18. formatjet/openapi/api/jobs/create_job.py +241 -0
  19. formatjet/openapi/api/jobs/create_upload.py +230 -0
  20. formatjet/openapi/api/jobs/delete_job.py +188 -0
  21. formatjet/openapi/api/jobs/estimate_job.py +239 -0
  22. formatjet/openapi/api/jobs/get_job.py +188 -0
  23. formatjet/openapi/api/jobs/list_jobs.py +208 -0
  24. formatjet/openapi/api/jobs/stream_job_events.py +194 -0
  25. formatjet/openapi/api/webhooks/__init__.py +1 -0
  26. formatjet/openapi/api/webhooks/create_webhook.py +225 -0
  27. formatjet/openapi/api/webhooks/delete_webhook.py +174 -0
  28. formatjet/openapi/api/webhooks/get_webhook.py +176 -0
  29. formatjet/openapi/api/webhooks/list_webhook_deliveries.py +234 -0
  30. formatjet/openapi/api/webhooks/list_webhooks.py +144 -0
  31. formatjet/openapi/api/webhooks/replay_webhook_event.py +242 -0
  32. formatjet/openapi/api/webhooks/rotate_webhook_secret.py +224 -0
  33. formatjet/openapi/api/webhooks/update_webhook.py +214 -0
  34. formatjet/openapi/client.py +280 -0
  35. formatjet/openapi/errors.py +16 -0
  36. formatjet/openapi/models/__init__.py +231 -0
  37. formatjet/openapi/models/account.py +114 -0
  38. formatjet/openapi/models/account_credits.py +102 -0
  39. formatjet/openapi/models/account_credits_monthly_type_0.py +78 -0
  40. formatjet/openapi/models/account_key.py +107 -0
  41. formatjet/openapi/models/account_key_mode.py +16 -0
  42. formatjet/openapi/models/account_key_scopes_item.py +17 -0
  43. formatjet/openapi/models/account_limits.py +111 -0
  44. formatjet/openapi/models/account_organization.py +69 -0
  45. formatjet/openapi/models/account_plan.py +69 -0
  46. formatjet/openapi/models/convert_files_body.py +75 -0
  47. formatjet/openapi/models/convert_from.py +95 -0
  48. formatjet/openapi/models/convert_to.py +95 -0
  49. formatjet/openapi/models/deletion_receipt_response.py +79 -0
  50. formatjet/openapi/models/deletion_receipt_response_receipt.py +50 -0
  51. formatjet/openapi/models/estimate.py +136 -0
  52. formatjet/openapi/models/estimate_credit_weight.py +70 -0
  53. formatjet/openapi/models/estimate_from.py +95 -0
  54. formatjet/openapi/models/estimate_request.py +79 -0
  55. formatjet/openapi/models/estimate_request_steps_item.py +124 -0
  56. formatjet/openapi/models/estimate_request_steps_item_from.py +95 -0
  57. formatjet/openapi/models/estimate_request_steps_item_op.py +15 -0
  58. formatjet/openapi/models/estimate_request_steps_item_options.py +47 -0
  59. formatjet/openapi/models/estimate_request_steps_item_to.py +95 -0
  60. formatjet/openapi/models/estimate_to.py +95 -0
  61. formatjet/openapi/models/format_.py +109 -0
  62. formatjet/openapi/models/job.py +297 -0
  63. formatjet/openapi/models/job_error_type_0.py +69 -0
  64. formatjet/openapi/models/job_estimate.py +61 -0
  65. formatjet/openapi/models/job_input.py +163 -0
  66. formatjet/openapi/models/job_input_format_type_1.py +95 -0
  67. formatjet/openapi/models/job_input_format_type_2_type_1.py +95 -0
  68. formatjet/openapi/models/job_input_format_type_3_type_1.py +95 -0
  69. formatjet/openapi/models/job_inputs_item.py +165 -0
  70. formatjet/openapi/models/job_inputs_item_format_type_1.py +95 -0
  71. formatjet/openapi/models/job_inputs_item_format_type_2_type_1.py +97 -0
  72. formatjet/openapi/models/job_inputs_item_format_type_3_type_1.py +97 -0
  73. formatjet/openapi/models/job_links.py +69 -0
  74. formatjet/openapi/models/job_metadata.py +45 -0
  75. formatjet/openapi/models/job_mode.py +14 -0
  76. formatjet/openapi/models/job_outputs_item.py +133 -0
  77. formatjet/openapi/models/job_request.py +213 -0
  78. formatjet/openapi/models/job_request_input_type_0.py +42 -0
  79. formatjet/openapi/models/job_request_input_type_1.py +53 -0
  80. formatjet/openapi/models/job_request_input_type_2.py +54 -0
  81. formatjet/openapi/models/job_request_inputs_item_type_0.py +42 -0
  82. formatjet/openapi/models/job_request_inputs_item_type_1.py +53 -0
  83. formatjet/openapi/models/job_request_metadata.py +47 -0
  84. formatjet/openapi/models/job_request_output.py +51 -0
  85. formatjet/openapi/models/job_request_steps_item.py +120 -0
  86. formatjet/openapi/models/job_request_steps_item_from.py +95 -0
  87. formatjet/openapi/models/job_request_steps_item_op.py +15 -0
  88. formatjet/openapi/models/job_request_steps_item_options.py +47 -0
  89. formatjet/openapi/models/job_request_steps_item_to.py +95 -0
  90. formatjet/openapi/models/job_status.py +29 -0
  91. formatjet/openapi/models/job_steps_item.py +234 -0
  92. formatjet/openapi/models/job_steps_item_from_type_1.py +95 -0
  93. formatjet/openapi/models/job_steps_item_from_type_2_type_1.py +95 -0
  94. formatjet/openapi/models/job_steps_item_from_type_3_type_1.py +95 -0
  95. formatjet/openapi/models/job_steps_item_op.py +15 -0
  96. formatjet/openapi/models/job_steps_item_options.py +45 -0
  97. formatjet/openapi/models/job_steps_item_status.py +29 -0
  98. formatjet/openapi/models/job_steps_item_to_type_1.py +95 -0
  99. formatjet/openapi/models/job_steps_item_to_type_2_type_1.py +95 -0
  100. formatjet/openapi/models/job_steps_item_to_type_3_type_1.py +95 -0
  101. formatjet/openapi/models/job_summary.py +160 -0
  102. formatjet/openapi/models/job_summary_mode.py +16 -0
  103. formatjet/openapi/models/job_summary_status.py +29 -0
  104. formatjet/openapi/models/list_formats_response_200.py +84 -0
  105. formatjet/openapi/models/list_jobs_response_200.py +95 -0
  106. formatjet/openapi/models/list_operations_from.py +95 -0
  107. formatjet/openapi/models/list_operations_response_200.py +82 -0
  108. formatjet/openapi/models/list_operations_to.py +95 -0
  109. formatjet/openapi/models/list_webhook_deliveries_response_200.py +95 -0
  110. formatjet/openapi/models/list_webhooks_response_200.py +81 -0
  111. formatjet/openapi/models/operation.py +148 -0
  112. formatjet/openapi/models/operation_credits.py +69 -0
  113. formatjet/openapi/models/operation_engines_item.py +87 -0
  114. formatjet/openapi/models/operation_engines_item_engines_item.py +69 -0
  115. formatjet/openapi/models/operation_files.py +70 -0
  116. formatjet/openapi/models/operation_kind.py +16 -0
  117. formatjet/openapi/models/operation_options_schema.py +45 -0
  118. formatjet/openapi/models/pricing.py +153 -0
  119. formatjet/openapi/models/pricing_credit_packs_item.py +77 -0
  120. formatjet/openapi/models/pricing_credit_weights_item.py +85 -0
  121. formatjet/openapi/models/pricing_plans_item.py +156 -0
  122. formatjet/openapi/models/pricing_plans_item_overage_type_1.py +61 -0
  123. formatjet/openapi/models/problem.py +155 -0
  124. formatjet/openapi/models/problem_code.py +47 -0
  125. formatjet/openapi/models/problem_errors_item.py +69 -0
  126. formatjet/openapi/models/replay_webhook_event_body.py +45 -0
  127. formatjet/openapi/models/upload.py +115 -0
  128. formatjet/openapi/models/upload_headers.py +47 -0
  129. formatjet/openapi/models/upload_method.py +15 -0
  130. formatjet/openapi/models/upload_request.py +56 -0
  131. formatjet/openapi/models/usage.py +93 -0
  132. formatjet/openapi/models/usage_days_item.py +93 -0
  133. formatjet/openapi/models/usage_totals.py +85 -0
  134. formatjet/openapi/models/webhook_delivery.py +204 -0
  135. formatjet/openapi/models/webhook_delivery_event_type.py +17 -0
  136. formatjet/openapi/models/webhook_delivery_mode.py +16 -0
  137. formatjet/openapi/models/webhook_endpoint.py +258 -0
  138. formatjet/openapi/models/webhook_endpoint_disabled_reason_type_1.py +20 -0
  139. formatjet/openapi/models/webhook_endpoint_disabled_reason_type_2_type_1.py +20 -0
  140. formatjet/openapi/models/webhook_endpoint_disabled_reason_type_3_type_1.py +20 -0
  141. formatjet/openapi/models/webhook_endpoint_events_item.py +17 -0
  142. formatjet/openapi/models/webhook_endpoint_mode.py +16 -0
  143. formatjet/openapi/models/webhook_endpoint_request.py +84 -0
  144. formatjet/openapi/models/webhook_endpoint_request_events_item.py +21 -0
  145. formatjet/openapi/models/webhook_endpoint_update.py +101 -0
  146. formatjet/openapi/models/webhook_endpoint_update_events_item.py +19 -0
  147. formatjet/openapi/models/webhook_endpoint_with_secret.py +267 -0
  148. formatjet/openapi/types.py +54 -0
  149. formatjet/py.typed +0 -0
  150. formatjet-0.1.0.dist-info/METADATA +126 -0
  151. formatjet-0.1.0.dist-info/RECORD +153 -0
  152. formatjet-0.1.0.dist-info/WHEEL +4 -0
  153. 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
+ )
@@ -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()