cutoptim 1.0.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.
cutoptim/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """cutoptim — the official client for the CutOptim Engine API (https://cutoptim.com/engine/reference).
2
+
3
+ Standard library only (urllib, hmac). Types are generated from the OpenAPI document (types_gen.py).
4
+
5
+ from cutoptim import CutOptim
6
+ client = CutOptim(api_key="co_test_...")
7
+ plan = client.optimize_2d({"parts": [{"w": 600, "h": 400, "qty": 4}], "stock": [{"w": 2440, "h": 1220}]})
8
+ """
9
+ from .client import CutOptim, CutOptimError, is_job_accepted
10
+ from .webhooks import verify_webhook, SIGNATURE_HEADER
11
+
12
+ __version__ = "1.0.0"
13
+ __all__ = ["CutOptim", "CutOptimError", "is_job_accepted", "verify_webhook", "SIGNATURE_HEADER", "__version__"]
cutoptim/client.py ADDED
@@ -0,0 +1,155 @@
1
+ """The CutOptim Engine API client (standard library only).
2
+
3
+ * Every optimize call carries an Idempotency-Key (yours, or a generated UUID), so retries are never charged twice and
4
+ never queue a second max job.
5
+ * 429 / 503 / 409 idempotency_in_progress / network errors are retried (default 2 times), honouring Retry-After.
6
+ * A non-2xx answer raises CutOptimError with the API's stable ``code`` — branch on it, never on the message.
7
+ * engine "max" answers 202 with a jobId: ``client.wait_for_job(job_id)`` — or register a webhook.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ import uuid
17
+ from typing import Any, Dict, Optional
18
+
19
+ from . import types_gen as t
20
+
21
+ _DEFAULT_BASE = "https://api.cutoptim.com"
22
+
23
+
24
+ class CutOptimError(Exception):
25
+ """A non-2xx answer. ``code`` is the stable error (``invalid_request``, ``quota_exceeded``, ...)."""
26
+
27
+ def __init__(self, status: int, code: str, message: str, details: Any = None, retry_after: Optional[int] = None):
28
+ super().__init__(message)
29
+ self.status = status
30
+ self.code = code
31
+ self.details = details
32
+ self.retry_after = retry_after
33
+
34
+
35
+ def is_job_accepted(result: Any) -> bool:
36
+ """True when an optimize answer is the async 202 of engine "max"."""
37
+ return isinstance(result, dict) and isinstance(result.get("jobId"), str) and result.get("status") == "queued"
38
+
39
+
40
+ class CutOptim:
41
+ def __init__(self, api_key: Optional[str] = None, base_url: str = _DEFAULT_BASE, max_retries: int = 2, timeout_s: float = 30.0):
42
+ from . import __version__
43
+
44
+ self.api_key = api_key
45
+ self.base_url = base_url.rstrip("/")
46
+ self.max_retries = max_retries
47
+ self.timeout_s = timeout_s
48
+ self._ua = "cutoptim-engine-python/" + __version__
49
+
50
+ # ── optimize (metered) ──
51
+ def optimize_2d(self, request: t.Optimize2DRequest, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
52
+ """2D panels. engine "max" returns the 202 job (see wait_for_job)."""
53
+ return self._call("POST", "/v1/optimize/2d", request, idempotency_key, idempotent=True)
54
+
55
+ def optimize_1d(self, request: t.Optimize1DRequest, idempotency_key: Optional[str] = None) -> t.Optimize1DResponse:
56
+ return self._call("POST", "/v1/optimize/1d", request, idempotency_key, idempotent=True)
57
+
58
+ def optimize_wood(self, request: t.OptimizeWoodRequest, idempotency_key: Optional[str] = None) -> t.OptimizeWoodResponse:
59
+ return self._call("POST", "/v1/optimize/wood", request, idempotency_key, idempotent=True)
60
+
61
+ def optimize_nest(self, request: t.OptimizeNestRequest, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
62
+ """True-shape nesting. engine "max" returns the 202 job."""
63
+ return self._call("POST", "/v1/optimize/nest", request, idempotency_key, idempotent=True)
64
+
65
+ # ── free / account ──
66
+ def validate_2d(self, request: t.Optimize2DRequest) -> t.ValidateResponse:
67
+ return self._call("POST", "/v1/validate/2d", request)
68
+
69
+ def validate_1d(self, request: t.Optimize1DRequest) -> t.ValidateResponse:
70
+ return self._call("POST", "/v1/validate/1d", request)
71
+
72
+ def validate_wood(self, request: t.OptimizeWoodRequest) -> t.ValidateResponse:
73
+ return self._call("POST", "/v1/validate/wood", request)
74
+
75
+ def validate_nest(self, request: t.OptimizeNestRequest) -> t.ValidateResponse:
76
+ return self._call("POST", "/v1/validate/nest", request)
77
+
78
+ def import_nest(self, request: t.NestImportRequest) -> t.NestImportResponse:
79
+ return self._call("POST", "/v1/import/nest", request)
80
+
81
+ def get_job(self, job_id: str) -> Dict[str, Any]:
82
+ return self._call("GET", "/v1/jobs/" + urllib.parse.quote(job_id, safe=""))
83
+
84
+ def usage(self) -> t.UsageResponse:
85
+ return self._call("GET", "/v1/usage")
86
+
87
+ def health(self) -> t.HealthResponse:
88
+ return self._call("GET", "/v1/health")
89
+
90
+ def wait_for_job(self, job_id: str, timeout_s: float = 300.0, interval_s: float = 2.0) -> Dict[str, Any]:
91
+ """Poll GET /v1/jobs/{id} (no quota) until the job succeeded or failed."""
92
+ deadline = time.monotonic() + timeout_s
93
+ wait = interval_s
94
+ while True:
95
+ job = self.get_job(job_id)
96
+ if job.get("status") in ("succeeded", "failed"):
97
+ return job
98
+ if time.monotonic() + wait > deadline:
99
+ raise CutOptimError(0, "timeout", "job %s is still %s after the wait timeout" % (job_id, job.get("status")))
100
+ time.sleep(wait)
101
+ wait = min(wait * 1.5, 10.0)
102
+
103
+ # ── transport ──
104
+ def _call(self, method: str, path: str, body: Any = None, idempotency_key: Optional[str] = None, idempotent: bool = False) -> Any:
105
+ headers = {"Accept": "application/json", "User-Agent": self._ua}
106
+ if self.api_key:
107
+ headers["Authorization"] = "Bearer " + self.api_key
108
+ data = None
109
+ if body is not None:
110
+ headers["Content-Type"] = "application/json"
111
+ data = json.dumps(body, separators=(",", ":")).encode("utf-8")
112
+ if idempotent:
113
+ # One key for the whole call, retries included.
114
+ headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
115
+ attempt = 0
116
+ while True:
117
+ req = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
118
+ try:
119
+ with urllib.request.urlopen(req, timeout=self.timeout_s) as res:
120
+ text = res.read().decode("utf-8")
121
+ return json.loads(text) if text else None
122
+ except urllib.error.HTTPError as e:
123
+ text = e.read().decode("utf-8", "replace")
124
+ try:
125
+ payload = json.loads(text) if text else None
126
+ except ValueError:
127
+ payload = None
128
+ retry_after = _int(e.headers.get("Retry-After"))
129
+ code = payload.get("error") if isinstance(payload, dict) and isinstance(payload.get("error"), str) else "http_%d" % e.code
130
+ retryable = e.code in (429, 503) or (e.code == 409 and code == "idempotency_in_progress")
131
+ if retryable and attempt < self.max_retries:
132
+ time.sleep(_backoff(attempt, retry_after))
133
+ attempt += 1
134
+ continue
135
+ message = payload.get("message") if isinstance(payload, dict) and isinstance(payload.get("message"), str) else "HTTP %d" % e.code
136
+ details = payload.get("details") if isinstance(payload, dict) else None
137
+ raise CutOptimError(e.code, code, message, details, retry_after) from None
138
+ except (urllib.error.URLError, TimeoutError, ConnectionError):
139
+ if attempt >= self.max_retries:
140
+ raise
141
+ time.sleep(_backoff(attempt, None))
142
+ attempt += 1
143
+
144
+
145
+ def _int(v: Optional[str]) -> Optional[int]:
146
+ try:
147
+ return int(v) if v else None
148
+ except ValueError:
149
+ return None
150
+
151
+
152
+ def _backoff(attempt: int, retry_after: Optional[int]) -> float:
153
+ if retry_after:
154
+ return float(min(retry_after, 60))
155
+ return min(0.5 * (2 ** attempt), 8.0)
cutoptim/py.typed ADDED
File without changes
cutoptim/types_gen.py ADDED
@@ -0,0 +1,814 @@
1
+ # GENERATED by scripts/gen-sdks.ts from the CutOptim Engine API OpenAPI document — DO NOT EDIT.
2
+ # contract 1 · spec sha256 d12a371f3242e7b21df0aa4ecf2df4b2f60f8670bbed7e47bbe3d4cbf08678fb · cutoptim 1.0.0
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Literal, TypedDict, Union
6
+
7
+ class WebhookEvent(TypedDict):
8
+ """One webhook event. Thin on purpose: the plan is read with GET /v1/jobs/{id}, never pushed."""
9
+ id: str
10
+ type: Literal["job.succeeded", "job.failed", "webhook.test"]
11
+ created: int
12
+ env: Literal["live", "test"]
13
+ data: Dict[str, Any]
14
+
15
+
16
+ class _ValidateResponseRequired(TypedDict):
17
+ valid: bool
18
+ mode: Literal["2d", "1d", "wood", "nest"]
19
+ contractVersion: str
20
+ parts: Dict[str, Any]
21
+ stock: Dict[str, Any]
22
+ warnings: List[str]
23
+
24
+
25
+ class ValidateResponse(_ValidateResponseRequired, total=False):
26
+ """The request is well-formed. No solve ran and no quota was spent."""
27
+ engineEnabled: bool
28
+
29
+
30
+ class _ErrorRequired(TypedDict):
31
+ error: Literal["invalid_request", "unauthorized", "quota_exceeded", "key_revoked", "too_large", "busy", "service_unavailable", "solve_timeout", "internal", "idempotency_conflict", "idempotency_in_progress"]
32
+ message: str
33
+
34
+
35
+ class Error(_ErrorRequired, total=False):
36
+ """The error envelope for every failure the engine itself produces."""
37
+ details: Dict[str, Any]
38
+
39
+
40
+ class NotFoundError(TypedDict):
41
+ """Routing failure. Uses a code (`not_found`) that is intentionally outside the `Error` enum, because it is produced by the router rather than by the API contract."""
42
+ error: Literal["not_found"]
43
+ message: str
44
+
45
+
46
+ NestPoint = List[float]
47
+
48
+ NestRing = List["NestPoint"]
49
+
50
+ class _NestZoneRequired(TypedDict):
51
+ polygon: "NestRing"
52
+
53
+
54
+ class NestZone(_NestZoneRequired, total=False):
55
+ """An exclusion / inferior-quality zone ON a sheet: a defect, a clamp footprint, a pre-printed area — something the rectangular API cannot express."""
56
+ quality: int
57
+
58
+
59
+ class _NestSourceRequired(TypedDict):
60
+ content: str
61
+
62
+
63
+ class NestSource(_NestSourceRequired, total=False):
64
+ """Read this part's outline out of an SVG or DXF FILE instead of listing coordinates. The same parser the CutOptim app uses for its Nesting mode runs server-side, so a part library that already exists as CAD files needs no path/arc flattener of your own. **The file replaces ONLY the geometry.** `qty`, `material`, `allowedRotations`, `minQuality`, `priority` and `meta` behave exactly as they do on a `polygon` part. **Exactly ONE outline per source.** Holes are folded in automatically (a ring contained in another becomes a hole), so a typical single-part drawing — outline plus bolt holes — is one outline. A file describing several separate parts is a 400 that names `POST /v1/import/nest`, which splits it. **Nothing is stored.** The bytes exist only as this request body, are parsed in memory and are gone when the response is written: no disk, no database, no temp file, no log line. There is nothing to delete and nothing retained. **Units are reported, never applied.** A DXF may declare `$INSUNITS`; it comes back in `imported.units` and several disagreeing files produce a warning, but coordinates pass through verbatim — this API converts nothing (see the unit rule on `/v1/optimize/nest`)."""
65
+ format: Literal["svg", "dxf"]
66
+ filename: str
67
+ flattenTolerance: float
68
+
69
+
70
+ class _NestImportRequestRequired(TypedDict):
71
+ content: str
72
+
73
+
74
+ class NestImportRequest(_NestImportRequestRequired, total=False):
75
+ """The same four fields `parts[].source` takes, at the top level. See NestSource for the caps, the sniffing rule and the no-storage guarantee."""
76
+ format: Literal["svg", "dxf"]
77
+ filename: str
78
+ flattenTolerance: float
79
+
80
+
81
+ class _NestImportedPartRequired(TypedDict):
82
+ name: str
83
+ polygon: "NestRing"
84
+
85
+
86
+ class NestImportedPart(_NestImportedPartRequired, total=False):
87
+ """One outline, in exactly the shape a `parts[]` row wants — paste it in and add your own qty / material / rotations."""
88
+ holes: List["NestRing"]
89
+
90
+
91
+ class _NestImportResponseRequired(TypedDict):
92
+ format: Literal["svg", "dxf"]
93
+ parts: List["NestImportedPart"]
94
+ vertices: int
95
+ warnings: List[str]
96
+ engineEnabled: bool
97
+ contractVersion: str
98
+
99
+
100
+ class NestImportResponse(_NestImportResponseRequired, total=False):
101
+ sourceUnit: Literal["mm", "in"]
102
+
103
+
104
+ class NestImportSummary(TypedDict):
105
+ """What the request's `source` files contributed. ABSENT when every part carried `polygon` coordinates, which is what keeps such a request byte-identical to before the feature existed."""
106
+ parts: int
107
+ vertices: int
108
+ units: List[Literal["mm", "in"]]
109
+
110
+
111
+ class NestPart(TypedDict, total=False):
112
+ """A part. Give its outline as `polygon` coordinates OR as a `source` file — exactly one of the two (both, or neither, is a 400)."""
113
+ name: str
114
+ polygon: "NestRing"
115
+ source: "NestSource"
116
+ holes: List["NestRing"]
117
+ qty: int
118
+ allowedRotations: Union[List[float], Literal["continuous"]]
119
+ minQuality: int
120
+ priority: bool
121
+ material: str
122
+ meta: Dict[str, Any]
123
+
124
+
125
+ class NestStock(TypedDict, total=False):
126
+ """A stock sheet type: either a rectangle (w & h) OR an arbitrary polygon outline (an off-cut remnant). Exactly one of {w & h} | polygon."""
127
+ w: float
128
+ h: float
129
+ polygon: "NestRing"
130
+ qty: int
131
+ price: float
132
+ cost: int
133
+ material: str
134
+ exclusions: List["NestZone"]
135
+ meta: Dict[str, Any]
136
+
137
+
138
+ class NestOptions(TypedDict, total=False):
139
+ """Solve options. All optional."""
140
+ minSeparation: float
141
+ edgeClearance: float
142
+ timeBudgetMs: int
143
+ seed: int
144
+ minimizeCost: bool
145
+ respectStock: bool
146
+ simplifyTolerance: float
147
+ compact: bool
148
+ compactFor: Literal["box", "horizontal", "vertical"]
149
+
150
+
151
+ class _OptimizeNestRequestRequired(TypedDict):
152
+ parts: List["NestPart"]
153
+ stock: List["NestStock"]
154
+
155
+
156
+ class OptimizeNestRequest(_OptimizeNestRequestRequired, total=False):
157
+ options: "NestOptions"
158
+ engine: Literal["lbf", "max", "sparrow"]
159
+ include: List[Literal["svg", "dxf"]]
160
+
161
+
162
+ class _PlacedNestPartRequired(TypedDict):
163
+ name: str
164
+ sheet: int
165
+ x: float
166
+ y: float
167
+ rotation: float
168
+
169
+
170
+ class PlacedNestPart(_PlacedNestPartRequired, total=False):
171
+ """A placed copy. Carries the rigid transform (rotation THEN translation), not a re-emitted polygon: rotate the input outline by `rotation` about its origin, then add (x, y)."""
172
+ material: str
173
+ meta: Dict[str, Any]
174
+
175
+
176
+ class _NestSheetRequired(TypedDict):
177
+ stock: int
178
+ price: Union[float, None]
179
+ density: float
180
+ cutLength: float
181
+ pierces: int
182
+ usedWidth: float
183
+ usedHeight: float
184
+ usedArea: float
185
+ parts: List["PlacedNestPart"]
186
+
187
+
188
+ class NestSheet(_NestSheetRequired, total=False):
189
+ w: float
190
+ h: float
191
+ polygon: "NestRing"
192
+ exclusions: List["NestZone"]
193
+ material: str
194
+ meta: Dict[str, Any]
195
+
196
+
197
+ class NestMetrics(TypedDict):
198
+ """Job totals."""
199
+ sheetCount: int
200
+ density: float
201
+ placed: int
202
+ total: int
203
+ totalPrice: float
204
+ cutLength: float
205
+ pierces: int
206
+
207
+
208
+ class _NestStockUsageRequired(TypedDict):
209
+ stock: int
210
+ sheetCount: int
211
+ totalPrice: float
212
+ density: float
213
+ usedArea: float
214
+
215
+
216
+ class NestStockUsage(_NestStockUsageRequired, total=False):
217
+ """Sheets used per request stock ROW ("quantity per sheet size"). The unit is the row, not the size: two rows with the same w × h but a different price or meta are two entries."""
218
+ w: float
219
+ h: float
220
+ material: str
221
+
222
+
223
+ class UnplacedNest(TypedDict):
224
+ name: str
225
+ qty: int
226
+
227
+
228
+ class NestMaterialSummary(TypedDict):
229
+ """One material's totals across the sheets cut from it."""
230
+ material: str
231
+ sheetCount: int
232
+ density: float
233
+ placed: int
234
+ total: int
235
+ totalPrice: float
236
+
237
+
238
+ class UnmatchedMaterialNest(TypedDict):
239
+ material: str
240
+ parts: List["UnplacedNest"]
241
+
242
+
243
+ class _OptimizeNestResponseRequired(TypedDict):
244
+ engine: Literal["lbf", "max"]
245
+ engineVersion: str
246
+ contractVersion: str
247
+ deterministic: bool
248
+ sheets: List["NestSheet"]
249
+ metrics: "NestMetrics"
250
+ stockUsage: List["NestStockUsage"]
251
+ unplaced: List["UnplacedNest"]
252
+ warnings: List[str]
253
+ timing: Dict[str, Any]
254
+
255
+
256
+ class OptimizeNestResponse(_OptimizeNestResponseRequired, total=False):
257
+ materials: List["NestMaterialSummary"]
258
+ unmatchedMaterials: List["UnmatchedMaterialNest"]
259
+ imported: "NestImportSummary"
260
+ svg: str
261
+ dxf: str
262
+
263
+
264
+ class _Part2DRequired(TypedDict):
265
+ w: float
266
+ h: float
267
+
268
+
269
+ class Part2D(_Part2DRequired, total=False):
270
+ name: str
271
+ qty: int
272
+ rotatable: bool
273
+ grain: Literal["w", "h"]
274
+ grainGroup: str
275
+ priority: bool
276
+ edgeBanding: Dict[str, Any]
277
+ material: str
278
+ meta: Dict[str, Any]
279
+ id: Any
280
+
281
+
282
+ class _MatchedSetRequired(TypedDict):
283
+ id: Union[str, int]
284
+ layout: "MatchedSetLayout"
285
+
286
+
287
+ class MatchedSet(_MatchedSetRequired, total=False):
288
+ """Parts cut out of ONE piece of board, side by side or one above the other, exactly one kerf apart, so the figure of the veneer runs on across them (a drawer front over two doors). The set travels through the optimizer as one rectangle — the block — and comes back as its members. Every refusal is a 400 before the quota is reserved; a size mismatch is a warning (`matchedSets[].warnings`), not an error. At most 24 members per set; a part can be in one set only. Engines `heuristic` and `balanced` (which does not rank `priority`, so under `respectStock` a set can lose its place there); `max` refuses a request with sets (400 `details.unsupported`)."""
289
+ name: str
290
+ qty: int
291
+ grain: Literal["w", "h", null]
292
+ onMiss: Literal["split", "unplaced"]
293
+ installGap: float
294
+
295
+
296
+ class MatchedSetLayout(TypedDict):
297
+ """Where each member sits, as lines of `parts[].id` values. `rows`: each line is a row of members side by side, the rows stacked top to bottom (`[["DF"], ["DOOR", "DOOR"]]` = a drawer front over two doors). `cols`: each line is a column of members one above the other, the columns side by side. A part may appear more than once in one set. Positions are numbered in reading order: top to bottom, left to right."""
298
+ dir: Literal["rows", "cols"]
299
+ lines: List[List[Union[str, int]]]
300
+
301
+
302
+ class _Stock2DRequired(TypedDict):
303
+ w: float
304
+ h: float
305
+
306
+
307
+ class Stock2D(_Stock2DRequired, total=False):
308
+ qty: int
309
+ price: float
310
+ material: str
311
+ priority: bool
312
+ grain: Literal["w", "h"]
313
+ meta: Dict[str, Any]
314
+
315
+
316
+ class Trim2D(TypedDict, total=False):
317
+ """Per-side edge trim removed before nesting. Asymmetric on purpose: a damaged long edge may need more than the others, and a symmetric trim would then give a wrong plan, not merely a worse one. `left` shifts placements right, `top` shifts them down. The sides are those of each sheet as sent — also on a `grain` row whose grain runs the other way."""
318
+ left: float
319
+ right: float
320
+ top: float
321
+ bottom: float
322
+
323
+
324
+ class Options2D(TypedDict, total=False):
325
+ kerf: float
326
+ tolerance: float
327
+ trim: "Trim2D"
328
+ firstCut: Literal["auto", "horizontal", "vertical"]
329
+ minimizeCost: bool
330
+ respectStock: bool
331
+ minOffcut: float
332
+ maxCutStages: Union[int, None]
333
+ minimizeRotations: bool
334
+ effort: Literal["fast", "balanced"]
335
+ setOutlines: bool
336
+
337
+
338
+ class _Optimize2DRequestRequired(TypedDict):
339
+ parts: List["Part2D"]
340
+ stock: List["Stock2D"]
341
+
342
+
343
+ class Optimize2DRequest(_Optimize2DRequestRequired, total=False):
344
+ options: "Options2D"
345
+ engine: Literal["heuristic", "balanced", "max"]
346
+ include: List[Literal["cutPlan", "offcuts", "svg", "csv", "dxf", "labels", "planHash", "pdf", "labelsPdf"]]
347
+ matchedSets: List["MatchedSet"]
348
+
349
+
350
+ class _Part1DRequired(TypedDict):
351
+ length: float
352
+
353
+
354
+ class Part1D(_Part1DRequired, total=False):
355
+ """A linear part. There is no `rotatable` or `grainGroup` in 1D."""
356
+ name: str
357
+ qty: int
358
+ priority: bool
359
+ material: str
360
+ meta: Dict[str, Any]
361
+
362
+
363
+ class _Stock1DRequired(TypedDict):
364
+ length: float
365
+
366
+
367
+ class Stock1D(_Stock1DRequired, total=False):
368
+ qty: int
369
+ price: float
370
+ material: str
371
+ priority: bool
372
+ meta: Dict[str, Any]
373
+
374
+
375
+ class Trim1D(TypedDict, total=False):
376
+ """A bar has two ends, not four edges. `start` is the leading trim (it shifts the first part along the bar, so the first `pos` equals `start`); `end` is the trailing trim."""
377
+ start: float
378
+ end: float
379
+
380
+
381
+ class Options1D(TypedDict, total=False):
382
+ """The 1D option set is deliberately smaller: there is no `firstCut`, `maxCutStages` or `minimizeRotations`, because none of them means anything on a bar."""
383
+ kerf: float
384
+ tolerance: float
385
+ trim: "Trim1D"
386
+ minimizeCost: bool
387
+ respectStock: bool
388
+ minOffcut: float
389
+
390
+
391
+ class _Optimize1DRequestRequired(TypedDict):
392
+ parts: List["Part1D"]
393
+ stock: List["Stock1D"]
394
+
395
+
396
+ class Optimize1DRequest(_Optimize1DRequestRequired, total=False):
397
+ options: "Options1D"
398
+ engine: Literal["heuristic", "balanced"]
399
+ include: List[Literal["cutPlan", "offcuts", "svg", "csv", "dxf", "labels", "planHash", "pdf", "labelsPdf"]]
400
+
401
+
402
+ class _PartWoodRequired(TypedDict):
403
+ sw: float
404
+ sh: float
405
+ length: float
406
+
407
+
408
+ class PartWood(_PartWoodRequired, total=False):
409
+ """A timber part. Same as a 1D part plus the CROSS-SECTION, which decides which stock it can come out of at all."""
410
+ name: str
411
+ qty: int
412
+ priority: bool
413
+ material: str
414
+ meta: Dict[str, Any]
415
+
416
+
417
+ class _StockWoodRequired(TypedDict):
418
+ sw: float
419
+ sh: float
420
+ length: float
421
+
422
+
423
+ class StockWood(_StockWoodRequired, total=False):
424
+ name: str
425
+ qty: int
426
+ price: float
427
+ material: str
428
+ priority: bool
429
+ meta: Dict[str, Any]
430
+
431
+
432
+ class OptionsWood(TypedDict, total=False):
433
+ """Identical to `Options1D` — a cross-section changes what fits, not how a bar is cut."""
434
+ kerf: float
435
+ tolerance: float
436
+ trim: "Trim1D"
437
+ minimizeCost: bool
438
+ respectStock: bool
439
+ minOffcut: float
440
+
441
+
442
+ class _OptimizeWoodRequestRequired(TypedDict):
443
+ parts: List["PartWood"]
444
+ stock: List["StockWood"]
445
+
446
+
447
+ class OptimizeWoodRequest(_OptimizeWoodRequestRequired, total=False):
448
+ options: "OptionsWood"
449
+ engine: Literal["heuristic", "balanced"]
450
+ include: List[Literal["cutPlan", "offcuts", "svg", "csv", "dxf", "labels", "planHash", "pdf", "labelsPdf"]]
451
+
452
+
453
+ class CutStep(TypedDict):
454
+ """One blade movement, in physically executable order (parent before children: you cannot crosscut a strip before you have ripped it off)."""
455
+ sheet: int
456
+ step: int
457
+ axis: Literal["h", "v"]
458
+ pos: float
459
+ length: float
460
+ stage: int
461
+
462
+
463
+ class _PlacedPart2DRequired(TypedDict):
464
+ name: str
465
+ x: float
466
+ y: float
467
+ w: float
468
+ h: float
469
+ rotated: bool
470
+
471
+
472
+ class PlacedPart2D(_PlacedPart2DRequired, total=False):
473
+ """A part on a board. `x`/`y` is its top-left corner measured from the top-left corner of the sheet, `x` growing right and `y` growing downward."""
474
+ meta: Dict[str, Any]
475
+ matchedSet: "PlacedMatchedSet"
476
+
477
+
478
+ class _PlacedMatchedSetRequired(TypedDict):
479
+ id: str
480
+ instance: int
481
+ position: int
482
+
483
+
484
+ class PlacedMatchedSet(_PlacedMatchedSetRequired, total=False):
485
+ """Present ONLY on a placed member of a matched set (never in a request without `matchedSets`): which set and instance it belongs to and where in it. `position` counts the members in reading order on the cabinet front — top to bottom, left to right — from 1; with the set's `letter` it is the code the `csv` `Position` column and the `svg` labels print (A1, A2 …)."""
486
+ hint: Literal["left", "middle", "right", "top", "bottom"]
487
+
488
+
489
+ class _MatchedSetSummaryRequired(TypedDict):
490
+ id: str
491
+ letter: str
492
+ requested: int
493
+ asBlock: int
494
+ split: int
495
+ unplaced: int
496
+ block: Dict[str, Any]
497
+ warnings: List[str]
498
+
499
+
500
+ class MatchedSetSummary(_MatchedSetSummaryRequired, total=False):
501
+ """One requested matched set: what became of its instances. Present ONLY in a request with sets."""
502
+ name: str
503
+
504
+
505
+ class Rect(TypedDict):
506
+ """A leftover rectangle, in the same coordinate system as the placements."""
507
+ x: float
508
+ y: float
509
+ w: float
510
+ h: float
511
+
512
+
513
+ class _SheetRequired(TypedDict):
514
+ w: float
515
+ h: float
516
+ price: Union[float, None]
517
+ parts: List["PlacedPart2D"]
518
+
519
+
520
+ class Sheet(_SheetRequired, total=False):
521
+ offcuts: List["Rect"]
522
+ material: str
523
+ grain: Literal["w", "h"]
524
+ meta: Dict[str, Any]
525
+
526
+
527
+ class Metrics2D(TypedDict):
528
+ """Aggregate numbers for the whole job."""
529
+ sheetCount: int
530
+ yieldPct: float
531
+ placed: int
532
+ total: int
533
+ cutLines: int
534
+ sawPasses: int
535
+ cutLength: float
536
+ totalPrice: float
537
+
538
+
539
+ class Unplaced2D(TypedDict):
540
+ """Parts that did not fit, aggregated back by name + dimensions."""
541
+ name: str
542
+ w: float
543
+ h: float
544
+ qty: int
545
+
546
+
547
+ class Timing(TypedDict):
548
+ solveMs: float
549
+
550
+
551
+ class EdgeBandingByType(TypedDict):
552
+ reference: str
553
+ meters: float
554
+
555
+
556
+ class EdgeBandingByPart(TypedDict):
557
+ name: str
558
+ meters: float
559
+ byType: List["EdgeBandingByType"]
560
+
561
+
562
+ class LabelEdge(TypedDict):
563
+ reference: str
564
+ length: float
565
+
566
+
567
+ class _Label2DRequired(TypedDict):
568
+ sheet: int
569
+ name: str
570
+ partNo: int
571
+ w: float
572
+ h: float
573
+ rotated: bool
574
+ x: float
575
+ y: float
576
+
577
+
578
+ class Label2D(_Label2DRequired, total=False):
579
+ """Label data for one placed piece. `w`/`h` are the ENTERED size, `rotated` says the piece was turned 90 degrees on the sheet, `x`/`y` equal the placed position in `sheets[sheet].parts`. `edges` are in the entered orientation and list only banded sides."""
580
+ material: str
581
+ grain: Literal["w", "h"]
582
+ edges: Dict[str, Any]
583
+ meta: Dict[str, Any]
584
+
585
+
586
+ class EdgeBanding(TypedDict):
587
+ """Linear metres of edge banding the order consumes, grouped by the caller-supplied type reference and reported per part AND as an order total. Present ONLY when at least one part requested banding, so a banding-free response omits the key. Exact geometry, no waste margin — the workshop adds its own. ⚠️ The metres ASSUME MILLIMETRE input: top and bottom edges run the part width, left and right run the height, multiplied by qty, divided by 1000. This is the one response figure named for a unit (edge banding is bought by the metre, and the division only means metres in millimetres); in another unit the raw edge length is w/h × qty and these numbers are that divided by 1000. 2D only."""
588
+ totalMeters: float
589
+ byType: List["EdgeBandingByType"]
590
+ byPart: List["EdgeBandingByPart"]
591
+
592
+
593
+ class _Optimize2DResponseRequired(TypedDict):
594
+ engine: Literal["heuristic", "balanced", "max"]
595
+ engineVersion: str
596
+ contractVersion: str
597
+ deterministic: Literal[true]
598
+ sheets: List["Sheet"]
599
+ metrics: "Metrics2D"
600
+ unplaced: List["Unplaced2D"]
601
+ warnings: List[str]
602
+ guillotineValid: bool
603
+ timing: "Timing"
604
+
605
+
606
+ class Optimize2DResponse(_Optimize2DResponseRequired, total=False):
607
+ cutPlan: Union[List["CutStep"], None]
608
+ edgeBanding: "EdgeBanding"
609
+ materials: List["MaterialSummary2D"]
610
+ unmatchedMaterials: List["UnmatchedMaterial2D"]
611
+ matchedSets: List["MatchedSetSummary"]
612
+ svg: str
613
+ csv: str
614
+ dxf: str
615
+ labels: List["Label2D"]
616
+ planHash: str
617
+ pdf: str
618
+ labelsPdf: str
619
+
620
+
621
+ class MaterialSummary2D(TypedDict):
622
+ """One material's totals across the sheets cut from it."""
623
+ material: str
624
+ sheetCount: int
625
+ yieldPct: float
626
+ placed: int
627
+ total: int
628
+ totalPrice: float
629
+
630
+
631
+ class UnmatchedMaterial2D(TypedDict):
632
+ """Demand of a material for which the job supplied ZERO stock of that material."""
633
+ material: str
634
+ parts: List["Unplaced2D"]
635
+
636
+
637
+ class _PlacedPart1DRequired(TypedDict):
638
+ name: str
639
+ pos: float
640
+ length: float
641
+
642
+
643
+ class PlacedPart1D(_PlacedPart1DRequired, total=False):
644
+ meta: Dict[str, Any]
645
+
646
+
647
+ class _RodRequired(TypedDict):
648
+ length: float
649
+ price: Union[float, None]
650
+ remaining: float
651
+ parts: List["PlacedPart1D"]
652
+
653
+
654
+ class Rod(_RodRequired, total=False):
655
+ offcuts: List[float]
656
+ material: str
657
+ meta: Dict[str, Any]
658
+
659
+
660
+ class Metrics1D(TypedDict):
661
+ rodCount: int
662
+ yieldPct: float
663
+ placed: int
664
+ total: int
665
+ cuts: int
666
+ totalPrice: float
667
+ toleranceAcceptedCount: int
668
+
669
+
670
+ class Unplaced1D(TypedDict):
671
+ name: str
672
+ length: float
673
+ qty: int
674
+
675
+
676
+ class _Optimize1DResponseRequired(TypedDict):
677
+ engine: Literal["heuristic", "balanced"]
678
+ engineVersion: str
679
+ contractVersion: str
680
+ deterministic: Literal[true]
681
+ rods: List["Rod"]
682
+ metrics: "Metrics1D"
683
+ unplaced: List["Unplaced1D"]
684
+ warnings: List[str]
685
+ timing: "Timing"
686
+
687
+
688
+ class Optimize1DResponse(_Optimize1DResponseRequired, total=False):
689
+ cutPlan: Union[List["CutStep"], None]
690
+ materials: List["MaterialSummary1D"]
691
+ unmatchedMaterials: List["UnmatchedMaterial1D"]
692
+ csv: str
693
+ dxf: str
694
+
695
+
696
+ class MaterialSummary1D(TypedDict):
697
+ """One material's totals across the rods cut from it."""
698
+ material: str
699
+ rodCount: int
700
+ yieldPct: float
701
+ placed: int
702
+ total: int
703
+ totalPrice: float
704
+
705
+
706
+ class UnmatchedMaterial1D(TypedDict):
707
+ """Demand of a material for which the job supplied ZERO stock of that material."""
708
+ material: str
709
+ parts: List["Unplaced1D"]
710
+
711
+
712
+ class WoodCutStep(TypedDict):
713
+ """A `CutStep` that also names its cross-section. ⚠️ `sheet` is the rod index WITHIN that section, not a job-wide rod counter — pair `section` + `sheet` to locate the bar."""
714
+ section: str
715
+
716
+
717
+ class MetricsSection(TypedDict):
718
+ """One cross-section's own totals — no need to re-derive them from `rods`."""
719
+ rodCount: int
720
+ yieldPct: float
721
+ placed: int
722
+ total: int
723
+ cuts: int
724
+ totalPrice: float
725
+
726
+
727
+ class _WoodSectionRequired(TypedDict):
728
+ section: str
729
+ sw: float
730
+ sh: float
731
+ stockName: Union[str, None]
732
+ rods: List["Rod"]
733
+ metrics: "MetricsSection"
734
+ unplaced: List["Unplaced1D"]
735
+
736
+
737
+ class WoodSection(_WoodSectionRequired, total=False):
738
+ material: str
739
+
740
+
741
+ class _WoodUnmatchedRequired(TypedDict):
742
+ section: str
743
+ sw: float
744
+ sh: float
745
+ parts: List["Unplaced1D"]
746
+ qty: int
747
+
748
+
749
+ class WoodUnmatched(_WoodUnmatchedRequired, total=False):
750
+ """Demand for a cross-section you supplied NO stock for. Deliberately separate from `unplaced`: the fix is to declare or buy the material, not to add length or relax a limit."""
751
+ material: str
752
+
753
+
754
+ class MetricsWood(TypedDict):
755
+ """Job-wide totals across every section."""
756
+ sectionCount: int
757
+ rodCount: int
758
+ yieldPct: float
759
+ placed: int
760
+ total: int
761
+ cuts: int
762
+ totalPrice: float
763
+ toleranceAcceptedCount: int
764
+
765
+
766
+ class _OptimizeWoodResponseRequired(TypedDict):
767
+ engine: Literal["heuristic", "balanced"]
768
+ engineVersion: str
769
+ contractVersion: str
770
+ deterministic: Literal[true]
771
+ sections: List["WoodSection"]
772
+ unmatched: List["WoodUnmatched"]
773
+ metrics: "MetricsWood"
774
+ unplaced: List["Unplaced1D"]
775
+ warnings: List[str]
776
+ timing: "Timing"
777
+
778
+
779
+ class OptimizeWoodResponse(_OptimizeWoodResponseRequired, total=False):
780
+ cutPlan: Union[List["WoodCutStep"], None]
781
+ csv: str
782
+ dxf: str
783
+
784
+
785
+ class _UsageResponseRequired(TypedDict):
786
+ plan: str
787
+ used: int
788
+ limit: int
789
+ remaining: int
790
+ periodEnd: str
791
+ keyPrefix: str
792
+ contractVersion: str
793
+
794
+
795
+ class UsageResponse(_UsageResponseRequired, total=False):
796
+ env: Literal["test"]
797
+ keyClass: Literal["tenant"]
798
+
799
+
800
+ class _HealthResponseRequired(TypedDict):
801
+ status: Literal["healthy"]
802
+ service: Literal["cutoptim-engine"]
803
+ contractVersion: str
804
+ engineVersion: str
805
+ engines: List[Literal["heuristic", "balanced"]]
806
+ modes: List[Literal["2d", "1d", "wood", "nest"]]
807
+ uptimeSec: int
808
+
809
+
810
+ class HealthResponse(_HealthResponseRequired, total=False):
811
+ nestEngines: List[Literal["lbf", "max"]]
812
+ maxEngines: List[Literal["max"]]
813
+
814
+
cutoptim/webhooks.py ADDED
@@ -0,0 +1,58 @@
1
+ """Verify a CutOptim webhook delivery (the CutOptim-Signature header).
2
+
3
+ CutOptim-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>[,v1=...]
4
+
5
+ Pass the RAW request body (the exact bytes received): a body parsed and serialised again does not match. During a
6
+ secret rotation the header carries two v1 values; either matching your secret is enough.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import re
13
+ import time
14
+ from typing import Iterable, Optional, Union
15
+
16
+ SIGNATURE_HEADER = "CutOptim-Signature"
17
+ DEFAULT_TOLERANCE_S = 300
18
+
19
+ _T = re.compile(r"^\d{1,12}$")
20
+ _V1 = re.compile(r"^[0-9a-f]{64}$")
21
+
22
+
23
+ def verify_webhook(
24
+ raw_body: Union[bytes, str],
25
+ header: Optional[str],
26
+ secrets: Union[str, Iterable[str]],
27
+ tolerance_s: int = DEFAULT_TOLERANCE_S,
28
+ now_s: Optional[int] = None,
29
+ ) -> bool:
30
+ """True when ``header`` signs ``raw_body`` with one of ``secrets`` inside the time window."""
31
+ if not isinstance(header, str) or len(header) > 4096:
32
+ return False
33
+ t: Optional[int] = None
34
+ v1 = []
35
+ for part in header.split(","):
36
+ k, sep, v = part.partition("=")
37
+ if not sep or not k.strip():
38
+ return False
39
+ k, v = k.strip(), v.strip()
40
+ if k == "t":
41
+ if t is not None or not _T.match(v):
42
+ return False
43
+ t = int(v)
44
+ elif k == "v1":
45
+ if not _V1.match(v):
46
+ return False
47
+ v1.append(v)
48
+ if t is None or not v1:
49
+ return False
50
+ now = int(time.time()) if now_s is None else now_s
51
+ if abs(now - t) > tolerance_s:
52
+ return False
53
+ body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
54
+ for secret in [secrets] if isinstance(secrets, str) else list(secrets):
55
+ want = hmac.new(secret.encode("utf-8"), str(t).encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
56
+ if any(hmac.compare_digest(want, got) for got in v1):
57
+ return True
58
+ return False
@@ -0,0 +1,243 @@
1
+ Metadata-Version: 2.5
2
+ Name: cutoptim
3
+ Version: 1.0.0
4
+ Summary: Official client for the CutOptim Engine API — cutting optimization (2D panels, 1D linear, timber, true-shape nesting) over HTTPS.
5
+ Project-URL: Homepage, https://cutoptim.com/engine/reference
6
+ Project-URL: Source, https://github.com/cutoptim/engine-python
7
+ Project-URL: Issues, https://github.com/cutoptim/engine-python/issues
8
+ Author-email: CutOptim <support@cutoptim.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api client,cut list,cutoptim,cutting optimization,nesting,panel saw
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+
19
+ <p align="center">
20
+ <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>
21
+ </p>
22
+
23
+ <h1 align="center">CutOptim Engine API — Python SDK</h1>
24
+
25
+ <p align="center">
26
+ Cutting optimization over HTTPS: <b>2D panels</b>, <b>1D linear</b>, <b>timber</b> and <b>true-shape nesting</b>.<br>
27
+ Deterministic plans, saw-ready cut sequences — standard library only.
28
+ </p>
29
+
30
+ <p align="center">
31
+ <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>
32
+ <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>
33
+ <img alt="Python 3.9+" src="https://img.shields.io/badge/python-%E2%89%A53.9-f0c040?labelColor=0d0f14">
34
+ <img alt="Zero dependencies" src="https://img.shields.io/badge/dependencies-0-f0c040?labelColor=0d0f14">
35
+ <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>
36
+ </p>
37
+
38
+ <p align="center">
39
+ <a href="https://cutoptim.com/engine/reference/quickstart">Quickstart</a> ·
40
+ <a href="https://cutoptim.com/engine/reference">API reference</a> ·
41
+ <a href="https://cutoptim.com/engine/reference/guides/webhooks">Webhooks</a> ·
42
+ <a href="https://cutoptim.com/engine/reference/changelog">Changelog</a> ·
43
+ <a href="https://cutoptim.com/dashboard#api-keys">Get an API key</a>
44
+ </p>
45
+
46
+ ---
47
+
48
+ ## Why this SDK
49
+
50
+ - **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.
51
+ - **No dependencies.** `urllib`, `json` and `hmac` from the standard library; Python 3.9+.
52
+ - **Retries that never cost twice.** Every optimize call carries an `Idempotency-Key`; a retried call returns the stored answer instead of a second charge.
53
+ - **Async jobs without the boilerplate.** `wait_for_job()` polls the `max` engine for you — or let a webhook tell you.
54
+ - **Webhook verification built in.** `verify_webhook()` checks the signature on the raw body, with the replay window and secret rotation handled.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install cutoptim
60
+ ```
61
+
62
+ 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).
63
+
64
+ ## Quickstart
65
+
66
+ ```python
67
+ import os
68
+ from cutoptim import CutOptim, is_job_accepted
69
+
70
+ client = CutOptim(api_key=os.environ["CUTOPTIM_KEY"])
71
+
72
+ plan = client.optimize_2d({
73
+ "parts": [
74
+ {"name": "Side", "w": 720, "h": 560, "qty": 2},
75
+ {"name": "Shelf", "w": 764, "h": 540, "qty": 3, "rotatable": False},
76
+ ],
77
+ "stock": [{"w": 2800, "h": 2070, "price": 48}],
78
+ "options": {"kerf": 4},
79
+ })
80
+
81
+ if not is_job_accepted(plan):
82
+ print(f"{plan['metrics']['sheetCount']} sheet(s), {plan['metrics']['yieldPct']}% yield")
83
+ for sheet in plan["sheets"]:
84
+ for p in sheet["parts"]:
85
+ print(p["name"], p["x"], p["y"], p["w"], p["h"], "rotated" if p["rotated"] else "")
86
+ ```
87
+
88
+ The same input always returns the same plan — cache it, test against it, quote from it.
89
+
90
+ ## Every mode
91
+
92
+ ```python
93
+ # 1D linear — bars, profiles, tubes
94
+ bars = client.optimize_1d({"parts": [{"name": "Rail", "length": 1200, "qty": 6}], "stock": [{"length": 6000}]})
95
+ print(bars["metrics"]["rodCount"], bars["metrics"]["yieldPct"])
96
+
97
+ # Timber — lengths cut from beams of a given cross-section (sw × sh)
98
+ timber = client.optimize_wood({
99
+ "parts": [{"name": "Joist", "sw": 45, "sh": 145, "length": 3600, "qty": 4}],
100
+ "stock": [{"sw": 45, "sh": 145, "length": 4800}],
101
+ })
102
+
103
+ # True-shape nesting — polygons (or SVG/DXF sources) on sheets
104
+ nest = client.optimize_nest({
105
+ "parts": [{"name": "Bracket", "polygon": [[0, 0], [120, 0], [120, 40], [40, 120], [0, 120]], "qty": 10}],
106
+ "stock": [{"w": 1000, "h": 500}],
107
+ })
108
+ if not is_job_accepted(nest):
109
+ print(nest["metrics"]["sheetCount"], nest["metrics"]["density"])
110
+ ```
111
+
112
+ Check a request without spending a call — the validate endpoints need no key and give the same verdict:
113
+
114
+ ```python
115
+ CutOptim().validate_2d(request) # {"valid": True, "warnings": [...]} or CutOptimError(400)
116
+ ```
117
+
118
+ ## Async `max` jobs
119
+
120
+ `"engine": "max"` searches harder (seconds to a minute) and answers `202` with a job id instead of a plan:
121
+
122
+ ```python
123
+ accepted = client.optimize_2d({**request, "engine": "max"})
124
+ if is_job_accepted(accepted):
125
+ job = client.wait_for_job(accepted["jobId"], timeout_s=300) # polls GET /v1/jobs/{id}, no quota
126
+ if job["status"] == "succeeded":
127
+ print(job["result"])
128
+ ```
129
+
130
+ ## Webhooks
131
+
132
+ 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:
133
+
134
+ ```python
135
+ # Flask
136
+ from flask import Flask, request
137
+ from cutoptim import verify_webhook
138
+
139
+ @app.post("/cutoptim/webhook")
140
+ def cutoptim_webhook():
141
+ if not verify_webhook(request.get_data(), request.headers.get("CutOptim-Signature"), WEBHOOK_SECRET):
142
+ return "", 400
143
+ event = request.get_json() # deduplicate on event["id"] — delivery is at least once
144
+ return "", 204 # answer within 10 s, then do the work
145
+ ```
146
+
147
+ ```python
148
+ # FastAPI
149
+ from fastapi import FastAPI, Request, Response
150
+
151
+ @app.post("/cutoptim/webhook")
152
+ async def cutoptim_webhook(request: Request):
153
+ raw = await request.body()
154
+ if not verify_webhook(raw, request.headers.get("CutOptim-Signature"), WEBHOOK_SECRET):
155
+ return Response(status_code=400)
156
+ return Response(status_code=204)
157
+ ```
158
+
159
+ During a secret rotation both secrets sign for 24 hours — pass both: `verify_webhook(raw, header, [new_secret, old_secret])`.
160
+
161
+ ## Retries and idempotency
162
+
163
+ | Situation | What the client does |
164
+ |---|---|
165
+ | `429 busy`, `503`, network error | retries (2× by default), waiting `Retry-After` when the API sends it |
166
+ | `409 idempotency_in_progress` | retries — the first request is still running; the retry receives its answer |
167
+ | any optimize call | sends an `Idempotency-Key` (a fresh UUID, or yours) — the **same** key on every retry |
168
+
169
+ Pass your own key to make retries safe across process restarts too:
170
+
171
+ ```python
172
+ client.optimize_nest(request, idempotency_key=f"order-{order_id}")
173
+ ```
174
+
175
+ Within 24 hours the same key and request return the stored answer — not charged again, and a `max` job is not queued twice.
176
+
177
+ ## Errors
178
+
179
+ Any non-2xx answer raises `CutOptimError`:
180
+
181
+ ```python
182
+ from cutoptim import CutOptimError
183
+
184
+ try:
185
+ client.optimize_2d(request)
186
+ except CutOptimError as e:
187
+ print(e.status, e.code, e.details, e.retry_after) # branch on e.code, never on the message
188
+ ```
189
+
190
+ | `code` | Status | Meaning |
191
+ |---|---|---|
192
+ | `invalid_request` | 400 | the request is wrong — `details["path"]` names the field; never charged |
193
+ | `unauthorized` | 401 | missing or unknown API key |
194
+ | `quota_exceeded` | 402 | the account's monthly quota is used up — `retry_after` counts to the reset |
195
+ | `key_revoked` | 403 | the key is revoked, or the API subscription is not active |
196
+ | `idempotency_conflict` | 409 | the key was used for a different request — use a new key |
197
+ | `too_large` | 413 | an input cap was exceeded (parts, rows, body size) |
198
+ | `busy` | 429 | at capacity right now — retried automatically |
199
+ | `service_unavailable` | 503 | an engine is not available on this deployment |
200
+ | `solve_timeout` | 504 | the nesting solve ran out of time |
201
+ | `timeout` | — | `wait_for_job()` gave up waiting (the job keeps running) |
202
+
203
+ 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).
204
+
205
+ ## Configuration
206
+
207
+ ```python
208
+ CutOptim(
209
+ api_key="co_live_…", # not needed for health() and validate_*()
210
+ base_url="https://api.cutoptim.com", # default
211
+ max_retries=2, # 429 / 503 / 409 in-progress / network errors
212
+ timeout_s=30.0, # per request
213
+ )
214
+ ```
215
+
216
+ ## API coverage
217
+
218
+ | Method | Endpoint | Quota |
219
+ |---|---|---|
220
+ | `optimize_2d(req, idempotency_key=None)` | `POST /v1/optimize/2d` | 1 call |
221
+ | `optimize_1d(req, idempotency_key=None)` | `POST /v1/optimize/1d` | 1 call |
222
+ | `optimize_wood(req, idempotency_key=None)` | `POST /v1/optimize/wood` | 1 call |
223
+ | `optimize_nest(req, idempotency_key=None)` | `POST /v1/optimize/nest` | 1 call |
224
+ | `validate_2d` · `validate_1d` · `validate_wood` · `validate_nest` | `POST /v1/validate/*` | free, no key |
225
+ | `import_nest(req)` | `POST /v1/import/nest` | free (key required) |
226
+ | `get_job(id)` · `wait_for_job(id)` | `GET /v1/jobs/{id}` | free |
227
+ | `usage()` | `GET /v1/usage` | free |
228
+ | `health()` | `GET /v1/health` | free, no key |
229
+
230
+ ## Versioning
231
+
232
+ 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).
233
+
234
+ ## Support
235
+
236
+ - Questions and bugs: [GitHub issues](https://github.com/cutoptim/engine-python/issues) or [support@cutoptim.com](mailto:support@cutoptim.com)
237
+ - Limits and quotas: [cutoptim.com/engine/reference/limits](https://cutoptim.com/engine/reference/limits)
238
+
239
+ This repository is published from the CutOptim monorepo; pull requests are welcome and applied upstream.
240
+
241
+ ## License
242
+
243
+ [MIT](https://github.com/cutoptim/engine-python/blob/main/LICENSE) © CutOptim
@@ -0,0 +1,9 @@
1
+ cutoptim/__init__.py,sha256=Go4xrpFCK531dlftviEX1xyN2QAYIfKhYuxaVDsThMU,651
2
+ cutoptim/client.py,sha256=z-lCy_QWWllyz_6TXRC7U2uWdpD42q2xIH2a4eXYk34,7270
3
+ cutoptim/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ cutoptim/types_gen.py,sha256=HPQqbBTgp_dRlZIVrAtyZIfKB4h9uDAyX4BlJSvqVBI,23662
5
+ cutoptim/webhooks.py,sha256=60TtN4UMbQ_R4H82D17Z1HHZr9crxbfWb1Ox28JwC_Y,2007
6
+ cutoptim-1.0.0.dist-info/METADATA,sha256=fQqd2NbpRUVChNM67b7lYh0fqP8Hpg-km0mTPlgWIwU,10653
7
+ cutoptim-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ cutoptim-1.0.0.dist-info/licenses/LICENSE,sha256=Bpdyt9EoiUa2PCUahOsP2JRDVl3r5EuomA6EyxjSgcE,1065
9
+ cutoptim-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CutOptim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.