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.
@@ -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