millwork-solver 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ """Repository-internal sync and async clients over the shared wire policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Iterator, Mapping
6
+ from typing import Any, Awaitable, Callable
7
+
8
+ from ..types import Page
9
+ from .catalog import _OperationCatalog
10
+ from .errors import _ClientClosedError, _ProtocolResponseError
11
+ from .transport import _AsyncTransport, _SyncTransport, _TransportPolicy, _prepare_request
12
+
13
+
14
+ def _page_from_response(response: Any, item_field: str) -> Page:
15
+ if not isinstance(response, dict):
16
+ raise _ProtocolResponseError("paginated response must be an object")
17
+ items = response.get(item_field)
18
+ cursor = response.get("next_cursor")
19
+ if not isinstance(items, list) or (cursor is not None and not isinstance(cursor, str)):
20
+ raise _ProtocolResponseError("paginated response has the wrong shape")
21
+ return Page(tuple(items), cursor)
22
+
23
+
24
+ def _events_from_response(response: Any) -> tuple[Mapping[str, Any], ...]:
25
+ if not isinstance(response, dict) or not isinstance(response.get("events"), list):
26
+ raise _ProtocolResponseError("events response has the wrong shape")
27
+ events = response["events"]
28
+ if any(not isinstance(event, dict) for event in events):
29
+ raise _ProtocolResponseError("event entries must be objects")
30
+ return tuple(events)
31
+
32
+
33
+ def _is_terminal_event(event: Mapping[str, Any]) -> bool:
34
+ return (
35
+ event.get("type") == "lifecycle"
36
+ and event.get("slice_id") is None
37
+ and event.get("state") in {"completed", "failed", "cancelled", "expired"}
38
+ )
39
+
40
+
41
+ class _SyncClient:
42
+ def __init__(
43
+ self,
44
+ catalog: _OperationCatalog,
45
+ policy: _TransportPolicy,
46
+ transport: _SyncTransport,
47
+ *,
48
+ close_transport: Callable[[], None],
49
+ sleep: Callable[[float], None],
50
+ ) -> None:
51
+ self._catalog = catalog
52
+ self._policy = policy
53
+ self._transport = transport
54
+ self._close_transport = close_transport
55
+ self._sleep = sleep
56
+ self._closed = False
57
+
58
+ def _ensure_open(self) -> None:
59
+ if self._closed:
60
+ raise _ClientClosedError("client is closed")
61
+
62
+ def _resolve_fixture_request(self, method: str, path: str) -> tuple[str, Mapping[str, str]]:
63
+ return self._catalog.match_fixture_request(method, path)
64
+
65
+ def request(
66
+ self,
67
+ operation_id: str,
68
+ *,
69
+ path_parameters: Mapping[str, object] | None = None,
70
+ query: Mapping[str, object] | None = None,
71
+ body: object | None = None,
72
+ idempotency_key: str | None = None,
73
+ ) -> Any:
74
+ self._ensure_open()
75
+ operation = self._catalog.operation(operation_id)
76
+ prepared = _prepare_request(
77
+ operation_id=operation.operation_id,
78
+ method=operation.method,
79
+ path=operation.bind_path(path_parameters),
80
+ query=query,
81
+ body=body,
82
+ idempotency_key=idempotency_key,
83
+ policy=self._policy,
84
+ )
85
+ return self._transport.execute(prepared)
86
+
87
+ def pages(
88
+ self,
89
+ operation_id: str,
90
+ *,
91
+ item_field: str,
92
+ path_parameters: Mapping[str, object] | None = None,
93
+ query: Mapping[str, object] | None = None,
94
+ ) -> Iterator[Page]:
95
+ cursor: str | None = None
96
+ while True:
97
+ page_query = dict(query or {})
98
+ if cursor is not None:
99
+ page_query["cursor"] = cursor
100
+ page = _page_from_response(
101
+ self.request(
102
+ operation_id,
103
+ path_parameters=path_parameters,
104
+ query=page_query,
105
+ ),
106
+ item_field,
107
+ )
108
+ yield page
109
+ if page.next_cursor is None:
110
+ return
111
+ cursor = page.next_cursor
112
+
113
+ def events(
114
+ self,
115
+ *,
116
+ path_parameters: Mapping[str, object],
117
+ poll_interval_seconds: float = 1.0,
118
+ ) -> Iterator[Mapping[str, Any]]:
119
+ if poll_interval_seconds < 0:
120
+ raise ValueError("poll_interval_seconds must not be negative")
121
+ after: str | None = None
122
+ while True:
123
+ events = _events_from_response(
124
+ self.request(
125
+ self._catalog.event_operation_id,
126
+ path_parameters=path_parameters,
127
+ query={"after": after},
128
+ )
129
+ )
130
+ if not events:
131
+ self._sleep(poll_interval_seconds)
132
+ for event in events:
133
+ yield event
134
+ event_id = event.get("event_id")
135
+ if not isinstance(event_id, str):
136
+ raise _ProtocolResponseError("event_id must be a string")
137
+ after = event_id
138
+ if _is_terminal_event(event):
139
+ return
140
+
141
+ def close(self) -> None:
142
+ if not self._closed:
143
+ self._closed = True
144
+ self._close_transport()
145
+
146
+ def __enter__(self) -> "_SyncClient":
147
+ self._ensure_open()
148
+ return self
149
+
150
+ def __exit__(self, *_: object) -> None:
151
+ self.close()
152
+
153
+
154
+ class _AsyncClient:
155
+ def __init__(
156
+ self,
157
+ catalog: _OperationCatalog,
158
+ policy: _TransportPolicy,
159
+ transport: _AsyncTransport,
160
+ *,
161
+ close_transport: Callable[[], Awaitable[None]],
162
+ sleep: Callable[[float], Awaitable[None]],
163
+ ) -> None:
164
+ self._catalog = catalog
165
+ self._policy = policy
166
+ self._transport = transport
167
+ self._close_transport = close_transport
168
+ self._sleep = sleep
169
+ self._closed = False
170
+
171
+ def _ensure_open(self) -> None:
172
+ if self._closed:
173
+ raise _ClientClosedError("client is closed")
174
+
175
+ def _resolve_fixture_request(self, method: str, path: str) -> tuple[str, Mapping[str, str]]:
176
+ return self._catalog.match_fixture_request(method, path)
177
+
178
+ async def request(
179
+ self,
180
+ operation_id: str,
181
+ *,
182
+ path_parameters: Mapping[str, object] | None = None,
183
+ query: Mapping[str, object] | None = None,
184
+ body: object | None = None,
185
+ idempotency_key: str | None = None,
186
+ ) -> Any:
187
+ self._ensure_open()
188
+ operation = self._catalog.operation(operation_id)
189
+ prepared = _prepare_request(
190
+ operation_id=operation.operation_id,
191
+ method=operation.method,
192
+ path=operation.bind_path(path_parameters),
193
+ query=query,
194
+ body=body,
195
+ idempotency_key=idempotency_key,
196
+ policy=self._policy,
197
+ )
198
+ return await self._transport.execute(prepared)
199
+
200
+ async def pages(
201
+ self,
202
+ operation_id: str,
203
+ *,
204
+ item_field: str,
205
+ path_parameters: Mapping[str, object] | None = None,
206
+ query: Mapping[str, object] | None = None,
207
+ ) -> AsyncIterator[Page]:
208
+ cursor: str | None = None
209
+ while True:
210
+ page_query = dict(query or {})
211
+ if cursor is not None:
212
+ page_query["cursor"] = cursor
213
+ page = _page_from_response(
214
+ await self.request(
215
+ operation_id,
216
+ path_parameters=path_parameters,
217
+ query=page_query,
218
+ ),
219
+ item_field,
220
+ )
221
+ yield page
222
+ if page.next_cursor is None:
223
+ return
224
+ cursor = page.next_cursor
225
+
226
+ async def events(
227
+ self,
228
+ *,
229
+ path_parameters: Mapping[str, object],
230
+ poll_interval_seconds: float = 1.0,
231
+ ) -> AsyncIterator[Mapping[str, Any]]:
232
+ if poll_interval_seconds < 0:
233
+ raise ValueError("poll_interval_seconds must not be negative")
234
+ after: str | None = None
235
+ while True:
236
+ events = _events_from_response(
237
+ await self.request(
238
+ self._catalog.event_operation_id,
239
+ path_parameters=path_parameters,
240
+ query={"after": after},
241
+ )
242
+ )
243
+ if not events:
244
+ await self._sleep(poll_interval_seconds)
245
+ for event in events:
246
+ yield event
247
+ event_id = event.get("event_id")
248
+ if not isinstance(event_id, str):
249
+ raise _ProtocolResponseError("event_id must be a string")
250
+ after = event_id
251
+ if _is_terminal_event(event):
252
+ return
253
+
254
+ async def aclose(self) -> None:
255
+ if not self._closed:
256
+ self._closed = True
257
+ await self._close_transport()
258
+
259
+ async def __aenter__(self) -> "_AsyncClient":
260
+ self._ensure_open()
261
+ return self
262
+
263
+ async def __aexit__(self, *_: object) -> None:
264
+ await self.aclose()
@@ -0,0 +1,11 @@
1
+ """Private compatibility names bound to the stable public error classes."""
2
+
3
+ from ..errors import (
4
+ ClientClosedError as _ClientClosedError,
5
+ NetworkError as _NetworkTransportError,
6
+ OperationBoundaryError as _OperationBoundaryError,
7
+ ProblemError as _ProblemResponseError,
8
+ ProtocolError as _ProtocolResponseError,
9
+ )
10
+
11
+ __all__: tuple[str, ...] = ()
@@ -0,0 +1,274 @@
1
+ """Shared safe-transport policy with distinct sync and async HTTPX execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Any, Awaitable, Callable, Mapping
10
+
11
+ import httpx
12
+
13
+ from .errors import _NetworkTransportError, _ProblemResponseError, _ProtocolResponseError
14
+
15
+
16
+ class _RetryBoundary(Enum):
17
+ SAFE_READ = "safe_read"
18
+ SAME_KEY_MUTATION = "same_key_mutation"
19
+ SINGLE_ATTEMPT_MUTATION = "single_attempt_mutation"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class _TransportPolicy:
24
+ safe_methods: frozenset[str]
25
+ maximum_retries: int
26
+ backoff_seconds: float
27
+
28
+ @classmethod
29
+ def from_repository(
30
+ cls,
31
+ repository_root: Path,
32
+ *,
33
+ backoff_seconds: float,
34
+ maximum_retries: int | None = None,
35
+ ) -> "_TransportPolicy":
36
+ fixture_path = repository_root / "contracts/developer-experience/fixtures/retry-error-idempotency.json"
37
+ with fixture_path.open("r", encoding="utf-8") as file:
38
+ fixture = json.load(file)
39
+ policy = fixture.get("policy", {})
40
+ if policy.get("safe_methods") != ["GET", "HEAD"]:
41
+ raise _ProtocolResponseError("F1 safe-method policy drifted")
42
+ if policy.get("retryable_failures") != ["network_error", "http_5xx"]:
43
+ raise _ProtocolResponseError("F1 retryable-failure policy drifted")
44
+ fixture_maximum_retries = policy.get("maximum_retries")
45
+ if not isinstance(fixture_maximum_retries, int) or fixture_maximum_retries < 0:
46
+ raise _ProtocolResponseError("F1 maximum-retries policy drifted")
47
+ selected_maximum_retries = (
48
+ fixture_maximum_retries if maximum_retries is None else maximum_retries
49
+ )
50
+ if not isinstance(selected_maximum_retries, int) or selected_maximum_retries < 0:
51
+ raise ValueError("maximum_retries must be a non-negative integer")
52
+ if backoff_seconds < 0:
53
+ raise ValueError("backoff_seconds must not be negative")
54
+ return cls(frozenset(policy["safe_methods"]), selected_maximum_retries, backoff_seconds)
55
+
56
+ @classmethod
57
+ def from_snapshot(
58
+ cls,
59
+ snapshot_path: Path,
60
+ *,
61
+ backoff_seconds: float,
62
+ maximum_retries: int | None = None,
63
+ ) -> "_TransportPolicy":
64
+ with snapshot_path.open("r", encoding="utf-8") as file:
65
+ snapshot = json.load(file)
66
+ policy = snapshot.get("retry_policy", {})
67
+ if policy.get("safe_methods") != ["GET", "HEAD"]:
68
+ raise _ProtocolResponseError("installed F1 safe-method policy drifted")
69
+ if policy.get("retryable_failures") != ["network_error", "http_5xx"]:
70
+ raise _ProtocolResponseError("installed F1 retryable-failure policy drifted")
71
+ default_maximum_retries = policy.get("maximum_retries")
72
+ if not isinstance(default_maximum_retries, int) or default_maximum_retries < 0:
73
+ raise _ProtocolResponseError("installed F1 maximum-retries policy drifted")
74
+ selected_maximum_retries = (
75
+ default_maximum_retries if maximum_retries is None else maximum_retries
76
+ )
77
+ if not isinstance(selected_maximum_retries, int) or selected_maximum_retries < 0:
78
+ raise ValueError("maximum_retries must be a non-negative integer")
79
+ if backoff_seconds < 0:
80
+ raise ValueError("backoff_seconds must not be negative")
81
+ return cls(frozenset(policy["safe_methods"]), selected_maximum_retries, backoff_seconds)
82
+
83
+ def classify(self, method: str, idempotency_key: str | None) -> _RetryBoundary:
84
+ if method in self.safe_methods:
85
+ return _RetryBoundary.SAFE_READ
86
+ if idempotency_key is not None and idempotency_key.strip():
87
+ return _RetryBoundary.SAME_KEY_MUTATION
88
+ return _RetryBoundary.SINGLE_ATTEMPT_MUTATION
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class _PreparedRequest:
93
+ operation_id: str
94
+ method: str
95
+ path: str
96
+ query_items: tuple[tuple[str, str], ...]
97
+ header_items: tuple[tuple[str, str], ...]
98
+ body_bytes: bytes | None
99
+ retry_boundary: _RetryBoundary
100
+
101
+
102
+ def _encode_query_value(value: object) -> str:
103
+ if isinstance(value, bool):
104
+ return "true" if value else "false"
105
+ if isinstance(value, str):
106
+ return value
107
+ if isinstance(value, int):
108
+ return str(value)
109
+ if isinstance(value, float):
110
+ try:
111
+ return json.dumps(value, allow_nan=False)
112
+ except ValueError as error:
113
+ raise TypeError("query numbers must be finite JSON scalars") from error
114
+ raise TypeError("query values must be string, number, boolean, or None")
115
+
116
+
117
+ def _prepare_request(
118
+ *,
119
+ operation_id: str,
120
+ method: str,
121
+ path: str,
122
+ query: Mapping[str, object] | None,
123
+ body: object | None,
124
+ idempotency_key: str | None,
125
+ policy: _TransportPolicy,
126
+ ) -> _PreparedRequest:
127
+ body_bytes = None
128
+ if body is not None:
129
+ body_bytes = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
130
+ headers = {"accept": "application/json"}
131
+ if body_bytes is not None:
132
+ headers["content-type"] = "application/json"
133
+ if idempotency_key is not None:
134
+ headers["idempotency-key"] = idempotency_key
135
+ query_items = tuple(
136
+ (name, _encode_query_value(value))
137
+ for name, value in (query or {}).items()
138
+ if value is not None
139
+ )
140
+ return _PreparedRequest(
141
+ operation_id=operation_id,
142
+ method=method,
143
+ path=path,
144
+ query_items=query_items,
145
+ header_items=tuple(headers.items()),
146
+ body_bytes=body_bytes,
147
+ retry_boundary=policy.classify(method, idempotency_key),
148
+ )
149
+
150
+
151
+ def _decode_response(response: httpx.Response, method: str) -> Any:
152
+ if 200 <= response.status_code < 300:
153
+ if response.status_code == 204 or method == "HEAD":
154
+ return None
155
+ try:
156
+ return response.json()
157
+ except (json.JSONDecodeError, UnicodeDecodeError) as error:
158
+ raise _ProtocolResponseError("success response is not valid JSON") from error
159
+
160
+ content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
161
+ if content_type != "application/problem+json":
162
+ raise _ProtocolResponseError("error response is not application/problem+json")
163
+ try:
164
+ problem = response.json()
165
+ except (json.JSONDecodeError, UnicodeDecodeError) as error:
166
+ raise _ProtocolResponseError("Problem response is not valid JSON") from error
167
+ if not isinstance(problem, dict):
168
+ raise _ProtocolResponseError("Problem response must be an object")
169
+ required_types = {
170
+ "type": str,
171
+ "title": str,
172
+ "status": int,
173
+ "instance": str,
174
+ }
175
+ for field, expected_type in required_types.items():
176
+ if not isinstance(problem.get(field), expected_type):
177
+ raise _ProtocolResponseError(f"Problem field {field} has the wrong type")
178
+ if problem["status"] != response.status_code:
179
+ raise _ProtocolResponseError("Problem status differs from HTTP status")
180
+ if "retry_after_s" in problem and not isinstance(problem["retry_after_s"], int):
181
+ raise _ProtocolResponseError("Problem retry_after_s has the wrong type")
182
+ if "errors" in problem:
183
+ errors = problem["errors"]
184
+ if not isinstance(errors, list) or any(
185
+ not isinstance(item, dict)
186
+ or not isinstance(item.get("field"), str)
187
+ or not isinstance(item.get("message"), str)
188
+ for item in errors
189
+ ):
190
+ raise _ProtocolResponseError("Problem errors have the wrong shape")
191
+ raise _ProblemResponseError(problem)
192
+
193
+
194
+ def _can_retry(prepared: _PreparedRequest, retries_used: int, policy: _TransportPolicy) -> bool:
195
+ return (
196
+ prepared.retry_boundary is not _RetryBoundary.SINGLE_ATTEMPT_MUTATION
197
+ and retries_used < policy.maximum_retries
198
+ )
199
+
200
+
201
+ class _SyncTransport:
202
+ def __init__(
203
+ self,
204
+ client: httpx.Client,
205
+ policy: _TransportPolicy,
206
+ sleep: Callable[[float], None],
207
+ ) -> None:
208
+ self._client = client
209
+ self._policy = policy
210
+ self._sleep = sleep
211
+
212
+ def execute(self, prepared: _PreparedRequest) -> Any:
213
+ retries_used = 0
214
+ while True:
215
+ try:
216
+ response = self._client.request(
217
+ prepared.method,
218
+ prepared.path,
219
+ params=prepared.query_items,
220
+ headers=dict(prepared.header_items),
221
+ content=prepared.body_bytes,
222
+ )
223
+ except httpx.TransportError as error:
224
+ if _can_retry(prepared, retries_used, self._policy):
225
+ self._sleep(self._policy.backoff_seconds * (2 ** retries_used))
226
+ retries_used += 1
227
+ continue
228
+ raise _NetworkTransportError(
229
+ f"{prepared.operation_id} failed after {retries_used + 1} attempt(s)",
230
+ cause=error,
231
+ ) from error
232
+ if response.status_code >= 500 and _can_retry(prepared, retries_used, self._policy):
233
+ self._sleep(self._policy.backoff_seconds * (2 ** retries_used))
234
+ retries_used += 1
235
+ continue
236
+ return _decode_response(response, prepared.method)
237
+
238
+
239
+ class _AsyncTransport:
240
+ def __init__(
241
+ self,
242
+ client: httpx.AsyncClient,
243
+ policy: _TransportPolicy,
244
+ sleep: Callable[[float], Awaitable[None]],
245
+ ) -> None:
246
+ self._client = client
247
+ self._policy = policy
248
+ self._sleep = sleep
249
+
250
+ async def execute(self, prepared: _PreparedRequest) -> Any:
251
+ retries_used = 0
252
+ while True:
253
+ try:
254
+ response = await self._client.request(
255
+ prepared.method,
256
+ prepared.path,
257
+ params=prepared.query_items,
258
+ headers=dict(prepared.header_items),
259
+ content=prepared.body_bytes,
260
+ )
261
+ except httpx.TransportError as error:
262
+ if _can_retry(prepared, retries_used, self._policy):
263
+ await self._sleep(self._policy.backoff_seconds * (2 ** retries_used))
264
+ retries_used += 1
265
+ continue
266
+ raise _NetworkTransportError(
267
+ f"{prepared.operation_id} failed after {retries_used + 1} attempt(s)",
268
+ cause=error,
269
+ ) from error
270
+ if response.status_code >= 500 and _can_retry(prepared, retries_used, self._policy):
271
+ await self._sleep(self._policy.backoff_seconds * (2 ** retries_used))
272
+ retries_used += 1
273
+ continue
274
+ return _decode_response(response, prepared.method)