sutro 0.1.19__tar.gz → 0.1.21__tar.gz

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.

Potentially problematic release.


This version of sutro might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sutro
3
- Version: 0.1.19
3
+ Version: 0.1.21
4
4
  Summary: Sutro Python SDK
5
5
  Project-URL: Homepage, https://sutro.sh
6
6
  Project-URL: Documentation, https://docs.sutro.sh
@@ -9,7 +9,7 @@ installer = "uv"
9
9
 
10
10
  [project]
11
11
  name = "sutro"
12
- version = "0.1.19"
12
+ version = "0.1.21"
13
13
  description = "Sutro Python SDK"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.10"
@@ -114,7 +114,6 @@ class Sutro:
114
114
  ):
115
115
  self.api_key = api_key or self.check_for_api_key()
116
116
  self.base_url = base_url
117
- self.HEARTBEAT_INTERVAL_SECONDS = 15 # Keep in sync w what the backend expects
118
117
 
119
118
  def check_for_api_key(self):
120
119
  """
@@ -286,69 +285,68 @@ class Sutro:
286
285
  job_id = None
287
286
  t = f"Creating {'[dry run] ' if dry_run else ''}priority {job_priority} job"
288
287
  spinner_text = to_colored_text(t)
289
- with yaspin(SPINNER, text=spinner_text, color=YASPIN_COLOR) as spinner:
290
- response = requests.post(
291
- endpoint, data=json.dumps(payload), headers=headers
292
- )
293
- response_data = response.json()
294
- if response.status_code != 200:
295
- spinner.write(
296
- to_colored_text(f"Error: {response.status_code}", state="fail")
288
+ try:
289
+ with yaspin(SPINNER, text=spinner_text, color=YASPIN_COLOR) as spinner:
290
+ response = requests.post(
291
+ endpoint, data=json.dumps(payload), headers=headers
297
292
  )
298
- spinner.stop()
299
- print(to_colored_text(response.json(), state="fail"))
300
- return None
301
- else:
302
- job_id = response_data["results"]
303
- if dry_run:
293
+ response_data = response.json()
294
+ if response.status_code != 200:
304
295
  spinner.write(
305
- to_colored_text(f"Awaiting cost estimates with job ID: {job_id}. You can safely detach and retrieve the cost estimates later.", state="info")
296
+ to_colored_text(f"Error: {response.status_code}", state="fail")
306
297
  )
307
298
  spinner.stop()
308
- self.await_job_completion(job_id, obtain_results=False)
309
- cost_estimate = self._get_job_cost_estimate(job_id)
310
- spinner.write(
311
- to_colored_text(f"✔ Cost estimates retrieved for job {job_id}: ${cost_estimate}", state="success")
312
- )
313
- return job_id
299
+ print(to_colored_text(response.json(), state="fail"))
300
+ return None
314
301
  else:
315
- spinner.write(
316
- to_colored_text(
317
- f"🛠 Priority {job_priority} Job created with ID: {job_id}.",
318
- state="success",
302
+ job_id = response_data["results"]
303
+ if dry_run:
304
+ spinner.write(
305
+ to_colored_text(f"Awaiting cost estimates with job ID: {job_id}. You can safely detach and retrieve the cost estimates later.", state="info")
319
306
  )
320
- )
321
- if not stay_attached:
307
+ spinner.stop()
308
+ self.await_job_completion(job_id, obtain_results=False)
309
+ cost_estimate = self._get_job_cost_estimate(job_id)
310
+ spinner.write(
311
+ to_colored_text(f"✔ Cost estimates retrieved for job {job_id}: ${cost_estimate}", state="success")
312
+ )
313
+ return job_id
314
+ else:
322
315
  spinner.write(
323
316
  to_colored_text(
324
- f"Use `so.get_job_status('{job_id}')` to check the status of the job."
325
- )
317
+ f"🛠 Priority {job_priority} Job created with ID: {job_id}.",
318
+ state="success",
326
319
  )
327
- return job_id
320
+ )
321
+ if not stay_attached:
322
+ spinner.write(
323
+ to_colored_text(
324
+ f"Use `so.get_job_status('{job_id}')` to check the status of the job."
325
+ )
326
+ )
327
+ return job_id
328
+ except KeyboardInterrupt:
329
+ pass
330
+ finally:
331
+ if spinner:
332
+ spinner.stop()
328
333
 
329
334
  success = False
330
335
  if stay_attached and job_id is not None:
331
336
  spinner.write(to_colored_text("Awaiting job start...", ))
332
- spinner.write(to_colored_text(f'Progress can also be monitored at: {make_clickable_link(f'https://app.sutro.sh/jobs/{job_id}')}'))
337
+ clickable_link = make_clickable_link(f'https://app.sutro.sh/jobs/{job_id}')
338
+ spinner.write(to_colored_text(f'Progress can also be monitored at: {clickable_link}'))
333
339
  started = self._await_job_start(job_id)
334
340
  if not started:
335
341
  failure_reason = self._get_failure_reason(job_id)
336
342
  spinner.write(to_colored_text(f"Failure reason: {failure_reason['message']}", "fail"))
337
343
  return None
338
-
339
344
  s = requests.Session()
340
- payload = {
341
- "job_id": job_id,
342
- }
343
345
  pbar = None
344
346
 
345
- # Register for stream and get session token
346
- session_token = self.register_stream_listener(job_id)
347
-
348
- # Use the heartbeat session context manager
349
- with self.stream_heartbeat_session(job_id, session_token) as s:
350
- with s.get(
351
- f"{self.base_url}/stream-job-progress/{job_id}?request_session_token={session_token}",
347
+ try:
348
+ with requests.get(
349
+ f"{self.base_url}/stream-job-progress/{job_id}",
352
350
  headers=headers,
353
351
  stream=True,
354
352
  ) as streaming_response:
@@ -359,6 +357,13 @@ class Sutro:
359
357
  color=YASPIN_COLOR,
360
358
  )
361
359
  spinner.start()
360
+
361
+ token_state = {
362
+ 'input_tokens': 0,
363
+ 'output_tokens': 0,
364
+ 'total_tokens_processed_per_second': 0
365
+ }
366
+
362
367
  for line in streaming_response.iter_lines():
363
368
  if line:
364
369
  try:
@@ -381,12 +386,30 @@ class Sutro:
381
386
  pbar.update(json_obj["result"] - pbar.n)
382
387
  pbar.refresh()
383
388
  if json_obj["result"] == len(input_data):
384
- pbar.close()
385
389
  success = True
386
390
  elif json_obj["update_type"] == "tokens":
391
+ # Update only the values that are present in this update
392
+ # Currently, the way the progress stream endpoint is defined,
393
+ # its possible to have updates come in that only have 1 or 2 fields
394
+ new = {
395
+ k: v for k, v in json_obj.get('result', {}).items()
396
+ if k in token_state and v >= token_state[k]
397
+ }
398
+ token_state.update(new)
399
+
387
400
  if pbar is not None:
388
- pbar.postfix = f"Input tokens processed: {json_obj['result']['input_tokens']}, Tokens generated: {json_obj['result']['output_tokens']}, Total tokens/s: {json_obj['result'].get('total_tokens_processed_per_second')}"
401
+ pbar.postfix = f"Input tokens processed: {token_state['input_tokens']}, Output tokens generated: {token_state['output_tokens']}, Total tokens/s: {token_state['total_tokens_processed_per_second']}"
389
402
  pbar.refresh()
403
+
404
+ except KeyboardInterrupt:
405
+ pass
406
+ finally:
407
+ # Need to clean these up on keyboard exit otherwise it causes
408
+ # an error
409
+ if pbar is not None:
410
+ pbar.close()
411
+ if spinner is not None:
412
+ spinner.stop()
390
413
  if success:
391
414
  spinner.text = to_colored_text(
392
415
  "✔ Job succeeded. Obtaining results...", state="success"
@@ -451,87 +474,6 @@ class Sutro:
451
474
  return None
452
475
  return None
453
476
 
454
- def register_stream_listener(self, job_id: str) -> str:
455
- """Register a new stream listener and get a session token."""
456
- headers = {
457
- "Authorization": f"Key {self.api_key}",
458
- "Content-Type": "application/json",
459
- }
460
- with requests.post(
461
- f"{self.base_url}/register-stream-listener/{job_id}",
462
- headers=headers,
463
- ) as response:
464
- response.raise_for_status()
465
- data = response.json()
466
- return data["request_session_token"]
467
-
468
- # This is a best effort action and is ok if it sometimes doesn't complete etc
469
- def unregister_stream_listener(self, job_id: str, session_token: str):
470
- """Explicitly unregister a stream listener."""
471
- headers = {
472
- "Authorization": f"Key {self.api_key}",
473
- "Content-Type": "application/json",
474
- }
475
- with requests.post(
476
- f"{self.base_url}/unregister-stream-listener/{job_id}",
477
- headers=headers,
478
- json={"request_session_token": session_token},
479
- ) as response:
480
- response.raise_for_status()
481
-
482
- def start_heartbeat(
483
- self,
484
- job_id: str,
485
- session_token: str,
486
- session: requests.Session,
487
- stop_event: threading.Event
488
- ):
489
- """Send heartbeats until stopped."""
490
- while not stop_event.is_set():
491
- try:
492
- headers = {
493
- "Authorization": f"Key {self.api_key}",
494
- "Content-Type": "application/json",
495
- }
496
- response = session.post(
497
- f"{self.base_url}/stream-heartbeat/{job_id}",
498
- headers=headers,
499
- params={"request_session_token": session_token},
500
- )
501
- response.raise_for_status()
502
- except Exception as e:
503
- if not stop_event.is_set(): # Only log if we weren't stopping anyway
504
- print(f"Heartbeat failed for job {job_id}: {e}")
505
-
506
- for _ in range(self.HEARTBEAT_INTERVAL_SECONDS):
507
- if stop_event.is_set():
508
- break
509
- time.sleep(1)
510
-
511
- @contextmanager
512
- def stream_heartbeat_session(self, job_id: str, session_token: str) -> Generator[requests.Session, None, None]:
513
- """Context manager that handles session registration and heartbeat."""
514
- session = requests.Session()
515
- stop_heartbeat = threading.Event()
516
-
517
- # Run this concurrently in a thread so we can not block main SDK path/behavior
518
- # but still run heartbeat requests
519
- with ThreadPoolExecutor(max_workers=1) as executor:
520
- executor.submit(
521
- self.start_heartbeat,
522
- job_id,
523
- session_token,
524
- session,
525
- stop_heartbeat
526
- )
527
-
528
- try:
529
- yield session
530
- finally:
531
- # Signal stop and cleanup
532
- stop_heartbeat.set()
533
- self.unregister_stream_listener(job_id, session_token)
534
- session.close()
535
477
 
536
478
  def attach(self, job_id):
537
479
  """
@@ -596,11 +538,9 @@ class Sutro:
596
538
  total_rows = job["num_rows"]
597
539
  success = False
598
540
 
599
- session_token = self.register_stream_listener(job_id)
600
-
601
- with self.stream_heartbeat_session(job_id, session_token) as s:
541
+ try:
602
542
  with s.get(
603
- f"{self.base_url}/stream-job-progress/{job_id}?request_session_token={session_token}",
543
+ f"{self.base_url}/stream-job-progress/{job_id}",
604
544
  headers=headers,
605
545
  stream=True,
606
546
  ) as streaming_response:
@@ -610,7 +550,8 @@ class Sutro:
610
550
  text=to_colored_text("Awaiting status updates..."),
611
551
  color=YASPIN_COLOR,
612
552
  )
613
- spinner.write(to_colored_text(f'Progress can also be monitored at: {make_clickable_link(f'https://app.sutro.sh/jobs/{job_id}')}'))
553
+ clickable_link = make_clickable_link(f'https://app.sutro.sh/jobs/{job_id}')
554
+ spinner.write(to_colored_text(f'Progress can also be monitored at: {clickable_link}'))
614
555
  spinner.start()
615
556
  for line in streaming_response.iter_lines():
616
557
  if line:
@@ -649,6 +590,13 @@ class Sutro:
649
590
  )
650
591
  )
651
592
  spinner.stop()
593
+ except KeyboardInterrupt:
594
+ pass
595
+ finally:
596
+ if pbar:
597
+ pbar.close()
598
+ if spinner:
599
+ spinner.stop()
652
600
 
653
601
 
654
602
 
@@ -1287,7 +1235,8 @@ class Sutro:
1287
1235
  with yaspin(
1288
1236
  SPINNER, text=to_colored_text("Awaiting job completion"), color=YASPIN_COLOR
1289
1237
  ) as spinner:
1290
- spinner.write(to_colored_text(f'Progress can also be monitored at: {make_clickable_link(f'https://app.sutro.sh/jobs/{job_id}')}'))
1238
+ clickable_link = make_clickable_link(f'https://app.sutro.sh/jobs/{job_id}')
1239
+ spinner.write(to_colored_text(f'Progress can also be monitored at: {clickable_link}'))
1291
1240
  while (time.time() - start_time) < timeout:
1292
1241
  try:
1293
1242
  status = self._fetch_job_status(job_id)
File without changes
File without changes
File without changes
File without changes
File without changes