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.
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/PKG-INFO +12 -1
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/README.md +11 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/__init__.py +1 -1
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/init.py +68 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/run.py +11 -1
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/templates.py +25 -0
- fluxloop_cli-0.2.30/fluxloop_cli/testing/__init__.py +24 -0
- fluxloop_cli-0.2.30/fluxloop_cli/testing/pytest_plugin.py +421 -0
- fluxloop_cli-0.2.30/fluxloop_cli/testing/types.py +188 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/PKG-INFO +12 -1
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/SOURCES.txt +7 -1
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/pyproject.toml +1 -1
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_evaluation_llm.py +1 -1
- fluxloop_cli-0.2.30/tests/test_init_pytest_template.py +62 -0
- fluxloop_cli-0.2.30/tests/test_pytest_plugin.py +103 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_run_command.py +2 -4
- fluxloop_cli-0.2.30/tests/test_testing_types.py +66 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/arg_binder.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/__init__.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/config.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/doctor.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/evaluate.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/generate.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/parse.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/record.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/status.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/config_loader.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/config_schema.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/constants.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/conversation_supervisor.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/environment.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/__init__.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/artifacts.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/config.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/__init__.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/analysis.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/core.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/reporting/html.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/success.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/llm.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/__init__.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/base.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/rules.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/input_generator.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/llm_generator.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/main.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/project_paths.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/runner.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/target_loader.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli/validators.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/entry_points.txt +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/requires.txt +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/top_level.txt +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/setup.cfg +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_analysis_recommendations.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_arg_binder.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_config_command.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_conversation_supervisor.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_evaluate_command.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_input_generator.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_prompt_library.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_runner_multi_turn.py +0 -0
- {fluxloop_cli-0.2.29 → fluxloop_cli-0.2.30}/tests/test_success_criteria.py +0 -0
- {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.
|
|
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:
|
|
@@ -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
|
|
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
|
+
|