snowglobe 0.4.1__py3-none-any.whl → 0.4.3__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.
@@ -20,8 +20,8 @@ from apscheduler import AsyncScheduler
20
20
  from apscheduler.triggers.interval import IntervalTrigger
21
21
  from fastapi import FastAPI, HTTPException, Request
22
22
 
23
- from .cli_utils import info
24
- from .config import config
23
+ from .cli_utils import info, shutdown_manager
24
+ from .config import config, get_api_key_or_raise
25
25
  from .models import CompletionFunctionOutputs, CompletionRequest, RiskEvaluationRequest
26
26
  from .stats import initialize_stats, track_batch_completion
27
27
  from .utils import fetch_experiments, fetch_messages
@@ -190,7 +190,7 @@ async def process_application_heartbeat(app_id):
190
190
  connection_test_response = await client.post(
191
191
  connection_test_url,
192
192
  json=connection_test_payload,
193
- headers={"x-api-key": config.API_KEY},
193
+ headers={"x-api-key": get_api_key_or_raise()},
194
194
  )
195
195
 
196
196
  if not connection_test_response.is_success:
@@ -240,7 +240,7 @@ async def process_risk_evaluation(test, risk_name):
240
240
  "risk_type": risk_name,
241
241
  "risk_triggered": risk_evaluation.triggered,
242
242
  },
243
- headers={"x-api-key": config.API_KEY},
243
+ headers={"x-api-key": get_api_key_or_raise()},
244
244
  )
245
245
  LOGGER.debug(f"Time taken: {time.time() - start} seconds")
246
246
  if not risk_evaluation_response.is_success:
@@ -271,7 +271,7 @@ async def process_test(test, completion_fn, app_id):
271
271
  "response": completionOutput.response,
272
272
  "persona": test["persona"],
273
273
  },
274
- headers={"x-api-key": config.API_KEY},
274
+ headers={"x-api-key": get_api_key_or_raise()},
275
275
  )
276
276
  LOGGER.debug(f"Time taken: {time.time() - start} seconds")
277
277
  # remove test['id'] from queued_tests
@@ -282,186 +282,382 @@ async def process_test(test, completion_fn, app_id):
282
282
 
283
283
  # The task to run
284
284
  async def poll_for_completions():
285
+ # Exit early if shutdown requested
286
+ if await shutdown_manager.is_async_shutdown_requested():
287
+ return
288
+
289
+ job_id = "poll_for_completions"
290
+
285
291
  if len(apps) == 0:
286
292
  LOGGER.warning("No applications found. Skipping completions check.")
287
293
  return
288
- experiments = await fetch_experiments()
289
294
 
290
- async with httpx.AsyncClient() as client:
291
- LOGGER.info(f"Polling {len(experiments)} experiments for completions...")
292
- for experiment in experiments:
293
- LOGGER.debug(
294
- f"Checking experiment: id={experiment.get('id')}, name={experiment.get('name', 'unknown')}"
295
- )
296
- for experiment in experiments:
297
- app_id = experiment.get("app_id")
298
- if app_id not in apps:
295
+ # Register this job as active
296
+ shutdown_manager.register_active_job(job_id)
297
+
298
+ try:
299
+ experiments = await fetch_experiments()
300
+ except Exception as e:
301
+ # Handle shutdown-related connection errors gracefully
302
+ if shutdown_manager.is_shutdown_requested():
303
+ LOGGER.debug(f"Connection error during shutdown (expected): {e}")
304
+ return
305
+ else:
306
+ LOGGER.error(f"Error fetching experiments: {e}")
307
+ raise
308
+ finally:
309
+ shutdown_manager.unregister_active_job(job_id)
310
+
311
+ try:
312
+ async with httpx.AsyncClient() as client:
313
+ LOGGER.info(f"Polling {len(experiments)} experiments for completions...")
314
+ for experiment in experiments:
299
315
  LOGGER.debug(
300
- f"Skipping experiment as we do not have a completion function for app_id: {app_id}"
301
- )
302
- continue
303
- experiment_id = experiment["id"]
304
- experiment_request = await client.get(
305
- f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment_id}?appId={app_id}",
306
- headers={"x-api-key": config.API_KEY},
307
- )
308
- if not experiment_request.is_success:
309
- LOGGER.error(
310
- f"Error fetching experiment {experiment_id}: {experiment_request.text}"
316
+ f"Checking experiment: id={experiment.get('id')}, name={experiment.get('name', 'unknown')}"
311
317
  )
312
- continue
313
- experiment = experiment_request.json()
314
318
 
315
- limit = 10
316
- LOGGER.debug(f"Checking for tests for experiment {experiment_id}")
317
- tests_response = await client.get(
318
- f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment_id}/tests?appId={app_id}&include-risk-evaluations=false&limit={limit}&unprocessed-only=true",
319
- headers={"x-api-key": config.API_KEY},
320
- )
319
+ for experiment in experiments:
320
+ # Check for shutdown between each experiment
321
+ if await shutdown_manager.is_async_shutdown_requested():
322
+ LOGGER.debug("Shutdown requested, stopping completions polling")
323
+ return
321
324
 
322
- if not tests_response.is_success:
323
- LOGGER.error(f"Error fetching tests: {tests_response.text}")
324
- continue
325
+ app_id = experiment.get("app_id")
326
+ if app_id not in apps:
327
+ LOGGER.debug(
328
+ f"Skipping experiment as we do not have a completion function for app_id: {app_id}"
329
+ )
330
+ continue
331
+ experiment_id = experiment["id"]
325
332
 
326
- tests = tests_response.json()
327
- completion_count = 0
328
- for test in tests:
329
- LOGGER.debug(f"Found test {test['id']} for experiment {experiment_id}")
330
- if not test["response"] and test["id"] not in queued_tests:
331
- completion_request = await httpx.AsyncClient().post(
332
- f"{config.SNOWGLOBE_CLIENT_URL}/completion",
333
- json={"test": test, "app_id": app_id},
334
- timeout=30,
333
+ try:
334
+ experiment_request = await client.get(
335
+ f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment_id}?appId={app_id}",
336
+ headers={"x-api-key": get_api_key_or_raise()},
335
337
  )
336
- # if 429 raise and exception and stop this batch
337
- if (
338
- not completion_request.is_success
339
- and completion_request.status_code == 429
340
- ):
341
- LOGGER.warning(
342
- f"Completion Rate limit exceeded for test {test['id']}: {completion_request.text}"
343
- )
344
- raise ValueError(
345
- status_code=429,
346
- detail=f"Rate limit exceeded for test {test['id']}",
338
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
339
+ if shutdown_manager.is_shutdown_requested():
340
+ LOGGER.debug(f"HTTP error during shutdown (expected): {e}")
341
+ return
342
+ else:
343
+ LOGGER.error(
344
+ f"Connection error fetching experiment {experiment_id}: {e}"
347
345
  )
348
- completion_count += 1
349
- queued_tests[test["id"]] = True
350
- if completion_count > 0:
351
- experiment_name = experiment.get("name", "unknown")
352
- if LOGGER.level <= logging.INFO: # Verbose mode
353
- LOGGER.info(
354
- f"Processed {completion_count} completions for experiment {experiment_name} ({experiment_id})"
346
+ continue
347
+
348
+ if not experiment_request.is_success:
349
+ LOGGER.error(
350
+ f"Error fetching experiment {experiment_id}: {experiment_request.text}"
355
351
  )
356
- else: # Clean UI mode
357
- timestamp = datetime.datetime.now().strftime("%H:%M:%S")
358
- info(
359
- f"[{timestamp}] Batch complete: {completion_count} responses sent ({experiment_name})"
352
+ continue
353
+ experiment = experiment_request.json()
354
+
355
+ limit = 10
356
+ LOGGER.debug(f"Checking for tests for experiment {experiment_id}")
357
+
358
+ try:
359
+ tests_response = await client.get(
360
+ f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment_id}/tests?appId={app_id}&include-risk-evaluations=false&limit={limit}&unprocessed-only=true",
361
+ headers={"x-api-key": get_api_key_or_raise()},
360
362
  )
363
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
364
+ if shutdown_manager.is_shutdown_requested():
365
+ LOGGER.debug(f"HTTP error during shutdown (expected): {e}")
366
+ return
367
+ else:
368
+ LOGGER.error(f"Connection error fetching tests: {e}")
369
+ continue
370
+
371
+ if not tests_response.is_success:
372
+ LOGGER.error(f"Error fetching tests: {tests_response.text}")
373
+ continue
361
374
 
362
- # Track batch completion
363
- track_batch_completion(experiment_name, completion_count)
375
+ tests = tests_response.json()
376
+ completion_count = 0
377
+ for test in tests:
378
+ # Check for shutdown before processing each test
379
+ if await shutdown_manager.is_async_shutdown_requested():
380
+ LOGGER.debug("Shutdown requested, stopping test processing")
381
+ return
382
+
383
+ LOGGER.debug(
384
+ f"Found test {test['id']} for experiment {experiment_id}"
385
+ )
386
+ if not test["response"] and test["id"] not in queued_tests:
387
+ try:
388
+ completion_request = await httpx.AsyncClient().post(
389
+ f"{config.SNOWGLOBE_CLIENT_URL}/completion",
390
+ json={"test": test, "app_id": app_id},
391
+ timeout=30,
392
+ )
393
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
394
+ if shutdown_manager.is_shutdown_requested():
395
+ LOGGER.debug(
396
+ f"HTTP error during shutdown (expected): {e}"
397
+ )
398
+ return
399
+ else:
400
+ LOGGER.error(
401
+ f"Connection error posting completion: {e}"
402
+ )
403
+ continue
404
+
405
+ # if 429 raise and exception and stop this batch
406
+ if (
407
+ not completion_request.is_success
408
+ and completion_request.status_code == 429
409
+ ):
410
+ LOGGER.warning(
411
+ f"Completion Rate limit exceeded for test {test['id']}: {completion_request.text}"
412
+ )
413
+ raise ValueError(
414
+ status_code=429,
415
+ detail=f"Rate limit exceeded for test {test['id']}",
416
+ )
417
+ completion_count += 1
418
+ queued_tests[test["id"]] = True
419
+
420
+ if completion_count > 0:
421
+ experiment_name = experiment.get("name", "unknown")
422
+ if LOGGER.level <= logging.INFO: # Verbose mode
423
+ LOGGER.info(
424
+ f"Processed {completion_count} completions for experiment {experiment_name} ({experiment_id})"
425
+ )
426
+ else: # Clean UI mode
427
+ timestamp = datetime.datetime.now().strftime("%H:%M:%S")
428
+ info(
429
+ f"[{timestamp}] ✓ Batch complete: {completion_count} responses sent ({experiment_name})"
430
+ )
431
+
432
+ # Track batch completion
433
+ track_batch_completion(experiment_name, completion_count)
434
+
435
+ except Exception as e:
436
+ # Handle any other errors
437
+ if shutdown_manager.is_shutdown_requested():
438
+ LOGGER.debug(f"Error during shutdown (expected): {e}")
439
+ else:
440
+ LOGGER.error(f"Unexpected error in poll_for_completions: {e}")
441
+ raise
364
442
 
365
443
 
366
444
  async def process_application_heartbeats():
367
- connection_test_count = 0
368
- LOGGER.info("Processing application heartbeats...")
369
- for app_id, app_info in apps.items():
370
- connection_test_request = await httpx.AsyncClient().post(
371
- f"{config.SNOWGLOBE_CLIENT_URL}/heartbeat",
372
- json={"app_id": app_id},
373
- timeout=30,
374
- )
375
- if not connection_test_request.is_success:
376
- LOGGER.error(
377
- f"Error sending heartbeat for application {app_id}: {connection_test_request.text}"
378
- )
379
- continue
380
- connection_test_count += 1
445
+ # Exit early if shutdown requested
446
+ if await shutdown_manager.is_async_shutdown_requested():
447
+ return
381
448
 
382
- LOGGER.info(f"Processed {connection_test_count} heartbeats for applications.")
449
+ job_id = "process_application_heartbeats"
450
+ shutdown_manager.register_active_job(job_id)
383
451
 
452
+ try:
453
+ connection_test_count = 0
454
+ LOGGER.info("Processing application heartbeats...")
455
+ for app_id, app_info in apps.items():
456
+ # Check for shutdown between each heartbeat
457
+ if await shutdown_manager.is_async_shutdown_requested():
458
+ LOGGER.debug("Shutdown requested, stopping heartbeats")
459
+ return
384
460
 
385
- async def poll_for_risk_evaluations():
386
- """Poll for risk evaluations and process them."""
387
- experiments = await fetch_experiments()
388
- LOGGER.info("Checking for pending risk evaluations...")
389
- LOGGER.debug(f"Found {len(experiments)} experiments with validation in progress")
390
- # experiments = [{"id": "123"}]
391
- async with httpx.AsyncClient() as client:
392
- for experiment in experiments:
393
- experiment_request = await client.get(
394
- f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment['id']}",
395
- headers={"x-api-key": config.API_KEY},
396
- )
397
- if not experiment_request.is_success:
461
+ try:
462
+ connection_test_request = await httpx.AsyncClient().post(
463
+ f"{config.SNOWGLOBE_CLIENT_URL}/heartbeat",
464
+ json={"app_id": app_id},
465
+ timeout=30,
466
+ )
467
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
468
+ if shutdown_manager.is_shutdown_requested():
469
+ LOGGER.debug(f"HTTP error during shutdown (expected): {e}")
470
+ return
471
+ else:
472
+ LOGGER.error(
473
+ f"Connection error sending heartbeat for {app_id}: {e}"
474
+ )
475
+ continue
476
+
477
+ if not connection_test_request.is_success:
398
478
  LOGGER.error(
399
- f"Error fetching experiment {experiment['id']}: {experiment_request.text}"
479
+ f"Error sending heartbeat for application {app_id}: {connection_test_request.text}"
400
480
  )
401
481
  continue
402
- experiment = experiment_request.json()
403
- risk_eval_count = 0
404
- for risk_name in risks.keys():
482
+ connection_test_count += 1
483
+
484
+ LOGGER.info(f"Processed {connection_test_count} heartbeats for applications.")
485
+ finally:
486
+ shutdown_manager.unregister_active_job(job_id)
487
+
488
+
489
+ async def poll_for_risk_evaluations():
490
+ """Poll for risk evaluations and process them."""
491
+ # Exit early if shutdown requested
492
+ if await shutdown_manager.is_async_shutdown_requested():
493
+ return
494
+
495
+ job_id = "poll_for_risk_evaluations"
496
+ shutdown_manager.register_active_job(job_id)
497
+
498
+ try:
499
+ experiments = await fetch_experiments()
500
+ except Exception as e:
501
+ # Handle shutdown-related connection errors gracefully
502
+ if shutdown_manager.is_shutdown_requested():
503
+ LOGGER.debug(f"Connection error during shutdown (expected): {e}")
504
+ return
505
+ else:
506
+ LOGGER.error(f"Error fetching experiments: {e}")
507
+ raise
508
+ finally:
509
+ shutdown_manager.unregister_active_job(job_id)
510
+
511
+ try:
512
+ LOGGER.info("Checking for pending risk evaluations...")
513
+ LOGGER.debug(
514
+ f"Found {len(experiments)} experiments with validation in progress"
515
+ )
516
+
517
+ async with httpx.AsyncClient() as client:
518
+ for experiment in experiments:
519
+ # Check for shutdown between experiments
520
+ if await shutdown_manager.is_async_shutdown_requested():
521
+ LOGGER.debug("Shutdown requested, stopping risk evaluations")
522
+ return
523
+
405
524
  try:
406
- if (
407
- risk_name
408
- not in experiment.get("source_data", {})
409
- .get("evaluation_configuration", {})
410
- .keys()
411
- ):
412
- LOGGER.debug(
413
- f"Skipping experiment {experiment['id']} as it does not have risk {risk_name}"
525
+ experiment_request = await client.get(
526
+ f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment['id']}",
527
+ headers={"x-api-key": get_api_key_or_raise()},
528
+ )
529
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
530
+ if shutdown_manager.is_shutdown_requested():
531
+ LOGGER.debug(f"HTTP error during shutdown (expected): {e}")
532
+ return
533
+ else:
534
+ LOGGER.error(
535
+ f"Connection error fetching experiment {experiment['id']}: {e}"
414
536
  )
415
537
  continue
416
- LOGGER.debug(
417
- f"checking for tests for experiment {experiment['id']}"
418
- )
419
- tests_response = await client.get(
420
- f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment['id']}/tests?unevaluated-risk={quote_plus(risk_name)}&include-risk-evaluations=true",
421
- headers={"x-api-key": config.API_KEY},
538
+
539
+ if not experiment_request.is_success:
540
+ LOGGER.error(
541
+ f"Error fetching experiment {experiment['id']}: {experiment_request.text}"
422
542
  )
543
+ continue
544
+ experiment = experiment_request.json()
545
+ risk_eval_count = 0
423
546
 
424
- if not tests_response.is_success:
425
- message = (
426
- tests_response.json().get("message") or tests_response.text
427
- )
428
- raise ValueError(
429
- status_code=tests_response.status_code,
430
- message=message,
431
- )
432
- tests = tests_response.json()
547
+ for risk_name in risks.keys():
548
+ # Check for shutdown before processing each risk
549
+ if await shutdown_manager.is_async_shutdown_requested():
550
+ LOGGER.debug("Shutdown requested, stopping risk processing")
551
+ return
433
552
 
434
- for test in tests:
435
- test_id = test["id"]
553
+ try:
436
554
  if (
437
- test_id not in queued_evaluations
438
- and test.get("response") is not None
555
+ risk_name
556
+ not in experiment.get("source_data", {})
557
+ .get("evaluation_configuration", {})
558
+ .keys()
439
559
  ):
440
- risk_eval_response = await httpx.AsyncClient().post(
441
- f"{config.SNOWGLOBE_CLIENT_URL}/risk-evaluation",
442
- json={"test": test, "risk_name": risk_name},
443
- timeout=30,
560
+ LOGGER.debug(
561
+ f"Skipping experiment {experiment['id']} as it does not have risk {risk_name}"
444
562
  )
445
- # if risk evaltion response is 429 raise and exception and bail on this batch
446
- if (
447
- not risk_eval_response.is_success
448
- and risk_eval_response.status_code == 429
449
- ):
450
- LOGGER.error(
451
- f"Rate limit exceeded for risk evaluation {test['id']}: {risk_eval_response.text}"
563
+ continue
564
+ LOGGER.debug(
565
+ f"checking for tests for experiment {experiment['id']}"
566
+ )
567
+
568
+ try:
569
+ tests_response = await client.get(
570
+ f"{config.CONTROL_PLANE_URL}/api/experiments/{experiment['id']}/tests?unevaluated-risk={quote_plus(risk_name)}&include-risk-evaluations=true",
571
+ headers={"x-api-key": get_api_key_or_raise()},
572
+ )
573
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
574
+ if shutdown_manager.is_shutdown_requested():
575
+ LOGGER.debug(
576
+ f"HTTP error during shutdown (expected): {e}"
452
577
  )
453
- raise ValueError(
454
- status_code=429,
455
- detail=f"Rate limit exceeded for risk evaluation {test['id']}",
578
+ return
579
+ else:
580
+ LOGGER.error(f"Connection error fetching tests: {e}")
581
+ continue
582
+
583
+ if not tests_response.is_success:
584
+ message = (
585
+ tests_response.json().get("message")
586
+ or tests_response.text
587
+ )
588
+ raise ValueError(
589
+ status_code=tests_response.status_code,
590
+ message=message,
591
+ )
592
+ tests = tests_response.json()
593
+
594
+ for test in tests:
595
+ # Check for shutdown before processing each test
596
+ if await shutdown_manager.is_async_shutdown_requested():
597
+ LOGGER.debug(
598
+ "Shutdown requested, stopping test evaluation"
456
599
  )
457
- queued_evaluations[test_id] = True
458
- risk_eval_count += 1
459
- except Exception as e:
460
- LOGGER.error(f"Error fetching tests: {e}")
461
- if risk_eval_count > 0:
462
- LOGGER.info(
463
- f"Processed {risk_eval_count} risk evaluations for experiment {experiment.get('name', 'unknown')} ({experiment['id']})"
464
- )
600
+ return
601
+
602
+ test_id = test["id"]
603
+ if (
604
+ test_id not in queued_evaluations
605
+ and test.get("response") is not None
606
+ ):
607
+ try:
608
+ risk_eval_response = await httpx.AsyncClient().post(
609
+ f"{config.SNOWGLOBE_CLIENT_URL}/risk-evaluation",
610
+ json={"test": test, "risk_name": risk_name},
611
+ timeout=30,
612
+ )
613
+ except (
614
+ httpx.ConnectError,
615
+ httpx.TimeoutException,
616
+ ) as e:
617
+ if shutdown_manager.is_shutdown_requested():
618
+ LOGGER.debug(
619
+ f"HTTP error during shutdown (expected): {e}"
620
+ )
621
+ return
622
+ else:
623
+ LOGGER.error(
624
+ f"Connection error posting risk evaluation: {e}"
625
+ )
626
+ continue
627
+
628
+ # if risk evaltion response is 429 raise and exception and bail on this batch
629
+ if (
630
+ not risk_eval_response.is_success
631
+ and risk_eval_response.status_code == 429
632
+ ):
633
+ LOGGER.error(
634
+ f"Rate limit exceeded for risk evaluation {test['id']}: {risk_eval_response.text}"
635
+ )
636
+ raise ValueError(
637
+ status_code=429,
638
+ detail=f"Rate limit exceeded for risk evaluation {test['id']}",
639
+ )
640
+ queued_evaluations[test_id] = True
641
+ risk_eval_count += 1
642
+ except Exception as e:
643
+ if shutdown_manager.is_shutdown_requested():
644
+ LOGGER.debug(f"Error during shutdown (expected): {e}")
645
+ return
646
+ else:
647
+ LOGGER.error(f"Error fetching tests: {e}")
648
+
649
+ if risk_eval_count > 0:
650
+ LOGGER.info(
651
+ f"Processed {risk_eval_count} risk evaluations for experiment {experiment.get('name', 'unknown')} ({experiment['id']})"
652
+ )
653
+
654
+ except Exception as e:
655
+ # Handle any other errors
656
+ if shutdown_manager.is_shutdown_requested():
657
+ LOGGER.debug(f"Error during shutdown (expected): {e}")
658
+ else:
659
+ LOGGER.error(f"Unexpected error in poll_for_risk_evaluations: {e}")
660
+ raise
465
661
 
466
662
 
467
663
  # Ensure the scheduler shuts down properly on application exit.
@@ -599,7 +795,7 @@ async def lifespan(app: FastAPI):
599
795
  for app_id in apps.keys():
600
796
  try:
601
797
  connection_test_url = (
602
- f"{config.CONTROL_PLANE_URL}/api/failed-code-connection-tests"
798
+ f"{config.CONTROL_PLANE_URL}/api/failed-code-connection-tests"
603
799
  )
604
800
  connection_test_payload = {
605
801
  "appId": app_id,
@@ -7,6 +7,7 @@ import sys
7
7
  import threading
8
8
  import time
9
9
  import webbrowser
10
+ from importlib.metadata import version
10
11
  from typing import Optional, Tuple
11
12
 
12
13
  import typer
@@ -49,6 +50,7 @@ cli_app = typer.Typer(
49
50
  help="❄️ Snowglobe CLI - Connect your applications to Snowglobe experiments",
50
51
  add_completion=False,
51
52
  rich_markup_mode="rich",
53
+ no_args_is_help=False,
52
54
  )
53
55
 
54
56
 
@@ -64,7 +66,7 @@ def setup_global_options(
64
66
  cli_state.json_output = json_output
65
67
 
66
68
 
67
- @cli_app.callback()
69
+ @cli_app.callback(invoke_without_command=True)
68
70
  def main(
69
71
  ctx: typer.Context,
70
72
  verbose: bool = typer.Option(
@@ -72,10 +74,26 @@ def main(
72
74
  ),
73
75
  quiet: bool = typer.Option(False, "--quiet", "-q", help="Minimize output"),
74
76
  json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
77
+ version_flag: bool = typer.Option(
78
+ False, "--version", help="Show version and exit", is_flag=True
79
+ ),
75
80
  ):
76
81
  """
77
82
  ❄️ Snowglobe CLI - Connect your applications to Snowglobe experiments
78
83
  """
84
+ if version_flag:
85
+ try:
86
+ pkg_version = version("snowglobe-connect")
87
+ typer.echo(f"snowglobe-connect {pkg_version}")
88
+ except Exception:
89
+ typer.echo("snowglobe-connect (version unknown)")
90
+ raise typer.Exit()
91
+
92
+ # Show help if no command provided and no version flag
93
+ if not ctx.invoked_subcommand and not version_flag:
94
+ typer.echo(ctx.get_help())
95
+ raise typer.Exit()
96
+
79
97
  setup_global_options(ctx, verbose, quiet, json_output)
80
98
 
81
99
 
@@ -185,16 +203,16 @@ def test(
185
203
 
186
204
  @cli_app.command()
187
205
  def init(
188
- name: Optional[str] = typer.Option(
189
- None, "--name", help="Custom filename for the agent wrapper"
190
- ),
191
- skip_template: bool = typer.Option(
192
- False, "--skip-template", help="Skip creating template file"
206
+ file: Optional[str] = typer.Option(
207
+ None,
208
+ "--file",
209
+ "-f",
210
+ help="Path or filename (within project) for the agent wrapper",
193
211
  ),
194
212
  # option for stateful agent
195
213
  stateful: bool = typer.Option(
196
214
  False, "--stateful", help="Initialize a stateful agent template"
197
- )
215
+ ),
198
216
  ):
199
217
  """
200
218
  Initialize a new Snowglobe agent in the current directory
@@ -255,15 +273,37 @@ def init(
255
273
  pm.ensure_project_structure()
256
274
 
257
275
  # Determine filename
258
- if name:
259
- # User provided custom name
260
- filename = name if name.endswith(".py") else f"{name}.py"
276
+ if file:
277
+ # User provided explicit file path or name
278
+ from pathlib import Path
279
+
280
+ provided_path = Path(os.path.expanduser(file))
281
+
282
+ # Ensure .py suffix
283
+ if provided_path.suffix != ".py":
284
+ provided_path = provided_path.with_suffix(".py")
285
+
286
+ # Normalize to a path within the project root
287
+ if provided_path.is_absolute():
288
+ try:
289
+ relative_path = provided_path.relative_to(pm.project_root)
290
+ except ValueError:
291
+ error("--file must be within the current project directory")
292
+ info(f"Project root: {pm.project_root}")
293
+ raise typer.Exit(1)
294
+ else:
295
+ relative_path = provided_path
296
+
297
+ # Store mapping as a POSIX-style relative path string
298
+ filename = relative_path.as_posix()
261
299
  else:
262
300
  # Generate from app name
263
301
  filename = pm.sanitize_filename(app_name)
264
302
 
265
- # Find available filename
266
- filename = pm.find_available_filename(filename)
303
+ # If filename was auto-generated from app name, find an available one
304
+ if not file:
305
+ filename = pm.find_available_filename(filename)
306
+
267
307
  file_path = pm.project_root / filename
268
308
 
269
309
  success(f"Using filename: {filename}")
@@ -272,34 +312,33 @@ def init(
272
312
  snowglobe_connect_template = stateful_snowglobe_connect_template
273
313
  else:
274
314
  snowglobe_connect_template = stateless_snowglobe_connect_template
315
+
316
+ # Ensure parent directories exist if a subpath was provided
317
+ os.makedirs(file_path.parent, exist_ok=True)
318
+
275
319
  # Create agent wrapper file
276
- if not skip_template:
277
- with spinner("Creating agent wrapper"):
278
- with open(file_path, "w") as f:
279
- f.write(snowglobe_connect_template)
280
- success(f"Created agent wrapper: {filename}")
320
+ with spinner("Creating agent wrapper"):
321
+ with open(file_path, "w") as f:
322
+ f.write(snowglobe_connect_template)
323
+ success(f"Created agent wrapper: {filename}")
281
324
 
282
- # Add to mapping
283
- pm.add_agent_mapping(filename, app_id, app_name)
284
- success("Added mapping to .snowglobe/agents.json")
325
+ # Add to mapping
326
+ pm.add_agent_mapping(filename, app_id, app_name)
327
+ success("Added mapping to .snowglobe/agents.json")
285
328
 
286
- console.print("\n[dim]Project structure:[/dim]")
287
- console.print("[dim] .snowglobe/agents.json\t- UUID mappings[/dim]")
288
- console.print(f"[dim] {filename}\t- Your agent wrapper[/dim]")
329
+ console.print("\n[dim]Project structure:[/dim]")
330
+ console.print("[dim] .snowglobe/agents.json\t- UUID mappings[/dim]")
331
+ console.print(f"[dim] {filename}\t- Your agent wrapper[/dim]")
289
332
 
290
- console.print()
291
- info("Next steps:")
292
- console.print("1. Edit the process_scenario function in your agent file:")
293
- console.print(f" [bold cyan]{filename}[/bold cyan]")
294
- console.print("2. Implement your application logic")
295
- console.print("3. Test your agent:")
296
- console.print(" [bold green]snowglobe-connect test[/bold green]")
297
- console.print("4. Start the client:")
298
- console.print(" [bold green]snowglobe-connect start[/bold green]")
299
- else:
300
- info(f"Skipped template creation. You'll need to create {filename} manually")
301
- # Still add the mapping even if we skip template
302
- pm.add_agent_mapping(filename, app_id, app_name)
333
+ console.print()
334
+ info("Next steps:")
335
+ console.print("1. Edit the process_scenario function in your agent file:")
336
+ console.print(f" [bold cyan]{filename}[/bold cyan]")
337
+ console.print("2. Implement your application logic")
338
+ console.print("3. Test your agent:")
339
+ console.print(" [bold green]snowglobe-connect test[/bold green]")
340
+ console.print("4. Start the client:")
341
+ console.print(" [bold green]snowglobe-connect start[/bold green]")
303
342
 
304
343
  console.print()
305
344
  success(f"Agent '{app_name}' initialized successfully! 🎉")
@@ -494,6 +533,7 @@ async def completion_fn(request: CompletionRequest) -> CompletionFunctionOutputs
494
533
  return CompletionFunctionOutputs(response=response_data.messages[0]["content"])
495
534
  """
496
535
 
536
+
497
537
  def _save_api_key_to_rc(api_key: str, rc_path: str) -> None:
498
538
  """Save API key to config file"""
499
539
  # Ensure directory exists
@@ -706,15 +746,48 @@ def start(
706
746
  "[bold green]🚀 Agent server is live! Processing scenarios...[/bold green]\n"
707
747
  )
708
748
 
709
- # Handle Ctrl+C gracefully
710
- def signal_handler(sig, frame):
711
- graceful_shutdown()
712
-
713
- signal.signal(signal.SIGINT, signal_handler)
749
+ # Handle Ctrl+C gracefully (legacy handler delegates to smart shutdown internally)
750
+ signal.signal(signal.SIGINT, graceful_shutdown)
714
751
 
715
752
  if not verbose:
716
753
  console.print("[dim]Press Ctrl+C to stop[/dim]\n")
717
754
 
755
+ # Show helpful links to open the application(s) in the Snowglobe UI
756
+ try:
757
+ pm = get_project_manager()
758
+ agents = pm.list_agents()
759
+
760
+ if agents:
761
+ if len(agents) == 1:
762
+ _, agent_info = agents[0]
763
+ app_id = agent_info.get("uuid")
764
+ app_name = agent_info.get("name", "Application")
765
+ if app_id:
766
+ app_url = f"{UI_URL}/applications/{app_id}"
767
+ console.print(
768
+ "🔗 Agent connected! Go to your Snowglobe agent and kick off a simulation:"
769
+ )
770
+ console.print(f" [link={app_url}]{app_name}[/link]\n")
771
+ else:
772
+ console.print(
773
+ "🔗 Agents connected! Go to your Snowglobe agents and kick off simulations:"
774
+ )
775
+ for _, agent_info in agents[:5]:
776
+ app_id = agent_info.get("uuid")
777
+ app_name = agent_info.get("name", "Application")
778
+ if app_id:
779
+ app_url = f"{UI_URL}/applications/{app_id}"
780
+ console.print(
781
+ f" - {app_name}: [link={app_url}]{app_url}[/link]"
782
+ )
783
+ if len(agents) > 5:
784
+ console.print(" (and more...)\n")
785
+ else:
786
+ console.print()
787
+ except Exception:
788
+ # Do not block startup if we cannot load agents mapping
789
+ pass
790
+
718
791
  # Import start_client here to avoid config initialization at module import time
719
792
  from .app import start_client
720
793
 
@@ -1,7 +1,9 @@
1
+ import asyncio
1
2
  import contextlib
2
3
  import math
3
4
  import os
4
5
  import sys
6
+ import threading
5
7
  import time
6
8
  from typing import Any, Dict, List, Optional, Tuple
7
9
 
@@ -25,7 +27,67 @@ class CliState:
25
27
  self.json_output = False
26
28
 
27
29
 
30
+ class ShutdownManager:
31
+ """Coordinate graceful shutdown between signal handler and background jobs"""
32
+
33
+ def __init__(self):
34
+ self.shutdown_event = threading.Event() # For sync code
35
+ self.async_shutdown_event = None # Created when event loop available
36
+ self.force_shutdown = False
37
+ self.shutdown_initiated = False
38
+ self.active_jobs = set() # Track active background jobs
39
+ self.active_jobs_lock = threading.Lock()
40
+
41
+ def register_active_job(self, job_id: str):
42
+ """Register a job as actively running"""
43
+ with self.active_jobs_lock:
44
+ self.active_jobs.add(job_id)
45
+
46
+ def unregister_active_job(self, job_id: str):
47
+ """Unregister a job when it completes"""
48
+ with self.active_jobs_lock:
49
+ self.active_jobs.discard(job_id)
50
+
51
+ def has_active_jobs(self) -> bool:
52
+ """Check if there are any active jobs running"""
53
+ with self.active_jobs_lock:
54
+ return len(self.active_jobs) > 0
55
+
56
+ def initiate_shutdown(self) -> bool:
57
+ """Initiate shutdown. Returns True if this is a force shutdown (second Ctrl+C)"""
58
+ if self.shutdown_initiated:
59
+ # Second Ctrl+C - force shutdown
60
+ self.force_shutdown = True
61
+ return True
62
+ else:
63
+ # First Ctrl+C - start graceful shutdown
64
+ self.shutdown_initiated = True
65
+ self.shutdown_event.set()
66
+
67
+ # Set async event if event loop exists
68
+ try:
69
+ loop = asyncio.get_running_loop()
70
+ if self.async_shutdown_event is None:
71
+ self.async_shutdown_event = asyncio.Event()
72
+ loop.call_soon_threadsafe(self.async_shutdown_event.set)
73
+ except RuntimeError:
74
+ pass
75
+
76
+ return False
77
+
78
+ def is_shutdown_requested(self) -> bool:
79
+ """Check if shutdown has been requested"""
80
+ return self.shutdown_event.is_set()
81
+
82
+ async def is_async_shutdown_requested(self) -> bool:
83
+ """Async version of shutdown check"""
84
+ if self.async_shutdown_event is None:
85
+ return self.shutdown_event.is_set()
86
+ return self.async_shutdown_event.is_set() or self.shutdown_event.is_set()
87
+
88
+
28
89
  cli_state = CliState()
90
+ shutdown_manager = ShutdownManager()
29
91
 
30
92
 
31
93
  def get_api_key() -> Optional[str]:
@@ -104,13 +166,8 @@ def docs_link(message: str, url: str = "https://www.snowglobe.so/docs") -> None:
104
166
  console.print(f"📖 {message}: {url}", style="cyan")
105
167
 
106
168
 
107
- def graceful_shutdown():
108
- """Handle graceful shutdown with session summary"""
109
- console.print("\n")
110
- warning("🛑 Shutting down gracefully...")
111
- success("Completing current scenarios")
112
- success("Connection closed")
113
-
169
+ def show_session_summary():
170
+ """Display session summary statistics"""
114
171
  stats = get_shutdown_stats()
115
172
 
116
173
  if stats and stats["total_messages"] > 0:
@@ -130,6 +187,93 @@ def graceful_shutdown():
130
187
  else:
131
188
  success("No scenarios processed during this session")
132
189
 
190
+
191
+ def begin_graceful_shutdown():
192
+ """Coordinate graceful shutdown - runs in background thread"""
193
+ TIMEOUT_SECONDS = 8
194
+
195
+ console.print("\n")
196
+ warning("🛑 Shutting down gracefully...")
197
+ success("Completing current scenarios")
198
+
199
+ # Wait for active jobs to finish (with timeout)
200
+ start_time = time.time()
201
+ while time.time() - start_time < TIMEOUT_SECONDS:
202
+ if not shutdown_manager.has_active_jobs():
203
+ break
204
+ time.sleep(0.1)
205
+
206
+ # Check if user pressed Ctrl+C again (force quit)
207
+ if shutdown_manager.force_shutdown:
208
+ return # Signal handler will handle force quit
209
+
210
+ # Jobs finished or timeout reached
211
+ elapsed = time.time() - start_time
212
+ if elapsed >= TIMEOUT_SECONDS:
213
+ warning(
214
+ f"Shutdown timeout reached after {TIMEOUT_SECONDS}s, completing shutdown..."
215
+ )
216
+
217
+ success("Connection closed")
218
+
219
+ # Show session summary
220
+ show_session_summary()
221
+
222
+ console.print()
223
+ success("Agent disconnected successfully")
224
+
225
+ # Exit cleanly
226
+ sys.exit(0)
227
+
228
+
229
+ def smart_signal_handler(sig, frame):
230
+ """Smart two-stage signal handler with immediate exit when possible"""
231
+ force_quit = shutdown_manager.initiate_shutdown()
232
+
233
+ if force_quit:
234
+ # Second Ctrl+C - force quit immediately
235
+ console.print("\n[bold red]🚨 Force shutdown![/bold red]")
236
+ sys.exit(1)
237
+ else:
238
+ # First Ctrl+C - check if we have active work
239
+ if shutdown_manager.has_active_jobs():
240
+ # Have active jobs - start graceful shutdown with user feedback
241
+ console.print(
242
+ "\n[bold yellow]🛑 Graceful shutdown initiated...[/bold yellow]"
243
+ )
244
+ console.print("[dim]Press Ctrl+C again to force quit[/dim]")
245
+
246
+ # Start graceful shutdown in background thread
247
+ threading.Thread(target=begin_graceful_shutdown, daemon=True).start()
248
+ else:
249
+ # No active jobs - shutdown immediately
250
+ console.print("\n[bold blue]🛑 Shutting down...[/bold blue]")
251
+
252
+ # Show session summary and exit
253
+ show_session_summary()
254
+ console.print()
255
+ success("Agent disconnected successfully")
256
+ sys.exit(0)
257
+
258
+
259
+ def graceful_shutdown(_sig_num, _frame):
260
+ """Handle graceful shutdown with session summary (idempotent, always exits 0).
261
+
262
+ This legacy entrypoint avoids toggling internal shutdown state so repeated
263
+ calls behave consistently in tests and scripts.
264
+ """
265
+ # If there are active jobs, allow them a moment to finish similar to begin_graceful_shutdown,
266
+ # but without changing the shutdown state in a way that triggers force-exit on reentry.
267
+ if shutdown_manager.has_active_jobs():
268
+ console.print("\n[bold yellow]🛑 Graceful shutdown initiated...[/bold yellow]")
269
+ console.print("[dim]Press Ctrl+C again to force quit[/dim]")
270
+
271
+ if cli_state.verbose:
272
+ console.print(f"signal received: {_sig_num}, frame: {_frame}")
273
+
274
+ # Show session summary and exit cleanly
275
+ console.print("\n[bold blue]🛑 Shutting down...[/bold blue]")
276
+ show_session_summary()
133
277
  console.print()
134
278
  success("Agent disconnected successfully")
135
279
  sys.exit(0)
@@ -174,12 +318,17 @@ def select_stateful_interactive(
174
318
  if cli_state.json_output:
175
319
  # For JSON mode, just return the default stateful value
176
320
  return stateful
177
- info("Some stateful agents such as ones that maintain communication over a websocket or convo specific completion endpoint require stateful integration.")
178
- info("If your agent takes messages and completions on a single completion endpoint regardless of context, you can answer no to the following question.")
321
+ info(
322
+ "Some stateful agents such as ones that maintain communication over a websocket or convo specific completion endpoint require stateful integration."
323
+ )
324
+ info(
325
+ "If your agent takes messages and completions on a single completion endpoint regardless of context, you can answer no to the following question."
326
+ )
179
327
  if Confirm.ask("Would you like to create a new application?"):
180
328
  return True
181
329
  return False
182
330
 
331
+
183
332
  def select_application_interactive(
184
333
  applications: List[Dict[str, Any]],
185
334
  ) -> Optional[Dict[str, Any]]:
@@ -42,7 +42,7 @@ def migrate_rc_file_if_needed():
42
42
 
43
43
 
44
44
  class Config:
45
- def __init__(self):
45
+ def __init__(self, require_api_key=True):
46
46
  # Migrate legacy .snowgloberc if needed
47
47
  migrate_rc_file_if_needed()
48
48
 
@@ -52,8 +52,16 @@ class Config:
52
52
  self.CONCURRENT_HEARTBEATS_PER_INTERVAL = 120
53
53
  self.CONCURRENT_HEARTBEATS_INTERVAL_SECONDS = 60
54
54
 
55
- # Initialize API key - just raise exception if missing, let caller handle it
56
- self.API_KEY = self.get_api_key()
55
+ # Initialize API key - optionally raise exception if missing
56
+ if require_api_key:
57
+ self.API_KEY = self.get_api_key()
58
+ else:
59
+ # For auth command, don't require API key during initialization
60
+ try:
61
+ self.API_KEY = self.get_api_key()
62
+ except ValueError:
63
+ self.API_KEY = None
64
+
57
65
  self.APPLICATION_ID = self.get_application_id()
58
66
  self.CONTROL_PLANE_URL = self.get_control_plane_url()
59
67
  self.SNOWGLOBE_CLIENT_URL = self.get_snowglobe_client_url()
@@ -210,4 +218,28 @@ class Config:
210
218
  return application_id
211
219
 
212
220
 
213
- config = Config()
221
+ config = Config(require_api_key=False)
222
+
223
+
224
+ def get_api_key_or_raise() -> str:
225
+ """Get API key, raising ValueError if not found - for commands that require it"""
226
+ api_key = config.API_KEY
227
+ if not api_key:
228
+ # Try to get it fresh in case it was just set
229
+ try:
230
+ api_key = config.get_api_key()
231
+ config.API_KEY = api_key # Cache it
232
+ except ValueError:
233
+ pass
234
+
235
+ if not api_key:
236
+ raise ValueError(
237
+ "API key is required either passed as an argument or set as an environment variable. \n"
238
+ "You can set the API key as an environment variable by running: \n"
239
+ "export SNOWGLOBE_API_KEY=<your_api_key> \n"
240
+ "or \n"
241
+ "export GUARDRAILS_API_KEY=<your_api_key> \n"
242
+ "Or you can create a .snowglobe/config.rc file in the current directory with the line: \n"
243
+ "SNOWGLOBE_API_KEY=<your_api_key> \n"
244
+ )
245
+ return api_key
@@ -2,7 +2,7 @@ from logging import getLogger
2
2
 
3
3
  import httpx
4
4
 
5
- from .config import config
5
+ from .config import config, get_api_key_or_raise
6
6
  from .models import SnowglobeData, SnowglobeMessage
7
7
 
8
8
  LOGGER = getLogger(__name__)
@@ -21,7 +21,7 @@ async def fetch_experiments(app_id: str = None) -> list[dict]:
21
21
  experiments_url += f"&appId={config.APPLICATION_ID}"
22
22
  experiments_response = await client.get(
23
23
  experiments_url,
24
- headers={"x-api-key": config.API_KEY},
24
+ headers={"x-api-key": get_api_key_or_raise()},
25
25
  )
26
26
 
27
27
  if not experiments_response.status_code == 200:
@@ -78,7 +78,7 @@ async def fetch_messages(*, test) -> list[SnowglobeMessage]:
78
78
  # get parent test
79
79
  parent_test_response = await client.get(
80
80
  f"{config.CONTROL_PLANE_URL}/api/experiments/{test['experiment_id']}/tests/{parent_id}",
81
- headers={"x-api-key": config.API_KEY},
81
+ headers={"x-api-key": get_api_key_or_raise()},
82
82
  )
83
83
 
84
84
  if not parent_test_response.status_code == 200:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowglobe
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: client server for usage with snowglobe experiments
5
5
  Author-email: Guardrails AI <contact@guardrailsai.com>
6
6
  License: MIT License
@@ -0,0 +1,15 @@
1
+ snowglobe/client/__init__.py,sha256=kzp9wPUUYBXqDSKZbfmD4vrAQvrWSW5HOvtpFlEJWfs,353
2
+ snowglobe/client/src/app.py,sha256=MRV0mzrH4iqkCk1H5pCQZgMj4jkIRNiUx0NkZpzK6tc,39035
3
+ snowglobe/client/src/cli.py,sha256=E3KZWEHiF38BejVIVgSRMINxKRKOS7p_aemhX4xt90E,27846
4
+ snowglobe/client/src/cli_utils.py,sha256=dQZpB5a9mCl7uvsRV0k7VCU0S_0t76WVwpBWncdAq1A,17222
5
+ snowglobe/client/src/config.py,sha256=YRx_AQEZoHaAqk6guTxynIEGV_iJ3wNNGtMmaKsYMbc,10488
6
+ snowglobe/client/src/models.py,sha256=BX310WrDN9Fd8v68me3XGL_ic1ulvjCrZyIT2ND1eUo,866
7
+ snowglobe/client/src/project_manager.py,sha256=Ze-qs4dQI2kIV-PmtWZ1b67hMUfsnsMHus90aT8HOow,9970
8
+ snowglobe/client/src/stats.py,sha256=IdaXroOZBmvLVa_p9pDE6hsxsc7-fBEDnLf8O6Ch0GA,1596
9
+ snowglobe/client/src/utils.py,sha256=hHOht0hc8fv3OuPTz2Tqs639CzSAF34JTZs5ifKV6YI,3708
10
+ snowglobe-0.4.3.dist-info/licenses/LICENSE,sha256=S90V6iFU5ZeSg44JQYS1To3pa7ZEobrHc_t483qSKSI,1070
11
+ snowglobe-0.4.3.dist-info/METADATA,sha256=_cIRzXjAteiWBDJPZToBCpJQgCbvSh0DC1gcC9EFgRw,4483
12
+ snowglobe-0.4.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
13
+ snowglobe-0.4.3.dist-info/entry_points.txt,sha256=mqx4mTwFPHttjctE2ceYTYWCCIG30Ji2C89aaCYgHcM,71
14
+ snowglobe-0.4.3.dist-info/top_level.txt,sha256=PoyYihnCBjRyjeIT19yBcE47JTe7i1OwRXvJ4d5EohM,10
15
+ snowglobe-0.4.3.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- snowglobe/client/__init__.py,sha256=kzp9wPUUYBXqDSKZbfmD4vrAQvrWSW5HOvtpFlEJWfs,353
2
- snowglobe/client/src/app.py,sha256=PZCTwF3n_2Bi825dOflGeN8GSjuf5ebKLSxV7S90TxA,30289
3
- snowglobe/client/src/cli.py,sha256=f8AKB0mAcMK-63aZczs4LxnB33RqoxKPEnv2w1lgu1c,25159
4
- snowglobe/client/src/cli_utils.py,sha256=PRWpbrALfTc3fpAXl32pfyZWkLYi_3N03csTQR1wnjc,11911
5
- snowglobe/client/src/config.py,sha256=HAJD7RkO6IB_mFkmGLpy9Ma3sB0KpZE7zmGsa9K__iE,9284
6
- snowglobe/client/src/models.py,sha256=BX310WrDN9Fd8v68me3XGL_ic1ulvjCrZyIT2ND1eUo,866
7
- snowglobe/client/src/project_manager.py,sha256=Ze-qs4dQI2kIV-PmtWZ1b67hMUfsnsMHus90aT8HOow,9970
8
- snowglobe/client/src/stats.py,sha256=IdaXroOZBmvLVa_p9pDE6hsxsc7-fBEDnLf8O6Ch0GA,1596
9
- snowglobe/client/src/utils.py,sha256=U0nQjTjO28OghG7lV6BuI_2MkcsOhu31Nz00nCJU4sM,3670
10
- snowglobe-0.4.1.dist-info/licenses/LICENSE,sha256=S90V6iFU5ZeSg44JQYS1To3pa7ZEobrHc_t483qSKSI,1070
11
- snowglobe-0.4.1.dist-info/METADATA,sha256=vwd2zyDpsWJTiBIXajJm4--PRd4-vQnDpmdosYpuqvg,4483
12
- snowglobe-0.4.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
13
- snowglobe-0.4.1.dist-info/entry_points.txt,sha256=mqx4mTwFPHttjctE2ceYTYWCCIG30Ji2C89aaCYgHcM,71
14
- snowglobe-0.4.1.dist-info/top_level.txt,sha256=PoyYihnCBjRyjeIT19yBcE47JTe7i1OwRXvJ4d5EohM,10
15
- snowglobe-0.4.1.dist-info/RECORD,,