fluxloop-cli 0.1.0__py3-none-any.whl → 0.2.1__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.
Potentially problematic release.
This version of fluxloop-cli might be problematic. Click here for more details.
- fluxloop_cli/__init__.py +1 -1
- fluxloop_cli/commands/__init__.py +2 -2
- fluxloop_cli/commands/config.py +12 -2
- fluxloop_cli/commands/generate.py +4 -84
- fluxloop_cli/commands/init.py +39 -12
- fluxloop_cli/commands/record.py +150 -0
- fluxloop_cli/commands/run.py +5 -1
- fluxloop_cli/commands/status.py +41 -5
- fluxloop_cli/config_loader.py +103 -14
- fluxloop_cli/config_schema.py +83 -0
- fluxloop_cli/constants.py +15 -0
- fluxloop_cli/main.py +2 -1
- fluxloop_cli/project_paths.py +60 -8
- fluxloop_cli/templates.py +288 -118
- {fluxloop_cli-0.1.0.dist-info → fluxloop_cli-0.2.1.dist-info}/METADATA +31 -27
- fluxloop_cli-0.2.1.dist-info/RECORD +26 -0
- fluxloop_cli-0.1.0.dist-info/RECORD +0 -24
- {fluxloop_cli-0.1.0.dist-info → fluxloop_cli-0.2.1.dist-info}/WHEEL +0 -0
- {fluxloop_cli-0.1.0.dist-info → fluxloop_cli-0.2.1.dist-info}/entry_points.txt +0 -0
- {fluxloop_cli-0.1.0.dist-info → fluxloop_cli-0.2.1.dist-info}/top_level.txt +0 -0
fluxloop_cli/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""CLI commands."""
|
|
2
2
|
|
|
3
|
-
from . import config, generate, init, parse, run, status
|
|
3
|
+
from . import config, generate, init, parse, run, status, record
|
|
4
4
|
|
|
5
|
-
__all__ = ["config", "generate", "init", "parse", "run", "status"]
|
|
5
|
+
__all__ = ["config", "generate", "init", "parse", "run", "status", "record"]
|
fluxloop_cli/commands/config.py
CHANGED
|
@@ -15,6 +15,7 @@ from rich.table import Table
|
|
|
15
15
|
from ..config_loader import load_experiment_config
|
|
16
16
|
from ..templates import create_env_file, create_gitignore, create_sample_agent
|
|
17
17
|
from ..constants import DEFAULT_CONFIG_PATH, DEFAULT_ROOT_DIR_NAME
|
|
18
|
+
from ..config_schema import CONFIG_SECTION_FILENAMES
|
|
18
19
|
from ..project_paths import (
|
|
19
20
|
resolve_config_path,
|
|
20
21
|
resolve_env_path,
|
|
@@ -228,7 +229,12 @@ def set_llm(
|
|
|
228
229
|
api_key: str = typer.Argument(..., help="API key or token for the provider"),
|
|
229
230
|
model: Optional[str] = typer.Option(None, "--model", "-m", help="Default model to use"),
|
|
230
231
|
overwrite_env: bool = typer.Option(False, "--overwrite-env", help="Overwrite existing key in .env"),
|
|
231
|
-
config_file: Path = typer.Option(
|
|
232
|
+
config_file: Path = typer.Option(
|
|
233
|
+
Path(CONFIG_SECTION_FILENAMES["input"]),
|
|
234
|
+
"--file",
|
|
235
|
+
"-f",
|
|
236
|
+
help="Configuration file to update",
|
|
237
|
+
),
|
|
232
238
|
env_file: Path = typer.Option(Path(".env"), "--env-file", help="Path to environment file"),
|
|
233
239
|
project: Optional[str] = typer.Option(None, "--project", help="Project name under the FluxLoop root"),
|
|
234
240
|
root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="FluxLoop root directory"),
|
|
@@ -330,7 +336,11 @@ def validate(
|
|
|
330
336
|
raise typer.Exit(1)
|
|
331
337
|
|
|
332
338
|
try:
|
|
333
|
-
config = load_experiment_config(
|
|
339
|
+
config = load_experiment_config(
|
|
340
|
+
resolved_path,
|
|
341
|
+
project=project,
|
|
342
|
+
root=root,
|
|
343
|
+
)
|
|
334
344
|
|
|
335
345
|
# Show validation results
|
|
336
346
|
console.print("[green]✓[/green] Configuration is valid!\n")
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
"""Generate command for producing input datasets."""
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
import os
|
|
5
4
|
import sys
|
|
6
5
|
from pathlib import Path
|
|
7
|
-
from typing import
|
|
6
|
+
from typing import Dict, List, Optional
|
|
8
7
|
|
|
9
8
|
import typer
|
|
10
9
|
from rich.console import Console
|
|
@@ -93,11 +92,6 @@ def inputs(
|
|
|
93
92
|
"--llm-api-key",
|
|
94
93
|
help="API key for LLM provider (falls back to FLUXLOOP_LLM_API_KEY)",
|
|
95
94
|
),
|
|
96
|
-
from_recording: Optional[Path] = typer.Option(
|
|
97
|
-
None,
|
|
98
|
-
"--from-recording",
|
|
99
|
-
help="Use a recorded call (JSONL) as template for generated inputs",
|
|
100
|
-
),
|
|
101
95
|
):
|
|
102
96
|
"""Generate input variations for review before running experiments."""
|
|
103
97
|
resolved_config = resolve_config_path(config_file, project, root)
|
|
@@ -136,6 +130,8 @@ def inputs(
|
|
|
136
130
|
try:
|
|
137
131
|
config = load_experiment_config(
|
|
138
132
|
resolved_config,
|
|
133
|
+
project=project,
|
|
134
|
+
root=root,
|
|
139
135
|
require_inputs_file=require_inputs_file,
|
|
140
136
|
)
|
|
141
137
|
except Exception as exc:
|
|
@@ -201,20 +197,8 @@ def inputs(
|
|
|
201
197
|
llm_api_key_override=llm_api_key,
|
|
202
198
|
)
|
|
203
199
|
|
|
204
|
-
recording_template = None
|
|
205
|
-
if from_recording:
|
|
206
|
-
try:
|
|
207
|
-
recording_template = _load_recording_template(from_recording, config)
|
|
208
|
-
console.print(
|
|
209
|
-
"📝 Generating inputs from recording"
|
|
210
|
-
f"\n → Base content: [cyan]{recording_template['base_content'][:60]}[/cyan]"
|
|
211
|
-
)
|
|
212
|
-
except Exception as exc:
|
|
213
|
-
console.print(f"[red]Failed to load recording template:[/red] {exc}")
|
|
214
|
-
raise typer.Exit(1)
|
|
215
|
-
|
|
216
200
|
try:
|
|
217
|
-
result = generate_inputs(config, settings
|
|
201
|
+
result = generate_inputs(config, settings)
|
|
218
202
|
except Exception as exc:
|
|
219
203
|
console.print(f"[red]Generation failed:[/red] {exc}")
|
|
220
204
|
if "--debug" in sys.argv:
|
|
@@ -238,67 +222,3 @@ def inputs(
|
|
|
238
222
|
f"\n🧠 Mode: [magenta]{result.metadata.get('generation_mode', 'deterministic')}[/magenta]"
|
|
239
223
|
f"\n🎯 Strategies: [cyan]{', '.join(strategy for strategy in strategies_used)}[/cyan]"
|
|
240
224
|
)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def _load_recording_template(path: Path, config: "ExperimentConfig") -> Dict[str, object]:
|
|
244
|
-
target_path = path
|
|
245
|
-
if not target_path.is_absolute():
|
|
246
|
-
source_dir = config.get_source_dir()
|
|
247
|
-
if source_dir:
|
|
248
|
-
target_path = (source_dir / target_path).resolve()
|
|
249
|
-
else:
|
|
250
|
-
target_path = target_path.resolve()
|
|
251
|
-
|
|
252
|
-
if not target_path.exists():
|
|
253
|
-
raise FileNotFoundError(f"Recording file not found: {target_path}")
|
|
254
|
-
|
|
255
|
-
with target_path.open("r", encoding="utf-8") as handle:
|
|
256
|
-
first_line = handle.readline()
|
|
257
|
-
|
|
258
|
-
if not first_line:
|
|
259
|
-
raise ValueError(f"Recording file is empty: {target_path}")
|
|
260
|
-
|
|
261
|
-
try:
|
|
262
|
-
record = json.loads(first_line)
|
|
263
|
-
except json.JSONDecodeError as exc:
|
|
264
|
-
raise ValueError(f"Invalid JSON in recording file {target_path}: {exc}")
|
|
265
|
-
|
|
266
|
-
kwargs = record.get("kwargs", {})
|
|
267
|
-
base_content = _extract_content(kwargs)
|
|
268
|
-
if not base_content:
|
|
269
|
-
sample = json.dumps(kwargs, indent=2)[:500]
|
|
270
|
-
raise ValueError(
|
|
271
|
-
"Unable to locate textual content in recording. "
|
|
272
|
-
"Expected keys such as data.content, input, input_text, message, query, text, or content." \
|
|
273
|
-
f"\nRecording snapshot:\n{sample}"
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
return {
|
|
277
|
-
"base_content": base_content,
|
|
278
|
-
"full_kwargs": kwargs,
|
|
279
|
-
"target": record.get("target"),
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def _extract_content(kwargs: Dict[str, Any]) -> Optional[str]:
|
|
284
|
-
content_paths = [
|
|
285
|
-
"data.content",
|
|
286
|
-
"input",
|
|
287
|
-
"input_text",
|
|
288
|
-
"message",
|
|
289
|
-
"query",
|
|
290
|
-
"text",
|
|
291
|
-
"content",
|
|
292
|
-
]
|
|
293
|
-
|
|
294
|
-
for path in content_paths:
|
|
295
|
-
try:
|
|
296
|
-
current: Any = kwargs
|
|
297
|
-
for part in path.split('.'):
|
|
298
|
-
current = current[part]
|
|
299
|
-
if isinstance(current, str) and current:
|
|
300
|
-
return current
|
|
301
|
-
except (KeyError, TypeError):
|
|
302
|
-
continue
|
|
303
|
-
|
|
304
|
-
return None
|
fluxloop_cli/commands/init.py
CHANGED
|
@@ -11,13 +11,21 @@ from rich.prompt import Confirm
|
|
|
11
11
|
from rich.tree import Tree
|
|
12
12
|
|
|
13
13
|
from ..templates import (
|
|
14
|
-
|
|
14
|
+
create_project_config,
|
|
15
|
+
create_input_config,
|
|
16
|
+
create_simulation_config,
|
|
17
|
+
create_evaluation_config,
|
|
15
18
|
create_sample_agent,
|
|
16
19
|
create_gitignore,
|
|
17
20
|
create_env_file,
|
|
18
21
|
)
|
|
19
22
|
from ..project_paths import resolve_root_dir, resolve_project_dir
|
|
20
|
-
from ..constants import
|
|
23
|
+
from ..constants import (
|
|
24
|
+
DEFAULT_ROOT_DIR_NAME,
|
|
25
|
+
CONFIG_DIRECTORY_NAME,
|
|
26
|
+
CONFIG_SECTION_FILENAMES,
|
|
27
|
+
CONFIG_SECTION_ORDER,
|
|
28
|
+
)
|
|
21
29
|
|
|
22
30
|
app = typer.Typer()
|
|
23
31
|
console = Console()
|
|
@@ -51,7 +59,7 @@ def project(
|
|
|
51
59
|
Initialize a new FluxLoop project.
|
|
52
60
|
|
|
53
61
|
This command creates:
|
|
54
|
-
-
|
|
62
|
+
- configs/: Separated configuration files (project/input/simulation/evaluation)
|
|
55
63
|
- .env: Environment variables template
|
|
56
64
|
- examples/: Sample agent code (optional)
|
|
57
65
|
"""
|
|
@@ -89,14 +97,20 @@ def project(
|
|
|
89
97
|
raise typer.Abort()
|
|
90
98
|
|
|
91
99
|
# Check for existing files
|
|
92
|
-
|
|
100
|
+
config_dir = project_path / CONFIG_DIRECTORY_NAME
|
|
101
|
+
section_paths = {
|
|
102
|
+
key: config_dir / CONFIG_SECTION_FILENAMES[key]
|
|
103
|
+
for key in CONFIG_SECTION_FILENAMES
|
|
104
|
+
}
|
|
93
105
|
env_file = project_path / ".env"
|
|
94
106
|
gitignore_file = project_path / ".gitignore"
|
|
95
107
|
|
|
96
108
|
if not force:
|
|
97
109
|
existing_files = []
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
for key in CONFIG_SECTION_ORDER:
|
|
111
|
+
path = section_paths[key]
|
|
112
|
+
if path.exists():
|
|
113
|
+
existing_files.append(path.relative_to(project_path).as_posix())
|
|
100
114
|
if env_file.exists():
|
|
101
115
|
existing_files.append(".env")
|
|
102
116
|
|
|
@@ -107,10 +121,21 @@ def project(
|
|
|
107
121
|
if not Confirm.ask("Overwrite existing files?", default=False):
|
|
108
122
|
raise typer.Abort()
|
|
109
123
|
|
|
110
|
-
# Create configuration
|
|
111
|
-
console.print("📝 Creating
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
# Create configuration files
|
|
125
|
+
console.print("📝 Creating configuration files...")
|
|
126
|
+
config_dir.mkdir(exist_ok=True)
|
|
127
|
+
|
|
128
|
+
section_writers = {
|
|
129
|
+
"project": lambda: create_project_config(project_name),
|
|
130
|
+
"input": create_input_config,
|
|
131
|
+
"simulation": lambda: create_simulation_config(project_name),
|
|
132
|
+
"evaluation": create_evaluation_config,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for key in CONFIG_SECTION_ORDER:
|
|
136
|
+
content = section_writers[key]() # type: ignore[operator]
|
|
137
|
+
section_path = section_paths[key]
|
|
138
|
+
section_path.write_text(content)
|
|
114
139
|
|
|
115
140
|
# Ensure root .env exists, create project override template
|
|
116
141
|
root_env_file = root_dir / ".env"
|
|
@@ -145,7 +170,9 @@ def project(
|
|
|
145
170
|
console.print("\n[bold green]✓ Project initialized successfully![/bold green]\n")
|
|
146
171
|
|
|
147
172
|
tree = Tree(f"[bold]{project_name}/[/bold]")
|
|
148
|
-
tree.add("
|
|
173
|
+
configs_node = tree.add(f"📁 {CONFIG_DIRECTORY_NAME}/")
|
|
174
|
+
for key in CONFIG_SECTION_ORDER:
|
|
175
|
+
configs_node.add(f"📄 {CONFIG_SECTION_FILENAMES[key]}")
|
|
149
176
|
tree.add("🔐 .env")
|
|
150
177
|
tree.add("📄 .gitignore")
|
|
151
178
|
tree.add("📁 recordings/")
|
|
@@ -158,7 +185,7 @@ def project(
|
|
|
158
185
|
|
|
159
186
|
# Show next steps
|
|
160
187
|
console.print("\n[bold]Next steps:[/bold]")
|
|
161
|
-
console.print("1.
|
|
188
|
+
console.print("1. Review configs in [cyan]configs/[/cyan] (project/input/simulation/evaluation)")
|
|
162
189
|
console.print("2. Set up environment variables in [cyan].env[/cyan]")
|
|
163
190
|
if with_example:
|
|
164
191
|
console.print("3. Try running: [green]fluxloop run experiment[/green]")
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Record command for managing argument recording mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from ..constants import DEFAULT_ROOT_DIR_NAME
|
|
13
|
+
from ..project_paths import (
|
|
14
|
+
resolve_env_path,
|
|
15
|
+
resolve_project_dir,
|
|
16
|
+
resolve_config_section_path,
|
|
17
|
+
)
|
|
18
|
+
from ..config_schema import CONFIG_SECTION_FILENAMES
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
app = typer.Typer()
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_env(env_path: Path) -> Dict[str, str]:
|
|
26
|
+
values: Dict[str, str] = {}
|
|
27
|
+
if env_path.exists():
|
|
28
|
+
for line in env_path.read_text().splitlines():
|
|
29
|
+
line = line.strip()
|
|
30
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
31
|
+
continue
|
|
32
|
+
key, value = line.split("=", 1)
|
|
33
|
+
values[key.strip()] = value.strip()
|
|
34
|
+
return values
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _write_env(env_path: Path, values: Dict[str, str]) -> None:
|
|
38
|
+
env_path.write_text("\n".join(f"{k}={v}" for k, v in values.items()) + "\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _update_simulation(recording_enabled: bool, project: Optional[str], root: Path) -> None:
|
|
42
|
+
try:
|
|
43
|
+
simulation_path = resolve_config_section_path("simulation", project, root)
|
|
44
|
+
except KeyError:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
data: Dict[str, object] = {}
|
|
48
|
+
if simulation_path.exists():
|
|
49
|
+
import yaml
|
|
50
|
+
|
|
51
|
+
data = yaml.safe_load(simulation_path.read_text()) or {}
|
|
52
|
+
|
|
53
|
+
replay = data.setdefault("replay_args", {})
|
|
54
|
+
replay["enabled"] = recording_enabled
|
|
55
|
+
|
|
56
|
+
import yaml
|
|
57
|
+
|
|
58
|
+
simulation_path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
simulation_path.write_text(
|
|
60
|
+
yaml.dump(data, sort_keys=False, default_flow_style=False),
|
|
61
|
+
encoding="utf-8",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def enable(
|
|
67
|
+
project: Optional[str] = typer.Option(None, "--project", help="Project name"),
|
|
68
|
+
root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="FluxLoop root directory"),
|
|
69
|
+
recording_file: Path = typer.Option(
|
|
70
|
+
Path("recordings/args_recording.jsonl"),
|
|
71
|
+
"--file",
|
|
72
|
+
help="Recording file path relative to project",
|
|
73
|
+
),
|
|
74
|
+
):
|
|
75
|
+
"""Enable recording mode by updating .env and simulation config."""
|
|
76
|
+
|
|
77
|
+
env_path = resolve_env_path(Path(".env"), project, root)
|
|
78
|
+
env_values = _load_env(env_path)
|
|
79
|
+
env_values["FLUXLOOP_RECORD_ARGS"] = "true"
|
|
80
|
+
env_values["FLUXLOOP_RECORDING_FILE"] = str(recording_file)
|
|
81
|
+
|
|
82
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
_write_env(env_path, env_values)
|
|
84
|
+
|
|
85
|
+
_update_simulation(True, project, root)
|
|
86
|
+
|
|
87
|
+
console.print(
|
|
88
|
+
f"[green]✓[/green] Recording enabled. Edit {env_path} or configs/simulation.yaml to adjust settings."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def disable(
|
|
94
|
+
project: Optional[str] = typer.Option(None, "--project", help="Project name"),
|
|
95
|
+
root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="FluxLoop root directory"),
|
|
96
|
+
):
|
|
97
|
+
"""Disable recording mode and reset settings."""
|
|
98
|
+
|
|
99
|
+
env_path = resolve_env_path(Path(".env"), project, root)
|
|
100
|
+
env_values = _load_env(env_path)
|
|
101
|
+
env_values["FLUXLOOP_RECORD_ARGS"] = "false"
|
|
102
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
_write_env(env_path, env_values)
|
|
104
|
+
|
|
105
|
+
_update_simulation(False, project, root)
|
|
106
|
+
|
|
107
|
+
console.print("[green]✓[/green] Recording disabled.")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def status(
|
|
112
|
+
project: Optional[str] = typer.Option(None, "--project", help="Project name"),
|
|
113
|
+
root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="FluxLoop root directory"),
|
|
114
|
+
):
|
|
115
|
+
"""Display current recording status."""
|
|
116
|
+
|
|
117
|
+
env_path = resolve_env_path(Path(".env"), project, root)
|
|
118
|
+
env_values = _load_env(env_path)
|
|
119
|
+
record_args = env_values.get("FLUXLOOP_RECORD_ARGS", "false").lower() == "true"
|
|
120
|
+
recording_file = env_values.get("FLUXLOOP_RECORDING_FILE", "(default)")
|
|
121
|
+
|
|
122
|
+
simulation_path = resolve_config_section_path("simulation", project, root)
|
|
123
|
+
simulation_exists = simulation_path.exists()
|
|
124
|
+
|
|
125
|
+
table = Table(title="Recording Status", show_header=False)
|
|
126
|
+
table.add_column("Property", style="cyan")
|
|
127
|
+
table.add_column("Value")
|
|
128
|
+
|
|
129
|
+
table.add_row("Mode", "ENABLED" if record_args else "disabled")
|
|
130
|
+
table.add_row("Recording File", recording_file)
|
|
131
|
+
table.add_row(".env Path", str(env_path))
|
|
132
|
+
table.add_row("Simulation Config", str(simulation_path) if simulation_exists else "(missing)")
|
|
133
|
+
|
|
134
|
+
console.print(table)
|
|
135
|
+
|
|
136
|
+
project_dir = (
|
|
137
|
+
resolve_project_dir(project, root)
|
|
138
|
+
if project
|
|
139
|
+
else Path.cwd()
|
|
140
|
+
)
|
|
141
|
+
recordings_dir = project_dir / Path(recording_file).parent
|
|
142
|
+
|
|
143
|
+
if recordings_dir.exists():
|
|
144
|
+
console.print(
|
|
145
|
+
f"\n[dim]Recording directory:[/dim] {recordings_dir}\n"
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
console.print(
|
|
149
|
+
f"\n[yellow]Recording directory does not exist yet:[/yellow] {recordings_dir}\n"
|
|
150
|
+
)
|
fluxloop_cli/commands/run.py
CHANGED
|
@@ -94,7 +94,11 @@ def experiment(
|
|
|
94
94
|
console.print(f"📋 Loading configuration from: [cyan]{resolved_config}[/cyan]")
|
|
95
95
|
|
|
96
96
|
try:
|
|
97
|
-
config = load_experiment_config(
|
|
97
|
+
config = load_experiment_config(
|
|
98
|
+
resolved_config,
|
|
99
|
+
project=project,
|
|
100
|
+
root=root,
|
|
101
|
+
)
|
|
98
102
|
except Exception as e:
|
|
99
103
|
console.print(f"[red]Error loading configuration:[/red] {e}")
|
|
100
104
|
raise typer.Exit(1)
|
fluxloop_cli/commands/status.py
CHANGED
|
@@ -11,7 +11,13 @@ from rich.table import Table
|
|
|
11
11
|
from rich.panel import Panel
|
|
12
12
|
|
|
13
13
|
from ..constants import DEFAULT_CONFIG_PATH, DEFAULT_ROOT_DIR_NAME
|
|
14
|
-
from ..
|
|
14
|
+
from ..config_schema import CONFIG_SECTION_FILENAMES, CONFIG_REQUIRED_KEYS
|
|
15
|
+
from ..project_paths import (
|
|
16
|
+
resolve_config_path,
|
|
17
|
+
resolve_config_directory,
|
|
18
|
+
resolve_root_dir,
|
|
19
|
+
resolve_project_relative,
|
|
20
|
+
)
|
|
15
21
|
|
|
16
22
|
app = typer.Typer()
|
|
17
23
|
console = Console()
|
|
@@ -90,17 +96,40 @@ def check(
|
|
|
90
96
|
|
|
91
97
|
# Check for configuration file
|
|
92
98
|
resolved_config = resolve_config_path(DEFAULT_CONFIG_PATH, project, root)
|
|
93
|
-
|
|
99
|
+
config_dir = resolve_config_directory(project, root)
|
|
100
|
+
|
|
101
|
+
missing_sections = [
|
|
102
|
+
CONFIG_SECTION_FILENAMES[key]
|
|
103
|
+
for key in CONFIG_SECTION_FILENAMES
|
|
104
|
+
if not (config_dir / CONFIG_SECTION_FILENAMES[key]).exists()
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
if resolved_config.exists() and not missing_sections:
|
|
94
108
|
status_table.add_row(
|
|
95
109
|
"Config",
|
|
96
110
|
"[green]✓ Found[/green]",
|
|
97
|
-
str(
|
|
111
|
+
str(config_dir)
|
|
98
112
|
)
|
|
99
113
|
else:
|
|
114
|
+
detail_parts = []
|
|
115
|
+
if not config_dir.exists():
|
|
116
|
+
detail_parts.append(f"Missing directory: {config_dir}")
|
|
117
|
+
if missing_sections:
|
|
118
|
+
required_missing = [
|
|
119
|
+
name for name in missing_sections
|
|
120
|
+
if _section_key_from_filename(name) in CONFIG_REQUIRED_KEYS
|
|
121
|
+
]
|
|
122
|
+
if required_missing:
|
|
123
|
+
detail_parts.append(
|
|
124
|
+
"Missing sections: " + ", ".join(required_missing)
|
|
125
|
+
)
|
|
126
|
+
if not detail_parts:
|
|
127
|
+
detail_parts.append("Run: fluxloop init project")
|
|
128
|
+
|
|
100
129
|
status_table.add_row(
|
|
101
130
|
"Config",
|
|
102
|
-
"[yellow]-
|
|
103
|
-
"
|
|
131
|
+
"[yellow]- Incomplete[/yellow]",
|
|
132
|
+
"\n".join(detail_parts)
|
|
104
133
|
)
|
|
105
134
|
|
|
106
135
|
# Check environment
|
|
@@ -133,6 +162,13 @@ def check(
|
|
|
133
162
|
console.print(f" • {rec}")
|
|
134
163
|
|
|
135
164
|
|
|
165
|
+
def _section_key_from_filename(filename: str) -> Optional[str]:
|
|
166
|
+
for key, value in CONFIG_SECTION_FILENAMES.items():
|
|
167
|
+
if value == filename:
|
|
168
|
+
return key
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
136
172
|
@app.command()
|
|
137
173
|
def experiments(
|
|
138
174
|
output_dir: Path = typer.Option(
|
fluxloop_cli/config_loader.py
CHANGED
|
@@ -4,12 +4,20 @@ Configuration loader for experiments.
|
|
|
4
4
|
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Any, Dict,
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
8
|
|
|
9
9
|
import yaml
|
|
10
10
|
from pydantic import ValidationError
|
|
11
11
|
|
|
12
12
|
from .project_paths import resolve_config_path
|
|
13
|
+
from .config_schema import (
|
|
14
|
+
CONFIG_SECTION_FILENAMES,
|
|
15
|
+
CONFIG_SECTION_ORDER,
|
|
16
|
+
CONFIG_REQUIRED_KEYS,
|
|
17
|
+
iter_section_paths,
|
|
18
|
+
is_legacy_config,
|
|
19
|
+
)
|
|
20
|
+
from .constants import CONFIG_DIRECTORY_NAME
|
|
13
21
|
|
|
14
22
|
# Add shared schemas to path
|
|
15
23
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "shared"))
|
|
@@ -20,33 +28,63 @@ from fluxloop.schemas import ExperimentConfig
|
|
|
20
28
|
def load_experiment_config(
|
|
21
29
|
config_file: Path,
|
|
22
30
|
*,
|
|
31
|
+
project: Optional[str] = None,
|
|
32
|
+
root: Optional[Path] = None,
|
|
23
33
|
require_inputs_file: bool = True,
|
|
24
34
|
) -> ExperimentConfig:
|
|
25
35
|
"""
|
|
26
36
|
Load and validate experiment configuration from YAML file.
|
|
27
37
|
"""
|
|
28
|
-
resolved_path = resolve_config_path(config_file, project
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
resolved_path = resolve_config_path(config_file, project, root)
|
|
39
|
+
|
|
40
|
+
structure, project_root, config_dir = _detect_config_context(resolved_path)
|
|
41
|
+
|
|
42
|
+
if structure == "legacy":
|
|
43
|
+
if not resolved_path.exists():
|
|
44
|
+
raise FileNotFoundError(f"Configuration file not found: {resolved_path}")
|
|
45
|
+
|
|
46
|
+
data = _load_yaml_mapping(resolved_path)
|
|
47
|
+
source_dir = resolved_path.parent
|
|
48
|
+
else:
|
|
49
|
+
# Multi-section configuration
|
|
50
|
+
merged: Dict[str, Any] = {}
|
|
51
|
+
missing_required = []
|
|
52
|
+
|
|
53
|
+
for section_path in iter_section_paths(project_root):
|
|
54
|
+
if not section_path.exists():
|
|
55
|
+
key = section_path.name
|
|
56
|
+
logical_key = _section_key_from_filename(section_path.name)
|
|
57
|
+
if logical_key in CONFIG_REQUIRED_KEYS:
|
|
58
|
+
missing_required.append(section_path.name)
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
section_data = _load_yaml_mapping(section_path)
|
|
62
|
+
_deep_merge(merged, section_data)
|
|
63
|
+
|
|
64
|
+
if missing_required:
|
|
65
|
+
raise FileNotFoundError(
|
|
66
|
+
"Missing required configuration sections: "
|
|
67
|
+
+ ", ".join(missing_required)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if not merged:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"No configuration data found in {config_dir}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
data = merged
|
|
76
|
+
source_dir = project_root
|
|
38
77
|
|
|
39
78
|
# Validate and create config object
|
|
40
79
|
try:
|
|
41
80
|
config = ExperimentConfig(**data)
|
|
42
|
-
config.set_source_dir(
|
|
81
|
+
config.set_source_dir(source_dir)
|
|
43
82
|
resolved_input_count = _resolve_input_count(
|
|
44
83
|
config,
|
|
45
84
|
require_inputs_file=require_inputs_file,
|
|
46
85
|
)
|
|
47
86
|
config.set_resolved_input_count(resolved_input_count)
|
|
48
87
|
except ValidationError as e:
|
|
49
|
-
# Format validation errors nicely
|
|
50
88
|
errors = []
|
|
51
89
|
for error in e.errors():
|
|
52
90
|
loc = ".".join(str(x) for x in error["loc"])
|
|
@@ -54,7 +92,7 @@ def load_experiment_config(
|
|
|
54
92
|
errors.append(f" - {loc}: {msg}")
|
|
55
93
|
|
|
56
94
|
raise ValueError(
|
|
57
|
-
|
|
95
|
+
"Invalid configuration:\n" + "\n".join(errors)
|
|
58
96
|
)
|
|
59
97
|
|
|
60
98
|
return config
|
|
@@ -157,3 +195,54 @@ def merge_config_overrides(
|
|
|
157
195
|
|
|
158
196
|
# Create new config
|
|
159
197
|
return ExperimentConfig(**data)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _load_yaml_mapping(path: Path) -> Dict[str, Any]:
|
|
201
|
+
if not path.exists():
|
|
202
|
+
return {}
|
|
203
|
+
|
|
204
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
205
|
+
payload = yaml.safe_load(f)
|
|
206
|
+
|
|
207
|
+
if payload is None:
|
|
208
|
+
return {}
|
|
209
|
+
|
|
210
|
+
if not isinstance(payload, dict):
|
|
211
|
+
raise ValueError(f"Configuration file must contain a mapping: {path}")
|
|
212
|
+
|
|
213
|
+
return payload
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _deep_merge(target: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[str, Any]:
|
|
217
|
+
for key, value in incoming.items():
|
|
218
|
+
if isinstance(value, dict) and isinstance(target.get(key), dict):
|
|
219
|
+
_deep_merge(target[key], value)
|
|
220
|
+
else:
|
|
221
|
+
target[key] = value
|
|
222
|
+
return target
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _detect_config_context(resolved_path: Path) -> tuple[str, Path, Path]:
|
|
226
|
+
"""Determine whether the path points to legacy or multi-section config."""
|
|
227
|
+
|
|
228
|
+
if resolved_path.is_dir():
|
|
229
|
+
if resolved_path.name == CONFIG_DIRECTORY_NAME:
|
|
230
|
+
return "multi", resolved_path.parent, resolved_path
|
|
231
|
+
if (resolved_path / CONFIG_DIRECTORY_NAME).exists():
|
|
232
|
+
return "multi", resolved_path, resolved_path / CONFIG_DIRECTORY_NAME
|
|
233
|
+
return "legacy", resolved_path, resolved_path
|
|
234
|
+
|
|
235
|
+
if resolved_path.parent.name == CONFIG_DIRECTORY_NAME:
|
|
236
|
+
return "multi", resolved_path.parent.parent, resolved_path.parent
|
|
237
|
+
|
|
238
|
+
if is_legacy_config(resolved_path.name):
|
|
239
|
+
return "legacy", resolved_path.parent, resolved_path.parent
|
|
240
|
+
|
|
241
|
+
return "legacy", resolved_path.parent, resolved_path.parent
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _section_key_from_filename(filename: str) -> Optional[str]:
|
|
245
|
+
for key, value in CONFIG_SECTION_FILENAMES.items():
|
|
246
|
+
if value == filename:
|
|
247
|
+
return key
|
|
248
|
+
return None
|