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