This commit is contained in:
225
src/code_privacy_shield/cli.py
Normal file
225
src/code_privacy_shield/cli.py
Normal file
@@ -0,0 +1,225 @@
|
||||
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))
|
||||
Reference in New Issue
Block a user