millwork-solver 0.1.0__tar.gz → 0.1.2__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.
Files changed (27) hide show
  1. millwork_solver-0.1.2/PKG-INFO +152 -0
  2. millwork_solver-0.1.2/README.md +133 -0
  3. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/pyproject.toml +1 -1
  4. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/__init__.py +6 -0
  5. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/_contract.json +82 -2
  6. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/_core/catalog.py +8 -8
  7. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/_core/clients.py +34 -0
  8. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/client.py +4 -1
  9. millwork_solver-0.1.2/src/millwork_solver/verifier_connection.py +279 -0
  10. millwork_solver-0.1.2/src/millwork_solver.egg-info/PKG-INFO +152 -0
  11. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver.egg-info/SOURCES.txt +1 -0
  12. millwork_solver-0.1.0/PKG-INFO +0 -32
  13. millwork_solver-0.1.0/README.md +0 -13
  14. millwork_solver-0.1.0/src/millwork_solver.egg-info/PKG-INFO +0 -32
  15. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/LICENSE +0 -0
  16. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/MANIFEST.in +0 -0
  17. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/setup.cfg +0 -0
  18. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/_core/__init__.py +0 -0
  19. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/_core/boundary.py +0 -0
  20. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/_core/errors.py +0 -0
  21. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/_core/transport.py +0 -0
  22. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/errors.py +0 -0
  23. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/py.typed +0 -0
  24. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver/types.py +0 -0
  25. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver.egg-info/dependency_links.txt +0 -0
  26. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver.egg-info/requires.txt +0 -0
  27. {millwork_solver-0.1.0 → millwork_solver-0.1.2}/src/millwork_solver.egg-info/top_level.txt +0 -0
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: millwork-solver
3
+ Version: 0.1.2
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 published `millwork-solver==0.1.1` wheel includes the public verifier
105
+ operation IDs and `verifier_connection`. Install that pinned release to run
106
+ the protected-connection methods below. The earlier `0.1.0` wheel does not
107
+ include these methods.
108
+
109
+ For a protected endpoint, register that endpoint with the same secretless
110
+ `postVerifiers` body, then use **its** `verifier_id` for the private handoff.
111
+ The REST registration has no `access` field; the protected connection is
112
+ established by `verifier_connection`. Give `continue_url` only to the intended
113
+ person through your host's private browser handoff. The callback below belongs
114
+ to that host and must not log or expose the URL. The endpoint key is never an
115
+ argument to these methods.
116
+
117
+ ```python
118
+ import uuid
119
+ from urllib.parse import urlsplit
120
+
121
+ def start_protected(client, verifier_id, trusted_app_origin, open_private_url):
122
+ intent = client.verifier_connection.create_intent(
123
+ verifier_id, {"kind": "preset_days", "days": 90},
124
+ idempotency_key=str(uuid.uuid4()))
125
+ expected = urlsplit(trusted_app_origin)
126
+ actual = urlsplit(intent["continue_url"])
127
+ if (actual.scheme, actual.netloc) != (expected.scheme, expected.netloc):
128
+ raise ValueError("Unexpected private-entry origin")
129
+ open_private_url(intent["continue_url"])
130
+ return intent["intent_id"]
131
+
132
+ def continue_protected(client, verifier_id, operation_key):
133
+ return client.verifier_connection.continue_connection(
134
+ verifier_id, operation_key=operation_key,
135
+ idempotency_key=str(uuid.uuid4()))
136
+ ```
137
+
138
+ `enter_key` means private entry is still needed. `resume` means retain the same
139
+ operation key and use a fresh request idempotency key. `start_again` means the
140
+ endpoint test failed; correct the endpoint or key and create a new intent.
141
+ `inspect` means another connection change intervened. `refused` means the server
142
+ rejected the change with a non-retryable client error; read `outcome.error["status"]`
143
+ and `outcome.error["title"]`, inspect that operation, and correct the cause before
144
+ another action. A `401` or `403` raises a `ProblemError` instead of returning a
145
+ lifecycle outcome. Only a read-backed `done`
146
+ confirms completion. The async `AsyncSolver.verifier_connection` has the same
147
+ methods, awaited. Replacement and restoration use a new intent and the same
148
+ private-entry path. Disconnect requires explicit caller confirmation:
149
+ `client.verifier_connection.disconnect(verifier_id, operation_key=...,
150
+ idempotency_key=..., confirmed=True)`. Millwork stopping use of a key does not
151
+ revoke it at your endpoint. A governed run requires separate authorization and
152
+ uses this `verifier_id`.
@@ -0,0 +1,133 @@
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 published `millwork-solver==0.1.1` wheel includes the public verifier
86
+ operation IDs and `verifier_connection`. Install that pinned release to run
87
+ the protected-connection methods below. The earlier `0.1.0` wheel does not
88
+ include these methods.
89
+
90
+ For a protected endpoint, register that endpoint with the same secretless
91
+ `postVerifiers` body, then use **its** `verifier_id` for the private handoff.
92
+ The REST registration has no `access` field; the protected connection is
93
+ established by `verifier_connection`. Give `continue_url` only to the intended
94
+ person through your host's private browser handoff. The callback below belongs
95
+ to that host and must not log or expose the URL. The endpoint key is never an
96
+ argument to these methods.
97
+
98
+ ```python
99
+ import uuid
100
+ from urllib.parse import urlsplit
101
+
102
+ def start_protected(client, verifier_id, trusted_app_origin, open_private_url):
103
+ intent = client.verifier_connection.create_intent(
104
+ verifier_id, {"kind": "preset_days", "days": 90},
105
+ idempotency_key=str(uuid.uuid4()))
106
+ expected = urlsplit(trusted_app_origin)
107
+ actual = urlsplit(intent["continue_url"])
108
+ if (actual.scheme, actual.netloc) != (expected.scheme, expected.netloc):
109
+ raise ValueError("Unexpected private-entry origin")
110
+ open_private_url(intent["continue_url"])
111
+ return intent["intent_id"]
112
+
113
+ def continue_protected(client, verifier_id, operation_key):
114
+ return client.verifier_connection.continue_connection(
115
+ verifier_id, operation_key=operation_key,
116
+ idempotency_key=str(uuid.uuid4()))
117
+ ```
118
+
119
+ `enter_key` means private entry is still needed. `resume` means retain the same
120
+ operation key and use a fresh request idempotency key. `start_again` means the
121
+ endpoint test failed; correct the endpoint or key and create a new intent.
122
+ `inspect` means another connection change intervened. `refused` means the server
123
+ rejected the change with a non-retryable client error; read `outcome.error["status"]`
124
+ and `outcome.error["title"]`, inspect that operation, and correct the cause before
125
+ another action. A `401` or `403` raises a `ProblemError` instead of returning a
126
+ lifecycle outcome. Only a read-backed `done`
127
+ confirms completion. The async `AsyncSolver.verifier_connection` has the same
128
+ methods, awaited. Replacement and restoration use a new intent and the same
129
+ private-entry path. Disconnect requires explicit caller confirmation:
130
+ `client.verifier_connection.disconnect(verifier_id, operation_key=...,
131
+ idempotency_key=..., confirmed=True)`. Millwork stopping use of a key does not
132
+ revoke it at your endpoint. A governed run requires separate authorization and
133
+ uses this `verifier_id`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "millwork-solver"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Sync and async Python clients for the Millwork Solver API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11,<3.15"
@@ -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.0.0",
6
- "openapi_sha256": "ad959a037922a16bda51221ed989d00ea0ae6b70eb86b5f2aef07eccee91d3c6",
5
+ "operation_manifest_version": "2.2.0",
6
+ "openapi_sha256": "0a7d6b2e24ac80602e3ae0991ff6d964fc3727304a49144c6a5d73eb89d75d97",
7
7
  "retry_fixture": "contracts/developer-experience/fixtures/retry-error-idempotency.json"
8
8
  },
9
9
  "operations": [
@@ -122,6 +122,16 @@
122
122
  "method": "GET",
123
123
  "path": "/v1/receipts/{executionId}"
124
124
  },
125
+ {
126
+ "operation_id": "getReviewComposerAgents",
127
+ "method": "GET",
128
+ "path": "/v1/review-composer/agents"
129
+ },
130
+ {
131
+ "operation_id": "getReviewComposerAgentsByArmIdConnection",
132
+ "method": "GET",
133
+ "path": "/v1/review-composer/agents/{armId}/connection"
134
+ },
125
135
  {
126
136
  "operation_id": "getSourceConnections",
127
137
  "method": "GET",
@@ -137,6 +147,26 @@
137
147
  "method": "GET",
138
148
  "path": "/v1/source-credential-handoffs/{handoffIntentId}"
139
149
  },
150
+ {
151
+ "operation_id": "getTenantModelSelection",
152
+ "method": "GET",
153
+ "path": "/v1/tenant-model-selection"
154
+ },
155
+ {
156
+ "operation_id": "getTenantTemplateApplications",
157
+ "method": "GET",
158
+ "path": "/v1/tenant-template-applications"
159
+ },
160
+ {
161
+ "operation_id": "getTenantTemplateApplicationsByApplicationId",
162
+ "method": "GET",
163
+ "path": "/v1/tenant-template-applications/{applicationId}"
164
+ },
165
+ {
166
+ "operation_id": "getTenantTemplates",
167
+ "method": "GET",
168
+ "path": "/v1/tenant-templates"
169
+ },
140
170
  {
141
171
  "operation_id": "getUsage",
142
172
  "method": "GET",
@@ -152,6 +182,11 @@
152
182
  "method": "GET",
153
183
  "path": "/v1/verifiers/{verifierId}"
154
184
  },
185
+ {
186
+ "operation_id": "getVerifiersByVerifierIdConnection",
187
+ "method": "GET",
188
+ "path": "/v1/verifiers/{verifierId}/connection"
189
+ },
155
190
  {
156
191
  "operation_id": "getWebhooks",
157
192
  "method": "GET",
@@ -212,6 +247,11 @@
212
247
  "method": "POST",
213
248
  "path": "/v1/executions/{executionId}/cancel"
214
249
  },
250
+ {
251
+ "operation_id": "postModelCatalogQuote",
252
+ "method": "POST",
253
+ "path": "/v1/model-catalog/quote"
254
+ },
215
255
  {
216
256
  "operation_id": "postProposalsByProposalIdApprove",
217
257
  "method": "POST",
@@ -222,6 +262,26 @@
222
262
  "method": "POST",
223
263
  "path": "/v1/proposals/{proposalId}/reject"
224
264
  },
265
+ {
266
+ "operation_id": "postReviewComposerAgents",
267
+ "method": "POST",
268
+ "path": "/v1/review-composer/agents"
269
+ },
270
+ {
271
+ "operation_id": "postReviewComposerAgentsByArmIdConnectionIntents",
272
+ "method": "POST",
273
+ "path": "/v1/review-composer/agents/{armId}/connection/intents"
274
+ },
275
+ {
276
+ "operation_id": "postReviewComposerAgentsByArmIdConnectionRevoke",
277
+ "method": "POST",
278
+ "path": "/v1/review-composer/agents/{armId}/connection/revoke"
279
+ },
280
+ {
281
+ "operation_id": "postReviewComposerAgentsByArmIdConnectionTest",
282
+ "method": "POST",
283
+ "path": "/v1/review-composer/agents/{armId}/connection/test"
284
+ },
225
285
  {
226
286
  "operation_id": "postSourceConnections",
227
287
  "method": "POST",
@@ -247,11 +307,31 @@
247
307
  "method": "POST",
248
308
  "path": "/v1/source-credential-handoffs"
249
309
  },
310
+ {
311
+ "operation_id": "postTenantModelSelection",
312
+ "method": "POST",
313
+ "path": "/v1/tenant-model-selection"
314
+ },
250
315
  {
251
316
  "operation_id": "postTenants",
252
317
  "method": "POST",
253
318
  "path": "/v1/tenants"
254
319
  },
320
+ {
321
+ "operation_id": "postTenantTemplateApplications",
322
+ "method": "POST",
323
+ "path": "/v1/tenant-template-applications"
324
+ },
325
+ {
326
+ "operation_id": "postTenantTemplateApplicationsByApplicationIdResume",
327
+ "method": "POST",
328
+ "path": "/v1/tenant-template-applications/{applicationId}/resume"
329
+ },
330
+ {
331
+ "operation_id": "postTenantTemplatePlans",
332
+ "method": "POST",
333
+ "path": "/v1/tenant-template-plans"
334
+ },
255
335
  {
256
336
  "operation_id": "postVerifiers",
257
337
  "method": "POST",
@@ -89,9 +89,9 @@ 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": 70,
93
- "included": 54,
94
- "excluded": 16,
92
+ "operations": 89,
93
+ "included": 70,
94
+ "excluded": 19,
95
95
  }:
96
96
  raise _OperationBoundaryError("PY1 pointer/count contract drifted")
97
97
 
@@ -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) != 70 or len(f1_included) != 54 or len(f1_excluded) != 16:
134
- raise _OperationBoundaryError("F1 54/16 operation partition drifted")
133
+ if len(wire_operations) != 89 or len(f1_included) != 70 or len(f1_excluded) != 19:
134
+ raise _OperationBoundaryError("F1 70/19 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 54 includes")
136
+ raise _OperationBoundaryError("PY1 pointer does not resolve exactly to F1's 70 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) != 54:
166
- raise _OperationBoundaryError("installed Python wire snapshot must contain 54 operations")
165
+ if not isinstance(operations, list) or len(operations) != 70:
166
+ raise _OperationBoundaryError("installed Python wire snapshot must contain 70 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 54-operation Python boundary."""
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,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: millwork-solver
3
+ Version: 0.1.2
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 published `millwork-solver==0.1.1` wheel includes the public verifier
105
+ operation IDs and `verifier_connection`. Install that pinned release to run
106
+ the protected-connection methods below. The earlier `0.1.0` wheel does not
107
+ include these methods.
108
+
109
+ For a protected endpoint, register that endpoint with the same secretless
110
+ `postVerifiers` body, then use **its** `verifier_id` for the private handoff.
111
+ The REST registration has no `access` field; the protected connection is
112
+ established by `verifier_connection`. Give `continue_url` only to the intended
113
+ person through your host's private browser handoff. The callback below belongs
114
+ to that host and must not log or expose the URL. The endpoint key is never an
115
+ argument to these methods.
116
+
117
+ ```python
118
+ import uuid
119
+ from urllib.parse import urlsplit
120
+
121
+ def start_protected(client, verifier_id, trusted_app_origin, open_private_url):
122
+ intent = client.verifier_connection.create_intent(
123
+ verifier_id, {"kind": "preset_days", "days": 90},
124
+ idempotency_key=str(uuid.uuid4()))
125
+ expected = urlsplit(trusted_app_origin)
126
+ actual = urlsplit(intent["continue_url"])
127
+ if (actual.scheme, actual.netloc) != (expected.scheme, expected.netloc):
128
+ raise ValueError("Unexpected private-entry origin")
129
+ open_private_url(intent["continue_url"])
130
+ return intent["intent_id"]
131
+
132
+ def continue_protected(client, verifier_id, operation_key):
133
+ return client.verifier_connection.continue_connection(
134
+ verifier_id, operation_key=operation_key,
135
+ idempotency_key=str(uuid.uuid4()))
136
+ ```
137
+
138
+ `enter_key` means private entry is still needed. `resume` means retain the same
139
+ operation key and use a fresh request idempotency key. `start_again` means the
140
+ endpoint test failed; correct the endpoint or key and create a new intent.
141
+ `inspect` means another connection change intervened. `refused` means the server
142
+ rejected the change with a non-retryable client error; read `outcome.error["status"]`
143
+ and `outcome.error["title"]`, inspect that operation, and correct the cause before
144
+ another action. A `401` or `403` raises a `ProblemError` instead of returning a
145
+ lifecycle outcome. Only a read-backed `done`
146
+ confirms completion. The async `AsyncSolver.verifier_connection` has the same
147
+ methods, awaited. Replacement and restoration use a new intent and the same
148
+ private-entry path. Disconnect requires explicit caller confirmation:
149
+ `client.verifier_connection.disconnect(verifier_id, operation_key=...,
150
+ idempotency_key=..., confirmed=True)`. Millwork stopping use of a key does not
151
+ revoke it at your endpoint. A governed run requires separate authorization and
152
+ 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
@@ -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.
@@ -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