|
| 1 | +<p align="center"> |
| 2 | + <a href="https://cutoptim.com/engine"><img src="https://raw.githubusercontent.com/cutoptim/engine-python/main/.github/logo.png" width="88" height="88" alt="CutOptim"></a> |
| 3 | +</p> |
| 4 | + |
| 5 | +<h1 align="center">CutOptim Engine API — Python SDK</h1> |
| 6 | + |
| 7 | +<p align="center"> |
| 8 | + Cutting optimization over HTTPS: <b>2D panels</b>, <b>1D linear</b>, <b>timber</b> and <b>true-shape nesting</b>.<br> |
| 9 | + Deterministic plans, saw-ready cut sequences — standard library only. |
| 10 | +</p> |
| 11 | + |
| 12 | +<p align="center"> |
| 13 | + <a href="https://pypi.org/project/cutoptim/"><img alt="PyPI" src="https://img.shields.io/pypi/v/cutoptim?color=f0c040&labelColor=0d0f14&label=pypi"></a> |
| 14 | + <a href="https://github.com/cutoptim/engine-python/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/cutoptim/engine-python/actions/workflows/ci.yml/badge.svg"></a> |
| 15 | + <img alt="Python 3.9+" src="https://img.shields.io/badge/python-%E2%89%A53.9-f0c040?labelColor=0d0f14"> |
| 16 | + <img alt="Zero dependencies" src="https://img.shields.io/badge/dependencies-0-f0c040?labelColor=0d0f14"> |
| 17 | + <a href="https://github.com/cutoptim/engine-python/blob/main/LICENSE"><img alt="MIT" src="https://img.shields.io/badge/license-MIT-f0c040?labelColor=0d0f14"></a> |
| 18 | +</p> |
| 19 | + |
| 20 | +<p align="center"> |
| 21 | + <a href="https://cutoptim.com/engine/reference/quickstart">Quickstart</a> · |
| 22 | + <a href="https://cutoptim.com/engine/reference">API reference</a> · |
| 23 | + <a href="https://cutoptim.com/engine/reference/guides/webhooks">Webhooks</a> · |
| 24 | + <a href="https://cutoptim.com/engine/reference/changelog">Changelog</a> · |
| 25 | + <a href="https://cutoptim.com/dashboard#api-keys">Get an API key</a> |
| 26 | +</p> |
| 27 | + |
| 28 | +--- |
| 29 | + |
| 30 | +## Why this SDK |
| 31 | + |
| 32 | +- **Typed from the contract.** Request and response shapes are `TypedDict`s generated from the [OpenAPI 3.1 document](https://cutoptim.com/engine/openapi.json) — mypy and your editor know every field. |
| 33 | +- **No dependencies.** `urllib`, `json` and `hmac` from the standard library; Python 3.9+. |
| 34 | +- **Retries that never cost twice.** Every optimize call carries an `Idempotency-Key`; a retried call returns the stored answer instead of a second charge. |
| 35 | +- **Async jobs without the boilerplate.** `wait_for_job()` polls the `max` engine for you — or let a webhook tell you. |
| 36 | +- **Webhook verification built in.** `verify_webhook()` checks the signature on the raw body, with the replay window and secret rotation handled. |
| 37 | + |
| 38 | +## Install |
| 39 | + |
| 40 | +```bash |
| 41 | +pip install cutoptim |
| 42 | +``` |
| 43 | + |
| 44 | +A free **test key** (`co_test_…`, never billed) is enough to build the whole integration — create one on your [dashboard](https://cutoptim.com/dashboard#api-keys). |
| 45 | + |
| 46 | +## Quickstart |
| 47 | + |
| 48 | +```python |
| 49 | +import os |
| 50 | +from cutoptim import CutOptim, is_job_accepted |
| 51 | + |
| 52 | +client = CutOptim(api_key=os.environ["CUTOPTIM_KEY"]) |
| 53 | + |
| 54 | +plan = client.optimize_2d({ |
| 55 | + "parts": [ |
| 56 | + {"name": "Side", "w": 720, "h": 560, "qty": 2}, |
| 57 | + {"name": "Shelf", "w": 764, "h": 540, "qty": 3, "rotatable": False}, |
| 58 | + ], |
| 59 | + "stock": [{"w": 2800, "h": 2070, "price": 48}], |
| 60 | + "options": {"kerf": 4}, |
| 61 | +}) |
| 62 | + |
| 63 | +if not is_job_accepted(plan): |
| 64 | + print(f"{plan['metrics']['sheetCount']} sheet(s), {plan['metrics']['yieldPct']}% yield") |
| 65 | + for sheet in plan["sheets"]: |
| 66 | + for p in sheet["parts"]: |
| 67 | + print(p["name"], p["x"], p["y"], p["w"], p["h"], "rotated" if p["rotated"] else "") |
| 68 | +``` |
| 69 | + |
| 70 | +The same input always returns the same plan — cache it, test against it, quote from it. |
| 71 | + |
| 72 | +## Every mode |
| 73 | + |
| 74 | +```python |
| 75 | +# 1D linear — bars, profiles, tubes |
| 76 | +bars = client.optimize_1d({"parts": [{"name": "Rail", "length": 1200, "qty": 6}], "stock": [{"length": 6000}]}) |
| 77 | +print(bars["metrics"]["rodCount"], bars["metrics"]["yieldPct"]) |
| 78 | + |
| 79 | +# Timber — lengths cut from beams of a given cross-section (sw × sh) |
| 80 | +timber = client.optimize_wood({ |
| 81 | + "parts": [{"name": "Joist", "sw": 45, "sh": 145, "length": 3600, "qty": 4}], |
| 82 | + "stock": [{"sw": 45, "sh": 145, "length": 4800}], |
| 83 | +}) |
| 84 | + |
| 85 | +# True-shape nesting — polygons (or SVG/DXF sources) on sheets |
| 86 | +nest = client.optimize_nest({ |
| 87 | + "parts": [{"name": "Bracket", "polygon": [[0, 0], [120, 0], [120, 40], [40, 120], [0, 120]], "qty": 10}], |
| 88 | + "stock": [{"w": 1000, "h": 500}], |
| 89 | +}) |
| 90 | +if not is_job_accepted(nest): |
| 91 | + print(nest["metrics"]["sheetCount"], nest["metrics"]["density"]) |
| 92 | +``` |
| 93 | + |
| 94 | +Check a request without spending a call — the validate endpoints need no key and give the same verdict: |
| 95 | + |
| 96 | +```python |
| 97 | +CutOptim().validate_2d(request) # {"valid": True, "warnings": [...]} or CutOptimError(400) |
| 98 | +``` |
| 99 | + |
| 100 | +## Async `max` jobs |
| 101 | + |
| 102 | +`"engine": "max"` searches harder (seconds to a minute) and answers `202` with a job id instead of a plan: |
| 103 | + |
| 104 | +```python |
| 105 | +accepted = client.optimize_2d({**request, "engine": "max"}) |
| 106 | +if is_job_accepted(accepted): |
| 107 | + job = client.wait_for_job(accepted["jobId"], timeout_s=300) # polls GET /v1/jobs/{id}, no quota |
| 108 | + if job["status"] == "succeeded": |
| 109 | + print(job["result"]) |
| 110 | +``` |
| 111 | + |
| 112 | +## Webhooks |
| 113 | + |
| 114 | +Instead of polling, register an endpoint on your [dashboard](https://cutoptim.com/dashboard#api-webhooks) and receive a signed `POST` (`job.succeeded` / `job.failed`) when a `max` job finishes. Verify it on the **raw** body: |
| 115 | + |
| 116 | +```python |
| 117 | +# Flask |
| 118 | +from flask import Flask, request |
| 119 | +from cutoptim import verify_webhook |
| 120 | + |
| 121 | +@app.post("/cutoptim/webhook") |
| 122 | +def cutoptim_webhook(): |
| 123 | + if not verify_webhook(request.get_data(), request.headers.get("CutOptim-Signature"), WEBHOOK_SECRET): |
| 124 | + return "", 400 |
| 125 | + event = request.get_json() # deduplicate on event["id"] — delivery is at least once |
| 126 | + return "", 204 # answer within 10 s, then do the work |
| 127 | +``` |
| 128 | + |
| 129 | +```python |
| 130 | +# FastAPI |
| 131 | +from fastapi import FastAPI, Request, Response |
| 132 | + |
| 133 | +@app.post("/cutoptim/webhook") |
| 134 | +async def cutoptim_webhook(request: Request): |
| 135 | + raw = await request.body() |
| 136 | + if not verify_webhook(raw, request.headers.get("CutOptim-Signature"), WEBHOOK_SECRET): |
| 137 | + return Response(status_code=400) |
| 138 | + return Response(status_code=204) |
| 139 | +``` |
| 140 | + |
| 141 | +During a secret rotation both secrets sign for 24 hours — pass both: `verify_webhook(raw, header, [new_secret, old_secret])`. |
| 142 | + |
| 143 | +## Retries and idempotency |
| 144 | + |
| 145 | +| Situation | What the client does | |
| 146 | +|---|---| |
| 147 | +| `429 busy`, `503`, network error | retries (2× by default), waiting `Retry-After` when the API sends it | |
| 148 | +| `409 idempotency_in_progress` | retries — the first request is still running; the retry receives its answer | |
| 149 | +| any optimize call | sends an `Idempotency-Key` (a fresh UUID, or yours) — the **same** key on every retry | |
| 150 | + |
| 151 | +Pass your own key to make retries safe across process restarts too: |
| 152 | + |
| 153 | +```python |
| 154 | +client.optimize_nest(request, idempotency_key=f"order-{order_id}") |
| 155 | +``` |
| 156 | + |
| 157 | +Within 24 hours the same key and request return the stored answer — not charged again, and a `max` job is not queued twice. |
| 158 | + |
| 159 | +## Errors |
| 160 | + |
| 161 | +Any non-2xx answer raises `CutOptimError`: |
| 162 | + |
| 163 | +```python |
| 164 | +from cutoptim import CutOptimError |
| 165 | + |
| 166 | +try: |
| 167 | + client.optimize_2d(request) |
| 168 | +except CutOptimError as e: |
| 169 | + print(e.status, e.code, e.details, e.retry_after) # branch on e.code, never on the message |
| 170 | +``` |
| 171 | + |
| 172 | +| `code` | Status | Meaning | |
| 173 | +|---|---|---| |
| 174 | +| `invalid_request` | 400 | the request is wrong — `details["path"]` names the field; never charged | |
| 175 | +| `unauthorized` | 401 | missing or unknown API key | |
| 176 | +| `quota_exceeded` | 402 | the account's monthly quota is used up — `retry_after` counts to the reset | |
| 177 | +| `key_revoked` | 403 | the key is revoked, or the API subscription is not active | |
| 178 | +| `idempotency_conflict` | 409 | the key was used for a different request — use a new key | |
| 179 | +| `too_large` | 413 | an input cap was exceeded (parts, rows, body size) | |
| 180 | +| `busy` | 429 | at capacity right now — retried automatically | |
| 181 | +| `service_unavailable` | 503 | an engine is not available on this deployment | |
| 182 | +| `solve_timeout` | 504 | the nesting solve ran out of time | |
| 183 | +| `timeout` | — | `wait_for_job()` gave up waiting (the job keeps running) | |
| 184 | + |
| 185 | +A network failure that outlasts the retries is raised as the underlying `urllib.error.URLError`. The full list is on the [errors page](https://cutoptim.com/engine/reference/errors). |
| 186 | + |
| 187 | +## Configuration |
| 188 | + |
| 189 | +```python |
| 190 | +CutOptim( |
| 191 | + api_key="co_live_…", # not needed for health() and validate_*() |
| 192 | + base_url="https://api.cutoptim.com", # default |
| 193 | + max_retries=2, # 429 / 503 / 409 in-progress / network errors |
| 194 | + timeout_s=30.0, # per request |
| 195 | +) |
| 196 | +``` |
| 197 | + |
| 198 | +## API coverage |
| 199 | + |
| 200 | +| Method | Endpoint | Quota | |
| 201 | +|---|---|---| |
| 202 | +| `optimize_2d(req, idempotency_key=None)` | `POST /v1/optimize/2d` | 1 call | |
| 203 | +| `optimize_1d(req, idempotency_key=None)` | `POST /v1/optimize/1d` | 1 call | |
| 204 | +| `optimize_wood(req, idempotency_key=None)` | `POST /v1/optimize/wood` | 1 call | |
| 205 | +| `optimize_nest(req, idempotency_key=None)` | `POST /v1/optimize/nest` | 1 call | |
| 206 | +| `validate_2d` · `validate_1d` · `validate_wood` · `validate_nest` | `POST /v1/validate/*` | free, no key | |
| 207 | +| `import_nest(req)` | `POST /v1/import/nest` | free (key required) | |
| 208 | +| `get_job(id)` · `wait_for_job(id)` | `GET /v1/jobs/{id}` | free | |
| 209 | +| `usage()` | `GET /v1/usage` | free | |
| 210 | +| `health()` | `GET /v1/health` | free, no key | |
| 211 | + |
| 212 | +## Versioning |
| 213 | + |
| 214 | +The SDK follows semver. The API is `v1` and only grows within it: a new field, endpoint or option arrives as a **minor** SDK release with the regenerated types; nothing you use today is removed or narrowed. See [versioning and stability](https://cutoptim.com/engine/reference/versioning) and the [API changelog](https://cutoptim.com/engine/reference/changelog) (also as RSS). |
| 215 | + |
| 216 | +## Support |
| 217 | + |
| 218 | +- Questions and bugs: [GitHub issues](https://github.com/cutoptim/engine-python/issues) or [support@cutoptim.com](mailto:support@cutoptim.com) |
| 219 | +- Limits and quotas: [cutoptim.com/engine/reference/limits](https://cutoptim.com/engine/reference/limits) |
| 220 | + |
| 221 | +This repository is published from the CutOptim monorepo; pull requests are welcome and applied upstream. |
| 222 | + |
| 223 | +## License |
| 224 | + |
| 225 | +[MIT](https://github.com/cutoptim/engine-python/blob/main/LICENSE) © CutOptim |
0 commit comments