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 CHANGED
@@ -1,2 +1,112 @@
1
- """struai"""
2
- __version__ = "0.0.1"
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
@@ -0,0 +1,2 @@
1
+ """Version information."""
2
+ __version__ = "0.2.0"