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,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}")
@@ -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")
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version (read by hatch at build time)."""
2
+
3
+ __version__ = "0.5.0"