Metadata-Version: 2.4
Name: morphiaone
Version: 0.1.0
Summary: Python SDK for the MorphiaOne V1 API (Developer Preview): Skin Analysis, Makeup Try-On and Hair Color Try-On.
Author: MorphiaOne
License-Expression: MIT
Project-URL: Repository, https://github.com/MorphiaOne/vision-api
Keywords: morphiaone,sdk,api,skin-analysis,virtual-try-on
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: <3.15,>=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx<1,>=0.27
Dynamic: license-file

# MorphiaOne Python SDK

`morphiaone` is the Python client for the MorphiaOne V1 API: Skin Analysis, Makeup
Try-On and Hair Color Try-On, plus the Assets, Jobs and Usage calls they rest on.

> **Developer Preview.** The V1 API contract is still a draft, and this SDK may change
> before 1.0, including in ways that break code written against it. Pin an exact
> version. This package is not published yet: until the first release, the install
> command below resolves nothing.

## Install

```bash
pip install morphiaone
```

Requires Python 3.11 or newer. One runtime dependency: `httpx`.

## Authenticate

Keep your API key in the environment of a server process, and read it from there:

```bash
export MORPHIAONE_API_KEY="<your API key>"
```

```python
import os

from morphiaone import MorphiaOne

client = MorphiaOne(
    api_key=os.environ["MORPHIAONE_API_KEY"],
    base_url="<your API base URL>",
)
```

Use the client as a context manager (`with MorphiaOne(...) as client:`) or call
`client.close()` when you are done. `AsyncMorphiaOne` has the same namespaces, with
every call a coroutine.

**Server-side only.** Your API key authorizes spending on your account. Never ship it in
a browser bundle, a mobile app or a chatbot client; call MorphiaOne from your own
backend and hand your front end only the result.

## How a request works

Every product call follows the same three steps:

1. Upload each input image once as an **Asset**.
2. Submit a product request that references the Asset ids. The API answers with a
   **Job**; the work runs asynchronously.
3. Wait for the Job, or poll it, and read its result Assets.

```python
source = client.assets.upload("face.jpg")  # a str or Path, or raw bytes
job = client.skin.analyze(asset_id=source.id, concerns=["acne", "redness"])
done = client.jobs.wait(job.id)  # raises ProcessingError if it failed
```

`assets.upload(image, *, media_type=None, timeout=None)` infers the media type
(`image/jpeg`, `image/png` or `image/webp`) from the file suffix or the leading bytes
when omitted.

Every submit also accepts the keyword-only `idempotency_key`, `timeout` and `wait`.
With `wait=True` the call polls to the succeeded job before returning, exactly as
`jobs.wait` would:

```python
done = client.skin.analyze(asset_id=source.id, concerns=["acne"], wait=True)
```

## Skin Analysis

```python
from morphiaone import SkinAnalysisOutput

job = client.skin.analyze(
    asset_id=source.id,
    concerns=["acne", "redness", "wrinkles"],
    visualizations=[{"concern": "acne", "kind": "mask"}],
)
done = client.jobs.wait(job.id)
if isinstance(done.output, SkinAnalysisOutput):
    for item in done.output.concerns:
        print(item.concern, item.status, item.score)
```

Skin Analysis is a cosmetic reading, not a medical one.

## Makeup Try-On

`products` is ordered, and the order is the layer order:

```python
from morphiaone import MakeupTryOnOutput

job = client.makeup.try_on(
    asset_id=source.id,
    products=[{"category": "lipstick", "color": "#C2185B", "opacity": 0.8}],
)
done = client.jobs.wait(job.id)
if isinstance(done.output, MakeupTryOnOutput):
    image = client.assets.get_content(done.output.image)
    with open("makeup.png", "wb") as out:
        out.write(image.content)
```

## Hair Color Try-On

Hair Color Try-On takes two Assets: the source image and a grayscale **hair mask** with
exactly the same dimensions. You produce the mask; the API does not segment hair, and it
changes color only, never style or length. The color is a two-stop gradient, `dark` and
`light`:

```python
from morphiaone import HairTryOnOutput

hair_mask = client.assets.upload("hair-mask.png")
job = client.hair.try_on(
    source_asset_id=source.id,
    hair_mask_asset_id=hair_mask.id,
    color_gradient={
        "dark": {"color": "#3B1F16", "opacity": 0.9},
        "light": {"color": "#C87943", "opacity": 0.8},
    },
)
done = client.jobs.wait(job.id)
if isinstance(done.output, HairTryOnOutput):
    image = client.assets.get_content(done.output.image)
```

## Jobs

`jobs.wait` polls for you and honours the server's `Retry-After`. It is a convenience
over the job API, not a replacement for it:

- Persist `job.id` before you wait. It is how you resume after a restart, from another
  process or from a queue consumer: `client.jobs.get(job_id)` or
  `client.jobs.wait(job_id)`.
- A wait timeout stops *local* waiting only. It does not cancel the job on the server.
- `done.output` is `SkinAnalysisOutput`, `MakeupTryOnOutput`, `HairTryOnOutput`, or
  `UnknownJobOutput` for an output type this SDK version does not know yet. An unknown
  type never raises, so a newer API does not break an older SDK.

## Result Assets

A result names Assets, not bytes. Read them with `client.assets.get_content(...)`,
which authenticates with the client's own key. The read is built from the Asset id,
never from a URL in the response, so a response can never redirect your credential to
another host. Redirects are never followed.

## Usage

```python
report = client.usage.get(start_date="2026-09-01", end_date="2026-10-01")
```

The window is half-open, in UTC. The report is counts, not an invoice.

## Errors

Every error the SDK raises derives from `MorphiaOneError`, and the tree is the
TypeScript SDK's, class for class. An HTTP error response is an `ApiError`, carrying the
HTTP `status`, the public error `code`, the human sentence as `message`, `details`, the
`request_id` and, when the server sent one, `retry_after` (seconds). On a local fault
(transport, protocol, deadline) those fields are `None`. Branch on `code`, never on the
message.

| Class | Derives from | When |
| --- | --- | --- |
| `MorphiaOneError` | `Exception` | every SDK error |
| `ApiError` | `MorphiaOneError` | any HTTP error response; the class for a non-5xx status with no narrower one |
| `AuthenticationError` | `ApiError` | 401: the key is missing, malformed or not accepted |
| `PermissionDeniedError` | `ApiError` | 403: the key is valid but refused; `code` says why. Not an `AuthenticationError`: reloading the key does not help |
| `InsufficientScopeError` | `PermissionDeniedError` | 403 `INSUFFICIENT_SCOPE`: the key's grant is too narrow |
| `ValidationError` | `ApiError` | 400, 415 or 422: the request is invalid |
| `PayloadTooLargeError` | `ValidationError` | 413: the image is too large; downscale, do not retry |
| `UnsupportedFeatureError` | `ValidationError` | 501: this deployment does not perform the requested feature |
| `InsufficientCreditsError` | `ApiError` | 402: no credit left; never retried |
| `NotFoundError` | `ApiError` | 404, or 410 for an expired input Asset: upload again |
| `ConflictError` | `ApiError` | 409: such as an idempotency key reused with a different body |
| `RateLimitError` | `ApiError` | 429: rate or quota limits, after the SDK's own retries |
| `ServerError` | `ApiError` | any other 5xx, after the SDK's own retries |
| `ServiceUnavailableError` | `ServerError` | 503: the service could not take the request, after retries |
| `TransportError` | `MorphiaOneError` | no response at all, after retries |
| `ProtocolError` | `MorphiaOneError` | a response the contract does not allow |
| `DeadlineExceeded` | `MorphiaOneError` | the call's own `timeout` elapsed |
| `ProcessingError` | `MorphiaOneError` | the job ran and failed; `job_id`, `code`, `details` and `error` are on it |

An upload over the client's `max_image_bytes`, an empty image or a malformed argument
is a `ValueError` raised before any request is sent (TypeScript throws `RangeError` for
the oversize upload and `TypeError` for the rest).

```python
from morphiaone import MorphiaOneError, RateLimitError

try:
    done = client.jobs.wait(job.id)
except RateLimitError as error:
    schedule_retry(after=error.retry_after)
except MorphiaOneError as error:
    log.warning("MorphiaOne request failed: %s %s", error.code, error.request_id)
    raise
```

Log `request_id` on every failure. It is the one value support needs to find a request.
An error code this SDK version does not know still raises the class for its HTTP
status; it never crashes decoding.

## Timeouts and retries

- Timeouts are in seconds. `request_timeout` (default 10) bounds one call, its retries
  included. `operation_timeout` (default 120, at least `request_timeout`) bounds
  `jobs.wait` and a submit with `wait=True`, submit and polling together. Both are
  client options, and every call accepts its own `timeout`, which replaces the relevant
  default for that call: on a submit with `wait=True` it bounds the whole call.
- Polling never runs forever: the interval is the server's `Retry-After`, else about one
  second, never less than 0.25 s and at most 60 s, plus a little positive jitter.
- Transport failures, `408`, `429`, `500`, `502`, `503` and `504` are retried (4
  attempts in all by default). A server `Retry-After` is waited as named, never
  shortened; when it is over 60 s, or longer than the call's remaining deadline, the
  error is raised (with `retry_after` set) instead of retrying early. Without one, the
  SDK backs off exponentially, each delay capped at `RetryPolicy.max_delay` (5 s).
  A submit's retries resend the same `Idempotency-Key`, and a `409
  IDEMPOTENCY_IN_PROGRESS` is retried too. A quota or credit `429` is not retried,
  because waiting does not clear it. The TypeScript SDK retries exactly the same set.

## Idempotency

Every submit carries an `Idempotency-Key`. When you do not pass one, the SDK generates
one per call and reuses it on every retry of that call, so a retry never runs the work
twice.

That protects one call, not your operation. To make a logical request retry-safe across
process restarts, pass a key derived from your own order or request id:

```python
job = client.skin.analyze(
    asset_id=source.id,
    concerns=["acne"],
    idempotency_key=f"order-{order_id}-skin",
)
```

Reusing a key with a different request is a `ConflictError`. Asset uploads carry no
key: a retried upload can leave an unused Asset behind, which expires on its own.

## Privacy

The SDK sends no telemetry of its own. It never logs your API key, the images you upload
or their content.

## Versioning

`/v1` is the HTTP API major. The SDK has its own SemVer: while it is 0.x, a minor release
may break. The SDK will reach 1.0 only once the V1 contract is released.

## Support and security

Full documentation is coming with the public release. For support, include the
`request_id` from the error. Report security issues via your MorphiaOne account
contact; do not put API keys or images in a report.

## License

MIT.
