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.
- langchain_arcnm-0.1.0/.gitignore +11 -0
- langchain_arcnm-0.1.0/CHANGELOG.md +8 -0
- langchain_arcnm-0.1.0/LICENSE +21 -0
- langchain_arcnm-0.1.0/PKG-INFO +155 -0
- langchain_arcnm-0.1.0/README.md +132 -0
- langchain_arcnm-0.1.0/langchain_arcnm/__init__.py +34 -0
- langchain_arcnm-0.1.0/langchain_arcnm/_client.py +244 -0
- langchain_arcnm-0.1.0/langchain_arcnm/py.typed +0 -0
- langchain_arcnm-0.1.0/langchain_arcnm/toolkit.py +90 -0
- langchain_arcnm-0.1.0/langchain_arcnm/tools.py +483 -0
- langchain_arcnm-0.1.0/pyproject.toml +64 -0
- langchain_arcnm-0.1.0/tests/__init__.py +0 -0
- langchain_arcnm-0.1.0/tests/integration_tests/__init__.py +0 -0
- langchain_arcnm-0.1.0/tests/integration_tests/shaft_thread.step +929 -0
- langchain_arcnm-0.1.0/tests/integration_tests/test_standard.py +97 -0
- langchain_arcnm-0.1.0/tests/unit_tests/__init__.py +0 -0
- langchain_arcnm-0.1.0/tests/unit_tests/test_standard.py +97 -0
- langchain_arcnm-0.1.0/tests/unit_tests/test_tools.py +229 -0
|
@@ -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
|