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,75 @@
|
|
|
1
|
+
"""Official Python client for the Floor Plan API.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
from floorplan_api import Client
|
|
6
|
+
|
|
7
|
+
client = Client(api_key="fp_test_...")
|
|
8
|
+
mask_png = client.extract("path/to/floorplan.png") # PNG, JPEG or WEBP
|
|
9
|
+
mask_png = client.extract("drawing-set.pdf", page=3) # one page of a PDF
|
|
10
|
+
open("walls.png", "wb").write(mask_png)
|
|
11
|
+
|
|
12
|
+
asyncio (``pip install "floorplan-api[async]"``)::
|
|
13
|
+
|
|
14
|
+
from floorplan_api import AsyncClient
|
|
15
|
+
|
|
16
|
+
async with AsyncClient() as client:
|
|
17
|
+
mask_png = await client.extract("plan.pdf")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from floorplan_api._input import SUPPORTED_CONTENT_TYPES, ImageInput, pdf_page_size
|
|
25
|
+
from floorplan_api._version import __version__
|
|
26
|
+
from floorplan_api.client import Client
|
|
27
|
+
from floorplan_api.exceptions import (
|
|
28
|
+
AuthenticationError,
|
|
29
|
+
ConnectionError,
|
|
30
|
+
FloorPlanError,
|
|
31
|
+
InvalidRequestError,
|
|
32
|
+
NotFoundError,
|
|
33
|
+
ProcessingError,
|
|
34
|
+
RateLimitError,
|
|
35
|
+
ServerError,
|
|
36
|
+
TimeoutError,
|
|
37
|
+
)
|
|
38
|
+
from floorplan_api.models import Job, JobStatus, MaskBytes, MaskResult
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from floorplan_api.async_client import AsyncClient
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"__version__",
|
|
45
|
+
"Client",
|
|
46
|
+
"AsyncClient",
|
|
47
|
+
"ImageInput",
|
|
48
|
+
"SUPPORTED_CONTENT_TYPES",
|
|
49
|
+
"pdf_page_size",
|
|
50
|
+
# Models
|
|
51
|
+
"Job",
|
|
52
|
+
"JobStatus",
|
|
53
|
+
"MaskBytes",
|
|
54
|
+
"MaskResult",
|
|
55
|
+
# Errors
|
|
56
|
+
"FloorPlanError",
|
|
57
|
+
"AuthenticationError",
|
|
58
|
+
"RateLimitError",
|
|
59
|
+
"InvalidRequestError",
|
|
60
|
+
"NotFoundError",
|
|
61
|
+
"ProcessingError",
|
|
62
|
+
"ServerError",
|
|
63
|
+
"TimeoutError",
|
|
64
|
+
"ConnectionError",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def __getattr__(name: str) -> Any:
|
|
69
|
+
# AsyncClient needs httpx (the `async` extra); import it only on demand so
|
|
70
|
+
# `import floorplan_api` keeps working with just `requests` installed.
|
|
71
|
+
if name == "AsyncClient":
|
|
72
|
+
from floorplan_api.async_client import AsyncClient
|
|
73
|
+
|
|
74
|
+
return AsyncClient
|
|
75
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
floorplan_api/_input.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Input handling: turn a path, bytes, or binary file-like into an upload part.
|
|
2
|
+
|
|
3
|
+
The API accepts PNG, JPEG, WEBP and PDF. The server decides by the
|
|
4
|
+
``Content-Type`` of the multipart part rather than by inspecting the bytes,
|
|
5
|
+
so the client has to label the part correctly. Raw bytes and unnamed
|
|
6
|
+
file-likes carry no name, so the leading magic bytes are sniffed; paths and
|
|
7
|
+
named file-likes are sniffed too and fall back to the file extension.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import IO, Any, Union
|
|
17
|
+
|
|
18
|
+
from floorplan_api.exceptions import InvalidRequestError
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"SUPPORTED_CONTENT_TYPES",
|
|
22
|
+
"ImageInput",
|
|
23
|
+
"ResolvedInput",
|
|
24
|
+
"content_type_from_filename",
|
|
25
|
+
"input_size",
|
|
26
|
+
"normalize_content_type",
|
|
27
|
+
"pdf_page_size",
|
|
28
|
+
"prepare_input",
|
|
29
|
+
"resolve_input",
|
|
30
|
+
"slice_pdf_page",
|
|
31
|
+
"sniff_content_type",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
ImageInput = Union[str, "os.PathLike[str]", bytes, bytearray, memoryview, IO[bytes]]
|
|
35
|
+
"""Accepted input forms: a filesystem path, the raw bytes of a file, or an
|
|
36
|
+
open binary file-like object. PNG, JPEG, WEBP or PDF."""
|
|
37
|
+
|
|
38
|
+
SUPPORTED_CONTENT_TYPES: tuple[str, ...] = (
|
|
39
|
+
"image/png",
|
|
40
|
+
"image/jpeg",
|
|
41
|
+
"image/webp",
|
|
42
|
+
"application/pdf",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_EXTENSION_TO_CONTENT_TYPE = {
|
|
46
|
+
".png": "image/png",
|
|
47
|
+
".jpg": "image/jpeg",
|
|
48
|
+
".jpeg": "image/jpeg",
|
|
49
|
+
".webp": "image/webp",
|
|
50
|
+
".pdf": "application/pdf",
|
|
51
|
+
}
|
|
52
|
+
_CONTENT_TYPE_TO_EXTENSION = {
|
|
53
|
+
"image/png": "png",
|
|
54
|
+
"image/jpeg": "jpg",
|
|
55
|
+
"image/webp": "webp",
|
|
56
|
+
"application/pdf": "pdf",
|
|
57
|
+
}
|
|
58
|
+
_SNIFF_LENGTH = 12
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sniff_content_type(head: bytes) -> str | None:
|
|
62
|
+
"""Identify PNG, JPEG, WEBP or PDF from the first bytes of a file."""
|
|
63
|
+
|
|
64
|
+
if head.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
65
|
+
return "image/png"
|
|
66
|
+
if head.startswith(b"\xff\xd8\xff"):
|
|
67
|
+
return "image/jpeg"
|
|
68
|
+
if len(head) >= 12 and head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
|
69
|
+
return "image/webp"
|
|
70
|
+
if head.startswith(b"%PDF"):
|
|
71
|
+
return "application/pdf"
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def content_type_from_filename(filename: str) -> str | None:
|
|
76
|
+
return _EXTENSION_TO_CONTENT_TYPE.get(Path(filename).suffix.lower())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def normalize_content_type(value: str | None) -> str | None:
|
|
80
|
+
"""Map aliases (``image/jpg``, ``application/x-pdf``) onto the canonical
|
|
81
|
+
types the API accepts; ``None`` for anything else."""
|
|
82
|
+
|
|
83
|
+
if value is None:
|
|
84
|
+
return None
|
|
85
|
+
lowered = value.lower().split(";", 1)[0].strip()
|
|
86
|
+
if lowered == "image/jpg":
|
|
87
|
+
return "image/jpeg"
|
|
88
|
+
if lowered == "application/x-pdf":
|
|
89
|
+
return "application/pdf"
|
|
90
|
+
return lowered if lowered in SUPPORTED_CONTENT_TYPES else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ResolvedInput:
|
|
95
|
+
"""An input ready to be sent as the ``image`` multipart part.
|
|
96
|
+
|
|
97
|
+
``page`` is the 1-based PDF page this input was cut down to, and
|
|
98
|
+
``page_size_pt`` that page's size in PDF points as it will be rendered;
|
|
99
|
+
both are ``None`` for raster input.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
filename: str
|
|
103
|
+
file: IO[bytes]
|
|
104
|
+
content_type: str
|
|
105
|
+
owns_file: bool = False
|
|
106
|
+
start: int = 0
|
|
107
|
+
page: int | None = None
|
|
108
|
+
page_size_pt: tuple[float, float] | None = None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def is_pdf(self) -> bool:
|
|
112
|
+
return self.content_type == "application/pdf"
|
|
113
|
+
|
|
114
|
+
def size(self) -> int | None:
|
|
115
|
+
"""Bytes left to send, or ``None`` if the stream cannot tell."""
|
|
116
|
+
|
|
117
|
+
return _stream_size(self.file, self.start)
|
|
118
|
+
|
|
119
|
+
def read_all(self) -> bytes:
|
|
120
|
+
"""Read the body from ``start`` and seek back."""
|
|
121
|
+
|
|
122
|
+
self.rewind()
|
|
123
|
+
data = self.file.read()
|
|
124
|
+
self.rewind()
|
|
125
|
+
return data
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def part(self) -> tuple[str, IO[bytes], str]:
|
|
129
|
+
return self.filename, self.file, self.content_type
|
|
130
|
+
|
|
131
|
+
def rewind(self) -> None:
|
|
132
|
+
"""Seek back to where the upload started, so a retry resends the body."""
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
self.file.seek(self.start)
|
|
136
|
+
except (OSError, ValueError, AttributeError):
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
def close(self) -> None:
|
|
140
|
+
if self.owns_file:
|
|
141
|
+
self.file.close()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _unsupported(detail: str) -> InvalidRequestError:
|
|
145
|
+
return InvalidRequestError(
|
|
146
|
+
f"Unsupported input: {detail}. Expected a PNG, JPEG, WEBP or PDF file."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _filename_for(name: str, content_type: str) -> str:
|
|
151
|
+
base = os.path.basename(name) or "floorplan"
|
|
152
|
+
if content_type_from_filename(base) == content_type:
|
|
153
|
+
return base
|
|
154
|
+
stem = Path(base).stem or "floorplan"
|
|
155
|
+
return f"{stem}.{_CONTENT_TYPE_TO_EXTENSION[content_type]}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _name_of(stream: Any) -> str:
|
|
159
|
+
name = getattr(stream, "name", None)
|
|
160
|
+
if isinstance(name, bytes):
|
|
161
|
+
name = name.decode("utf-8", "replace")
|
|
162
|
+
if not isinstance(name, str):
|
|
163
|
+
return ""
|
|
164
|
+
return os.path.basename(name)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _peek(stream: IO[bytes]) -> tuple[bytes, IO[bytes], int]:
|
|
168
|
+
"""Return ``(head, file, start)`` without consuming a seekable stream.
|
|
169
|
+
|
|
170
|
+
A stream that cannot seek is read fully into memory, so that the body
|
|
171
|
+
can be resent on retry.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
seekable = bool(stream.seekable())
|
|
176
|
+
except Exception:
|
|
177
|
+
seekable = False
|
|
178
|
+
if seekable:
|
|
179
|
+
start = stream.tell()
|
|
180
|
+
head = stream.read(_SNIFF_LENGTH)
|
|
181
|
+
stream.seek(start)
|
|
182
|
+
if isinstance(head, str):
|
|
183
|
+
raise InvalidRequestError(
|
|
184
|
+
"Open the file in binary mode ('rb'); text-mode files are not supported."
|
|
185
|
+
)
|
|
186
|
+
return head, stream, start
|
|
187
|
+
data = stream.read()
|
|
188
|
+
if isinstance(data, str):
|
|
189
|
+
raise InvalidRequestError(
|
|
190
|
+
"Open the file in binary mode ('rb'); text-mode files are not supported."
|
|
191
|
+
)
|
|
192
|
+
return data[:_SNIFF_LENGTH], io.BytesIO(data), 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def resolve_input(image: ImageInput) -> ResolvedInput:
|
|
196
|
+
"""Validate ``image`` and return the multipart part to upload.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
InvalidRequestError: if the path does not exist, the stream is not
|
|
200
|
+
binary, or the bytes are not a PNG, JPEG, WEBP or PDF.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
if isinstance(image, (str, os.PathLike)):
|
|
204
|
+
path = Path(image)
|
|
205
|
+
if not path.is_file():
|
|
206
|
+
raise InvalidRequestError(f"Image file does not exist: {path!s}")
|
|
207
|
+
fh = path.open("rb")
|
|
208
|
+
try:
|
|
209
|
+
head = fh.read(_SNIFF_LENGTH)
|
|
210
|
+
fh.seek(0)
|
|
211
|
+
content_type = sniff_content_type(head) or content_type_from_filename(path.name)
|
|
212
|
+
if content_type is None:
|
|
213
|
+
raise _unsupported(f"could not identify {path.name}")
|
|
214
|
+
except BaseException:
|
|
215
|
+
fh.close()
|
|
216
|
+
raise
|
|
217
|
+
return ResolvedInput(
|
|
218
|
+
_filename_for(path.name, content_type), fh, content_type, owns_file=True
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if isinstance(image, (bytes, bytearray, memoryview)):
|
|
222
|
+
data = bytes(image)
|
|
223
|
+
content_type = sniff_content_type(data[:_SNIFF_LENGTH])
|
|
224
|
+
if content_type is None:
|
|
225
|
+
raise _unsupported("bytes do not start with a PNG, JPEG, WEBP or PDF signature")
|
|
226
|
+
return ResolvedInput(
|
|
227
|
+
_filename_for("floorplan", content_type), io.BytesIO(data), content_type, owns_file=True
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if isinstance(image, io.TextIOBase):
|
|
231
|
+
raise InvalidRequestError(
|
|
232
|
+
"Open the file in binary mode ('rb'); text-mode files are not supported."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if hasattr(image, "read"):
|
|
236
|
+
name = _name_of(image)
|
|
237
|
+
head, file, start = _peek(image)
|
|
238
|
+
content_type = sniff_content_type(head) or (
|
|
239
|
+
content_type_from_filename(name) if name else None
|
|
240
|
+
)
|
|
241
|
+
if content_type is None:
|
|
242
|
+
what = name or "stream"
|
|
243
|
+
raise _unsupported(f"{what} does not start with a PNG, JPEG, WEBP or PDF signature")
|
|
244
|
+
return ResolvedInput(
|
|
245
|
+
_filename_for(name or "floorplan", content_type),
|
|
246
|
+
file,
|
|
247
|
+
content_type,
|
|
248
|
+
owns_file=file is not image,
|
|
249
|
+
start=start,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
raise InvalidRequestError("image must be a path, bytes, or a binary file-like object")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _stream_size(stream: Any, start: int) -> int | None:
|
|
256
|
+
if not (hasattr(stream, "seek") and hasattr(stream, "tell")):
|
|
257
|
+
return None
|
|
258
|
+
try:
|
|
259
|
+
current = stream.tell()
|
|
260
|
+
stream.seek(0, os.SEEK_END)
|
|
261
|
+
end = stream.tell()
|
|
262
|
+
stream.seek(current, os.SEEK_SET)
|
|
263
|
+
return int(end - start)
|
|
264
|
+
except (OSError, ValueError, AttributeError):
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def input_size(image: ImageInput) -> int | None:
|
|
269
|
+
"""Best-effort size in bytes, or ``None`` if it cannot be determined cheaply."""
|
|
270
|
+
|
|
271
|
+
if isinstance(image, (str, os.PathLike)):
|
|
272
|
+
try:
|
|
273
|
+
return Path(image).stat().st_size
|
|
274
|
+
except OSError:
|
|
275
|
+
return None
|
|
276
|
+
if isinstance(image, (bytes, bytearray, memoryview)):
|
|
277
|
+
return len(image)
|
|
278
|
+
if hasattr(image, "tell"):
|
|
279
|
+
try:
|
|
280
|
+
return _stream_size(image, int(image.tell()))
|
|
281
|
+
except (OSError, ValueError, AttributeError):
|
|
282
|
+
return None
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _validate_page(page: int | None) -> int:
|
|
287
|
+
if page is None:
|
|
288
|
+
return 1
|
|
289
|
+
if isinstance(page, bool) or not isinstance(page, int):
|
|
290
|
+
raise InvalidRequestError("page must be a positive integer (1 = first page).")
|
|
291
|
+
if page < 1:
|
|
292
|
+
raise InvalidRequestError(f"page must be >= 1 (got {page}); pages are 1-based.")
|
|
293
|
+
return page
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def slice_pdf_page(data: bytes, page: int) -> bytes:
|
|
297
|
+
"""Return a new single-page PDF holding page ``page`` (1-based) of ``data``.
|
|
298
|
+
|
|
299
|
+
This is what the client uploads for a PDF. ``pypdf`` copies the page
|
|
300
|
+
object with its content stream, resources (fonts, images) and
|
|
301
|
+
annotations into a fresh document; nothing is rasterised or re-rendered.
|
|
302
|
+
The other pages, document metadata, bookmarks, attachments and form
|
|
303
|
+
definitions are not included.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
InvalidRequestError: if the PDF is encrypted, cannot be parsed, or
|
|
307
|
+
has no page ``page``.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
from pypdf import PdfReader, PdfWriter
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
reader = PdfReader(io.BytesIO(data), strict=False)
|
|
314
|
+
if reader.is_encrypted:
|
|
315
|
+
raise InvalidRequestError(
|
|
316
|
+
"PDF is password-protected; remove the password before uploading."
|
|
317
|
+
)
|
|
318
|
+
count = len(reader.pages)
|
|
319
|
+
except InvalidRequestError:
|
|
320
|
+
raise
|
|
321
|
+
except Exception as exc: # pypdf raises a mix of its own and builtin errors
|
|
322
|
+
text = str(exc).lower()
|
|
323
|
+
if "encrypt" in text or "password" in text or "cryptography" in text:
|
|
324
|
+
raise InvalidRequestError(
|
|
325
|
+
"PDF is password-protected; remove the password before uploading."
|
|
326
|
+
) from exc
|
|
327
|
+
raise InvalidRequestError(f"Could not read PDF: {exc}") from exc
|
|
328
|
+
if count == 0:
|
|
329
|
+
raise InvalidRequestError("PDF has no pages.")
|
|
330
|
+
if page > count:
|
|
331
|
+
raise InvalidRequestError(f"PDF has {count} page(s); page {page} does not exist.")
|
|
332
|
+
try:
|
|
333
|
+
writer = PdfWriter()
|
|
334
|
+
writer.add_page(reader.pages[page - 1])
|
|
335
|
+
out = io.BytesIO()
|
|
336
|
+
writer.write(out)
|
|
337
|
+
except Exception as exc:
|
|
338
|
+
raise InvalidRequestError(f"Could not extract page {page} from PDF: {exc}") from exc
|
|
339
|
+
return out.getvalue()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def pdf_page_size(data: bytes, page: int = 1) -> tuple[float, float]:
|
|
343
|
+
"""``(width, height)`` in PDF points of page ``page`` (1-based) as it renders.
|
|
344
|
+
|
|
345
|
+
The server renders the page's crop box honouring its ``/Rotate`` entry,
|
|
346
|
+
so a landscape page stored rotated reports landscape dimensions here.
|
|
347
|
+
Together with the mask's pixel size this gives the render scale:
|
|
348
|
+
``px_per_pt = mask.width / width_pt``.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
InvalidRequestError: if the PDF cannot be read or has no such page.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
from pypdf import PdfReader
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
reader = PdfReader(io.BytesIO(data), strict=False)
|
|
358
|
+
count = len(reader.pages)
|
|
359
|
+
if not 1 <= page <= count:
|
|
360
|
+
raise InvalidRequestError(f"PDF has {count} page(s); page {page} does not exist.")
|
|
361
|
+
pdf_page = reader.pages[page - 1]
|
|
362
|
+
box = pdf_page.cropbox
|
|
363
|
+
width = float(box.width)
|
|
364
|
+
height = float(box.height)
|
|
365
|
+
rotation = int(pdf_page.rotation or 0) % 360
|
|
366
|
+
except InvalidRequestError:
|
|
367
|
+
raise
|
|
368
|
+
except Exception as exc:
|
|
369
|
+
raise InvalidRequestError(f"Could not read PDF: {exc}") from exc
|
|
370
|
+
if rotation in (90, 270):
|
|
371
|
+
width, height = height, width
|
|
372
|
+
return abs(width), abs(height)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def prepare_input(image: ImageInput, page: int | None = None) -> ResolvedInput:
|
|
376
|
+
"""Resolve ``image`` and, for a PDF, cut it down to the one page to process.
|
|
377
|
+
|
|
378
|
+
Raster input is returned as-is: the bytes are uploaded verbatim. For a
|
|
379
|
+
PDF only the requested page is uploaded: a single-page PDF is built
|
|
380
|
+
locally (see :func:`slice_pdf_page`), so a 40-page drawing set costs one
|
|
381
|
+
page of bandwidth and storage, and the page's size in points is recorded
|
|
382
|
+
for :attr:`floorplan_api.MaskBytes.page_size_pt`. ``page`` is 1-based and
|
|
383
|
+
defaults to the first page; passing a page other than 1 for a raster
|
|
384
|
+
input is an error.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
wanted = _validate_page(page)
|
|
388
|
+
resolved = resolve_input(image)
|
|
389
|
+
try:
|
|
390
|
+
if not resolved.is_pdf:
|
|
391
|
+
if wanted != 1:
|
|
392
|
+
raise InvalidRequestError(
|
|
393
|
+
f"page={wanted} was given but {resolved.filename} is not a PDF; "
|
|
394
|
+
"page selection applies to PDF input only."
|
|
395
|
+
)
|
|
396
|
+
return resolved
|
|
397
|
+
single = slice_pdf_page(resolved.read_all(), wanted)
|
|
398
|
+
except BaseException:
|
|
399
|
+
resolved.close()
|
|
400
|
+
raise
|
|
401
|
+
resolved.close()
|
|
402
|
+
stem = Path(resolved.filename).stem or "floorplan"
|
|
403
|
+
return ResolvedInput(
|
|
404
|
+
f"{stem}.pdf",
|
|
405
|
+
io.BytesIO(single),
|
|
406
|
+
"application/pdf",
|
|
407
|
+
owns_file=True,
|
|
408
|
+
start=0,
|
|
409
|
+
page=wanted,
|
|
410
|
+
page_size_pt=pdf_page_size(single, 1),
|
|
411
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Pieces shared by the synchronous and asyncio clients: URL and header
|
|
2
|
+
construction, retry policy, and mapping HTTP responses onto models and
|
|
3
|
+
exceptions. Nothing in here performs I/O."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import random
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from floorplan_api._version import __version__
|
|
15
|
+
from floorplan_api.exceptions import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
FloorPlanError,
|
|
18
|
+
InvalidRequestError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
ProcessingError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
ServerError,
|
|
23
|
+
)
|
|
24
|
+
from floorplan_api.exceptions import (
|
|
25
|
+
TimeoutError as ApiTimeoutError,
|
|
26
|
+
)
|
|
27
|
+
from floorplan_api.models import Job, MaskBytes
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://api.floorplanapi.com"
|
|
30
|
+
DEFAULT_TIMEOUT = 60.0
|
|
31
|
+
DEFAULT_MAX_RETRIES = 3
|
|
32
|
+
DEFAULT_RETRY_BACKOFF = 0.5
|
|
33
|
+
DEFAULT_PRESIGN_THRESHOLD = 10 * 1024 * 1024
|
|
34
|
+
MAX_RETRY_AFTER = 30.0
|
|
35
|
+
USER_AGENT = f"floorplan-api-python/{__version__}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_credentials(api_key: str | None, base_url: str | None) -> tuple[str, str]:
|
|
39
|
+
key = api_key or os.environ.get("FLOORPLAN_API_KEY")
|
|
40
|
+
if not key:
|
|
41
|
+
raise AuthenticationError(
|
|
42
|
+
"An API key is required. Pass api_key=... or set FLOORPLAN_API_KEY in the environment."
|
|
43
|
+
)
|
|
44
|
+
url = (base_url or os.environ.get("FLOORPLAN_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
45
|
+
return key, url
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_headers(api_key: str) -> dict[str, str]:
|
|
49
|
+
return {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_url(base_url: str, path: str) -> str:
|
|
53
|
+
if path.startswith(("http://", "https://")):
|
|
54
|
+
return path
|
|
55
|
+
if not path.startswith("/"):
|
|
56
|
+
path = "/" + path
|
|
57
|
+
return f"{base_url}{path}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
61
|
+
if not value:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
return float(value)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def retry_delay(attempt: int, backoff: float, retry_after: float | None = None) -> float:
|
|
70
|
+
"""Seconds to sleep before retry number ``attempt`` (0-based).
|
|
71
|
+
|
|
72
|
+
A server-provided ``Retry-After`` wins, capped at :data:`MAX_RETRY_AFTER`;
|
|
73
|
+
otherwise exponential backoff with up to 25 % jitter.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
if retry_after is not None and retry_after > 0:
|
|
77
|
+
return float(min(retry_after, MAX_RETRY_AFTER))
|
|
78
|
+
delay = backoff * (2**attempt)
|
|
79
|
+
return float(delay + delay * 0.25 * random.random())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def decode_json(text: str) -> dict[str, Any]:
|
|
83
|
+
"""Decode a JSON object body; anything else (including PNG bytes) is ``{}``."""
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
body = json.loads(text)
|
|
87
|
+
except (ValueError, TypeError):
|
|
88
|
+
return {}
|
|
89
|
+
return body if isinstance(body, dict) else {}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def job_id_from(body: Mapping[str, Any]) -> str | None:
|
|
93
|
+
value = body.get("job_id")
|
|
94
|
+
return value if isinstance(value, str) and value else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def should_retry(status: int, body: Mapping[str, Any]) -> bool:
|
|
98
|
+
"""Whether a failed response is transient.
|
|
99
|
+
|
|
100
|
+
429 and 5xx are retried, except a 5xx that names a ``job_id``: that is
|
|
101
|
+
the outcome of a job that was queued (504 = still running, 500 = the
|
|
102
|
+
worker failed it), and resubmitting would only queue another copy.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
if status == 429:
|
|
106
|
+
return True
|
|
107
|
+
if status >= 500:
|
|
108
|
+
return job_id_from(body) is None
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def error_from_response(
|
|
113
|
+
status: int,
|
|
114
|
+
headers: Mapping[str, str],
|
|
115
|
+
body: Mapping[str, Any],
|
|
116
|
+
text: str,
|
|
117
|
+
) -> FloorPlanError:
|
|
118
|
+
"""Map an error response onto the matching :class:`FloorPlanError` subclass."""
|
|
119
|
+
|
|
120
|
+
raw_error = body.get("error")
|
|
121
|
+
error_block: Mapping[str, Any] = raw_error if isinstance(raw_error, dict) else {}
|
|
122
|
+
message = error_block.get("message") or text or f"HTTP {status}"
|
|
123
|
+
type_ = error_block.get("type")
|
|
124
|
+
job_id = job_id_from(body)
|
|
125
|
+
kwargs: dict[str, Any] = {
|
|
126
|
+
"status_code": status,
|
|
127
|
+
"type": type_ if isinstance(type_, str) else None,
|
|
128
|
+
"request_id": headers.get("X-Request-Id") or None,
|
|
129
|
+
"job_id": job_id,
|
|
130
|
+
"response": dict(body) if body else None,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if status in (401, 403):
|
|
134
|
+
return AuthenticationError(message, **kwargs)
|
|
135
|
+
if status == 404:
|
|
136
|
+
return NotFoundError(message, **kwargs)
|
|
137
|
+
if status == 429:
|
|
138
|
+
retry_after = parse_retry_after(headers.get("Retry-After"))
|
|
139
|
+
return RateLimitError(message, retry_after=retry_after, **kwargs)
|
|
140
|
+
if status == 504 and job_id is not None:
|
|
141
|
+
return ApiTimeoutError(message, **kwargs)
|
|
142
|
+
if status == 500 and job_id is not None:
|
|
143
|
+
return ProcessingError(message, **kwargs)
|
|
144
|
+
if 400 <= status < 500:
|
|
145
|
+
return InvalidRequestError(message, **kwargs)
|
|
146
|
+
if status >= 500:
|
|
147
|
+
return ServerError(message, **kwargs)
|
|
148
|
+
return FloorPlanError(message, **kwargs)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def mask_from_response(
|
|
152
|
+
content: bytes,
|
|
153
|
+
headers: Mapping[str, str],
|
|
154
|
+
page_size_pt: tuple[float, float] | None = None,
|
|
155
|
+
) -> MaskBytes:
|
|
156
|
+
return MaskBytes.from_response(content, headers, page_size_pt=page_size_pt)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def job_from_submit(payload: Mapping[str, Any]) -> Job:
|
|
160
|
+
"""Build a :class:`Job` from the 202 body of an async submission."""
|
|
161
|
+
|
|
162
|
+
return Job.from_dict(
|
|
163
|
+
{
|
|
164
|
+
"job_id": payload["job_id"],
|
|
165
|
+
"status": payload["status"],
|
|
166
|
+
"created_at": payload.get("created_at") or now_iso(),
|
|
167
|
+
"completed_at": None,
|
|
168
|
+
"result": None,
|
|
169
|
+
"error": None,
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def now_iso() -> str:
|
|
175
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|