refactor default artifacts workspace and path compatibility

This commit is contained in:
windyboy
2026-02-16 13:23:01 +08:00
parent 2dd329cba9
commit caa6881fd2
8 changed files with 314 additions and 79 deletions
+102 -39
View File
@@ -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}")
+9
View File
@@ -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):