openrunner-sdk 2.17.4__tar.gz → 2.18.0__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.
Files changed (120) hide show
  1. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/PKG-INFO +1 -1
  2. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/__init__.py +1 -1
  3. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/cli.py +5 -4
  4. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/install_commands.py +316 -0
  5. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/pyproject.toml +1 -1
  6. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/conftest.py +34 -0
  7. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_cli.py +7 -7
  8. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_integration_fastai.py +3 -4
  9. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_integration_huggingface.py +3 -2
  10. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_integration_keras.py +4 -6
  11. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_integration_langchain.py +4 -6
  12. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_integration_lightning.py +4 -4
  13. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_integration_pytorch.py +3 -4
  14. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_integration_sklearn.py +3 -3
  15. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_integration_xgboost.py +3 -3
  16. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/.gitignore +0 -0
  17. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/=6.0 +0 -0
  18. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/=8.1 +0 -0
  19. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/README.md +0 -0
  20. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/CLAUDE.md +0 -0
  21. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/api_client.py +0 -0
  22. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/artifact.py +0 -0
  23. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/buffer.py +0 -0
  24. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/cache.py +0 -0
  25. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/config.py +0 -0
  26. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/cost.py +0 -0
  27. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/dataset.py +0 -0
  28. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/environment.py +0 -0
  29. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/evaluation.py +0 -0
  30. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/feedback.py +0 -0
  31. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/git_info.py +0 -0
  32. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/guardrails.py +0 -0
  33. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/__init__.py +0 -0
  34. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/accelerate.py +0 -0
  35. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/anthropic_tracer.py +0 -0
  36. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/catboost.py +0 -0
  37. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/diffusers.py +0 -0
  38. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/fastai.py +0 -0
  39. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/forced_alignment.py +0 -0
  40. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/gladia.py +0 -0
  41. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/gymnasium.py +0 -0
  42. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/huggingface.py +0 -0
  43. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/hydra.py +0 -0
  44. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/ignite.py +0 -0
  45. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/jax.py +0 -0
  46. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/keras.py +0 -0
  47. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/langchain.py +0 -0
  48. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/lightgbm.py +0 -0
  49. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/lightning.py +0 -0
  50. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/llamaindex.py +0 -0
  51. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/openai_finetune.py +0 -0
  52. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/openai_tracer.py +0 -0
  53. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/optuna.py +0 -0
  54. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/pytorch.py +0 -0
  55. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/sb3.py +0 -0
  56. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/sklearn.py +0 -0
  57. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/tensorflow.py +0 -0
  58. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/trl.py +0 -0
  59. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/tts.py +0 -0
  60. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/ultralytics.py +0 -0
  61. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/voice_agent.py +0 -0
  62. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/whisper.py +0 -0
  63. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/integration/xgboost.py +0 -0
  64. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/launch.py +0 -0
  65. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/mcp_server.py +0 -0
  66. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/media.py +0 -0
  67. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/migrate.py +0 -0
  68. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/model.py +0 -0
  69. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/offline.py +0 -0
  70. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/pii.py +0 -0
  71. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/plot.py +0 -0
  72. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/prompt.py +0 -0
  73. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/query_api.py +0 -0
  74. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/redact.py +0 -0
  75. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/run.py +0 -0
  76. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/scorers.py +0 -0
  77. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/sender.py +0 -0
  78. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/session.py +0 -0
  79. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/settings.py +0 -0
  80. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/summary.py +0 -0
  81. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/sweep.py +0 -0
  82. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/system_metrics.py +0 -0
  83. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/tensorboard.py +0 -0
  84. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/trace.py +0 -0
  85. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/transcript_formatter.py +0 -0
  86. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/wal.py +0 -0
  87. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/wandb_compat/__init__.py +0 -0
  88. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/wandb_compat/_shim.py +0 -0
  89. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/openrunner/wer.py +0 -0
  90. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/__init__.py +0 -0
  91. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_alert.py +0 -0
  92. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_aliases.py +0 -0
  93. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_api_client.py +0 -0
  94. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_artifact.py +0 -0
  95. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_buffer.py +0 -0
  96. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_cache.py +0 -0
  97. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_class_scorers.py +0 -0
  98. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_config.py +0 -0
  99. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_evaluation.py +0 -0
  100. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_finish.py +0 -0
  101. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_git_info.py +0 -0
  102. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_init.py +0 -0
  103. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_launch.py +0 -0
  104. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_log.py +0 -0
  105. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_log_code.py +0 -0
  106. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_media.py +0 -0
  107. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_migrate.py +0 -0
  108. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_offline.py +0 -0
  109. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_offline_sync.py +0 -0
  110. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_pii.py +0 -0
  111. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_plot.py +0 -0
  112. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_query_api.py +0 -0
  113. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_resume.py +0 -0
  114. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_sdk_features.py +0 -0
  115. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_sender.py +0 -0
  116. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_summary.py +0 -0
  117. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_sweep.py +0 -0
  118. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_system_metrics.py +0 -0
  119. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_trace.py +0 -0
  120. {openrunner_sdk-2.17.4 → openrunner_sdk-2.18.0}/tests/test_wandb_compat.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openrunner-sdk
3
- Version: 2.17.4
3
+ Version: 2.18.0
4
4
  Summary: OpenRunner SDK - W&B-compatible ML experiment tracking client
5
5
  Project-URL: Homepage, https://github.com/jqueguiner/openrunner
6
6
  Project-URL: Repository, https://github.com/jqueguiner/openrunner
@@ -120,7 +120,7 @@ launch.from_run = _launch_from_run # type: ignore[attr-defined]
120
120
  # openrunner.trace.patch_openai() syntax
121
121
  trace.patch_openai = _patch_openai # type: ignore[attr-defined]
122
122
 
123
- __version__ = "2.17.4"
123
+ __version__ = "2.18.0"
124
124
 
125
125
  logger = logging.getLogger("openrunner")
126
126
 
@@ -124,11 +124,12 @@ def login(host: str | None, expiry: int, manual: bool) -> None:
124
124
  base_url = host
125
125
  elif settings.get("base_url"):
126
126
  base_url = settings["base_url"]
127
- elif sys.stdin.isatty():
128
- base_url = click.prompt("Server URL")
129
127
  else:
130
- click.echo("Error: No server URL configured. Run with --host or set base_url in ~/.openrunner/settings.json")
131
- return
128
+ try:
129
+ base_url = click.prompt("Server URL", default="http://localhost:8000")
130
+ except (EOFError, click.Abort):
131
+ click.echo("Error: No server URL configured. Run with --host or set base_url in ~/.openrunner/settings.json")
132
+ return
132
133
 
133
134
  if manual:
134
135
  # Legacy manual flow
@@ -468,6 +468,316 @@ Report version and installed commands to user. Remind them to enable the MCP plu
468
468
  """
469
469
 
470
470
 
471
+ PLOT_SESSION_RUN_CMD = """---
472
+ name: {prefix}plot-session-run
473
+ description: Plot training metrics from runs in current project (ASCII chart + table, PNG on desktop)
474
+ argument-hint: "[run_id] [--metric key] [--all] [--watch [secs]]"
475
+ allowed-tools:
476
+ - Bash
477
+ - Read
478
+ ---
479
+
480
+ Plot training run metrics from the current OpenRunner project as ASCII sparklines and summary table.
481
+ If on a desktop with display, also generates a PNG chart.
482
+
483
+ Use `--watch` (or `-w`) for live refresh every 2 minutes (override: `--watch 30` for 30s).
484
+ Auto-stops when no runs are in progress.
485
+
486
+ ## Process
487
+
488
+ ### Step 1: Fetch runs and metrics
489
+
490
+ ```bash
491
+ python3 -c "
492
+ import sys, os, json
493
+ from pathlib import Path
494
+ for p in ([os.path.join(d, 'site-packages') for d in __import__('glob').glob(os.path.expanduser('~/.local/lib/python3.*/')) + __import__('glob').glob(os.path.expanduser('~/Library/Python/3.*/lib/python/'))]):
495
+ if os.path.isdir(p): sys.path.insert(0, p)
496
+
497
+ from openrunner.session import get_session_config
498
+ from openrunner.api_client import APIClient
499
+
500
+ config = get_session_config()
501
+ if not config.get('api_key'):
502
+ print('NOT_CONFIGURED')
503
+ sys.exit(1)
504
+
505
+ client = APIClient(base_url=config['base_url'], api_key=config['api_key'])
506
+
507
+ # Resolve project
508
+ project_name = config.get('project', '')
509
+ project_id = None
510
+ for p in client.list_projects():
511
+ if f'{{p.get(\"org_name\",\"\")}}/{{p.get(\"name\",\"\")}}' == project_name or p.get('name') == project_name:
512
+ project_id = p.get('id')
513
+ break
514
+
515
+ if not project_id:
516
+ print(f'PROJECT_NOT_FOUND={{project_name}}')
517
+ client.close()
518
+ sys.exit(1)
519
+
520
+ args = '$ARGUMENTS'.strip()
521
+ run_id = None
522
+ metric_keys = ['train/loss', 'learning_rate']
523
+ show_all = False
524
+ watch_mode = False
525
+ watch_interval = 120
526
+
527
+ # Parse args
528
+ parts = args.split() if args else []
529
+ i = 0
530
+ while i < len(parts):
531
+ if parts[i] == '--metric' and i+1 < len(parts):
532
+ metric_keys = [parts[i+1]]; i += 2
533
+ elif parts[i] == '--all':
534
+ show_all = True; i += 1
535
+ elif parts[i] == '--watch' or parts[i] == '-w':
536
+ watch_mode = True
537
+ if i+1 < len(parts) and parts[i+1].isdigit():
538
+ watch_interval = int(parts[i+1]); i += 2
539
+ else:
540
+ i += 1
541
+ elif not parts[i].startswith('--'):
542
+ run_id = parts[i]; i += 1
543
+ else:
544
+ i += 1
545
+
546
+ import time as _time
547
+
548
+ iteration = 0
549
+ while True:
550
+ iteration += 1
551
+
552
+ # Clear screen on refresh (not first run)
553
+ if iteration > 1:
554
+ print('\\033[2J\\033[H', end='')
555
+
556
+ # Get runs
557
+ if run_id:
558
+ runs = [{{'id': run_id}}]
559
+ else:
560
+ resp = client._request('get', f'/projects/{{project_id}}/runs', params={{'limit': 10, 'order': 'desc'}})
561
+ runs_data = resp.json()
562
+ runs = runs_data.get('runs', runs_data) if isinstance(runs_data, dict) else runs_data
563
+ if not runs:
564
+ print('NO_RUNS')
565
+ client.close()
566
+ sys.exit(1)
567
+
568
+ # Collect metrics
569
+ bars = '▁▂▃▄▅▆▇█'
570
+ results = []
571
+
572
+ for run in runs[:10]:
573
+ rid = run.get('id', run) if isinstance(run, dict) else run
574
+ try:
575
+ keys_param = ','.join(metric_keys)
576
+ resp = client._request('get', f'/runs/{{rid}}/metrics', params={{'keys': keys_param}})
577
+ data = resp.json()
578
+ metrics = data.get('metrics', {{}})
579
+ if not metrics and not show_all:
580
+ continue
581
+
582
+ # If none of the requested metrics found, use whatever is available
583
+ found_any = any(k in metrics for k in metric_keys)
584
+ if not found_any and metrics and not show_all:
585
+ metric_keys = list(metrics.keys())[:2]
586
+
587
+ for key, points in metrics.items():
588
+ if not show_all and key not in metric_keys:
589
+ continue
590
+ if not points:
591
+ continue
592
+
593
+ values = [pt.get('value', pt) if isinstance(pt, dict) else pt for pt in points]
594
+ steps = [pt.get('step', i) if isinstance(pt, dict) else i for i, pt in enumerate(points)]
595
+ values = [v for v in values if isinstance(v, (int, float))]
596
+ if not values:
597
+ continue
598
+
599
+ min_v = min(values)
600
+ max_v = max(values)
601
+ range_v = max_v - min_v or 1
602
+ width = 60
603
+
604
+ # Sample if too many
605
+ if len(values) > width:
606
+ step_size = len(values) / width
607
+ sampled = [values[int(j * step_size)] for j in range(width)]
608
+ else:
609
+ sampled = values
610
+
611
+ sparkline = ''
612
+ for v in sampled:
613
+ idx = int((v - min_v) / range_v * (len(bars) - 1))
614
+ sparkline += bars[idx]
615
+
616
+ run_name = run.get('display_name') or run.get('name') or rid[:8] if isinstance(run, dict) else rid[:8]
617
+ run_state = run.get('state', 'unknown') if isinstance(run, dict) else 'unknown'
618
+ direction = 'rising' if values[-1] > values[0] else 'falling'
619
+ pct = ((values[-1] - values[0]) / (values[0] or 1)) * 100
620
+
621
+ results.append({{
622
+ 'run_id': rid,
623
+ 'run_name': run_name,
624
+ 'metric': key,
625
+ 'sparkline': sparkline,
626
+ 'min': min_v,
627
+ 'max': max_v,
628
+ 'latest': values[-1],
629
+ 'steps': len(values),
630
+ 'step_range': f'{{steps[0]}}->{{steps[-1]}}',
631
+ 'direction': direction,
632
+ 'pct': pct,
633
+ 'state': run_state,
634
+ }})
635
+ except Exception as e:
636
+ results.append({{'run_id': rid, 'error': str(e)}})
637
+
638
+ if not results:
639
+ print('NO_METRICS')
640
+ client.close()
641
+ sys.exit(1)
642
+
643
+ # ASCII output
644
+ print('=' * 72)
645
+ print(f' OPENRUNNER METRICS — {{project_name}}')
646
+ print('=' * 72)
647
+
648
+ for r in results:
649
+ if 'error' in r:
650
+ print(f' {{r[\"run_id\"][:8]}} — error: {{r[\"error\"]}}')
651
+ continue
652
+ print()
653
+ print(f' Run: {{r[\"run_name\"]}} ({{r[\"run_id\"][:8]}})')
654
+ print(f' Metric: {{r[\"metric\"]}} | Steps: {{r[\"step_range\"]}} ({{r[\"steps\"]}} pts)')
655
+ print(' Range: %.6f -> %.6f | Latest: %.6f' % (r['min'], r['max'], r['latest']))
656
+ print(' ' + r['sparkline'])
657
+ arrow = '↗' if r['pct'] > 0 else '↘'
658
+ print(' %s %s (%+.1f%%)' % (arrow, r['direction'], r['pct']))
659
+
660
+ # Summary table
661
+ print()
662
+ print('-' * 72)
663
+ print(' %-20s %-15s %10s %10s %10s %8s' % ('Run', 'Metric', 'Latest', 'Min', 'Max', 'Trend'))
664
+ print('-' * 72)
665
+ for r in results:
666
+ if 'error' in r:
667
+ continue
668
+ name = (r['run_name'] or '')[:20]
669
+ met = r['metric'][:15]
670
+ arrow = '↗' if r['pct'] > 0 else '↘'
671
+ trend = '%s%+.0f%%' % (arrow, r['pct'])
672
+ print(' %-20s %-15s %10.4f %10.4f %10.4f %8s' % (name, met, r['latest'], r['min'], r['max'], trend))
673
+ print('-' * 72)
674
+
675
+ # Live runs footer — show link + loss indicator for in-progress runs
676
+ base_url = config.get('base_url', '').rstrip('/')
677
+ live_runs = [r for r in results if r.get('state') in ('running', 'in_progress') and 'error' not in r]
678
+ if live_runs:
679
+ print()
680
+ print('=' * 72)
681
+ print(' LIVE RUNS')
682
+ print('=' * 72)
683
+ seen_ids = set()
684
+ for r in live_runs:
685
+ if r['run_id'] in seen_ids:
686
+ continue
687
+ seen_ids.add(r['run_id'])
688
+ # Loss indicator
689
+ loss_metrics = [x for x in live_runs if x['run_id'] == r['run_id'] and 'loss' in x['metric'].lower()]
690
+ lr_metrics = [x for x in live_runs if x['run_id'] == r['run_id'] and 'lr' in x['metric'].lower() or 'learning' in x['metric'].lower()]
691
+ indicator = ''
692
+ if loss_metrics:
693
+ lm = loss_metrics[0]
694
+ arrow = '↘' if lm['pct'] < 0 else '↗'
695
+ indicator += ' loss: %.4f %s' % (lm['latest'], arrow)
696
+ if lr_metrics:
697
+ lrm = lr_metrics[0]
698
+ indicator += ' | lr: %.2e' % lrm['latest']
699
+ run_url = '%s/%s/runs/%s' % (base_url, project_name, r['run_id'])
700
+ print()
701
+ print(' %s %s' % (r['run_name'], indicator))
702
+ # Compact sparkline for loss
703
+ if loss_metrics:
704
+ print(' %s' % loss_metrics[0]['sparkline'])
705
+ print(' -> %s' % run_url)
706
+ print()
707
+ print('=' * 72)
708
+
709
+ # Watch mode: loop or exit
710
+ if not watch_mode:
711
+ break
712
+ if not live_runs:
713
+ print('\\nAll runs finished. Exiting watch mode.')
714
+ break
715
+ print('\\n Refreshing in %ds... (Ctrl+C to stop)' % watch_interval)
716
+ try:
717
+ _time.sleep(watch_interval)
718
+ except KeyboardInterrupt:
719
+ print('\\nWatch stopped.')
720
+ break
721
+
722
+ # PNG generation if display available
723
+ has_display = os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY') or sys.platform == 'darwin'
724
+ if has_display:
725
+ try:
726
+ import matplotlib
727
+ matplotlib.use('Agg')
728
+ import matplotlib.pyplot as plt
729
+ import tempfile
730
+
731
+ fig, axes = plt.subplots(len([r for r in results if 'error' not in r]), 1,
732
+ figsize=(12, 3 * len([r for r in results if 'error' not in r])),
733
+ squeeze=False)
734
+ fig.suptitle(f'OpenRunner Metrics — {{project_name}}', fontsize=14, fontweight='bold')
735
+
736
+ plot_idx = 0
737
+ for r in results:
738
+ if 'error' in r:
739
+ continue
740
+ # Re-fetch values for plot
741
+ resp2 = client._request('get', f'/runs/{{r[\"run_id\"]}}/metrics', params={{'keys': r[\"metric\"]}})
742
+ pts = resp2.json().get('metrics', {{}}).get(r['metric'], [])
743
+ vals = [pt.get('value', pt) if isinstance(pt, dict) else pt for pt in pts]
744
+ stp = [pt.get('step', i) if isinstance(pt, dict) else i for i, pt in enumerate(pts)]
745
+ vals = [v for v in vals if isinstance(v, (int, float))]
746
+ stp = stp[:len(vals)]
747
+
748
+ ax = axes[plot_idx][0]
749
+ ax.plot(stp, vals, linewidth=1.5, color='#2196F3')
750
+ ax.fill_between(stp, vals, alpha=0.1, color='#2196F3')
751
+ ax.set_title(f'{{r[\"run_name\"]}} — {{r[\"metric\"]}}', fontsize=11)
752
+ ax.set_xlabel('Step')
753
+ ax.set_ylabel(r['metric'])
754
+ ax.grid(True, alpha=0.3)
755
+ plot_idx += 1
756
+
757
+ plt.tight_layout()
758
+ png_path = Path(tempfile.gettempdir()) / 'openrunner_metrics.png'
759
+ fig.savefig(png_path, dpi=150, bbox_inches='tight')
760
+ plt.close(fig)
761
+ print(f'\\nPNG_SAVED={{png_path}}')
762
+ except ImportError:
763
+ print('\\nPNG_SKIP=matplotlib not installed (pip install matplotlib)')
764
+ except Exception as e:
765
+ print(f'\\nPNG_ERROR={{e}}')
766
+
767
+ client.close()
768
+ print('\\nDONE')
769
+ "
770
+ ```
771
+
772
+ ### Step 2: Present results
773
+
774
+ Show the ASCII chart and table to the user. If a PNG was generated, mention its path so they can open it.
775
+
776
+ If NOT_CONFIGURED: tell user to run `/openrunner:setup` first.
777
+ If NO_RUNS: tell user no training runs found in project.
778
+ If NO_METRICS: tell user runs exist but no metrics logged yet.
779
+ """
780
+
471
781
  MCP_CMD = """---
472
782
  name: {prefix}mcp
473
783
  description: Register OpenRunner MCP server for native tool access
@@ -535,6 +845,7 @@ def install_claude_code() -> list[str]:
535
845
  "code-upload.md": CODE_UPLOAD_CMD,
536
846
  "update.md": UPDATE_CMD,
537
847
  "mcp.md": MCP_CMD,
848
+ "plot-session-run.md": PLOT_SESSION_RUN_CMD,
538
849
  }
539
850
 
540
851
  files = []
@@ -555,6 +866,10 @@ When the user asks about training metrics, loss charts, run data, or experiment
555
866
 
556
867
  Do NOT parse log files or grep for metrics when OpenRunner MCP tools are available.
557
868
 
869
+ When the user asks to plot or visualize run metrics in this session:
870
+ - Use `/openrunner:plot-session-run` for full ASCII charts + table + PNG
871
+ - Supports: `/openrunner:plot-session-run [run_id] [--metric key] [--all]`
872
+
558
873
  When the user asks about papers, research, or sessions:
559
874
  - Use `mcp__openrunner__openrunner_search_papers` for paper content
560
875
  - Use `mcp__openrunner__openrunner_search_research` for research plans
@@ -597,6 +912,7 @@ def _install_all_commands_to_dir(cmd_dir: Path, prefix: str = "openrunner:") ->
597
912
  "code-upload.md": CODE_UPLOAD_CMD,
598
913
  "update.md": UPDATE_CMD,
599
914
  "mcp.md": MCP_CMD,
915
+ "plot-session-run.md": PLOT_SESSION_RUN_CMD,
600
916
  }
601
917
 
602
918
  files = []
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openrunner-sdk"
3
- version = "2.17.4"
3
+ version = "2.18.0"
4
4
  description = "OpenRunner SDK - W&B-compatible ML experiment tracking client"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -68,6 +68,40 @@ def temp_git_repo(tmp_path: Path) -> Generator[Path, None, None]:
68
68
  yield repo_dir
69
69
 
70
70
 
71
+ @pytest.fixture
72
+ def block_import(monkeypatch: pytest.MonkeyPatch):
73
+ """Return a helper that blocks a module from being imported.
74
+
75
+ Usage:
76
+ def test_foo(block_import):
77
+ block_import("torch")
78
+ with pytest.raises(ImportError):
79
+ import openrunner.integration.pytorch
80
+ """
81
+ import builtins
82
+ import sys
83
+
84
+ real_import = builtins.__import__
85
+ blocked: set[str] = set()
86
+
87
+ def mock_import(name, *args, **kwargs):
88
+ for pkg in blocked:
89
+ if name == pkg or name.startswith(pkg + "."):
90
+ raise ImportError(f"No module named '{name}'")
91
+ return real_import(name, *args, **kwargs)
92
+
93
+ monkeypatch.setattr(builtins, "__import__", mock_import)
94
+
95
+ def _block(module_name: str):
96
+ blocked.add(module_name)
97
+ # Remove from sys.modules
98
+ to_remove = [k for k in sys.modules if k == module_name or k.startswith(module_name + ".")]
99
+ for k in to_remove:
100
+ monkeypatch.delitem(sys.modules, k, raising=False)
101
+
102
+ return _block
103
+
104
+
71
105
  @pytest.fixture
72
106
  def mock_http_transport() -> MagicMock:
73
107
  """Create a mock HTTP transport for testing API client without real network calls."""
@@ -39,8 +39,8 @@ class TestLogin:
39
39
  def test_login_stores_settings(self, runner, settings_dir):
40
40
  result = runner.invoke(
41
41
  main,
42
- ["login"],
43
- input="my-api-key\nhttp://myserver:8000\n",
42
+ ["login", "--manual"],
43
+ input="http://myserver:8000\nmy-api-key\n",
44
44
  )
45
45
  assert result.exit_code == 0
46
46
  assert "Login successful" in result.output
@@ -54,8 +54,8 @@ class TestLogin:
54
54
  def test_login_uses_default_url(self, runner, settings_dir):
55
55
  result = runner.invoke(
56
56
  main,
57
- ["login"],
58
- input="my-api-key\n\n", # empty input = default
57
+ ["login", "--manual"],
58
+ input="\nmy-api-key\n", # empty input = default URL
59
59
  )
60
60
  assert result.exit_code == 0
61
61
  settings = json.loads(
@@ -70,15 +70,15 @@ class TestLogin:
70
70
  )
71
71
  result = runner.invoke(
72
72
  main,
73
- ["login"],
74
- input="new-key\nhttp://new:8000\n",
73
+ ["login", "--manual"],
74
+ input="new-key\n",
75
75
  )
76
76
  assert result.exit_code == 0
77
77
  settings = json.loads(
78
78
  (settings_dir / "settings.json").read_text()
79
79
  )
80
80
  assert settings["api_key"] == "new-key"
81
- assert settings["base_url"] == "http://new:8000"
81
+ assert settings["base_url"] == "http://old:8000"
82
82
 
83
83
 
84
84
  class TestSyncAll:
@@ -163,10 +163,9 @@ class TestOpenRunnerFastAICallback:
163
163
  class TestFastAIImportError:
164
164
  """ImportError when fastai is missing."""
165
165
 
166
- def test_import_error_when_fastai_missing(self, monkeypatch):
167
- monkeypatch.delitem(sys.modules, "fastai", raising=False)
168
- monkeypatch.delitem(sys.modules, "fastai.callback", raising=False)
169
- monkeypatch.delitem(sys.modules, "fastai.callback.core", raising=False)
166
+ def test_import_error_when_fastai_missing(self, monkeypatch, block_import):
167
+ block_import("fastai")
168
+ import sys
170
169
  sys.modules.pop("openrunner.integration.fastai", None)
171
170
 
172
171
  with pytest.raises(ImportError, match="pip install openrunner"):
@@ -161,8 +161,9 @@ class TestOpenRunnerCallback:
161
161
  class TestHuggingFaceImportError:
162
162
  """Test 10: clear ImportError when transformers is missing."""
163
163
 
164
- def test_import_error_when_transformers_missing(self, monkeypatch):
165
- monkeypatch.delitem(sys.modules, "transformers", raising=False)
164
+ def test_import_error_when_transformers_missing(self, monkeypatch, block_import):
165
+ block_import("transformers")
166
+ import sys
166
167
  sys.modules.pop("openrunner.integration.huggingface", None)
167
168
 
168
169
  with pytest.raises(ImportError, match="pip install openrunner"):
@@ -147,12 +147,10 @@ class TestOpenRunnerKerasCallback:
147
147
  class TestKerasImportError:
148
148
  """ImportError when keras is missing."""
149
149
 
150
- def test_import_error_when_keras_missing(self, monkeypatch):
151
- monkeypatch.delitem(sys.modules, "keras", raising=False)
152
- monkeypatch.delitem(sys.modules, "keras.callbacks", raising=False)
153
- monkeypatch.delitem(sys.modules, "tensorflow", raising=False)
154
- monkeypatch.delitem(sys.modules, "tensorflow.keras", raising=False)
155
- monkeypatch.delitem(sys.modules, "tensorflow.keras.callbacks", raising=False)
150
+ def test_import_error_when_keras_missing(self, monkeypatch, block_import):
151
+ block_import("keras")
152
+ block_import("tensorflow")
153
+ import sys
156
154
  sys.modules.pop("openrunner.integration.keras", None)
157
155
 
158
156
  with pytest.raises(ImportError, match="pip install openrunner"):
@@ -200,12 +200,10 @@ class TestOpenRunnerCallbackHandler:
200
200
  class TestLangChainImportError:
201
201
  """ImportError when langchain is missing."""
202
202
 
203
- def test_import_error_when_langchain_missing(self, monkeypatch):
204
- monkeypatch.delitem(sys.modules, "langchain_core", raising=False)
205
- monkeypatch.delitem(sys.modules, "langchain_core.callbacks", raising=False)
206
- monkeypatch.delitem(sys.modules, "langchain", raising=False)
207
- monkeypatch.delitem(sys.modules, "langchain.callbacks", raising=False)
208
- monkeypatch.delitem(sys.modules, "langchain.callbacks.base", raising=False)
203
+ def test_import_error_when_langchain_missing(self, monkeypatch, block_import):
204
+ block_import("langchain_core")
205
+ block_import("langchain")
206
+ import sys
209
207
  sys.modules.pop("openrunner.integration.langchain", None)
210
208
 
211
209
  with pytest.raises(ImportError, match="pip install openrunner"):
@@ -175,10 +175,10 @@ class TestOpenRunnerLogger:
175
175
  class TestLightningImportError:
176
176
  """Test 8: clear ImportError when lightning is missing."""
177
177
 
178
- def test_import_error_when_lightning_missing(self, monkeypatch):
179
- monkeypatch.delitem(sys.modules, "lightning", raising=False)
180
- monkeypatch.delitem(sys.modules, "lightning.pytorch", raising=False)
181
- monkeypatch.delitem(sys.modules, "lightning.pytorch.loggers", raising=False)
178
+ def test_import_error_when_lightning_missing(self, monkeypatch, block_import):
179
+ block_import("lightning")
180
+ block_import("pytorch_lightning")
181
+ import sys
182
182
  sys.modules.pop("openrunner.integration.lightning", None)
183
183
 
184
184
  with pytest.raises(ImportError, match="pip install openrunner"):
@@ -113,10 +113,9 @@ class TestLogGradients:
113
113
  class TestPyTorchImportError:
114
114
  """Test 4: clear ImportError when torch is missing."""
115
115
 
116
- def test_import_error_when_torch_missing(self, monkeypatch):
117
- # Remove the mock torch to simulate missing install
118
- monkeypatch.delitem(sys.modules, "torch", raising=False)
119
- monkeypatch.delitem(sys.modules, "torch.nn", raising=False)
116
+ def test_import_error_when_torch_missing(self, monkeypatch, block_import):
117
+ block_import("torch")
118
+ import sys
120
119
  sys.modules.pop("openrunner.integration.pytorch", None)
121
120
 
122
121
  with pytest.raises(ImportError, match="pip install openrunner"):
@@ -250,9 +250,9 @@ class TestLogFeatureImportance:
250
250
  class TestSklearnImportError:
251
251
  """ImportError when sklearn is missing."""
252
252
 
253
- def test_import_error_when_sklearn_missing(self, monkeypatch):
254
- monkeypatch.delitem(sys.modules, "sklearn", raising=False)
255
- monkeypatch.delitem(sys.modules, "sklearn.metrics", raising=False)
253
+ def test_import_error_when_sklearn_missing(self, monkeypatch, block_import):
254
+ block_import("sklearn")
255
+ import sys
256
256
  sys.modules.pop("openrunner.integration.sklearn", None)
257
257
 
258
258
  with pytest.raises(ImportError, match="pip install openrunner"):
@@ -167,9 +167,9 @@ class TestOpenRunnerXGBoostCallback:
167
167
  class TestXGBoostImportError:
168
168
  """ImportError when xgboost is missing."""
169
169
 
170
- def test_import_error_when_xgboost_missing(self, monkeypatch):
171
- monkeypatch.delitem(sys.modules, "xgboost", raising=False)
172
- monkeypatch.delitem(sys.modules, "xgboost.callback", raising=False)
170
+ def test_import_error_when_xgboost_missing(self, monkeypatch, block_import):
171
+ block_import("xgboost")
172
+ import sys
173
173
  sys.modules.pop("openrunner.integration.xgboost", None)
174
174
 
175
175
  with pytest.raises(ImportError, match="pip install openrunner"):
File without changes
File without changes