fluxloop-cli 0.2.29__tar.gz → 0.2.30__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 (71) hide show
  1. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/PKG-INFO +12 -1
  2. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/README.md +11 -0
  3. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/__init__.py +1 -1
  4. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/init.py +68 -0
  5. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/run.py +11 -1
  6. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/templates.py +25 -0
  7. fluxloop_cli-0.2.30/fluxloop_cli/testing/__init__.py +24 -0
  8. fluxloop_cli-0.2.30/fluxloop_cli/testing/pytest_plugin.py +421 -0
  9. fluxloop_cli-0.2.30/fluxloop_cli/testing/types.py +188 -0
  10. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/PKG-INFO +12 -1
  11. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/SOURCES.txt +7 -1
  12. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/pyproject.toml +1 -1
  13. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_evaluation_llm.py +1 -1
  14. fluxloop_cli-0.2.30/tests/test_init_pytest_template.py +62 -0
  15. fluxloop_cli-0.2.30/tests/test_pytest_plugin.py +103 -0
  16. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_run_command.py +2 -4
  17. fluxloop_cli-0.2.30/tests/test_testing_types.py +66 -0
  18. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/arg_binder.py +0 -0
  19. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/__init__.py +0 -0
  20. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/config.py +0 -0
  21. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/doctor.py +0 -0
  22. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/evaluate.py +0 -0
  23. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/generate.py +0 -0
  24. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/parse.py +0 -0
  25. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/record.py +0 -0
  26. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/status.py +0 -0
  27. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/config_loader.py +0 -0
  28. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/config_schema.py +0 -0
  29. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/constants.py +0 -0
  30. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/conversation_supervisor.py +0 -0
  31. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/environment.py +0 -0
  32. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/__init__.py +0 -0
  33. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/artifacts.py +0 -0
  34. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/config.py +0 -0
  35. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/__init__.py +0 -0
  36. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/analysis.py +0 -0
  37. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/core.py +0 -0
  38. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -0
  39. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/reporting/html.py +0 -0
  40. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -0
  41. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/success.py +0 -0
  42. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/llm.py +0 -0
  43. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/__init__.py +0 -0
  44. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/base.py +0 -0
  45. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -0
  46. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -0
  47. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -0
  48. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -0
  49. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/rules.py +0 -0
  50. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/input_generator.py +0 -0
  51. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/llm_generator.py +0 -0
  52. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/main.py +0 -0
  53. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/project_paths.py +0 -0
  54. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/runner.py +0 -0
  55. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/target_loader.py +0 -0
  56. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/validators.py +0 -0
  57. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
  58. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/entry_points.txt +0 -0
  59. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/requires.txt +0 -0
  60. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/top_level.txt +0 -0
  61. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/setup.cfg +0 -0
  62. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_analysis_recommendations.py +0 -0
  63. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_arg_binder.py +0 -0
  64. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_config_command.py +0 -0
  65. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_conversation_supervisor.py +0 -0
  66. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_evaluate_command.py +0 -0
  67. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_input_generator.py +0 -0
  68. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_prompt_library.py +0 -0
  69. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_runner_multi_turn.py +0 -0
  70. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_success_criteria.py +0 -0
  71. {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_target_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fluxloop-cli
3
- Version: 0.2.29
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:
@@ -30,6 +30,7 @@ The legacy `setting.yaml` is still supported, but new projects created with
30
30
  - `fluxloop config set-llm` – update LLM provider/model in `configs/input.yaml`
31
31
  - `fluxloop record enable|disable|status` – toggle recording mode across `.env` and simulation config
32
32
  - `fluxloop doctor` – summarize Python, FluxLoop CLI/MCP, and MCP index state for the active environment
33
+ - `--yes/-y` (for `fluxloop run experiment`) – skip the interactive confirmation prompt, ideal for CI and the Pytest bridge
33
34
 
34
35
  ### Multi-turn supervisor options
35
36
 
@@ -47,6 +48,16 @@ These flags override the values in `configs/simulation.yaml` (`multi_turn` block
47
48
 
48
49
  Run `fluxloop --help` or `fluxloop <command> --help` for more detail.
49
50
 
51
+ ## Pytest Bridge (0.2.29+)
52
+
53
+ - `fluxloop init pytest-template [project_root]` creates `tests/test_fluxloop_smoke.py`, already wired to the new `fluxloop_runner` fixture.
54
+ - 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()`.
55
+ - Full guide + CI example: see `docs/guides/pytest_bridge.md` (includes GitHub Actions workflow at `examples/ci/fluxloop_pytest.yml`).
56
+ - Typical workflow:
57
+ 1. `pip install -e packages/cli[dev]`
58
+ 2. `fluxloop init pytest-template .`
59
+ 3. `pytest -k fluxloop_smoke --maxfail=1`
60
+
50
61
  ## Evaluation Workflow
51
62
 
52
63
  Evaluation now follows a two-step process so that multi-turn context is preserved:
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.2.29"
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(
@@ -118,6 +118,12 @@ def experiment(
118
118
  "--display/--no-display",
119
119
  help="Show rich console output (disable for plain log streaming)",
120
120
  ),
121
+ yes: bool = typer.Option(
122
+ False,
123
+ "--yes",
124
+ "-y",
125
+ help="Skip confirmation prompt and run immediately",
126
+ ),
121
127
  ):
122
128
  """
123
129
  Run an experiment based on configuration file.
@@ -248,7 +254,11 @@ def experiment(
248
254
  f"\nThis will execute {total_runs} runs."
249
255
  )
250
256
 
251
- if not typer.confirm("Continue?"):
257
+ if yes:
258
+ proceed = True
259
+ else:
260
+ proceed = typer.confirm("Continue?")
261
+ if not proceed:
252
262
  raise typer.Abort()
253
263
 
254
264
  # Create runner
@@ -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
+