morphiaone 0.1.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.
morphiaone/__init__.py ADDED
@@ -0,0 +1,91 @@
1
+ """MorphiaOne SDK (Developer Preview): the V1 API client, its errors and retry policy.
2
+
3
+ from morphiaone import MorphiaOne
4
+
5
+ client = MorphiaOne(api_key=..., base_url=...)
6
+ asset = client.assets.upload("face.jpg")
7
+ job = client.skin.analyze(asset.id, ["acne"], wait=True)
8
+ """
9
+
10
+ from .client import RetryPolicy
11
+ from .errors import (
12
+ ApiError,
13
+ AuthenticationError,
14
+ ConflictError,
15
+ DeadlineExceeded,
16
+ InsufficientCreditsError,
17
+ InsufficientScopeError,
18
+ MorphiaOneError,
19
+ NotFoundError,
20
+ PayloadTooLargeError,
21
+ PermissionDeniedError,
22
+ ProcessingError,
23
+ ProtocolError,
24
+ RateLimitError,
25
+ ServerError,
26
+ ServiceUnavailableError,
27
+ TransportError,
28
+ UnsupportedFeatureError,
29
+ ValidationError,
30
+ )
31
+ from .models import JobError, JobStatus
32
+ from .v1 import (
33
+ Asset,
34
+ AssetContent,
35
+ AsyncMorphiaOne,
36
+ HairAppliedColorGradient,
37
+ HairAppliedColorStop,
38
+ HairTryOnApplied,
39
+ HairTryOnOutput,
40
+ Job,
41
+ JobInputRef,
42
+ JobOutput,
43
+ MakeupTryOnOutput,
44
+ MorphiaOne,
45
+ ResultAssetRef,
46
+ SkinAnalysisOutput,
47
+ SkinConcernResult,
48
+ UnknownJobOutput,
49
+ UsageReport,
50
+ )
51
+
52
+ __all__ = [
53
+ "ApiError",
54
+ "Asset",
55
+ "AssetContent",
56
+ "AsyncMorphiaOne",
57
+ "AuthenticationError",
58
+ "ConflictError",
59
+ "DeadlineExceeded",
60
+ "HairAppliedColorGradient",
61
+ "HairAppliedColorStop",
62
+ "HairTryOnApplied",
63
+ "HairTryOnOutput",
64
+ "InsufficientCreditsError",
65
+ "InsufficientScopeError",
66
+ "Job",
67
+ "JobError",
68
+ "JobInputRef",
69
+ "JobOutput",
70
+ "JobStatus",
71
+ "MakeupTryOnOutput",
72
+ "MorphiaOne",
73
+ "MorphiaOneError",
74
+ "NotFoundError",
75
+ "PayloadTooLargeError",
76
+ "PermissionDeniedError",
77
+ "ProcessingError",
78
+ "ProtocolError",
79
+ "RateLimitError",
80
+ "ResultAssetRef",
81
+ "RetryPolicy",
82
+ "ServerError",
83
+ "ServiceUnavailableError",
84
+ "SkinAnalysisOutput",
85
+ "SkinConcernResult",
86
+ "TransportError",
87
+ "UnknownJobOutput",
88
+ "UnsupportedFeatureError",
89
+ "UsageReport",
90
+ "ValidationError",
91
+ ]
morphiaone/client.py ADDED
@@ -0,0 +1,499 @@
1
+ """The transport MorphiaOne and AsyncMorphiaOne share, and nothing else.
2
+
3
+ The legacy public API this module once wrapped is retired; what stays is the request
4
+ loop, the retry policy and the error table, which `v1.py` borrows through `_transport`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import random
12
+ import time
13
+ from collections.abc import Awaitable, Callable
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from email.utils import parsedate_to_datetime
17
+ from typing import Self
18
+ from urllib.parse import urlsplit
19
+
20
+ import httpx
21
+
22
+ from .errors import (
23
+ ApiError,
24
+ AuthenticationError,
25
+ ConflictError,
26
+ DeadlineExceeded,
27
+ InsufficientCreditsError,
28
+ InsufficientScopeError,
29
+ NotFoundError,
30
+ PayloadTooLargeError,
31
+ PermissionDeniedError,
32
+ ProtocolError,
33
+ RateLimitError,
34
+ ServerError,
35
+ ServiceUnavailableError,
36
+ TransportError,
37
+ UnsupportedFeatureError,
38
+ ValidationError,
39
+ )
40
+
41
+ #: The same set as the TypeScript client. The contract marks 429, 500 and 503
42
+ #: retryable (`scripts/developer_docs.py` STATUS_MEANINGS); 408, 502 and 504 are a
43
+ #: proxy's, never the API's, and a retry of them resends the same Idempotency-Key.
44
+ #: 425 is not here: nothing in the contract answers it.
45
+ _RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
46
+ #: Refusals a blind retry cannot cure (staff Gate F, B3): a daily quota resets on its
47
+ #: own clock and a credit balance only moves when someone tops it up, so the SDK
48
+ #: raises them at once, whatever `Retry-After` says.
49
+ _NEVER_AUTO_RETRIED_CODES = frozenset({"QUOTA_EXCEEDED", "INSUFFICIENT_CREDITS"})
50
+ #: A 429 the SDK retries only when the server says when: the account's own queue is
51
+ #: full, and without a `Retry-After` there is no interval worth waiting.
52
+ _RETRIED_ONLY_WITH_RETRY_AFTER = frozenset({"TENANT_QUEUE_LIMIT_EXCEEDED"})
53
+ #: A server-named ``Retry-After`` is honoured as named, not shortened to ``max_delay``:
54
+ #: a retry sent before the server said it would answer is refused again. Above this
55
+ #: ceiling, or when the caller's deadline cannot hold the wait, the SDK raises the error
56
+ #: (with ``retry_after`` set) instead of retrying early. Same values as TypeScript.
57
+ RETRY_AFTER_CEILING = 60.0
58
+ #: The poll interval when the server names none, and the floor under any it names, so a
59
+ #: ``Retry-After: 0`` or ``max_delay=0`` never becomes a tight poll loop.
60
+ _DEFAULT_POLL_INTERVAL = 1.0
61
+ MIN_POLL_INTERVAL = 0.25
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class RetryPolicy:
66
+ max_attempts: int = 4
67
+ base_delay: float = 0.25
68
+ max_delay: float = 5.0
69
+ jitter_ratio: float = 0.2
70
+
71
+ def __post_init__(self) -> None:
72
+ if self.max_attempts < 1:
73
+ raise ValueError("max_attempts must be positive")
74
+ if self.base_delay < 0 or self.max_delay < 0:
75
+ raise ValueError("retry delays cannot be negative")
76
+ if self.base_delay > self.max_delay:
77
+ raise ValueError("base_delay cannot exceed max_delay")
78
+ if not 0 <= self.jitter_ratio <= 1:
79
+ raise ValueError("jitter_ratio must be between 0 and 1")
80
+
81
+
82
+ class _Transport:
83
+ """One credentialed HTTP client with bounded, jittered retries."""
84
+
85
+ def __init__(
86
+ self,
87
+ *,
88
+ api_key: str,
89
+ base_url: str,
90
+ request_timeout: float = 10.0,
91
+ operation_timeout: float = 120.0,
92
+ max_image_bytes: int = 10 * 1024 * 1024,
93
+ retry_policy: RetryPolicy | None = None,
94
+ http_client: httpx.Client | None = None,
95
+ ) -> None:
96
+ _validate_client_options(
97
+ api_key, base_url, request_timeout, operation_timeout, max_image_bytes
98
+ )
99
+ self._api_key = api_key
100
+ self._base_url = base_url.rstrip("/")
101
+ self._request_timeout = request_timeout
102
+ self._operation_timeout = operation_timeout
103
+ self._max_image_bytes = max_image_bytes
104
+ self._retry_policy = retry_policy or RetryPolicy()
105
+ self._client = http_client or httpx.Client()
106
+ self._owns_client = http_client is None
107
+
108
+ def __enter__(self) -> Self:
109
+ return self
110
+
111
+ def __exit__(self, *_: object) -> None:
112
+ self.close()
113
+
114
+ def close(self) -> None:
115
+ if self._owns_client:
116
+ self._client.close()
117
+
118
+ def _request(
119
+ self,
120
+ send: Callable[[float], httpx.Response],
121
+ deadline: float,
122
+ *,
123
+ expected: set[int],
124
+ ) -> httpx.Response:
125
+ last_transport_error: httpx.TransportError | None = None
126
+ for attempt in range(self._retry_policy.max_attempts):
127
+ remaining = deadline - time.monotonic()
128
+ if remaining <= 0:
129
+ raise DeadlineExceeded("operation deadline exceeded")
130
+ try:
131
+ response = send(min(self._request_timeout, remaining))
132
+ except httpx.TransportError as error:
133
+ last_transport_error = error
134
+ if attempt + 1 >= self._retry_policy.max_attempts:
135
+ break
136
+ self._backoff(attempt, deadline)
137
+ continue
138
+ if response.status_code in expected:
139
+ return response
140
+ _refuse_redirect(response)
141
+ last = attempt + 1 >= self._retry_policy.max_attempts
142
+ if not last and _retryable_status(response):
143
+ retry_after = _retry_after_seconds(response.headers.get("Retry-After"))
144
+ if retry_after is None:
145
+ self._backoff(attempt, deadline)
146
+ continue
147
+ if _fits_server_wait(retry_after, deadline, self._retry_policy):
148
+ time.sleep(_server_delay(retry_after, self._retry_policy))
149
+ continue
150
+ raise _parse_api_error(response)
151
+ api_error = _parse_api_error(response)
152
+ retryable = (
153
+ response.status_code == 409
154
+ and api_error.code == "IDEMPOTENCY_IN_PROGRESS"
155
+ )
156
+ if not retryable or last:
157
+ raise api_error
158
+ if api_error.retry_after is None:
159
+ self._backoff(attempt, deadline)
160
+ elif _fits_server_wait(api_error.retry_after, deadline, self._retry_policy):
161
+ time.sleep(_server_delay(api_error.retry_after, self._retry_policy))
162
+ else:
163
+ raise api_error
164
+ if last_transport_error is not None:
165
+ raise TransportError(
166
+ "request failed after bounded retries"
167
+ ) from last_transport_error
168
+ raise DeadlineExceeded("operation deadline exceeded")
169
+
170
+ def _backoff(self, attempt: int, deadline: float) -> None:
171
+ base = self._retry_policy.base_delay * (2**attempt)
172
+ self._sleep(_jittered_delay(base, self._retry_policy), deadline)
173
+
174
+ @staticmethod
175
+ def _sleep(delay: float, deadline: float) -> None:
176
+ if delay >= deadline - time.monotonic():
177
+ raise DeadlineExceeded("operation deadline exceeded")
178
+ time.sleep(delay)
179
+
180
+ def _url(self, path: str) -> str:
181
+ return f"{self._base_url}{path}"
182
+
183
+
184
+ class _AsyncTransport:
185
+ """The asyncio twin of `_Transport`."""
186
+
187
+ def __init__(
188
+ self,
189
+ *,
190
+ api_key: str,
191
+ base_url: str,
192
+ request_timeout: float = 10.0,
193
+ operation_timeout: float = 120.0,
194
+ max_image_bytes: int = 10 * 1024 * 1024,
195
+ retry_policy: RetryPolicy | None = None,
196
+ http_client: httpx.AsyncClient | None = None,
197
+ ) -> None:
198
+ _validate_client_options(
199
+ api_key, base_url, request_timeout, operation_timeout, max_image_bytes
200
+ )
201
+ self._api_key = api_key
202
+ self._base_url = base_url.rstrip("/")
203
+ self._request_timeout = request_timeout
204
+ self._operation_timeout = operation_timeout
205
+ self._max_image_bytes = max_image_bytes
206
+ self._retry_policy = retry_policy or RetryPolicy()
207
+ self._client = http_client or httpx.AsyncClient()
208
+ self._owns_client = http_client is None
209
+
210
+ async def __aenter__(self) -> Self:
211
+ return self
212
+
213
+ async def __aexit__(self, *_: object) -> None:
214
+ await self.aclose()
215
+
216
+ async def aclose(self) -> None:
217
+ if self._owns_client:
218
+ await self._client.aclose()
219
+
220
+ async def _request(
221
+ self,
222
+ send: Callable[[float], Awaitable[httpx.Response]],
223
+ deadline: float,
224
+ *,
225
+ expected: set[int],
226
+ ) -> httpx.Response:
227
+ last_transport_error: httpx.TransportError | None = None
228
+ for attempt in range(self._retry_policy.max_attempts):
229
+ remaining = deadline - time.monotonic()
230
+ if remaining <= 0:
231
+ raise DeadlineExceeded("operation deadline exceeded")
232
+ try:
233
+ response = await send(min(self._request_timeout, remaining))
234
+ except httpx.TransportError as error:
235
+ last_transport_error = error
236
+ if attempt + 1 >= self._retry_policy.max_attempts:
237
+ break
238
+ await self._backoff(attempt, deadline)
239
+ continue
240
+ if response.status_code in expected:
241
+ return response
242
+ _refuse_redirect(response)
243
+ last = attempt + 1 >= self._retry_policy.max_attempts
244
+ if not last and _retryable_status(response):
245
+ retry_after = _retry_after_seconds(response.headers.get("Retry-After"))
246
+ if retry_after is None:
247
+ await self._backoff(attempt, deadline)
248
+ continue
249
+ if _fits_server_wait(retry_after, deadline, self._retry_policy):
250
+ await asyncio.sleep(_server_delay(retry_after, self._retry_policy))
251
+ continue
252
+ raise _parse_api_error(response)
253
+ api_error = _parse_api_error(response)
254
+ retryable = (
255
+ response.status_code == 409
256
+ and api_error.code == "IDEMPOTENCY_IN_PROGRESS"
257
+ )
258
+ if not retryable or last:
259
+ raise api_error
260
+ if api_error.retry_after is None:
261
+ await self._backoff(attempt, deadline)
262
+ elif _fits_server_wait(api_error.retry_after, deadline, self._retry_policy):
263
+ await asyncio.sleep(
264
+ _server_delay(api_error.retry_after, self._retry_policy)
265
+ )
266
+ else:
267
+ raise api_error
268
+ if last_transport_error is not None:
269
+ raise TransportError(
270
+ "request failed after bounded retries"
271
+ ) from last_transport_error
272
+ raise DeadlineExceeded("operation deadline exceeded")
273
+
274
+ async def _backoff(self, attempt: int, deadline: float) -> None:
275
+ base = self._retry_policy.base_delay * (2**attempt)
276
+ await self._sleep(_jittered_delay(base, self._retry_policy), deadline)
277
+
278
+ @staticmethod
279
+ async def _sleep(delay: float, deadline: float) -> None:
280
+ if delay >= deadline - time.monotonic():
281
+ raise DeadlineExceeded("operation deadline exceeded")
282
+ await asyncio.sleep(delay)
283
+
284
+ def _url(self, path: str) -> str:
285
+ return f"{self._base_url}{path}"
286
+
287
+
288
+ def _validate_client_options(
289
+ api_key: str,
290
+ base_url: str,
291
+ request_timeout: float,
292
+ operation_timeout: float,
293
+ max_image_bytes: int,
294
+ ) -> None:
295
+ parsed = urlsplit(base_url)
296
+ if (
297
+ parsed.scheme not in {"http", "https"}
298
+ or not parsed.netloc
299
+ or parsed.username is not None
300
+ or parsed.password is not None
301
+ or parsed.query
302
+ or parsed.fragment
303
+ ):
304
+ raise ValueError("base_url must be an absolute HTTP(S) URL")
305
+ if not api_key:
306
+ raise ValueError("api_key cannot be empty")
307
+ if request_timeout <= 0 or operation_timeout <= 0:
308
+ raise ValueError("timeouts must be positive")
309
+ if operation_timeout < request_timeout:
310
+ raise ValueError("operation_timeout must be at least request_timeout")
311
+ if max_image_bytes < 1:
312
+ raise ValueError("max_image_bytes must be positive")
313
+
314
+
315
+ def _deadline(timeout: float | None, default: float) -> float:
316
+ duration = default if timeout is None else timeout
317
+ if duration <= 0:
318
+ raise ValueError("timeout must be positive")
319
+ return time.monotonic() + duration
320
+
321
+
322
+ def _jittered_delay(delay: float, policy: RetryPolicy) -> float:
323
+ """The client's own backoff, when the server named no ``Retry-After``.
324
+
325
+ Capped at ``max_delay``, positive jitter only. Same formula as TypeScript.
326
+ """
327
+ bounded = min(max(0.0, delay), policy.max_delay)
328
+ return min(
329
+ policy.max_delay,
330
+ bounded + random.uniform(0, bounded * policy.jitter_ratio),
331
+ )
332
+
333
+
334
+ def _server_delay(retry_after: float, policy: RetryPolicy) -> float:
335
+ """A server-named wait: never shorter than it, jitter no larger than ``max_delay``."""
336
+ base = max(0.0, retry_after)
337
+ return base + random.uniform(0, min(base * policy.jitter_ratio, policy.max_delay))
338
+
339
+
340
+ def _fits_server_wait(retry_after: float, deadline: float, policy: RetryPolicy) -> bool:
341
+ """Whether a server-named wait may be slept before the retry.
342
+
343
+ At most ``RETRY_AFTER_CEILING``, and short enough (jitter included) that the retry
344
+ still lands before the caller's deadline. Otherwise the caller raises the error.
345
+ """
346
+ if retry_after > RETRY_AFTER_CEILING:
347
+ return False
348
+ worst = retry_after + min(retry_after * policy.jitter_ratio, policy.max_delay)
349
+ return worst < deadline - time.monotonic()
350
+
351
+
352
+ def _poll_delay(retry_after: float | None, policy: RetryPolicy) -> float:
353
+ """The wait between two polls of an unsettled job.
354
+
355
+ The server's ``Retry-After``, floored at ``MIN_POLL_INTERVAL`` and held under
356
+ ``RETRY_AFTER_CEILING``; 1 s when it names none. Independent of ``max_delay``,
357
+ which bounds the client's own retry backoff only.
358
+ """
359
+ named = _DEFAULT_POLL_INTERVAL if retry_after is None else retry_after
360
+ base = min(max(named, MIN_POLL_INTERVAL), RETRY_AFTER_CEILING)
361
+ return _server_delay(base, policy)
362
+
363
+
364
+ def _refuse_redirect(response: httpx.Response) -> None:
365
+ """A redirect answer to a credentialed request is a fault, never a hop.
366
+
367
+ Every request this client sends carries the API key, and httpx strips only
368
+ ``Authorization`` when it follows a redirect to another origin -- ``X-API-Key``
369
+ would travel. So every call passes ``follow_redirects=False`` (an injected
370
+ ``http_client`` may have turned following on) and the answer is refused here,
371
+ before the error parser reads it as an unrelated envelope fault.
372
+ """
373
+ if 300 <= response.status_code < 400:
374
+ raise ProtocolError(
375
+ f"server answered HTTP {response.status_code} with a redirect; "
376
+ "a credentialed request is not followed"
377
+ )
378
+
379
+
380
+ # One status, one class. Ordered widest-last so the specific entries win, and written
381
+ # as a table rather than a chain because the table is what the contract-alignment test
382
+ # reads: a status the runtime answers and this table does not name fails there.
383
+ _ERROR_TYPES: dict[int, type[ApiError]] = {
384
+ 400: ValidationError,
385
+ 401: AuthenticationError,
386
+ 402: InsufficientCreditsError,
387
+ 403: PermissionDeniedError,
388
+ 404: NotFoundError,
389
+ 409: ConflictError,
390
+ 410: NotFoundError,
391
+ 413: PayloadTooLargeError,
392
+ 415: ValidationError,
393
+ 422: ValidationError,
394
+ 429: RateLimitError,
395
+ 501: UnsupportedFeatureError,
396
+ 503: ServiceUnavailableError,
397
+ }
398
+
399
+
400
+ # One code, one class, consulted before the status table. This is the narrow half of
401
+ # the mapping and it stays narrow: an entry belongs here only when a code's remedy
402
+ # differs from what its status alone implies. `INSUFFICIENT_SCOPE` qualifies -- the
403
+ # other three codes the gateway answers 403 for are a disabled tenant, a disabled
404
+ # account and an unrecognized key, and none of those is a scope problem.
405
+ _ERROR_CODES: dict[str, type[ApiError]] = {
406
+ "INSUFFICIENT_SCOPE": InsufficientScopeError,
407
+ }
408
+
409
+
410
+ def _error_type(status_code: int, code: str = "") -> type[ApiError]:
411
+ """The class for one status, refined by code where the code says more.
412
+
413
+ The code is checked first because `error.code` is the machine contract and the
414
+ status is the coarser reading of it. Every class the code table can return is a
415
+ subclass of the one the status table returns for the same response, so a caller
416
+ who catches by status keeps catching it either way.
417
+
418
+ An unlisted 5xx -- other than 501/503 -- is `ServerError`, and any other unlisted
419
+ status `ApiError`, with every response field filled in, so a status this SDK does
420
+ not know yet is readable rather than a parsing failure.
421
+ """
422
+ refined = _ERROR_CODES.get(code)
423
+ if refined is not None:
424
+ return refined
425
+ named = _ERROR_TYPES.get(status_code)
426
+ if named is not None:
427
+ return named
428
+ return ServerError if status_code >= 500 else ApiError
429
+
430
+
431
+ def _retryable_status(response: httpx.Response) -> bool:
432
+ """Whether a refusal is retried by its status, before its error is raised.
433
+
434
+ The three 429 codes are distinct (B3): `RATE_LIMITED` is retried, honouring
435
+ `Retry-After`; `TENANT_QUEUE_LIMIT_EXCEEDED` is retried only with a `Retry-After`;
436
+ `QUOTA_EXCEEDED` is never retried blindly. A 429 whose body names no code is
437
+ retried as a rate limit.
438
+ """
439
+ if response.status_code not in _RETRYABLE_STATUS_CODES:
440
+ return False
441
+ if response.status_code != 429:
442
+ return True
443
+ try:
444
+ code = response.json()["error"]["code"]
445
+ except (KeyError, TypeError, ValueError):
446
+ return True
447
+ if code in _NEVER_AUTO_RETRIED_CODES:
448
+ return False
449
+ if code in _RETRIED_ONLY_WITH_RETRY_AFTER:
450
+ return _retry_after_seconds(response.headers.get("Retry-After")) is not None
451
+ return True
452
+
453
+
454
+ def _parse_api_error(response: httpx.Response) -> ApiError:
455
+ try:
456
+ payload = response.json()["error"]
457
+ code = payload["code"]
458
+ message = payload["message"]
459
+ details = payload.get("details", {})
460
+ request_id = payload.get("request_id")
461
+ if request_id is None:
462
+ request_id = response.headers.get("X-Request-Id")
463
+ if not isinstance(code, str) or not isinstance(message, str):
464
+ raise TypeError
465
+ if not isinstance(details, (dict, list)):
466
+ raise TypeError
467
+ if request_id is not None and not isinstance(request_id, str):
468
+ raise TypeError
469
+ except (KeyError, TypeError, json.JSONDecodeError) as error:
470
+ raise ProtocolError(
471
+ f"server returned an invalid error envelope for HTTP {response.status_code}"
472
+ ) from error
473
+ retry_after = _retry_after_seconds(response.headers.get("Retry-After"))
474
+ error_type = _error_type(response.status_code, code)
475
+ return error_type(
476
+ message,
477
+ status=response.status_code,
478
+ code=code,
479
+ details=details,
480
+ request_id=request_id,
481
+ retry_after=retry_after,
482
+ )
483
+
484
+
485
+ def _retry_after_seconds(value: str | None) -> float | None:
486
+ if value is None:
487
+ return None
488
+ normalized = value.strip()
489
+ if normalized.isdigit():
490
+ return float(normalized)
491
+ try:
492
+ retry_at = parsedate_to_datetime(normalized)
493
+ except (TypeError, ValueError, OverflowError):
494
+ return None
495
+ if retry_at is None:
496
+ return None
497
+ if retry_at.tzinfo is None:
498
+ retry_at = retry_at.replace(tzinfo=timezone.utc)
499
+ return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())