fluxloop-cli 0.2.28__py3-none-any.whl → 0.2.30__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.
fluxloop_cli/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.2.28"
5
+ __version__ = "0.2.30"
6
6
 
7
7
  from .main import app
8
8
 
@@ -18,6 +18,7 @@ from ..templates import (
18
18
  create_sample_agent,
19
19
  create_gitignore,
20
20
  create_env_file,
21
+ create_pytest_bridge_template,
21
22
  )
22
23
  from ..project_paths import resolve_root_dir, resolve_project_dir
23
24
  from ..constants import (
@@ -191,6 +192,73 @@ def project(
191
192
  console.print("8. Diagnose environment anytime: [green]fluxloop doctor[/green]")
192
193
 
193
194
 
195
+ @app.command("pytest-template")
196
+ def pytest_template(
197
+ project_root: Path = typer.Argument(
198
+ Path.cwd(),
199
+ help="Project root containing configs/ or setting.yaml",
200
+ ),
201
+ tests_dir: str = typer.Option(
202
+ "tests",
203
+ "--tests-dir",
204
+ help="Directory (relative to project root) where tests live",
205
+ ),
206
+ filename: str = typer.Option(
207
+ "test_fluxloop_smoke.py",
208
+ "--filename",
209
+ help="Test file name to create inside the tests directory",
210
+ ),
211
+ force: bool = typer.Option(
212
+ False,
213
+ "--force",
214
+ help="Overwrite existing template without confirmation",
215
+ ),
216
+ ) -> None:
217
+ """
218
+ Scaffold a pytest smoke test that uses the FluxLoop runner fixtures.
219
+ """
220
+
221
+ root_path = project_root.expanduser().resolve()
222
+ if not root_path.exists():
223
+ console.print(f"[red]Error:[/red] Project root {root_path} does not exist.")
224
+ raise typer.Exit(1)
225
+
226
+ tests_path = (root_path / tests_dir).resolve()
227
+ tests_path.mkdir(parents=True, exist_ok=True)
228
+
229
+ target_file = tests_path / filename
230
+
231
+ if target_file.exists() and not force:
232
+ if not Confirm.ask(
233
+ f"{target_file} already exists. Overwrite?",
234
+ default=False,
235
+ ):
236
+ raise typer.Abort()
237
+
238
+ configs_sim = root_path / CONFIG_DIRECTORY_NAME / CONFIG_SECTION_FILENAMES["simulation"]
239
+ legacy_config = root_path / "setting.yaml"
240
+
241
+ if configs_sim.exists():
242
+ relative_config = configs_sim.relative_to(root_path).as_posix()
243
+ elif legacy_config.exists():
244
+ relative_config = legacy_config.relative_to(root_path).as_posix()
245
+ else:
246
+ # Fall back to configs/simulation.yaml even if it does not exist yet
247
+ relative_config = (CONFIG_DIRECTORY_NAME + "/" + CONFIG_SECTION_FILENAMES["simulation"])
248
+ console.print(
249
+ "[yellow]Warning:[/yellow] Could not find configs/simulation.yaml or setting.yaml. "
250
+ "Template will reference the default simulation path."
251
+ )
252
+
253
+ template_content = create_pytest_bridge_template(relative_config)
254
+ target_file.write_text(template_content, encoding="utf-8")
255
+
256
+ console.print(
257
+ f"[green]✓[/green] Pytest template created at [cyan]{target_file}[/cyan]. "
258
+ "Run [bold]pytest -k fluxloop_smoke[/bold] to execute the sample test."
259
+ )
260
+
261
+
194
262
  @app.command()
195
263
  def agent(
196
264
  name: str = typer.Argument(
@@ -3,10 +3,9 @@ Run command for executing experiments and simulations.
3
3
  """
4
4
 
5
5
  import asyncio
6
- import json
7
6
  import sys
8
7
  from pathlib import Path
9
- from typing import Optional
8
+ from typing import Callable, Optional
10
9
 
11
10
  import typer
12
11
  from rich.console import Console
@@ -28,16 +27,6 @@ app = typer.Typer()
28
27
  console = Console()
29
28
 
30
29
 
31
- def _emit_progress_event(event: str, **payload: object) -> None:
32
- """Emit a structured progress event to stderr for machine consumption."""
33
- data = {"event": event, **payload}
34
- try:
35
- print(json.dumps(data, default=str), file=sys.stderr, flush=True)
36
- except Exception:
37
- # Fall back to repr if serialization fails
38
- print({"event": event, **{k: repr(v) for k, v in payload.items()}}, file=sys.stderr, flush=True)
39
-
40
-
41
30
  @app.command()
42
31
  def experiment(
43
32
  config_file: Path = typer.Option(
@@ -124,6 +113,17 @@ def experiment(
124
113
  "--dry-run",
125
114
  help="Show what would be run without executing",
126
115
  ),
116
+ display: bool = typer.Option(
117
+ True,
118
+ "--display/--no-display",
119
+ help="Show rich console output (disable for plain log streaming)",
120
+ ),
121
+ yes: bool = typer.Option(
122
+ False,
123
+ "--yes",
124
+ "-y",
125
+ help="Skip confirmation prompt and run immediately",
126
+ ),
127
127
  ):
128
128
  """
129
129
  Run an experiment based on configuration file.
@@ -239,19 +239,8 @@ def experiment(
239
239
 
240
240
  console.print(summary)
241
241
 
242
- _emit_progress_event(
243
- "experiment_summary",
244
- name=config.name,
245
- iterations=config.iterations,
246
- personas=len(config.personas),
247
- total_runs=total_runs,
248
- multi_turn_enabled=bool(config.multi_turn and config.multi_turn.enabled),
249
- output_dir=config.output_directory,
250
- )
251
-
252
242
  if dry_run:
253
243
  console.print("\n[yellow]Dry run mode - no execution will occur[/yellow]")
254
- _emit_progress_event("experiment_status", status="dry_run", name=config.name)
255
244
  return
256
245
 
257
246
  # Confirm execution
@@ -265,128 +254,138 @@ def experiment(
265
254
  f"\nThis will execute {total_runs} runs."
266
255
  )
267
256
 
268
- _emit_progress_event(
269
- "experiment_ready",
270
- name=config.name,
271
- total_runs=total_runs,
272
- )
273
-
274
- if not typer.confirm("Continue?"):
275
- _emit_progress_event("experiment_status", status="aborted", reason="user_declined", name=config.name)
257
+ if yes:
258
+ proceed = True
259
+ else:
260
+ proceed = typer.confirm("Continue?")
261
+ if not proceed:
276
262
  raise typer.Abort()
277
263
 
278
264
  # Create runner
279
265
  # Run experiment with progress tracking
280
266
  console.print("\n[bold green]▶️ Starting experiment...[/bold green]\n")
281
267
 
282
- _emit_progress_event("experiment_started", name=config.name, total_runs=total_runs)
283
-
284
- with Progress(
285
- SpinnerColumn(),
286
- TextColumn("[progress.description]{task.description}"),
287
- BarColumn(),
288
- TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
289
- TimeRemainingColumn(),
290
- TextColumn("({task.completed} of {task.total})"),
291
- console=console,
292
- ) as progress:
293
- # Create main task
294
- main_task = progress.add_task(
295
- f"Running {config.name}",
296
- total=total_runs,
297
- )
298
- multi_turn_total = (
299
- config.multi_turn.max_turns
300
- if config.multi_turn and config.multi_turn.max_turns
301
- else None
302
- )
303
- turn_task = progress.add_task(
304
- "[cyan]Turn 0/0",
305
- total=multi_turn_total or 1,
306
- visible=False,
307
- )
268
+ def _execute_with_callbacks(
269
+ progress_callback: Optional[Callable[[], None]],
270
+ turn_progress_callback: Optional[Callable[[int, int, Optional[str]], None]],
271
+ ):
272
+ try:
273
+ return asyncio.run(
274
+ runner.run_experiment(
275
+ progress_callback=progress_callback,
276
+ turn_progress_callback=turn_progress_callback,
277
+ )
278
+ )
279
+ except KeyboardInterrupt:
280
+ console.print("\n[yellow]Experiment interrupted by user[/yellow]")
281
+ raise typer.Exit(1)
282
+ except Exception as e:
283
+ console.print(f"\n[red]Error during experiment:[/red] {e}")
284
+ if "--debug" in sys.argv:
285
+ console.print_exception()
286
+ raise typer.Exit(1)
308
287
 
309
- def _progress_callback():
310
- progress.advance(main_task)
311
- task = progress.tasks[main_task]
312
- _emit_progress_event(
313
- "run_progress",
314
- completed=int(task.completed),
315
- total=int(task.total or 0),
316
- description=task.description,
317
- experiment=config.name,
288
+ if display and console.is_terminal:
289
+ with Progress(
290
+ SpinnerColumn(),
291
+ TextColumn("[progress.description]{task.description}"),
292
+ BarColumn(),
293
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
294
+ TimeRemainingColumn(),
295
+ TextColumn("({task.completed} of {task.total})"),
296
+ console=console,
297
+ ) as progress:
298
+ main_task = progress.add_task(
299
+ f"Running {config.name}",
300
+ total=total_runs,
301
+ )
302
+ multi_turn_total = (
303
+ config.multi_turn.max_turns
304
+ if config.multi_turn and config.multi_turn.max_turns
305
+ else None
306
+ )
307
+ turn_task = progress.add_task(
308
+ "[cyan]Turn 0/0",
309
+ total=multi_turn_total or 1,
310
+ visible=False,
318
311
  )
319
312
 
320
- def _turn_progress_callback(
321
- current_turn: int,
322
- total_turns: int,
323
- message: Optional[str] = None,
324
- ) -> None:
325
- total = total_turns or 1
326
- clamped_turn = max(0, current_turn)
327
- if message is None:
328
- completed = min(clamped_turn, total)
329
- description = f"[cyan]Turn {min(clamped_turn, total)}/{total}: complete"
313
+ def _progress_callback() -> None:
314
+ progress.advance(main_task)
315
+
316
+ def _turn_progress_callback(
317
+ current_turn: int,
318
+ total_turns: int,
319
+ message: Optional[str] = None,
320
+ ) -> None:
321
+ total = total_turns or 1
322
+ clamped_turn = max(0, current_turn)
323
+ if message is None:
324
+ completed = min(clamped_turn, total)
325
+ description = f"[cyan]Turn {min(clamped_turn, total)}/{total}: complete"
326
+ progress.update(
327
+ turn_task,
328
+ total=total,
329
+ completed=completed,
330
+ description=description,
331
+ visible=False,
332
+ )
333
+ return
334
+
335
+ if not progress.tasks[turn_task].visible:
336
+ progress.update(turn_task, visible=True)
337
+
338
+ preview = message.replace("\n", " ")
339
+ if len(preview) > 40:
340
+ preview = preview[:37] + "..."
341
+
342
+ completed = max(0, min(clamped_turn - 1, total))
343
+ description = f"[cyan]Turn {min(clamped_turn, total)}/{total}: {preview}"
330
344
  progress.update(
331
345
  turn_task,
332
346
  total=total,
333
347
  completed=completed,
334
348
  description=description,
335
- visible=False,
336
- )
337
- _emit_progress_event(
338
- "turn_progress",
339
- state="complete",
340
- current=min(clamped_turn, total),
341
- total=total,
342
- experiment=config.name,
343
349
  )
344
- return
345
-
346
- if not progress.tasks[turn_task].visible:
347
- progress.update(turn_task, visible=True)
348
350
 
349
- preview = message.replace("\n", " ")
350
- if len(preview) > 40:
351
- preview = preview[:37] + "..."
352
-
353
- completed = max(0, min(clamped_turn - 1, total))
354
- description = f"[cyan]Turn {min(clamped_turn, total)}/{total}: {preview}"
351
+ results = _execute_with_callbacks(_progress_callback, _turn_progress_callback)
352
+ else:
353
+ completed_runs = 0
355
354
 
356
- progress.update(
357
- turn_task,
358
- total=total,
359
- completed=completed,
360
- description=description,
361
- )
362
- _emit_progress_event(
363
- "turn_progress",
364
- state="in_progress",
365
- current=clamped_turn,
366
- total=total,
367
- preview=preview,
368
- experiment=config.name,
355
+ def _progress_callback() -> None:
356
+ nonlocal completed_runs
357
+ completed_runs += 1
358
+ print(
359
+ f"[fluxloop] run progress {completed_runs}/{total_runs}",
360
+ flush=True,
369
361
  )
370
362
 
371
- # Run experiment
372
- try:
373
- results = asyncio.run(
374
- runner.run_experiment(
375
- progress_callback=_progress_callback,
376
- turn_progress_callback=_turn_progress_callback,
363
+ def _turn_progress_callback(
364
+ current_turn: int,
365
+ total_turns: int,
366
+ message: Optional[str] = None,
367
+ ) -> None:
368
+ total = total_turns or 1
369
+ clamped_turn = max(0, current_turn)
370
+ if message:
371
+ preview = message.replace("\n", " ")
372
+ if len(preview) > 80:
373
+ preview = preview[:77] + "..."
374
+ print(
375
+ f"[fluxloop] turn progress {min(clamped_turn, total)}/{total}: {preview}",
376
+ flush=True,
377
377
  )
378
- )
379
- except KeyboardInterrupt:
380
- console.print("\n[yellow]Experiment interrupted by user[/yellow]")
381
- _emit_progress_event("experiment_status", status="interrupted", name=config.name)
382
- raise typer.Exit(1)
383
- except Exception as e:
384
- console.print(f"\n[red]Error during experiment:[/red] {e}")
385
- # Debug mode - show full traceback
386
- if "--debug" in sys.argv:
387
- console.print_exception()
388
- _emit_progress_event("experiment_status", status="error", error=str(e), name=config.name)
389
- raise typer.Exit(1)
378
+ else:
379
+ print(
380
+ f"[fluxloop] turn progress {min(clamped_turn, total)}/{total}: complete",
381
+ flush=True,
382
+ )
383
+
384
+ print(
385
+ f"[fluxloop] running {config.name} ({total_runs} total runs)",
386
+ flush=True,
387
+ )
388
+ results = _execute_with_callbacks(_progress_callback, _turn_progress_callback)
390
389
 
391
390
  config.set_resolved_input_count(results.get("input_count", config.get_input_count()))
392
391
 
@@ -399,15 +398,6 @@ def experiment(
399
398
  )
400
399
 
401
400
  _display_results(results)
402
- _emit_progress_event(
403
- "experiment_status",
404
- status="completed",
405
- name=config.name,
406
- total_runs=results.get("total_runs", total_runs),
407
- successful=results.get("successful"),
408
- failed=results.get("failed"),
409
- success_rate=results.get("success_rate"),
410
- )
411
401
 
412
402
 
413
403
  @app.command()
fluxloop_cli/templates.py CHANGED
@@ -429,6 +429,31 @@ def create_evaluation_config() -> str:
429
429
  ).strip() + "\n"
430
430
 
431
431
 
432
+ def create_pytest_bridge_template(config_relative_path: str) -> str:
433
+ """Return a ready-to-run pytest smoke test referencing the bridge fixtures."""
434
+
435
+ return dedent(
436
+ f"""
437
+ \"\"\"FluxLoop Pytest smoke test generated via `fluxloop init pytest-template`.\"\"\"
438
+
439
+ from pathlib import Path
440
+
441
+
442
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
443
+ SIMULATION_CONFIG = PROJECT_ROOT / "{config_relative_path}"
444
+
445
+
446
+ def test_fluxloop_smoke(fluxloop_runner):
447
+ result = fluxloop_runner(
448
+ project_root=PROJECT_ROOT,
449
+ simulation_config=SIMULATION_CONFIG,
450
+ env={{"PYTHONPATH": str(PROJECT_ROOT)}},
451
+ )
452
+ result.require_success(label="fluxloop smoke")
453
+ """
454
+ ).strip() + "\n"
455
+
456
+
432
457
  def create_sample_agent() -> str:
433
458
  """Create a sample agent implementation."""
434
459
 
@@ -0,0 +1,24 @@
1
+ """
2
+ Testing utilities interface definitions for FluxLoop Pytest bridge.
3
+ """
4
+
5
+ from .types import (
6
+ DEFAULT_SCENARIOS,
7
+ FluxLoopRunnerMode,
8
+ FluxLoopRunnerRequest,
9
+ FluxLoopRunnerOverrides,
10
+ FluxLoopTestError,
11
+ FluxLoopTestResult,
12
+ FluxLoopTestScenario,
13
+ )
14
+
15
+ __all__ = [
16
+ "DEFAULT_SCENARIOS",
17
+ "FluxLoopRunnerMode",
18
+ "FluxLoopRunnerOverrides",
19
+ "FluxLoopRunnerRequest",
20
+ "FluxLoopTestError",
21
+ "FluxLoopTestResult",
22
+ "FluxLoopTestScenario",
23
+ ]
24
+
@@ -0,0 +1,421 @@
1
+ """
2
+ Pytest fixtures for executing FluxLoop experiments (Week 2/4 deliverables).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import contextlib
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import uuid
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Mapping, MutableMapping, Optional, Sequence, Union
16
+
17
+ import pytest
18
+ import yaml
19
+
20
+ from fluxloop_cli.config_loader import load_experiment_config, merge_config_overrides
21
+ from fluxloop_cli.runner import ExperimentRunner
22
+
23
+ from .types import (
24
+ FluxLoopRunnerRequest,
25
+ FluxLoopRunnerOverrides,
26
+ FluxLoopTestResult,
27
+ )
28
+
29
+ PathLike = Union[str, Path]
30
+ RunnerCallable = Callable[..., FluxLoopTestResult]
31
+ WORKSPACE_DIR_NAME = ".fluxloop_pytest"
32
+
33
+
34
+ def _build_request(
35
+ *,
36
+ project_root: PathLike,
37
+ simulation_config: Optional[PathLike],
38
+ overrides: Optional[Mapping[str, Any]],
39
+ mode: str,
40
+ timeout: Optional[int],
41
+ env: Optional[Mapping[str, str]],
42
+ ) -> FluxLoopRunnerRequest:
43
+ request = FluxLoopRunnerRequest(
44
+ project_root=Path(project_root),
45
+ simulation_config=Path(simulation_config) if simulation_config else None,
46
+ mode=mode,
47
+ )
48
+ if timeout is not None:
49
+ request.timeout_seconds = timeout
50
+ if overrides:
51
+ request.overrides.merge(overrides)
52
+ if env:
53
+ request.extra_env.update({k: str(v) for k, v in env.items()})
54
+ request.resolve_paths()
55
+ return request
56
+
57
+
58
+ def _determine_config_path(request: FluxLoopRunnerRequest) -> Path:
59
+ if request.simulation_config and request.simulation_config.exists():
60
+ return request.simulation_config
61
+
62
+ candidates: Sequence[Path] = (
63
+ request.project_root / "configs",
64
+ request.project_root / "setting.yaml",
65
+ )
66
+ for candidate in candidates:
67
+ if candidate.exists():
68
+ return candidate
69
+ raise FileNotFoundError(
70
+ f"Could not locate configs/ or setting.yaml under {request.project_root}"
71
+ )
72
+
73
+
74
+ @contextlib.contextmanager
75
+ def _patched_environ(values: Mapping[str, str]):
76
+ previous: MutableMapping[str, Optional[str]] = {}
77
+ try:
78
+ for key, value in values.items():
79
+ previous[key] = os.environ.get(key)
80
+ os.environ[key] = value
81
+ yield
82
+ finally:
83
+ for key, old_value in previous.items():
84
+ if old_value is None:
85
+ os.environ.pop(key, None)
86
+ else:
87
+ os.environ[key] = old_value
88
+
89
+
90
+ def _build_result_payload(runner: ExperimentRunner) -> dict[str, Any]:
91
+ results = runner.results
92
+ payload = {
93
+ "results": {
94
+ "total_runs": results.get("total_runs", 0),
95
+ "failed": results.get("failed", 0),
96
+ "success_rate": results.get("success_rate", 0.0),
97
+ "avg_duration_ms": results.get("avg_duration_ms", 0.0),
98
+ },
99
+ "errors": results.get("errors", []),
100
+ "output_dir": str(runner.output_dir),
101
+ }
102
+ return payload
103
+
104
+
105
+ def _build_result_object(runner: ExperimentRunner) -> FluxLoopTestResult:
106
+ payload = _build_result_payload(runner)
107
+ trace_summary_path = runner.output_dir / "trace_summary.jsonl"
108
+ if not trace_summary_path.exists():
109
+ trace_summary_path = None
110
+ per_trace_path = runner.output_dir / "per_trace_analysis" / "per_trace.jsonl"
111
+ if not per_trace_path.exists():
112
+ per_trace_path = None
113
+ return FluxLoopTestResult.from_summary(
114
+ payload,
115
+ trace_summary_path=trace_summary_path,
116
+ per_trace_path=per_trace_path,
117
+ )
118
+
119
+
120
+ def _run_with_timeout(
121
+ runner: ExperimentRunner,
122
+ *,
123
+ timeout: Optional[int],
124
+ ) -> None:
125
+ async def _execute():
126
+ coroutine = runner.run_experiment()
127
+ if timeout is not None:
128
+ return await asyncio.wait_for(coroutine, timeout=timeout)
129
+ return await coroutine
130
+
131
+ try:
132
+ asyncio.run(_execute())
133
+ except asyncio.TimeoutError as exc:
134
+ raise AssertionError(
135
+ f"FluxLoop experiment exceeded timeout ({timeout}s)"
136
+ ) from exc
137
+
138
+
139
+ def _load_config_with_overrides(
140
+ request: FluxLoopRunnerRequest,
141
+ *,
142
+ require_inputs_file: bool,
143
+ ):
144
+ config_path = _determine_config_path(request)
145
+ config = load_experiment_config(
146
+ config_path,
147
+ require_inputs_file=require_inputs_file,
148
+ )
149
+ overrides = request.overrides.normalized()
150
+ if overrides:
151
+ source_dir = config.get_source_dir()
152
+ config = merge_config_overrides(config, overrides)
153
+ if source_dir:
154
+ config.set_source_dir(source_dir)
155
+ return config
156
+
157
+
158
+ def _run_via_sdk(
159
+ request: FluxLoopRunnerRequest,
160
+ *,
161
+ require_inputs_file: bool,
162
+ ) -> FluxLoopTestResult:
163
+ config = _load_config_with_overrides(
164
+ request,
165
+ require_inputs_file=require_inputs_file,
166
+ )
167
+
168
+ runner = ExperimentRunner(config)
169
+
170
+ with _patched_environ(request.extra_env):
171
+ _run_with_timeout(runner, timeout=request.timeout_seconds)
172
+
173
+ return _build_result_object(runner)
174
+
175
+
176
+ def _workspace_dir(project_root: Path) -> Path:
177
+ workspace = project_root / WORKSPACE_DIR_NAME
178
+ workspace.mkdir(exist_ok=True)
179
+ return workspace
180
+
181
+
182
+ def _abs_path(value: Optional[str], base: Path) -> Optional[str]:
183
+ if not value:
184
+ return value
185
+ path = Path(value)
186
+ if path.is_absolute():
187
+ return str(path)
188
+ return str((base / path).resolve())
189
+
190
+
191
+ def _config_to_python_dict(config) -> dict[str, Any]:
192
+ if hasattr(config, "model_dump"):
193
+ return config.model_dump(mode="json") # type: ignore[call-arg]
194
+ return config.to_dict()
195
+
196
+
197
+ def _materialize_cli_config(
198
+ config,
199
+ request: FluxLoopRunnerRequest,
200
+ workspace: Path,
201
+ ) -> tuple[Path, Path]:
202
+ source_dir = config.get_source_dir() or request.project_root
203
+ config_dict = _config_to_python_dict(config)
204
+ outputs_base = (workspace / "outputs").resolve()
205
+ outputs_base.mkdir(exist_ok=True)
206
+ config_dict["output_directory"] = str(outputs_base)
207
+
208
+ if config_dict.get("inputs_file"):
209
+ config_dict["inputs_file"] = _abs_path(config_dict["inputs_file"], source_dir)
210
+
211
+ runner_dict = config_dict.get("runner") or {}
212
+ if runner_dict.get("working_directory"):
213
+ runner_dict["working_directory"] = _abs_path(
214
+ runner_dict["working_directory"], source_dir
215
+ )
216
+ python_paths = runner_dict.get("python_path") or []
217
+ runner_dict["python_path"] = [
218
+ _abs_path(entry, source_dir) for entry in python_paths if entry
219
+ ]
220
+ config_dict["runner"] = runner_dict
221
+
222
+ replay_args = config_dict.get("replay_args") or {}
223
+ if replay_args.get("recording_file"):
224
+ replay_args["recording_file"] = _abs_path(replay_args["recording_file"], source_dir)
225
+ config_dict["replay_args"] = replay_args
226
+
227
+ config_path = request.project_root / f".cli_config_{uuid.uuid4().hex}.yaml"
228
+ with open(config_path, "w", encoding="utf-8") as handle:
229
+ yaml.safe_dump(config_dict, handle, sort_keys=False)
230
+ return config_path, outputs_base
231
+
232
+
233
+ def _update_pythonpath(env: MutableMapping[str, str], project_root: Path) -> None:
234
+ existing = env.get("PYTHONPATH")
235
+ entries = [str(project_root)]
236
+ if existing:
237
+ entries.append(existing)
238
+ env["PYTHONPATH"] = os.pathsep.join(entries)
239
+
240
+
241
+ def _append_log(path: Path, content: str) -> None:
242
+ with open(path, "a", encoding="utf-8") as handle:
243
+ handle.write(content)
244
+ if not content.endswith("\n"):
245
+ handle.write("\n")
246
+
247
+
248
+ def _invoke_cli_command(
249
+ args: list[str],
250
+ *,
251
+ env: Mapping[str, str],
252
+ cwd: Path,
253
+ timeout: Optional[int],
254
+ stdout_log: Path,
255
+ stderr_log: Path,
256
+ ) -> subprocess.CompletedProcess[str]:
257
+ proc = subprocess.run(
258
+ [sys.executable, "-m", "fluxloop_cli.main", *args],
259
+ text=True,
260
+ capture_output=True,
261
+ cwd=str(cwd),
262
+ env=dict(env),
263
+ timeout=timeout,
264
+ )
265
+ _append_log(stdout_log, f"$ fluxloop {' '.join(args)}")
266
+ _append_log(stdout_log, proc.stdout)
267
+ _append_log(stderr_log, proc.stderr)
268
+ return proc
269
+
270
+
271
+ def _latest_experiment_dir(outputs_base: Path) -> Path:
272
+ candidates = [p for p in outputs_base.iterdir() if p.is_dir()]
273
+ if not candidates:
274
+ raise AssertionError(f"No experiment directories found under {outputs_base}")
275
+ return max(candidates, key=lambda p: p.stat().st_mtime)
276
+
277
+
278
+ def _run_via_cli(
279
+ request: FluxLoopRunnerRequest,
280
+ *,
281
+ require_inputs_file: bool,
282
+ ) -> FluxLoopTestResult:
283
+ config = _load_config_with_overrides(
284
+ request,
285
+ require_inputs_file=require_inputs_file,
286
+ )
287
+ workspace = _workspace_dir(request.project_root)
288
+ logs_dir = workspace / "logs"
289
+ logs_dir.mkdir(exist_ok=True)
290
+
291
+ run_id = uuid.uuid4().hex
292
+ stdout_log = logs_dir / f"{run_id}.stdout.log"
293
+ stderr_log = logs_dir / f"{run_id}.stderr.log"
294
+
295
+ config_path, outputs_base = _materialize_cli_config(config, request, workspace)
296
+
297
+ env = os.environ.copy()
298
+ env.update({k: v for k, v in request.extra_env.items()})
299
+ _update_pythonpath(env, request.project_root)
300
+
301
+ try:
302
+ run_args = [
303
+ "run",
304
+ "experiment",
305
+ "--config",
306
+ str(config_path),
307
+ "--yes",
308
+ "--no-display",
309
+ "--no-collector",
310
+ ]
311
+ run_proc = _invoke_cli_command(
312
+ run_args,
313
+ env=env,
314
+ cwd=request.project_root,
315
+ timeout=request.timeout_seconds,
316
+ stdout_log=stdout_log,
317
+ stderr_log=stderr_log,
318
+ )
319
+ if run_proc.returncode != 0:
320
+ raise AssertionError(
321
+ f"fluxloop run experiment failed (see {stderr_log})"
322
+ )
323
+
324
+ experiment_dir = _latest_experiment_dir(outputs_base)
325
+
326
+ parse_args = [
327
+ "parse",
328
+ "experiment",
329
+ str(experiment_dir),
330
+ "--overwrite",
331
+ ]
332
+ parse_proc = _invoke_cli_command(
333
+ parse_args,
334
+ env=env,
335
+ cwd=request.project_root,
336
+ timeout=request.timeout_seconds,
337
+ stdout_log=stdout_log,
338
+ stderr_log=stderr_log,
339
+ )
340
+ if parse_proc.returncode != 0:
341
+ raise AssertionError(
342
+ f"fluxloop parse experiment failed (see {stderr_log})"
343
+ )
344
+
345
+ summary_path = experiment_dir / "summary.json"
346
+ if not summary_path.exists():
347
+ raise AssertionError(f"summary.json missing under {experiment_dir}")
348
+ summary_payload = json.loads(summary_path.read_text(encoding="utf-8"))
349
+ summary_payload["output_dir"] = str(experiment_dir)
350
+
351
+ trace_summary_path = experiment_dir / "trace_summary.jsonl"
352
+ if not trace_summary_path.exists():
353
+ trace_summary_path = None
354
+
355
+ per_trace_path = experiment_dir / "per_trace_analysis" / "per_trace.jsonl"
356
+ if not per_trace_path.exists():
357
+ per_trace_path = None
358
+
359
+ cli_command = f"fluxloop {' '.join(run_args)}"
360
+
361
+ return FluxLoopTestResult.from_summary(
362
+ summary_payload,
363
+ trace_summary_path=trace_summary_path,
364
+ per_trace_path=per_trace_path,
365
+ stdout_path=stdout_log,
366
+ stderr_path=stderr_log,
367
+ cli_command=cli_command,
368
+ )
369
+ finally:
370
+ with contextlib.suppress(FileNotFoundError):
371
+ config_path.unlink()
372
+
373
+
374
+ def _build_runner_fixture(mode: str) -> RunnerCallable:
375
+ def _runner(
376
+ *,
377
+ project_root: PathLike,
378
+ simulation_config: Optional[PathLike] = None,
379
+ overrides: Optional[Mapping[str, Any]] = None,
380
+ timeout: Optional[int] = None,
381
+ env: Optional[Mapping[str, str]] = None,
382
+ require_inputs_file: bool = True,
383
+ ) -> FluxLoopTestResult:
384
+ request = _build_request(
385
+ project_root=project_root,
386
+ simulation_config=simulation_config,
387
+ overrides=overrides,
388
+ mode=mode,
389
+ timeout=timeout,
390
+ env=env,
391
+ )
392
+ if mode == "cli":
393
+ return _run_via_cli(
394
+ request,
395
+ require_inputs_file=require_inputs_file,
396
+ )
397
+ return _run_via_sdk(
398
+ request,
399
+ require_inputs_file=require_inputs_file,
400
+ )
401
+
402
+ return _runner
403
+
404
+
405
+ @pytest.fixture
406
+ def fluxloop_runner() -> RunnerCallable:
407
+ """
408
+ Execute experiments via the SDK path (default fixture).
409
+ """
410
+
411
+ return _build_runner_fixture("sdk")
412
+
413
+
414
+ @pytest.fixture
415
+ def fluxloop_cli() -> RunnerCallable:
416
+ """
417
+ Execute experiments via the CLI path (subprocess parity).
418
+ """
419
+
420
+ return _build_runner_fixture("cli")
421
+
@@ -0,0 +1,188 @@
1
+ """
2
+ Core data structures for the Pytest bridge (Week 1 deliverables).
3
+
4
+ These definitions capture the request/result contract so fixtures can be
5
+ implemented later without changing user-facing APIs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any, Dict, Mapping, MutableMapping, Optional, Sequence
13
+
14
+
15
+ FluxLoopRunnerMode = str # Literal["sdk", "cli"] 예정, but keep flexible for now
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class FluxLoopRunnerOverrides:
20
+ """User supplied key-path → value overrides for ExperimentConfig."""
21
+
22
+ values: MutableMapping[str, Any] = field(default_factory=dict)
23
+
24
+ def normalized(self) -> Dict[str, Any]:
25
+ """Return a shallow copy for downstream mutation safety."""
26
+ return dict(self.values)
27
+
28
+ def merge(self, *overrides: Mapping[str, Any]) -> "FluxLoopRunnerOverrides":
29
+ merged = self.normalized()
30
+ for item in overrides:
31
+ merged.update(item)
32
+ self.values = merged
33
+ return self
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class FluxLoopRunnerRequest:
38
+ """
39
+ Normalized request payload consumed by the future pytest fixtures.
40
+
41
+ `project_root` is required so we can locate configs/ and .env files.
42
+ """
43
+
44
+ project_root: Path
45
+ simulation_config: Optional[Path] = None
46
+ mode: FluxLoopRunnerMode = "sdk"
47
+ overrides: FluxLoopRunnerOverrides = field(default_factory=FluxLoopRunnerOverrides)
48
+ timeout_seconds: Optional[int] = 600
49
+ extra_env: MutableMapping[str, str] = field(default_factory=dict)
50
+
51
+ def resolve_paths(self) -> None:
52
+ """
53
+ Ensure all paths are absolute so fixtures can run from any cwd.
54
+ """
55
+ self.project_root = self.project_root.expanduser().resolve()
56
+ if self.simulation_config is not None:
57
+ config_path = self.simulation_config
58
+ if not config_path.is_absolute():
59
+ config_path = self.project_root / config_path
60
+ self.simulation_config = config_path.expanduser().resolve()
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class FluxLoopTestError:
65
+ """Represents a single failure entry from the experiment summary."""
66
+
67
+ iteration: Optional[int]
68
+ persona: Optional[str]
69
+ input_text: Optional[str]
70
+ message: str
71
+
72
+ @classmethod
73
+ def from_dict(cls, payload: Mapping[str, Any]) -> "FluxLoopTestError":
74
+ return cls(
75
+ iteration=payload.get("iteration"),
76
+ persona=payload.get("persona"),
77
+ input_text=payload.get("input"),
78
+ message=str(payload.get("error", "")),
79
+ )
80
+
81
+
82
+ @dataclass(slots=True)
83
+ class FluxLoopTestResult:
84
+ """Aggregate summary emitted back to pytest test functions."""
85
+
86
+ total_runs: int
87
+ failed_runs: int
88
+ success_rate: float
89
+ avg_duration_ms: float
90
+ output_dir: Path
91
+ summary_path: Path
92
+ trace_summary_path: Optional[Path]
93
+ per_trace_path: Optional[Path]
94
+ stdout_path: Optional[Path] = None
95
+ stderr_path: Optional[Path] = None
96
+ cli_command: Optional[str] = None
97
+ errors: Sequence[FluxLoopTestError] = field(default_factory=tuple)
98
+
99
+ @property
100
+ def succeeded(self) -> bool:
101
+ return self.failed_runs == 0
102
+
103
+ def require_success(self, *, label: Optional[str] = None) -> None:
104
+ if self.succeeded:
105
+ return
106
+ reason = label or "FluxLoop experiment"
107
+ details = "; ".join(err.message for err in self.errors[:3]) or "unknown failure"
108
+ raise AssertionError(f"{reason} failed ({self.failed_runs} runs): {details}")
109
+
110
+ @classmethod
111
+ def from_summary(
112
+ cls,
113
+ summary: Mapping[str, Any],
114
+ *,
115
+ trace_summary_path: Optional[Path] = None,
116
+ per_trace_path: Optional[Path] = None,
117
+ stdout_path: Optional[Path] = None,
118
+ stderr_path: Optional[Path] = None,
119
+ cli_command: Optional[str] = None,
120
+ ) -> "FluxLoopTestResult":
121
+ results = summary.get("results", {})
122
+ errors = [FluxLoopTestError.from_dict(raw) for raw in summary.get("errors", [])]
123
+ output_dir = Path(summary.get("output_dir", summary.get("name", "experiments"))).expanduser()
124
+ summary_path = output_dir / "summary.json"
125
+ return cls(
126
+ total_runs=int(results.get("total_runs", 0)),
127
+ failed_runs=int(results.get("failed", 0)),
128
+ success_rate=float(results.get("success_rate", 0.0)),
129
+ avg_duration_ms=float(results.get("avg_duration_ms", 0.0)),
130
+ output_dir=output_dir,
131
+ summary_path=summary_path,
132
+ trace_summary_path=trace_summary_path,
133
+ per_trace_path=per_trace_path,
134
+ stdout_path=stdout_path,
135
+ stderr_path=stderr_path,
136
+ cli_command=cli_command,
137
+ errors=tuple(errors),
138
+ )
139
+
140
+
141
+ @dataclass(slots=True)
142
+ class FluxLoopTestScenario:
143
+ """Design-time catalog of QA scenarios the fixtures must support."""
144
+
145
+ name: str
146
+ description: str
147
+ tags: Sequence[str] = field(default_factory=tuple)
148
+
149
+
150
+ DEFAULT_SCENARIOS: Sequence[FluxLoopTestScenario] = (
151
+ FluxLoopTestScenario(
152
+ name="single-turn-sync",
153
+ description="Basic python function target without multi-turn supervisor.",
154
+ tags=("sdk", "happy-path"),
155
+ ),
156
+ FluxLoopTestScenario(
157
+ name="multi-turn-supervisor",
158
+ description="ConversationSupervisor enabled with scripted user turns.",
159
+ tags=("sdk", "multi-turn"),
160
+ ),
161
+ FluxLoopTestScenario(
162
+ name="subprocess-jsonl",
163
+ description="Runner configured via subprocess JSONL stream to validate CLI parity.",
164
+ tags=("cli", "subprocess"),
165
+ ),
166
+ FluxLoopTestScenario(
167
+ name="mcp-adapter",
168
+ description="MCP server target requiring environment injection and guard handling.",
169
+ tags=("sdk", "mcp"),
170
+ ),
171
+ FluxLoopTestScenario(
172
+ name="failure-capture",
173
+ description="Intentional failure to verify error surfaces and pytest assertion formatting.",
174
+ tags=("sdk", "negative"),
175
+ ),
176
+ )
177
+
178
+
179
+ __all__ = [
180
+ "FluxLoopRunnerMode",
181
+ "FluxLoopRunnerOverrides",
182
+ "FluxLoopRunnerRequest",
183
+ "FluxLoopTestError",
184
+ "FluxLoopTestResult",
185
+ "FluxLoopTestScenario",
186
+ "DEFAULT_SCENARIOS",
187
+ ]
188
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fluxloop-cli
3
- Version: 0.2.28
3
+ Version: 0.2.30
4
4
  Summary: FluxLoop CLI for running agent simulations
5
5
  Author-email: FluxLoop Team <team@fluxloop.dev>
6
6
  License: Apache-2.0
@@ -71,6 +71,7 @@ The legacy `setting.yaml` is still supported, but new projects created with
71
71
  - `fluxloop config set-llm` – update LLM provider/model in `configs/input.yaml`
72
72
  - `fluxloop record enable|disable|status` – toggle recording mode across `.env` and simulation config
73
73
  - `fluxloop doctor` – summarize Python, FluxLoop CLI/MCP, and MCP index state for the active environment
74
+ - `--yes/-y` (for `fluxloop run experiment`) – skip the interactive confirmation prompt, ideal for CI and the Pytest bridge
74
75
 
75
76
  ### Multi-turn supervisor options
76
77
 
@@ -88,6 +89,16 @@ These flags override the values in `configs/simulation.yaml` (`multi_turn` block
88
89
 
89
90
  Run `fluxloop --help` or `fluxloop <command> --help` for more detail.
90
91
 
92
+ ## Pytest Bridge (0.2.29+)
93
+
94
+ - `fluxloop init pytest-template [project_root]` creates `tests/test_fluxloop_smoke.py`, already wired to the new `fluxloop_runner` fixture.
95
+ - Fixtures live in `fluxloop_cli.testing.pytest_plugin` and return a `FluxLoopTestResult`, so you can assert on `total_runs`, `success_rate`, or call `require_success()`.
96
+ - Full guide + CI example: see `docs/guides/pytest_bridge.md` (includes GitHub Actions workflow at `examples/ci/fluxloop_pytest.yml`).
97
+ - Typical workflow:
98
+ 1. `pip install -e packages/cli[dev]`
99
+ 2. `fluxloop init pytest-template .`
100
+ 3. `pytest -k fluxloop_smoke --maxfail=1`
101
+
91
102
  ## Evaluation Workflow
92
103
 
93
104
  Evaluation now follows a two-step process so that multi-turn context is preserved:
@@ -1,4 +1,4 @@
1
- fluxloop_cli/__init__.py,sha256=Yy0ysK8xslb79WaB0apu48ZBwviey964qWc5uDat8Mc,143
1
+ fluxloop_cli/__init__.py,sha256=9mSy8Uew5CRCHwCbTK63mr3OmKigybmLBSHEHuVz_1U,143
2
2
  fluxloop_cli/arg_binder.py,sha256=6-i81kK6kmR8inNvIp0GeGY51M5Jere-hAdignMl2bE,12765
3
3
  fluxloop_cli/config_loader.py,sha256=PYy0CfGVbU8jpPbx4sJzOu7i3BbrkQMNaRiSOp_uX9g,10307
4
4
  fluxloop_cli/config_schema.py,sha256=JZJRcMFun5hp3vKLAyek7W3NvISyzRzZt0BZAeSU38I,2415
@@ -11,17 +11,17 @@ fluxloop_cli/main.py,sha256=apLSNIgXv-V269ttKvd-t5za2UUjePswmR5KZGhOLPE,2772
11
11
  fluxloop_cli/project_paths.py,sha256=FoHp-g3aY1nytxGys85Oy3wJ6gmiKU6FVOwkgTtlHNA,4128
12
12
  fluxloop_cli/runner.py,sha256=ee-ue9wmN7X3VKuIk1Sg3z_vYiKwypWkKxFsTccH6rA,50199
13
13
  fluxloop_cli/target_loader.py,sha256=ACCu2izqGKoOrEiNnAajH0FgLZcw3j1pWn5rAhEuWFU,5528
14
- fluxloop_cli/templates.py,sha256=SvJrz0xqZ7VxkXnQ4FgffiClLuno3tMAG5qNJF4SwKc,19277
14
+ fluxloop_cli/templates.py,sha256=880cum_f8J40gwQiR7HbmQuqWgUXBTmji5EsaCjQDm0,20070
15
15
  fluxloop_cli/validators.py,sha256=_bLXmxUSzVrDtLjqyTba0bDqamRIaOUHhV4xZ7K36Xw,1155
16
16
  fluxloop_cli/commands/__init__.py,sha256=i8xTg7uE9B6ExA0EP_j2h9DZWhD-eC94a-7khXIzRwo,243
17
17
  fluxloop_cli/commands/config.py,sha256=9moP68pcNBnJYGMcptgdU1UW-6WlI-Nan6eykPdwl8I,12121
18
18
  fluxloop_cli/commands/doctor.py,sha256=svoz9wbLv5Byj9G1Y9GI5lYpVdoG2e7IzfG7tZxuMK0,8184
19
19
  fluxloop_cli/commands/evaluate.py,sha256=itxb3TyfqV44DaCczC3oC94cJ2rmz2cIpY-794eXhvQ,7229
20
20
  fluxloop_cli/commands/generate.py,sha256=6tBanI8gj5Zzwz2cv1YeW60bYiO7iWivD0AgOcJ-GUU,7172
21
- fluxloop_cli/commands/init.py,sha256=eugHrsOTaXQmX5jxRNGSO3C3SpRVI5BCSqP9GqmGHPQ,8156
21
+ fluxloop_cli/commands/init.py,sha256=1KUdOs9HqwgBZB94YyKjn95TrK3-t4cmBAUAMA6uwhc,10530
22
22
  fluxloop_cli/commands/parse.py,sha256=5dgoHt6x2h7SBf6AOlbafoIWz3psEBrJdZyErIvgSgw,15417
23
23
  fluxloop_cli/commands/record.py,sha256=IfoXdFAentyerBrIdQeweYr_kM7DjGks2aoKjjqCZ9Q,5139
24
- fluxloop_cli/commands/run.py,sha256=OMT9XrDBDZACyQ_8tlUiqSIRq6QROH25Uc1PHZoEHyU,16849
24
+ fluxloop_cli/commands/run.py,sha256=Mw5f6OdunjaOtaZAZoT9Ct1AL8FiBksqh2S3cz8_cCk,16431
25
25
  fluxloop_cli/commands/status.py,sha256=ERZrWoSP3V7dz5A_TEE5b8E0nGwsPggP4nXw4tLOzxE,7841
26
26
  fluxloop_cli/evaluation/__init__.py,sha256=voMYkA4S5Gb3GyvpTU8JnKu0novOyZBEag1cYEqYi1Q,759
27
27
  fluxloop_cli/evaluation/artifacts.py,sha256=zDVB-SSQZ1JYWLPHwrfz5AuCv46pUFZfEfFY_5doAjQ,2176
@@ -41,8 +41,11 @@ fluxloop_cli/evaluation/prompts/information_completeness.py,sha256=BjZ-wEIANWWBQ
41
41
  fluxloop_cli/evaluation/prompts/intent_recognition.py,sha256=QlroUq1y0wwWsT2qcS1NXdo7OwNqUu4Tw2xx7Frr50Q,1572
42
42
  fluxloop_cli/evaluation/prompts/response_clarity.py,sha256=1OYyx39UfS-YR__VdqhLejHNxH1NBVFm7Bha3MuMN9Y,1418
43
43
  fluxloop_cli/evaluation/prompts/response_consistency.py,sha256=tyO3Wsf2J_CvErCuNc2bw-UQWqRW_BupaBj3CS66_Yk,1550
44
- fluxloop_cli-0.2.28.dist-info/METADATA,sha256=Vs5b3XsEL5irrC2_8DzahTy1bfD-O3JznpzTqB5hxsY,6329
45
- fluxloop_cli-0.2.28.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
46
- fluxloop_cli-0.2.28.dist-info/entry_points.txt,sha256=NxOEMku4yLMY5kp_Qcd3JcevfXP6A98FsSf9xHcwkyE,51
47
- fluxloop_cli-0.2.28.dist-info/top_level.txt,sha256=ahLkaxzwhmVU4z-YhkmQVzAbW3-wez9cKnwPiDK7uKM,13
48
- fluxloop_cli-0.2.28.dist-info/RECORD,,
44
+ fluxloop_cli/testing/__init__.py,sha256=3FZ0JzA6gbeDAOlvplEiNDplbOXmNT-TYQ-4C7eHUlo,482
45
+ fluxloop_cli/testing/pytest_plugin.py,sha256=j9fAlh_uL_yRcXPPcx1AcvnxpKfUdfPnEf5ozJOQ7aA,12533
46
+ fluxloop_cli/testing/types.py,sha256=CcpoSnV-eOBc_Ngvg6ZRpeb-Bnz4eT2zBdGz0-icVB4,6190
47
+ fluxloop_cli-0.2.30.dist-info/METADATA,sha256=gp3keZepVZ1jT9xQTBG3KpyIsFfdKTCTdfmNi6mCK80,7074
48
+ fluxloop_cli-0.2.30.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
49
+ fluxloop_cli-0.2.30.dist-info/entry_points.txt,sha256=NxOEMku4yLMY5kp_Qcd3JcevfXP6A98FsSf9xHcwkyE,51
50
+ fluxloop_cli-0.2.30.dist-info/top_level.txt,sha256=ahLkaxzwhmVU4z-YhkmQVzAbW3-wez9cKnwPiDK7uKM,13
51
+ fluxloop_cli-0.2.30.dist-info/RECORD,,