diff --git a/CHANGELOG.md b/CHANGELOG.md index 96293fa..94c860e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,15 @@ Revival release: 2026 model/practice modernization. Fully backwards-compatible ### Fixed - PII filter import crash caused by an inline `(?i)` regex flag in the middle of the bearer-token pattern, which broke the `scan_pii` ingestion path +- `distill-align init` now works without the `run` subcommand, matching the README and CLI reference +- The documented global `--config` flag is honored and errors cleanly when the file is missing +- `synthesize --mode` and `jobs list --status` reject invalid values with a helpful message listing valid choices instead of a raw traceback +- `export --format preference` (documented in the README) no longer crashes; unknown formats produce a clean error listing supported formats +- `synthesize`/`export`/`validate` reject non-array JSON inputs with a clear message instead of a `TypeError` +- `ingest` fails loudly on empty input instead of silently writing an empty chunks file +- `status` reports API keys for all documented providers (Anthropic, Gemini, Azure, generic) instead of only OpenAI +- `config show` reports unparseable config files gracefully +- Generated config template no longer contains an invalid tab character that made every `distill-align init` config unparseable YAML ## [0.1.1] - 2026-06-18 diff --git a/src/distill_align/cli/main.py b/src/distill_align/cli/main.py index 7fda219..3a84e52 100644 --- a/src/distill_align/cli/main.py +++ b/src/distill_align/cli/main.py @@ -17,6 +17,7 @@ """ import asyncio +import contextlib from pathlib import Path from typing import Literal, cast @@ -62,7 +63,6 @@ def main( ): """Distill-Align: Generate fine-tuning datasets from raw domain data.""" setup_logging(log_level=log_level, log_file=log_file, log_format=log_format) - ctx.obj = {"config_file": config_file} # Async-free PyPI update check (fast, silent on failure) try: @@ -75,15 +75,20 @@ def main( except Exception: pass # never crash the CLI - # Load custom providers from config file (if any) - try: - from ..core.config_file import find_config_file, load_config - + # Load custom providers from the config file (if any). An explicit + # --config path is honored; a missing explicit file is a hard error. + cfg_path: Path | None + if config_file: + cfg_path = Path(config_file) + if not cfg_path.exists(): + console.print(f"[red]Error: Config file not found: {config_file}[/red]") + raise typer.Exit(1) + else: cfg_path = find_config_file() - if cfg_path: + + if cfg_path is not None: + with contextlib.suppress(Exception): load_config(cfg_path) # load_config internally registers custom providers - except Exception: - pass # Non-fatal @app.command() @@ -138,6 +143,14 @@ def progress_cb(current, total, name): progress.update(task, completed=True) + if not chunks: + console.print( + "[red]Error: No chunks were produced from the input.[/red] " + "[yellow]The file may be empty, or the directory may contain no supported " + "documents (markdown, code, PDF, DOCX, HTML, CSV, JSON, Jupyter).[/yellow]" + ) + raise typer.Exit(1) + import json output_path = Path(output) @@ -201,7 +214,20 @@ def synthesize( console.print(f"[red]Error: Input file does not exist: {input}[/red]") raise typer.Exit(1) + if mode != "default": + try: + ConversationMode(mode) + except ValueError: + valid_modes = ", ".join(m.value for m in ConversationMode) + console.print(f"[red]Error: Invalid mode '{mode}'. Valid modes: default, {valid_modes}[/red]") + raise typer.Exit(1) from None + chunks_data = safe_json_load(input_path) + if not isinstance(chunks_data, list): + console.print("[red]Error: Input file must contain a JSON array of chunks.[/red]") + raise typer.Exit(1) + if not chunks_data: + console.print("[yellow]Warning: Input file contains no chunks; the output will be empty.[/yellow]") chunks = [DataChunk(**chunk) for chunk in chunks_data] # Security: deprecate --api-key in favor of environment variables @@ -318,18 +344,37 @@ def export( console.print(Panel.fit("📤 Export Pipeline", style="bold green")) from ..core.json_utils import safe_json_load + from ..exporter.pipeline import FORMATTER_MAP input_path = Path(input) if not input_path.exists(): console.print(f"[red]Error: Input file does not exist: {input}[/red]") raise typer.Exit(1) + format_list = [f.strip() for f in formats.split(",")] + unknown_formats = [f for f in format_list if f not in FORMATTER_MAP] + if unknown_formats: + console.print( + f"[red]Error: Unsupported export format(s): {', '.join(unknown_formats)}[/red]\n" + f"[yellow]Supported formats: {', '.join(sorted(FORMATTER_MAP))}[/yellow]" + ) + raise typer.Exit(1) + conv_data = safe_json_load(input_path) + if not isinstance(conv_data, list): + console.print("[red]Error: Input file must contain a JSON array of conversations.[/red]") + raise typer.Exit(1) + if not conv_data: + console.print("[yellow]Warning: Input file contains no conversations; the output will be empty.[/yellow]") conversations = [ConversationSchema(**conv) for conv in conv_data] - format_list = [f.strip() for f in formats.split(",")] config = ExportConfig( - formats=cast(list[Literal["sharegpt", "alpaca", "chatml", "conversation", "hf_messages"]], format_list), + formats=cast( + list[ + Literal["sharegpt", "alpaca", "chatml", "conversation", "hf_messages", "jsonl", "parquet", "preference"] + ], + format_list, + ), output_dir=output_dir, unsloth_model=model_name, ) @@ -380,6 +425,9 @@ def validate( raise typer.Exit(1) conv_data = safe_json_load(input_path) + if not isinstance(conv_data, list): + console.print("[red]Error: Input file must contain a JSON array of conversations.[/red]") + raise typer.Exit(1) conversations = [ConversationSchema(**conv) for conv in conv_data] validator = DatasetValidator() @@ -501,11 +549,21 @@ def status(): # Check env vars import os - api_key = os.getenv("OPENAI_API_KEY") or os.getenv("DISTILL_LLM_API_KEY") - if api_key: - table.add_row("API Key", "[green]Set[/green]") + key_vars = { + "OpenAI": "OPENAI_API_KEY", + "Anthropic": "ANTHROPIC_API_KEY", + "Gemini": "GOOGLE_API_KEY", + "Azure OpenAI": "AZURE_OPENAI_API_KEY", + "Generic (DISTILL_)": "DISTILL_LLM_API_KEY", + } + set_providers = sorted(name for name, var in key_vars.items() if os.getenv(var)) + if set_providers: + table.add_row("API Keys", f"[green]{', '.join(set_providers)}[/green]") else: - table.add_row("API Key", "[yellow]Not set (use OPENAI_API_KEY env var)[/yellow]") + table.add_row( + "API Keys", + "[yellow]Not set (use OPENAI_API_KEY, ANTHROPIC_API_KEY, or GOOGLE_API_KEY)[/yellow]", + ) console.print(table) @@ -521,7 +579,14 @@ def jobs_list( from ..core.checkpoint import CheckpointManager, JobStatus manager = CheckpointManager() - status_filter = JobStatus(status) if status else None + status_filter: JobStatus | None = None + if status: + try: + status_filter = JobStatus(status) + except ValueError: + valid_statuses = ", ".join(s.value for s in JobStatus) + console.print(f"[red]Error: Invalid status '{status}'. Valid statuses: {valid_statuses}[/red]") + raise typer.Exit(1) from None jobs = manager.list_jobs(status=status_filter, job_type=job_type, limit=limit) if not jobs: @@ -607,7 +672,12 @@ def config_show(): console.print("[yellow]No config file found. Run 'distill-align init' to create one.[/yellow]") return - config = load_config(config_path) + try: + config = load_config(config_path) + except Exception as e: + console.print(f"[red]Error: Failed to parse config file: {config_path}[/red]") + console.print(f"[yellow]{e}[/yellow]") + raise typer.Exit(1) from None console.print(Panel(str(config_path), title="Config File")) console.print(config.model_dump_json(indent=2)) @@ -623,12 +693,29 @@ def config_path(): # Init subcommand +@init_app.callback(invoke_without_command=True) +def init_default( + ctx: typer.Context, + path: str = typer.Option("distill-align.yaml", "--path", "-p", help="Output config path"), + name: str = typer.Option("my-dataset", "--name", "-n", help="Project name"), +) -> None: + """Initialize a new project config file (default command).""" + if ctx.invoked_subcommand is not None: + return # a subcommand (e.g. `init run`) handles the invocation + _create_project_config(path, name) + + @init_app.command("run") def init_run( path: str = typer.Option("distill-align.yaml", "--path", "-p", help="Output config path"), name: str = typer.Option("my-dataset", "--name", "-n", help="Project name"), -): +) -> None: """Initialize a new project config file.""" + _create_project_config(path, name) + + +def _create_project_config(path: str, name: str) -> None: + """Generate a default project config file and point the user at next steps.""" output = generate_default_config(project_name=name, path=path) console.print(f"[green]✓ Created config file: {output}[/green]") console.print("\nEdit it to configure your pipeline, then run:") diff --git a/src/distill_align/core/config_file.py b/src/distill_align/core/config_file.py index a5cfcd1..abd2093 100644 --- a/src/distill_align/core/config_file.py +++ b/src/distill_align/core/config_file.py @@ -168,7 +168,7 @@ class DistillAlignConfig(BaseModel): num_epochs: 3 learning_rate: 0.0002 - # Custom providers (optional) + # Custom providers (optional) # Add any OpenAI-compatible, Anthropic-compatible, or other API providers: # custom_providers: # - name: groq diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 26c8a6e..58aa772 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -32,6 +32,14 @@ def test_status_output(self): assert "Python" in result.stdout assert "Status" in result.stdout or "System" in result.stdout + def test_status_reports_other_provider_keys(self, monkeypatch): + """status reports non-OpenAI provider keys instead of 'Not set'.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + assert "Anthropic" in result.stdout + assert "Not set" not in result.stdout + class TestCLIHelp: """Tests for help output.""" @@ -79,6 +87,24 @@ def test_init_run_creates_config(self, tmp_path): content = config_path.read_text() assert "test-proj" in content or "project" in content + def test_init_without_subcommand_creates_config(self, tmp_path): + """Plain `distill-align init` creates a config file (documented usage).""" + config_path = tmp_path / "proj.yaml" + result = runner.invoke(app, ["init", "--path", str(config_path), "--name", "my-proj"]) + assert result.exit_code == 0 + assert config_path.exists() + + def test_generated_config_is_parseable(self, tmp_path): + """The config generated by init must be valid YAML.""" + from distill_align.core.config_file import load_config + + config_path = tmp_path / "proj.yaml" + result = runner.invoke(app, ["init", "--path", str(config_path)]) + assert result.exit_code == 0 + + config = load_config(config_path) # raises on invalid YAML + assert config.project.name == "my-dataset" + class TestCLIIngest: """Tests for ingest command.""" @@ -108,6 +134,16 @@ def test_ingest_markdown_file(self, tmp_path): data = json.loads(output.read_text()) assert len(data) > 0 + def test_ingest_empty_directory_errors(self, tmp_path): + """ingest on a directory with no supported files fails loudly.""" + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + output = tmp_path / "out.json" + result = runner.invoke(app, ["ingest", str(empty_dir), "--output", str(output)]) + assert result.exit_code == 1 + assert "No chunks" in result.stdout + assert not output.exists() + class TestCLIExport: """Tests for export command.""" @@ -151,6 +187,60 @@ def test_export_valid_conversations(self, tmp_path): files = list(out_dir.glob("*.json")) assert len(files) >= 1 + def test_export_preference_format(self, tmp_path): + """export --format preference works end-to-end (documented in README).""" + from distill_align.core.schemas import ConversationSchema, SynthesizedTurn + + conv = ConversationSchema( + id="test-cli-export-pref", + source_chunk_id="chunk-1", + turns=[ + SynthesizedTurn(role="user", content="Hello"), + SynthesizedTurn(role="assistant", content="Hi there!"), + ], + ) + input_file = tmp_path / "conversations.json" + input_file.write_text(json.dumps([conv.model_dump()], indent=2), encoding="utf-8") + out_dir = tmp_path / "pref" + result = runner.invoke( + app, + [ + "export", + str(input_file), + "--output-dir", + str(out_dir), + "--format", + "preference", + "--no-unsloth", + ], + ) + assert result.exit_code == 0 + pref_file = out_dir / "dataset_preference.json" + assert pref_file.exists() + data = json.loads(pref_file.read_text()) + assert len(data) == 1 + assert "chosen" in data[0] and "rejected" in data[0] + + def test_export_unknown_format(self, tmp_path): + """export rejects an unknown --format with a helpful message.""" + from distill_align.core.schemas import ConversationSchema, SynthesizedTurn + + conv = ConversationSchema( + id="test-cli-export-unknown", + source_chunk_id="chunk-1", + turns=[SynthesizedTurn(role="user", content="Hello")], + ) + input_file = tmp_path / "conversations.json" + input_file.write_text(json.dumps([conv.model_dump()], indent=2), encoding="utf-8") + out_dir = tmp_path / "unknown" + result = runner.invoke( + app, + ["export", str(input_file), "--output-dir", str(out_dir), "--format", "bogus", "--no-unsloth"], + ) + assert result.exit_code == 1 + assert "bogus" in result.stdout + assert "sharegpt" in result.stdout or "alpaca" in result.stdout + class TestCLIValidate: """Tests for validate command.""" @@ -182,6 +272,44 @@ def test_validate_valid_data(self, tmp_path): assert result.exit_code == 0 +class TestCLISynthesize: + """Tests for synthesize command validation.""" + + @staticmethod + def _chunks_file(tmp_path): + chunk_file = tmp_path / "chunks.json" + chunk_file.write_text( + json.dumps( + [ + { + "id": "c1", + "content": "hello world", + "metadata": {"file_path": "a.md", "file_name": "a.md"}, + "source": "a.md", + "index": 0, + } + ] + ), + encoding="utf-8", + ) + return chunk_file + + def test_synthesize_invalid_mode(self, tmp_path): + """synthesize rejects an unknown --mode with a helpful message.""" + result = runner.invoke(app, ["synthesize", str(self._chunks_file(tmp_path)), "--mode", "bogus"]) + assert result.exit_code == 1 + assert "Invalid mode" in result.stdout + assert "teach" in result.stdout and "explain" in result.stdout + + def test_synthesize_non_array_input(self, tmp_path): + """synthesize rejects a JSON object (not an array) with a clear error.""" + bad_file = tmp_path / "bad.json" + bad_file.write_text(json.dumps({"foo": "bar"}), encoding="utf-8") + result = runner.invoke(app, ["synthesize", str(bad_file)]) + assert result.exit_code == 1 + assert "JSON array" in result.stdout + + class TestCLIJobs: """Tests for jobs commands.""" @@ -190,6 +318,13 @@ def test_jobs_list(self): result = runner.invoke(app, ["jobs", "list"]) assert result.exit_code == 0 + def test_jobs_list_invalid_status(self): + """jobs list rejects an invalid --status with a helpful message.""" + result = runner.invoke(app, ["jobs", "list", "--status", "bogus"]) + assert result.exit_code == 1 + assert "Invalid status" in result.stdout + assert "completed" in result.stdout or "running" in result.stdout + def test_jobs_resume_missing(self): """jobs resume with bogus ID shows error.""" result = runner.invoke(app, ["jobs", "resume", "nonexistent-job-id"]) @@ -209,6 +344,14 @@ def test_config_show(self): result = runner.invoke(app, ["config", "show"]) assert result.exit_code == 0 + def test_config_show_invalid_yaml(self, tmp_path, monkeypatch): + """config show reports unparseable config files instead of crashing.""" + (tmp_path / "distill-align.yaml").write_text("invalid: [unclosed", encoding="utf-8") + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["config", "show"]) + assert result.exit_code == 1 + assert "Failed to parse" in result.stdout + def test_config_path(self): """config path runs without error.""" result = runner.invoke(app, ["config", "path"]) @@ -223,6 +366,12 @@ def test_unknown_command(self): result = runner.invoke(app, ["nonexistent-command-xyz"]) assert result.exit_code != 0 + def test_config_flag_missing_file(self): + """--config pointing at a missing file errors cleanly.""" + result = runner.invoke(app, ["--config", "/nonexistent/config.yaml", "version"]) + assert result.exit_code == 1 + assert "Config file not found" in result.stdout + def test_ingest_without_source(self): """ingest without source shows usage.""" result = runner.invoke(app, ["ingest"])