improve review-plan preview output and docs

This commit is contained in:
windyboy
2026-04-02 11:31:49 +08:00
parent ace3229b62
commit ea21e15b3a
7 changed files with 795 additions and 255 deletions
+360 -188
View File
@@ -17,6 +17,7 @@ from click.core import ParameterSource
from vlm.config import Config, load_config, create_default_config, validate_config
from vlm.context import CLIContext, pass_context
from vlm.logging_config import setup_logging, get_logger
from vlm.plan_render import fallback_plan_summary, preferred_plan_summary, render_review_preview
from vlm.utils import format_size
@@ -76,6 +77,53 @@ def resolve_legacy_default_input_path(
return legacy_default
def _command_error(ctx: CLIContext, user_message: str, logger_message: str, *, exc_info: bool = False) -> None:
"""Print a command error message, log it, and exit consistently."""
click.echo(user_message, err=True)
ctx.logger.error(logger_message, exc_info=exc_info)
raise SystemExit(1)
def _load_or_create_config(config: Path) -> Config:
"""Load configuration from disk or create a default config file."""
if config.exists():
try:
cfg = load_config(config)
except yaml.YAMLError as e:
click.echo(f"Error: Invalid YAML syntax in configuration file: {e}", err=True)
click.echo("Using default configuration values.", err=True)
cfg = create_default_config(config)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
click.echo("Using default configuration values.", err=True)
cfg = create_default_config(config)
else:
click.echo(f"Configuration file not found at {config}", err=True)
click.echo("Creating default configuration...", err=True)
cfg = create_default_config(config)
click.echo(f"Default configuration created at {config}", err=True)
validation_errors = validate_config(cfg)
if validation_errors:
click.echo("Configuration validation errors:", err=True)
for error in validation_errors:
click.echo(f" - {error}", err=True)
click.echo("Please fix the configuration file and try again.", err=True)
raise ValueError("invalid configuration")
return cfg
def _initialize_cli_context(config: Path, log_level: Optional[str]) -> CLIContext:
"""Load config, apply CLI overrides, and build the CLI context."""
cfg = _load_or_create_config(config)
if log_level:
cfg.log_level = log_level.upper()
logger = setup_logging(log_level=cfg.log_level)
return CLIContext(config=cfg, logger=logger)
@click.group()
@click.option(
'--config',
@@ -101,10 +149,11 @@ def main(ctx, config: Path, log_level: Optional[str]):
1. vlm scan - Discover all video files
2. vlm parse - Extract titles, years, seasons, episodes
3. vlm analyze - Detect gaps and duplicates
4. vlm plan - Generate execution plan
5. vlm execute - Execute plan (dry-run by default)
6. vlm execute --confirm - Actually execute operations
3. vlm enrich - Enrich parsed identities with external metadata
4. vlm analyze - Detect gaps and duplicates
5. vlm plan - Generate execution plan
6. vlm execute - Execute plan (dry-run by default)
7. vlm execute --confirm - Actually execute operations
Use 'vlm COMMAND --help' for more information on a specific command.
"""
@@ -112,43 +161,7 @@ def main(ctx, config: Path, log_level: Optional[str]):
ctx.ensure_object(dict)
try:
# Load or create configuration
if config.exists():
try:
cfg = load_config(config)
except yaml.YAMLError as e:
click.echo(f"Error: Invalid YAML syntax in configuration file: {e}", err=True)
click.echo("Using default configuration values.", err=True)
cfg = create_default_config(config)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
click.echo("Using default configuration values.", err=True)
cfg = create_default_config(config)
else:
click.echo(f"Configuration file not found at {config}", err=True)
click.echo("Creating default configuration...", err=True)
cfg = create_default_config(config)
click.echo(f"Default configuration created at {config}", err=True)
# Validate configuration
validation_errors = validate_config(cfg)
if validation_errors:
click.echo("Configuration validation errors:", err=True)
for error in validation_errors:
click.echo(f" - {error}", err=True)
click.echo("Please fix the configuration file and try again.", err=True)
sys.exit(1)
# Override log level if specified on command line
if log_level:
cfg.log_level = log_level.upper()
# Set up logging
logger = setup_logging(log_level=cfg.log_level)
# Store context for subcommands
ctx.obj = CLIContext(config=cfg, logger=logger)
ctx.obj = _initialize_cli_context(config, log_level)
except Exception as e:
traceback.print_exc(file=sys.stderr)
click.echo(f"Error initializing VLM: {e}", err=True)
@@ -250,17 +263,20 @@ def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path])
from vlm.commands.parse import parse_cmd
parse_cmd(ctx, input, output, inventory)
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
ctx.logger.error(f"Input file not found: {input}")
sys.exit(1)
_command_error(
ctx,
f"Error: Input file not found: {input}",
f"Input file not found: {input}",
)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
ctx.logger.error(f"Parse failed: {e}")
sys.exit(1)
_command_error(ctx, f"Error: {e}", f"Parse failed: {e}")
except OSError as e:
click.echo(f"Error reading/writing files: {e}", err=True)
ctx.logger.error(f"Parse file I/O failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error reading/writing files: {e}",
f"Parse file I/O failed: {e}",
exc_info=True,
)
@main.command()
@@ -330,21 +346,27 @@ def enrich(
retries,
)
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
ctx.logger.error(f"Input file not found: {input}")
sys.exit(1)
_command_error(
ctx,
f"Error: Input file not found: {input}",
f"Input file not found: {input}",
)
except json.JSONDecodeError as e:
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
ctx.logger.error(f"JSON parsing failed during enrich: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error: Failed to parse JSON file: {e}",
f"JSON parsing failed during enrich: {e}",
exc_info=True,
)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
ctx.logger.error(f"Enrich validation failed: {e}")
sys.exit(1)
_command_error(ctx, f"Error: {e}", f"Enrich validation failed: {e}")
except OSError as e:
click.echo(f"Error reading/writing files: {e}", err=True)
ctx.logger.error(f"Enrich file I/O failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error reading/writing files: {e}",
f"Enrich file I/O failed: {e}",
exc_info=True,
)
@main.command()
@@ -385,17 +407,25 @@ def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path
from vlm.commands.analyze import analyze_cmd
analyze_cmd(ctx, input, output, inventory)
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
ctx.logger.error(f"Input file not found: {input}")
sys.exit(1)
_command_error(
ctx,
f"Error: Input file not found: {input}",
f"Input file not found: {input}",
)
except json.JSONDecodeError as e:
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
ctx.logger.error(f"JSON parsing failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error: Failed to parse JSON file: {e}",
f"JSON parsing failed: {e}",
exc_info=True,
)
except Exception as e:
click.echo(f"Error during analysis: {e}", err=True)
ctx.logger.error(f"Analysis failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error during analysis: {e}",
f"Analysis failed: {e}",
exc_info=True,
)
@main.command()
@@ -436,17 +466,25 @@ def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None):
from vlm.commands.plan import plan_cmd
plan_cmd(ctx, input, output, analysis)
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
ctx.logger.error(f"Input file not found: {input}")
sys.exit(1)
_command_error(
ctx,
f"Error: Input file not found: {input}",
f"Input file not found: {input}",
)
except json.JSONDecodeError as e:
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
ctx.logger.error(f"JSON parsing failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error: Failed to parse JSON file: {e}",
f"JSON parsing failed: {e}",
exc_info=True,
)
except Exception as e:
click.echo(f"Error during plan generation: {e}", err=True)
ctx.logger.error(f"Plan generation failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error during plan generation: {e}",
f"Plan generation failed: {e}",
exc_info=True,
)
@main.command(name="review-plan")
@@ -476,6 +514,19 @@ def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None):
show_default=True,
help='Flag operations with episode >= this value as high risk'
)
@click.option(
'--preview-limit',
type=int,
default=10,
show_default=True,
help='Number of high-risk operations to preview in console output'
)
@click.option(
'--show-all',
is_flag=True,
default=False,
help='Show all high-risk operations in console preview'
)
@pass_context
def review_plan_cmd(
ctx: CLIContext,
@@ -483,6 +534,8 @@ def review_plan_cmd(
output: Path,
season_threshold: int,
episode_threshold: int,
preview_limit: int,
show_all: bool,
):
"""Review a plan and export high-risk operations for manual confirmation."""
from vlm.planner import load_plan
@@ -495,6 +548,9 @@ def review_plan_cmd(
if season_threshold < 1 or episode_threshold < 1:
click.echo("Error: thresholds must be >= 1", err=True)
sys.exit(1)
if preview_limit < 1:
click.echo("Error: --preview-limit must be >= 1", err=True)
sys.exit(1)
click.echo(f"Loading plan: {input}")
execution_plan = load_plan(input)
@@ -506,6 +562,10 @@ def review_plan_cmd(
save_review_csv(rows, output)
click.echo()
click.echo("Plan overview:")
click.echo(preferred_plan_summary(execution_plan))
click.echo()
click.echo("Plan review summary:")
click.echo(f" Total operations: {counters['total_operations']}")
@@ -515,16 +575,28 @@ def review_plan_cmd(
click.echo(f" high_season: {counters['high_season']}")
click.echo(f" high_episode: {counters['high_episode']}")
click.echo(f" conflicts: {counters['conflicts']}")
click.echo()
click.echo("High-risk operations preview:")
if rows:
preview_lines, hidden_count = render_review_preview(
rows,
preview_limit=preview_limit,
show_all=show_all,
)
for line in preview_lines:
click.echo(line)
if hidden_count > 0:
click.echo(
f" ... and {hidden_count} more high-risk operations "
"(use --show-all to display all)"
)
else:
click.echo(" (none)")
click.echo()
click.echo(f"Saved manual review CSV to: {output}")
if rows:
click.echo("Top review samples:")
for row in rows[:5]:
click.echo(
f" - [{row['index']}] {row['operation_type']} {Path(row['source_path']).name} ({row['risk_flags']})"
)
logger.info(
"Plan review completed: total=%s high_risk=%s output=%s",
counters["total_operations"],
@@ -532,17 +604,86 @@ def review_plan_cmd(
output,
)
except FileNotFoundError:
click.echo(f"Error: Plan file not found: {input}", err=True)
logger.error(f"Plan file not found: {input}")
sys.exit(1)
_command_error(ctx, f"Error: Plan file not found: {input}", f"Plan file not found: {input}")
except json.JSONDecodeError as e:
click.echo(f"Error: Failed to parse plan JSON: {e}", err=True)
logger.error(f"Plan review JSON parsing failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error: Failed to parse plan JSON: {e}",
f"Plan review JSON parsing failed: {e}",
exc_info=True,
)
except Exception as e:
click.echo(f"Error during plan review: {e}", err=True)
logger.error(f"Plan review failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error during plan review: {e}",
f"Plan review failed: {e}",
exc_info=True,
)
@main.command(name="apply-review")
@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)'
)
@click.option(
'--csv',
type=click.Path(exists=True, path_type=Path),
default=Path('plan_manual_review.csv'),
help='Path to the modified manual review CSV (default: plan_manual_review.csv)'
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=None,
help='Path to save updated plan (default: overwrite input plan)'
)
@pass_context
def apply_review_cmd(
ctx: CLIContext,
plan: Path,
csv: Path,
output: Optional[Path],
):
"""Apply modifications from a manual review CSV back to the plan JSON.
This command reads the 'operation_type' column from the CSV and updates
the corresponding operations in the plan. This is the primary way to
manually approve or reject high-risk operations.
"""
from vlm.planner import load_plan, save_plan, apply_review_to_plan
logger = ctx.logger
output_path = output or plan
try:
click.echo(f"Loading plan: {plan}")
execution_plan = load_plan(plan)
click.echo(f"Applying review from: {csv}")
updated_plan = apply_review_to_plan(execution_plan, csv)
save_plan(updated_plan, output_path)
click.echo(f"Successfully updated plan saved to: {output_path}")
# Calculate changes for user feedback
modified = 0
for i, op in enumerate(updated_plan.operations):
if op.operation_type != execution_plan.operations[i].operation_type:
modified += 1
click.echo(f"Total operations modified: {modified}")
logger.info("Applied review from %s to %s, modified %s ops", csv, output_path, modified)
except Exception as e:
_command_error(
ctx,
f"Error applying review: {e}",
f"Apply review failed: {e}",
exc_info=True,
)
@main.command()
@@ -602,17 +743,20 @@ def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops:
from vlm.commands.execute import execute_cmd
execute_cmd(ctx, plan, confirm, yes, verbose_ops, preserve_directories, safe_mode)
except FileNotFoundError:
click.echo(f"Error: File not found: {plan}", err=True)
ctx.logger.error(f"Execution file not found: {plan}")
sys.exit(1)
_command_error(
ctx,
f"Error: File not found: {plan}",
f"Execution file not found: {plan}",
)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
ctx.logger.error(f"Execution validation failed: {e}")
sys.exit(1)
_command_error(ctx, f"Error: {e}", f"Execution validation failed: {e}")
except OSError as e:
click.echo(f"Error during execution: {e}", err=True)
ctx.logger.error(f"Execution I/O failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error during execution: {e}",
f"Execution I/O failed: {e}",
exc_info=True,
)
@main.group()
@@ -692,9 +836,12 @@ def quarantine_list(ctx: CLIContext, category: Optional[str]):
(f" from category '{category}'" if category else ""))
except Exception as e:
click.echo(f"Error listing quarantined files: {e}", err=True)
logger.error(f"Failed to list quarantined files: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error listing quarantined files: {e}",
f"Failed to list quarantined files: {e}",
exc_info=True,
)
@quarantine.command('add')
@@ -744,21 +891,25 @@ def quarantine_add(ctx: CLIContext, file: Path, reason: Optional[str]):
click.echo("To restore this file, run:")
click.echo(f" vlm quarantine restore {result.operation.destination_path}")
else:
click.echo(f"✗ Failed to quarantine file: {result.error_message}", err=True)
sys.exit(1)
_command_error(
ctx,
f"✗ Failed to quarantine file: {result.error_message}",
f"Quarantine failed for {file}: {result.error_message}",
)
logger.info(f"Quarantined file: {file}")
except ValueError as e:
# Category restriction error
click.echo(f"Error: {e}", err=True)
logger.error(f"Quarantine rejected: {e}")
sys.exit(1)
_command_error(ctx, f"Error: {e}", f"Quarantine rejected: {e}")
except Exception as e:
click.echo(f"Error quarantining file: {e}", err=True)
logger.error(f"Failed to quarantine file: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error quarantining file: {e}",
f"Failed to quarantine file: {e}",
exc_info=True,
)
@quarantine.command('restore')
@@ -800,14 +951,21 @@ def quarantine_restore(ctx: CLIContext, file: Path):
click.echo(f" Original location: {result.operation.destination_path}", err=True)
else:
click.echo(f"✗ Failed to restore file: {result.error_message}", err=True)
sys.exit(1)
_command_error(
ctx,
f"Error restoring file: {result.error_message or result.operation.conflict_reason or 'unknown error'}",
f"Failed to restore file: {file}",
)
logger.info(f"Restored file from quarantine: {file}")
except Exception as e:
click.echo(f"Error restoring file: {e}", err=True)
logger.error(f"Failed to restore file: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error restoring file: {e}",
f"Failed to restore file: {e}",
exc_info=True,
)
@main.command()
@@ -838,31 +996,21 @@ def rollback(ctx: CLIContext, log: Optional[Path]):
from vlm.commands.execute import rollback_cmd
rollback_cmd(ctx, log)
except FileNotFoundError as e:
click.echo(f"Error: {e}", err=True)
ctx.logger.error(f"Rollback log not found: {e}")
sys.exit(1)
_command_error(ctx, f"Error: {e}", f"Rollback log not found: {e}")
except ValueError as e:
click.echo(f"Error: {e}", err=True)
ctx.logger.error(f"Rollback failed: {e}")
sys.exit(1)
_command_error(ctx, f"Error: {e}", f"Rollback failed: {e}")
except OSError as e:
click.echo(f"Error during rollback: {e}", err=True)
ctx.logger.error(f"Rollback failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error during rollback: {e}",
f"Rollback failed: {e}",
exc_info=True,
)
def _fallback_plan_summary(execution_plan) -> str:
"""Build a short plan summary from summary and summary_by_reason when human_summary is empty."""
s = execution_plan.summary or {}
by_r = execution_plan.summary_by_reason or {}
total = s.get("total", len(execution_plan.operations))
parts = [
f"计划操作统计:共 {total} 条(move {s.get('move', 0)}rename {s.get('rename', 0)}"
f"quarantine {s.get('quarantine', 0)}no-op {s.get('no-op', 0)}"
]
if by_r:
parts.append("原因分布:" + "".join(f"{r}: {c}" for r, c in list(by_r.items())[:8]))
return "\n".join(parts)
return fallback_plan_summary(execution_plan)
@main.group()
@@ -945,14 +1093,15 @@ def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional
logger.info(f"Generated inventory report in {format} format with {len(video_files)} files")
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}")
sys.exit(1)
_command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
except Exception as e:
click.echo(f"Error generating inventory report: {e}", err=True)
logger.error(f"Inventory report generation failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error generating inventory report: {e}",
f"Inventory report generation failed: {e}",
exc_info=True,
)
@report.command('completeness')
@@ -1053,19 +1202,23 @@ def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optio
logger.info(f"Generated completeness report in {format} format with {len(season_completeness)} series")
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}")
sys.exit(1)
_command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
except json.JSONDecodeError as e:
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
logger.error(f"JSON parsing failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error: Failed to parse JSON file: {e}",
f"JSON parsing failed: {e}",
exc_info=True,
)
except Exception as e:
click.echo(f"Error generating completeness report: {e}", err=True)
logger.error(f"Completeness report generation failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error generating completeness report: {e}",
f"Completeness report generation failed: {e}",
exc_info=True,
)
@report.command('duplicates')
@@ -1208,19 +1361,23 @@ def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optiona
logger.info(f"Generated duplicate report in {format} format with {len(duplicate_groups)} groups")
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}")
sys.exit(1)
_command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
except json.JSONDecodeError as e:
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
logger.error(f"JSON parsing failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error: Failed to parse JSON file: {e}",
f"JSON parsing failed: {e}",
exc_info=True,
)
except Exception as e:
click.echo(f"Error generating duplicate report: {e}", err=True)
logger.error(f"Duplicate report generation failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error generating duplicate report: {e}",
f"Duplicate report generation failed: {e}",
exc_info=True,
)
@report.command('summary')
@@ -1283,14 +1440,15 @@ def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]):
logger.info(f"Generated summary report with {len(video_files)} files")
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}")
sys.exit(1)
_command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
except Exception as e:
click.echo(f"Error generating summary report: {e}", err=True)
logger.error(f"Summary report generation failed: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error generating summary report: {e}",
f"Summary report generation failed: {e}",
exc_info=True,
)
@main.group()
@@ -1344,9 +1502,12 @@ def state_show(ctx: CLIContext, file: Path):
logger.info(f"Showed state for file: {file}")
except Exception as e:
click.echo(f"Error showing file state: {e}", err=True)
logger.error(f"Failed to show file state: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error showing file state: {e}",
f"Failed to show file state: {e}",
exc_info=True,
)
@state.command('set')
@@ -1405,14 +1566,15 @@ def state_set(ctx: CLIContext, file: Path, status: str, reason: Optional[str]):
logger.info(f"Set state for file {file}: status={status}, reason={reason}")
except ValueError as e:
click.echo(f"Error: {e}", err=True)
logger.error(f"Invalid status: {e}")
sys.exit(1)
_command_error(ctx, f"Error: {e}", f"Invalid status: {e}")
except Exception as e:
click.echo(f"Error setting file state: {e}", err=True)
logger.error(f"Failed to set file state: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error setting file state: {e}",
f"Failed to set file state: {e}",
exc_info=True,
)
@state.command('query')
@@ -1470,9 +1632,12 @@ def state_query(ctx: CLIContext, status: str):
logger.info(f"Queried files with status '{status}': {len(file_states)} found")
except Exception as e:
click.echo(f"Error querying file states: {e}", err=True)
logger.error(f"Failed to query file states: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error querying file states: {e}",
f"Failed to query file states: {e}",
exc_info=True,
)
@state.command('clear')
@@ -1519,9 +1684,12 @@ def state_clear(ctx: CLIContext, file: Path):
logger.info(f"Cleared state for file: {file}")
except Exception as e:
click.echo(f"Error clearing file state: {e}", err=True)
logger.error(f"Failed to clear file state: {e}", exc_info=True)
sys.exit(1)
_command_error(
ctx,
f"Error clearing file state: {e}",
f"Failed to clear file state: {e}",
exc_info=True,
)
@main.group(name='config')
@@ -1556,8 +1724,12 @@ def config_init(ctx: CLIContext, path: Path):
click.echo("Edit this file to customize your settings.")
except Exception as e:
click.echo(f"Error creating configuration: {e}", err=True)
sys.exit(1)
_command_error(
ctx,
f"Error creating configuration: {e}",
f"Configuration creation failed: {e}",
exc_info=True,
)
@config_cmd.command('show')
@@ -1590,7 +1762,7 @@ def config_validate(ctx: CLIContext):
click.echo("Configuration validation errors:", err=True)
for error in errors:
click.echo(f" - {error}", err=True)
sys.exit(1)
_command_error(ctx, "Configuration validation failed.", "Configuration validation failed")
if __name__ == '__main__':
+8 -16
View File
@@ -10,6 +10,7 @@ import click
from vlm.context import CLIContext
from vlm.executor import ExecutionEngine
from vlm.planner import load_plan
from vlm.plan_render import preferred_plan_summary
from vlm.state import StateManager
@@ -58,7 +59,10 @@ def execute_cmd(
if safe_mode:
click.echo("Safe mode enabled - validating plan for directory preservation...")
emptied_dirs = execution_plan.metadata.get("emptied_directories", [])
validation_snapshot = execution_plan.metadata.get("validation_snapshot", {})
if not isinstance(validation_snapshot, dict):
validation_snapshot = {}
emptied_dirs = validation_snapshot.get("emptied_directories", execution_plan.metadata.get("emptied_directories", []))
if emptied_dirs:
raise ValueError(
"SAFE MODE VIOLATION: plan would empty directories. "
@@ -82,22 +86,10 @@ def execute_cmd(
click.echo(f"Created at: {execution_plan.created_at}")
click.echo(f"Total operations: {len(execution_plan.operations)}")
if execution_plan.human_summary:
plan_summary = preferred_plan_summary(execution_plan)
if plan_summary:
click.echo()
click.echo(execution_plan.human_summary)
elif execution_plan.summary or execution_plan.summary_by_reason:
s = execution_plan.summary or {}
by_r = execution_plan.summary_by_reason or {}
parts = [
"操作统计:共 "
f"{s.get('total', len(execution_plan.operations))}"
f"move {s.get('move', 0)}rename {s.get('rename', 0)}"
f"quarantine {s.get('quarantine', 0)}no-op {s.get('no-op', 0)}"
]
if by_r:
parts.append("原因分布:" + "".join(f"{r}: {c}" for r, c in list(by_r.items())[:5]))
click.echo()
click.echo("\n".join(parts))
click.echo(plan_summary)
click.echo()
if mode == "dry-run":
+66
View File
@@ -0,0 +1,66 @@
"""Utilities for rendering execution plan information in CLI output."""
from __future__ import annotations
from typing import Any
def fallback_plan_summary(execution_plan: Any) -> str:
"""Build a short plan summary from summary and summary_by_reason.
This is used when execution_plan.human_summary is missing or empty.
"""
summary = getattr(execution_plan, "summary", {}) or {}
summary_by_reason = getattr(execution_plan, "summary_by_reason", {}) or {}
operations = getattr(execution_plan, "operations", []) or []
total = summary.get("total", len(operations))
parts = [
f"计划操作统计:共 {total} 条(move {summary.get('move', 0)}rename {summary.get('rename', 0)}"
f"quarantine {summary.get('quarantine', 0)}no-op {summary.get('no-op', 0)}"
]
if summary_by_reason:
parts.append(
"原因分布:" + "".join(f"{reason}: {count}" for reason, count in list(summary_by_reason.items())[:8])
)
return "\n".join(parts)
def preferred_plan_summary(execution_plan: Any) -> str:
"""Return human_summary if available, otherwise fallback summary."""
human_summary = getattr(execution_plan, "human_summary", "")
if isinstance(human_summary, str) and human_summary.strip():
return human_summary
return fallback_plan_summary(execution_plan)
def render_review_preview(
rows: list[dict[str, str]],
preview_limit: int = 10,
show_all: bool = False,
) -> tuple[list[str], int]:
"""Render high-risk review rows for console preview.
Returns a tuple of (lines, remaining_count).
"""
if preview_limit < 1:
raise ValueError("preview_limit must be >= 1")
selected = rows if show_all else rows[:preview_limit]
remaining = 0 if show_all else max(0, len(rows) - len(selected))
lines: list[str] = []
for row in selected:
idx = row.get("index", "?")
operation_type = row.get("operation_type", "")
flags = row.get("risk_flags", "") or "none"
source_path = row.get("source_path", "")
destination_path = row.get("destination_path", "")
reason = row.get("reason", "")
lines.append(f" - [{idx}] {operation_type} | flags={flags}")
lines.append(f" source: {source_path}")
lines.append(f" destination: {destination_path or '(none)'}")
lines.append(f" reason: {reason}")
return lines, remaining