humanbound-cli 0.4.0__tar.gz → 0.4.1__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 (61) hide show
  1. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/PKG-INFO +1 -1
  2. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/client.py +41 -2
  3. humanbound_cli-0.4.1/humanbound_cli/commands/logs.py +479 -0
  4. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/report.py +172 -48
  5. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/PKG-INFO +1 -1
  6. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/top_level.txt +0 -1
  7. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/pyproject.toml +1 -1
  8. humanbound_cli-0.4.0/humanbound_cli/commands/logs.py +0 -240
  9. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/LICENSE +0 -0
  10. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/README.md +0 -0
  11. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/__init__.py +0 -0
  12. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/__init__.py +0 -0
  13. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/api_keys.py +0 -0
  14. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/auth.py +0 -0
  15. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/campaigns.py +0 -0
  16. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/completion.py +0 -0
  17. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/connectors.py +0 -0
  18. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/coverage.py +0 -0
  19. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/discover.py +0 -0
  20. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/docs.py +0 -0
  21. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/experiments.py +0 -0
  22. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/findings.py +0 -0
  23. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/guardrails.py +0 -0
  24. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/init.py +0 -0
  25. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/inventory.py +0 -0
  26. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/members.py +0 -0
  27. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/orgs.py +0 -0
  28. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/posture.py +0 -0
  29. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/projects.py +0 -0
  30. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/providers.py +0 -0
  31. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/scan.py +0 -0
  32. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/sentinel.py +0 -0
  33. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/test.py +0 -0
  34. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/upload_logs.py +0 -0
  35. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/config.py +0 -0
  36. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/connectors/__init__.py +0 -0
  37. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/connectors/microsoft.py +0 -0
  38. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/exceptions.py +0 -0
  39. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/extractors/__init__.py +0 -0
  40. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/extractors/openapi.py +0 -0
  41. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/extractors/repo.py +0 -0
  42. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/main.py +0 -0
  43. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/pytest_plugin/__init__.py +0 -0
  44. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/pytest_plugin/fixtures.py +0 -0
  45. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/pytest_plugin/report.py +0 -0
  46. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/report_builder.py +0 -0
  47. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/serve/__init__.py +0 -0
  48. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/serve/config_builder.py +0 -0
  49. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/serve/local_server.py +0 -0
  50. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/serve/runtime_detector.py +0 -0
  51. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/serve/tunnel_client.py +0 -0
  52. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/SOURCES.txt +0 -0
  53. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/dependency_links.txt +0 -0
  54. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/entry_points.txt +0 -0
  55. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/requires.txt +0 -0
  56. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/relay/relay.py +0 -0
  57. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/setup.cfg +0 -0
  58. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/tests/__init__.py +0 -0
  59. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/tests/cli_integration_test.py +0 -0
  60. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/tests/conftest.py +0 -0
  61. {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/tests/test_cli_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: humanbound-cli
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Humanbound CLI - command line interface for AI agent security testing.
5
5
  Author-email: Kostas Siabanis <hello@humanbound.ai>, Demetris Gerogiannis <hello@humanbound.ai>
6
6
  License: Apache-2.0
@@ -6,13 +6,11 @@ import time
6
6
  import webbrowser
7
7
  import http.server
8
8
  import socketserver
9
- import threading
10
9
  import secrets
11
10
  import hashlib
12
11
  import base64
13
12
  import urllib.parse
14
13
  from typing import Optional, Dict, Any, List
15
- from pathlib import Path
16
14
 
17
15
  import requests
18
16
 
@@ -906,6 +904,47 @@ class HumanboundClient:
906
904
  include_project=True,
907
905
  )
908
906
 
907
+ def get_project_logs(
908
+ self,
909
+ page: int = 1,
910
+ size: int = 50,
911
+ result: Optional[str] = None,
912
+ from_date: Optional[str] = None,
913
+ until_date: Optional[str] = None,
914
+ test_category: Optional[str] = None,
915
+ last: Optional[int] = None,
916
+ ) -> dict:
917
+ """Get logs for the current project with optional filters.
918
+
919
+ Args:
920
+ page: Page number.
921
+ size: Items per page.
922
+ result: Filter by result (pass/fail).
923
+ from_date: Start date (ISO 8601).
924
+ until_date: End date (ISO 8601).
925
+ test_category: Filter by test category (substring match).
926
+ last: Limit to last N experiments.
927
+
928
+ Returns:
929
+ Paginated response with logs.
930
+ """
931
+ if not self._project_id:
932
+ raise ValidationError("No project selected. Use set_project() first.")
933
+
934
+ params: Dict[str, Any] = {"page": page, "size": size}
935
+ if result:
936
+ params["result"] = result
937
+ if from_date:
938
+ params["from"] = from_date
939
+ if until_date:
940
+ params["until"] = until_date
941
+ if test_category:
942
+ params["test_category"] = test_category
943
+ if last:
944
+ params["last"] = last
945
+
946
+ return self.get("logs", params=params, include_project=True)
947
+
909
948
  # -------------------------------------------------------------------------
910
949
  # Provider Methods
911
950
  # -------------------------------------------------------------------------
@@ -0,0 +1,479 @@
1
+ """Logs command for retrieving and exporting experiment results."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+ import json
7
+ from datetime import datetime, timedelta
8
+ from pathlib import Path
9
+
10
+ from ..client import HumanboundClient
11
+ from ..exceptions import NotAuthenticatedError, APIError
12
+
13
+ console = Console()
14
+ console_err = Console(stderr=True)
15
+
16
+
17
+ @click.command("logs")
18
+ @click.argument("experiment_id", required=False)
19
+ @click.option(
20
+ "--format", "-f", "output_format",
21
+ type=click.Choice(["table", "json", "html"]),
22
+ default="table",
23
+ help="Output format"
24
+ )
25
+ @click.option(
26
+ "--output", "-o",
27
+ type=click.Path(),
28
+ help="Output file path (prints to stdout if not specified)"
29
+ )
30
+ @click.option(
31
+ "--verdict", "-v",
32
+ type=click.Choice(["pass", "fail", "all"]),
33
+ default="all",
34
+ help="Filter by verdict"
35
+ )
36
+ @click.option(
37
+ "--page", default=1, help="Page number (for table format)"
38
+ )
39
+ @click.option(
40
+ "--size", default=50, help="Items per page (for table format)"
41
+ )
42
+ @click.option(
43
+ "--all", "fetch_all", is_flag=True, help="Fetch all logs (for json format)"
44
+ )
45
+ @click.option(
46
+ "--last", "last_n", type=int, help="Logs from last N experiments"
47
+ )
48
+ @click.option(
49
+ "--category", "test_category", help="Filter by test category (substring match)"
50
+ )
51
+ @click.option(
52
+ "--from", "from_date", help="Start date (ISO 8601, e.g. 2026-01-01)"
53
+ )
54
+ @click.option(
55
+ "--until", "until_date", help="End date (ISO 8601)"
56
+ )
57
+ @click.option(
58
+ "--days", type=int, help="Last N days (shortcut for --from)"
59
+ )
60
+ def logs_command(experiment_id, output_format, output, verdict, page, size, fetch_all, last_n, test_category, from_date, until_date, days):
61
+ """Get logs from an experiment or across a project.
62
+
63
+ If no experiment_id or scope flags are provided, uses the most recent experiment.
64
+ Use scope flags (--last, --category, --from, --until, --days) for project-wide logs.
65
+
66
+ \b
67
+ Examples:
68
+ hb logs # Latest experiment logs
69
+ hb logs abc123 # Specific experiment
70
+ hb logs --last 5 # Last 5 experiments
71
+ hb logs --last 3 --verdict fail # Failed logs from last 3
72
+ hb logs --category owasp_multi_turn # All multi-turn logs
73
+ hb logs --days 7 --format json -o week.json
74
+ hb logs --from 2026-01-01 --until 2026-02-01 --format html -o jan.html
75
+ """
76
+ client = HumanboundClient()
77
+
78
+ if not client.is_authenticated():
79
+ console_err.print("[red]Not authenticated.[/red] Run 'hb login' first.")
80
+ raise SystemExit(1)
81
+
82
+ if not client.project_id:
83
+ console_err.print("[yellow]No project selected.[/yellow]")
84
+ console_err.print("Use 'hb projects use <id>' to select a project first.")
85
+ raise SystemExit(1)
86
+
87
+ # Validation
88
+ scope_flags = any([last_n, test_category, from_date, until_date, days])
89
+ if experiment_id and scope_flags:
90
+ console_err.print("[red]Cannot combine experiment ID with scope flags.[/red]")
91
+ console_err.print("Use either an experiment ID OR scope flags (--last, --category, --from, --until, --days).")
92
+ raise SystemExit(1)
93
+ if days and from_date:
94
+ console_err.print("[red]Cannot combine --days with --from.[/red]")
95
+ raise SystemExit(1)
96
+
97
+ # --days → --from
98
+ if days:
99
+ from_date = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%dT00:00:00")
100
+
101
+ try:
102
+ if scope_flags:
103
+ _project_level_logs(
104
+ client, output_format, output, verdict, page, size, fetch_all,
105
+ last_n, test_category, from_date, until_date,
106
+ )
107
+ elif experiment_id:
108
+ # Resolve partial experiment ID
109
+ experiment_id = _resolve_experiment_id(client, experiment_id)
110
+
111
+ if output_format == "html":
112
+ _export_html(client, experiment_id, output)
113
+ elif output_format == "json":
114
+ _export_json(client, experiment_id, output, verdict, fetch_all, page, size)
115
+ else:
116
+ _show_table(client, experiment_id, verdict, page, size)
117
+ else:
118
+ # No args → most recent experiment (existing behavior)
119
+ response = client.list_experiments(page=1, size=1)
120
+ exps = response.get("data", [])
121
+ if not exps:
122
+ console_err.print("[yellow]No experiments found.[/yellow]")
123
+ raise SystemExit(1)
124
+ experiment_id = exps[0].get("id")
125
+ console_err.print(f"[dim]Using most recent experiment: {experiment_id}[/dim]")
126
+
127
+ if output_format == "html":
128
+ _export_html(client, experiment_id, output)
129
+ elif output_format == "json":
130
+ _export_json(client, experiment_id, output, verdict, fetch_all, page, size)
131
+ else:
132
+ _show_table(client, experiment_id, verdict, page, size)
133
+
134
+ except NotAuthenticatedError:
135
+ console_err.print("[red]Not authenticated.[/red] Run 'hb login' first.")
136
+ raise SystemExit(1)
137
+ except APIError as e:
138
+ console_err.print(f"[red]Error:[/red] {e}")
139
+ raise SystemExit(1)
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Project-level logs
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def _build_experiment_lookup(client):
147
+ """Fetch all experiments and build {id: {name, test_category}} lookup."""
148
+ lookup = {}
149
+ current_page = 1
150
+ while True:
151
+ response = client.list_experiments(page=current_page, size=100)
152
+ for exp in response.get("data", []):
153
+ lookup[exp.get("id")] = {
154
+ "name": exp.get("name", ""),
155
+ "test_category": exp.get("test_category", ""),
156
+ }
157
+ if not response.get("has_next_page"):
158
+ break
159
+ current_page += 1
160
+ return lookup
161
+
162
+
163
+ def _enrich_log(log, exp_lookup):
164
+ """Add experiment_name and test_category to a log entry from lookup."""
165
+ exp_id = log.get("experiment_id", "")
166
+ info = exp_lookup.get(exp_id, {})
167
+ log["experiment_name"] = info.get("name", "")
168
+ log["test_category"] = info.get("test_category", "")
169
+ return log
170
+
171
+
172
+ def _project_level_logs(client, output_format, output, verdict, page, size, fetch_all,
173
+ last_n, test_category, from_date, until_date):
174
+ """Fetch and display project-level logs with scope filters."""
175
+ result_filter = None if verdict == "all" else verdict
176
+
177
+ # Build experiment lookup for enriching logs
178
+ exp_lookup = _build_experiment_lookup(client)
179
+
180
+ if output_format == "html":
181
+ _project_export_html(client, output, result_filter, last_n, test_category, from_date, until_date, exp_lookup)
182
+ elif output_format == "json":
183
+ _project_export_json(client, output, result_filter, fetch_all, page, size, last_n, test_category, from_date, until_date, exp_lookup)
184
+ else:
185
+ _project_show_table(client, result_filter, page, size, last_n, test_category, from_date, until_date, exp_lookup)
186
+
187
+
188
+ def _project_show_table(client, result_filter, page, size, last_n, test_category, from_date, until_date, exp_lookup):
189
+ """Show project-level logs in table format."""
190
+ response = client.get_project_logs(
191
+ page=page, size=size, result=result_filter,
192
+ from_date=from_date, until_date=until_date,
193
+ test_category=test_category, last=last_n,
194
+ )
195
+ logs = response.get("data", [])
196
+
197
+ if not logs:
198
+ console.print("[yellow]No logs found.[/yellow]")
199
+ return
200
+
201
+ table = Table(title=f"Project Logs (page {page})")
202
+ table.add_column("Experiment", width=20)
203
+ table.add_column("Test Category", width=20)
204
+ table.add_column("Verdict", width=6)
205
+ table.add_column("Severity", width=8)
206
+ table.add_column("Category", width=15)
207
+ table.add_column("Prompt", max_width=40)
208
+
209
+ for log in logs:
210
+ _enrich_log(log, exp_lookup)
211
+
212
+ result_val = log.get("result", "")
213
+ result_style = "[green]pass[/green]" if result_val == "pass" else "[red]fail[/red]"
214
+
215
+ severity = log.get("severity", "")
216
+ severity_style = {
217
+ "critical": "[red bold]critical[/red bold]",
218
+ "high": "[red]high[/red]",
219
+ "medium": "[yellow]medium[/yellow]",
220
+ "low": "[blue]low[/blue]",
221
+ }.get(str(severity).lower(), str(severity))
222
+
223
+ # Shorten test_category for display
224
+ tc = log.get("test_category", "")
225
+ tc_short = tc.split("/")[-1] if "/" in tc else tc
226
+
227
+ table.add_row(
228
+ (log.get("experiment_name", "") or "")[:20],
229
+ tc_short[:20],
230
+ result_style,
231
+ severity_style if result_val == "fail" else "",
232
+ log.get("fail_category") or log.get("gen_category") or "",
233
+ (log.get("prompt", "") or "")[:40],
234
+ )
235
+
236
+ console.print(table)
237
+
238
+ if response.get("has_next_page"):
239
+ console.print(f"\n[dim]Showing {len(logs)} logs. Use --page to see more.[/dim]")
240
+
241
+
242
+ def _project_export_json(client, output, result_filter, fetch_all, page, size, last_n, test_category, from_date, until_date, exp_lookup):
243
+ """Export project-level logs as JSON."""
244
+ all_logs = []
245
+
246
+ if fetch_all:
247
+ current_page = 1
248
+ while True:
249
+ response = client.get_project_logs(
250
+ page=current_page, size=100, result=result_filter,
251
+ from_date=from_date, until_date=until_date,
252
+ test_category=test_category, last=last_n,
253
+ )
254
+ logs = response.get("data", [])
255
+ all_logs.extend(logs)
256
+ if not response.get("has_next_page"):
257
+ break
258
+ current_page += 1
259
+ else:
260
+ response = client.get_project_logs(
261
+ page=page, size=size, result=result_filter,
262
+ from_date=from_date, until_date=until_date,
263
+ test_category=test_category, last=last_n,
264
+ )
265
+ all_logs = response.get("data", [])
266
+
267
+ # Enrich each log with experiment name and test_category
268
+ for log in all_logs:
269
+ _enrich_log(log, exp_lookup)
270
+
271
+ export_data = {
272
+ "project_id": client.project_id,
273
+ "filters": {
274
+ "last": last_n,
275
+ "test_category": test_category,
276
+ "from": from_date,
277
+ "until": until_date,
278
+ "result": result_filter,
279
+ },
280
+ "logs": all_logs,
281
+ "total_logs": len(all_logs),
282
+ }
283
+
284
+ json_output = json.dumps(export_data, indent=2, default=str)
285
+
286
+ if output:
287
+ Path(output).write_text(json_output)
288
+ console.print(f"[green]JSON exported to:[/green] {output}")
289
+ else:
290
+ print(json_output)
291
+
292
+
293
+ def _project_export_html(client, output, result_filter, last_n, test_category, from_date, until_date, exp_lookup):
294
+ """Export project-level logs as HTML report."""
295
+ with console.status("Generating HTML report...", spinner="dots"):
296
+ # Fetch all matching logs
297
+ all_logs = []
298
+ current_page = 1
299
+ while True:
300
+ response = client.get_project_logs(
301
+ page=current_page, size=100, result=result_filter,
302
+ from_date=from_date, until_date=until_date,
303
+ test_category=test_category, last=last_n,
304
+ )
305
+ all_logs.extend(response.get("data", []))
306
+ if not response.get("has_next_page"):
307
+ break
308
+ current_page += 1
309
+
310
+ # Enrich each log with experiment name and test_category
311
+ for log in all_logs:
312
+ _enrich_log(log, exp_lookup)
313
+
314
+ # Build pseudo-experiment for the report template
315
+ pseudo_experiment = {
316
+ "id": f"project-{client.project_id[:8]}",
317
+ "name": "Project Logs",
318
+ "test_category": test_category or "Project-wide",
319
+ "testing_level": "",
320
+ "status": "completed",
321
+ "results": {},
322
+ "created_at": from_date or "",
323
+ }
324
+
325
+ from ..report import generate_html_report
326
+ report_html = generate_html_report(pseudo_experiment, all_logs)
327
+
328
+ filename = output or f"project_{client.project_id[:8]}_logs.html"
329
+ Path(filename).write_text(report_html)
330
+ console.print(f"[green]HTML report exported to:[/green] {filename}")
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Experiment-level helpers (unchanged)
335
+ # ---------------------------------------------------------------------------
336
+
337
+ def _resolve_experiment_id(client: HumanboundClient, partial_id: str) -> str:
338
+ """Resolve a partial experiment ID to full ID."""
339
+ if len(partial_id) >= 32:
340
+ return partial_id
341
+
342
+ # Search recent experiments for match
343
+ response = client.list_experiments(page=1, size=50)
344
+ for exp in response.get("data", []):
345
+ if exp.get("id", "").startswith(partial_id):
346
+ return exp.get("id")
347
+
348
+ # Not found, return as-is and let API handle error
349
+ return partial_id
350
+
351
+
352
+ def _show_table(client: HumanboundClient, experiment_id: str, verdict: str, page: int, size: int):
353
+ """Show logs in table format."""
354
+ result_filter = None if verdict == "all" else verdict
355
+
356
+ response = client.get_experiment_logs(
357
+ experiment_id,
358
+ page=page,
359
+ size=size,
360
+ result=result_filter,
361
+ )
362
+ logs = response.get("data", [])
363
+
364
+ if not logs:
365
+ console.print("[yellow]No logs found.[/yellow]")
366
+ return
367
+
368
+ table = Table(title=f"Experiment Logs (page {page})")
369
+ table.add_column("ID", style="dim")
370
+ table.add_column("Verdict", width=6)
371
+ table.add_column("Severity", width=8)
372
+ table.add_column("Category", width=15)
373
+ table.add_column("Prompt", max_width=50)
374
+
375
+ for log in logs:
376
+ result_val = log.get("result", "")
377
+ result_style = "[green]pass[/green]" if result_val == "pass" else "[red]fail[/red]"
378
+
379
+ severity = log.get("severity", "")
380
+ severity_style = {
381
+ "critical": "[red bold]critical[/red bold]",
382
+ "high": "[red]high[/red]",
383
+ "medium": "[yellow]medium[/yellow]",
384
+ "low": "[blue]low[/blue]",
385
+ }.get(str(severity).lower(), str(severity))
386
+
387
+ table.add_row(
388
+ log.get("id", ""),
389
+ result_style,
390
+ severity_style if result_val == "fail" else "",
391
+ log.get("fail_category") or log.get("gen_category") or "",
392
+ (log.get("prompt", "") or "")[:50],
393
+ )
394
+
395
+ console.print(table)
396
+
397
+ total = response.get("total", 0)
398
+ if response.get("has_next_page"):
399
+ console.print(f"\n[dim]Showing {len(logs)} of {total}. Use --page to see more.[/dim]")
400
+
401
+
402
+ def _export_json(client: HumanboundClient, experiment_id: str, output: str, verdict: str, fetch_all: bool, page: int, size: int):
403
+ """Export logs as JSON."""
404
+ result_filter = None if verdict == "all" else verdict
405
+
406
+ all_logs = []
407
+
408
+ if fetch_all:
409
+ # Fetch all pages
410
+ current_page = 1
411
+ while True:
412
+ response = client.get_experiment_logs(
413
+ experiment_id,
414
+ page=current_page,
415
+ size=100,
416
+ result=result_filter,
417
+ )
418
+ logs = response.get("data", [])
419
+ all_logs.extend(logs)
420
+
421
+ if not response.get("has_next_page"):
422
+ break
423
+ current_page += 1
424
+ else:
425
+ response = client.get_experiment_logs(
426
+ experiment_id,
427
+ page=page,
428
+ size=size,
429
+ result=result_filter,
430
+ )
431
+ all_logs = response.get("data", [])
432
+
433
+ # Get experiment info for context
434
+ experiment = client.get_experiment(experiment_id)
435
+
436
+ export_data = {
437
+ "experiment": {
438
+ "id": experiment.get("id"),
439
+ "name": experiment.get("name"),
440
+ "status": experiment.get("status"),
441
+ "test_category": experiment.get("test_category"),
442
+ "testing_level": experiment.get("testing_level"),
443
+ "created_at": experiment.get("created_at"),
444
+ },
445
+ "results": experiment.get("results", {}),
446
+ "logs": all_logs,
447
+ "total_logs": len(all_logs),
448
+ }
449
+
450
+ json_output = json.dumps(export_data, indent=2, default=str)
451
+
452
+ if output:
453
+ Path(output).write_text(json_output)
454
+ console.print(f"[green]JSON exported to:[/green] {output}")
455
+ else:
456
+ print(json_output)
457
+
458
+
459
+ def _export_html(client: HumanboundClient, experiment_id: str, output: str):
460
+ """Export logs as HTML report."""
461
+ with console.status("Generating HTML report...", spinner="dots"):
462
+ experiment = client.get_experiment(experiment_id)
463
+
464
+ # Fetch all logs
465
+ all_logs = []
466
+ page = 1
467
+ while True:
468
+ resp = client.get_experiment_logs(experiment_id, page=page, size=100)
469
+ all_logs.extend(resp.get("data", []))
470
+ if not resp.get("has_next_page"):
471
+ break
472
+ page += 1
473
+
474
+ from ..report import generate_html_report
475
+ report_html = generate_html_report(experiment, all_logs)
476
+
477
+ filename = output or f"experiment_{experiment_id[:8]}_report.html"
478
+ Path(filename).write_text(report_html)
479
+ console.print(f"[green]HTML report exported to:[/green] {filename}")