struai 0.0.1__py3-none-any.whl → 0.2.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.
- struai/__init__.py +112 -2
- struai/_base.py +360 -0
- struai/_client.py +112 -0
- struai/_exceptions.py +103 -0
- struai/_version.py +2 -0
- struai/models/__init__.py +62 -0
- struai/models/common.py +36 -0
- struai/models/drawings.py +81 -0
- struai/models/entities.py +62 -0
- struai/models/projects.py +83 -0
- struai/models/search.py +70 -0
- struai/py.typed +0 -0
- struai/resources/__init__.py +5 -0
- struai/resources/drawings.py +122 -0
- struai/resources/projects.py +628 -0
- struai-0.2.0.dist-info/METADATA +151 -0
- struai-0.2.0.dist-info/RECORD +18 -0
- struai-0.0.1.dist-info/METADATA +0 -17
- struai-0.0.1.dist-info/RECORD +0 -4
- {struai-0.0.1.dist-info → struai-0.2.0.dist-info}/WHEEL +0 -0
struai/__init__.py
CHANGED
|
@@ -1,2 +1,112 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
1
|
+
"""StruAI Python SDK - Drawing Analysis API client.
|
|
2
|
+
|
|
3
|
+
Example:
|
|
4
|
+
>>> from struai import StruAI
|
|
5
|
+
>>>
|
|
6
|
+
>>> client = StruAI(api_key="sk-xxx")
|
|
7
|
+
>>>
|
|
8
|
+
>>> # Tier 1: Raw detection ($0.02/page)
|
|
9
|
+
>>> result = client.drawings.analyze("plans.pdf", page=4)
|
|
10
|
+
>>> for leader in result.annotations.leaders:
|
|
11
|
+
... print(leader.texts_inside[0].text)
|
|
12
|
+
>>>
|
|
13
|
+
>>> # Tier 2: Graph + Search ($0.15/page)
|
|
14
|
+
>>> project = client.projects.create("Building A")
|
|
15
|
+
>>> job = project.sheets.add("plans.pdf", page=4)
|
|
16
|
+
>>> job.wait()
|
|
17
|
+
>>> results = project.search("W12x26 beam connections")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from ._client import StruAI, AsyncStruAI
|
|
22
|
+
from ._exceptions import (
|
|
23
|
+
StruAIError,
|
|
24
|
+
APIError,
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
PermissionDeniedError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
ValidationError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
InternalServerError,
|
|
31
|
+
TimeoutError,
|
|
32
|
+
ConnectionError,
|
|
33
|
+
JobFailedError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Re-export commonly used models
|
|
37
|
+
from .models import (
|
|
38
|
+
# Common
|
|
39
|
+
Point,
|
|
40
|
+
BBox,
|
|
41
|
+
TextSpan,
|
|
42
|
+
Dimensions,
|
|
43
|
+
# Tier 1 - Drawings
|
|
44
|
+
DrawingResult,
|
|
45
|
+
Annotations,
|
|
46
|
+
Leader,
|
|
47
|
+
SectionTag,
|
|
48
|
+
DetailTag,
|
|
49
|
+
RevisionTriangle,
|
|
50
|
+
RevisionCloud,
|
|
51
|
+
TitleBlock,
|
|
52
|
+
# Tier 2 - Projects
|
|
53
|
+
Project,
|
|
54
|
+
Sheet,
|
|
55
|
+
JobStatus,
|
|
56
|
+
SheetResult,
|
|
57
|
+
# Search
|
|
58
|
+
SearchResponse,
|
|
59
|
+
SearchHit,
|
|
60
|
+
QueryResponse,
|
|
61
|
+
# Entities
|
|
62
|
+
Entity,
|
|
63
|
+
EntityListItem,
|
|
64
|
+
EntityRelation,
|
|
65
|
+
Fact,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
# Version
|
|
70
|
+
"__version__",
|
|
71
|
+
# Clients
|
|
72
|
+
"StruAI",
|
|
73
|
+
"AsyncStruAI",
|
|
74
|
+
# Exceptions
|
|
75
|
+
"StruAIError",
|
|
76
|
+
"APIError",
|
|
77
|
+
"AuthenticationError",
|
|
78
|
+
"PermissionDeniedError",
|
|
79
|
+
"NotFoundError",
|
|
80
|
+
"ValidationError",
|
|
81
|
+
"RateLimitError",
|
|
82
|
+
"InternalServerError",
|
|
83
|
+
"TimeoutError",
|
|
84
|
+
"ConnectionError",
|
|
85
|
+
"JobFailedError",
|
|
86
|
+
# Models - Common
|
|
87
|
+
"Point",
|
|
88
|
+
"BBox",
|
|
89
|
+
"TextSpan",
|
|
90
|
+
"Dimensions",
|
|
91
|
+
# Models - Tier 1
|
|
92
|
+
"DrawingResult",
|
|
93
|
+
"Annotations",
|
|
94
|
+
"Leader",
|
|
95
|
+
"SectionTag",
|
|
96
|
+
"DetailTag",
|
|
97
|
+
"RevisionTriangle",
|
|
98
|
+
"RevisionCloud",
|
|
99
|
+
"TitleBlock",
|
|
100
|
+
# Models - Tier 2
|
|
101
|
+
"Project",
|
|
102
|
+
"Sheet",
|
|
103
|
+
"JobStatus",
|
|
104
|
+
"SheetResult",
|
|
105
|
+
"SearchResponse",
|
|
106
|
+
"SearchHit",
|
|
107
|
+
"QueryResponse",
|
|
108
|
+
"Entity",
|
|
109
|
+
"EntityListItem",
|
|
110
|
+
"EntityRelation",
|
|
111
|
+
"Fact",
|
|
112
|
+
]
|
struai/_base.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Base HTTP client with retry logic."""
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Dict, Optional, Type, TypeVar, Union
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from ._exceptions import (
|
|
10
|
+
APIError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
ConnectionError,
|
|
13
|
+
InternalServerError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
PermissionDeniedError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
TimeoutError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
)
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T", bound=BaseModel)
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.stru.ai"
|
|
25
|
+
DEFAULT_TIMEOUT = 60.0
|
|
26
|
+
DEFAULT_MAX_RETRIES = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_base_url(base_url: str) -> str:
|
|
30
|
+
trimmed = base_url.rstrip("/")
|
|
31
|
+
parsed = urlparse(trimmed)
|
|
32
|
+
if parsed.scheme and parsed.netloc:
|
|
33
|
+
path = parsed.path.rstrip("/")
|
|
34
|
+
if path in ("", "/"):
|
|
35
|
+
return f"{trimmed}/v1"
|
|
36
|
+
return trimmed
|
|
37
|
+
if trimmed.endswith("/v1"):
|
|
38
|
+
return trimmed
|
|
39
|
+
return f"{trimmed}/v1"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BaseClient:
|
|
43
|
+
"""Base HTTP client with retry logic."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api_key: str,
|
|
48
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
49
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
50
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
51
|
+
):
|
|
52
|
+
self.api_key = api_key
|
|
53
|
+
self.base_url = _normalize_base_url(base_url)
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
self.max_retries = max_retries
|
|
56
|
+
self._client: Optional[httpx.Client] = None
|
|
57
|
+
|
|
58
|
+
def _get_client(self) -> httpx.Client:
|
|
59
|
+
if self._client is None:
|
|
60
|
+
self._client = httpx.Client(
|
|
61
|
+
base_url=self.base_url,
|
|
62
|
+
headers=self._default_headers(),
|
|
63
|
+
timeout=self.timeout,
|
|
64
|
+
)
|
|
65
|
+
return self._client
|
|
66
|
+
|
|
67
|
+
def _default_headers(self) -> Dict[str, str]:
|
|
68
|
+
return {
|
|
69
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
70
|
+
"User-Agent": f"struai-python/{__version__}",
|
|
71
|
+
"Accept": "application/json",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def _handle_response_error(self, response: httpx.Response) -> None:
|
|
75
|
+
"""Raise appropriate exception for error responses."""
|
|
76
|
+
if response.status_code < 400:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
body = response.json()
|
|
81
|
+
error = body.get("error", {})
|
|
82
|
+
message = error.get("message", response.text)
|
|
83
|
+
code = error.get("code")
|
|
84
|
+
except Exception:
|
|
85
|
+
message = response.text
|
|
86
|
+
code = None
|
|
87
|
+
|
|
88
|
+
request_id = response.headers.get("x-request-id")
|
|
89
|
+
|
|
90
|
+
exc_map = {
|
|
91
|
+
401: AuthenticationError,
|
|
92
|
+
403: PermissionDeniedError,
|
|
93
|
+
404: NotFoundError,
|
|
94
|
+
422: ValidationError,
|
|
95
|
+
429: RateLimitError,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if response.status_code in exc_map:
|
|
99
|
+
exc_class = exc_map[response.status_code]
|
|
100
|
+
elif response.status_code >= 500:
|
|
101
|
+
exc_class = InternalServerError
|
|
102
|
+
else:
|
|
103
|
+
exc_class = APIError
|
|
104
|
+
|
|
105
|
+
if exc_class == RateLimitError:
|
|
106
|
+
retry_after = int(response.headers.get("Retry-After", 30))
|
|
107
|
+
raise RateLimitError(
|
|
108
|
+
message,
|
|
109
|
+
status_code=response.status_code,
|
|
110
|
+
code=code,
|
|
111
|
+
request_id=request_id,
|
|
112
|
+
response=response,
|
|
113
|
+
retry_after=retry_after,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
raise exc_class(
|
|
117
|
+
message,
|
|
118
|
+
status_code=response.status_code,
|
|
119
|
+
code=code,
|
|
120
|
+
request_id=request_id,
|
|
121
|
+
response=response,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _request(
|
|
125
|
+
self,
|
|
126
|
+
method: str,
|
|
127
|
+
path: str,
|
|
128
|
+
*,
|
|
129
|
+
json: Optional[Dict[str, Any]] = None,
|
|
130
|
+
data: Optional[Dict[str, Any]] = None,
|
|
131
|
+
files: Optional[Dict[str, Any]] = None,
|
|
132
|
+
params: Optional[Dict[str, Any]] = None,
|
|
133
|
+
cast_to: Optional[Type[T]] = None,
|
|
134
|
+
) -> Union[T, Dict[str, Any], None]:
|
|
135
|
+
"""Make HTTP request with retry logic."""
|
|
136
|
+
client = self._get_client()
|
|
137
|
+
last_error: Optional[Exception] = None
|
|
138
|
+
|
|
139
|
+
for attempt in range(self.max_retries + 1):
|
|
140
|
+
try:
|
|
141
|
+
if files:
|
|
142
|
+
response = client.request(
|
|
143
|
+
method, path, data=data, files=files, params=params
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
response = client.request(method, path, json=json, params=params)
|
|
147
|
+
|
|
148
|
+
self._handle_response_error(response)
|
|
149
|
+
|
|
150
|
+
# Handle empty responses (DELETE)
|
|
151
|
+
if response.status_code == 204 or not response.content:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
result = response.json()
|
|
155
|
+
if cast_to is not None:
|
|
156
|
+
return cast_to.model_validate(result)
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
except httpx.TimeoutException as e:
|
|
160
|
+
last_error = TimeoutError(f"Request timed out: {e}")
|
|
161
|
+
except httpx.ConnectError as e:
|
|
162
|
+
last_error = ConnectionError(f"Connection failed: {e}")
|
|
163
|
+
except (RateLimitError, InternalServerError) as e:
|
|
164
|
+
last_error = e
|
|
165
|
+
if attempt < self.max_retries:
|
|
166
|
+
wait = getattr(e, "retry_after", 2 ** (attempt + 1))
|
|
167
|
+
time.sleep(min(wait, 30))
|
|
168
|
+
continue
|
|
169
|
+
raise
|
|
170
|
+
except APIError:
|
|
171
|
+
raise
|
|
172
|
+
|
|
173
|
+
if attempt < self.max_retries:
|
|
174
|
+
time.sleep(2**attempt)
|
|
175
|
+
else:
|
|
176
|
+
raise last_error
|
|
177
|
+
|
|
178
|
+
raise last_error # type: ignore
|
|
179
|
+
|
|
180
|
+
def get(self, path: str, **kwargs) -> Any:
|
|
181
|
+
return self._request("GET", path, **kwargs)
|
|
182
|
+
|
|
183
|
+
def post(self, path: str, **kwargs) -> Any:
|
|
184
|
+
return self._request("POST", path, **kwargs)
|
|
185
|
+
|
|
186
|
+
def delete(self, path: str, **kwargs) -> Any:
|
|
187
|
+
return self._request("DELETE", path, **kwargs)
|
|
188
|
+
|
|
189
|
+
def close(self) -> None:
|
|
190
|
+
if self._client is not None:
|
|
191
|
+
self._client.close()
|
|
192
|
+
self._client = None
|
|
193
|
+
|
|
194
|
+
def __enter__(self):
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def __exit__(self, *args):
|
|
198
|
+
self.close()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class AsyncBaseClient:
|
|
202
|
+
"""Async base HTTP client with retry logic."""
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
api_key: str,
|
|
207
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
208
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
209
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
210
|
+
):
|
|
211
|
+
self.api_key = api_key
|
|
212
|
+
self.base_url = _normalize_base_url(base_url)
|
|
213
|
+
self.timeout = timeout
|
|
214
|
+
self.max_retries = max_retries
|
|
215
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
216
|
+
|
|
217
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
218
|
+
if self._client is None:
|
|
219
|
+
self._client = httpx.AsyncClient(
|
|
220
|
+
base_url=self.base_url,
|
|
221
|
+
headers=self._default_headers(),
|
|
222
|
+
timeout=self.timeout,
|
|
223
|
+
)
|
|
224
|
+
return self._client
|
|
225
|
+
|
|
226
|
+
def _default_headers(self) -> Dict[str, str]:
|
|
227
|
+
return {
|
|
228
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
229
|
+
"User-Agent": f"struai-python/{__version__}",
|
|
230
|
+
"Accept": "application/json",
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def _handle_response_error(self, response: httpx.Response) -> None:
|
|
234
|
+
"""Raise appropriate exception for error responses."""
|
|
235
|
+
if response.status_code < 400:
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
body = response.json()
|
|
240
|
+
error = body.get("error", {})
|
|
241
|
+
message = error.get("message", response.text)
|
|
242
|
+
code = error.get("code")
|
|
243
|
+
except Exception:
|
|
244
|
+
message = response.text
|
|
245
|
+
code = None
|
|
246
|
+
|
|
247
|
+
request_id = response.headers.get("x-request-id")
|
|
248
|
+
|
|
249
|
+
exc_map = {
|
|
250
|
+
401: AuthenticationError,
|
|
251
|
+
403: PermissionDeniedError,
|
|
252
|
+
404: NotFoundError,
|
|
253
|
+
422: ValidationError,
|
|
254
|
+
429: RateLimitError,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if response.status_code in exc_map:
|
|
258
|
+
exc_class = exc_map[response.status_code]
|
|
259
|
+
elif response.status_code >= 500:
|
|
260
|
+
exc_class = InternalServerError
|
|
261
|
+
else:
|
|
262
|
+
exc_class = APIError
|
|
263
|
+
|
|
264
|
+
if exc_class == RateLimitError:
|
|
265
|
+
retry_after = int(response.headers.get("Retry-After", 30))
|
|
266
|
+
raise RateLimitError(
|
|
267
|
+
message,
|
|
268
|
+
status_code=response.status_code,
|
|
269
|
+
code=code,
|
|
270
|
+
request_id=request_id,
|
|
271
|
+
response=response,
|
|
272
|
+
retry_after=retry_after,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
raise exc_class(
|
|
276
|
+
message,
|
|
277
|
+
status_code=response.status_code,
|
|
278
|
+
code=code,
|
|
279
|
+
request_id=request_id,
|
|
280
|
+
response=response,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
async def _request(
|
|
284
|
+
self,
|
|
285
|
+
method: str,
|
|
286
|
+
path: str,
|
|
287
|
+
*,
|
|
288
|
+
json: Optional[Dict[str, Any]] = None,
|
|
289
|
+
data: Optional[Dict[str, Any]] = None,
|
|
290
|
+
files: Optional[Dict[str, Any]] = None,
|
|
291
|
+
params: Optional[Dict[str, Any]] = None,
|
|
292
|
+
cast_to: Optional[Type[T]] = None,
|
|
293
|
+
) -> Union[T, Dict[str, Any], None]:
|
|
294
|
+
"""Make async HTTP request with retry logic."""
|
|
295
|
+
import asyncio
|
|
296
|
+
|
|
297
|
+
client = await self._get_client()
|
|
298
|
+
last_error: Optional[Exception] = None
|
|
299
|
+
|
|
300
|
+
for attempt in range(self.max_retries + 1):
|
|
301
|
+
try:
|
|
302
|
+
if files:
|
|
303
|
+
response = await client.request(
|
|
304
|
+
method, path, data=data, files=files, params=params
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
response = await client.request(
|
|
308
|
+
method, path, json=json, params=params
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
self._handle_response_error(response)
|
|
312
|
+
|
|
313
|
+
if response.status_code == 204 or not response.content:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
result = response.json()
|
|
317
|
+
if cast_to is not None:
|
|
318
|
+
return cast_to.model_validate(result)
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
except httpx.TimeoutException as e:
|
|
322
|
+
last_error = TimeoutError(f"Request timed out: {e}")
|
|
323
|
+
except httpx.ConnectError as e:
|
|
324
|
+
last_error = ConnectionError(f"Connection failed: {e}")
|
|
325
|
+
except (RateLimitError, InternalServerError) as e:
|
|
326
|
+
last_error = e
|
|
327
|
+
if attempt < self.max_retries:
|
|
328
|
+
wait = getattr(e, "retry_after", 2 ** (attempt + 1))
|
|
329
|
+
await asyncio.sleep(min(wait, 30))
|
|
330
|
+
continue
|
|
331
|
+
raise
|
|
332
|
+
except APIError:
|
|
333
|
+
raise
|
|
334
|
+
|
|
335
|
+
if attempt < self.max_retries:
|
|
336
|
+
await asyncio.sleep(2**attempt)
|
|
337
|
+
else:
|
|
338
|
+
raise last_error
|
|
339
|
+
|
|
340
|
+
raise last_error # type: ignore
|
|
341
|
+
|
|
342
|
+
async def get(self, path: str, **kwargs) -> Any:
|
|
343
|
+
return await self._request("GET", path, **kwargs)
|
|
344
|
+
|
|
345
|
+
async def post(self, path: str, **kwargs) -> Any:
|
|
346
|
+
return await self._request("POST", path, **kwargs)
|
|
347
|
+
|
|
348
|
+
async def delete(self, path: str, **kwargs) -> Any:
|
|
349
|
+
return await self._request("DELETE", path, **kwargs)
|
|
350
|
+
|
|
351
|
+
async def close(self) -> None:
|
|
352
|
+
if self._client is not None:
|
|
353
|
+
await self._client.aclose()
|
|
354
|
+
self._client = None
|
|
355
|
+
|
|
356
|
+
async def __aenter__(self):
|
|
357
|
+
return self
|
|
358
|
+
|
|
359
|
+
async def __aexit__(self, *args):
|
|
360
|
+
await self.close()
|
struai/_client.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Main StruAI client classes."""
|
|
2
|
+
import os
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ._base import AsyncBaseClient, BaseClient, DEFAULT_BASE_URL, DEFAULT_TIMEOUT
|
|
7
|
+
from ._exceptions import StruAIError
|
|
8
|
+
from .resources.drawings import AsyncDrawings, Drawings
|
|
9
|
+
from .resources.projects import AsyncProjects, Projects
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StruAI(BaseClient):
|
|
13
|
+
"""StruAI client for drawing analysis API.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
api_key: Your API key. Falls back to STRUAI_API_KEY env var.
|
|
17
|
+
base_url: API base URL. Defaults to https://api.stru.ai.
|
|
18
|
+
If no path is provided, /v1 is appended automatically.
|
|
19
|
+
timeout: Request timeout in seconds. Default 60.
|
|
20
|
+
max_retries: Max retry attempts for failed requests. Default 2.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
>>> client = StruAI(api_key="sk-xxx")
|
|
24
|
+
>>>
|
|
25
|
+
>>> # Tier 1: Raw detection
|
|
26
|
+
>>> result = client.drawings.analyze("structural.pdf", page=4)
|
|
27
|
+
>>> print(result.annotations.leaders[0].texts_inside[0].text)
|
|
28
|
+
'W12x26'
|
|
29
|
+
>>>
|
|
30
|
+
>>> # Tier 2: Graph + Search
|
|
31
|
+
>>> project = client.projects.create("Building A")
|
|
32
|
+
>>> job = project.sheets.add("structural.pdf", page=4)
|
|
33
|
+
>>> job.wait()
|
|
34
|
+
>>> results = project.search("W12x26 beam connections")
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
api_key: Optional[str] = None,
|
|
40
|
+
*,
|
|
41
|
+
base_url: Optional[str] = None,
|
|
42
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
43
|
+
max_retries: int = 2,
|
|
44
|
+
):
|
|
45
|
+
if api_key is None:
|
|
46
|
+
api_key = os.environ.get("STRUAI_API_KEY")
|
|
47
|
+
if api_key is None:
|
|
48
|
+
raise StruAIError(
|
|
49
|
+
"API key required. Pass api_key or set STRUAI_API_KEY environment variable."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
super().__init__(
|
|
53
|
+
api_key=api_key,
|
|
54
|
+
base_url=base_url or DEFAULT_BASE_URL,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
max_retries=max_retries,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@cached_property
|
|
60
|
+
def drawings(self) -> Drawings:
|
|
61
|
+
"""Tier 1: Raw detection API."""
|
|
62
|
+
return Drawings(self)
|
|
63
|
+
|
|
64
|
+
@cached_property
|
|
65
|
+
def projects(self) -> Projects:
|
|
66
|
+
"""Tier 2: Graph + Search API."""
|
|
67
|
+
return Projects(self)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AsyncStruAI(AsyncBaseClient):
|
|
71
|
+
"""Async StruAI client for drawing analysis API.
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
>>> async with AsyncStruAI(api_key="sk-xxx") as client:
|
|
75
|
+
... result = await client.drawings.analyze("structural.pdf", page=4)
|
|
76
|
+
...
|
|
77
|
+
... project = await client.projects.create("Building A")
|
|
78
|
+
... job = await project.sheets.add("structural.pdf", page=4)
|
|
79
|
+
... await job.wait()
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
api_key: Optional[str] = None,
|
|
85
|
+
*,
|
|
86
|
+
base_url: Optional[str] = None,
|
|
87
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
88
|
+
max_retries: int = 2,
|
|
89
|
+
):
|
|
90
|
+
if api_key is None:
|
|
91
|
+
api_key = os.environ.get("STRUAI_API_KEY")
|
|
92
|
+
if api_key is None:
|
|
93
|
+
raise StruAIError(
|
|
94
|
+
"API key required. Pass api_key or set STRUAI_API_KEY environment variable."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
super().__init__(
|
|
98
|
+
api_key=api_key,
|
|
99
|
+
base_url=base_url or DEFAULT_BASE_URL,
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
max_retries=max_retries,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@cached_property
|
|
105
|
+
def drawings(self) -> AsyncDrawings:
|
|
106
|
+
"""Tier 1: Raw detection API."""
|
|
107
|
+
return AsyncDrawings(self)
|
|
108
|
+
|
|
109
|
+
@cached_property
|
|
110
|
+
def projects(self) -> AsyncProjects:
|
|
111
|
+
"""Tier 2: Graph + Search API."""
|
|
112
|
+
return AsyncProjects(self)
|
struai/_exceptions.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""StruAI exceptions."""
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StruAIError(Exception):
|
|
8
|
+
"""Base exception for all StruAI errors."""
|
|
9
|
+
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIError(StruAIError):
|
|
14
|
+
"""API returned an error response."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
*,
|
|
20
|
+
status_code: Optional[int] = None,
|
|
21
|
+
code: Optional[str] = None,
|
|
22
|
+
request_id: Optional[str] = None,
|
|
23
|
+
response: Optional[httpx.Response] = None,
|
|
24
|
+
):
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.message = message
|
|
27
|
+
self.status_code = status_code
|
|
28
|
+
self.code = code
|
|
29
|
+
self.request_id = request_id
|
|
30
|
+
self.response = response
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
parts = [self.message]
|
|
34
|
+
if self.code:
|
|
35
|
+
parts.append(f"code={self.code}")
|
|
36
|
+
if self.status_code:
|
|
37
|
+
parts.append(f"status={self.status_code}")
|
|
38
|
+
return " ".join(parts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthenticationError(APIError):
|
|
42
|
+
"""Invalid or missing API key (401)."""
|
|
43
|
+
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PermissionDeniedError(APIError):
|
|
48
|
+
"""Insufficient permissions (403)."""
|
|
49
|
+
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NotFoundError(APIError):
|
|
54
|
+
"""Resource not found (404)."""
|
|
55
|
+
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ValidationError(APIError):
|
|
60
|
+
"""Invalid request parameters (422)."""
|
|
61
|
+
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RateLimitError(APIError):
|
|
66
|
+
"""Rate limit exceeded (429)."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
message: str,
|
|
71
|
+
*,
|
|
72
|
+
retry_after: Optional[int] = None,
|
|
73
|
+
**kwargs,
|
|
74
|
+
):
|
|
75
|
+
super().__init__(message, **kwargs)
|
|
76
|
+
self.retry_after = retry_after
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class InternalServerError(APIError):
|
|
80
|
+
"""Server error (5xx)."""
|
|
81
|
+
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TimeoutError(StruAIError):
|
|
86
|
+
"""Request timed out."""
|
|
87
|
+
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ConnectionError(StruAIError):
|
|
92
|
+
"""Network connection failed."""
|
|
93
|
+
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class JobFailedError(StruAIError):
|
|
98
|
+
"""Async job failed."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, message: str, *, job_id: str, error: str):
|
|
101
|
+
super().__init__(message)
|
|
102
|
+
self.job_id = job_id
|
|
103
|
+
self.error = error
|
struai/_version.py
ADDED