Metadata-Version: 2.4
Name: makers-sdk
Version: 0.2.0b1
Summary: Synchronous Python client for the EdgeOne Makers SDK
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: cos-python-sdk-v5==1.9.44
Provides-Extra: test
Requires-Dist: jsonschema[format]>=4.18; extra == "test"

# EdgeOne Makers Python SDK

[English](README.md) | [中文](README.zh-CN.md)

Synchronous Python 3.10+ client for EdgeOne Makers projects, environment variables, artifact deployments, and tenant tokens. There is no async Makers.

PyPI package: `makers-sdk` · `import makers_sdk` · Version `0.2.0-beta.1` (`0.2.0b1`) · Contract `0.1.42` · MIT License

## Installation

```sh
pip install makers-sdk
```

```python
from makers_sdk import Makers
```

---

## Makers

### Constructor

```python
import os
from makers_sdk import Makers

makers = Makers(
    token=os.environ["MAKERS_API_TOKEN"],
    region="china",
)
```

| Parameter | Type | Required | Default | Description |
|-----------|------|:--------:|---------|-------------|
| `token` | `str` | No | — | EdgeOne Makers API Token. Required unless `credentials` is set |
| `region` | `str` | No | API Token: auto-detect; AK/SK: china | API Token: `"china"` → `pages-api.cloud.tencent.com/v1`; `"global"` → `pages-api.edgeone.ai/v1`; AK/SK mapping below |
| `timeout` | `float` | No | `30` | Per-request timeout in **seconds** |
| `retries` | `int` | No | `3` | Max retry count for queries; writes do not retry |
| `logger` | `Logger` | No | — | Must implement `debug`, `info`, `warn`, `error` |
| `credentials` | `{"secret_id", "secret_key"}` | No | — | Tencent Cloud API credentials. Selects the cloud API channel and enables ownership, CNAME, and certificate methods. Both fields must be non-empty strings. Not an STS token |
| `zone_id` | `str` | With `credentials` | — | Makers zone ID. Required with `credentials`; passing it without `credentials` raises `ValidationError` |

Auto-detection probes china then global and caches the result per Makers instance. It runs only for API Token channel requests.

### Cloud API credentials

```python
makers = Makers(
    credentials={
        "secret_id": os.environ["TENCENTCLOUD_SECRET_ID"],
        "secret_key": os.environ["TENCENTCLOUD_SECRET_KEY"],
    },
    zone_id=os.environ["EDGEONE_MAKERS_ZONE_ID"],
    region="global",
)
```

With `credentials` and `zone_id`, projects, environment variables, deployments, custom-domain create, list, and delete, and `tokens.create` use the cloud API channel: `DescribePagesResources`, `CreatePagesResources`, `ModifyPagesResources`, or `DeletePagesResources` at `https://teo.tencentcloudapi.com`, with the Makers `ZoneId` at the top level of the request body. With only `token`, they use the Pages API. Method signatures and results are the same on both channels, and a cloud API failure is not retried over the API Token channel.

`zone_id` is the Makers zone ID. The SDK does not look it up. Get it from the cloud API `DescribeZones` with the filter `zone-type=pages` (enabling Makers creates a zone named `default-pages-zone`), or read the project's `ZoneId` from `DescribePagesProjects`. An account with several Makers zones constructs one `Makers` per zone; project calls must use the zone the project belongs to.

With `credentials` and `zone_id`, `region: "china"` selects gateway `X-TC-Region: ap-guangzhou` and `region: "global"` selects `ap-singapore`; omitting region defaults to Guangzhou without probing Pages hosts or other regions. AK/SK still takes precedence when token is also supplied. Failures never switch channels or regions, and query retries keep the same region. Project `area` is independent of client `region`. The six direct teo domain / certificate methods still omit `X-TC-Region` and keep their host and signing unchanged.

Compatibility: explicit `global` was previously ignored with AK/SK and now selects Singapore; omitted region behaves as before. The Singapore mapping is covered by protocol regression tests, but international-account live end-to-end validation is pending; selecting a region does not guarantee account access or service availability.

Projects, environment variables, deployments, every `domains` method, and `tokens.create` support cloud API credentials alone. Keep cloud API credentials on the platform side and do not pass them to a `Makers` built from a tenant token.

### Public members

| Member | Type | Description |
|--------|------|-------------|
| `makers.projects` | `Projects` | Project and environment variable operations |
| `makers.deployments` | `Deployments` | Deployment operations |
| `makers.tokens` | `Tokens` | Issue a tenant token (`tokens.create`) |
| `makers.domains` | `Domains` | Custom domains: create, list, delete, ownership verification, CNAME, and certificates |
| `makers.region` | `str \| None` | Read-only; configured or detected region |

---

## Tokens

Issue a tenant token with a **master API Token** or the account's cloud API credentials (`credentials` + `zone_id`), then construct a new `Makers` with the returned `token` to manage that tenant's projects. `tokens.create` requires `tenant_id`, `name`, and `expires_in`. It returns `{ "token", "token_id", "expired" }`.

`expires_in` is relative seconds, not a unix timestamp. The returned `expired` is the backend expiry time as a unix-seconds number.

Call `tokens.create` with a master API Token or the account's cloud API credentials. With `credentials` it uses `CreatePagesResources` with `Interface` `pages:CreatePagesUserApiToken`; with only `token` it uses the Pages API. Both return the same tenant token for the same tenant ID. The SDK does not inspect whether the current token is a master Token or a tenant token, and will not block a tenant token from calling `tokens.create`. v1 does not provide list, get, or delete token methods.

Repeating `tokens.create` with the same `tenant_id` is idempotent: the same `token` and `token_id` are returned, and only `expired` may refresh.

### `tokens.create(*, tenant_id, name, expires_in)`

```python
platform = Makers(
    token=os.environ["MAKERS_API_TOKEN"],
)
created = platform.tokens.create(
    tenant_id="user-open-id",
    name="user-open-id",
    expires_in=86400,
)
user = Makers(token=created["token"])
created_project = user.projects.create(name="my-site")
project_id = created_project["project_id"]
user.deployments.deploy(
    project_id=project_id,
    artifact={"files": {"index.html": "<h1>Hello</h1>"}},
)
```

| Parameter | Type | Required | Description |
|-----------|------|:--------:|-------------|
| `tenant_id` | `str` | Yes | Tenant identifier; non-empty, max 64 characters |
| `name` | `str` | Yes | Token name; 1–128 characters |
| `expires_in` | `int` | Yes | Lifetime in relative seconds (integer 10–315360000), not a unix timestamp |

**Returns** `dict`: `{ "token", "token_id", "expired" }`. `expired` is the backend expiry time as unix seconds. Invalid input raises `ValidationError` before any network request.

---

## Projects

All return values are `dict` with `snake_case` keys. Optional fields may be absent — always use `.get()`.

### `projects.create(*, name, area?, initial_env_vars?)`

Creates a project. Returns `{"project_id": ...}` only — query `get` for the full model.

```python
created = makers.projects.create(
    name="docs-site",
    area="overseas",
    initial_env_vars=[{"key": "API_URL", "value": "https://example.com"}],
)
project_id = created["project_id"]
```

| Parameter | Type | Required | Description |
|-----------|------|:--------:|-------------|
| `name` | `str` | Yes | A name already used in the account currently raises `MakersError` with `code` `ResourceUnavailable` |
| `area` | `str` | No | `"mainland"` / `"overseas"` / `"global"`; omitted = not sent |
| `initial_env_vars` | `list[dict]` | No | Set env vars at creation time |

### `projects.list(**options)`

```python
page = makers.projects.list(
    name="docs",
    page=0,
    page_size=20,
    order={"field": "created_on", "direction": "desc"},
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_ids` | `list[str]` | — | Filter by ID list |
| `name` | `str` | — | Filter by name |
| `status` | `str` | — | Filter by status |
| `provider` | `str` | — | Filter by provider |
| `page` | `int` | `0` | Zero-based page index |
| `page_size` | `int` | `20` | 1–100 |
| `order` | `dict` | — | `field`: `"created_on"` or `"modified_on"`; `direction`: `"asc"` or `"desc"` |

**Returns** `dict`: `{ "items", "page", "page_size", "total", "has_next" }`.

### `projects.list_all(**options)`

```python
for project in makers.projects.list_all():
    print(project["project_id"], project["name"])
```

Same options as `list` except no `page`. Returns `Iterator[dict]`.

### `projects.get(*, project_id)`

Returns the full project dict. This is the only way to obtain `preset_domain`.

```python
project = makers.projects.get(project_id=project_id)
print(project.get("preset_domain"))
```

### `projects.update(*, project_id, **fields)`

Updates project settings. Only provided fields are sent; omitted fields are not cleared.

```python
makers.projects.update(
    project_id=project_id,
    name="new-name",
    root_dir=".",
    output_dir="dist",
    build_cmd="npm run build",
    install_cmd="npm install",
    framework="other",
    nodejs_version="20",
)
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `project_id` | `str` | Required |
| `name` | `str` | Project name |
| `root_dir` | `str` | Root directory |
| `output_dir` | `str` | Output directory |
| `build_cmd` | `str` | Build command |
| `install_cmd` | `str` | Install command |
| `framework` | `str` | Framework identifier |
| `nodejs_version` | `str` | Node.js version |

### `projects.delete(*, project_id)`

Deletes a project. **Irreversible.**

---

## Environment variables

Methods live on `makers.projects`. Public fields: `key`, `value`, optional `comment`. Change project variables with `set_envs`; `deploy` does not accept env vars.

These methods manage project variables, not environment resources. They have no `env` or scope selector: passing a custom deployment Slug does not add per-environment variables. The SDK does not expose environment-resource create, list, update, or delete methods.

### `projects.list_envs(*, project_id)`

Returns `list[dict]`. Values are automatically added to the redaction list.

### `projects.set_envs(*, project_id, env_vars)`

Batch upsert. Internally reads existing vars first, then writes. Not atomic. Duplicate keys raise `ValidationError` before any network request.

```python
makers.projects.set_envs(
    project_id=project_id,
    env_vars=[
        {"key": "API_URL", "value": "https://example.com", "comment": "origin"},
    ],
)
```

### `projects.delete_envs(*, project_id, keys)`

Deletes env vars by key. Duplicate keys raise `ValidationError`. Each key is sent as `{"Key": key, "Value": "-"}`; `Value` is a placeholder and is not used for matching.

---

## Deployments

### `deployments.deploy(*, project_id, artifact, **options)`

Deploys an artifact. Accepts exactly one variant:

| Variant | Type | Behavior |
|---------|------|----------|
| `"files"` | `dict[str, str \| bytes]` | SDK zips in memory |
| `"directory"` | `str` (+ optional `"exclude_patterns": list[str]`) | SDK zips the directory |
| `"archive"` | `str` (zip file path) | Uploaded directly after safety validation |

```python
# Inline files
makers.deployments.deploy(
    project_id=project_id,
    artifact={"files": {"index.html": "<h1>ok</h1>"}},
    wait=True,
)

# Application source (project root)
makers.deployments.deploy(
    project_id=project_id,
    artifact={"directory": "."},
    wait=True,
)

# Static build output
makers.deployments.deploy(
    project_id=project_id,
    artifact={"directory": "./dist", "exclude_patterns": ["**/*.map"]},
)

# Prebuilt CLI output
makers.deployments.deploy(
    project_id=project_id,
    artifact={"directory": "./.edgeone"},
    wait=True,
)

# Zip archive
makers.deployments.deploy(
    project_id=project_id,
    artifact={"archive": "./site.zip"},
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | `str` | — | Required |
| `artifact` | `dict` | — | Required; exactly one variant |
| `env` | `str` | `"Production"` | Non-empty environment Slug, e.g. `"Production"`, `"Preview"`, or `"Staging"`. Preview requires an existing production deployment |
| `wait` | `bool` | `False` | Poll until terminal status |
| `timeout` | `float` | `900` | Max wait time in **seconds** (15 min) |
| `poll_interval` | `float` | `5` | Poll interval in **seconds** |
| `upload_progress` | callable | — | Fires once after upload completes |
| `status_change` | callable | — | Fires on each status transition |

**Returns** `dict`. With `wait=False`, only `deployment_id`, `project_id`, and `env` are populated — that is not an openable site URL. After `wait=True` reaches `Success`, `preview_url` is an openable link and **expires**.

**Note**: `timeout` here is the overall wait budget (default 900s), distinct from the per-request `Makers(timeout=30)`.

**Custom environments**: the SDK passes `env` unchanged on both authentication channels, without case conversion or trimming. Empty, whitespace-only, and non-string values raise `ValidationError` before packaging or requests. Only exact `"Preview"` triggers the production-deployment prerequisite. Provision a custom environment before deploying to it; `deploy` neither creates nor checks environment resources, and backend errors retain the usual SDK error handling. Public API documentation lists only Production / Preview; custom-Slug passthrough is an SDK extension, not a guarantee of backend support, environment isolation, or page availability.

```python
# Staging must already exist. This does not create an environment or set variables.
deployment = makers.deployments.deploy(
    project_id=project_id,
    env="Staging",
    artifact={"files": {"index.html": "<h1>Staging</h1>"}},
)
```

**Directory input**: pass one directory. Typical inputs are application source (project root), static build output (`index.html` must be at that directory root), or prebuilt Makers/SSR output (`./.edgeone` after a local CLI build). Application source is the usual SDK path: the SDK zips the tree with default ignores and uploads it. It does not compile locally and does not itself run `npm run build` or `edgeone makers build`. For Upload projects, Pages may then run `edgeone makers build`. The SDK does not run framework adapters. Default ignores are a fixed list; the SDK does not read `.gitignore`. A caller-provided `archive` is uploaded as-is.

**Directory safety**: rejects symlinks; ignores `.git`, `node_modules`, the artifact-root `.edgeone` directory, `.env`, logs, temp and system files. Non-`.edgeone` directories also ignore any path segment that starts with `.`; `.well-known` is kept. Nested `.edgeone` paths are kept. Passing a directory named `.edgeone` packs CLI layout (`<parent>/.edgeone/...`, plus sibling `edgeone.json` when present), keeps nested `node_modules`, and keeps hidden files. Other directories still drop `node_modules` at any depth. These defaults are a fixed list, not `.gitignore`. `exclude_patterns` appends POSIX globs; negation (`!`) is rejected.

### `deployments.wait(*, project_id, deployment_id, **options)`

Polls until a terminal status: `Success`, `Failed`, `Timeout`, `Cancelled`, or `Invalid`. After `Success`, `preview_url` is an openable site URL (signed for default upload projects) and **expires**. `get` / `list` / `wait=False` do not sign.

```python
result = makers.deployments.wait(
    project_id=project_id,
    deployment_id=deployment_id,
    timeout=900,
    poll_interval=5,
    status_change=lambda e: print(e["deployment"].get("status")),
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | `str` | — | Required |
| `deployment_id` | `str` | — | Required |
| `timeout` | `float` | `900` | Raises `DeploymentTimeoutError` on expiry; **does not cancel the deployment** |
| `poll_interval` | `float` | `5` | |
| `status_change` | callable | — | |

### `deployments.list(*, project_id, **options)`

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | `str` | — | Required |
| `status` | `list[str]` | — | Filter by status |
| `time_range` | `dict` | — | `{"start": ..., "end": ...}` ISO 8601 |
| `repo_branch` | `list[str]` | — | Filter by branch |
| `page` | `int` | `0` | |
| `page_size` | `int` | `20` | |
| `order` | `dict` | — | |

### `deployments.list_all(*, project_id, **options)`

Same as `list` without `page`. Returns `Iterator[dict]`.

### `deployments.get(*, project_id, deployment_id)`

Returns a single deployment dict. Raises `NotFoundError` if not found.

### `deployments.get_log(*, project_id, deployment_id)`

Returns `{"log_url": ...}` — the build log URL.

---

## Domains

Create, list, and delete a custom domain on the channel of the `Makers`. With `credentials`, they use the cloud API channel: `CreatePagesResources` with `Interface` `pages:CreatePagesZoneCustomDomain`, `DescribePagesResources` with `pages:DescribePagesZoneCustomDomains`, and `DeletePagesResources` with `pages:DeletePagesProjectCustomDomains`. With only `token`, they use the Pages API. Parameters and results are the same on both channels. Verify ownership, check CNAME, apply or check a free certificate, and list or modify host certificates are direct teo Actions and always need `credentials`.

`credentials` is optional. When present, `secret_id` and `secret_key` must both be non-empty strings and `zone_id` is required, or construction raises `ValidationError`. The credentials mapping does not accept an STS token, and cloud API requests do not send `X-TC-Token`. Calling `verify_ownership`, `check_cname`, `apply_free_certificate`, `check_free_certificate`, `list_host_certificates`, or `modify_certificate` without `credentials` raises `ValidationError` before any request.

`check_cname`, `apply_free_certificate`, `check_free_certificate`, `list_host_certificates`, and `modify_certificate` take an optional `zone_id`. When it is omitted or `None`, the constructor `zone_id` is sent as `ZoneId`. A passed value is sent instead, for a domain whose project belongs to another Makers zone. The zone is the Makers zone of the domain's project.

An empty `project_id`, `domain`, `domains`, `zone_id`, `record_names`, or `hosts` raises `ValidationError` before any request. `list`, `verify_ownership`, `check_cname`, `check_free_certificate`, and `list_host_certificates` follow query retries. `create`, `delete`, `apply_free_certificate`, and `modify_certificate` do not retry.

### `domains.create(*, project_id, domain)`

Calls `CreatePagesZoneCustomDomain`. Returns only `domain` and `id`. `id` is the response `Id`, or `None` when `Id` is missing or not a string. A missing or non-string `Domain` raises `MakersError`. An empty string is still returned as `domain`. The method does not return list fields and does not send another request to fill them.

### `domains.list(*, project_id, domain=None)`

Calls `DescribePagesZoneCustomDomains`. `project_id` is required. `domain` is optional, and `Domain` is sent only when it is set. Returns the `PagesDomains` array. Each row has `type`, `domain`, `force_redirect_https`, `redirect_status_code`, `current_cname`, `main_domain`, `status`, `ownership_verification`, `cname`, `area`, and `zone_id`. When `domain` is set, only rows for that domain are returned. The business parameters do not include `ZoneId`; on the cloud API channel the gateway adds the top-level Makers `ZoneId`. A string is `None` when the value is null, missing, or not a string. An empty string stays an empty string. `redirect_status_code` is `None` when missing, null, or not a finite number, and `0` stays `0`. A `ForceRedirect` object supplies `force_redirect_https` from `Switch` and `redirect_status_code` from `RedirectStatusCode`; the flat `ForceRedirectHTTPS` and `RedirectStatusCode` fields are then ignored. Without that object, those flat fields are used. Null verification children stay `None`, and strings inside a present child follow the same null rule. When `PagesDomains` is absent and the payload has a top-level `Domain` string, that payload is one row.

### `domains.delete(*, project_id, domains)`

Calls `DeletePagesProjectCustomDomains`. `domains` must contain at least one non-empty domain, sent as `CustomDomains`. The method returns `None`.

### `domains.verify_ownership(*, domain)`

Calls `VerifyOwnership` at `https://teo.tencentcloudapi.com` (service `teo`, version `2022-09-01`). The body is only `{"Domain": ...}`. The request does not send `X-TC-Region` or `X-TC-Token`. `region="global"` still uses this host.

Returns `{"status", "message"}`. `status` is `success` or `fail`, including a failed ownership check. `message` uses `Message`, then `Result`, and may be empty on success. The result does not include `request_id`.

HTTP 200 with `Response.Error`, and any non-2xx response, raise. `AuthFailure*` is `AuthError`, a code containing `Invalid` is `ValidationError`, a code containing `NotFound` is `NotFoundError`, `RequestLimitExceeded` is `RateLimitError`, and any other code is `MakersError`. `request_id` is set only on the error.

### `domains.check_cname(*, zone_id=None, record_names)`

Calls `CheckCnameStatus` at `https://teo.tencentcloudapi.com` (service `teo`, version `2022-09-01`). The body is `{"ZoneId", "RecordNames"}`. `record_names` must contain at least one non-empty domain. The request does not send `X-TC-Region` or `X-TC-Token`. `region="global"` still uses this host.

Returns `{"cname_status"}`. Each item is `{"record_name", "cname", "status"}`. `status` is the backend value and is not rewritten. `active`, `moved`, and `invalid` stay as sent. The result does not include `request_id`. Cloud API failures use the same error mapping as `verify_ownership`.

### `domains.apply_free_certificate(*, zone_id=None, domain, verification_method)`

Calls `ApplyFreeCertificate` at `https://teo.tencentcloudapi.com` (service `teo`, version `2022-09-01`). The body is `{"ZoneId", "Domain", "VerificationMethod"}`. `verification_method` must be `dns_challenge` or `http_challenge`. Any other value raises `ValidationError` before the request. The request does not send `X-TC-Region` or `X-TC-Token`. `region="global"` still uses this host. The method does not retry.

Returns `{"dns_verification", "file_verification"}`. The fields match the DNS and file children of a listed custom domain. A missing child is `None`. The result does not include `request_id`.

### `domains.check_free_certificate(*, zone_id=None, domain)`

Calls `CheckFreeCertificateVerification` at `https://teo.tencentcloudapi.com` (service `teo`, version `2022-09-01`). The body is `{"ZoneId", "Domain"}`. The request does not send `X-TC-Region` or `X-TC-Token`. `region="global"` still uses this host. The method follows query retries.

Returns `{"common_name", "expire_time", "signature_algorithm"}`. `expire_time` stays the backend string. The result does not include `request_id`. `OperationDenied.FreeCertificateVerificationIsEmpty` raises `MakersError`. Other cloud API failures use the same error mapping as `verify_ownership`.

### `domains.list_host_certificates(*, zone_id=None, filters=None)`

Calls `DescribeHostCertificates` at `https://teo.tencentcloudapi.com` (service `teo`, version `2022-09-01`). The body is `{"ZoneId"}` plus `Filters` when `filters` is set. Each filter is `{"name", "values"}`, sent as `{"Name", "Values"}`. `fuzzy` is sent as `Fuzzy` only when the caller passes it. The method reads every page with `Offset` and `Limit` `100` and returns all rows; it takes no page or offset. The request does not send `X-TC-Region` or `X-TC-Token`. `region="global"` still uses this host. The method follows query retries.

Returns `{"certificates", "total_count"}`. Each certificate is `{"host", "mode", "host_cert_info"}`. `host_cert_info` items are `{"alias", "cert_id", "deploy_time", "expire_time", "sign_algo", "status", "type"}`. `mode` stays the backend value, so `disable` is not rewritten to `disabled`. `ClientCertInfo` and `UpstreamCertInfo` are not returned. The result does not include `request_id`.

### `domains.modify_certificate(*, zone_id=None, hosts, mode)`

Calls `ModifyHostsCertificate` at `https://teo.tencentcloudapi.com` (service `teo`, version `2022-09-01`). The body is `{"ZoneId", "Hosts", "Mode"}`. `hosts` must contain at least one non-empty domain. `mode` must be `disable`, `eofreecert`, or `eofreecert_manual`. Any other value raises `ValidationError` before the request. The request does not send `ServerCertInfo`, `X-TC-Region`, or `X-TC-Token`. `region="global"` still uses this host. The method does not retry.

The method returns `None`.

---

## Data models

All values are plain `dict`. Optional fields may be absent — use `.get()` to access them.

### Project

| Field | Type | Always present | Description |
|-------|------|:--------------:|-------------|
| `project_id` | `str` | Yes | |
| `name` | `str` | Yes | Unique per account |
| `status` | `str` | Yes | |
| `area` | `str` | No | `"mainland"` / `"overseas"` / `"global"` |
| `preset_domain` | `str` | No | Production domain; may be absent for new projects |
| `created_on` | `str` | Yes | ISO 8601 |
| `modified_on` | `str` | Yes | ISO 8601 |

### Deployment

| Field | Type | Always present | Description |
|-------|------|:--------------:|-------------|
| `deployment_id` | `str` | Yes | |
| `project_id` | `str` | Yes | |
| `env` | `str` | Yes | Backend environment Slug, preserved unchanged |
| `status` | `str` | No | Terminal: `Success` / `Failed` / `Timeout` / `Cancelled` / `Invalid` |
| `preview_url` | `str` | No | After wait Success: openable site URL (expires). `get` / `wait=False` may expose the raw backend URL, which is not openable. |
| `code` | `int` | No | Build exit code; `0` means success |
| `created_on` | `str` | No | Not returned with `wait=False` |
| `modified_on` | `str` | No | Not returned with `wait=False` |

### Token

| Field | Type | Always present | Description |
|-------|------|:--------------:|-------------|
| `token` | `str` | Yes | Tenant API token; use it to construct a user-side `Makers` |
| `token_id` | `str` | Yes | Token identifier |
| `expired` | `int` | Yes | Backend expiry time as unix seconds |

### EnvVar

| Field | Type | Always present |
|-------|------|:--------------:|
| `key` | `str` | Yes |
| `value` | `str` | Yes |
| `comment` | `str` | No |

### Callbacks

**upload_progress event**: `{"uploaded_bytes", "total_bytes", "completed_files", "total_files"}`

**status_change event**: `{"deployment": dict, "previous_status": str | None}`

---

## Errors

All errors extend `MakersError` with fields: `code`, `message`, `request_id`, `http_status`, `cause`.

| Error class | Trigger |
|-------------|---------|
| `AuthError` | Code 105 or HTTP 401 |
| `ValidationError` | Code contains "Invalid" or HTTP 400 |
| `NotFoundError` | Code contains "NotFound" or HTTP 404 |
| `ConflictError` | Code contains "Conflict" or HTTP 409 |
| `RateLimitError` | Code 110 or HTTP 429 |
| `UploadError` | COS upload failure |
| `TimeoutError` | Request timeout |
| `DeploymentTimeoutError` | Wait timeout (extends `TimeoutError`) |

```python
from makers_sdk import MakersError, NotFoundError, DeploymentTimeoutError

try:
    makers.projects.get(project_id="missing")
except NotFoundError as error:
    print(error.code, error.request_id, error.http_status)
except MakersError:
    raise
```

### Retry behavior

Queries retry on network errors, HTTP 408/429/5xx, and outer `Code: 110`, up to `retries` times (default 3). Exponential backoff with full jitter, capped at 10 seconds. `Retry-After` header is respected when present. Creates, updates, and COS credential requests do not retry.

---

## Development

```sh
python -m pip install -e ".[test]"
python -m unittest discover -s tests -v
```
