buildfunctions 0.2.0__py3-none-any.whl → 0.2.1__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.
- buildfunctions/__init__.py +139 -1
- buildfunctions/client.py +282 -0
- buildfunctions/cpu_function.py +167 -0
- buildfunctions/cpu_sandbox.py +393 -0
- buildfunctions/dotdict.py +39 -0
- buildfunctions/errors.py +90 -0
- buildfunctions/framework.py +22 -0
- buildfunctions/gpu_function.py +241 -0
- buildfunctions/gpu_sandbox.py +443 -0
- buildfunctions/http_client.py +97 -0
- buildfunctions/memory.py +28 -0
- buildfunctions/py.typed +0 -0
- buildfunctions/resolve_code.py +109 -0
- buildfunctions/types.py +227 -0
- buildfunctions/uploader.py +198 -0
- buildfunctions-0.2.1.dist-info/METADATA +176 -0
- buildfunctions-0.2.1.dist-info/RECORD +18 -0
- {buildfunctions-0.2.0.dist-info → buildfunctions-0.2.1.dist-info}/WHEEL +1 -2
- buildfunctions/api.py +0 -2
- buildfunctions-0.2.0.dist-info/METADATA +0 -6
- buildfunctions-0.2.0.dist-info/RECORD +0 -6
- buildfunctions-0.2.0.dist-info/top_level.txt +0 -1
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""CPU Sandbox - Hardware-isolated execution environment for untrusted AI actions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import socket
|
|
8
|
+
import struct
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from buildfunctions.dotdict import DotDict
|
|
15
|
+
from buildfunctions.errors import BuildfunctionsError, ValidationError
|
|
16
|
+
from buildfunctions.memory import parse_memory
|
|
17
|
+
from buildfunctions.resolve_code import resolve_code
|
|
18
|
+
from buildfunctions.types import CPUSandboxConfig, CPUSandboxInstance, RunResult, UploadOptions
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://www.buildfunctions.com"
|
|
21
|
+
|
|
22
|
+
# AWS Route53 authoritative nameservers for buildfunctions.app
|
|
23
|
+
AWS_NAMESERVERS = [
|
|
24
|
+
"205.251.193.143",
|
|
25
|
+
"205.251.198.254",
|
|
26
|
+
"205.251.195.249",
|
|
27
|
+
"205.251.198.95",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Module-level state
|
|
31
|
+
_global_api_token: str | None = None
|
|
32
|
+
_global_base_url: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def set_cpu_sandbox_api_token(api_token: str, base_url: str | None = None) -> None:
|
|
36
|
+
"""Set the API token for sandbox operations."""
|
|
37
|
+
global _global_api_token, _global_base_url
|
|
38
|
+
_global_api_token = api_token
|
|
39
|
+
_global_base_url = base_url
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _format_requirements(requirements: str | list[str] | None) -> str:
|
|
43
|
+
if not requirements:
|
|
44
|
+
return ""
|
|
45
|
+
if isinstance(requirements, list):
|
|
46
|
+
return "\n".join(requirements)
|
|
47
|
+
return requirements
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate_config(config: CPUSandboxConfig) -> None:
|
|
51
|
+
name = config.get("name")
|
|
52
|
+
if not name or not isinstance(name, str):
|
|
53
|
+
raise ValidationError("Sandbox name is required")
|
|
54
|
+
|
|
55
|
+
language = config.get("language")
|
|
56
|
+
if not language or not isinstance(language, str):
|
|
57
|
+
raise ValidationError("Language is required")
|
|
58
|
+
|
|
59
|
+
if language == "javascript" and not config.get("runtime"):
|
|
60
|
+
raise ValidationError('JavaScript requires explicit runtime: "node" or "deno"')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_dns_query(hostname: str) -> bytes:
|
|
64
|
+
"""Build a DNS A record query packet."""
|
|
65
|
+
import random
|
|
66
|
+
transaction_id = random.randint(0, 65535)
|
|
67
|
+
flags = 0x0100 # Standard query with recursion desired
|
|
68
|
+
questions = 1
|
|
69
|
+
answer_rrs = 0
|
|
70
|
+
authority_rrs = 0
|
|
71
|
+
additional_rrs = 0
|
|
72
|
+
|
|
73
|
+
header = struct.pack(">HHHHHH", transaction_id, flags, questions, answer_rrs, authority_rrs, additional_rrs)
|
|
74
|
+
|
|
75
|
+
# Build question section
|
|
76
|
+
question = b""
|
|
77
|
+
for part in hostname.split("."):
|
|
78
|
+
question += bytes([len(part)]) + part.encode("ascii")
|
|
79
|
+
question += b"\x00" # End of name
|
|
80
|
+
question += struct.pack(">HH", 1, 1) # Type A, Class IN
|
|
81
|
+
|
|
82
|
+
return header + question
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_dns_response(response: bytes) -> str | None:
|
|
86
|
+
"""Parse DNS response and extract first A record IP."""
|
|
87
|
+
if len(response) < 12:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
# Skip header (12 bytes) and question section
|
|
91
|
+
pos = 12
|
|
92
|
+
|
|
93
|
+
# Skip question name
|
|
94
|
+
while pos < len(response) and response[pos] != 0:
|
|
95
|
+
if response[pos] & 0xC0 == 0xC0: # Compression pointer
|
|
96
|
+
pos += 2
|
|
97
|
+
break
|
|
98
|
+
pos += response[pos] + 1
|
|
99
|
+
else:
|
|
100
|
+
pos += 1 # Skip null terminator
|
|
101
|
+
|
|
102
|
+
pos += 4 # Skip QTYPE and QCLASS
|
|
103
|
+
|
|
104
|
+
# Parse answer section
|
|
105
|
+
while pos < len(response):
|
|
106
|
+
# Skip name (may be compressed)
|
|
107
|
+
if response[pos] & 0xC0 == 0xC0:
|
|
108
|
+
pos += 2
|
|
109
|
+
else:
|
|
110
|
+
while pos < len(response) and response[pos] != 0:
|
|
111
|
+
pos += response[pos] + 1
|
|
112
|
+
pos += 1
|
|
113
|
+
|
|
114
|
+
if pos + 10 > len(response):
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
rtype, rclass, ttl, rdlength = struct.unpack(">HHIH", response[pos:pos + 10])
|
|
118
|
+
pos += 10
|
|
119
|
+
|
|
120
|
+
if rtype == 1 and rdlength == 4: # A record
|
|
121
|
+
ip_bytes = response[pos:pos + 4]
|
|
122
|
+
return ".".join(str(b) for b in ip_bytes)
|
|
123
|
+
|
|
124
|
+
pos += rdlength
|
|
125
|
+
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _resolve_with_aws(hostname: str) -> str | None:
|
|
130
|
+
"""Resolve hostname using AWS Route53 authoritative nameservers via raw UDP DNS."""
|
|
131
|
+
query = _build_dns_query(hostname)
|
|
132
|
+
|
|
133
|
+
for nameserver in AWS_NAMESERVERS:
|
|
134
|
+
try:
|
|
135
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
136
|
+
sock.settimeout(2.0)
|
|
137
|
+
sock.sendto(query, (nameserver, 53))
|
|
138
|
+
response, _ = sock.recvfrom(512)
|
|
139
|
+
sock.close()
|
|
140
|
+
|
|
141
|
+
ip = _parse_dns_response(response)
|
|
142
|
+
if ip:
|
|
143
|
+
return ip
|
|
144
|
+
except Exception:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _https_get_with_ip(ip: str, hostname: str, path: str) -> dict[str, Any]:
|
|
151
|
+
"""HTTPS GET using resolved IP (bypasses system DNS)."""
|
|
152
|
+
transport = httpx.AsyncHTTPTransport(
|
|
153
|
+
verify=True,
|
|
154
|
+
)
|
|
155
|
+
async with httpx.AsyncClient(
|
|
156
|
+
transport=transport,
|
|
157
|
+
timeout=httpx.Timeout(10.0),
|
|
158
|
+
) as client:
|
|
159
|
+
response = await client.get(
|
|
160
|
+
f"https://{ip}{path}",
|
|
161
|
+
headers={"Host": hostname},
|
|
162
|
+
extensions={"sni_hostname": hostname},
|
|
163
|
+
)
|
|
164
|
+
return {"status": response.status_code, "body": response.text}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def _wait_for_endpoint(endpoint: str, max_attempts: int = 60, delay_ms: int = 500) -> None:
|
|
168
|
+
"""Wait for endpoint using AWS Route53 authoritative DNS."""
|
|
169
|
+
from urllib.parse import urlparse
|
|
170
|
+
|
|
171
|
+
parsed = urlparse(endpoint)
|
|
172
|
+
hostname = parsed.hostname or ""
|
|
173
|
+
path = parsed.path or "/"
|
|
174
|
+
if parsed.query:
|
|
175
|
+
path = f"{path}?{parsed.query}"
|
|
176
|
+
|
|
177
|
+
for attempt in range(1, max_attempts + 1):
|
|
178
|
+
try:
|
|
179
|
+
ip = _resolve_with_aws(hostname)
|
|
180
|
+
if not ip:
|
|
181
|
+
raise RuntimeError("DNS resolution failed")
|
|
182
|
+
|
|
183
|
+
result = await _https_get_with_ip(ip, hostname, path)
|
|
184
|
+
if 200 <= result["status"] < 500:
|
|
185
|
+
return
|
|
186
|
+
except Exception as e:
|
|
187
|
+
if attempt == 1 or attempt % 10 == 0:
|
|
188
|
+
print(f" Waiting... (attempt {attempt}/{max_attempts})")
|
|
189
|
+
|
|
190
|
+
await asyncio.sleep(delay_ms / 1000.0)
|
|
191
|
+
|
|
192
|
+
raise BuildfunctionsError(f"Endpoint not ready after {max_attempts} attempts", "NETWORK_ERROR")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def _fetch_with_auth_dns(endpoint: str) -> dict[str, Any]:
|
|
196
|
+
"""Fetch endpoint using AWS Route53 authoritative DNS."""
|
|
197
|
+
from urllib.parse import urlparse
|
|
198
|
+
|
|
199
|
+
parsed = urlparse(endpoint)
|
|
200
|
+
hostname = parsed.hostname or ""
|
|
201
|
+
path = parsed.path or "/"
|
|
202
|
+
if parsed.query:
|
|
203
|
+
path = f"{path}?{parsed.query}"
|
|
204
|
+
|
|
205
|
+
ip = _resolve_with_aws(hostname)
|
|
206
|
+
if not ip:
|
|
207
|
+
raise BuildfunctionsError("DNS resolution failed", "NETWORK_ERROR")
|
|
208
|
+
|
|
209
|
+
return await _https_get_with_ip(ip, hostname, path)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _create_cpu_sandbox_instance(
|
|
213
|
+
sandbox_id: str,
|
|
214
|
+
name: str,
|
|
215
|
+
runtime: str,
|
|
216
|
+
endpoint: str,
|
|
217
|
+
api_token: str,
|
|
218
|
+
base_url: str,
|
|
219
|
+
) -> DotDict:
|
|
220
|
+
"""Create a CPU sandbox instance with run/upload/delete methods."""
|
|
221
|
+
deleted = {"value": False}
|
|
222
|
+
|
|
223
|
+
async def run(code: str | None = None) -> RunResult:
|
|
224
|
+
if deleted["value"]:
|
|
225
|
+
raise BuildfunctionsError("Sandbox has been deleted", "INVALID_REQUEST")
|
|
226
|
+
|
|
227
|
+
await _wait_for_endpoint(endpoint)
|
|
228
|
+
|
|
229
|
+
response = await _fetch_with_auth_dns(endpoint)
|
|
230
|
+
response_text = response["body"]
|
|
231
|
+
|
|
232
|
+
if not response_text:
|
|
233
|
+
raise BuildfunctionsError("Empty response from sandbox", "UNKNOWN_ERROR", response["status"])
|
|
234
|
+
|
|
235
|
+
if response["status"] < 200 or response["status"] >= 300:
|
|
236
|
+
raise BuildfunctionsError(f"Execution failed: {response_text}", "UNKNOWN_ERROR", response["status"])
|
|
237
|
+
|
|
238
|
+
# Try to parse as JSON, otherwise return raw text
|
|
239
|
+
try:
|
|
240
|
+
data = json.loads(response_text)
|
|
241
|
+
except json.JSONDecodeError:
|
|
242
|
+
data = response_text
|
|
243
|
+
|
|
244
|
+
return RunResult(
|
|
245
|
+
response=data,
|
|
246
|
+
status=response["status"],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
async def upload(options: UploadOptions) -> None:
|
|
250
|
+
if deleted["value"]:
|
|
251
|
+
raise BuildfunctionsError("Sandbox has been deleted", "INVALID_REQUEST")
|
|
252
|
+
|
|
253
|
+
local_path = options.get("local_path")
|
|
254
|
+
file_path = options.get("file_path")
|
|
255
|
+
|
|
256
|
+
if not local_path or not file_path:
|
|
257
|
+
raise ValidationError("Both local_path and file_path are required")
|
|
258
|
+
|
|
259
|
+
local = Path(local_path)
|
|
260
|
+
if not local.exists():
|
|
261
|
+
raise ValidationError(f"Local file not found: {local_path}")
|
|
262
|
+
|
|
263
|
+
content = local.read_text(encoding="utf-8")
|
|
264
|
+
|
|
265
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
|
|
266
|
+
response = await client.post(
|
|
267
|
+
f"{base_url}/api/sdk/sandbox/upload",
|
|
268
|
+
headers={
|
|
269
|
+
"Content-Type": "application/json",
|
|
270
|
+
"Authorization": f"Bearer {api_token}",
|
|
271
|
+
},
|
|
272
|
+
json={
|
|
273
|
+
"sandboxId": sandbox_id,
|
|
274
|
+
"filePath": file_path,
|
|
275
|
+
"content": content,
|
|
276
|
+
"type": "cpu",
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if not response.is_success:
|
|
281
|
+
raise BuildfunctionsError("Upload failed", "UNKNOWN_ERROR", response.status_code)
|
|
282
|
+
|
|
283
|
+
async def delete_fn() -> None:
|
|
284
|
+
if deleted["value"]:
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
288
|
+
response = await client.request(
|
|
289
|
+
"DELETE",
|
|
290
|
+
f"{base_url}/api/sdk/sandbox/delete",
|
|
291
|
+
headers={
|
|
292
|
+
"Content-Type": "application/json",
|
|
293
|
+
"Authorization": f"Bearer {api_token}",
|
|
294
|
+
},
|
|
295
|
+
json={
|
|
296
|
+
"sandboxId": sandbox_id,
|
|
297
|
+
"type": "cpu",
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if not response.is_success:
|
|
302
|
+
raise BuildfunctionsError("Delete failed", "UNKNOWN_ERROR", response.status_code)
|
|
303
|
+
|
|
304
|
+
deleted["value"] = True
|
|
305
|
+
|
|
306
|
+
return DotDict({
|
|
307
|
+
"id": sandbox_id,
|
|
308
|
+
"name": name,
|
|
309
|
+
"runtime": runtime,
|
|
310
|
+
"endpoint": endpoint,
|
|
311
|
+
"type": "cpu",
|
|
312
|
+
"run": run,
|
|
313
|
+
"upload": upload,
|
|
314
|
+
"delete": delete_fn,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def _create_cpu_sandbox(config: CPUSandboxConfig) -> DotDict:
|
|
319
|
+
"""Create a new CPU sandbox."""
|
|
320
|
+
if not _global_api_token:
|
|
321
|
+
raise ValidationError("API key not set. Initialize Buildfunctions client first.")
|
|
322
|
+
|
|
323
|
+
_validate_config(config)
|
|
324
|
+
|
|
325
|
+
base_url = _global_base_url or DEFAULT_BASE_URL
|
|
326
|
+
api_token = _global_api_token
|
|
327
|
+
|
|
328
|
+
name = config["name"].lower()
|
|
329
|
+
language = config["language"]
|
|
330
|
+
file_ext = ".py" if language == "python" else ".js" if language == "javascript" else ".py"
|
|
331
|
+
|
|
332
|
+
# Resolve code (inline string or file path)
|
|
333
|
+
resolved_code = await resolve_code(config["code"]) if config.get("code") else ""
|
|
334
|
+
|
|
335
|
+
request_body = {
|
|
336
|
+
"type": "cpu",
|
|
337
|
+
"name": name,
|
|
338
|
+
"fileExt": file_ext,
|
|
339
|
+
"code": resolved_code,
|
|
340
|
+
"sourceWith": resolved_code,
|
|
341
|
+
"sourceWithout": resolved_code,
|
|
342
|
+
"language": language,
|
|
343
|
+
"runtime": config.get("runtime", language),
|
|
344
|
+
"memoryAllocated": parse_memory(config["memory"]) if config.get("memory") else 128,
|
|
345
|
+
"timeout": config.get("timeout", 10),
|
|
346
|
+
"envVariables": json.dumps(config.get("env_variables", [])),
|
|
347
|
+
"requirements": _format_requirements(config.get("requirements")),
|
|
348
|
+
"cronExpression": "",
|
|
349
|
+
"subdomain": name,
|
|
350
|
+
"totalVariables": len(config.get("env_variables", [])),
|
|
351
|
+
"functionCount": 0,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client:
|
|
355
|
+
response = await client.post(
|
|
356
|
+
f"{base_url}/api/sdk/sandbox/create",
|
|
357
|
+
headers={
|
|
358
|
+
"Content-Type": "application/json",
|
|
359
|
+
"Authorization": f"Bearer {api_token}",
|
|
360
|
+
},
|
|
361
|
+
json=request_body,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
response_text = response.text
|
|
365
|
+
|
|
366
|
+
if not response.is_success:
|
|
367
|
+
raise BuildfunctionsError(f"Failed to create sandbox: {response_text}", "UNKNOWN_ERROR", response.status_code)
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
data = json.loads(response_text)
|
|
371
|
+
except json.JSONDecodeError:
|
|
372
|
+
raise BuildfunctionsError(
|
|
373
|
+
f"Invalid JSON response: {response_text}", "UNKNOWN_ERROR", response.status_code
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
sandbox_id = data["siteId"]
|
|
377
|
+
sandbox_endpoint = data.get("endpoint") or f"https://{name}.buildfunctions.app"
|
|
378
|
+
sandbox_runtime = config.get("runtime", language)
|
|
379
|
+
|
|
380
|
+
return _create_cpu_sandbox_instance(sandbox_id, name, sandbox_runtime, sandbox_endpoint, api_token, base_url)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class CPUSandbox:
|
|
384
|
+
"""CPU Sandbox factory - matches TypeScript SDK pattern."""
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
async def create(config: CPUSandboxConfig) -> DotDict:
|
|
388
|
+
"""Create a new CPU sandbox."""
|
|
389
|
+
return await _create_cpu_sandbox(config)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# Alias for direct function call style
|
|
393
|
+
create_cpu_sandbox = _create_cpu_sandbox
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""DotDict - A dict subclass that supports both dot notation and bracket notation.
|
|
2
|
+
|
|
3
|
+
Allows accessing dict keys as attributes:
|
|
4
|
+
d = DotDict({"name": "test", "id": 123})
|
|
5
|
+
d.name # "test"
|
|
6
|
+
d["name"] # "test"
|
|
7
|
+
d.get("name") # "test"
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DotDict(dict):
|
|
16
|
+
"""Dict that supports attribute access (dot notation) in addition to bracket notation."""
|
|
17
|
+
|
|
18
|
+
def __getattr__(self, key: str) -> Any:
|
|
19
|
+
try:
|
|
20
|
+
value = self[key]
|
|
21
|
+
# Recursively wrap nested dicts
|
|
22
|
+
if isinstance(value, dict) and not isinstance(value, DotDict):
|
|
23
|
+
value = DotDict(value)
|
|
24
|
+
self[key] = value
|
|
25
|
+
return value
|
|
26
|
+
except KeyError:
|
|
27
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{key}'")
|
|
28
|
+
|
|
29
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
|
30
|
+
self[key] = value
|
|
31
|
+
|
|
32
|
+
def __delattr__(self, key: str) -> None:
|
|
33
|
+
try:
|
|
34
|
+
del self[key]
|
|
35
|
+
except KeyError:
|
|
36
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{key}'")
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
return f"DotDict({super().__repr__()})"
|
buildfunctions/errors.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Buildfunctions SDK Error Classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from buildfunctions.types import ErrorCode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BuildfunctionsError(Exception):
|
|
11
|
+
"""Base error for all Buildfunctions SDK errors."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
code: ErrorCode = "UNKNOWN_ERROR",
|
|
17
|
+
status_code: int | None = None,
|
|
18
|
+
details: dict[str, Any] | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.code = code
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.details = details
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthenticationError(BuildfunctionsError):
|
|
27
|
+
"""Raised when authentication fails."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str = "Invalid or missing API key") -> None:
|
|
30
|
+
super().__init__(message, "UNAUTHORIZED", 401)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotFoundError(BuildfunctionsError):
|
|
34
|
+
"""Raised when a resource is not found."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, resource: str = "Resource") -> None:
|
|
37
|
+
super().__init__(f"{resource} not found", "NOT_FOUND", 404)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ValidationError(BuildfunctionsError):
|
|
41
|
+
"""Raised when input validation fails."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
44
|
+
super().__init__(message, "VALIDATION_ERROR", 400, details)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CapacityError(BuildfunctionsError):
|
|
48
|
+
"""Raised when the service is at maximum capacity."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str = "Service at maximum capacity. Please try again later.") -> None:
|
|
51
|
+
super().__init__(message, "MAX_CAPACITY", 503)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _error_code_from_status(status_code: int) -> ErrorCode:
|
|
55
|
+
"""Map HTTP status code to error code."""
|
|
56
|
+
match status_code:
|
|
57
|
+
case 401:
|
|
58
|
+
return "UNAUTHORIZED"
|
|
59
|
+
case 404:
|
|
60
|
+
return "NOT_FOUND"
|
|
61
|
+
case 400:
|
|
62
|
+
return "INVALID_REQUEST"
|
|
63
|
+
case 503:
|
|
64
|
+
return "MAX_CAPACITY"
|
|
65
|
+
case 409:
|
|
66
|
+
return "SIZE_LIMIT_EXCEEDED"
|
|
67
|
+
case _:
|
|
68
|
+
return "UNKNOWN_ERROR"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _map_error_code(code: str | None, status_code: int) -> ErrorCode:
|
|
72
|
+
"""Map error code string to ErrorCode, falling back to status code mapping."""
|
|
73
|
+
valid_codes: set[str] = {
|
|
74
|
+
"UNAUTHORIZED",
|
|
75
|
+
"NOT_FOUND",
|
|
76
|
+
"INVALID_REQUEST",
|
|
77
|
+
"MAX_CAPACITY",
|
|
78
|
+
"SIZE_LIMIT_EXCEEDED",
|
|
79
|
+
"VALIDATION_ERROR",
|
|
80
|
+
}
|
|
81
|
+
if code and code in valid_codes:
|
|
82
|
+
return code # type: ignore[return-value]
|
|
83
|
+
return _error_code_from_status(status_code)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def error_from_response(status_code: int, body: dict[str, Any]) -> BuildfunctionsError:
|
|
87
|
+
"""Create an error from an API response."""
|
|
88
|
+
message = body.get("error", "An unknown error occurred")
|
|
89
|
+
code = _map_error_code(body.get("code"), status_code)
|
|
90
|
+
return BuildfunctionsError(message, code, status_code)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Framework detection from requirements string."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from buildfunctions.types import Framework
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def detect_framework(requirements: str | None) -> Framework | None:
|
|
9
|
+
"""Scan requirements for torch/pytorch.
|
|
10
|
+
|
|
11
|
+
Returns 'pytorch' if found, None otherwise.
|
|
12
|
+
Currently defaults to 'pytorch' when no requirements given.
|
|
13
|
+
"""
|
|
14
|
+
if not requirements:
|
|
15
|
+
return "pytorch"
|
|
16
|
+
|
|
17
|
+
lower = requirements.lower()
|
|
18
|
+
|
|
19
|
+
if "torch" in lower:
|
|
20
|
+
return "pytorch"
|
|
21
|
+
|
|
22
|
+
return None
|