blaxel 0.2.32__py3-none-any.whl → 0.2.33__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.
blaxel/__init__.py CHANGED
@@ -4,8 +4,8 @@ from .core.common.autoload import autoload
4
4
  from .core.common.env import env
5
5
  from .core.common.settings import settings
6
6
 
7
- __version__ = "0.2.32"
8
- __commit__ = "72a43488d87cfae83389f5a928dfa9256649396b"
7
+ __version__ = "0.2.33"
8
+ __commit__ = "3472a6744215e59d421423f105edb2ec34edebe8"
9
9
  __sentry_dsn__ = "https://9711de13cd02b285ca4378c01de8dc30@o4508714045276160.ingest.us.sentry.io/4510461121462272"
10
10
  __all__ = ["autoload", "settings", "env"]
11
11
 
@@ -1,5 +1,6 @@
1
- from .autoload import autoload, capture_exception
1
+ from .autoload import autoload
2
2
  from .env import env
3
+ from .sentry import capture_exception, flush_sentry, init_sentry, is_sentry_initialized
3
4
  from .internal import get_alphanumeric_limited_hash, get_global_unique_hash
4
5
  from .settings import Settings, settings
5
6
  from .webhook import (
@@ -12,6 +13,9 @@ from .webhook import (
12
13
  __all__ = [
13
14
  "autoload",
14
15
  "capture_exception",
16
+ "flush_sentry",
17
+ "init_sentry",
18
+ "is_sentry_initialized",
15
19
  "Settings",
16
20
  "settings",
17
21
  "env",
@@ -1,10 +1,4 @@
1
- import atexit
2
1
  import logging
3
- import sys
4
- import threading
5
- from asyncio import CancelledError
6
-
7
- from sentry_sdk import Client, Hub
8
2
 
9
3
  from ..client import client
10
4
  from ..client.response_interceptor import (
@@ -12,130 +6,11 @@ from ..client.response_interceptor import (
12
6
  response_interceptors_sync,
13
7
  )
14
8
  from ..sandbox.client import client as client_sandbox
9
+ from .sentry import init_sentry
15
10
  from .settings import settings
16
11
 
17
12
  logger = logging.getLogger(__name__)
18
13
 
19
- # Isolated Sentry hub for SDK-only error tracking (doesn't interfere with user's Sentry)
20
- _sentry_hub: Hub | None = None
21
- _captured_exceptions: set = set() # Track already captured exceptions to avoid duplicates
22
-
23
- # Exceptions that are part of normal control flow and should not be captured
24
- _IGNORED_EXCEPTIONS = (
25
- StopIteration, # Iterator exhaustion
26
- StopAsyncIteration, # Async iterator exhaustion
27
- GeneratorExit, # Generator cleanup
28
- KeyboardInterrupt, # User interrupt (Ctrl+C)
29
- SystemExit, # Program exit
30
- CancelledError, # Async task cancellation
31
- )
32
-
33
- # Optional dependencies that may not be installed - import errors for these are expected
34
- _OPTIONAL_DEPENDENCIES = ("opentelemetry",)
35
-
36
-
37
- def _get_exception_key(exc_type, exc_value, frame) -> str:
38
- """Generate a unique key for an exception based on type, message, and origin."""
39
- # Use type name + message + original file/line where exception was raised
40
- # This ensures the same logical exception is only captured once
41
- exc_name = exc_type.__name__ if exc_type else "Unknown"
42
- exc_msg = str(exc_value) if exc_value else ""
43
- # Get the original traceback location (where exception was first raised)
44
- tb = getattr(exc_value, "__traceback__", None)
45
- if tb:
46
- # Walk to the deepest frame (origin of exception)
47
- while tb.tb_next:
48
- tb = tb.tb_next
49
- origin = f"{tb.tb_frame.f_code.co_filename}:{tb.tb_lineno}"
50
- else:
51
- origin = f"{frame.f_code.co_filename}:{frame.f_lineno}"
52
- return f"{exc_name}:{exc_msg}:{origin}"
53
-
54
-
55
- def _is_optional_dependency_error(exc_type, exc_value) -> bool:
56
- """Check if the exception is an import error for an optional dependency."""
57
- # ModuleNotFoundError is a subclass of ImportError, so checking ImportError covers both
58
- if exc_type and issubclass(exc_type, ImportError):
59
- msg = str(exc_value).lower()
60
- return any(dep in msg for dep in _OPTIONAL_DEPENDENCIES)
61
- return False
62
-
63
-
64
- def _trace_blaxel_exceptions(frame, event, arg):
65
- """Trace function that captures exceptions from blaxel SDK code."""
66
- if event == "exception":
67
- exc_type, exc_value, exc_tb = arg
68
-
69
- # Skip control flow exceptions (not actual errors)
70
- if exc_type and issubclass(exc_type, _IGNORED_EXCEPTIONS):
71
- return _trace_blaxel_exceptions
72
-
73
- # Skip import errors for optional dependencies (expected when not installed)
74
- if _is_optional_dependency_error(exc_type, exc_value):
75
- return _trace_blaxel_exceptions
76
-
77
- filename = frame.f_code.co_filename
78
-
79
- # Only capture if it's from blaxel in site-packages
80
- if "site-packages/blaxel" in filename:
81
- # Avoid capturing the same exception multiple times using a content-based key
82
- exc_key = _get_exception_key(exc_type, exc_value, frame)
83
- if exc_key not in _captured_exceptions:
84
- _captured_exceptions.add(exc_key)
85
- capture_exception(exc_value)
86
- # Clean up old exception keys to prevent memory leak
87
- if len(_captured_exceptions) > 1000:
88
- _captured_exceptions.clear()
89
-
90
- return _trace_blaxel_exceptions
91
-
92
-
93
- def sentry() -> None:
94
- """Initialize an isolated Sentry client for SDK error tracking."""
95
- global _sentry_hub
96
- try:
97
- dsn = settings.sentry_dsn
98
- if not dsn:
99
- return
100
-
101
- # Create an isolated client that won't interfere with user's Sentry
102
- sentry_client = Client(
103
- dsn=dsn,
104
- environment=settings.env,
105
- release=f"sdk-python@{settings.version}",
106
- default_integrations=False,
107
- auto_enabling_integrations=False,
108
- )
109
- _sentry_hub = Hub(sentry_client)
110
-
111
- # Set SDK-specific tags
112
- with _sentry_hub.configure_scope() as scope:
113
- scope.set_tag("blaxel.workspace", settings.workspace)
114
- scope.set_tag("blaxel.version", settings.version)
115
- scope.set_tag("blaxel.commit", settings.commit)
116
-
117
- # Install trace function to automatically capture SDK exceptions
118
- sys.settrace(_trace_blaxel_exceptions)
119
- threading.settrace(_trace_blaxel_exceptions)
120
-
121
- # Register atexit handler to flush pending events
122
- atexit.register(_flush_sentry)
123
-
124
- except Exception as e:
125
- logger.debug(f"Error initializing Sentry: {e}")
126
-
127
-
128
- def capture_exception(exception: Exception | None = None) -> None:
129
- """Capture an exception to the SDK's isolated Sentry hub."""
130
- if _sentry_hub is not None and _sentry_hub.client is not None:
131
- _sentry_hub.capture_exception(exception)
132
-
133
-
134
- def _flush_sentry():
135
- """Flush pending Sentry events on program exit."""
136
- if _sentry_hub is not None and _sentry_hub.client is not None:
137
- _sentry_hub.client.flush(timeout=2)
138
-
139
14
 
140
15
  def telemetry() -> None:
141
16
  from blaxel.telemetry import telemetry_manager
@@ -164,7 +39,7 @@ def autoload() -> None:
164
39
 
165
40
  if settings.tracking:
166
41
  try:
167
- sentry()
42
+ init_sentry()
168
43
  except Exception:
169
44
  pass
170
45
 
@@ -0,0 +1,319 @@
1
+ import atexit
2
+ import json
3
+ import logging
4
+ import sys
5
+ import threading
6
+ import traceback
7
+ import uuid
8
+ from asyncio import CancelledError
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+ from urllib.parse import urlparse
12
+
13
+ import httpx
14
+
15
+ from .settings import settings
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Lightweight Sentry client using httpx - only captures SDK errors
20
+ _sentry_initialized = False
21
+ _captured_exceptions: set = set() # Track already captured exceptions to avoid duplicates
22
+
23
+ # Parsed DSN components
24
+ _sentry_config: dict[str, str] | None = None
25
+
26
+ # Queue for pending events
27
+ _pending_events: list[dict[str, Any]] = []
28
+ _flush_lock = threading.Lock()
29
+ _handlers_registered = False
30
+
31
+ # Exceptions that are part of normal control flow and should not be captured
32
+ _IGNORED_EXCEPTIONS = (
33
+ StopIteration, # Iterator exhaustion
34
+ StopAsyncIteration, # Async iterator exhaustion
35
+ GeneratorExit, # Generator cleanup
36
+ KeyboardInterrupt, # User interrupt (Ctrl+C)
37
+ SystemExit, # Program exit
38
+ CancelledError, # Async task cancellation
39
+ )
40
+
41
+ # Optional dependencies that may not be installed - import errors for these are expected
42
+ _OPTIONAL_DEPENDENCIES = ("opentelemetry",)
43
+
44
+ # SDK path patterns to identify errors originating from our SDK
45
+ _SDK_PATTERNS = [
46
+ "blaxel/",
47
+ "blaxel\\",
48
+ "site-packages/blaxel",
49
+ "site-packages\\blaxel",
50
+ ]
51
+
52
+
53
+ def _is_from_sdk(error: Exception) -> bool:
54
+ """Check if an error originated from SDK code based on stack trace."""
55
+ tb = error.__traceback__
56
+ if not tb:
57
+ return False
58
+
59
+ # Walk through the traceback
60
+ while tb:
61
+ filename = tb.tb_frame.f_code.co_filename
62
+ if any(pattern in filename for pattern in _SDK_PATTERNS):
63
+ return True
64
+ tb = tb.tb_next
65
+
66
+ return False
67
+
68
+
69
+ def _parse_dsn(dsn: str) -> dict[str, str] | None:
70
+ """
71
+ Parse a Sentry DSN into its components.
72
+ DSN format: https://{public_key}@{host}/{project_id}
73
+ """
74
+ try:
75
+ parsed = urlparse(dsn)
76
+ public_key = parsed.username
77
+ host = parsed.hostname
78
+ project_id = parsed.path.lstrip("/")
79
+
80
+ if not public_key or not host or not project_id:
81
+ return None
82
+
83
+ return {"public_key": public_key, "host": host, "project_id": project_id}
84
+ except Exception:
85
+ return None
86
+
87
+
88
+ def _generate_event_id() -> str:
89
+ """Generate a UUID v4 for event ID."""
90
+ return uuid.uuid4().hex
91
+
92
+
93
+ def _parse_stack_trace(exc: Exception) -> list[dict[str, Any]]:
94
+ """Parse exception traceback into Sentry-compatible frames."""
95
+ frames: list[dict[str, Any]] = []
96
+ tb = traceback.extract_tb(exc.__traceback__)
97
+
98
+ for frame in tb:
99
+ frames.append(
100
+ {
101
+ "filename": frame.filename,
102
+ "function": frame.name or "<anonymous>",
103
+ "lineno": frame.lineno,
104
+ "colno": 0,
105
+ }
106
+ )
107
+
108
+ return frames
109
+
110
+
111
+ def _error_to_sentry_event(error: Exception) -> dict[str, Any]:
112
+ """Convert an Exception to a Sentry event payload."""
113
+ frames = _parse_stack_trace(error)
114
+
115
+ return {
116
+ "event_id": _generate_event_id(),
117
+ "timestamp": datetime.now(timezone.utc).timestamp(),
118
+ "platform": "python",
119
+ "level": "error",
120
+ "environment": settings.env,
121
+ "release": f"sdk-python@{settings.version}",
122
+ "tags": {
123
+ "blaxel.workspace": settings.workspace,
124
+ "blaxel.version": settings.version,
125
+ "blaxel.commit": settings.commit,
126
+ },
127
+ "exception": {
128
+ "values": [
129
+ {
130
+ "type": type(error).__name__,
131
+ "value": str(error),
132
+ "stacktrace": {"frames": frames},
133
+ }
134
+ ]
135
+ },
136
+ }
137
+
138
+
139
+ def _send_to_sentry(event: dict[str, Any]) -> None:
140
+ """Send an event to Sentry using httpx."""
141
+ if not _sentry_config:
142
+ return
143
+
144
+ public_key = _sentry_config["public_key"]
145
+ host = _sentry_config["host"]
146
+ project_id = _sentry_config["project_id"]
147
+ envelope_url = f"https://{host}/api/{project_id}/envelope/"
148
+
149
+ # Create envelope header
150
+ envelope_header = json.dumps(
151
+ {
152
+ "event_id": event["event_id"],
153
+ "sent_at": datetime.now(timezone.utc).isoformat(),
154
+ "dsn": f"https://{public_key}@{host}/{project_id}",
155
+ }
156
+ )
157
+
158
+ # Create item header
159
+ item_header = json.dumps({"type": "event", "content_type": "application/json"})
160
+
161
+ # Create envelope body
162
+ envelope = f"{envelope_header}\n{item_header}\n{json.dumps(event)}"
163
+
164
+ try:
165
+ httpx.post(
166
+ envelope_url,
167
+ headers={
168
+ "Content-Type": "application/x-sentry-envelope",
169
+ "X-Sentry-Auth": f"Sentry sentry_version=7, sentry_client=blaxel-sdk/{settings.version}, sentry_key={public_key}",
170
+ },
171
+ content=envelope,
172
+ timeout=5.0,
173
+ )
174
+ except Exception:
175
+ # Silently fail - error reporting should never break the SDK
176
+ pass
177
+
178
+
179
+ def _get_exception_key(exc_type, exc_value, frame) -> str:
180
+ """Generate a unique key for an exception based on type, message, and origin."""
181
+ exc_name = exc_type.__name__ if exc_type else "Unknown"
182
+ exc_msg = str(exc_value) if exc_value else ""
183
+ tb = getattr(exc_value, "__traceback__", None)
184
+ if tb:
185
+ while tb.tb_next:
186
+ tb = tb.tb_next
187
+ origin = f"{tb.tb_frame.f_code.co_filename}:{tb.tb_lineno}"
188
+ else:
189
+ origin = f"{frame.f_code.co_filename}:{frame.f_lineno}"
190
+ return f"{exc_name}:{exc_msg}:{origin}"
191
+
192
+
193
+ def _is_optional_dependency_error(exc_type, exc_value) -> bool:
194
+ """Check if the exception is an import error for an optional dependency."""
195
+ if exc_type and issubclass(exc_type, ImportError):
196
+ msg = str(exc_value).lower()
197
+ return any(dep in msg for dep in _OPTIONAL_DEPENDENCIES)
198
+ return False
199
+
200
+
201
+ def _trace_blaxel_exceptions(frame, event, arg):
202
+ """Trace function that captures exceptions from blaxel SDK code."""
203
+ if event == "exception":
204
+ exc_type, exc_value, exc_tb = arg
205
+
206
+ # Skip control flow exceptions (not actual errors)
207
+ if exc_type and issubclass(exc_type, _IGNORED_EXCEPTIONS):
208
+ return _trace_blaxel_exceptions
209
+
210
+ # Skip import errors for optional dependencies (expected when not installed)
211
+ if _is_optional_dependency_error(exc_type, exc_value):
212
+ return _trace_blaxel_exceptions
213
+
214
+ filename = frame.f_code.co_filename
215
+
216
+ # Only capture if it's from blaxel in site-packages
217
+ if "site-packages/blaxel" in filename:
218
+ # Avoid capturing the same exception multiple times using a content-based key
219
+ exc_key = _get_exception_key(exc_type, exc_value, frame)
220
+ if exc_key not in _captured_exceptions:
221
+ _captured_exceptions.add(exc_key)
222
+ capture_exception(exc_value)
223
+ # Clean up old exception keys to prevent memory leak
224
+ if len(_captured_exceptions) > 1000:
225
+ _captured_exceptions.clear()
226
+
227
+ return _trace_blaxel_exceptions
228
+
229
+
230
+ def init_sentry() -> None:
231
+ """Initialize the lightweight Sentry client for SDK error tracking."""
232
+ global _sentry_initialized, _sentry_config, _handlers_registered
233
+ try:
234
+ dsn = settings.sentry_dsn
235
+ if not dsn:
236
+ return
237
+
238
+ # Parse DSN
239
+ _sentry_config = _parse_dsn(dsn)
240
+ if not _sentry_config:
241
+ return
242
+
243
+ # Only allow dev/prod environments
244
+ if settings.env not in ("dev", "prod"):
245
+ return
246
+
247
+ _sentry_initialized = True
248
+
249
+ # Register handlers only once
250
+ if not _handlers_registered:
251
+ _handlers_registered = True
252
+
253
+ # Install trace function to automatically capture SDK exceptions
254
+ sys.settrace(_trace_blaxel_exceptions)
255
+ threading.settrace(_trace_blaxel_exceptions)
256
+
257
+ # Register atexit handler to flush pending events
258
+ atexit.register(flush_sentry)
259
+
260
+ except Exception as e:
261
+ logger.debug(f"Error initializing Sentry: {e}")
262
+
263
+
264
+ def capture_exception(exception: Exception | None = None) -> None:
265
+ """Capture an exception to Sentry.
266
+ Only errors originating from SDK code will be captured.
267
+ """
268
+ if not _sentry_initialized or not _sentry_config or exception is None:
269
+ return
270
+
271
+ try:
272
+ # Generate unique key to prevent duplicate captures
273
+ exc_key = f"{type(exception).__name__}:{str(exception)}"
274
+ if exc_key in _captured_exceptions:
275
+ return
276
+
277
+ _captured_exceptions.add(exc_key)
278
+
279
+ # Clean up old exception keys to prevent memory leak
280
+ if len(_captured_exceptions) > 1000:
281
+ _captured_exceptions.clear()
282
+
283
+ # Convert error to Sentry event and queue it
284
+ event = _error_to_sentry_event(exception)
285
+ with _flush_lock:
286
+ _pending_events.append(event)
287
+
288
+ # Send immediately (fire and forget)
289
+ _send_to_sentry(event)
290
+
291
+ except Exception:
292
+ # Silently fail - error capturing should never break the SDK
293
+ pass
294
+
295
+
296
+ def flush_sentry(timeout: float = 2.0) -> None:
297
+ """Flush pending Sentry events."""
298
+ if not _sentry_initialized:
299
+ return
300
+
301
+ with _flush_lock:
302
+ if not _pending_events:
303
+ return
304
+
305
+ events_to_send = _pending_events.copy()
306
+ _pending_events.clear()
307
+
308
+ # Send all pending events
309
+ for event in events_to_send:
310
+ try:
311
+ _send_to_sentry(event)
312
+ except Exception:
313
+ # Silently fail
314
+ pass
315
+
316
+
317
+ def is_sentry_initialized() -> bool:
318
+ """Check if Sentry is initialized and available."""
319
+ return _sentry_initialized
@@ -184,55 +184,170 @@ class SandboxProcess(SandboxAction):
184
184
  ) -> Union[ProcessResponse, ProcessResponseWithLog]:
185
185
  """Execute a process in the sandbox."""
186
186
  on_log = None
187
+ on_stdout = None
188
+ on_stderr = None
189
+
187
190
  if isinstance(process, ProcessRequestWithLog):
188
191
  on_log = process.on_log
192
+ on_stdout = process.on_stdout
193
+ on_stderr = process.on_stderr
189
194
  process = process.to_dict()
190
195
 
191
196
  if isinstance(process, dict):
192
197
  if "on_log" in process:
193
198
  on_log = process["on_log"]
194
199
  del process["on_log"]
200
+ if "on_stdout" in process:
201
+ on_stdout = process["on_stdout"]
202
+ del process["on_stdout"]
203
+ if "on_stderr" in process:
204
+ on_stderr = process["on_stderr"]
205
+ del process["on_stderr"]
195
206
  process = ProcessRequest.from_dict(process)
196
207
 
197
208
  # Store original wait_for_completion setting
198
209
  should_wait_for_completion = process.wait_for_completion
199
210
 
200
- # Always start process without wait_for_completion to avoid server-side blocking
201
- if should_wait_for_completion and on_log is not None:
202
- process.wait_for_completion = False
203
-
204
- client = self.get_client()
205
- response = await client.post("/process", json=process.to_dict())
206
- try:
207
- content_bytes = await response.aread()
208
- self.handle_response_error(response)
209
- import json
210
-
211
- response_data = json.loads(content_bytes) if content_bytes else None
212
- result = ProcessResponse.from_dict(response_data)
213
- finally:
214
- await response.aclose()
215
-
216
- # Handle wait_for_completion with parallel log streaming
217
- if should_wait_for_completion and on_log is not None:
218
- stream_control = self._stream_logs(result.pid, {"on_log": on_log})
211
+ # When waiting for completion with streaming callbacks, use streaming endpoint
212
+ if should_wait_for_completion and (on_log or on_stdout or on_stderr):
213
+ return await self._exec_with_streaming(
214
+ process, on_log=on_log, on_stdout=on_stdout, on_stderr=on_stderr
215
+ )
216
+ else:
217
+ client = self.get_client()
218
+ response = await client.post("/process", json=process.to_dict())
219
219
  try:
220
- # Wait for process completion
221
- result = await self.wait(result.pid, interval=500, max_wait=1000 * 60 * 60)
220
+ content_bytes = await response.aread()
221
+ self.handle_response_error(response)
222
+ import json
223
+
224
+ response_data = json.loads(content_bytes) if content_bytes else None
225
+ result = ProcessResponse.from_dict(response_data)
222
226
  finally:
223
- # Clean up log streaming
224
- if stream_control:
225
- stream_control["close"]()
226
- else:
227
- # For non-blocking execution, set up log streaming immediately if requested
228
- if on_log is not None:
229
- stream_control = self._stream_logs(result.pid, {"on_log": on_log})
227
+ await response.aclose()
228
+
229
+ if on_log or on_stdout or on_stderr:
230
+ stream_control = self._stream_logs(
231
+ result.pid, {"on_log": on_log, "on_stdout": on_stdout, "on_stderr": on_stderr}
232
+ )
230
233
  return ProcessResponseWithLog(
231
234
  result,
232
235
  lambda: stream_control["close"]() if stream_control else None,
233
236
  )
234
237
 
235
- return result
238
+ return result
239
+
240
+ async def _exec_with_streaming(
241
+ self,
242
+ process_request: ProcessRequest,
243
+ on_log: Callable[[str], None] | None = None,
244
+ on_stdout: Callable[[str], None] | None = None,
245
+ on_stderr: Callable[[str], None] | None = None,
246
+ ) -> ProcessResponseWithLog:
247
+ """Execute a process with streaming response handling for NDJSON."""
248
+ import json
249
+
250
+ headers = (
251
+ self.sandbox_config.headers
252
+ if self.sandbox_config.force_url
253
+ else {**settings.headers, **self.sandbox_config.headers}
254
+ )
255
+
256
+ async with httpx.AsyncClient() as client_instance:
257
+ async with client_instance.stream(
258
+ "POST",
259
+ f"{self.url}/process",
260
+ headers={
261
+ **headers,
262
+ "Content-Type": "application/json",
263
+ "Accept": "text/event-stream",
264
+ },
265
+ json=process_request.to_dict(),
266
+ timeout=None,
267
+ ) as response:
268
+ if response.status_code >= 400:
269
+ error_text = await response.aread()
270
+ raise Exception(f"Failed to execute process: {error_text}")
271
+
272
+ content_type = response.headers.get("Content-Type", "")
273
+ is_streaming = "application/x-ndjson" in content_type
274
+
275
+ # Fallback: server doesn't support streaming, use legacy approach
276
+ if not is_streaming:
277
+ content = await response.aread()
278
+ data = json.loads(content)
279
+ result = ProcessResponse.from_dict(data)
280
+
281
+ # If process already completed (server waited), emit logs through callbacks
282
+ if result.status == "completed" or result.status == "failed":
283
+ if result.stdout:
284
+ for line in result.stdout.split("\n"):
285
+ if line:
286
+ if on_stdout:
287
+ on_stdout(line)
288
+ if result.stderr:
289
+ for line in result.stderr.split("\n"):
290
+ if line:
291
+ if on_stderr:
292
+ on_stderr(line)
293
+ if result.logs:
294
+ for line in result.logs.split("\n"):
295
+ if line:
296
+ if on_log:
297
+ on_log(line)
298
+
299
+ return ProcessResponseWithLog(result, lambda: None)
300
+
301
+ # Streaming response handling
302
+ buffer = ""
303
+ result = None
304
+
305
+ async for chunk in response.aiter_text():
306
+ buffer += chunk
307
+ lines = buffer.split("\n")
308
+ buffer = lines.pop()
309
+
310
+ for line in lines:
311
+ if not line.strip():
312
+ continue
313
+ try:
314
+ parsed = json.loads(line)
315
+ parsed_type = parsed.get("type", "")
316
+ parsed_data = parsed.get("data", "")
317
+
318
+ if parsed_type == "stdout":
319
+ if parsed_data:
320
+ if on_stdout:
321
+ on_stdout(parsed_data)
322
+ if on_log:
323
+ on_log(parsed_data)
324
+ elif parsed_type == "stderr":
325
+ if parsed_data:
326
+ if on_stderr:
327
+ on_stderr(parsed_data)
328
+ if on_log:
329
+ on_log(parsed_data)
330
+ elif parsed_type == "result":
331
+ try:
332
+ result = ProcessResponse.from_dict(json.loads(parsed_data))
333
+ except Exception:
334
+ raise Exception(f"Failed to parse result JSON: {parsed_data}")
335
+ except json.JSONDecodeError:
336
+ continue
337
+
338
+ # Process any remaining buffer
339
+ if buffer.strip():
340
+ if buffer.startswith("result:"):
341
+ json_str = buffer[7:]
342
+ try:
343
+ result = ProcessResponse.from_dict(json.loads(json_str))
344
+ except Exception:
345
+ raise Exception(f"Failed to parse result JSON: {json_str}")
346
+
347
+ if not result:
348
+ raise Exception("No result received from streaming response")
349
+
350
+ return ProcessResponseWithLog(result, lambda: None)
236
351
 
237
352
  async def wait(
238
353
  self, identifier: str, max_wait: int = 60000, interval: int = 1000
@@ -143,43 +143,169 @@ class SyncSandboxProcess(SyncSandboxAction):
143
143
  process: Union[ProcessRequest, ProcessRequestWithLog, Dict[str, Any]],
144
144
  ) -> Union[ProcessResponse, ProcessResponseWithLog]:
145
145
  on_log = None
146
+ on_stdout = None
147
+ on_stderr = None
148
+
146
149
  if isinstance(process, ProcessRequestWithLog):
147
150
  on_log = process.on_log
151
+ on_stdout = process.on_stdout
152
+ on_stderr = process.on_stderr
148
153
  process = process.to_dict()
154
+
149
155
  if isinstance(process, dict):
150
156
  if "on_log" in process:
151
157
  on_log = process["on_log"]
152
158
  del process["on_log"]
159
+ if "on_stdout" in process:
160
+ on_stdout = process["on_stdout"]
161
+ del process["on_stdout"]
162
+ if "on_stderr" in process:
163
+ on_stderr = process["on_stderr"]
164
+ del process["on_stderr"]
153
165
  process = ProcessRequest.from_dict(process)
166
+
154
167
  should_wait_for_completion = process.wait_for_completion
155
- if should_wait_for_completion and on_log is not None:
156
- process.wait_for_completion = False
157
- with self.get_client() as client_instance:
158
- response = client_instance.post("/process", json=process.to_dict())
159
- response_data = None
160
- if response.content:
161
- try:
162
- response_data = response.json()
163
- except Exception:
164
- self.handle_response_error(response)
165
- raise
166
- self.handle_response_error(response)
167
- result = ProcessResponse.from_dict(response_data)
168
- if should_wait_for_completion and on_log is not None:
169
- stream_control = self._stream_logs(result.pid, {"on_log": on_log})
170
- try:
171
- result = self.wait(result.pid, interval=500, max_wait=1000 * 60 * 60)
172
- finally:
173
- if stream_control:
174
- stream_control["close"]()
175
- else:
176
- if on_log is not None:
177
- stream_control = self._stream_logs(result.pid, {"on_log": on_log})
168
+
169
+ # When waiting for completion with streaming callbacks, use streaming endpoint
170
+ if should_wait_for_completion and (on_log or on_stdout or on_stderr):
171
+ return self._exec_with_streaming(
172
+ process, on_log=on_log, on_stdout=on_stdout, on_stderr=on_stderr
173
+ )
174
+ else:
175
+ with self.get_client() as client_instance:
176
+ response = client_instance.post("/process", json=process.to_dict())
177
+ response_data = None
178
+ if response.content:
179
+ try:
180
+ response_data = response.json()
181
+ except Exception:
182
+ self.handle_response_error(response)
183
+ raise
184
+ self.handle_response_error(response)
185
+ result = ProcessResponse.from_dict(response_data)
186
+
187
+ if on_log or on_stdout or on_stderr:
188
+ stream_control = self._stream_logs(
189
+ result.pid, {"on_log": on_log, "on_stdout": on_stdout, "on_stderr": on_stderr}
190
+ )
178
191
  return ProcessResponseWithLog(
179
192
  result,
180
193
  lambda: stream_control["close"]() if stream_control else None,
181
194
  )
182
- return result
195
+
196
+ return result
197
+
198
+ def _exec_with_streaming(
199
+ self,
200
+ process_request: ProcessRequest,
201
+ on_log: Callable[[str], None] | None = None,
202
+ on_stdout: Callable[[str], None] | None = None,
203
+ on_stderr: Callable[[str], None] | None = None,
204
+ ) -> ProcessResponseWithLog:
205
+ """Execute a process with streaming response handling for NDJSON."""
206
+ import json
207
+
208
+ headers = (
209
+ self.sandbox_config.headers
210
+ if self.sandbox_config.force_url
211
+ else {**settings.headers, **self.sandbox_config.headers}
212
+ )
213
+
214
+ with httpx.Client() as client_instance:
215
+ with client_instance.stream(
216
+ "POST",
217
+ f"{self.url}/process",
218
+ headers={
219
+ **headers,
220
+ "Content-Type": "application/json",
221
+ "Accept": "text/event-stream",
222
+ },
223
+ json=process_request.to_dict(),
224
+ timeout=None,
225
+ ) as response:
226
+ if response.status_code >= 400:
227
+ error_text = response.read()
228
+ raise Exception(f"Failed to execute process: {error_text}")
229
+
230
+ content_type = response.headers.get("Content-Type", "")
231
+ is_streaming = "application/x-ndjson" in content_type
232
+
233
+ # Fallback: server doesn't support streaming, use legacy approach
234
+ if not is_streaming:
235
+ content = response.read()
236
+ data = json.loads(content)
237
+ result = ProcessResponse.from_dict(data)
238
+
239
+ # If process already completed (server waited), emit logs through callbacks
240
+ if result.status == "completed" or result.status == "failed":
241
+ if result.stdout:
242
+ for line in result.stdout.split("\n"):
243
+ if line:
244
+ if on_stdout:
245
+ on_stdout(line)
246
+ if result.stderr:
247
+ for line in result.stderr.split("\n"):
248
+ if line:
249
+ if on_stderr:
250
+ on_stderr(line)
251
+ if result.logs:
252
+ for line in result.logs.split("\n"):
253
+ if line:
254
+ if on_log:
255
+ on_log(line)
256
+
257
+ return ProcessResponseWithLog(result, lambda: None)
258
+
259
+ # Streaming response handling
260
+ buffer = ""
261
+ result = None
262
+
263
+ for chunk in response.iter_text():
264
+ buffer += chunk
265
+ lines = buffer.split("\n")
266
+ buffer = lines.pop()
267
+
268
+ for line in lines:
269
+ if not line.strip():
270
+ continue
271
+ try:
272
+ parsed = json.loads(line)
273
+ parsed_type = parsed.get("type", "")
274
+ parsed_data = parsed.get("data", "")
275
+
276
+ if parsed_type == "stdout":
277
+ if parsed_data:
278
+ if on_stdout:
279
+ on_stdout(parsed_data)
280
+ if on_log:
281
+ on_log(parsed_data)
282
+ elif parsed_type == "stderr":
283
+ if parsed_data:
284
+ if on_stderr:
285
+ on_stderr(parsed_data)
286
+ if on_log:
287
+ on_log(parsed_data)
288
+ elif parsed_type == "result":
289
+ try:
290
+ result = ProcessResponse.from_dict(json.loads(parsed_data))
291
+ except Exception:
292
+ raise Exception(f"Failed to parse result JSON: {parsed_data}")
293
+ except json.JSONDecodeError:
294
+ continue
295
+
296
+ # Process any remaining buffer
297
+ if buffer.strip():
298
+ if buffer.startswith("result:"):
299
+ json_str = buffer[7:]
300
+ try:
301
+ result = ProcessResponse.from_dict(json.loads(json_str))
302
+ except Exception:
303
+ raise Exception(f"Failed to parse result JSON: {json_str}")
304
+
305
+ if not result:
306
+ raise Exception("No result received from streaming response")
307
+
308
+ return ProcessResponseWithLog(result, lambda: None)
183
309
 
184
310
  def wait(self, identifier: str, max_wait: int = 60000, interval: int = 1000) -> ProcessResponse:
185
311
  start_time = time.monotonic() * 1000
@@ -282,6 +282,8 @@ class SandboxCreateConfiguration:
282
282
  @_attrs_define
283
283
  class ProcessRequestWithLog(ProcessRequest):
284
284
  on_log: Callable[[str], None] | None = None
285
+ on_stdout: Callable[[str], None] | None = None
286
+ on_stderr: Callable[[str], None] | None = None
285
287
 
286
288
 
287
289
  class ProcessResponseWithLog:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blaxel
3
- Version: 0.2.32
3
+ Version: 0.2.33
4
4
  Summary: Blaxel - AI development platform SDK
5
5
  Project-URL: Homepage, https://blaxel.ai
6
6
  Project-URL: Documentation, https://docs.blaxel.ai
@@ -17,7 +17,6 @@ Requires-Dist: pyjwt>=2.0.0
17
17
  Requires-Dist: python-dateutil>=2.8.0
18
18
  Requires-Dist: pyyaml>=6.0.0
19
19
  Requires-Dist: requests>=2.32.3
20
- Requires-Dist: sentry-sdk>=2.46.0
21
20
  Requires-Dist: tomli>=2.2.1
22
21
  Requires-Dist: websockets<16.0.0
23
22
  Provides-Extra: all
@@ -1,4 +1,4 @@
1
- blaxel/__init__.py,sha256=DodOCaAPXa8pstbUXBPzRjID-7e_I2akWFDmtctRxkc,413
1
+ blaxel/__init__.py,sha256=wk69Pg_DfZapO7suCkD3c_pEa3GOrJe2AUqhua2R2WE,413
2
2
  blaxel/core/__init__.py,sha256=CKMC7TaCYdOdnwqcJCN9VjBbNg366coZUGTxI1mgFQQ,1710
3
3
  blaxel/core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  blaxel/core/agents/__init__.py,sha256=MJZga99lU8JWUUPHd4rmUfdo7ALwWgF7CQq95SfT2OI,4456
@@ -330,11 +330,12 @@ blaxel/core/client/models/workspace.py,sha256=3Z-bpTYukkbqW46B39Nljr4XZtAV41AqqK
330
330
  blaxel/core/client/models/workspace_labels.py,sha256=M_ZbpQeVNdgqWv2RmTYO7znBczUJ6zi5_XS1EDin3Gk,1253
331
331
  blaxel/core/client/models/workspace_runtime.py,sha256=Pdr_Q-ugs_5famziqGSyBzUkKQ6e-pAFRGza6GhppSA,1672
332
332
  blaxel/core/client/models/workspace_user.py,sha256=f28XZuP3V46y2YYbfiIiNbnIMpy3JINLwpy_mX9N1iU,3430
333
- blaxel/core/common/__init__.py,sha256=K5uIZe98NIjYkjMPdKvP5RuwzIfv1Obm6g3aWh2qjZ0,599
334
- blaxel/core/common/autoload.py,sha256=1v6-MwjifmPLnfFA11deNYxydSPTKIawc3nQGOb4l50,6375
333
+ blaxel/core/common/__init__.py,sha256=A69U94NslPOYjKvja0bN5z98eguJPC6UaGaVN2-FdRY,736
334
+ blaxel/core/common/autoload.py,sha256=Tq6cmmNcOwUy77ACWM-4Ap8DgRY6GWkAOJmIwugRAPU,1481
335
335
  blaxel/core/common/env.py,sha256=05Jm2mw0-KIgR7QaNVyvZPP91B9OlxlJ6mcx6Mqfji0,1234
336
336
  blaxel/core/common/internal.py,sha256=NDTFh9Duj84os8GkMXjGzn-UVS9zBDyLAcfPxIpoQGA,3218
337
337
  blaxel/core/common/logger.py,sha256=Jt0MCJgYDPq36rl7UyKRDJH76a-AwYdfggNeNYJt6N0,4779
338
+ blaxel/core/common/sentry.py,sha256=P_v1vWivlh-usXV1_JeJ603r1OHoWaR5jaYV2JJ6vDM,9759
338
339
  blaxel/core/common/settings.py,sha256=f7ZP8VpB8gKESptyb6S82gTHQsbmx6RcaAkDkxy2JpE,4599
339
340
  blaxel/core/common/webhook.py,sha256=N1f2bamP7wRyPyCfmAZKMdjeB3aQ6d6pcafHyVZKtPk,5330
340
341
  blaxel/core/jobs/__init__.py,sha256=LZTtkOwqUyMjRTdeLv4EXLbVhgebwvY95jd56IGO4MQ,17082
@@ -343,7 +344,7 @@ blaxel/core/mcp/client.py,sha256=EZ7l5w3bTXaD41nalHzM-byxfQK-JdcmQqxg3zGpVO4,550
343
344
  blaxel/core/mcp/server.py,sha256=edAztWBlukERw9-dzS2Sk96TP8R3-CSofY1CZDu19ZA,5967
344
345
  blaxel/core/models/__init__.py,sha256=ydz1txqIVyOhehItut-AOnLMnGp7AtCD2zku9gkvAsE,1722
345
346
  blaxel/core/sandbox/__init__.py,sha256=SQ9YH0I9ruAYscY_W75jqIhNbejbY_92xL7VqM083Mc,1345
346
- blaxel/core/sandbox/types.py,sha256=h_e0Zk_BNRjix1RRtomqVwdG64ViA34KwM4ZQIM5Bp0,12601
347
+ blaxel/core/sandbox/types.py,sha256=m8yWOXxpLfzeNPhxBwU1VnXFeIHnv95OQuDnST3tYrc,12703
347
348
  blaxel/core/sandbox/client/__init__.py,sha256=N26bD5o1jsTb48oExow6Rgivd8ylaU9jaWZfZsVilP8,128
348
349
  blaxel/core/sandbox/client/client.py,sha256=EGCYliUHCk4RyIBlydEZyVpc_VUiIIGPuu2E-xYeKFY,7074
349
350
  blaxel/core/sandbox/client/errors.py,sha256=gO8GBmKqmSNgAg-E5oT-oOyxztvp7V_6XG7OUTT15q0,546
@@ -438,7 +439,7 @@ blaxel/core/sandbox/default/filesystem.py,sha256=Gn1G3DpMmDXplUEpXVENXkjUDAEkWAK
438
439
  blaxel/core/sandbox/default/interpreter.py,sha256=TJSryQvq2rWedyhMU69tOlXVOF1iIpgIbV2z3_mF72E,11316
439
440
  blaxel/core/sandbox/default/network.py,sha256=3ZvrJB_9JdZrclNkwifZOIciz2OqzV0LQfbebjZXLIY,358
440
441
  blaxel/core/sandbox/default/preview.py,sha256=dV_xuu9Efop5TnzuFJPeLUZ7CEepuYkJedx01fDVMX4,6132
441
- blaxel/core/sandbox/default/process.py,sha256=hUdFxFD5w8Md827gVwRijhY55D4BQEbmxuLrqFrCC6I,11965
442
+ blaxel/core/sandbox/default/process.py,sha256=7nI1wJXeZWoveBesC13wur-ghIjP5POZ38G8wqCdJTw,16983
442
443
  blaxel/core/sandbox/default/sandbox.py,sha256=b641_MHILDdsz9ZkbSIoRAmbB_0grbd3C_SG5JOwe18,12576
443
444
  blaxel/core/sandbox/default/session.py,sha256=XzVpPOH_az6T38Opp4Hmj3RIg7QCzA1l5wh1YDh7czc,5313
444
445
  blaxel/core/sandbox/sync/__init__.py,sha256=iqTRxQYbJyHTXoA4MHaigeXFxi9wtJ3o9XygZuFe3bM,372
@@ -448,7 +449,7 @@ blaxel/core/sandbox/sync/filesystem.py,sha256=FoxM9EJ5sXGysf-x22tbt9yrcbbpaunTD3
448
449
  blaxel/core/sandbox/sync/interpreter.py,sha256=5cAzwnt5BgnByGimagMBotjGW2vMAz4vutBBrrFV9-A,11062
449
450
  blaxel/core/sandbox/sync/network.py,sha256=QkCFKfFayvwL1J4JYwOuXPGlYQuX4J9Jj55Kf_kD-ig,283
450
451
  blaxel/core/sandbox/sync/preview.py,sha256=w3bC8iA3QecHiLkRvITmQ6LTT9Co_93G24QpZFgEQSE,6379
451
- blaxel/core/sandbox/sync/process.py,sha256=U4aYv-uRXDOKScz6Ets-QkwZZ1kdgR9ve04CkUaUkEU,9624
452
+ blaxel/core/sandbox/sync/process.py,sha256=W-ZUM6VyFDxTmexHTQn9PI6iRc0QiB9JMOEq__r2bBA,14913
452
453
  blaxel/core/sandbox/sync/sandbox.py,sha256=jNqwMJXIpjC8Fs9nn-ujfSpFK2PgCvEePISKQKaMhH0,10515
453
454
  blaxel/core/sandbox/sync/session.py,sha256=e0CVbW2LBRYTwm4RL52S0UdNvhNfuFLo6AYE5hk9DH0,4931
454
455
  blaxel/core/tools/__init__.py,sha256=OK2TFqeXAIi6CC7xtL8fFl-4DvCB7jjihkhx6RTld_c,13147
@@ -500,7 +501,7 @@ blaxel/telemetry/instrumentation/map.py,sha256=PCzZJj39yiYVYJrxLBNP-NW-tjjYyTijw
500
501
  blaxel/telemetry/instrumentation/utils.py,sha256=FGyMY5ZE4f-0JdZpm_R_BCoKLJ18hftz8vsh7ftDwMk,1889
501
502
  blaxel/telemetry/log/log.py,sha256=vtzUIFIIj4MTTKUigILDYXN8NHHPOo44OaKukpyIjQg,2407
502
503
  blaxel/telemetry/log/logger.py,sha256=IcFWCd1yyWWGAjAd2i0pDYqpZHQ61pmcaQ7Kf4bC8lg,4150
503
- blaxel-0.2.32.dist-info/METADATA,sha256=yQQO_JdQpH1Ka7_HxoTU2he4DXnWLWh8--eD8LWzdZc,10108
504
- blaxel-0.2.32.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
505
- blaxel-0.2.32.dist-info/licenses/LICENSE,sha256=p5PNQvpvyDT_0aYBDgmV1fFI_vAD2aSV0wWG7VTgRis,1069
506
- blaxel-0.2.32.dist-info/RECORD,,
504
+ blaxel-0.2.33.dist-info/METADATA,sha256=nWdiqiO6Qc_LIqAz4Tc6AyLaXFzhtyFkL0lDUSwwxQU,10074
505
+ blaxel-0.2.33.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
506
+ blaxel-0.2.33.dist-info/licenses/LICENSE,sha256=p5PNQvpvyDT_0aYBDgmV1fFI_vAD2aSV0wWG7VTgRis,1069
507
+ blaxel-0.2.33.dist-info/RECORD,,