millwork-solver 0.1.0__tar.gz → 0.1.1__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.
- millwork_solver-0.1.1/PKG-INFO +154 -0
- millwork_solver-0.1.1/README.md +135 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/pyproject.toml +1 -1
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/__init__.py +6 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/_contract.json +47 -2
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/_core/catalog.py +7 -7
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/_core/clients.py +34 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/client.py +4 -1
- millwork_solver-0.1.1/src/millwork_solver/verifier_connection.py +279 -0
- millwork_solver-0.1.1/src/millwork_solver.egg-info/PKG-INFO +154 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver.egg-info/SOURCES.txt +1 -0
- millwork_solver-0.1.0/PKG-INFO +0 -32
- millwork_solver-0.1.0/README.md +0 -13
- millwork_solver-0.1.0/src/millwork_solver.egg-info/PKG-INFO +0 -32
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/LICENSE +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/MANIFEST.in +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/setup.cfg +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/_core/__init__.py +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/_core/boundary.py +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/_core/errors.py +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/_core/transport.py +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/errors.py +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/py.typed +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver/types.py +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver.egg-info/dependency_links.txt +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver.egg-info/requires.txt +0 -0
- {millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: millwork-solver
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Sync and async Python clients for the Millwork Solver API
|
|
5
|
+
Author: Millwork
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Repository, https://github.com/millworkdev/solver-python
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Python: <3.15,>=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: httpx==0.28.1
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# Millwork Solver for Python
|
|
21
|
+
|
|
22
|
+
`millwork-solver` is an early preview client for the Millwork Solver API. Import
|
|
23
|
+
`millwork_solver` to use the synchronous `Solver` client or the asynchronous
|
|
24
|
+
`AsyncSolver` client on CPython 3.11 through 3.14.
|
|
25
|
+
|
|
26
|
+
The [generated REST lifecycle reference](https://docs.getmillwork.dev/api-reference/output-checks)
|
|
27
|
+
owns output-check operation IDs and fields. The
|
|
28
|
+
[verifier-dock contract](https://docs.getmillwork.dev/contracts/verifier-dock/v1.json)
|
|
29
|
+
owns the endpoint request and response. Follow
|
|
30
|
+
[Recipe 0](https://docs.getmillwork.dev/cookbook/output-checks/build-the-dock)
|
|
31
|
+
for local tests, deployment, and a receipt.
|
|
32
|
+
|
|
33
|
+
Pass `api_key` and `base_url` directly, or use `SOLVERAPI_API_KEY` and
|
|
34
|
+
`SOLVERAPI_BASE_URL`. Retry settings use `SOLVERAPI_MAX_RETRIES` (default `2`)
|
|
35
|
+
and `SOLVERAPI_RETRY_BACKOFF_MS` (default `500`).
|
|
36
|
+
|
|
37
|
+
Both clients provide the same typed operation surface, explicit timeout and
|
|
38
|
+
close behavior, and receipt-aware responses. Echo reaches a terminal status and
|
|
39
|
+
returns a receipt; it does not return result content.
|
|
40
|
+
|
|
41
|
+
Use the API base URL with its `/v1` suffix:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export SOLVERAPI_BASE_URL="https://api.getmillwork.dev/v1"
|
|
45
|
+
export SOLVERAPI_API_KEY="<organization API key>"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Call an operation by its OpenAPI `operation_id`. This complete test run uses
|
|
49
|
+
`postExecutions`; the request must include both `task` and `policy`:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import uuid
|
|
53
|
+
|
|
54
|
+
from millwork_solver import Solver
|
|
55
|
+
|
|
56
|
+
with Solver() as client:
|
|
57
|
+
run = client.request(
|
|
58
|
+
"postExecutions",
|
|
59
|
+
body={
|
|
60
|
+
"mode": "echo",
|
|
61
|
+
"task": {"objective": "Check the Millwork API lifecycle."},
|
|
62
|
+
"policy": {
|
|
63
|
+
"data_classes": ["public"],
|
|
64
|
+
"budget": {"max_cost_usd": 1, "max_runtime_s": 30},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
idempotency_key=str(uuid.uuid4()),
|
|
68
|
+
)
|
|
69
|
+
receipt = client.request(
|
|
70
|
+
"getReceiptsByExecutionId",
|
|
71
|
+
path_parameters={"executionId": run["execution_id"]},
|
|
72
|
+
)
|
|
73
|
+
print(run["status"], receipt["mode"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Common operation IDs include `getAccount`, `getArms`, `getModelCatalog`,
|
|
77
|
+
`getVerifiers`, `postExecutions`, `getExecutionsByExecutionId`,
|
|
78
|
+
`getExecutionsByExecutionIdResult`, and `getReceiptsByExecutionId`. The package
|
|
79
|
+
ships the complete operation contract in `millwork_solver/_contract.json`.
|
|
80
|
+
|
|
81
|
+
## Verifier lifecycle
|
|
82
|
+
|
|
83
|
+
The installed client can register and test a public output check. Set
|
|
84
|
+
`VERIFIER_URL` to an endpoint implementing the
|
|
85
|
+
[output-check contract](https://docs.getmillwork.dev/cookbook/output-checks/build-the-dock#the-endpoint-contract):
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import os
|
|
89
|
+
import uuid
|
|
90
|
+
from millwork_solver import Solver
|
|
91
|
+
|
|
92
|
+
with Solver() as client:
|
|
93
|
+
registered = client.request("postVerifiers", body={
|
|
94
|
+
"display_name": "My output check", "version": "1.0.0", "kind": "endpoint",
|
|
95
|
+
"endpoint": {"url": os.environ["VERIFIER_URL"], "auth_ref": ""},
|
|
96
|
+
"input_data_classes": ["public"],
|
|
97
|
+
"scoring": {"correctness": "boolean_anchors", "quality": "scalar_0_1"},
|
|
98
|
+
}, idempotency_key=str(uuid.uuid4()))
|
|
99
|
+
verifier_id = registered["verifier_id"]
|
|
100
|
+
print(verifier_id, client.request("postVerifiersByVerifierIdTest",
|
|
101
|
+
path_parameters={"verifierId": verifier_id}, idempotency_key=str(uuid.uuid4())))
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The protected-connection methods below describe this source checkout. The
|
|
105
|
+
published `millwork-solver==0.1.0` wheel has six public verifier operation
|
|
106
|
+
IDs in its catalog but does not include `verifier_connection`. Until a wheel carrying
|
|
107
|
+
these methods is published and read back, use the
|
|
108
|
+
[private CLI or dashboard path](https://docs.getmillwork.dev/guides/connect-an-output-check#connect-a-protected-endpoint)
|
|
109
|
+
for a protected endpoint.
|
|
110
|
+
|
|
111
|
+
For a protected endpoint, register that endpoint with the same secretless
|
|
112
|
+
`postVerifiers` body, then use **its** `verifier_id` for the private handoff.
|
|
113
|
+
The REST registration has no `access` field; the protected connection is
|
|
114
|
+
established by `verifier_connection`. Give `continue_url` only to the intended
|
|
115
|
+
person through your host's private browser handoff. The callback below belongs
|
|
116
|
+
to that host and must not log or expose the URL. The endpoint key is never an
|
|
117
|
+
argument to these methods.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import uuid
|
|
121
|
+
from urllib.parse import urlsplit
|
|
122
|
+
|
|
123
|
+
def start_protected(client, verifier_id, trusted_app_origin, open_private_url):
|
|
124
|
+
intent = client.verifier_connection.create_intent(
|
|
125
|
+
verifier_id, {"kind": "preset_days", "days": 90},
|
|
126
|
+
idempotency_key=str(uuid.uuid4()))
|
|
127
|
+
expected = urlsplit(trusted_app_origin)
|
|
128
|
+
actual = urlsplit(intent["continue_url"])
|
|
129
|
+
if (actual.scheme, actual.netloc) != (expected.scheme, expected.netloc):
|
|
130
|
+
raise ValueError("Unexpected private-entry origin")
|
|
131
|
+
open_private_url(intent["continue_url"])
|
|
132
|
+
return intent["intent_id"]
|
|
133
|
+
|
|
134
|
+
def continue_protected(client, verifier_id, operation_key):
|
|
135
|
+
return client.verifier_connection.continue_connection(
|
|
136
|
+
verifier_id, operation_key=operation_key,
|
|
137
|
+
idempotency_key=str(uuid.uuid4()))
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`enter_key` means private entry is still needed. `resume` means retain the same
|
|
141
|
+
operation key and use a fresh request idempotency key. `start_again` means the
|
|
142
|
+
endpoint test failed; correct the endpoint or key and create a new intent.
|
|
143
|
+
`inspect` means another connection change intervened. `refused` means the server
|
|
144
|
+
rejected the change with a non-retryable client error; read `outcome.error["status"]`
|
|
145
|
+
and `outcome.error["title"]`, inspect that operation, and correct the cause before
|
|
146
|
+
another action. A `401` or `403` raises a `ProblemError` instead of returning a
|
|
147
|
+
lifecycle outcome. Only a read-backed `done`
|
|
148
|
+
confirms completion. The async `AsyncSolver.verifier_connection` has the same
|
|
149
|
+
methods, awaited. Replacement and restoration use a new intent and the same
|
|
150
|
+
private-entry path. Disconnect requires explicit caller confirmation:
|
|
151
|
+
`client.verifier_connection.disconnect(verifier_id, operation_key=...,
|
|
152
|
+
idempotency_key=..., confirmed=True)`. Millwork stopping use of a key does not
|
|
153
|
+
revoke it at your endpoint. A governed run requires separate authorization and
|
|
154
|
+
uses this `verifier_id`.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Millwork Solver for Python
|
|
2
|
+
|
|
3
|
+
`millwork-solver` is an early preview client for the Millwork Solver API. Import
|
|
4
|
+
`millwork_solver` to use the synchronous `Solver` client or the asynchronous
|
|
5
|
+
`AsyncSolver` client on CPython 3.11 through 3.14.
|
|
6
|
+
|
|
7
|
+
The [generated REST lifecycle reference](https://docs.getmillwork.dev/api-reference/output-checks)
|
|
8
|
+
owns output-check operation IDs and fields. The
|
|
9
|
+
[verifier-dock contract](https://docs.getmillwork.dev/contracts/verifier-dock/v1.json)
|
|
10
|
+
owns the endpoint request and response. Follow
|
|
11
|
+
[Recipe 0](https://docs.getmillwork.dev/cookbook/output-checks/build-the-dock)
|
|
12
|
+
for local tests, deployment, and a receipt.
|
|
13
|
+
|
|
14
|
+
Pass `api_key` and `base_url` directly, or use `SOLVERAPI_API_KEY` and
|
|
15
|
+
`SOLVERAPI_BASE_URL`. Retry settings use `SOLVERAPI_MAX_RETRIES` (default `2`)
|
|
16
|
+
and `SOLVERAPI_RETRY_BACKOFF_MS` (default `500`).
|
|
17
|
+
|
|
18
|
+
Both clients provide the same typed operation surface, explicit timeout and
|
|
19
|
+
close behavior, and receipt-aware responses. Echo reaches a terminal status and
|
|
20
|
+
returns a receipt; it does not return result content.
|
|
21
|
+
|
|
22
|
+
Use the API base URL with its `/v1` suffix:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
export SOLVERAPI_BASE_URL="https://api.getmillwork.dev/v1"
|
|
26
|
+
export SOLVERAPI_API_KEY="<organization API key>"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Call an operation by its OpenAPI `operation_id`. This complete test run uses
|
|
30
|
+
`postExecutions`; the request must include both `task` and `policy`:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import uuid
|
|
34
|
+
|
|
35
|
+
from millwork_solver import Solver
|
|
36
|
+
|
|
37
|
+
with Solver() as client:
|
|
38
|
+
run = client.request(
|
|
39
|
+
"postExecutions",
|
|
40
|
+
body={
|
|
41
|
+
"mode": "echo",
|
|
42
|
+
"task": {"objective": "Check the Millwork API lifecycle."},
|
|
43
|
+
"policy": {
|
|
44
|
+
"data_classes": ["public"],
|
|
45
|
+
"budget": {"max_cost_usd": 1, "max_runtime_s": 30},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
idempotency_key=str(uuid.uuid4()),
|
|
49
|
+
)
|
|
50
|
+
receipt = client.request(
|
|
51
|
+
"getReceiptsByExecutionId",
|
|
52
|
+
path_parameters={"executionId": run["execution_id"]},
|
|
53
|
+
)
|
|
54
|
+
print(run["status"], receipt["mode"])
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Common operation IDs include `getAccount`, `getArms`, `getModelCatalog`,
|
|
58
|
+
`getVerifiers`, `postExecutions`, `getExecutionsByExecutionId`,
|
|
59
|
+
`getExecutionsByExecutionIdResult`, and `getReceiptsByExecutionId`. The package
|
|
60
|
+
ships the complete operation contract in `millwork_solver/_contract.json`.
|
|
61
|
+
|
|
62
|
+
## Verifier lifecycle
|
|
63
|
+
|
|
64
|
+
The installed client can register and test a public output check. Set
|
|
65
|
+
`VERIFIER_URL` to an endpoint implementing the
|
|
66
|
+
[output-check contract](https://docs.getmillwork.dev/cookbook/output-checks/build-the-dock#the-endpoint-contract):
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import os
|
|
70
|
+
import uuid
|
|
71
|
+
from millwork_solver import Solver
|
|
72
|
+
|
|
73
|
+
with Solver() as client:
|
|
74
|
+
registered = client.request("postVerifiers", body={
|
|
75
|
+
"display_name": "My output check", "version": "1.0.0", "kind": "endpoint",
|
|
76
|
+
"endpoint": {"url": os.environ["VERIFIER_URL"], "auth_ref": ""},
|
|
77
|
+
"input_data_classes": ["public"],
|
|
78
|
+
"scoring": {"correctness": "boolean_anchors", "quality": "scalar_0_1"},
|
|
79
|
+
}, idempotency_key=str(uuid.uuid4()))
|
|
80
|
+
verifier_id = registered["verifier_id"]
|
|
81
|
+
print(verifier_id, client.request("postVerifiersByVerifierIdTest",
|
|
82
|
+
path_parameters={"verifierId": verifier_id}, idempotency_key=str(uuid.uuid4())))
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The protected-connection methods below describe this source checkout. The
|
|
86
|
+
published `millwork-solver==0.1.0` wheel has six public verifier operation
|
|
87
|
+
IDs in its catalog but does not include `verifier_connection`. Until a wheel carrying
|
|
88
|
+
these methods is published and read back, use the
|
|
89
|
+
[private CLI or dashboard path](https://docs.getmillwork.dev/guides/connect-an-output-check#connect-a-protected-endpoint)
|
|
90
|
+
for a protected endpoint.
|
|
91
|
+
|
|
92
|
+
For a protected endpoint, register that endpoint with the same secretless
|
|
93
|
+
`postVerifiers` body, then use **its** `verifier_id` for the private handoff.
|
|
94
|
+
The REST registration has no `access` field; the protected connection is
|
|
95
|
+
established by `verifier_connection`. Give `continue_url` only to the intended
|
|
96
|
+
person through your host's private browser handoff. The callback below belongs
|
|
97
|
+
to that host and must not log or expose the URL. The endpoint key is never an
|
|
98
|
+
argument to these methods.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import uuid
|
|
102
|
+
from urllib.parse import urlsplit
|
|
103
|
+
|
|
104
|
+
def start_protected(client, verifier_id, trusted_app_origin, open_private_url):
|
|
105
|
+
intent = client.verifier_connection.create_intent(
|
|
106
|
+
verifier_id, {"kind": "preset_days", "days": 90},
|
|
107
|
+
idempotency_key=str(uuid.uuid4()))
|
|
108
|
+
expected = urlsplit(trusted_app_origin)
|
|
109
|
+
actual = urlsplit(intent["continue_url"])
|
|
110
|
+
if (actual.scheme, actual.netloc) != (expected.scheme, expected.netloc):
|
|
111
|
+
raise ValueError("Unexpected private-entry origin")
|
|
112
|
+
open_private_url(intent["continue_url"])
|
|
113
|
+
return intent["intent_id"]
|
|
114
|
+
|
|
115
|
+
def continue_protected(client, verifier_id, operation_key):
|
|
116
|
+
return client.verifier_connection.continue_connection(
|
|
117
|
+
verifier_id, operation_key=operation_key,
|
|
118
|
+
idempotency_key=str(uuid.uuid4()))
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`enter_key` means private entry is still needed. `resume` means retain the same
|
|
122
|
+
operation key and use a fresh request idempotency key. `start_again` means the
|
|
123
|
+
endpoint test failed; correct the endpoint or key and create a new intent.
|
|
124
|
+
`inspect` means another connection change intervened. `refused` means the server
|
|
125
|
+
rejected the change with a non-retryable client error; read `outcome.error["status"]`
|
|
126
|
+
and `outcome.error["title"]`, inspect that operation, and correct the cause before
|
|
127
|
+
another action. A `401` or `403` raises a `ProblemError` instead of returning a
|
|
128
|
+
lifecycle outcome. Only a read-backed `done`
|
|
129
|
+
confirms completion. The async `AsyncSolver.verifier_connection` has the same
|
|
130
|
+
methods, awaited. Replacement and restoration use a new intent and the same
|
|
131
|
+
private-entry path. Disconnect requires explicit caller confirmation:
|
|
132
|
+
`client.verifier_connection.disconnect(verifier_id, operation_key=...,
|
|
133
|
+
idempotency_key=..., confirmed=True)`. Millwork stopping use of a key does not
|
|
134
|
+
revoke it at your endpoint. A governed run requires separate authorization and
|
|
135
|
+
uses this `verifier_id`.
|
|
@@ -10,6 +10,7 @@ from .errors import (
|
|
|
10
10
|
ProtocolError,
|
|
11
11
|
)
|
|
12
12
|
from .types import Page
|
|
13
|
+
from .verifier_connection import AsyncVerifierConnection, ConnectionIntent, LifecycleOutcome, StopChoice, VerifierConnection
|
|
13
14
|
|
|
14
15
|
__all__ = (
|
|
15
16
|
"AsyncSolver",
|
|
@@ -21,4 +22,9 @@ __all__ = (
|
|
|
21
22
|
"ProblemError",
|
|
22
23
|
"ProtocolError",
|
|
23
24
|
"Solver",
|
|
25
|
+
"VerifierConnection",
|
|
26
|
+
"AsyncVerifierConnection",
|
|
27
|
+
"ConnectionIntent",
|
|
28
|
+
"LifecycleOutcome",
|
|
29
|
+
"StopChoice",
|
|
24
30
|
)
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"schema_version": 1,
|
|
3
3
|
"generated_from": {
|
|
4
4
|
"python_operation_view": "contracts/developer-experience/python-operation-view.json",
|
|
5
|
-
"operation_manifest_version": "1.
|
|
6
|
-
"openapi_sha256": "
|
|
5
|
+
"operation_manifest_version": "1.5.0",
|
|
6
|
+
"openapi_sha256": "98d1b8a882c20b3916fbb4a09d2c88f117ce3b8b922089789b80af68acb23857",
|
|
7
7
|
"retry_fixture": "contracts/developer-experience/fixtures/retry-error-idempotency.json"
|
|
8
8
|
},
|
|
9
9
|
"operations": [
|
|
@@ -137,6 +137,26 @@
|
|
|
137
137
|
"method": "GET",
|
|
138
138
|
"path": "/v1/source-credential-handoffs/{handoffIntentId}"
|
|
139
139
|
},
|
|
140
|
+
{
|
|
141
|
+
"operation_id": "getTenantModelSelection",
|
|
142
|
+
"method": "GET",
|
|
143
|
+
"path": "/v1/tenant-model-selection"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"operation_id": "getTenantTemplateApplications",
|
|
147
|
+
"method": "GET",
|
|
148
|
+
"path": "/v1/tenant-template-applications"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"operation_id": "getTenantTemplateApplicationsByApplicationId",
|
|
152
|
+
"method": "GET",
|
|
153
|
+
"path": "/v1/tenant-template-applications/{applicationId}"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"operation_id": "getTenantTemplates",
|
|
157
|
+
"method": "GET",
|
|
158
|
+
"path": "/v1/tenant-templates"
|
|
159
|
+
},
|
|
140
160
|
{
|
|
141
161
|
"operation_id": "getUsage",
|
|
142
162
|
"method": "GET",
|
|
@@ -152,6 +172,11 @@
|
|
|
152
172
|
"method": "GET",
|
|
153
173
|
"path": "/v1/verifiers/{verifierId}"
|
|
154
174
|
},
|
|
175
|
+
{
|
|
176
|
+
"operation_id": "getVerifiersByVerifierIdConnection",
|
|
177
|
+
"method": "GET",
|
|
178
|
+
"path": "/v1/verifiers/{verifierId}/connection"
|
|
179
|
+
},
|
|
155
180
|
{
|
|
156
181
|
"operation_id": "getWebhooks",
|
|
157
182
|
"method": "GET",
|
|
@@ -247,11 +272,31 @@
|
|
|
247
272
|
"method": "POST",
|
|
248
273
|
"path": "/v1/source-credential-handoffs"
|
|
249
274
|
},
|
|
275
|
+
{
|
|
276
|
+
"operation_id": "postTenantModelSelection",
|
|
277
|
+
"method": "POST",
|
|
278
|
+
"path": "/v1/tenant-model-selection"
|
|
279
|
+
},
|
|
250
280
|
{
|
|
251
281
|
"operation_id": "postTenants",
|
|
252
282
|
"method": "POST",
|
|
253
283
|
"path": "/v1/tenants"
|
|
254
284
|
},
|
|
285
|
+
{
|
|
286
|
+
"operation_id": "postTenantTemplateApplications",
|
|
287
|
+
"method": "POST",
|
|
288
|
+
"path": "/v1/tenant-template-applications"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"operation_id": "postTenantTemplateApplicationsByApplicationIdResume",
|
|
292
|
+
"method": "POST",
|
|
293
|
+
"path": "/v1/tenant-template-applications/{applicationId}/resume"
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"operation_id": "postTenantTemplatePlans",
|
|
297
|
+
"method": "POST",
|
|
298
|
+
"path": "/v1/tenant-template-plans"
|
|
299
|
+
},
|
|
255
300
|
{
|
|
256
301
|
"operation_id": "postVerifiers",
|
|
257
302
|
"method": "POST",
|
|
@@ -89,8 +89,8 @@ class _OperationCatalog:
|
|
|
89
89
|
sources = view.get("sources")
|
|
90
90
|
expected_counts = view.get("expected_counts")
|
|
91
91
|
if not isinstance(sources, dict) or expected_counts != {
|
|
92
|
-
"operations":
|
|
93
|
-
"included":
|
|
92
|
+
"operations": 79,
|
|
93
|
+
"included": 63,
|
|
94
94
|
"excluded": 16,
|
|
95
95
|
}:
|
|
96
96
|
raise _OperationBoundaryError("PY1 pointer/count contract drifted")
|
|
@@ -130,10 +130,10 @@ class _OperationCatalog:
|
|
|
130
130
|
for operation_id, classification in classification_by_id.items()
|
|
131
131
|
if classification.get("disposition") == "exclude"
|
|
132
132
|
}
|
|
133
|
-
if len(wire_operations) !=
|
|
134
|
-
raise _OperationBoundaryError("F1
|
|
133
|
+
if len(wire_operations) != 79 or len(f1_included) != 63 or len(f1_excluded) != 16:
|
|
134
|
+
raise _OperationBoundaryError("F1 62/16 operation partition drifted")
|
|
135
135
|
if included_set != f1_included or len(included_ids) != len(included_set):
|
|
136
|
-
raise _OperationBoundaryError("PY1 pointer does not resolve exactly to F1's
|
|
136
|
+
raise _OperationBoundaryError("PY1 pointer does not resolve exactly to F1's 62 includes")
|
|
137
137
|
if set(classification_by_id) != set(wire_operations):
|
|
138
138
|
raise _OperationBoundaryError("F1 classifications do not cover OpenAPI exactly")
|
|
139
139
|
|
|
@@ -162,8 +162,8 @@ class _OperationCatalog:
|
|
|
162
162
|
if snapshot.get("schema_version") != 1:
|
|
163
163
|
raise _OperationBoundaryError("installed Python wire snapshot version drifted")
|
|
164
164
|
operations = snapshot.get("operations")
|
|
165
|
-
if not isinstance(operations, list) or len(operations) !=
|
|
166
|
-
raise _OperationBoundaryError("installed Python wire snapshot must contain
|
|
165
|
+
if not isinstance(operations, list) or len(operations) != 63:
|
|
166
|
+
raise _OperationBoundaryError("installed Python wire snapshot must contain 62 operations")
|
|
167
167
|
|
|
168
168
|
allowed: dict[str, _Operation] = {}
|
|
169
169
|
for item in operations:
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import AsyncIterator, Iterator, Mapping
|
|
6
6
|
from typing import Any, Awaitable, Callable
|
|
7
|
+
from urllib.parse import quote
|
|
7
8
|
|
|
8
9
|
from ..types import Page
|
|
9
10
|
from .catalog import _OperationCatalog
|
|
@@ -38,6 +39,27 @@ def _is_terminal_event(event: Mapping[str, Any]) -> bool:
|
|
|
38
39
|
)
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
_CONNECTION_ACTIONS = {
|
|
43
|
+
"intent": ("POST", "connection-intents"),
|
|
44
|
+
"inspect": ("GET", "connection"),
|
|
45
|
+
"promote": ("POST", "connection/test"),
|
|
46
|
+
"revoke": ("POST", "connection/revoke"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _connection_request(action: str, verifier_id: str, *, query: Mapping[str, object] | None,
|
|
51
|
+
body: object | None, idempotency_key: str | None,
|
|
52
|
+
policy: _TransportPolicy):
|
|
53
|
+
if action not in _CONNECTION_ACTIONS or not verifier_id:
|
|
54
|
+
raise ValueError("unknown verifier connection action or empty verifier ID")
|
|
55
|
+
method, suffix = _CONNECTION_ACTIONS[action]
|
|
56
|
+
return _prepare_request(
|
|
57
|
+
operation_id=f"verifierConnection.{action}", method=method,
|
|
58
|
+
path=f"/v1/verifiers/{quote(verifier_id, safe='')}/{suffix}",
|
|
59
|
+
query=query, body=body, idempotency_key=idempotency_key, policy=policy,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
41
63
|
class _SyncClient:
|
|
42
64
|
def __init__(
|
|
43
65
|
self,
|
|
@@ -59,6 +81,12 @@ class _SyncClient:
|
|
|
59
81
|
if self._closed:
|
|
60
82
|
raise _ClientClosedError("client is closed")
|
|
61
83
|
|
|
84
|
+
def _verifier_connection(self, action: str, verifier_id: str, *, query: Mapping[str, object] | None = None,
|
|
85
|
+
body: object | None = None, idempotency_key: str | None = None) -> Any:
|
|
86
|
+
self._ensure_open()
|
|
87
|
+
return self._transport.execute(_connection_request(action, verifier_id, query=query, body=body,
|
|
88
|
+
idempotency_key=idempotency_key, policy=self._policy))
|
|
89
|
+
|
|
62
90
|
def _resolve_fixture_request(self, method: str, path: str) -> tuple[str, Mapping[str, str]]:
|
|
63
91
|
return self._catalog.match_fixture_request(method, path)
|
|
64
92
|
|
|
@@ -172,6 +200,12 @@ class _AsyncClient:
|
|
|
172
200
|
if self._closed:
|
|
173
201
|
raise _ClientClosedError("client is closed")
|
|
174
202
|
|
|
203
|
+
async def _verifier_connection(self, action: str, verifier_id: str, *, query: Mapping[str, object] | None = None,
|
|
204
|
+
body: object | None = None, idempotency_key: str | None = None) -> Any:
|
|
205
|
+
self._ensure_open()
|
|
206
|
+
return await self._transport.execute(_connection_request(action, verifier_id, query=query, body=body,
|
|
207
|
+
idempotency_key=idempotency_key, policy=self._policy))
|
|
208
|
+
|
|
175
209
|
def _resolve_fixture_request(self, method: str, path: str) -> tuple[str, Mapping[str, str]]:
|
|
176
210
|
return self._catalog.match_fixture_request(method, path)
|
|
177
211
|
|
|
@@ -13,6 +13,7 @@ import httpx
|
|
|
13
13
|
from ._core.boundary import _create_async_client, _create_sync_client
|
|
14
14
|
from ._core.clients import _AsyncClient, _SyncClient
|
|
15
15
|
from .types import Page
|
|
16
|
+
from .verifier_connection import AsyncVerifierConnection, VerifierConnection
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def _required_setting(explicit: str | None, environment_name: str) -> str:
|
|
@@ -68,7 +69,7 @@ def _snapshot_resource():
|
|
|
68
69
|
|
|
69
70
|
|
|
70
71
|
class Solver:
|
|
71
|
-
"""Synchronous client for the signed
|
|
72
|
+
"""Synchronous client for the signed operation catalog and protected verifier lifecycle."""
|
|
72
73
|
|
|
73
74
|
def __init__(
|
|
74
75
|
self,
|
|
@@ -101,6 +102,7 @@ class Solver:
|
|
|
101
102
|
except BaseException:
|
|
102
103
|
self._snapshot_context.__exit__(None, None, None)
|
|
103
104
|
raise
|
|
105
|
+
self.verifier_connection = VerifierConnection(self._client)
|
|
104
106
|
self._closed = False
|
|
105
107
|
|
|
106
108
|
def request(
|
|
@@ -193,6 +195,7 @@ class AsyncSolver:
|
|
|
193
195
|
except BaseException:
|
|
194
196
|
self._snapshot_context.__exit__(None, None, None)
|
|
195
197
|
raise
|
|
198
|
+
self.verifier_connection = AsyncVerifierConnection(self._client)
|
|
196
199
|
self._closed = False
|
|
197
200
|
|
|
198
201
|
async def request(
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Secretless verifier connection handoff and read-backed lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal, TypedDict
|
|
8
|
+
|
|
9
|
+
from ._core.clients import _AsyncClient, _SyncClient
|
|
10
|
+
from .errors import NetworkError, ProblemError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NoExpiration(TypedDict):
|
|
14
|
+
kind: Literal["no_expiration"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PresetDays(TypedDict):
|
|
18
|
+
kind: Literal["preset_days"]
|
|
19
|
+
days: Literal[30, 90, 180, 365]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CalendarDate(TypedDict):
|
|
23
|
+
kind: Literal["calendar_date"]
|
|
24
|
+
date: str
|
|
25
|
+
time_zone: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
StopChoice = NoExpiration | PresetDays | CalendarDate
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConnectionIntent(TypedDict):
|
|
32
|
+
continue_url: str
|
|
33
|
+
intake_origin: str
|
|
34
|
+
intent_id: str
|
|
35
|
+
origin: str
|
|
36
|
+
expires_at: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _checked_stop_choice(choice: StopChoice) -> StopChoice:
|
|
40
|
+
if not isinstance(choice, dict):
|
|
41
|
+
raise TypeError("invalid verifier stop choice")
|
|
42
|
+
kind = choice.get("kind")
|
|
43
|
+
if kind == "no_expiration" and set(choice) == {"kind"}:
|
|
44
|
+
return {"kind": kind}
|
|
45
|
+
if kind == "preset_days" and set(choice) == {"kind", "days"} and type(choice["days"]) is int and choice["days"] in {30, 90, 180, 365}:
|
|
46
|
+
return {"kind": kind, "days": choice["days"]}
|
|
47
|
+
if kind == "calendar_date" and set(choice) == {"kind", "date", "time_zone"}:
|
|
48
|
+
date, time_zone = choice["date"], choice["time_zone"]
|
|
49
|
+
if isinstance(date, str) and len(date) == 10 and isinstance(time_zone, str) and time_zone:
|
|
50
|
+
return {"kind": kind, "date": date, "time_zone": time_zone}
|
|
51
|
+
raise ValueError("invalid verifier stop choice")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class LifecycleOutcome:
|
|
56
|
+
state: Literal["action_required", "pending", "unknown", "test_failed", "changed", "nothing_pending", "refused", "done"]
|
|
57
|
+
next_action: Literal["enter_key", "resume", "start_again", "inspect"] | None
|
|
58
|
+
connection: Mapping[str, Any] | None
|
|
59
|
+
error: Mapping[str, Any] | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _refusal(error: Exception, connection: Mapping[str, Any] | None) -> LifecycleOutcome | None:
|
|
63
|
+
if isinstance(error, ProblemError):
|
|
64
|
+
if error.status in {401, 403}:
|
|
65
|
+
raise error
|
|
66
|
+
if error.status < 500 and error.status not in {408, 429}:
|
|
67
|
+
return LifecycleOutcome("refused", "inspect", connection,
|
|
68
|
+
{"status": error.status, "title": error.title})
|
|
69
|
+
return None
|
|
70
|
+
if isinstance(error, NetworkError):
|
|
71
|
+
return None
|
|
72
|
+
raise error
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _operation(view: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
76
|
+
value = view.get("last_operation")
|
|
77
|
+
return value if isinstance(value, dict) and value.get("status") != "unknown" else {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _staged_handle(view: Mapping[str, Any]) -> str | None:
|
|
81
|
+
operation = _operation(view)
|
|
82
|
+
state = operation.get("resulting_state")
|
|
83
|
+
handle = state.get("handle") if isinstance(state, dict) and operation.get("kind") == "stage" else None
|
|
84
|
+
return handle if isinstance(handle, str) else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _promotion_result(view: Mapping[str, Any], handle: str) -> LifecycleOutcome:
|
|
88
|
+
if view.get("status") == "active" and view.get("handle") == handle:
|
|
89
|
+
return LifecycleOutcome("done", None, view)
|
|
90
|
+
last_test = view.get("last_test")
|
|
91
|
+
if isinstance(last_test, dict) and last_test.get("outcome") == "failed" and last_test.get("handle") == handle:
|
|
92
|
+
return LifecycleOutcome("test_failed", "start_again", view)
|
|
93
|
+
pending = view.get("pending_key")
|
|
94
|
+
if isinstance(pending, dict) and pending.get("handle") == handle:
|
|
95
|
+
return LifecycleOutcome("pending", "resume", view)
|
|
96
|
+
return LifecycleOutcome("changed", "inspect", view)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _disconnect_result(view: Mapping[str, Any] | None) -> LifecycleOutcome:
|
|
100
|
+
if view is None:
|
|
101
|
+
return LifecycleOutcome("unknown", "resume", None)
|
|
102
|
+
operation = _operation(view)
|
|
103
|
+
if view.get("status") == "revoked" or (operation.get("kind") == "revoke" and operation.get("phase") == "committed"):
|
|
104
|
+
return LifecycleOutcome("done", None, view)
|
|
105
|
+
return LifecycleOutcome("unknown", "resume", view)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _require_mapping(value: Any) -> Mapping[str, Any]:
|
|
109
|
+
if not isinstance(value, dict):
|
|
110
|
+
raise TypeError("verifier connection response must be an object")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class VerifierConnection:
|
|
115
|
+
"""Synchronous protected lifecycle. The key is entered at continue_url, never here."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, client: _SyncClient) -> None:
|
|
118
|
+
self._client = client
|
|
119
|
+
|
|
120
|
+
def create_intent(self, verifier_id: str, stop_choice: StopChoice, *, idempotency_key: str) -> ConnectionIntent:
|
|
121
|
+
return _require_mapping(self._client._verifier_connection("intent", verifier_id,
|
|
122
|
+
body={"stop_choice": _checked_stop_choice(stop_choice)}, idempotency_key=idempotency_key)) # type: ignore[return-value]
|
|
123
|
+
|
|
124
|
+
def inspect(self, verifier_id: str, *, operation_key: str | None = None) -> Mapping[str, Any]:
|
|
125
|
+
query = {"operation_key": operation_key} if operation_key else None
|
|
126
|
+
return _require_mapping(self._client._verifier_connection("inspect", verifier_id, query=query))
|
|
127
|
+
|
|
128
|
+
def test_and_promote(self, verifier_id: str, *, handle: str, captured_generation: int,
|
|
129
|
+
operation_key: str, idempotency_key: str) -> Mapping[str, Any]:
|
|
130
|
+
return _require_mapping(self._client._verifier_connection("promote", verifier_id,
|
|
131
|
+
body={"handle": handle, "captured_generation": captured_generation,
|
|
132
|
+
"operation_key": operation_key}, idempotency_key=idempotency_key))
|
|
133
|
+
|
|
134
|
+
def revoke(self, verifier_id: str, *, operation_key: str, idempotency_key: str) -> Mapping[str, Any]:
|
|
135
|
+
return _require_mapping(self._client._verifier_connection("revoke", verifier_id,
|
|
136
|
+
body={"operation_key": operation_key}, idempotency_key=idempotency_key))
|
|
137
|
+
|
|
138
|
+
def _safe_inspect(self, verifier_id: str, operation_key: str) -> Mapping[str, Any] | LifecycleOutcome | None:
|
|
139
|
+
try:
|
|
140
|
+
return self.inspect(verifier_id, operation_key=operation_key)
|
|
141
|
+
except (ProblemError, NetworkError) as error:
|
|
142
|
+
return _refusal(error, None)
|
|
143
|
+
|
|
144
|
+
def continue_connection(self, verifier_id: str, *, operation_key: str, idempotency_key: str) -> LifecycleOutcome:
|
|
145
|
+
before = self._safe_inspect(verifier_id, operation_key)
|
|
146
|
+
if isinstance(before, LifecycleOutcome):
|
|
147
|
+
return before
|
|
148
|
+
if before is None:
|
|
149
|
+
return LifecycleOutcome("unknown", "resume", None)
|
|
150
|
+
operation = _operation(before)
|
|
151
|
+
if operation.get("kind") == "promote" and operation.get("phase") == "committed":
|
|
152
|
+
return LifecycleOutcome("done", None, before)
|
|
153
|
+
handle = _staged_handle(before)
|
|
154
|
+
if handle is None:
|
|
155
|
+
return LifecycleOutcome("action_required", "enter_key", before)
|
|
156
|
+
pending = before.get("pending_key")
|
|
157
|
+
if isinstance(pending, dict) and pending.get("handle") != handle:
|
|
158
|
+
return LifecycleOutcome("changed", "inspect", before)
|
|
159
|
+
if not isinstance(pending, dict):
|
|
160
|
+
return _promotion_result(before, handle)
|
|
161
|
+
write_refusal = None
|
|
162
|
+
try:
|
|
163
|
+
self.test_and_promote(verifier_id, handle=handle,
|
|
164
|
+
captured_generation=pending["captured_generation"],
|
|
165
|
+
operation_key=operation_key, idempotency_key=idempotency_key)
|
|
166
|
+
except (ProblemError, NetworkError) as error:
|
|
167
|
+
write_refusal = _refusal(error, before)
|
|
168
|
+
after = self._safe_inspect(verifier_id, operation_key)
|
|
169
|
+
if isinstance(after, LifecycleOutcome):
|
|
170
|
+
return after
|
|
171
|
+
if after is None:
|
|
172
|
+
return write_refusal or LifecycleOutcome("unknown", "resume", None)
|
|
173
|
+
result = _promotion_result(after, handle)
|
|
174
|
+
if write_refusal and result.state not in {"test_failed", "done"}:
|
|
175
|
+
return LifecycleOutcome("refused", "inspect", after, write_refusal.error)
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
def disconnect(self, verifier_id: str, *, operation_key: str, idempotency_key: str,
|
|
179
|
+
confirmed: bool) -> LifecycleOutcome:
|
|
180
|
+
if not confirmed:
|
|
181
|
+
raise ValueError("verifier disconnect requires explicit confirmation")
|
|
182
|
+
write_refusal = None
|
|
183
|
+
try:
|
|
184
|
+
self.revoke(verifier_id, operation_key=operation_key, idempotency_key=idempotency_key)
|
|
185
|
+
except (ProblemError, NetworkError) as error:
|
|
186
|
+
write_refusal = _refusal(error, None)
|
|
187
|
+
after = self._safe_inspect(verifier_id, operation_key)
|
|
188
|
+
if isinstance(after, LifecycleOutcome):
|
|
189
|
+
return after
|
|
190
|
+
if after is None and write_refusal:
|
|
191
|
+
return write_refusal
|
|
192
|
+
result = _disconnect_result(after)
|
|
193
|
+
if write_refusal and result.state != "done":
|
|
194
|
+
return LifecycleOutcome("refused", "inspect", after, write_refusal.error)
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class AsyncVerifierConnection:
|
|
199
|
+
"""Async equivalent of the secretless protected lifecycle."""
|
|
200
|
+
|
|
201
|
+
def __init__(self, client: _AsyncClient) -> None:
|
|
202
|
+
self._client = client
|
|
203
|
+
|
|
204
|
+
async def create_intent(self, verifier_id: str, stop_choice: StopChoice, *, idempotency_key: str) -> ConnectionIntent:
|
|
205
|
+
return _require_mapping(await self._client._verifier_connection("intent", verifier_id,
|
|
206
|
+
body={"stop_choice": _checked_stop_choice(stop_choice)}, idempotency_key=idempotency_key)) # type: ignore[return-value]
|
|
207
|
+
|
|
208
|
+
async def inspect(self, verifier_id: str, *, operation_key: str | None = None) -> Mapping[str, Any]:
|
|
209
|
+
query = {"operation_key": operation_key} if operation_key else None
|
|
210
|
+
return _require_mapping(await self._client._verifier_connection("inspect", verifier_id, query=query))
|
|
211
|
+
|
|
212
|
+
async def test_and_promote(self, verifier_id: str, *, handle: str, captured_generation: int,
|
|
213
|
+
operation_key: str, idempotency_key: str) -> Mapping[str, Any]:
|
|
214
|
+
return _require_mapping(await self._client._verifier_connection("promote", verifier_id,
|
|
215
|
+
body={"handle": handle, "captured_generation": captured_generation,
|
|
216
|
+
"operation_key": operation_key}, idempotency_key=idempotency_key))
|
|
217
|
+
|
|
218
|
+
async def revoke(self, verifier_id: str, *, operation_key: str, idempotency_key: str) -> Mapping[str, Any]:
|
|
219
|
+
return _require_mapping(await self._client._verifier_connection("revoke", verifier_id,
|
|
220
|
+
body={"operation_key": operation_key}, idempotency_key=idempotency_key))
|
|
221
|
+
|
|
222
|
+
async def _safe_inspect(self, verifier_id: str, operation_key: str) -> Mapping[str, Any] | LifecycleOutcome | None:
|
|
223
|
+
try:
|
|
224
|
+
return await self.inspect(verifier_id, operation_key=operation_key)
|
|
225
|
+
except (ProblemError, NetworkError) as error:
|
|
226
|
+
return _refusal(error, None)
|
|
227
|
+
|
|
228
|
+
async def continue_connection(self, verifier_id: str, *, operation_key: str, idempotency_key: str) -> LifecycleOutcome:
|
|
229
|
+
before = await self._safe_inspect(verifier_id, operation_key)
|
|
230
|
+
if isinstance(before, LifecycleOutcome):
|
|
231
|
+
return before
|
|
232
|
+
if before is None:
|
|
233
|
+
return LifecycleOutcome("unknown", "resume", None)
|
|
234
|
+
operation = _operation(before)
|
|
235
|
+
if operation.get("kind") == "promote" and operation.get("phase") == "committed":
|
|
236
|
+
return LifecycleOutcome("done", None, before)
|
|
237
|
+
handle = _staged_handle(before)
|
|
238
|
+
if handle is None:
|
|
239
|
+
return LifecycleOutcome("action_required", "enter_key", before)
|
|
240
|
+
pending = before.get("pending_key")
|
|
241
|
+
if isinstance(pending, dict) and pending.get("handle") != handle:
|
|
242
|
+
return LifecycleOutcome("changed", "inspect", before)
|
|
243
|
+
if not isinstance(pending, dict):
|
|
244
|
+
return _promotion_result(before, handle)
|
|
245
|
+
write_refusal = None
|
|
246
|
+
try:
|
|
247
|
+
await self.test_and_promote(verifier_id, handle=handle,
|
|
248
|
+
captured_generation=pending["captured_generation"],
|
|
249
|
+
operation_key=operation_key, idempotency_key=idempotency_key)
|
|
250
|
+
except (ProblemError, NetworkError) as error:
|
|
251
|
+
write_refusal = _refusal(error, before)
|
|
252
|
+
after = await self._safe_inspect(verifier_id, operation_key)
|
|
253
|
+
if isinstance(after, LifecycleOutcome):
|
|
254
|
+
return after
|
|
255
|
+
if after is None:
|
|
256
|
+
return write_refusal or LifecycleOutcome("unknown", "resume", None)
|
|
257
|
+
result = _promotion_result(after, handle)
|
|
258
|
+
if write_refusal and result.state not in {"test_failed", "done"}:
|
|
259
|
+
return LifecycleOutcome("refused", "inspect", after, write_refusal.error)
|
|
260
|
+
return result
|
|
261
|
+
|
|
262
|
+
async def disconnect(self, verifier_id: str, *, operation_key: str, idempotency_key: str,
|
|
263
|
+
confirmed: bool) -> LifecycleOutcome:
|
|
264
|
+
if not confirmed:
|
|
265
|
+
raise ValueError("verifier disconnect requires explicit confirmation")
|
|
266
|
+
write_refusal = None
|
|
267
|
+
try:
|
|
268
|
+
await self.revoke(verifier_id, operation_key=operation_key, idempotency_key=idempotency_key)
|
|
269
|
+
except (ProblemError, NetworkError) as error:
|
|
270
|
+
write_refusal = _refusal(error, None)
|
|
271
|
+
after = await self._safe_inspect(verifier_id, operation_key)
|
|
272
|
+
if isinstance(after, LifecycleOutcome):
|
|
273
|
+
return after
|
|
274
|
+
if after is None and write_refusal:
|
|
275
|
+
return write_refusal
|
|
276
|
+
result = _disconnect_result(after)
|
|
277
|
+
if write_refusal and result.state != "done":
|
|
278
|
+
return LifecycleOutcome("refused", "inspect", after, write_refusal.error)
|
|
279
|
+
return result
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: millwork-solver
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Sync and async Python clients for the Millwork Solver API
|
|
5
|
+
Author: Millwork
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Repository, https://github.com/millworkdev/solver-python
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Python: <3.15,>=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: httpx==0.28.1
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# Millwork Solver for Python
|
|
21
|
+
|
|
22
|
+
`millwork-solver` is an early preview client for the Millwork Solver API. Import
|
|
23
|
+
`millwork_solver` to use the synchronous `Solver` client or the asynchronous
|
|
24
|
+
`AsyncSolver` client on CPython 3.11 through 3.14.
|
|
25
|
+
|
|
26
|
+
The [generated REST lifecycle reference](https://docs.getmillwork.dev/api-reference/output-checks)
|
|
27
|
+
owns output-check operation IDs and fields. The
|
|
28
|
+
[verifier-dock contract](https://docs.getmillwork.dev/contracts/verifier-dock/v1.json)
|
|
29
|
+
owns the endpoint request and response. Follow
|
|
30
|
+
[Recipe 0](https://docs.getmillwork.dev/cookbook/output-checks/build-the-dock)
|
|
31
|
+
for local tests, deployment, and a receipt.
|
|
32
|
+
|
|
33
|
+
Pass `api_key` and `base_url` directly, or use `SOLVERAPI_API_KEY` and
|
|
34
|
+
`SOLVERAPI_BASE_URL`. Retry settings use `SOLVERAPI_MAX_RETRIES` (default `2`)
|
|
35
|
+
and `SOLVERAPI_RETRY_BACKOFF_MS` (default `500`).
|
|
36
|
+
|
|
37
|
+
Both clients provide the same typed operation surface, explicit timeout and
|
|
38
|
+
close behavior, and receipt-aware responses. Echo reaches a terminal status and
|
|
39
|
+
returns a receipt; it does not return result content.
|
|
40
|
+
|
|
41
|
+
Use the API base URL with its `/v1` suffix:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export SOLVERAPI_BASE_URL="https://api.getmillwork.dev/v1"
|
|
45
|
+
export SOLVERAPI_API_KEY="<organization API key>"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Call an operation by its OpenAPI `operation_id`. This complete test run uses
|
|
49
|
+
`postExecutions`; the request must include both `task` and `policy`:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import uuid
|
|
53
|
+
|
|
54
|
+
from millwork_solver import Solver
|
|
55
|
+
|
|
56
|
+
with Solver() as client:
|
|
57
|
+
run = client.request(
|
|
58
|
+
"postExecutions",
|
|
59
|
+
body={
|
|
60
|
+
"mode": "echo",
|
|
61
|
+
"task": {"objective": "Check the Millwork API lifecycle."},
|
|
62
|
+
"policy": {
|
|
63
|
+
"data_classes": ["public"],
|
|
64
|
+
"budget": {"max_cost_usd": 1, "max_runtime_s": 30},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
idempotency_key=str(uuid.uuid4()),
|
|
68
|
+
)
|
|
69
|
+
receipt = client.request(
|
|
70
|
+
"getReceiptsByExecutionId",
|
|
71
|
+
path_parameters={"executionId": run["execution_id"]},
|
|
72
|
+
)
|
|
73
|
+
print(run["status"], receipt["mode"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Common operation IDs include `getAccount`, `getArms`, `getModelCatalog`,
|
|
77
|
+
`getVerifiers`, `postExecutions`, `getExecutionsByExecutionId`,
|
|
78
|
+
`getExecutionsByExecutionIdResult`, and `getReceiptsByExecutionId`. The package
|
|
79
|
+
ships the complete operation contract in `millwork_solver/_contract.json`.
|
|
80
|
+
|
|
81
|
+
## Verifier lifecycle
|
|
82
|
+
|
|
83
|
+
The installed client can register and test a public output check. Set
|
|
84
|
+
`VERIFIER_URL` to an endpoint implementing the
|
|
85
|
+
[output-check contract](https://docs.getmillwork.dev/cookbook/output-checks/build-the-dock#the-endpoint-contract):
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import os
|
|
89
|
+
import uuid
|
|
90
|
+
from millwork_solver import Solver
|
|
91
|
+
|
|
92
|
+
with Solver() as client:
|
|
93
|
+
registered = client.request("postVerifiers", body={
|
|
94
|
+
"display_name": "My output check", "version": "1.0.0", "kind": "endpoint",
|
|
95
|
+
"endpoint": {"url": os.environ["VERIFIER_URL"], "auth_ref": ""},
|
|
96
|
+
"input_data_classes": ["public"],
|
|
97
|
+
"scoring": {"correctness": "boolean_anchors", "quality": "scalar_0_1"},
|
|
98
|
+
}, idempotency_key=str(uuid.uuid4()))
|
|
99
|
+
verifier_id = registered["verifier_id"]
|
|
100
|
+
print(verifier_id, client.request("postVerifiersByVerifierIdTest",
|
|
101
|
+
path_parameters={"verifierId": verifier_id}, idempotency_key=str(uuid.uuid4())))
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The protected-connection methods below describe this source checkout. The
|
|
105
|
+
published `millwork-solver==0.1.0` wheel has six public verifier operation
|
|
106
|
+
IDs in its catalog but does not include `verifier_connection`. Until a wheel carrying
|
|
107
|
+
these methods is published and read back, use the
|
|
108
|
+
[private CLI or dashboard path](https://docs.getmillwork.dev/guides/connect-an-output-check#connect-a-protected-endpoint)
|
|
109
|
+
for a protected endpoint.
|
|
110
|
+
|
|
111
|
+
For a protected endpoint, register that endpoint with the same secretless
|
|
112
|
+
`postVerifiers` body, then use **its** `verifier_id` for the private handoff.
|
|
113
|
+
The REST registration has no `access` field; the protected connection is
|
|
114
|
+
established by `verifier_connection`. Give `continue_url` only to the intended
|
|
115
|
+
person through your host's private browser handoff. The callback below belongs
|
|
116
|
+
to that host and must not log or expose the URL. The endpoint key is never an
|
|
117
|
+
argument to these methods.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import uuid
|
|
121
|
+
from urllib.parse import urlsplit
|
|
122
|
+
|
|
123
|
+
def start_protected(client, verifier_id, trusted_app_origin, open_private_url):
|
|
124
|
+
intent = client.verifier_connection.create_intent(
|
|
125
|
+
verifier_id, {"kind": "preset_days", "days": 90},
|
|
126
|
+
idempotency_key=str(uuid.uuid4()))
|
|
127
|
+
expected = urlsplit(trusted_app_origin)
|
|
128
|
+
actual = urlsplit(intent["continue_url"])
|
|
129
|
+
if (actual.scheme, actual.netloc) != (expected.scheme, expected.netloc):
|
|
130
|
+
raise ValueError("Unexpected private-entry origin")
|
|
131
|
+
open_private_url(intent["continue_url"])
|
|
132
|
+
return intent["intent_id"]
|
|
133
|
+
|
|
134
|
+
def continue_protected(client, verifier_id, operation_key):
|
|
135
|
+
return client.verifier_connection.continue_connection(
|
|
136
|
+
verifier_id, operation_key=operation_key,
|
|
137
|
+
idempotency_key=str(uuid.uuid4()))
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`enter_key` means private entry is still needed. `resume` means retain the same
|
|
141
|
+
operation key and use a fresh request idempotency key. `start_again` means the
|
|
142
|
+
endpoint test failed; correct the endpoint or key and create a new intent.
|
|
143
|
+
`inspect` means another connection change intervened. `refused` means the server
|
|
144
|
+
rejected the change with a non-retryable client error; read `outcome.error["status"]`
|
|
145
|
+
and `outcome.error["title"]`, inspect that operation, and correct the cause before
|
|
146
|
+
another action. A `401` or `403` raises a `ProblemError` instead of returning a
|
|
147
|
+
lifecycle outcome. Only a read-backed `done`
|
|
148
|
+
confirms completion. The async `AsyncSolver.verifier_connection` has the same
|
|
149
|
+
methods, awaited. Replacement and restoration use a new intent and the same
|
|
150
|
+
private-entry path. Disconnect requires explicit caller confirmation:
|
|
151
|
+
`client.verifier_connection.disconnect(verifier_id, operation_key=...,
|
|
152
|
+
idempotency_key=..., confirmed=True)`. Millwork stopping use of a key does not
|
|
153
|
+
revoke it at your endpoint. A governed run requires separate authorization and
|
|
154
|
+
uses this `verifier_id`.
|
|
@@ -8,6 +8,7 @@ src/millwork_solver/client.py
|
|
|
8
8
|
src/millwork_solver/errors.py
|
|
9
9
|
src/millwork_solver/py.typed
|
|
10
10
|
src/millwork_solver/types.py
|
|
11
|
+
src/millwork_solver/verifier_connection.py
|
|
11
12
|
src/millwork_solver.egg-info/PKG-INFO
|
|
12
13
|
src/millwork_solver.egg-info/SOURCES.txt
|
|
13
14
|
src/millwork_solver.egg-info/dependency_links.txt
|
millwork_solver-0.1.0/PKG-INFO
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: millwork-solver
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Sync and async Python clients for the Millwork Solver API
|
|
5
|
-
Author: Millwork
|
|
6
|
-
License-Expression: Apache-2.0
|
|
7
|
-
Project-URL: Repository, https://github.com/millworkdev/solver-python
|
|
8
|
-
Classifier: Development Status :: 3 - Alpha
|
|
9
|
-
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
-
Requires-Python: <3.15,>=3.11
|
|
15
|
-
Description-Content-Type: text/markdown
|
|
16
|
-
License-File: LICENSE
|
|
17
|
-
Requires-Dist: httpx==0.28.1
|
|
18
|
-
Dynamic: license-file
|
|
19
|
-
|
|
20
|
-
# Millwork Solver for Python
|
|
21
|
-
|
|
22
|
-
`millwork-solver` is an early preview client for the Millwork Solver API. Import
|
|
23
|
-
`millwork_solver` to use the synchronous `Solver` client or the asynchronous
|
|
24
|
-
`AsyncSolver` client on CPython 3.11 through 3.14.
|
|
25
|
-
|
|
26
|
-
Pass `api_key` and `base_url` directly, or use `SOLVERAPI_API_KEY` and
|
|
27
|
-
`SOLVERAPI_BASE_URL`. Retry settings use `SOLVERAPI_MAX_RETRIES` (default `2`)
|
|
28
|
-
and `SOLVERAPI_RETRY_BACKOFF_MS` (default `500`).
|
|
29
|
-
|
|
30
|
-
Both clients provide the same typed operation surface, explicit timeout and
|
|
31
|
-
close behavior, and receipt-aware responses. Echo reaches a terminal status and
|
|
32
|
-
returns a receipt; it does not return result content.
|
millwork_solver-0.1.0/README.md
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
# Millwork Solver for Python
|
|
2
|
-
|
|
3
|
-
`millwork-solver` is an early preview client for the Millwork Solver API. Import
|
|
4
|
-
`millwork_solver` to use the synchronous `Solver` client or the asynchronous
|
|
5
|
-
`AsyncSolver` client on CPython 3.11 through 3.14.
|
|
6
|
-
|
|
7
|
-
Pass `api_key` and `base_url` directly, or use `SOLVERAPI_API_KEY` and
|
|
8
|
-
`SOLVERAPI_BASE_URL`. Retry settings use `SOLVERAPI_MAX_RETRIES` (default `2`)
|
|
9
|
-
and `SOLVERAPI_RETRY_BACKOFF_MS` (default `500`).
|
|
10
|
-
|
|
11
|
-
Both clients provide the same typed operation surface, explicit timeout and
|
|
12
|
-
close behavior, and receipt-aware responses. Echo reaches a terminal status and
|
|
13
|
-
returns a receipt; it does not return result content.
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: millwork-solver
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Sync and async Python clients for the Millwork Solver API
|
|
5
|
-
Author: Millwork
|
|
6
|
-
License-Expression: Apache-2.0
|
|
7
|
-
Project-URL: Repository, https://github.com/millworkdev/solver-python
|
|
8
|
-
Classifier: Development Status :: 3 - Alpha
|
|
9
|
-
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
-
Requires-Python: <3.15,>=3.11
|
|
15
|
-
Description-Content-Type: text/markdown
|
|
16
|
-
License-File: LICENSE
|
|
17
|
-
Requires-Dist: httpx==0.28.1
|
|
18
|
-
Dynamic: license-file
|
|
19
|
-
|
|
20
|
-
# Millwork Solver for Python
|
|
21
|
-
|
|
22
|
-
`millwork-solver` is an early preview client for the Millwork Solver API. Import
|
|
23
|
-
`millwork_solver` to use the synchronous `Solver` client or the asynchronous
|
|
24
|
-
`AsyncSolver` client on CPython 3.11 through 3.14.
|
|
25
|
-
|
|
26
|
-
Pass `api_key` and `base_url` directly, or use `SOLVERAPI_API_KEY` and
|
|
27
|
-
`SOLVERAPI_BASE_URL`. Retry settings use `SOLVERAPI_MAX_RETRIES` (default `2`)
|
|
28
|
-
and `SOLVERAPI_RETRY_BACKOFF_MS` (default `500`).
|
|
29
|
-
|
|
30
|
-
Both clients provide the same typed operation surface, explicit timeout and
|
|
31
|
-
close behavior, and receipt-aware responses. Echo reaches a terminal status and
|
|
32
|
-
returns a receipt; it does not return result content.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{millwork_solver-0.1.0 → millwork_solver-0.1.1}/src/millwork_solver.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|