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 +13 -0
- cutoptim/client.py +155 -0
- cutoptim/py.typed +0 -0
- cutoptim/types_gen.py +814 -0
- cutoptim/webhooks.py +58 -0
- cutoptim-1.0.0.dist-info/METADATA +243 -0
- cutoptim-1.0.0.dist-info/RECORD +9 -0
- cutoptim-1.0.0.dist-info/WHEEL +4 -0
- cutoptim-1.0.0.dist-info/licenses/LICENSE +21 -0
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,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.
|