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
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""asyncio client for the Floor Plan API (built on ``httpx``).
|
|
2
|
+
|
|
3
|
+
Install with the extra::
|
|
4
|
+
|
|
5
|
+
pip install "floorplan-api[async]"
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import httpx
|
|
17
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
18
|
+
raise ImportError(
|
|
19
|
+
"AsyncClient requires httpx. Install it with: pip install 'floorplan-api[async]'"
|
|
20
|
+
) from exc
|
|
21
|
+
|
|
22
|
+
from floorplan_api._input import (
|
|
23
|
+
ImageInput,
|
|
24
|
+
ResolvedInput,
|
|
25
|
+
normalize_content_type,
|
|
26
|
+
prepare_input,
|
|
27
|
+
)
|
|
28
|
+
from floorplan_api._transport import (
|
|
29
|
+
DEFAULT_MAX_RETRIES,
|
|
30
|
+
DEFAULT_PRESIGN_THRESHOLD,
|
|
31
|
+
DEFAULT_RETRY_BACKOFF,
|
|
32
|
+
DEFAULT_TIMEOUT,
|
|
33
|
+
build_headers,
|
|
34
|
+
build_url,
|
|
35
|
+
decode_json,
|
|
36
|
+
error_from_response,
|
|
37
|
+
job_from_submit,
|
|
38
|
+
mask_from_response,
|
|
39
|
+
parse_retry_after,
|
|
40
|
+
resolve_credentials,
|
|
41
|
+
retry_delay,
|
|
42
|
+
should_retry,
|
|
43
|
+
)
|
|
44
|
+
from floorplan_api.exceptions import (
|
|
45
|
+
ConnectionError as ApiConnectionError,
|
|
46
|
+
)
|
|
47
|
+
from floorplan_api.exceptions import (
|
|
48
|
+
InvalidRequestError,
|
|
49
|
+
ServerError,
|
|
50
|
+
)
|
|
51
|
+
from floorplan_api.exceptions import (
|
|
52
|
+
TimeoutError as ApiTimeoutError,
|
|
53
|
+
)
|
|
54
|
+
from floorplan_api.models import Job, MaskBytes
|
|
55
|
+
|
|
56
|
+
__all__ = ["AsyncClient"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AsyncClient:
|
|
60
|
+
"""asyncio Floor Plan API client. Same surface as :class:`~floorplan_api.Client`,
|
|
61
|
+
every method is a coroutine.
|
|
62
|
+
|
|
63
|
+
Use it as an async context manager so the connection pool is closed::
|
|
64
|
+
|
|
65
|
+
async with AsyncClient(api_key="fp_test_...") as client:
|
|
66
|
+
mask = await client.extract("plan.pdf")
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
api_key: Your API key, or ``FLOORPLAN_API_KEY`` from the environment.
|
|
70
|
+
base_url: API base URL, or ``FLOORPLAN_BASE_URL``; defaults to
|
|
71
|
+
``https://api.floorplanapi.com``.
|
|
72
|
+
timeout: Per-request timeout in seconds. Default: 60.
|
|
73
|
+
max_retries: Retries for network failures, 429s, and transient 5xx.
|
|
74
|
+
Default: 3.
|
|
75
|
+
retry_backoff: Base delay for exponential backoff. Default: 0.5.
|
|
76
|
+
client: An existing :class:`httpx.AsyncClient` to reuse (proxies,
|
|
77
|
+
custom transport, HTTP/2). If omitted, one is created.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
api_key: str | None = None,
|
|
83
|
+
*,
|
|
84
|
+
base_url: str | None = None,
|
|
85
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
86
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
87
|
+
retry_backoff: float = DEFAULT_RETRY_BACKOFF,
|
|
88
|
+
client: httpx.AsyncClient | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
self.api_key, self.base_url = resolve_credentials(api_key, base_url)
|
|
91
|
+
self.timeout = timeout
|
|
92
|
+
self.max_retries = max(0, int(max_retries))
|
|
93
|
+
self.retry_backoff = max(0.0, float(retry_backoff))
|
|
94
|
+
self._client = client or httpx.AsyncClient()
|
|
95
|
+
self._owns_client = client is None
|
|
96
|
+
|
|
97
|
+
# ----- public API -----
|
|
98
|
+
|
|
99
|
+
async def extract(
|
|
100
|
+
self,
|
|
101
|
+
image: ImageInput | None = None,
|
|
102
|
+
*,
|
|
103
|
+
upload_key: str | None = None,
|
|
104
|
+
page: int | None = None,
|
|
105
|
+
timeout: float | None = None,
|
|
106
|
+
) -> MaskBytes:
|
|
107
|
+
"""Run wall segmentation and return the mask PNG. See
|
|
108
|
+
:py:meth:`floorplan_api.Client.extract`."""
|
|
109
|
+
|
|
110
|
+
return await self._submit_for_mask("/v1/extract", image, upload_key, page, timeout)
|
|
111
|
+
|
|
112
|
+
async def analyze(
|
|
113
|
+
self,
|
|
114
|
+
image: ImageInput | None = None,
|
|
115
|
+
*,
|
|
116
|
+
upload_key: str | None = None,
|
|
117
|
+
page: int | None = None,
|
|
118
|
+
timeout: float | None = None,
|
|
119
|
+
) -> MaskBytes:
|
|
120
|
+
"""Same wall mask as :py:meth:`extract`. See
|
|
121
|
+
:py:meth:`floorplan_api.Client.analyze`."""
|
|
122
|
+
|
|
123
|
+
return await self._submit_for_mask("/v1/analyze", image, upload_key, page, timeout)
|
|
124
|
+
|
|
125
|
+
async def analyze_async(
|
|
126
|
+
self,
|
|
127
|
+
image: ImageInput | None = None,
|
|
128
|
+
*,
|
|
129
|
+
upload_key: str | None = None,
|
|
130
|
+
page: int | None = None,
|
|
131
|
+
timeout: float | None = None,
|
|
132
|
+
) -> Job:
|
|
133
|
+
"""Submit a job and return its :class:`~floorplan_api.Job` immediately."""
|
|
134
|
+
|
|
135
|
+
resolved, files = _multipart(image, upload_key, page, {"async": "true"})
|
|
136
|
+
try:
|
|
137
|
+
response = await self._request(
|
|
138
|
+
"POST", "/v1/analyze", files=files, timeout=timeout, rewind=resolved
|
|
139
|
+
)
|
|
140
|
+
finally:
|
|
141
|
+
if resolved is not None:
|
|
142
|
+
resolved.close()
|
|
143
|
+
return job_from_submit(response.json())
|
|
144
|
+
|
|
145
|
+
async def get_job(self, job_id: str, *, timeout: float | None = None) -> Job:
|
|
146
|
+
"""Fetch the latest status of an asynchronous job."""
|
|
147
|
+
|
|
148
|
+
response = await self._request("GET", f"/v1/status/{job_id}", timeout=timeout)
|
|
149
|
+
return Job.from_dict(response.json())
|
|
150
|
+
|
|
151
|
+
async def wait_for_job(
|
|
152
|
+
self,
|
|
153
|
+
job_id: str,
|
|
154
|
+
*,
|
|
155
|
+
poll_interval: float = 1.0,
|
|
156
|
+
timeout: float = 300.0,
|
|
157
|
+
) -> Job:
|
|
158
|
+
"""Poll a job until it reaches a terminal status. See
|
|
159
|
+
:py:meth:`floorplan_api.Client.wait_for_job`."""
|
|
160
|
+
|
|
161
|
+
deadline = time.monotonic() + timeout
|
|
162
|
+
while True:
|
|
163
|
+
job = await self.get_job(job_id)
|
|
164
|
+
if job.is_terminal:
|
|
165
|
+
return job
|
|
166
|
+
if time.monotonic() > deadline:
|
|
167
|
+
raise ApiTimeoutError(
|
|
168
|
+
f"Job {job_id} did not finish within {timeout} seconds.", job_id=job_id
|
|
169
|
+
)
|
|
170
|
+
await asyncio.sleep(poll_interval)
|
|
171
|
+
|
|
172
|
+
async def download_mask(self, job_id: str, *, timeout: float | None = None) -> MaskBytes:
|
|
173
|
+
"""Download the wall mask PNG for a completed job."""
|
|
174
|
+
|
|
175
|
+
response = await self._request("GET", f"/v1/jobs/{job_id}/mask", timeout=timeout)
|
|
176
|
+
return mask_from_response(response.content, response.headers)
|
|
177
|
+
|
|
178
|
+
async def create_upload(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
content_type: str = "image/png",
|
|
182
|
+
timeout: float | None = None,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
"""Reserve a presigned upload slot. See
|
|
185
|
+
:py:meth:`floorplan_api.Client.create_upload`."""
|
|
186
|
+
|
|
187
|
+
normalized = normalize_content_type(content_type)
|
|
188
|
+
if normalized is None:
|
|
189
|
+
raise InvalidRequestError(
|
|
190
|
+
f"Unsupported content_type {content_type!r}. Use image/png, image/jpeg, "
|
|
191
|
+
"image/webp or application/pdf."
|
|
192
|
+
)
|
|
193
|
+
response = await self._request(
|
|
194
|
+
"POST", "/v1/uploads", json_body={"content_type": normalized}, timeout=timeout
|
|
195
|
+
)
|
|
196
|
+
payload = response.json()
|
|
197
|
+
if not isinstance(payload, dict):
|
|
198
|
+
raise ServerError("Unexpected response from /v1/uploads.", status_code=None)
|
|
199
|
+
return payload
|
|
200
|
+
|
|
201
|
+
async def upload(
|
|
202
|
+
self,
|
|
203
|
+
image: ImageInput,
|
|
204
|
+
*,
|
|
205
|
+
page: int | None = None,
|
|
206
|
+
content_type: str | None = None,
|
|
207
|
+
timeout: float | None = None,
|
|
208
|
+
) -> str:
|
|
209
|
+
"""PUT the file directly to object storage and return its storage key.
|
|
210
|
+
See :py:meth:`floorplan_api.Client.upload`."""
|
|
211
|
+
|
|
212
|
+
resolved = prepare_input(image, page)
|
|
213
|
+
return await self._upload_prepared(resolved, content_type, timeout)
|
|
214
|
+
|
|
215
|
+
async def _upload_prepared(
|
|
216
|
+
self,
|
|
217
|
+
resolved: ResolvedInput,
|
|
218
|
+
content_type: str | None,
|
|
219
|
+
timeout: float | None,
|
|
220
|
+
) -> str:
|
|
221
|
+
try:
|
|
222
|
+
ct = normalize_content_type(content_type) or resolved.content_type
|
|
223
|
+
presigned = await self.create_upload(content_type=ct, timeout=timeout)
|
|
224
|
+
request_timeout = self.timeout if timeout is None else timeout
|
|
225
|
+
try:
|
|
226
|
+
put = await self._client.put(
|
|
227
|
+
presigned["upload_url"],
|
|
228
|
+
content=resolved.file.read(),
|
|
229
|
+
headers={"Content-Type": ct},
|
|
230
|
+
timeout=request_timeout,
|
|
231
|
+
)
|
|
232
|
+
except httpx.TimeoutException as exc:
|
|
233
|
+
raise ApiTimeoutError(str(exc)) from exc
|
|
234
|
+
except httpx.TransportError as exc:
|
|
235
|
+
raise ApiConnectionError(str(exc)) from exc
|
|
236
|
+
if put.status_code >= 400:
|
|
237
|
+
raise ServerError(
|
|
238
|
+
f"Upload PUT failed ({put.status_code}): {put.text[:200]}",
|
|
239
|
+
status_code=put.status_code,
|
|
240
|
+
)
|
|
241
|
+
finally:
|
|
242
|
+
resolved.close()
|
|
243
|
+
key = presigned.get("key")
|
|
244
|
+
if not isinstance(key, str) or not key:
|
|
245
|
+
raise ServerError("Upload response missing `key`.", status_code=None)
|
|
246
|
+
return key
|
|
247
|
+
|
|
248
|
+
async def upload_then_extract(
|
|
249
|
+
self,
|
|
250
|
+
image: ImageInput,
|
|
251
|
+
*,
|
|
252
|
+
page: int | None = None,
|
|
253
|
+
threshold_bytes: int = DEFAULT_PRESIGN_THRESHOLD,
|
|
254
|
+
timeout: float | None = None,
|
|
255
|
+
) -> MaskBytes:
|
|
256
|
+
"""Extract walls, switching to the presigned flow for large files.
|
|
257
|
+
See :py:meth:`floorplan_api.Client.upload_then_extract`."""
|
|
258
|
+
|
|
259
|
+
resolved = prepare_input(image, page)
|
|
260
|
+
size = resolved.size()
|
|
261
|
+
if size is not None and size < threshold_bytes:
|
|
262
|
+
return await self._submit_prepared("/v1/extract", resolved, {}, timeout)
|
|
263
|
+
key = await self._upload_prepared(resolved, None, timeout)
|
|
264
|
+
return await self._submit_files(
|
|
265
|
+
"/v1/extract",
|
|
266
|
+
None,
|
|
267
|
+
{"upload_key": (None, key)},
|
|
268
|
+
timeout,
|
|
269
|
+
page_size_pt=resolved.page_size_pt,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
async def aclose(self) -> None:
|
|
273
|
+
"""Close the underlying connection pool (only if this client created it)."""
|
|
274
|
+
|
|
275
|
+
if self._owns_client:
|
|
276
|
+
await self._client.aclose()
|
|
277
|
+
|
|
278
|
+
async def __aenter__(self) -> AsyncClient:
|
|
279
|
+
return self
|
|
280
|
+
|
|
281
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
282
|
+
await self.aclose()
|
|
283
|
+
|
|
284
|
+
# ----- internals -----
|
|
285
|
+
|
|
286
|
+
async def _submit_for_mask(
|
|
287
|
+
self,
|
|
288
|
+
path: str,
|
|
289
|
+
image: ImageInput | None,
|
|
290
|
+
upload_key: str | None,
|
|
291
|
+
page: int | None,
|
|
292
|
+
timeout: float | None,
|
|
293
|
+
) -> MaskBytes:
|
|
294
|
+
resolved, files = _multipart(image, upload_key, page, {})
|
|
295
|
+
return await self._submit_files(path, resolved, files, timeout)
|
|
296
|
+
|
|
297
|
+
async def _submit_prepared(
|
|
298
|
+
self,
|
|
299
|
+
path: str,
|
|
300
|
+
resolved: ResolvedInput,
|
|
301
|
+
extra: Mapping[str, str],
|
|
302
|
+
timeout: float | None,
|
|
303
|
+
) -> MaskBytes:
|
|
304
|
+
files: dict[str, Any] = {name: (None, value) for name, value in extra.items()}
|
|
305
|
+
files["image"] = resolved.part
|
|
306
|
+
return await self._submit_files(
|
|
307
|
+
path, resolved, files, timeout, page_size_pt=resolved.page_size_pt
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
async def _submit_files(
|
|
311
|
+
self,
|
|
312
|
+
path: str,
|
|
313
|
+
resolved: ResolvedInput | None,
|
|
314
|
+
files: dict[str, Any],
|
|
315
|
+
timeout: float | None,
|
|
316
|
+
page_size_pt: tuple[float, float] | None = None,
|
|
317
|
+
) -> MaskBytes:
|
|
318
|
+
if resolved is not None and page_size_pt is None:
|
|
319
|
+
page_size_pt = resolved.page_size_pt
|
|
320
|
+
try:
|
|
321
|
+
response = await self._request(
|
|
322
|
+
"POST", path, files=files, timeout=timeout, rewind=resolved
|
|
323
|
+
)
|
|
324
|
+
finally:
|
|
325
|
+
if resolved is not None:
|
|
326
|
+
resolved.close()
|
|
327
|
+
return mask_from_response(response.content, response.headers, page_size_pt)
|
|
328
|
+
|
|
329
|
+
async def _request(
|
|
330
|
+
self,
|
|
331
|
+
method: str,
|
|
332
|
+
path: str,
|
|
333
|
+
*,
|
|
334
|
+
files: Mapping[str, Any] | None = None,
|
|
335
|
+
json_body: Any | None = None,
|
|
336
|
+
timeout: float | None = None,
|
|
337
|
+
rewind: ResolvedInput | None = None,
|
|
338
|
+
) -> httpx.Response:
|
|
339
|
+
url = build_url(self.base_url, path)
|
|
340
|
+
request_timeout = self.timeout if timeout is None else timeout
|
|
341
|
+
attempt = 0
|
|
342
|
+
while True:
|
|
343
|
+
if attempt and rewind is not None:
|
|
344
|
+
rewind.rewind()
|
|
345
|
+
try:
|
|
346
|
+
response = await self._client.request(
|
|
347
|
+
method,
|
|
348
|
+
url,
|
|
349
|
+
headers=build_headers(self.api_key),
|
|
350
|
+
files=files,
|
|
351
|
+
json=json_body,
|
|
352
|
+
timeout=request_timeout,
|
|
353
|
+
)
|
|
354
|
+
except httpx.TimeoutException as exc:
|
|
355
|
+
if attempt < self.max_retries:
|
|
356
|
+
await asyncio.sleep(retry_delay(attempt, self.retry_backoff))
|
|
357
|
+
attempt += 1
|
|
358
|
+
continue
|
|
359
|
+
raise ApiTimeoutError(str(exc)) from exc
|
|
360
|
+
except httpx.TransportError as exc:
|
|
361
|
+
if attempt < self.max_retries:
|
|
362
|
+
await asyncio.sleep(retry_delay(attempt, self.retry_backoff))
|
|
363
|
+
attempt += 1
|
|
364
|
+
continue
|
|
365
|
+
raise ApiConnectionError(str(exc)) from exc
|
|
366
|
+
|
|
367
|
+
if response.status_code < 400:
|
|
368
|
+
return response
|
|
369
|
+
|
|
370
|
+
body = decode_json(response.text)
|
|
371
|
+
if attempt < self.max_retries and should_retry(response.status_code, body):
|
|
372
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
|
|
373
|
+
await asyncio.sleep(retry_delay(attempt, self.retry_backoff, retry_after))
|
|
374
|
+
attempt += 1
|
|
375
|
+
continue
|
|
376
|
+
raise error_from_response(response.status_code, response.headers, body, response.text)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _multipart(
|
|
380
|
+
image: ImageInput | None,
|
|
381
|
+
upload_key: str | None,
|
|
382
|
+
page: int | None,
|
|
383
|
+
extra: Mapping[str, str],
|
|
384
|
+
) -> tuple[ResolvedInput | None, dict[str, Any]]:
|
|
385
|
+
"""Build the ``files`` mapping for httpx; text fields are sent as parts
|
|
386
|
+
without a filename so the body is always ``multipart/form-data``."""
|
|
387
|
+
|
|
388
|
+
if (image is None) == (upload_key is None):
|
|
389
|
+
raise InvalidRequestError("Pass exactly one of `image` or `upload_key`.")
|
|
390
|
+
files: dict[str, Any] = {name: (None, value) for name, value in extra.items()}
|
|
391
|
+
if upload_key is not None:
|
|
392
|
+
files["upload_key"] = (None, upload_key)
|
|
393
|
+
if page is not None and page != 1:
|
|
394
|
+
# The object is already in storage, so the server selects the page.
|
|
395
|
+
files["page"] = (None, str(page))
|
|
396
|
+
return None, files
|
|
397
|
+
# For a file we hold, the page is cut out locally and only it is sent.
|
|
398
|
+
resolved = prepare_input(image, page) # type: ignore[arg-type]
|
|
399
|
+
files["image"] = resolved.part
|
|
400
|
+
return resolved, files
|