import sys from pathlib import Path from typing import List, Optional import click from . import __version__ from .config import Config from .redactor import Redactor from .patterns import PatternLibrary @click.group() @click.version_option(version=__version__) @click.option("--config", "-c", type=click.Path(), help="Path to config file") @click.option("--quiet", "-q", is_flag=True, help="Suppress output") @click.pass_context def main(ctx: click.Context, config: str, quiet: bool) -> None: ctx.ensure_object(dict) ctx.obj["config_path"] = config ctx.obj["quiet"] = quiet if quiet is not None else False @main.command() @click.argument("paths", nargs=-1, type=click.Path(exists=True)) @click.option("--output", "-o", type=click.Path(), help="Output file (stdout if not specified)") @click.option("--preview", "-p", is_flag=True, help="Preview what will be redacted") @click.option("--in-place", "-i", is_flag=True, help="Edit files in place") @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", help="Output format") @click.pass_context def redact(ctx: click.Context, paths: tuple, output: str, preview: bool, in_place: bool, output_format: str) -> None: quiet = ctx.obj.get("quiet", False) config_path = ctx.obj.get("config_path") config = Config(config_path) config.load() if preview: config.set("general.preview_mode", True) pattern_library = PatternLibrary() redactor = Redactor(pattern_library) for custom_pattern in config.get_custom_patterns(): redactor.add_custom_pattern( name=custom_pattern.get("name", "Custom"), pattern=custom_pattern.get("pattern", ""), category=custom_pattern.get("category", "custom"), ) if not paths: content = click.get_text_stream("stdin").read() result = redactor.redact(content, preserve_structure=config.should_preserve_structure()) if preview: _show_preview(result, output_format) else: click.echo(result.redacted) return for path_str in paths: path = Path(path_str) if path.is_file(): _process_file(redactor, path, output, preview, in_place, output_format, config) elif path.is_dir(): _process_directory(redactor, path, output, preview, in_place, output_format, config) def _process_file( redactor: Redactor, path: Path, output: Optional[str], preview: bool, in_place: bool, output_format: str, config: Config ) -> None: try: result = redactor.redact_file(str(path), encoding="utf-8") if preview: if not config.is_quiet_mode(): click.echo(f"\n=== {path} ===") _show_preview(result, output_format) elif in_place: path.write_text(result.redacted, encoding="utf-8") if not config.is_quiet_mode(): summary = redactor.get_match_summary(result) click.echo(f"Redacted {path}: {len(result.matches)} matches found") elif output: Path(output).write_text(result.redacted, encoding="utf-8") else: click.echo(result.redacted) except (IOError, UnicodeDecodeError) as e: click.echo(f"Error processing {path}: {e}", err=True) def _process_directory( redactor: Redactor, path: Path, output: Optional[str], preview: bool, in_place: bool, output_format: str, config: Config ) -> None: exclude_patterns = config.get_exclude_patterns() for file_path in path.rglob("*"): if file_path.is_file(): if _should_exclude(file_path, exclude_patterns): continue _process_file(redactor, file_path, output, preview, in_place, output_format, config) def _should_exclude(file_path: Path, exclude_patterns: List[str]) -> bool: for pattern in exclude_patterns: if file_path.match(pattern): return True if pattern.startswith("*") and file_path.name.endswith(pattern[1:]): return True return False def _show_preview(result, output_format: str) -> None: if output_format == "json": import json preview_data = { "matches": [ { "category": m.category, "name": m.name, "original": m.original, "replacement": m.replacement, } for m in result.matches ], "total_matches": len(result.matches), "categories": list(result.categories), } click.echo(json.dumps(preview_data, indent=2)) else: for match in result.matches: click.echo(f"[{match.category}] {match.name}: {match.original} -> {match.replacement}") @main.command() @click.argument("path", type=click.Path()) @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", help="Output format") @click.pass_context def preview(ctx: click.Context, path: str, output_format: str) -> None: ctx.invoke(redact, paths=(path,), preview=True, output_format=output_format) @main.command() @click.argument("path", type=click.Path()) @click.pass_context def check(ctx: click.Context, path: str) -> None: config_path = ctx.obj.get("config_path") config = Config(config_path) config.load() pattern_library = PatternLibrary() redactor = Redactor(pattern_library) try: if Path(path).is_file(): result = redactor.redact_file(path, encoding="utf-8") else: content = Path(path).read_text(encoding="utf-8") result = redactor.redact(content) if result.matches: click.echo(f"Found {len(result.matches)} potential sensitive items:") summary = redactor.get_match_summary(result) for key, count in sorted(summary.items()): click.echo(f" - {key}: {count}") sys.exit(1) else: click.echo("No sensitive data detected.") sys.exit(0) except (IOError, UnicodeDecodeError) as e: click.echo(f"Error reading {path}: {e}", err=True) sys.exit(1) @main.command() @click.argument("path", type=click.Path()) @click.pass_context def init_config(ctx: click.Context, path: str) -> None: if Config.create_example_config(path): click.echo(f"Created example config at {path}") else: click.echo("Failed to create config file", err=True) sys.exit(1) @main.command() @click.pass_context def config_locations(ctx: click.Context) -> None: click.echo(f"Project config: {Config.get_project_config_path()}") click.echo(f"User config: {Config.get_default_config_path()}") @main.group() def config() -> None: """Manage configuration.""" pass @config.command("show") @click.pass_context def config_show(ctx: click.Context) -> None: import toml config_path = ctx.obj.get("config_path") config = Config(config_path) config.load() click.echo(toml.dumps(config.config))