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.
@@ -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__()})"
@@ -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