langchain-arcnm 0.1.0__tar.gz

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.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .env
11
+ .DS_Store
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. Price a part from a CAD file or a part revision, read and list
6
+ calculations, read the cost sheet at any lot size, find parts and their
7
+ revisions, list costing environments, check the calculation allowance, and
8
+ cancel a running calculation.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rattle mng GmbH
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.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-arcnm
3
+ Version: 0.1.0
4
+ Summary: LangChain tools for ARCNM: manufacturing should-cost for machined and sheet-metal parts, from a CAD file.
5
+ Project-URL: Homepage, https://arcnm.io
6
+ Project-URL: Documentation, https://github.com/arcnm/langchain-arcnm#readme
7
+ Project-URL: Source, https://github.com/arcnm/langchain-arcnm
8
+ Project-URL: Changelog, https://github.com/arcnm/langchain-arcnm/blob/main/CHANGELOG.md
9
+ Author-email: ARCNM <hello@arcnm.io>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: arcnm,cad,langchain,manufacturing,quoting,should-cost
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Manufacturing
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Scientific/Engineering
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: <4.0,>=3.10
20
+ Requires-Dist: httpx<1.0.0,>=0.27.0
21
+ Requires-Dist: langchain-core<2.0.0,>=1.0.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # langchain-arcnm
25
+
26
+ **Manufacturing should-cost from a CAD file, for your LangChain agent.**
27
+
28
+ [ARCNM](https://arcnm.io) turns a STEP file into an itemised manufacturing cost
29
+ for machined and sheet-metal parts: unit cost, setup cost, cycle time, and the
30
+ cost sheet behind them. This package gives a LangChain or LangGraph agent the
31
+ ARCNM REST API as tools, so it can price a part, explain what drives the cost,
32
+ and compare lot sizes.
33
+
34
+ ```bash
35
+ pip install -U langchain-arcnm
36
+ ```
37
+
38
+ ## Setup
39
+
40
+ 1. In ARCNM, open Settings, then API keys, then New key. ARCNM shows the key once.
41
+ 2. Export it:
42
+
43
+ ```bash
44
+ export ARCNM_API_KEY="..."
45
+ ```
46
+
47
+ Reads need the `parts:read` scope. Starting or cancelling a calculation needs
48
+ `parts:write`. The Free plan includes 10 calculations a month, no card required.
49
+
50
+ ## Use it in an agent
51
+
52
+ ```python
53
+ from langchain.agents import create_agent
54
+ from langchain_arcnm import ArcnmToolkit
55
+
56
+ agent = create_agent(
57
+ "anthropic:claude-sonnet-5-5",
58
+ tools=ArcnmToolkit(root_dir="./cad").get_tools(),
59
+ )
60
+ result = agent.invoke(
61
+ {
62
+ "messages": [
63
+ {
64
+ "role": "user",
65
+ "content": "What does bracket.step cost at a lot of 50, and what drives it?",
66
+ }
67
+ ]
68
+ }
69
+ )
70
+ print(result["messages"][-1].content)
71
+ ```
72
+
73
+ ## Call a tool directly
74
+
75
+ ```python
76
+ from langchain_arcnm import UploadAndQuoteTool
77
+
78
+ quote = UploadAndQuoteTool()
79
+ print(quote.invoke({"file_path": "bracket.step", "lot_size": 50}))
80
+ ```
81
+
82
+ Every tool returns JSON as a string. An API error comes back as the tool
83
+ result, with its stable error code and request id, so the agent can react to
84
+ it; set `handle_tool_error=False` to have it raised instead.
85
+
86
+ ## Tools
87
+
88
+ | Tool | What it does | Uses a calculation |
89
+ |---|---|---|
90
+ | `upload_and_quote` | Uploads a CAD file, and a drawing if you have one, and prices it. | Yes |
91
+ | `quote_part` | Prices a part revision ARCNM already has, at another lot size or in another costing environment. | Yes |
92
+ | `cancel_calculation` | Stops a calculation that is still queued or running. | No |
93
+ | `get_calculation` | Reads one calculation: status, costs, cycle time, confidence. `wait=true` waits for it to finish. | No |
94
+ | `list_calculations` | Lists recent calculations, by status or part. | No |
95
+ | `get_cost_sheet` | The itemised cost sheet, at the priced lot size or any other. | No |
96
+ | `list_parts` | Finds parts by part number. | No |
97
+ | `list_part_revisions` | Lists a part's revisions, for `quote_part`. | No |
98
+ | `list_environments` | Lists the costing environments (machines, rates, materials) a calculation can price with. | No |
99
+ | `check_quota` | How many calculations are left in this period. | No |
100
+
101
+ The names match the [ARCNM MCP server](https://github.com/arcnm/arcnm-mcp), so
102
+ an agent sees the same verbs on either surface.
103
+
104
+ `upload_and_quote`, `quote_part` and `get_calculation` with `wait=true` wait up
105
+ to `wait_timeout` seconds (default 120) for the result. A calculation still
106
+ running after that comes back with its id, and the agent can ask again with
107
+ `get_calculation`.
108
+
109
+ ## Configuration
110
+
111
+ Pass these to `ArcnmToolkit` or to any single tool.
112
+
113
+ | Argument | Default | |
114
+ |---|---|---|
115
+ | `api_key` | `ARCNM_API_KEY` | Your ARCNM API key. |
116
+ | `base_url` | `ARCNM_BASE_URL`, else `https://api.arcnm.io` | Change it only when you were given a different origin. |
117
+ | `org_slug` | `ARCNM_ORG_SLUG` | The organization to act in, for a key that belongs to several. |
118
+ | `root_dir` | none | The directory `upload_and_quote` may read files from. |
119
+ | `read_only` | `False` | Toolkit only. Leaves out the tools that start or stop a calculation. |
120
+ | `wait_timeout` | `120` | Seconds to wait for a started calculation. `0` returns at once. |
121
+ | `poll_interval` | `2` | Seconds between status checks while waiting. |
122
+ | `request_timeout` | `60` | Seconds one HTTP request may take. |
123
+
124
+ ## Security
125
+
126
+ `upload_and_quote` reads the file path the model gives it and sends that file
127
+ to ARCNM. Set `root_dir` so it can only read the folder you meant, and use
128
+ `read_only=True` for an agent that should never start a calculation. A key with
129
+ only `parts:read` cannot start one either.
130
+
131
+ ## Development
132
+
133
+ ```bash
134
+ uv sync --all-groups
135
+ make test # unit tests, no network
136
+ make integration_test # against the live API; needs ARCNM_API_KEY, uses no calculations
137
+ make lint
138
+ ```
139
+
140
+ `make integration_test BILLABLE=1` also quotes `tests/integration_tests/shaft_thread.step`,
141
+ which uses one calculation.
142
+
143
+ `arcnm-contract.json` lists every ARCNM operation and field this package relies
144
+ on. ARCNM checks it against every change to its API before that change merges.
145
+ It is not part of the published package.
146
+
147
+ ## Links
148
+
149
+ * [ARCNM docs](https://arcnm.io/docs)
150
+ * [ARCNM API examples](https://github.com/arcnm/arcnm-examples)
151
+ * [LangChain tools](https://docs.langchain.com/oss/python/langchain/tools)
152
+
153
+ ## License
154
+
155
+ MIT
@@ -0,0 +1,132 @@
1
+ # langchain-arcnm
2
+
3
+ **Manufacturing should-cost from a CAD file, for your LangChain agent.**
4
+
5
+ [ARCNM](https://arcnm.io) turns a STEP file into an itemised manufacturing cost
6
+ for machined and sheet-metal parts: unit cost, setup cost, cycle time, and the
7
+ cost sheet behind them. This package gives a LangChain or LangGraph agent the
8
+ ARCNM REST API as tools, so it can price a part, explain what drives the cost,
9
+ and compare lot sizes.
10
+
11
+ ```bash
12
+ pip install -U langchain-arcnm
13
+ ```
14
+
15
+ ## Setup
16
+
17
+ 1. In ARCNM, open Settings, then API keys, then New key. ARCNM shows the key once.
18
+ 2. Export it:
19
+
20
+ ```bash
21
+ export ARCNM_API_KEY="..."
22
+ ```
23
+
24
+ Reads need the `parts:read` scope. Starting or cancelling a calculation needs
25
+ `parts:write`. The Free plan includes 10 calculations a month, no card required.
26
+
27
+ ## Use it in an agent
28
+
29
+ ```python
30
+ from langchain.agents import create_agent
31
+ from langchain_arcnm import ArcnmToolkit
32
+
33
+ agent = create_agent(
34
+ "anthropic:claude-sonnet-5-5",
35
+ tools=ArcnmToolkit(root_dir="./cad").get_tools(),
36
+ )
37
+ result = agent.invoke(
38
+ {
39
+ "messages": [
40
+ {
41
+ "role": "user",
42
+ "content": "What does bracket.step cost at a lot of 50, and what drives it?",
43
+ }
44
+ ]
45
+ }
46
+ )
47
+ print(result["messages"][-1].content)
48
+ ```
49
+
50
+ ## Call a tool directly
51
+
52
+ ```python
53
+ from langchain_arcnm import UploadAndQuoteTool
54
+
55
+ quote = UploadAndQuoteTool()
56
+ print(quote.invoke({"file_path": "bracket.step", "lot_size": 50}))
57
+ ```
58
+
59
+ Every tool returns JSON as a string. An API error comes back as the tool
60
+ result, with its stable error code and request id, so the agent can react to
61
+ it; set `handle_tool_error=False` to have it raised instead.
62
+
63
+ ## Tools
64
+
65
+ | Tool | What it does | Uses a calculation |
66
+ |---|---|---|
67
+ | `upload_and_quote` | Uploads a CAD file, and a drawing if you have one, and prices it. | Yes |
68
+ | `quote_part` | Prices a part revision ARCNM already has, at another lot size or in another costing environment. | Yes |
69
+ | `cancel_calculation` | Stops a calculation that is still queued or running. | No |
70
+ | `get_calculation` | Reads one calculation: status, costs, cycle time, confidence. `wait=true` waits for it to finish. | No |
71
+ | `list_calculations` | Lists recent calculations, by status or part. | No |
72
+ | `get_cost_sheet` | The itemised cost sheet, at the priced lot size or any other. | No |
73
+ | `list_parts` | Finds parts by part number. | No |
74
+ | `list_part_revisions` | Lists a part's revisions, for `quote_part`. | No |
75
+ | `list_environments` | Lists the costing environments (machines, rates, materials) a calculation can price with. | No |
76
+ | `check_quota` | How many calculations are left in this period. | No |
77
+
78
+ The names match the [ARCNM MCP server](https://github.com/arcnm/arcnm-mcp), so
79
+ an agent sees the same verbs on either surface.
80
+
81
+ `upload_and_quote`, `quote_part` and `get_calculation` with `wait=true` wait up
82
+ to `wait_timeout` seconds (default 120) for the result. A calculation still
83
+ running after that comes back with its id, and the agent can ask again with
84
+ `get_calculation`.
85
+
86
+ ## Configuration
87
+
88
+ Pass these to `ArcnmToolkit` or to any single tool.
89
+
90
+ | Argument | Default | |
91
+ |---|---|---|
92
+ | `api_key` | `ARCNM_API_KEY` | Your ARCNM API key. |
93
+ | `base_url` | `ARCNM_BASE_URL`, else `https://api.arcnm.io` | Change it only when you were given a different origin. |
94
+ | `org_slug` | `ARCNM_ORG_SLUG` | The organization to act in, for a key that belongs to several. |
95
+ | `root_dir` | none | The directory `upload_and_quote` may read files from. |
96
+ | `read_only` | `False` | Toolkit only. Leaves out the tools that start or stop a calculation. |
97
+ | `wait_timeout` | `120` | Seconds to wait for a started calculation. `0` returns at once. |
98
+ | `poll_interval` | `2` | Seconds between status checks while waiting. |
99
+ | `request_timeout` | `60` | Seconds one HTTP request may take. |
100
+
101
+ ## Security
102
+
103
+ `upload_and_quote` reads the file path the model gives it and sends that file
104
+ to ARCNM. Set `root_dir` so it can only read the folder you meant, and use
105
+ `read_only=True` for an agent that should never start a calculation. A key with
106
+ only `parts:read` cannot start one either.
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ uv sync --all-groups
112
+ make test # unit tests, no network
113
+ make integration_test # against the live API; needs ARCNM_API_KEY, uses no calculations
114
+ make lint
115
+ ```
116
+
117
+ `make integration_test BILLABLE=1` also quotes `tests/integration_tests/shaft_thread.step`,
118
+ which uses one calculation.
119
+
120
+ `arcnm-contract.json` lists every ARCNM operation and field this package relies
121
+ on. ARCNM checks it against every change to its API before that change merges.
122
+ It is not part of the published package.
123
+
124
+ ## Links
125
+
126
+ * [ARCNM docs](https://arcnm.io/docs)
127
+ * [ARCNM API examples](https://github.com/arcnm/arcnm-examples)
128
+ * [LangChain tools](https://docs.langchain.com/oss/python/langchain/tools)
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,34 @@
1
+ """LangChain tools for ARCNM: manufacturing should-cost from a CAD file."""
2
+
3
+ from langchain_arcnm._client import ArcnmAPIError, ArcnmClient
4
+ from langchain_arcnm.toolkit import ArcnmToolkit
5
+ from langchain_arcnm.tools import (
6
+ ArcnmBaseTool,
7
+ CancelCalculationTool,
8
+ CheckQuotaTool,
9
+ GetCalculationTool,
10
+ GetCostSheetTool,
11
+ ListCalculationsTool,
12
+ ListEnvironmentsTool,
13
+ ListPartRevisionsTool,
14
+ ListPartsTool,
15
+ QuotePartTool,
16
+ UploadAndQuoteTool,
17
+ )
18
+
19
+ __all__ = [
20
+ "ArcnmAPIError",
21
+ "ArcnmBaseTool",
22
+ "ArcnmClient",
23
+ "ArcnmToolkit",
24
+ "CancelCalculationTool",
25
+ "CheckQuotaTool",
26
+ "GetCalculationTool",
27
+ "GetCostSheetTool",
28
+ "ListCalculationsTool",
29
+ "ListEnvironmentsTool",
30
+ "ListPartRevisionsTool",
31
+ "ListPartsTool",
32
+ "QuotePartTool",
33
+ "UploadAndQuoteTool",
34
+ ]
@@ -0,0 +1,244 @@
1
+ """HTTP calls to the ARCNM REST API. One small client, no LangChain imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+ from typing import IO, Any
8
+
9
+ import httpx
10
+
11
+ DEFAULT_BASE_URL = "https://api.arcnm.io"
12
+ API_PREFIX = "/api/v1"
13
+ USER_AGENT = "langchain-arcnm/0.1.0"
14
+
15
+ #: A calculation in one of these states will not change on its own. ``blocked``
16
+ #: means it was created but parked because the plan has no calculations left.
17
+ TERMINAL_STATUSES = frozenset({"succeeded", "failed", "cancelled", "timed_out", "blocked"})
18
+
19
+
20
+ class ArcnmAPIError(Exception):
21
+ """An ARCNM API call failed.
22
+
23
+ ``code`` is the stable ``error.code`` of the response; branch on it, never
24
+ on the message. ``request_id`` is what ARCNM support asks for.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ *,
31
+ status_code: int | None = None,
32
+ code: str | None = None,
33
+ request_id: str | None = None,
34
+ ) -> None:
35
+ super().__init__(message)
36
+ self.status_code = status_code
37
+ self.code = code
38
+ self.request_id = request_id
39
+
40
+
41
+ def normalize_base_url(value: str) -> str:
42
+ """The API origin, without a trailing slash or the ``/api/v1`` prefix."""
43
+ url = httpx.URL(value.strip())
44
+ if url.scheme not in ("http", "https") or not url.host:
45
+ raise ValueError("The ARCNM base URL must start with http:// or https://.")
46
+ if url.userinfo:
47
+ raise ValueError("Pass the API key as api_key, not inside the base URL.")
48
+ origin = str(url.copy_with(path="/", query=None, fragment=None)).rstrip("/")
49
+ return origin
50
+
51
+
52
+ def _failure(response: httpx.Response) -> ArcnmAPIError:
53
+ body: Any = None
54
+ if response.headers.get("content-type", "").startswith("application/json"):
55
+ try:
56
+ body = response.json()
57
+ except ValueError:
58
+ body = None
59
+ envelope = body.get("error") if isinstance(body, dict) else None
60
+ code = envelope.get("code") if isinstance(envelope, dict) else None
61
+ message = envelope.get("message") if isinstance(envelope, dict) else None
62
+ if message is None and isinstance(body, dict) and isinstance(body.get("detail"), str):
63
+ message = body["detail"]
64
+ request_id = body.get("request_id") if isinstance(body, dict) else None
65
+ text = message or response.reason_phrase or "request failed"
66
+ if code:
67
+ text = f"{code}: {text}"
68
+ if request_id:
69
+ text = f"{text} (request_id {request_id})"
70
+ return ArcnmAPIError(
71
+ f"ARCNM returned HTTP {response.status_code}. {text}",
72
+ status_code=response.status_code,
73
+ code=code if isinstance(code, str) else None,
74
+ request_id=request_id if isinstance(request_id, str) else None,
75
+ )
76
+
77
+
78
+ class ArcnmClient:
79
+ """A thin, synchronous client for the endpoints the tools call."""
80
+
81
+ def __init__(
82
+ self,
83
+ api_key: str,
84
+ *,
85
+ base_url: str = DEFAULT_BASE_URL,
86
+ org_slug: str | None = None,
87
+ timeout: float = 60.0,
88
+ transport: httpx.BaseTransport | None = None,
89
+ ) -> None:
90
+ if not api_key:
91
+ raise ValueError(
92
+ "No ARCNM API key. Set ARCNM_API_KEY, or pass api_key. "
93
+ "Create one in ARCNM under Settings, then API keys."
94
+ )
95
+ headers = {"X-API-Key": api_key, "Accept": "application/json", "User-Agent": USER_AGENT}
96
+ if org_slug:
97
+ headers["X-Org-Slug"] = org_slug
98
+ self._http = httpx.Client(
99
+ base_url=normalize_base_url(base_url) + API_PREFIX,
100
+ headers=headers,
101
+ timeout=timeout,
102
+ transport=transport,
103
+ )
104
+
105
+ def __enter__(self) -> ArcnmClient:
106
+ return self
107
+
108
+ def __exit__(self, *exc: object) -> None:
109
+ self.close()
110
+
111
+ def close(self) -> None:
112
+ self._http.close()
113
+
114
+ def request(
115
+ self,
116
+ method: str,
117
+ path: str,
118
+ *,
119
+ params: dict[str, Any] | None = None,
120
+ json: dict[str, Any] | None = None,
121
+ data: dict[str, Any] | None = None,
122
+ files: dict[str, tuple[str, IO[bytes]]] | None = None,
123
+ ) -> Any:
124
+ query = {k: v for k, v in (params or {}).items() if v is not None}
125
+ form = {k: str(v) for k, v in (data or {}).items() if v is not None}
126
+ try:
127
+ response = self._http.request(
128
+ method, path, params=query, json=json, data=form or None, files=files
129
+ )
130
+ except httpx.HTTPError as error:
131
+ raise ArcnmAPIError(f"Could not reach ARCNM: {error}") from error
132
+ if response.is_error:
133
+ raise _failure(response)
134
+ if not response.content:
135
+ return {}
136
+ return response.json()
137
+
138
+ # -- the calls ---------------------------------------------------------
139
+
140
+ def upload_and_quote(
141
+ self,
142
+ cad_path: Path,
143
+ *,
144
+ part_number: str,
145
+ drawing_path: Path | None = None,
146
+ lot_size: int | None = None,
147
+ annual_volume: int | None = None,
148
+ costing_environment_id: str | None = None,
149
+ ) -> dict[str, Any]:
150
+ fields = {
151
+ "part_number": part_number,
152
+ "lot_size": lot_size,
153
+ "annual_volume": annual_volume,
154
+ "costing_environment_id": costing_environment_id,
155
+ }
156
+ with cad_path.open("rb") as cad:
157
+ files: dict[str, tuple[str, IO[bytes]]] = {"cad_file": (cad_path.name, cad)}
158
+ if drawing_path is None:
159
+ return self.request( # type: ignore[no-any-return]
160
+ "POST", "/calculations/upload-and-quote", data=fields, files=files
161
+ )
162
+ with drawing_path.open("rb") as drawing:
163
+ files["drawing_file"] = (drawing_path.name, drawing)
164
+ return self.request( # type: ignore[no-any-return]
165
+ "POST", "/calculations/upload-and-quote", data=fields, files=files
166
+ )
167
+
168
+ def quote(
169
+ self,
170
+ part_revision_id: str,
171
+ *,
172
+ lot_size: int | None = None,
173
+ annual_volume: int | None = None,
174
+ costing_environment_id: str | None = None,
175
+ ) -> dict[str, Any]:
176
+ body = {
177
+ "part_revision_id": part_revision_id,
178
+ "lot_size": lot_size,
179
+ "annual_volume": annual_volume,
180
+ "costing_environment_id": costing_environment_id,
181
+ }
182
+ return self.request( # type: ignore[no-any-return]
183
+ "POST", "/calculations/quote", json={k: v for k, v in body.items() if v is not None}
184
+ )
185
+
186
+ def get_calculation(self, calculation_id: str) -> dict[str, Any]:
187
+ return self.request( # type: ignore[no-any-return]
188
+ "GET",
189
+ f"/calculations/{calculation_id}",
190
+ params={"include_analytics": "false"},
191
+ )
192
+
193
+ def wait_for_calculation(
194
+ self, calculation: dict[str, Any], *, timeout: float, poll_interval: float
195
+ ) -> dict[str, Any]:
196
+ """Poll until the calculation is terminal or ``timeout`` seconds pass."""
197
+ deadline = time.monotonic() + timeout
198
+ while not is_terminal(calculation) and time.monotonic() < deadline:
199
+ time.sleep(min(poll_interval, max(0.0, deadline - time.monotonic())))
200
+ calculation = self.get_calculation(str(calculation["id"]))
201
+ return calculation
202
+
203
+ def list_calculations(
204
+ self, *, status: str | None = None, part_id: str | None = None, limit: int = 10
205
+ ) -> dict[str, Any]:
206
+ return self.request( # type: ignore[no-any-return]
207
+ "GET",
208
+ "/calculations",
209
+ params={"status_filter": status, "part_id": part_id, "limit": limit},
210
+ )
211
+
212
+ def get_cost_sheet(self, calculation_id: str, *, quantity: int | None = None) -> dict[str, Any]:
213
+ return self.request( # type: ignore[no-any-return]
214
+ "GET", f"/calculations/{calculation_id}/cost-sheet", params={"quantity": quantity}
215
+ )
216
+
217
+ def cancel_calculation(self, calculation_id: str) -> dict[str, Any]:
218
+ return self.request( # type: ignore[no-any-return]
219
+ "POST", f"/calculations/{calculation_id}/cancel"
220
+ )
221
+
222
+ def list_environments(self, *, limit: int = 20) -> list[dict[str, Any]]:
223
+ return self.request( # type: ignore[no-any-return]
224
+ "GET", "/environments/", params={"limit": limit}
225
+ )
226
+
227
+ def get_entitlement(self) -> dict[str, Any]:
228
+ return self.request("GET", "/calculations/entitlement") # type: ignore[no-any-return]
229
+
230
+ def list_parts(self, *, query: str | None = None, limit: int = 10) -> dict[str, Any]:
231
+ return self.request( # type: ignore[no-any-return]
232
+ "GET", "/parts/", params={"q": query, "limit": limit}
233
+ )
234
+
235
+ def list_part_revisions(self, part_id: str, *, limit: int = 10) -> dict[str, Any]:
236
+ return self.request( # type: ignore[no-any-return]
237
+ "GET", f"/parts/{part_id}/revisions", params={"limit": limit, "order": "desc"}
238
+ )
239
+
240
+
241
+ def is_terminal(calculation: dict[str, Any]) -> bool:
242
+ if calculation.get("terminal") is True:
243
+ return True
244
+ return calculation.get("status") in TERMINAL_STATUSES
File without changes