From caa6881fd255261613de7fb34385baaca8f16894 Mon Sep 17 00:00:00 2001 From: windyboy Date: Mon, 16 Feb 2026 13:23:01 +0800 Subject: [PATCH] refactor default artifacts workspace and path compatibility --- .gitignore | 4 + CHANGELOG.md | 13 +++ README.md | 76 ++++++++-------- src/vlm/cli.py | 141 +++++++++++++++++++++--------- src/vlm/config.py | 9 ++ tests/test_cli_artifacts_paths.py | 114 ++++++++++++++++++++++++ tests/test_cli_reports.py | 8 +- tests/test_config.py | 28 ++++++ 8 files changed, 314 insertions(+), 79 deletions(-) create mode 100644 tests/test_cli_artifacts_paths.py diff --git a/.gitignore b/.gitignore index 12cb60a..ff1b4c2 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ Thumbs.db # Logs *.log + +# Generated workflow artifacts +artifacts/ +runs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc0154..c5ae894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## 2026-02-16 +### Artifact Path Governance + +- Changed default workflow artifact outputs from repository root to `artifacts/`: + - `artifacts/inventory.csv` + - `artifacts/identities.json` + - `artifacts/analysis.json` + - `artifacts/plan.json` + - `artifacts/plan_manual_review.csv` +- Added `workspace_dir` to configuration (`~/.vlm/config.yaml`) to customize default artifact directory. +- Stage-A compatibility: when a command uses default input path and only legacy root artifact exists, CLI now prints a migration warning and falls back to the legacy file. +- Added ignore rules for generated artifact directories in `.gitignore` (`artifacts/`, `runs/`). +- Removed temporary process document `ARTIFACTS_REFACTOR_PLAN_2026-02-16.md`; `CHANGELOG.md` remains the canonical change record. + ### Refactor Results - Modularized CLI command implementations: diff --git a/README.md b/README.md index 32ca8a6..48ebed3 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ This creates `~/.vlm/config.yaml`. Edit it to set your library root: ```yaml library_root: "/mnt/Downloads" # Change this to your video library path +workspace_dir: "artifacts" # Default workspace for generated files ``` ### 2. Scan Your Library @@ -58,14 +59,14 @@ Discover all video files in your library: vlm scan ``` -This creates `inventory.csv` with all discovered files and their metadata. +This creates `artifacts/inventory.csv` with all discovered files and their metadata. **What happens:** - Discovers video files using the system `find` command - Extracts file metadata (size, modification time) - Categorizes files based on directory structure (movie/series/anime/other) - Extracts video metadata using ffprobe (if available) -- Saves results to `inventory.csv` +- Saves results to `artifacts/inventory.csv` ### 3. Parse Filenames @@ -78,10 +79,10 @@ vlm parse **For accurate duplicate resolution by quality, embed video metadata:** ```bash -vlm parse --inventory inventory.csv +vlm parse --inventory artifacts/inventory.csv ``` -This creates `identities.json` with parsed information. When using `--inventory`, video metadata (size, resolution, codec) is embedded, enabling accurate quality comparison during duplicate analysis. +This creates `artifacts/identities.json` with parsed information. When using `--inventory`, video metadata (size, resolution, codec) is embedded, enabling accurate quality comparison during duplicate analysis. **What it extracts:** - **Movies**: Title and year (e.g., "Inception (2010)") @@ -91,13 +92,13 @@ This creates `identities.json` with parsed information. When using `--inventory` ### 4. Enrich Titles and Reputation (Optional but Recommended) -Add translation and reputation metadata to `identities.json`: +Add translation and reputation metadata to `artifacts/identities.json`: ```bash vlm enrich ``` -This updates `identities.json` in place and adds fields like: +This updates `artifacts/identities.json` in place and adds fields like: - `title_zh`, `title_en`, `display_title` - `reputation_score`, `reputation_votes`, `reputation_source` - `review_status`, `enrichment_confidence` @@ -123,7 +124,7 @@ Detect episode gaps and duplicates: vlm analyze ``` -This creates `analysis.json` with: +This creates `artifacts/analysis.json` with: - Series with missing episodes - Duplicate files with quality comparison @@ -138,10 +139,10 @@ vlm plan To let the plan automatically resolve duplicate groups (keep one file per group by reputation, quarantine the rest), pass the analysis file: ```bash -vlm plan --analysis analysis.json +vlm plan --analysis artifacts/analysis.json ``` -This creates `plan.json` with: +This creates `artifacts/plan.json` with: - Proposed operations (move, rename, quarantine, no-op) - **Summary**: counts by operation type and by reason - **Human summary** (中文): short narrative for quick review @@ -158,7 +159,7 @@ Duplicate keep strategy is configurable in `~/.vlm/config.yaml` under `plan.dupl - `first_seen` - Keep the first file in each duplicate group. - `manual` - Do not generate quarantine operations; duplicates are listed in analysis only. -**Review the plan** by opening `plan.json` in your editor, or read the human summary when you run `vlm execute`. You can edit the plan JSON if needed. +**Review the plan** by opening `artifacts/plan.json` in your editor, or read the human summary when you run `vlm execute`. You can edit the plan JSON if needed. ### 7. Execute (Dry-Run First) @@ -177,7 +178,7 @@ vlm execute --confirm **Important**: This creates a rollback log in `~/.vlm/rollback/` for reverting changes. Execution safeguards: -- Even if a manually edited `plan.json` contains an unsafe destination, execution rejects paths outside `library_root` +- Even if a manually edited `artifacts/plan.json` contains an unsafe destination, execution rejects paths outside `library_root` - Summary counters treat conflict skips separately from real failures (`failed`/`skipped` are mutually exclusive) ### 8. Rollback (If Needed) @@ -201,26 +202,26 @@ vlm config init # 2. Scan your library vlm scan -# Output: inventory.csv with 1234 files discovered +# Output: artifacts/inventory.csv with 1234 files discovered # 3. Parse filenames with metadata embedding (recommended for duplicate resolution) -vlm parse --inventory inventory.csv -# Output: identities.json with parsed titles, episodes, and embedded video metadata (v2 schema) +vlm parse --inventory artifacts/inventory.csv +# Output: artifacts/identities.json with parsed titles, episodes, and embedded video metadata (v2 schema) # 4. Enrich identities (translation + reputation) vlm enrich -# Output: identities.json updated in place (incremental cache enabled) +# Output: artifacts/identities.json updated in place (incremental cache enabled) # 5. Analyze for gaps and duplicates vlm analyze -# Output: analysis.json with 5 series with gaps, 12 duplicate groups (accurate quality comparison) +# Output: artifacts/analysis.json with 5 series with gaps, 12 duplicate groups (accurate quality comparison) # 6. Generate execution plan (optionally use analysis for duplicate handling) -vlm plan --analysis analysis.json -# Output: plan.json with operations, human summary, and duplicate quarantine decisions +vlm plan --analysis artifacts/analysis.json +# Output: artifacts/plan.json with operations, human summary, and duplicate quarantine decisions # 7. Review the plan -cat plan.json | less +cat artifacts/plan.json | less # or open in your editor # 8. Dry-run to preview @@ -253,7 +254,7 @@ vlm config validate ### Scanning ```bash -# Scan with default output (inventory.csv) +# Scan with default output (artifacts/inventory.csv) vlm scan # Scan with custom output file @@ -274,7 +275,7 @@ vlm scan --output my_library.csv vlm parse # Parse with metadata embedding (v2 schema - enables quality comparison) -vlm parse --inventory inventory.csv +vlm parse --inventory artifacts/inventory.csv # Parse with custom input/output vlm parse --input my_inventory.csv --output my_identities.json @@ -290,7 +291,7 @@ vlm parse --input my_inventory.csv --output my_identities.json --inventory my_in ### Enrichment ```bash -# Enrich identities in place (default: identities.json) +# Enrich identities in place (default: artifacts/identities.json) vlm enrich # Enrich custom file and write to another file @@ -323,7 +324,7 @@ vlm analyze --input my_identities.json --output my_analysis.json vlm plan # Use analysis so duplicate groups become "keep one + quarantine rest" (by_reputation by default) -vlm plan --analysis analysis.json +vlm plan --analysis artifacts/analysis.json # Custom input/output vlm plan --input my_identities.json --output my_plan.json @@ -382,12 +383,12 @@ vlm report inventory --format json --output inventory_report.json # Generate completeness report (series with gaps) vlm report completeness -# Include plan content summary in the report (human_summary from plan.json) -vlm report completeness --plan plan.json +# Include plan content summary in the report (human_summary from artifacts/plan.json) +vlm report completeness --plan artifacts/plan.json # Generate duplicates report vlm report duplicates -vlm report duplicates --plan plan.json +vlm report duplicates --plan artifacts/plan.json # Generate summary statistics vlm report summary @@ -418,6 +419,9 @@ The configuration file (`~/.vlm/config.yaml`) controls VLM's behavior: # Required: Root directory of your video library library_root: "/mnt/nas/videos" +# Workspace directory for generated artifacts +workspace_dir: "artifacts" + # Video file extensions to recognize video_extensions: - .mp4 @@ -512,10 +516,10 @@ Validation flow: ```bash # 1) run small incremental pass -vlm enrich --input identities.json --refresh-changed-only +vlm enrich --input artifacts/identities.json --refresh-changed-only # 2) then full refresh if output looks correct -vlm enrich --input identities.json --refresh-all +vlm enrich --input artifacts/identities.json --refresh-all ``` ### Template Variables @@ -592,7 +596,7 @@ vlm report summary ```bash # 1. Scan and parse vlm scan -vlm parse --inventory inventory.csv +vlm parse --inventory artifacts/inventory.csv vlm enrich # 2. Analyze completeness @@ -600,7 +604,7 @@ vlm analyze # 3. View report (optionally include plan summary if you have a plan) vlm report completeness -vlm report completeness --plan plan.json +vlm report completeness --plan artifacts/plan.json ``` ### Scenario 3: Finding and Removing Duplicates @@ -608,7 +612,7 @@ vlm report completeness --plan plan.json ```bash # 1. Scan and parse (with --inventory for accurate quality comparison) vlm scan -vlm parse --inventory inventory.csv +vlm parse --inventory artifacts/inventory.csv vlm enrich # 2. Analyze for duplicates @@ -618,9 +622,9 @@ vlm analyze vlm report duplicates # 4. Generate plan with analysis: VLM keeps one file per duplicate group (by reputation) and quarantines the rest -vlm plan --analysis analysis.json +vlm plan --analysis artifacts/analysis.json -# 5. Review plan (human summary in plan.json and when you run execute) +# 5. Review plan (human summary in artifacts/plan.json and when you run execute) vlm execute vlm execute --confirm @@ -628,7 +632,7 @@ vlm execute --confirm vlm quarantine add /path/to/lower/quality/file.mkv --reason "duplicate - lower quality" # Report with plan context -vlm report duplicates --plan plan.json +vlm report duplicates --plan artifacts/plan.json ``` ### Scenario 4: Reorganizing Your Library @@ -639,13 +643,13 @@ vlm report duplicates --plan plan.json # 2. Scan and parse vlm scan -vlm parse --inventory inventory.csv +vlm parse --inventory artifacts/inventory.csv vlm enrich # 3. Generate plan vlm plan -# 4. Review plan.json carefully +# 4. Review artifacts/plan.json carefully # 5. Dry-run to preview vlm execute diff --git a/src/vlm/cli.py b/src/vlm/cli.py index 57e290c..c25627b 100644 --- a/src/vlm/cli.py +++ b/src/vlm/cli.py @@ -12,6 +12,7 @@ from typing import Optional import click import yaml +from click.core import ParameterSource from vlm.config import Config, load_config, create_default_config, validate_config from vlm.context import CLIContext, pass_context @@ -24,6 +25,57 @@ def default_config_path() -> Path: return Path.home() / ".vlm" / "config.yaml" +def _workspace_dir_from_context() -> Path: + """Resolve workspace directory from CLI context at runtime.""" + click_ctx = click.get_current_context(silent=True) + if click_ctx and isinstance(click_ctx.obj, CLIContext): + return click_ctx.obj.config.workspace_dir + return Path("artifacts") + + +def default_artifact_path(filename: str) -> Path: + """Build default artifact path for a filename at runtime.""" + return _workspace_dir_from_context() / filename + + +def _is_default_parameter(parameter_name: str) -> bool: + """Check whether a parameter value came from Click default.""" + click_ctx = click.get_current_context(silent=True) + if click_ctx is None: + return False + return click_ctx.get_parameter_source(parameter_name) == ParameterSource.DEFAULT + + +def resolve_legacy_default_input_path( + current_path: Path, + parameter_name: str, + legacy_filename: str, + option_name: str, +) -> Path: + """Fallback to legacy root path when default workspace file is missing. + + This keeps stage-A compatibility for users who still have legacy artifacts + in repository root while printing a migration warning. + """ + if not _is_default_parameter(parameter_name): + return current_path + + workspace_default = default_artifact_path(legacy_filename) + legacy_default = Path(legacy_filename) + if current_path != workspace_default: + return current_path + if current_path.exists() or not legacy_default.exists(): + return current_path + + click.echo( + "Warning: detected legacy default input at " + f"{legacy_default.resolve()}. Please migrate to " + f"{workspace_default.resolve()} (example: {option_name} {workspace_default.resolve()}).", + err=True, + ) + return legacy_default + + @click.group() @click.option( '--config', @@ -107,8 +159,8 @@ def main(ctx, config: Path, log_level: Optional[str]): @click.option( '--output', type=click.Path(path_type=Path), - default=Path('inventory.csv'), - help='Output file for inventory (default: inventory.csv)' + default=lambda: default_artifact_path('inventory.csv'), + help='Output file for inventory (default: artifacts/inventory.csv)' ) @click.option( '--metadata/--no-metadata', @@ -160,15 +212,15 @@ def scan( @main.command() @click.option( '--input', - type=click.Path(exists=True, path_type=Path), - default=Path('inventory.csv'), - help='Input inventory CSV file (default: inventory.csv)' + type=click.Path(path_type=Path), + default=lambda: default_artifact_path('inventory.csv'), + help='Input inventory CSV file (default: artifacts/inventory.csv)' ) @click.option( '--output', type=click.Path(path_type=Path), - default=Path('identities.json'), - help='Output file for parsed identities (default: identities.json)' + default=lambda: default_artifact_path('identities.json'), + help='Output file for parsed identities (default: artifacts/identities.json)' ) @click.option( '--inventory', @@ -194,6 +246,7 @@ def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]) vlm parse --output parsed_identities.json # Custom output """ try: + input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input") from vlm.commands.parse import parse_cmd parse_cmd(ctx, input, output, inventory) except FileNotFoundError: @@ -213,9 +266,9 @@ def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]) @main.command() @click.option( '--input', - type=click.Path(exists=True, path_type=Path), - default=Path('identities.json'), - help='Path to identities JSON file (default: identities.json)' + type=click.Path(path_type=Path), + default=lambda: default_artifact_path('identities.json'), + help='Path to identities JSON file (default: artifacts/identities.json)' ) @click.option( '--output', @@ -265,6 +318,7 @@ def enrich( results back into identities JSON. """ try: + input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input") from vlm.commands.enrich import enrich_cmd enrich_cmd( ctx, @@ -296,15 +350,15 @@ def enrich( @main.command() @click.option( '--input', - type=click.Path(exists=True, path_type=Path), - default=Path('identities.json'), - help='Path to parsed identities JSON file (default: identities.json)' + type=click.Path(path_type=Path), + default=lambda: default_artifact_path('identities.json'), + help='Path to parsed identities JSON file (default: artifacts/identities.json)' ) @click.option( '--output', type=click.Path(path_type=Path), - default=Path('analysis.json'), - help='Path to save analysis results (default: analysis.json)' + default=lambda: default_artifact_path('analysis.json'), + help='Path to save analysis results (default: artifacts/analysis.json)' ) @click.option( '--inventory', @@ -327,6 +381,7 @@ def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path vlm analyze --output my_analysis.json # Custom output """ try: + input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input") from vlm.commands.analyze import analyze_cmd analyze_cmd(ctx, input, output, inventory) except FileNotFoundError: @@ -346,15 +401,15 @@ def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path @main.command() @click.option( '--input', - type=click.Path(exists=True, path_type=Path), - default=Path('identities.json'), - help='Path to parsed identities JSON file (default: identities.json)' + type=click.Path(path_type=Path), + default=lambda: default_artifact_path('identities.json'), + help='Path to parsed identities JSON file (default: artifacts/identities.json)' ) @click.option( '--output', type=click.Path(path_type=Path), - default=Path('plan.json'), - help='Path to save execution plan (default: plan.json)' + default=lambda: default_artifact_path('plan.json'), + help='Path to save execution plan (default: artifacts/plan.json)' ) @click.option( '--analysis', @@ -377,6 +432,7 @@ def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None): vlm plan --output my_plan.json # Custom output """ try: + input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input") from vlm.commands.plan import plan_cmd plan_cmd(ctx, input, output, analysis) except FileNotFoundError: @@ -397,14 +453,14 @@ def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None): @click.option( '--input', type=click.Path(exists=True, path_type=Path), - default=Path('plan.json'), - help='Path to execution plan JSON file (default: plan.json)' + default=lambda: default_artifact_path('plan.json'), + help='Path to execution plan JSON file (default: artifacts/plan.json)' ) @click.option( '--output', type=click.Path(path_type=Path), - default=Path('plan_manual_review.csv'), - help='Path to save manual review CSV (default: plan_manual_review.csv)' + default=lambda: default_artifact_path('plan_manual_review.csv'), + help='Path to save manual review CSV (default: artifacts/plan_manual_review.csv)' ) @click.option( '--season-threshold', @@ -435,6 +491,7 @@ def review_plan_cmd( logger = ctx.logger try: + input = resolve_legacy_default_input_path(input, "input", "plan.json", "--input") if season_threshold < 1 or episode_threshold < 1: click.echo("Error: thresholds must be >= 1", err=True) sys.exit(1) @@ -491,9 +548,9 @@ def review_plan_cmd( @main.command() @click.option( '--plan', - type=click.Path(exists=True, path_type=Path), - default=Path('plan.json'), - help='Path to execution plan JSON file (default: plan.json)' + type=click.Path(path_type=Path), + default=lambda: default_artifact_path('plan.json'), + help='Path to execution plan JSON file (default: artifacts/plan.json)' ) @click.option( '--confirm', @@ -541,6 +598,7 @@ def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops: vlm execute --confirm --yes # Execute without confirmation prompt """ try: + plan = resolve_legacy_default_input_path(plan, "plan", "plan.json", "--plan") from vlm.commands.execute import execute_cmd execute_cmd(ctx, plan, confirm, yes, verbose_ops, preserve_directories, safe_mode) except FileNotFoundError: @@ -827,9 +885,9 @@ def report(ctx: CLIContext): ) @click.option( '--input', - type=click.Path(exists=True, path_type=Path), - default=Path('inventory.csv'), - help='Input inventory CSV file (default: inventory.csv)' + type=click.Path(path_type=Path), + default=lambda: default_artifact_path('inventory.csv'), + help='Input inventory CSV file (default: artifacts/inventory.csv)' ) @click.option( '--output', @@ -856,6 +914,7 @@ def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional logger = ctx.logger try: + input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input") # Load inventory from CSV click.echo(f"Loading inventory from: {input}") @@ -905,9 +964,9 @@ def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional ) @click.option( '--input', - type=click.Path(exists=True, path_type=Path), - default=Path('analysis.json'), - help='Input analysis JSON file (default: analysis.json)' + type=click.Path(path_type=Path), + default=lambda: default_artifact_path('analysis.json'), + help='Input analysis JSON file (default: artifacts/analysis.json)' ) @click.option( '--output', @@ -942,6 +1001,7 @@ def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optio config = ctx.config logger = ctx.logger + input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input") plan_summary = None if plan: if not plan.exists(): @@ -1017,9 +1077,9 @@ def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optio ) @click.option( '--input', - type=click.Path(exists=True, path_type=Path), - default=Path('analysis.json'), - help='Input analysis JSON file (default: analysis.json)' + type=click.Path(path_type=Path), + default=lambda: default_artifact_path('analysis.json'), + help='Input analysis JSON file (default: artifacts/analysis.json)' ) @click.option( '--output', @@ -1056,6 +1116,7 @@ def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optiona config = ctx.config logger = ctx.logger + input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input") plan_summary = None if plan: if not plan.exists(): @@ -1165,9 +1226,9 @@ def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optiona @report.command('summary') @click.option( '--input', - type=click.Path(exists=True, path_type=Path), - default=Path('inventory.csv'), - help='Input inventory CSV file (default: inventory.csv)' + type=click.Path(path_type=Path), + default=lambda: default_artifact_path('inventory.csv'), + help='Input inventory CSV file (default: artifacts/inventory.csv)' ) @click.option( '--output', @@ -1194,6 +1255,7 @@ def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]): logger = ctx.logger try: + input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input") # Load inventory from CSV click.echo(f"Loading inventory from: {input}") @@ -1511,6 +1573,7 @@ def config_show(ctx: CLIContext): click.echo(f" Movie filename template: {cfg.movie_filename_template}") click.echo(f" Series filename template: {cfg.series_filename_template}") click.echo(f" Quarantine directory: {cfg.quarantine_dir}") + click.echo(f" Workspace directory: {cfg.workspace_dir}") click.echo(f" Log level: {cfg.log_level}") diff --git a/src/vlm/config.py b/src/vlm/config.py index d7f0176..1c3f6a4 100644 --- a/src/vlm/config.py +++ b/src/vlm/config.py @@ -22,6 +22,7 @@ class Config: series_filename_template: str = "S{season:02d}E{episode:02d}{ext}" log_level: str = "INFO" quarantine_dir: str = ".quarantine" + workspace_dir: Path = Path("artifacts") categories: dict[str, list[str]] = field(default_factory=lambda: { "movie": ["movie", "movies"], "series": ["series", "tv", "shows"], @@ -85,6 +86,7 @@ def load_config(path: Path) -> Config: series_filename_template = templates.get("series_filename", "S{season:02d}E{episode:02d}{ext}") quarantine_dir = data.get("quarantine_dir", ".quarantine") + workspace_dir = Path(data.get("workspace_dir", "artifacts")).expanduser() log_level = data.get("log_level", "INFO") categories = data.get("categories", { "movie": ["movie", "movies"], @@ -116,6 +118,7 @@ def load_config(path: Path) -> Config: series_filename_template=series_filename_template, log_level=log_level, quarantine_dir=quarantine_dir, + workspace_dir=workspace_dir, categories=categories, enrichment_enabled=enrichment.get("enabled", True), enrichment_incremental=enrichment.get("incremental", True), @@ -202,6 +205,7 @@ def create_default_config(path: Path) -> Config: "series_filename": default_config.series_filename_template, }, "quarantine_dir": default_config.quarantine_dir, + "workspace_dir": str(default_config.workspace_dir), "log_level": default_config.log_level, "categories": default_config.categories, "enrichment": enrichment_content, @@ -278,6 +282,11 @@ def validate_config(config: Config) -> list[str]: elif config.quarantine_dir.startswith("/") or config.quarantine_dir.startswith("\\"): errors.append("quarantine_dir must be relative to category root, not absolute") + if not isinstance(config.workspace_dir, Path): + errors.append("workspace_dir must be a Path object") + elif not str(config.workspace_dir).strip(): + errors.append("workspace_dir cannot be empty") + if not config.categories: errors.append("categories cannot be empty") elif not isinstance(config.categories, dict): diff --git a/tests/test_cli_artifacts_paths.py b/tests/test_cli_artifacts_paths.py new file mode 100644 index 0000000..7da666d --- /dev/null +++ b/tests/test_cli_artifacts_paths.py @@ -0,0 +1,114 @@ +"""Tests for CLI artifact default paths and legacy compatibility warnings.""" + +import json +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner + +from vlm.cli import main + + +def _write_config(path: Path, library_root: Path, workspace_dir: str | None = None) -> None: + workspace_line = f"\nworkspace_dir: {workspace_dir}" if workspace_dir else "" + path.write_text( + ( + f"library_root: {library_root}\n" + "video_extensions:\n" + " - .mp4\n" + " - .mkv\n" + "categories:\n" + " movie: [movie, movies]\n" + " series: [series, tv, shows]\n" + " anime: [anime]\n" + f"{workspace_line}\n" + ), + encoding="utf-8", + ) + + +def _write_inventory_csv(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "# vlm inventory\n" + "path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n" + "/library/movie/Matrix (1999).mkv,Matrix (1999).mkv,1000000,2024-01-01T00:00:00,movie,,,\n", + encoding="utf-8", + ) + + +def test_scan_default_output_uses_artifacts_dir(tmp_path): + """Scan should write to artifacts/inventory.csv by default.""" + library_root = tmp_path / "library" + library_root.mkdir(parents=True) + + config_file = tmp_path / "config.yaml" + _write_config(config_file, library_root) + + runner = CliRunner() + with patch("vlm.commands.scan.scan_library", return_value=[]), patch( + "vlm.commands.scan.save_inventory_csv" + ) as mock_save: + result = runner.invoke(main, ["--config", str(config_file), "scan"]) + + assert result.exit_code == 0 + assert mock_save.call_count == 1 + assert mock_save.call_args.args[1] == Path("artifacts/inventory.csv") + + +def test_scan_default_output_uses_workspace_dir_from_config(tmp_path): + """Scan should honor configured workspace_dir for default output.""" + library_root = tmp_path / "library" + library_root.mkdir(parents=True) + + config_file = tmp_path / "config.yaml" + _write_config(config_file, library_root, workspace_dir="work/cache") + + runner = CliRunner() + with patch("vlm.commands.scan.scan_library", return_value=[]), patch( + "vlm.commands.scan.save_inventory_csv" + ) as mock_save: + result = runner.invoke(main, ["--config", str(config_file), "scan"]) + + assert result.exit_code == 0 + assert mock_save.call_count == 1 + assert mock_save.call_args.args[1] == Path("work/cache/inventory.csv") + + +def test_parse_default_input_falls_back_to_legacy_root_file_with_warning(tmp_path): + """Parse should warn and fallback to legacy root inventory.csv during stage-A migration.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)): + library_root = Path("library") + library_root.mkdir(parents=True) + config_file = Path("config.yaml") + _write_config(config_file, library_root) + + _write_inventory_csv(Path("inventory.csv")) + result = runner.invoke(main, ["--config", str(config_file), "parse"]) + + assert result.exit_code == 0 + assert "Warning: detected legacy default input at" in result.output + assert "example: --input" in result.output + assert Path("artifacts/identities.json").exists() + + data = json.loads(Path("artifacts/identities.json").read_text(encoding="utf-8")) + assert data["metadata"]["source_inventory"] == "inventory.csv" + + +def test_parse_default_input_no_warning_when_artifacts_input_exists(tmp_path): + """When artifacts input exists, parse should use it without migration warning.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)): + library_root = Path("library") + library_root.mkdir(parents=True) + config_file = Path("config.yaml") + _write_config(config_file, library_root) + + _write_inventory_csv(Path("artifacts/inventory.csv")) + _write_inventory_csv(Path("inventory.csv")) + + result = runner.invoke(main, ["--config", str(config_file), "parse"]) + assert result.exit_code == 0 + assert "Warning: detected legacy default input at" not in result.output + diff --git a/tests/test_cli_reports.py b/tests/test_cli_reports.py index 04bee79..c608c4b 100644 --- a/tests/test_cli_reports.py +++ b/tests/test_cli_reports.py @@ -326,9 +326,9 @@ class TestCLIReports: '--input', str(tmp_path / "nonexistent.csv") ]) - # Verify error (Click validates file existence before our code runs) + # Verify runtime missing-file handling assert result.exit_code != 0 - assert "does not exist" in result.output + assert "Error: Input file not found:" in result.output def test_report_completeness_missing_file(self, tmp_path): """Test completeness report with missing input file.""" @@ -338,6 +338,6 @@ class TestCLIReports: '--input', str(tmp_path / "nonexistent.json") ]) - # Verify error (Click validates file existence before our code runs) + # Verify runtime missing-file handling assert result.exit_code != 0 - assert "does not exist" in result.output + assert "Error: Input file not found:" in result.output diff --git a/tests/test_config.py b/tests/test_config.py index 9dda026..f49e9af 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -20,6 +20,7 @@ class TestConfig: assert config.series_template == "series/{title}/Season {season:02d}/" assert config.log_level == "INFO" assert config.quarantine_dir == ".quarantine" + assert config.workspace_dir == Path("artifacts") assert config.enrichment_providers == ["tmdb"] assert config.plan_max_season == 15 assert config.plan_max_episode == 100 @@ -80,6 +81,7 @@ class TestLoadConfig: 'series_filename': 'S{season:02d}E{episode:02d}{ext}' }, 'quarantine_dir': '.quarantine', + 'workspace_dir': 'artifacts', 'log_level': 'DEBUG', 'plan': { 'duplicate_keep': 'by_quality', @@ -100,6 +102,7 @@ class TestLoadConfig: assert config.series_template == 'series/{title}/Season {season:02d}/' assert config.log_level == 'DEBUG' assert config.quarantine_dir == '.quarantine' + assert config.workspace_dir == Path('artifacts') assert config.duplicate_keep == 'by_quality' assert config.plan_max_season == 12 assert config.plan_max_episode == 80 @@ -192,6 +195,20 @@ class TestLoadConfig: assert ".mp4" in config.video_extensions assert config.movie_template == "movie/{title} ({year})/" assert config.log_level == "INFO" + assert config.workspace_dir == Path("artifacts") + + def test_load_config_with_workspace_dir(self, tmp_path): + """Test loading config with explicit workspace_dir.""" + config_file = tmp_path / "config.yaml" + config_data = { + 'library_root': '/mnt/nas/videos', + 'workspace_dir': 'work/artifacts' + } + with open(config_file, 'w') as f: + yaml.dump(config_data, f) + + config = load_config(config_file) + assert config.workspace_dir == Path('work/artifacts') def test_load_config_with_custom_categories(self, tmp_path): """Test loading config with custom categories.""" @@ -282,6 +299,7 @@ class TestCreateDefaultConfig: assert 'templates' in data assert 'log_level' in data assert 'quarantine_dir' in data + assert 'workspace_dir' in data assert 'enrichment' in data assert 'enrich' in data assert 'plan' in data @@ -308,6 +326,7 @@ class TestCreateDefaultConfig: # Configs should be equivalent assert loaded_config.library_root == created_config.library_root assert loaded_config.video_extensions == created_config.video_extensions + assert loaded_config.workspace_dir == created_config.workspace_dir assert loaded_config.movie_template == created_config.movie_template assert loaded_config.log_level == created_config.log_level @@ -417,6 +436,15 @@ class TestValidateConfig: assert len(errors) > 0 assert any("quarantine_dir" in err for err in errors) + + def test_validate_workspace_dir_type(self): + """workspace_dir must be a Path object.""" + config = Config( + library_root=Path("/mnt/nas/videos"), + workspace_dir="artifacts", # type: ignore[arg-type] + ) + errors = validate_config(config) + assert any("workspace_dir must be a Path object" in err for err in errors) def test_validate_multiple_errors(self): """Test validating config with multiple errors."""