floorplan-api 0.5.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.
- floorplan_api/__init__.py +75 -0
- floorplan_api/_input.py +411 -0
- floorplan_api/_transport.py +175 -0
- floorplan_api/_version.py +3 -0
- floorplan_api/async_client.py +400 -0
- floorplan_api/client.py +502 -0
- floorplan_api/exceptions.py +123 -0
- floorplan_api/models.py +241 -0
- floorplan_api/py.typed +0 -0
- floorplan_api-0.5.0.dist-info/METADATA +381 -0
- floorplan_api-0.5.0.dist-info/RECORD +13 -0
- floorplan_api-0.5.0.dist-info/WHEEL +4 -0
- floorplan_api-0.5.0.dist-info/licenses/LICENSE +21 -0
floorplan_api/client.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Synchronous HTTP client for the Floor Plan API (built on ``requests``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from floorplan_api._input import (
|
|
12
|
+
ImageInput,
|
|
13
|
+
ResolvedInput,
|
|
14
|
+
normalize_content_type,
|
|
15
|
+
prepare_input,
|
|
16
|
+
)
|
|
17
|
+
from floorplan_api._transport import (
|
|
18
|
+
DEFAULT_MAX_RETRIES,
|
|
19
|
+
DEFAULT_PRESIGN_THRESHOLD,
|
|
20
|
+
DEFAULT_RETRY_BACKOFF,
|
|
21
|
+
DEFAULT_TIMEOUT,
|
|
22
|
+
build_headers,
|
|
23
|
+
build_url,
|
|
24
|
+
decode_json,
|
|
25
|
+
error_from_response,
|
|
26
|
+
job_from_submit,
|
|
27
|
+
mask_from_response,
|
|
28
|
+
parse_retry_after,
|
|
29
|
+
resolve_credentials,
|
|
30
|
+
retry_delay,
|
|
31
|
+
should_retry,
|
|
32
|
+
)
|
|
33
|
+
from floorplan_api.exceptions import (
|
|
34
|
+
ConnectionError as ApiConnectionError,
|
|
35
|
+
)
|
|
36
|
+
from floorplan_api.exceptions import (
|
|
37
|
+
InvalidRequestError,
|
|
38
|
+
ServerError,
|
|
39
|
+
)
|
|
40
|
+
from floorplan_api.exceptions import (
|
|
41
|
+
TimeoutError as ApiTimeoutError,
|
|
42
|
+
)
|
|
43
|
+
from floorplan_api.models import Job, MaskBytes
|
|
44
|
+
|
|
45
|
+
__all__ = ["Client", "ImageInput"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Client:
|
|
49
|
+
"""Synchronous Floor Plan API client.
|
|
50
|
+
|
|
51
|
+
The Floor Plan API takes a floor plan (PNG, JPEG, WEBP, or one page of
|
|
52
|
+
a PDF) and returns a binary wall-segmentation PNG mask on the pixel grid
|
|
53
|
+
of the decoded input, where ``255`` marks wall and ``0`` everything else.
|
|
54
|
+
Walls follow the *carved* convention: door and window openings are not
|
|
55
|
+
wall, and walls are as thick as the source linework.
|
|
56
|
+
|
|
57
|
+
What the client sends:
|
|
58
|
+
|
|
59
|
+
* A raster (PNG, JPEG, WEBP) is uploaded byte for byte; the client never
|
|
60
|
+
decodes or re-encodes it.
|
|
61
|
+
* A PDF is not uploaded whole: the requested ``page`` is copied into a
|
|
62
|
+
new single-page PDF with ``pypdf`` (content stream, resources and
|
|
63
|
+
annotations kept; other pages, metadata, bookmarks, attachments and
|
|
64
|
+
forms left out) and that file is uploaded. Nothing is rasterised
|
|
65
|
+
client-side. With ``upload_key`` the object is already in storage, so
|
|
66
|
+
``page`` is sent to the server instead.
|
|
67
|
+
|
|
68
|
+
What the server does with the bytes (``docs/IMAGE_PIPELINE.md``):
|
|
69
|
+
|
|
70
|
+
* Rasters are decoded with OpenCV: alpha is dropped without compositing
|
|
71
|
+
(flatten RGBA onto white yourself), grayscale is expanded, 16-bit is
|
|
72
|
+
reduced to 8-bit, EXIF orientation is applied, ICC profiles ignored.
|
|
73
|
+
A raster whose longer edge exceeds 8192 px is processed downscaled and
|
|
74
|
+
the mask is resized back to the input size.
|
|
75
|
+
* A PDF page is rendered at 200 DPI onto white, reduced so its longer
|
|
76
|
+
edge is at most 8192 px; the mask has that rendered size. The client
|
|
77
|
+
records the page's size in points, so :attr:`MaskBytes.pdf_scale`
|
|
78
|
+
maps PDF coordinates onto the mask.
|
|
79
|
+
* Inline uploads are capped at 10 MB; presigned uploads have no cap
|
|
80
|
+
from the API (dev servers cap them at 50 MB).
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
api_key: Your API key. If omitted, the value of the
|
|
84
|
+
``FLOORPLAN_API_KEY`` environment variable is used.
|
|
85
|
+
base_url: Override the API base URL (useful for self-hosted
|
|
86
|
+
deployments or local testing). Defaults to
|
|
87
|
+
``https://api.floorplanapi.com``. May also be supplied via the
|
|
88
|
+
``FLOORPLAN_BASE_URL`` environment variable.
|
|
89
|
+
timeout: Per-request timeout in seconds. Default: 60.
|
|
90
|
+
max_retries: Maximum number of retry attempts for transient
|
|
91
|
+
errors: network failures, 429s, and 5xx responses that are not
|
|
92
|
+
the outcome of a queued job. Default: 3.
|
|
93
|
+
retry_backoff: Base delay in seconds for exponential backoff
|
|
94
|
+
between retries. A server ``Retry-After`` header takes
|
|
95
|
+
precedence, capped at 30 s per attempt. Default: 0.5.
|
|
96
|
+
session: An existing :class:`requests.Session` to reuse. If
|
|
97
|
+
omitted, a new session is created.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
AuthenticationError: when no API key is provided.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
api_key: str | None = None,
|
|
106
|
+
*,
|
|
107
|
+
base_url: str | None = None,
|
|
108
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
109
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
110
|
+
retry_backoff: float = DEFAULT_RETRY_BACKOFF,
|
|
111
|
+
session: requests.Session | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self.api_key, self.base_url = resolve_credentials(api_key, base_url)
|
|
114
|
+
self.timeout = timeout
|
|
115
|
+
self.max_retries = max(0, int(max_retries))
|
|
116
|
+
self.retry_backoff = max(0.0, float(retry_backoff))
|
|
117
|
+
self._session = session or requests.Session()
|
|
118
|
+
|
|
119
|
+
# ----- public API -----
|
|
120
|
+
|
|
121
|
+
def extract(
|
|
122
|
+
self,
|
|
123
|
+
image: ImageInput | None = None,
|
|
124
|
+
*,
|
|
125
|
+
upload_key: str | None = None,
|
|
126
|
+
page: int | None = None,
|
|
127
|
+
timeout: float | None = None,
|
|
128
|
+
) -> MaskBytes:
|
|
129
|
+
"""Run wall segmentation synchronously and return the mask PNG.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
image: Path, bytes, or open binary file-like: PNG, JPEG, WEBP or
|
|
133
|
+
PDF. Inline uploads are capped at 10 MB; use
|
|
134
|
+
:py:meth:`upload_then_extract` for larger files.
|
|
135
|
+
upload_key: Storage key from :py:meth:`upload`, instead of
|
|
136
|
+
``image``. Exactly one of the two must be given.
|
|
137
|
+
page: For a PDF, the 1-based page to process (default 1). With
|
|
138
|
+
``image`` the page is cut out locally and only that page is
|
|
139
|
+
uploaded; with ``upload_key`` it is passed to the server.
|
|
140
|
+
Rejected for raster input unless it is 1.
|
|
141
|
+
timeout: Override the client's default timeout for this call.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
:class:`~floorplan_api.MaskBytes`: the PNG bytes, with
|
|
145
|
+
``width``, ``height``, ``job_id``, ``request_id``, ``mode`` and,
|
|
146
|
+
for a PDF, ``page_size_pt`` / ``pdf_scale`` attached.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
TimeoutError: with ``job_id`` set if the worker did not finish
|
|
150
|
+
within the server's synchronous budget. The job is still
|
|
151
|
+
running: :py:meth:`wait_for_job` and :py:meth:`download_mask`
|
|
152
|
+
fetch its result without resubmitting.
|
|
153
|
+
ProcessingError: if the worker failed the job (not retried).
|
|
154
|
+
AuthenticationError, RateLimitError, InvalidRequestError,
|
|
155
|
+
NotFoundError, ServerError, ConnectionError: as documented on
|
|
156
|
+
each class.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
return self._submit_for_mask("/v1/extract", image, upload_key, page, timeout)
|
|
160
|
+
|
|
161
|
+
def analyze(
|
|
162
|
+
self,
|
|
163
|
+
image: ImageInput | None = None,
|
|
164
|
+
*,
|
|
165
|
+
upload_key: str | None = None,
|
|
166
|
+
page: int | None = None,
|
|
167
|
+
timeout: float | None = None,
|
|
168
|
+
) -> MaskBytes:
|
|
169
|
+
"""Synchronous analyze: the same wall mask as :py:meth:`extract`.
|
|
170
|
+
|
|
171
|
+
``analyze`` and ``extract`` currently produce identical output; the
|
|
172
|
+
two endpoints are kept distinct so future tiers (per-room
|
|
173
|
+
classification, symbol detection) can attach to ``analyze`` without
|
|
174
|
+
breaking ``extract``'s simpler contract.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
return self._submit_for_mask("/v1/analyze", image, upload_key, page, timeout)
|
|
178
|
+
|
|
179
|
+
def analyze_async(
|
|
180
|
+
self,
|
|
181
|
+
image: ImageInput | None = None,
|
|
182
|
+
*,
|
|
183
|
+
upload_key: str | None = None,
|
|
184
|
+
page: int | None = None,
|
|
185
|
+
timeout: float | None = None,
|
|
186
|
+
) -> Job:
|
|
187
|
+
"""Submit an analyze job and return immediately.
|
|
188
|
+
|
|
189
|
+
Returns a :class:`~floorplan_api.Job` you can poll with
|
|
190
|
+
:py:meth:`get_job` or block on with :py:meth:`wait_for_job`. Once it
|
|
191
|
+
reaches ``completed``, fetch the PNG via :py:meth:`download_mask`.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
resolved, files = _multipart(image, upload_key, page, {"async": "true"})
|
|
195
|
+
try:
|
|
196
|
+
response = self._request(
|
|
197
|
+
"POST", "/v1/analyze", files=files, timeout=timeout, rewind=resolved
|
|
198
|
+
)
|
|
199
|
+
finally:
|
|
200
|
+
if resolved is not None:
|
|
201
|
+
resolved.close()
|
|
202
|
+
return job_from_submit(response.json())
|
|
203
|
+
|
|
204
|
+
def get_job(self, job_id: str, *, timeout: float | None = None) -> Job:
|
|
205
|
+
"""Fetch the latest status of an asynchronous job."""
|
|
206
|
+
|
|
207
|
+
response = self._request("GET", f"/v1/status/{job_id}", timeout=timeout)
|
|
208
|
+
return Job.from_dict(response.json())
|
|
209
|
+
|
|
210
|
+
def wait_for_job(
|
|
211
|
+
self,
|
|
212
|
+
job_id: str,
|
|
213
|
+
*,
|
|
214
|
+
poll_interval: float = 1.0,
|
|
215
|
+
timeout: float = 300.0,
|
|
216
|
+
) -> Job:
|
|
217
|
+
"""Poll a job until it reaches a terminal status.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
job_id: The job ID from :py:meth:`analyze_async`, or from the
|
|
221
|
+
``job_id`` attribute of a :class:`~floorplan_api.TimeoutError`
|
|
222
|
+
raised by a synchronous call.
|
|
223
|
+
poll_interval: Seconds to wait between polls. Default: 1.0.
|
|
224
|
+
timeout: Maximum number of seconds to wait. Default: 300.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The final :class:`Job` (``completed`` or ``failed``).
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
TimeoutError: if the job does not finish within ``timeout``;
|
|
231
|
+
``job_id`` is set and the job keeps running server-side.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
deadline = time.monotonic() + timeout
|
|
235
|
+
while True:
|
|
236
|
+
job = self.get_job(job_id)
|
|
237
|
+
if job.is_terminal:
|
|
238
|
+
return job
|
|
239
|
+
if time.monotonic() > deadline:
|
|
240
|
+
raise ApiTimeoutError(
|
|
241
|
+
f"Job {job_id} did not finish within {timeout} seconds.", job_id=job_id
|
|
242
|
+
)
|
|
243
|
+
time.sleep(poll_interval)
|
|
244
|
+
|
|
245
|
+
def download_mask(self, job_id: str, *, timeout: float | None = None) -> MaskBytes:
|
|
246
|
+
"""Download the wall mask PNG for a completed job.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
InvalidRequestError: (409) if the job has not completed yet.
|
|
250
|
+
NotFoundError: if the job is unknown or has expired.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
response = self._request("GET", f"/v1/jobs/{job_id}/mask", timeout=timeout)
|
|
254
|
+
return mask_from_response(response.content, response.headers)
|
|
255
|
+
|
|
256
|
+
def create_upload(
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
content_type: str = "image/png",
|
|
260
|
+
timeout: float | None = None,
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
"""Reserve a presigned upload slot.
|
|
263
|
+
|
|
264
|
+
Returns ``{upload_url, key, expires_at, content_type}``. PUT the
|
|
265
|
+
bytes to ``upload_url`` with header ``Content-Type: <content_type>``
|
|
266
|
+
within 15 minutes, then pass ``key`` as ``upload_key`` to
|
|
267
|
+
:py:meth:`extract`, :py:meth:`analyze` or :py:meth:`analyze_async`.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
normalized = normalize_content_type(content_type)
|
|
271
|
+
if normalized is None:
|
|
272
|
+
raise InvalidRequestError(
|
|
273
|
+
f"Unsupported content_type {content_type!r}. Use image/png, image/jpeg, "
|
|
274
|
+
"image/webp or application/pdf."
|
|
275
|
+
)
|
|
276
|
+
response = self._request(
|
|
277
|
+
"POST", "/v1/uploads", json_body={"content_type": normalized}, timeout=timeout
|
|
278
|
+
)
|
|
279
|
+
payload = response.json()
|
|
280
|
+
if not isinstance(payload, dict):
|
|
281
|
+
raise ServerError("Unexpected response from /v1/uploads.", status_code=None)
|
|
282
|
+
return payload
|
|
283
|
+
|
|
284
|
+
def upload(
|
|
285
|
+
self,
|
|
286
|
+
image: ImageInput,
|
|
287
|
+
*,
|
|
288
|
+
page: int | None = None,
|
|
289
|
+
content_type: str | None = None,
|
|
290
|
+
timeout: float | None = None,
|
|
291
|
+
) -> str:
|
|
292
|
+
"""PUT the file directly to object storage and return its storage key.
|
|
293
|
+
|
|
294
|
+
The API server never sees the bytes: the client requests a presigned
|
|
295
|
+
URL via :py:meth:`create_upload` and uploads straight to storage. Use
|
|
296
|
+
the returned key as ``upload_key`` on :py:meth:`extract`,
|
|
297
|
+
:py:meth:`analyze` or :py:meth:`analyze_async`, or use
|
|
298
|
+
:py:meth:`upload_then_extract` for the one-shot version.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
image: Path, bytes, or open binary file-like.
|
|
302
|
+
page: For a PDF, the 1-based page to keep (default 1). Only that
|
|
303
|
+
page is uploaded, so the key refers to a single-page PDF.
|
|
304
|
+
content_type: Override the detected type (rarely needed).
|
|
305
|
+
timeout: Timeout for each of the two requests.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
resolved = prepare_input(image, page)
|
|
309
|
+
return self._upload_prepared(resolved, content_type, timeout)
|
|
310
|
+
|
|
311
|
+
def _upload_prepared(
|
|
312
|
+
self,
|
|
313
|
+
resolved: ResolvedInput,
|
|
314
|
+
content_type: str | None,
|
|
315
|
+
timeout: float | None,
|
|
316
|
+
) -> str:
|
|
317
|
+
try:
|
|
318
|
+
ct = normalize_content_type(content_type) or resolved.content_type
|
|
319
|
+
presigned = self.create_upload(content_type=ct, timeout=timeout)
|
|
320
|
+
request_timeout = self.timeout if timeout is None else timeout
|
|
321
|
+
try:
|
|
322
|
+
put = self._session.put(
|
|
323
|
+
presigned["upload_url"],
|
|
324
|
+
data=resolved.file,
|
|
325
|
+
headers={"Content-Type": ct},
|
|
326
|
+
timeout=request_timeout,
|
|
327
|
+
)
|
|
328
|
+
except requests.Timeout as exc:
|
|
329
|
+
raise ApiTimeoutError(str(exc)) from exc
|
|
330
|
+
except requests.ConnectionError as exc:
|
|
331
|
+
raise ApiConnectionError(str(exc)) from exc
|
|
332
|
+
if put.status_code >= 400:
|
|
333
|
+
raise ServerError(
|
|
334
|
+
f"Upload PUT failed ({put.status_code}): {put.text[:200]}",
|
|
335
|
+
status_code=put.status_code,
|
|
336
|
+
)
|
|
337
|
+
finally:
|
|
338
|
+
resolved.close()
|
|
339
|
+
key = presigned.get("key")
|
|
340
|
+
if not isinstance(key, str) or not key:
|
|
341
|
+
raise ServerError("Upload response missing `key`.", status_code=None)
|
|
342
|
+
return key
|
|
343
|
+
|
|
344
|
+
def upload_then_extract(
|
|
345
|
+
self,
|
|
346
|
+
image: ImageInput,
|
|
347
|
+
*,
|
|
348
|
+
page: int | None = None,
|
|
349
|
+
threshold_bytes: int = DEFAULT_PRESIGN_THRESHOLD,
|
|
350
|
+
timeout: float | None = None,
|
|
351
|
+
) -> MaskBytes:
|
|
352
|
+
"""Extract walls, switching to the presigned flow for large files.
|
|
353
|
+
|
|
354
|
+
Files smaller than ``threshold_bytes`` (default 10 MiB, the inline
|
|
355
|
+
cap) go through :py:meth:`extract` directly. Larger files, or files
|
|
356
|
+
whose size cannot be determined cheaply, are PUT to object storage
|
|
357
|
+
via a presigned URL and extracted by storage key.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
resolved = prepare_input(image, page)
|
|
361
|
+
size = resolved.size()
|
|
362
|
+
if size is not None and size < threshold_bytes:
|
|
363
|
+
return self._submit_prepared("/v1/extract", resolved, {}, timeout)
|
|
364
|
+
key = self._upload_prepared(resolved, None, timeout)
|
|
365
|
+
return self._submit_files(
|
|
366
|
+
"/v1/extract",
|
|
367
|
+
None,
|
|
368
|
+
{"upload_key": (None, key)},
|
|
369
|
+
timeout,
|
|
370
|
+
page_size_pt=resolved.page_size_pt,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def close(self) -> None:
|
|
374
|
+
"""Close the underlying HTTP session."""
|
|
375
|
+
|
|
376
|
+
self._session.close()
|
|
377
|
+
|
|
378
|
+
def __enter__(self) -> Client:
|
|
379
|
+
return self
|
|
380
|
+
|
|
381
|
+
def __exit__(self, *exc: Any) -> None:
|
|
382
|
+
self.close()
|
|
383
|
+
|
|
384
|
+
# ----- internals -----
|
|
385
|
+
|
|
386
|
+
def _submit_for_mask(
|
|
387
|
+
self,
|
|
388
|
+
path: str,
|
|
389
|
+
image: ImageInput | None,
|
|
390
|
+
upload_key: str | None,
|
|
391
|
+
page: int | None,
|
|
392
|
+
timeout: float | None,
|
|
393
|
+
) -> MaskBytes:
|
|
394
|
+
resolved, files = _multipart(image, upload_key, page, {})
|
|
395
|
+
return self._submit_files(path, resolved, files, timeout) # page size rides on resolved
|
|
396
|
+
|
|
397
|
+
def _submit_prepared(
|
|
398
|
+
self,
|
|
399
|
+
path: str,
|
|
400
|
+
resolved: ResolvedInput,
|
|
401
|
+
extra: Mapping[str, str],
|
|
402
|
+
timeout: float | None,
|
|
403
|
+
) -> MaskBytes:
|
|
404
|
+
files: dict[str, Any] = {name: (None, value) for name, value in extra.items()}
|
|
405
|
+
files["image"] = resolved.part
|
|
406
|
+
return self._submit_files(
|
|
407
|
+
path, resolved, files, timeout, page_size_pt=resolved.page_size_pt
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def _submit_files(
|
|
411
|
+
self,
|
|
412
|
+
path: str,
|
|
413
|
+
resolved: ResolvedInput | None,
|
|
414
|
+
files: dict[str, Any],
|
|
415
|
+
timeout: float | None,
|
|
416
|
+
page_size_pt: tuple[float, float] | None = None,
|
|
417
|
+
) -> MaskBytes:
|
|
418
|
+
if resolved is not None and page_size_pt is None:
|
|
419
|
+
page_size_pt = resolved.page_size_pt
|
|
420
|
+
try:
|
|
421
|
+
response = self._request("POST", path, files=files, timeout=timeout, rewind=resolved)
|
|
422
|
+
finally:
|
|
423
|
+
if resolved is not None:
|
|
424
|
+
resolved.close()
|
|
425
|
+
return mask_from_response(response.content, response.headers, page_size_pt)
|
|
426
|
+
|
|
427
|
+
def _request(
|
|
428
|
+
self,
|
|
429
|
+
method: str,
|
|
430
|
+
path: str,
|
|
431
|
+
*,
|
|
432
|
+
files: Mapping[str, Any] | None = None,
|
|
433
|
+
json_body: Any | None = None,
|
|
434
|
+
timeout: float | None = None,
|
|
435
|
+
rewind: ResolvedInput | None = None,
|
|
436
|
+
) -> requests.Response:
|
|
437
|
+
url = build_url(self.base_url, path)
|
|
438
|
+
request_timeout = self.timeout if timeout is None else timeout
|
|
439
|
+
attempt = 0
|
|
440
|
+
while True:
|
|
441
|
+
if attempt and rewind is not None:
|
|
442
|
+
rewind.rewind()
|
|
443
|
+
try:
|
|
444
|
+
response = self._session.request(
|
|
445
|
+
method=method,
|
|
446
|
+
url=url,
|
|
447
|
+
headers=build_headers(self.api_key),
|
|
448
|
+
files=files,
|
|
449
|
+
json=json_body,
|
|
450
|
+
timeout=request_timeout,
|
|
451
|
+
)
|
|
452
|
+
except requests.Timeout as exc:
|
|
453
|
+
if attempt < self.max_retries:
|
|
454
|
+
time.sleep(retry_delay(attempt, self.retry_backoff))
|
|
455
|
+
attempt += 1
|
|
456
|
+
continue
|
|
457
|
+
raise ApiTimeoutError(str(exc)) from exc
|
|
458
|
+
except requests.ConnectionError as exc:
|
|
459
|
+
if attempt < self.max_retries:
|
|
460
|
+
time.sleep(retry_delay(attempt, self.retry_backoff))
|
|
461
|
+
attempt += 1
|
|
462
|
+
continue
|
|
463
|
+
raise ApiConnectionError(str(exc)) from exc
|
|
464
|
+
|
|
465
|
+
if response.status_code < 400:
|
|
466
|
+
return response
|
|
467
|
+
|
|
468
|
+
body = decode_json(response.text)
|
|
469
|
+
if attempt < self.max_retries and should_retry(response.status_code, body):
|
|
470
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
|
|
471
|
+
time.sleep(retry_delay(attempt, self.retry_backoff, retry_after))
|
|
472
|
+
attempt += 1
|
|
473
|
+
continue
|
|
474
|
+
raise error_from_response(response.status_code, response.headers, body, response.text)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _multipart(
|
|
478
|
+
image: ImageInput | None,
|
|
479
|
+
upload_key: str | None,
|
|
480
|
+
page: int | None,
|
|
481
|
+
extra: Mapping[str, str],
|
|
482
|
+
) -> tuple[ResolvedInput | None, dict[str, Any]]:
|
|
483
|
+
"""Build the multipart ``files`` mapping for an extract/analyze call.
|
|
484
|
+
|
|
485
|
+
Every field, including plain text ones, is passed through ``files`` so
|
|
486
|
+
the request is always ``multipart/form-data`` regardless of whether an
|
|
487
|
+
image part is present.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
if (image is None) == (upload_key is None):
|
|
491
|
+
raise InvalidRequestError("Pass exactly one of `image` or `upload_key`.")
|
|
492
|
+
files: dict[str, Any] = {name: (None, value) for name, value in extra.items()}
|
|
493
|
+
if upload_key is not None:
|
|
494
|
+
files["upload_key"] = (None, upload_key)
|
|
495
|
+
if page is not None and page != 1:
|
|
496
|
+
# The object is already in storage, so the server selects the page.
|
|
497
|
+
files["page"] = (None, str(page))
|
|
498
|
+
return None, files
|
|
499
|
+
# For a file we hold, the page is cut out locally and only it is sent.
|
|
500
|
+
resolved = prepare_input(image, page) # type: ignore[arg-type]
|
|
501
|
+
files["image"] = resolved.part
|
|
502
|
+
return resolved, files
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Exception hierarchy for the Floor Plan API client.
|
|
2
|
+
|
|
3
|
+
All exceptions raised by :class:`floorplan_api.Client` and
|
|
4
|
+
:class:`floorplan_api.AsyncClient` derive from :class:`FloorPlanError`.
|
|
5
|
+
Catch the base class to handle every API error::
|
|
6
|
+
|
|
7
|
+
from floorplan_api import Client, FloorPlanError
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
mask = client.extract("plan.png")
|
|
11
|
+
except FloorPlanError as exc:
|
|
12
|
+
print(f"API error: {exc}")
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FloorPlanError(Exception):
|
|
21
|
+
"""Base class for every error raised by the client.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
message: Human-readable error message.
|
|
25
|
+
status_code: HTTP status code returned by the server (if any).
|
|
26
|
+
type: Server-provided error type (e.g. ``"authentication_error"``).
|
|
27
|
+
request_id: Server-provided request ID (helpful for support tickets).
|
|
28
|
+
job_id: The job the server created for this request, when it told
|
|
29
|
+
us. Set on :class:`TimeoutError` (504) and
|
|
30
|
+
:class:`ProcessingError` (500) so the caller can still poll
|
|
31
|
+
:py:meth:`~floorplan_api.Client.get_job` and download the mask.
|
|
32
|
+
response: Raw decoded JSON response body when available.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
message: str,
|
|
38
|
+
*,
|
|
39
|
+
status_code: int | None = None,
|
|
40
|
+
type: str | None = None,
|
|
41
|
+
request_id: str | None = None,
|
|
42
|
+
job_id: str | None = None,
|
|
43
|
+
response: dict[str, Any] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.message = message
|
|
47
|
+
self.status_code = status_code
|
|
48
|
+
self.type = type
|
|
49
|
+
self.request_id = request_id
|
|
50
|
+
self.job_id = job_id
|
|
51
|
+
self.response = response
|
|
52
|
+
|
|
53
|
+
def __str__(self) -> str:
|
|
54
|
+
parts = [self.message]
|
|
55
|
+
if self.status_code is not None:
|
|
56
|
+
parts.append(f"(status={self.status_code})")
|
|
57
|
+
if self.request_id:
|
|
58
|
+
parts.append(f"(request_id={self.request_id})")
|
|
59
|
+
if self.job_id:
|
|
60
|
+
parts.append(f"(job_id={self.job_id})")
|
|
61
|
+
return " ".join(parts)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AuthenticationError(FloorPlanError):
|
|
65
|
+
"""Raised when the API key is missing, malformed, expired, or revoked (401),
|
|
66
|
+
or the resource belongs to another account (403)."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class InvalidRequestError(FloorPlanError):
|
|
70
|
+
"""Raised when the request was malformed or the input unsupported
|
|
71
|
+
(400, 409, 413, 415), or when the client rejects the input before sending."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class NotFoundError(FloorPlanError):
|
|
75
|
+
"""Raised when a resource does not exist (404)."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RateLimitError(FloorPlanError):
|
|
79
|
+
"""Raised when the rate limit was exceeded (429) and retries are exhausted.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
retry_after: Number of seconds to wait before retrying, when provided
|
|
83
|
+
by the server's ``Retry-After`` header.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, message: str, *, retry_after: float | None = None, **kwargs: Any) -> None:
|
|
87
|
+
super().__init__(message, **kwargs)
|
|
88
|
+
self.retry_after = retry_after
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ServerError(FloorPlanError):
|
|
92
|
+
"""Raised when the API returns a 5xx response after retries are exhausted.
|
|
93
|
+
|
|
94
|
+
A 503 with ``Retry-After`` (queue at capacity) is retried, honouring the
|
|
95
|
+
header, before this is raised.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ProcessingError(ServerError):
|
|
100
|
+
"""Raised when the worker failed the job (500 with ``job_id``).
|
|
101
|
+
|
|
102
|
+
The input was accepted and queued, but inference on it failed, for
|
|
103
|
+
example because the bytes could not be decoded. This is not retried:
|
|
104
|
+
resubmitting the same input would fail the same way. ``job_id`` and
|
|
105
|
+
``type`` (``"inference_error"``, ``"storage_error"``, ...) are set.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TimeoutError(FloorPlanError): # noqa: A001 — intentional shadowing for API parity
|
|
110
|
+
"""Raised when a request exceeds the configured timeout, when
|
|
111
|
+
:py:meth:`~floorplan_api.Client.wait_for_job` gives up, or when the server
|
|
112
|
+
answers 504 because the worker did not finish within its synchronous
|
|
113
|
+
budget.
|
|
114
|
+
|
|
115
|
+
In the 504 case ``job_id`` is set and the job is still running: call
|
|
116
|
+
``client.wait_for_job(exc.job_id)`` then ``client.download_mask(exc.job_id)``
|
|
117
|
+
instead of resubmitting. The client never retries a 504 that carries a
|
|
118
|
+
``job_id``, because each retry would queue another copy of the job.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ConnectionError(FloorPlanError): # noqa: A001 — intentional shadowing for API parity
|
|
123
|
+
"""Raised when the underlying transport fails (DNS, TCP, TLS)."""
|