expunct-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- expunct_cli/__init__.py +1 -0
- expunct_cli/client.py +49 -0
- expunct_cli/commands/__init__.py +0 -0
- expunct_cli/commands/audit.py +56 -0
- expunct_cli/commands/config_cmd.py +80 -0
- expunct_cli/commands/detect.py +106 -0
- expunct_cli/commands/jobs.py +108 -0
- expunct_cli/commands/policies.py +189 -0
- expunct_cli/commands/redact.py +120 -0
- expunct_cli/main.py +46 -0
- expunct_cli/output.py +140 -0
- expunct_cli-0.1.0.dist-info/METADATA +349 -0
- expunct_cli-0.1.0.dist-info/RECORD +15 -0
- expunct_cli-0.1.0.dist-info/WHEEL +4 -0
- expunct_cli-0.1.0.dist-info/entry_points.txt +2 -0
expunct_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
expunct_cli/client.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from expunct import Expunct
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path.home() / ".expunct"
|
|
11
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
12
|
+
|
|
13
|
+
console = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config() -> dict:
|
|
17
|
+
if CONFIG_FILE.exists():
|
|
18
|
+
with open(CONFIG_FILE) as f:
|
|
19
|
+
return json.load(f)
|
|
20
|
+
return {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def save_config(config: dict) -> None:
|
|
24
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
with open(CONFIG_FILE, "w") as f:
|
|
26
|
+
json.dump(config, f, indent=2)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_client() -> Expunct:
|
|
30
|
+
config = load_config()
|
|
31
|
+
|
|
32
|
+
api_key = os.environ.get("EXPUNCT_API_KEY") or config.get("api_key")
|
|
33
|
+
base_url = os.environ.get("EXPUNCT_BASE_URL") or config.get("base_url")
|
|
34
|
+
tenant_id = os.environ.get("EXPUNCT_TENANT_ID") or config.get("tenant_id")
|
|
35
|
+
|
|
36
|
+
if not api_key:
|
|
37
|
+
console.print(
|
|
38
|
+
"[bold red]Error:[/] No API key found. "
|
|
39
|
+
"Set EXPUNCT_API_KEY or run: expunct config set api_key YOUR_KEY"
|
|
40
|
+
)
|
|
41
|
+
raise SystemExit(1)
|
|
42
|
+
|
|
43
|
+
kwargs: dict = {"api_key": api_key}
|
|
44
|
+
if base_url:
|
|
45
|
+
kwargs["base_url"] = base_url
|
|
46
|
+
if tenant_id:
|
|
47
|
+
kwargs["tenant_id"] = tenant_id
|
|
48
|
+
|
|
49
|
+
return Expunct(**kwargs)
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from expunct import (
|
|
5
|
+
ApiError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
ValidationError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from expunct_cli.client import get_client
|
|
12
|
+
from expunct_cli.output import print_error, print_json
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("list")
|
|
18
|
+
def list_audit(
|
|
19
|
+
page: int = typer.Option(1, "--page", help="Page number."),
|
|
20
|
+
page_size: int = typer.Option(20, "--page-size", help="Results per page."),
|
|
21
|
+
event_type: str | None = typer.Option(None, "--event-type", "-e", help="Filter by event type."),
|
|
22
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""List audit log entries."""
|
|
25
|
+
try:
|
|
26
|
+
client = get_client()
|
|
27
|
+
result = client.audit.list(page=page, page_size=page_size, event_type=event_type)
|
|
28
|
+
|
|
29
|
+
if json_output:
|
|
30
|
+
print_json(result)
|
|
31
|
+
else:
|
|
32
|
+
from rich.console import Console
|
|
33
|
+
from rich.table import Table
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
items = getattr(result, "items", getattr(result, "events", []))
|
|
37
|
+
total = getattr(result, "total", len(items))
|
|
38
|
+
|
|
39
|
+
table = Table(title=f"Audit Log (page {page}, {total} total)")
|
|
40
|
+
table.add_column("Timestamp", style="dim")
|
|
41
|
+
table.add_column("Event Type", style="cyan")
|
|
42
|
+
table.add_column("User", style="bold")
|
|
43
|
+
table.add_column("Details", style="dim")
|
|
44
|
+
|
|
45
|
+
for item in items:
|
|
46
|
+
timestamp = str(getattr(item, "timestamp", getattr(item, "created_at", "")))
|
|
47
|
+
event = str(getattr(item, "event_type", ""))
|
|
48
|
+
user = str(getattr(item, "user", getattr(item, "user_id", "")))
|
|
49
|
+
details = str(getattr(item, "details", getattr(item, "description", "")))
|
|
50
|
+
table.add_row(timestamp, event, user, details)
|
|
51
|
+
|
|
52
|
+
console.print(table)
|
|
53
|
+
|
|
54
|
+
except (ApiError, AuthenticationError, RateLimitError, ValidationError) as e:
|
|
55
|
+
print_error(str(e))
|
|
56
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from expunct_cli.client import CONFIG_FILE, load_config, save_config
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(no_args_is_help=True)
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
VALID_KEYS = {"api_key", "base_url", "tenant_id"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command("set")
|
|
17
|
+
def config_set(
|
|
18
|
+
key: str = typer.Argument(help="Config key (api_key, base_url, tenant_id)."),
|
|
19
|
+
value: str = typer.Argument(help="Config value."),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Set a configuration value."""
|
|
22
|
+
if key not in VALID_KEYS:
|
|
23
|
+
valid = ", ".join(sorted(VALID_KEYS))
|
|
24
|
+
console.print(
|
|
25
|
+
f"[bold red]Error:[/] Invalid key '{key}'."
|
|
26
|
+
f" Valid keys: {valid}"
|
|
27
|
+
)
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
config = load_config()
|
|
31
|
+
config[key] = value
|
|
32
|
+
save_config(config)
|
|
33
|
+
display_value = _mask(value) if key == "api_key" else value
|
|
34
|
+
console.print(f"[green]✓[/] Set {key} = {display_value}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("get")
|
|
38
|
+
def config_get(
|
|
39
|
+
key: str = typer.Argument(help="Config key to read."),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Get a configuration value."""
|
|
42
|
+
config = load_config()
|
|
43
|
+
value = config.get(key)
|
|
44
|
+
if value is None:
|
|
45
|
+
console.print(f"[dim]Key '{key}' is not set.[/]")
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
|
|
48
|
+
display_value = _mask(value) if key == "api_key" else value
|
|
49
|
+
typer.echo(display_value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("show")
|
|
53
|
+
def config_show() -> None:
|
|
54
|
+
"""Show all configuration values."""
|
|
55
|
+
config = load_config()
|
|
56
|
+
if not config:
|
|
57
|
+
console.print("[dim]No configuration set. Run: expunct config set api_key YOUR_KEY[/]")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
table = Table(title="Configuration")
|
|
61
|
+
table.add_column("Key", style="cyan")
|
|
62
|
+
table.add_column("Value")
|
|
63
|
+
|
|
64
|
+
for key in sorted(config):
|
|
65
|
+
value = _mask(config[key]) if key == "api_key" else config[key]
|
|
66
|
+
table.add_row(key, str(value))
|
|
67
|
+
|
|
68
|
+
console.print(table)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("path")
|
|
72
|
+
def config_path() -> None:
|
|
73
|
+
"""Print the config file path."""
|
|
74
|
+
typer.echo(str(CONFIG_FILE))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _mask(value: str) -> str:
|
|
78
|
+
if len(value) <= 8:
|
|
79
|
+
return "****"
|
|
80
|
+
return value[:4] + "****" + value[-4:]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from expunct import (
|
|
9
|
+
ApiError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PollingTimeoutError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from expunct_cli.client import get_client
|
|
18
|
+
from expunct_cli.output import print_error, print_findings, print_json
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(no_args_is_help=False, invoke_without_command=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback(invoke_without_command=True)
|
|
24
|
+
def detect(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
file: Path | None = typer.Argument(None, help="File to scan for PII."),
|
|
27
|
+
text: str | None = typer.Option(None, "--text", "-t", help="Inline text to scan."),
|
|
28
|
+
uri: str | None = typer.Option(None, "--uri", "-u", help="URI to scan (e.g., gs://bucket/file.txt)."),
|
|
29
|
+
language: str = typer.Option("en", "--language", "-l", help="Language code."),
|
|
30
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
31
|
+
timeout: int = typer.Option(300, "--timeout", help="Timeout in seconds for waiting."),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Detect PII entities in text, files, or URIs."""
|
|
34
|
+
if ctx.invoked_subcommand is not None:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
client = get_client()
|
|
39
|
+
|
|
40
|
+
if text is not None:
|
|
41
|
+
_detect_text(client, text, language, json_output, timeout)
|
|
42
|
+
elif uri is not None:
|
|
43
|
+
_detect_uri(client, uri, language, json_output, timeout)
|
|
44
|
+
elif file is not None:
|
|
45
|
+
_detect_file(client, file, language, json_output, timeout)
|
|
46
|
+
elif not sys.stdin.isatty():
|
|
47
|
+
stdin_text = sys.stdin.read()
|
|
48
|
+
_detect_text(client, stdin_text, language, json_output, timeout)
|
|
49
|
+
else:
|
|
50
|
+
typer.echo(ctx.get_help())
|
|
51
|
+
raise typer.Exit()
|
|
52
|
+
|
|
53
|
+
except (
|
|
54
|
+
ApiError, AuthenticationError, NotFoundError,
|
|
55
|
+
RateLimitError, ValidationError, PollingTimeoutError,
|
|
56
|
+
) as e:
|
|
57
|
+
print_error(str(e))
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _detect_text(client, text: str, language: str, json_output: bool, timeout: int) -> None:
|
|
62
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp:
|
|
63
|
+
tmp.write(text)
|
|
64
|
+
tmp.flush()
|
|
65
|
+
tmp_path = tmp.name
|
|
66
|
+
|
|
67
|
+
with open(tmp_path, "rb") as f:
|
|
68
|
+
job = client.redact.file(f, language=language)
|
|
69
|
+
|
|
70
|
+
result = client.wait_for_job(job.id, timeout=timeout)
|
|
71
|
+
_output_findings(result, json_output)
|
|
72
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _detect_file(client, file: Path, language: str, json_output: bool, timeout: int) -> None:
|
|
76
|
+
if not file.exists():
|
|
77
|
+
print_error(f"File not found: {file}")
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
|
|
80
|
+
with open(file, "rb") as f:
|
|
81
|
+
job = client.redact.file(f, language=language)
|
|
82
|
+
|
|
83
|
+
result = client.wait_for_job(job.id, timeout=timeout)
|
|
84
|
+
_output_findings(result, json_output)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _detect_uri(client, uri: str, language: str, json_output: bool, timeout: int) -> None:
|
|
88
|
+
job = client.redact.uri(uri, language=language)
|
|
89
|
+
result = client.wait_for_job(job.id, timeout=timeout)
|
|
90
|
+
_output_findings(result, json_output)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _output_findings(result, json_output: bool) -> None:
|
|
94
|
+
findings = getattr(result, "findings", []) or []
|
|
95
|
+
if json_output:
|
|
96
|
+
findings_data = []
|
|
97
|
+
for f in findings:
|
|
98
|
+
if hasattr(f, "model_dump"):
|
|
99
|
+
findings_data.append(f.model_dump())
|
|
100
|
+
elif hasattr(f, "__dict__"):
|
|
101
|
+
findings_data.append(f.__dict__)
|
|
102
|
+
else:
|
|
103
|
+
findings_data.append(f)
|
|
104
|
+
print_json({"findings": findings_data, "count": len(findings_data)})
|
|
105
|
+
else:
|
|
106
|
+
print_findings(findings)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from expunct import (
|
|
7
|
+
ApiError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
PollingTimeoutError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from expunct_cli.client import get_client
|
|
16
|
+
from expunct_cli.output import print_error, print_job, print_jobs_table, print_json
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(no_args_is_help=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("list")
|
|
22
|
+
def list_jobs(
|
|
23
|
+
page: int = typer.Option(1, "--page", help="Page number."),
|
|
24
|
+
page_size: int = typer.Option(20, "--page-size", help="Results per page."),
|
|
25
|
+
status: str | None = typer.Option(None, "--status", "-s", help="Filter by status."),
|
|
26
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""List redaction jobs."""
|
|
29
|
+
try:
|
|
30
|
+
client = get_client()
|
|
31
|
+
result = client.jobs.list(page=page, page_size=page_size, status=status)
|
|
32
|
+
|
|
33
|
+
if json_output:
|
|
34
|
+
print_json(result)
|
|
35
|
+
else:
|
|
36
|
+
jobs = getattr(result, "items", getattr(result, "jobs", []))
|
|
37
|
+
total = getattr(result, "total", len(jobs))
|
|
38
|
+
print_jobs_table(jobs, total, page, page_size)
|
|
39
|
+
|
|
40
|
+
except (ApiError, AuthenticationError, RateLimitError, ValidationError) as e:
|
|
41
|
+
print_error(str(e))
|
|
42
|
+
raise typer.Exit(1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command("get")
|
|
46
|
+
def get_job(
|
|
47
|
+
job_id: str = typer.Argument(help="Job ID."),
|
|
48
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Get details of a specific job."""
|
|
51
|
+
try:
|
|
52
|
+
client = get_client()
|
|
53
|
+
result = client.jobs.get(job_id)
|
|
54
|
+
|
|
55
|
+
if json_output:
|
|
56
|
+
print_json(result)
|
|
57
|
+
else:
|
|
58
|
+
print_job(result)
|
|
59
|
+
|
|
60
|
+
except (ApiError, AuthenticationError, NotFoundError, RateLimitError, ValidationError) as e:
|
|
61
|
+
print_error(str(e))
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command("download")
|
|
66
|
+
def download_job(
|
|
67
|
+
job_id: str = typer.Argument(help="Job ID."),
|
|
68
|
+
output: Path = typer.Option(..., "--output", "-o", help="Output file path."),
|
|
69
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Download the redacted result of a job."""
|
|
72
|
+
try:
|
|
73
|
+
client = get_client()
|
|
74
|
+
client.jobs.download(job_id, dest=str(output))
|
|
75
|
+
|
|
76
|
+
if json_output:
|
|
77
|
+
print_json({"job_id": job_id, "output": str(output), "size": output.stat().st_size})
|
|
78
|
+
else:
|
|
79
|
+
typer.echo(f"Downloaded to {output} ({output.stat().st_size} bytes)")
|
|
80
|
+
|
|
81
|
+
except (ApiError, AuthenticationError, NotFoundError, RateLimitError, ValidationError) as e:
|
|
82
|
+
print_error(str(e))
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("wait")
|
|
87
|
+
def wait_job(
|
|
88
|
+
job_id: str = typer.Argument(help="Job ID."),
|
|
89
|
+
timeout: int = typer.Option(300, "--timeout", help="Timeout in seconds."),
|
|
90
|
+
interval: int = typer.Option(2, "--interval", help="Polling interval in seconds."),
|
|
91
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Wait for a job to complete, then show details."""
|
|
94
|
+
try:
|
|
95
|
+
client = get_client()
|
|
96
|
+
result = client.wait_for_job(job_id, interval=interval, timeout=timeout)
|
|
97
|
+
|
|
98
|
+
if json_output:
|
|
99
|
+
print_json(result)
|
|
100
|
+
else:
|
|
101
|
+
print_job(result)
|
|
102
|
+
|
|
103
|
+
except (
|
|
104
|
+
ApiError, AuthenticationError, NotFoundError,
|
|
105
|
+
RateLimitError, ValidationError, PollingTimeoutError,
|
|
106
|
+
) as e:
|
|
107
|
+
print_error(str(e))
|
|
108
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from expunct import (
|
|
5
|
+
ApiError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from expunct_cli.client import get_client
|
|
13
|
+
from expunct_cli.output import print_error, print_json, print_policies_table
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("list")
|
|
19
|
+
def list_policies(
|
|
20
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""List all redaction policies."""
|
|
23
|
+
try:
|
|
24
|
+
client = get_client()
|
|
25
|
+
result = client.policies.list()
|
|
26
|
+
|
|
27
|
+
if json_output:
|
|
28
|
+
print_json(result)
|
|
29
|
+
else:
|
|
30
|
+
policies = result if isinstance(result, list) else getattr(result, "items", [])
|
|
31
|
+
print_policies_table(policies)
|
|
32
|
+
|
|
33
|
+
except (ApiError, AuthenticationError, RateLimitError, ValidationError) as e:
|
|
34
|
+
print_error(str(e))
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command("get")
|
|
39
|
+
def get_policy(
|
|
40
|
+
policy_id: str = typer.Argument(help="Policy ID."),
|
|
41
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Get details of a specific policy."""
|
|
44
|
+
try:
|
|
45
|
+
client = get_client()
|
|
46
|
+
result = client.policies.get(policy_id)
|
|
47
|
+
|
|
48
|
+
if json_output:
|
|
49
|
+
print_json(result)
|
|
50
|
+
else:
|
|
51
|
+
from rich.console import Console
|
|
52
|
+
from rich.panel import Panel
|
|
53
|
+
from rich.table import Table
|
|
54
|
+
|
|
55
|
+
console = Console()
|
|
56
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
57
|
+
table.add_column("Field", style="bold cyan")
|
|
58
|
+
table.add_column("Value")
|
|
59
|
+
|
|
60
|
+
for field in [
|
|
61
|
+
"id", "name", "redaction_method",
|
|
62
|
+
"confidence_threshold", "languages",
|
|
63
|
+
"pii_categories",
|
|
64
|
+
]:
|
|
65
|
+
value = getattr(result, field, None)
|
|
66
|
+
if value is not None:
|
|
67
|
+
table.add_row(field, str(value))
|
|
68
|
+
|
|
69
|
+
console.print(Panel(table, title="Policy Details", border_style="blue"))
|
|
70
|
+
|
|
71
|
+
except (ApiError, AuthenticationError, NotFoundError, RateLimitError, ValidationError) as e:
|
|
72
|
+
print_error(str(e))
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command("create")
|
|
77
|
+
def create_policy(
|
|
78
|
+
name: str = typer.Option(..., "--name", "-n", help="Policy name."),
|
|
79
|
+
redaction_method: str | None = typer.Option(
|
|
80
|
+
None, "--redaction-method", help="Redaction method.",
|
|
81
|
+
),
|
|
82
|
+
confidence_threshold: float | None = typer.Option(
|
|
83
|
+
None, "--confidence-threshold",
|
|
84
|
+
help="Confidence threshold (0.0-1.0).",
|
|
85
|
+
),
|
|
86
|
+
languages: list[str] | None = typer.Option(
|
|
87
|
+
None, "--languages", help="Supported languages.",
|
|
88
|
+
),
|
|
89
|
+
pii_categories: list[str] | None = typer.Option(
|
|
90
|
+
None, "--pii-categories",
|
|
91
|
+
help="PII categories to detect.",
|
|
92
|
+
),
|
|
93
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Create a new redaction policy."""
|
|
96
|
+
try:
|
|
97
|
+
client = get_client()
|
|
98
|
+
|
|
99
|
+
kwargs: dict = {"name": name}
|
|
100
|
+
if redaction_method is not None:
|
|
101
|
+
kwargs["redaction_method"] = redaction_method
|
|
102
|
+
if confidence_threshold is not None:
|
|
103
|
+
kwargs["confidence_threshold"] = confidence_threshold
|
|
104
|
+
if languages is not None:
|
|
105
|
+
kwargs["languages"] = languages
|
|
106
|
+
if pii_categories is not None:
|
|
107
|
+
kwargs["pii_categories"] = pii_categories
|
|
108
|
+
|
|
109
|
+
result = client.policies.create(**kwargs)
|
|
110
|
+
|
|
111
|
+
if json_output:
|
|
112
|
+
print_json(result)
|
|
113
|
+
else:
|
|
114
|
+
typer.echo(f"Policy created: {getattr(result, 'id', result)}")
|
|
115
|
+
|
|
116
|
+
except (ApiError, AuthenticationError, RateLimitError, ValidationError) as e:
|
|
117
|
+
print_error(str(e))
|
|
118
|
+
raise typer.Exit(1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("update")
|
|
122
|
+
def update_policy(
|
|
123
|
+
policy_id: str = typer.Argument(help="Policy ID."),
|
|
124
|
+
name: str | None = typer.Option(
|
|
125
|
+
None, "--name", "-n", help="Policy name.",
|
|
126
|
+
),
|
|
127
|
+
redaction_method: str | None = typer.Option(
|
|
128
|
+
None, "--redaction-method", help="Redaction method.",
|
|
129
|
+
),
|
|
130
|
+
confidence_threshold: float | None = typer.Option(
|
|
131
|
+
None, "--confidence-threshold",
|
|
132
|
+
help="Confidence threshold (0.0-1.0).",
|
|
133
|
+
),
|
|
134
|
+
languages: list[str] | None = typer.Option(
|
|
135
|
+
None, "--languages", help="Supported languages.",
|
|
136
|
+
),
|
|
137
|
+
pii_categories: list[str] | None = typer.Option(
|
|
138
|
+
None, "--pii-categories",
|
|
139
|
+
help="PII categories to detect.",
|
|
140
|
+
),
|
|
141
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Update an existing redaction policy."""
|
|
144
|
+
try:
|
|
145
|
+
client = get_client()
|
|
146
|
+
|
|
147
|
+
kwargs: dict = {}
|
|
148
|
+
if name is not None:
|
|
149
|
+
kwargs["name"] = name
|
|
150
|
+
if redaction_method is not None:
|
|
151
|
+
kwargs["redaction_method"] = redaction_method
|
|
152
|
+
if confidence_threshold is not None:
|
|
153
|
+
kwargs["confidence_threshold"] = confidence_threshold
|
|
154
|
+
if languages is not None:
|
|
155
|
+
kwargs["languages"] = languages
|
|
156
|
+
if pii_categories is not None:
|
|
157
|
+
kwargs["pii_categories"] = pii_categories
|
|
158
|
+
|
|
159
|
+
result = client.policies.update(policy_id, **kwargs)
|
|
160
|
+
|
|
161
|
+
if json_output:
|
|
162
|
+
print_json(result)
|
|
163
|
+
else:
|
|
164
|
+
typer.echo(f"Policy updated: {policy_id}")
|
|
165
|
+
|
|
166
|
+
except (ApiError, AuthenticationError, NotFoundError, RateLimitError, ValidationError) as e:
|
|
167
|
+
print_error(str(e))
|
|
168
|
+
raise typer.Exit(1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command("delete")
|
|
172
|
+
def delete_policy(
|
|
173
|
+
policy_id: str = typer.Argument(help="Policy ID."),
|
|
174
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Delete a redaction policy."""
|
|
177
|
+
try:
|
|
178
|
+
if not yes:
|
|
179
|
+
confirm = typer.confirm(f"Delete policy {policy_id}?")
|
|
180
|
+
if not confirm:
|
|
181
|
+
raise typer.Abort()
|
|
182
|
+
|
|
183
|
+
client = get_client()
|
|
184
|
+
client.policies.delete(policy_id)
|
|
185
|
+
typer.echo(f"Policy deleted: {policy_id}")
|
|
186
|
+
|
|
187
|
+
except (ApiError, AuthenticationError, NotFoundError, RateLimitError, ValidationError) as e:
|
|
188
|
+
print_error(str(e))
|
|
189
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from expunct import (
|
|
8
|
+
ApiError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
PollingTimeoutError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from expunct_cli.client import get_client
|
|
17
|
+
from expunct_cli.output import print_error, print_job, print_json, print_redacted
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(no_args_is_help=False, invoke_without_command=True)
|
|
20
|
+
|
|
21
|
+
BINARY_EXTENSIONS = {
|
|
22
|
+
".pdf", ".docx", ".xlsx", ".pptx", ".png", ".jpg",
|
|
23
|
+
".jpeg", ".gif", ".bmp", ".tiff", ".mp4", ".avi",
|
|
24
|
+
".mov", ".mkv", ".wav", ".mp3", ".flac", ".ogg",
|
|
25
|
+
".zip",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.callback(invoke_without_command=True)
|
|
30
|
+
def redact(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
file: Path | None = typer.Argument(None, help="File to redact. Omit to read from stdin."),
|
|
33
|
+
text: str | None = typer.Option(None, "--text", "-t", help="Inline text to redact."),
|
|
34
|
+
uri: str | None = typer.Option(None, "--uri", "-u", help="URI to redact (e.g., gs://bucket/file.txt)."),
|
|
35
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Output file path."),
|
|
36
|
+
language: str = typer.Option("en", "--language", "-l", help="Language code."),
|
|
37
|
+
policy_id: str | None = typer.Option(None, "--policy-id", "-p", help="Policy ID to use."),
|
|
38
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
39
|
+
wait: bool = typer.Option(True, "--wait/--no-wait", help="Wait for URI job to complete."),
|
|
40
|
+
timeout: int = typer.Option(300, "--timeout", help="Timeout in seconds for waiting."),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Redact PII from text, files, or URIs."""
|
|
43
|
+
if ctx.invoked_subcommand is not None:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
client = get_client()
|
|
48
|
+
|
|
49
|
+
if text is not None:
|
|
50
|
+
_redact_text(client, text, language, json_output)
|
|
51
|
+
elif uri is not None:
|
|
52
|
+
_redact_uri(client, uri, language, json_output, wait, timeout)
|
|
53
|
+
elif file is not None:
|
|
54
|
+
_redact_file(client, file, output, language, json_output)
|
|
55
|
+
elif not sys.stdin.isatty():
|
|
56
|
+
stdin_text = sys.stdin.read()
|
|
57
|
+
_redact_text(client, stdin_text, language, json_output)
|
|
58
|
+
else:
|
|
59
|
+
typer.echo(ctx.get_help())
|
|
60
|
+
raise typer.Exit()
|
|
61
|
+
|
|
62
|
+
except (
|
|
63
|
+
ApiError, AuthenticationError, NotFoundError,
|
|
64
|
+
RateLimitError, ValidationError, PollingTimeoutError,
|
|
65
|
+
) as e:
|
|
66
|
+
print_error(str(e))
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _redact_text(client, text: str, language: str, json_output: bool) -> None:
|
|
71
|
+
redacted = client.sanitize_text(text, language=language)
|
|
72
|
+
if json_output:
|
|
73
|
+
print_json({"original": text, "redacted": redacted})
|
|
74
|
+
else:
|
|
75
|
+
if sys.stdout.isatty():
|
|
76
|
+
print_redacted(text, redacted)
|
|
77
|
+
else:
|
|
78
|
+
sys.stdout.write(redacted)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _redact_file(client, file: Path, output: Path | None, language: str, json_output: bool) -> None:
|
|
82
|
+
if not file.exists():
|
|
83
|
+
print_error(f"File not found: {file}")
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
|
|
86
|
+
is_binary = file.suffix.lower() in BINARY_EXTENSIONS
|
|
87
|
+
if is_binary and output is None:
|
|
88
|
+
print_error(f"Binary file type ({file.suffix}) requires --output/-o flag.")
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
|
|
91
|
+
with open(file, "rb") as f:
|
|
92
|
+
result = client.sanitize_file(f, language=language, dest=str(output) if output else None)
|
|
93
|
+
|
|
94
|
+
if output:
|
|
95
|
+
typer.echo(f"Redacted file written to {output}", err=True)
|
|
96
|
+
else:
|
|
97
|
+
if isinstance(result, bytes):
|
|
98
|
+
text = result.decode("utf-8")
|
|
99
|
+
else:
|
|
100
|
+
text = str(result)
|
|
101
|
+
if json_output:
|
|
102
|
+
print_json({"redacted": text})
|
|
103
|
+
else:
|
|
104
|
+
sys.stdout.write(text)
|
|
105
|
+
if sys.stdout.isatty() and not text.endswith("\n"):
|
|
106
|
+
sys.stdout.write("\n")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _redact_uri(
|
|
110
|
+
client, uri: str, language: str,
|
|
111
|
+
json_output: bool, wait: bool, timeout: int,
|
|
112
|
+
) -> None:
|
|
113
|
+
if wait:
|
|
114
|
+
result = client.sanitize_uri(uri, language=language)
|
|
115
|
+
else:
|
|
116
|
+
result = client.redact.uri(uri, language=language)
|
|
117
|
+
if json_output:
|
|
118
|
+
print_json(result)
|
|
119
|
+
else:
|
|
120
|
+
print_job(result)
|
expunct_cli/main.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from expunct_cli import __version__
|
|
6
|
+
from expunct_cli.commands.audit import app as audit_app
|
|
7
|
+
from expunct_cli.commands.config_cmd import app as config_app
|
|
8
|
+
from expunct_cli.commands.detect import app as detect_app
|
|
9
|
+
from expunct_cli.commands.jobs import app as jobs_app
|
|
10
|
+
from expunct_cli.commands.policies import app as policies_app
|
|
11
|
+
from expunct_cli.commands.redact import app as redact_app
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="expunct",
|
|
15
|
+
help="Expunct CLI — PII redaction from the command line.",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def version_callback(value: bool) -> None:
|
|
21
|
+
if value:
|
|
22
|
+
typer.echo(f"expunct-cli {__version__}")
|
|
23
|
+
raise typer.Exit()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.callback()
|
|
27
|
+
def main(
|
|
28
|
+
version: bool = typer.Option(
|
|
29
|
+
False, "--version", "-v",
|
|
30
|
+
help="Show version and exit.",
|
|
31
|
+
callback=version_callback,
|
|
32
|
+
is_eager=True
|
|
33
|
+
),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Expunct CLI — PII redaction from the command line."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
app.add_typer(redact_app, name="redact", help="Redact PII from text, files, or URIs.")
|
|
39
|
+
app.add_typer(detect_app, name="detect", help="Detect PII entities without redacting.")
|
|
40
|
+
app.add_typer(jobs_app, name="jobs", help="Manage redaction jobs.")
|
|
41
|
+
app.add_typer(policies_app, name="policies", help="Manage redaction policies.")
|
|
42
|
+
app.add_typer(audit_app, name="audit", help="View audit logs.")
|
|
43
|
+
app.add_typer(config_app, name="config", help="Manage CLI configuration.")
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
app()
|
expunct_cli/output.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
error_console = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def print_json(data: Any) -> None:
|
|
17
|
+
if hasattr(data, "model_dump"):
|
|
18
|
+
data = data.model_dump()
|
|
19
|
+
elif hasattr(data, "__dict__") and not isinstance(data, dict):
|
|
20
|
+
data = data.__dict__
|
|
21
|
+
json.dump(data, sys.stdout, indent=2, default=str)
|
|
22
|
+
sys.stdout.write("\n")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def print_redacted(original: str, redacted: str) -> None:
|
|
26
|
+
console.print(Panel(original, title="Original", border_style="dim"))
|
|
27
|
+
console.print(Panel(redacted, title="Redacted", border_style="green"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def print_findings(findings: list) -> None:
|
|
31
|
+
if not findings:
|
|
32
|
+
console.print("[dim]No PII entities detected.[/]")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
table = Table(title="PII Findings")
|
|
36
|
+
table.add_column("Entity Type", style="cyan")
|
|
37
|
+
table.add_column("Entity Value", style="red")
|
|
38
|
+
table.add_column("Confidence", justify="right", style="yellow")
|
|
39
|
+
table.add_column("Location", style="dim")
|
|
40
|
+
|
|
41
|
+
for f in findings:
|
|
42
|
+
if isinstance(f, dict):
|
|
43
|
+
entity_type = f.get("entity_type", "")
|
|
44
|
+
entity_value = f.get("entity_value", "")
|
|
45
|
+
confidence = f.get("confidence", "")
|
|
46
|
+
location = f.get("location", "")
|
|
47
|
+
else:
|
|
48
|
+
entity_type = f.entity_type
|
|
49
|
+
entity_value = f.entity_value
|
|
50
|
+
confidence = f.confidence
|
|
51
|
+
location = f.location
|
|
52
|
+
|
|
53
|
+
table.add_row(
|
|
54
|
+
str(entity_type),
|
|
55
|
+
str(entity_value),
|
|
56
|
+
f"{confidence:.2f}" if isinstance(confidence, float) else str(confidence),
|
|
57
|
+
str(location),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
console.print(table)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def print_job(job: Any) -> None:
|
|
64
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
65
|
+
table.add_column("Field", style="bold cyan")
|
|
66
|
+
table.add_column("Value")
|
|
67
|
+
|
|
68
|
+
fields = ["id", "status", "language", "created_at", "updated_at", "input_uri", "output_uri"]
|
|
69
|
+
for field in fields:
|
|
70
|
+
value = getattr(job, field, None)
|
|
71
|
+
if value is not None:
|
|
72
|
+
table.add_row(field, str(value))
|
|
73
|
+
|
|
74
|
+
console.print(Panel(table, title="Job Details", border_style="blue"))
|
|
75
|
+
|
|
76
|
+
findings = getattr(job, "findings", None)
|
|
77
|
+
if findings:
|
|
78
|
+
print_findings(findings)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def print_jobs_table(jobs: list, total: int, page: int, page_size: int) -> None:
|
|
82
|
+
table = Table(title=f"Jobs (page {page}, {total} total)")
|
|
83
|
+
table.add_column("ID", style="cyan")
|
|
84
|
+
table.add_column("Status", style="bold")
|
|
85
|
+
table.add_column("Language", style="dim")
|
|
86
|
+
table.add_column("Created At", style="dim")
|
|
87
|
+
|
|
88
|
+
for job in jobs:
|
|
89
|
+
if isinstance(job, dict):
|
|
90
|
+
job_id = job.get("id", "")
|
|
91
|
+
status = job.get("status", "")
|
|
92
|
+
language = job.get("language", "")
|
|
93
|
+
created_at = job.get("created_at", "")
|
|
94
|
+
else:
|
|
95
|
+
job_id = getattr(job, "id", "")
|
|
96
|
+
status = getattr(job, "status", "")
|
|
97
|
+
language = getattr(job, "language", "")
|
|
98
|
+
created_at = getattr(job, "created_at", "")
|
|
99
|
+
|
|
100
|
+
status_style = {
|
|
101
|
+
"completed": "green",
|
|
102
|
+
"failed": "red",
|
|
103
|
+
"processing": "yellow",
|
|
104
|
+
}.get(str(status), "")
|
|
105
|
+
table.add_row(
|
|
106
|
+
str(job_id),
|
|
107
|
+
Text(str(status), style=status_style),
|
|
108
|
+
str(language),
|
|
109
|
+
str(created_at),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
console.print(table)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def print_policies_table(policies: list) -> None:
|
|
116
|
+
table = Table(title="Policies")
|
|
117
|
+
table.add_column("ID", style="cyan")
|
|
118
|
+
table.add_column("Name", style="bold")
|
|
119
|
+
table.add_column("Redaction Method", style="dim")
|
|
120
|
+
table.add_column("Confidence Threshold", justify="right", style="yellow")
|
|
121
|
+
|
|
122
|
+
for p in policies:
|
|
123
|
+
if isinstance(p, dict):
|
|
124
|
+
pid = p.get("id", "")
|
|
125
|
+
name = p.get("name", "")
|
|
126
|
+
method = p.get("redaction_method", "")
|
|
127
|
+
threshold = p.get("confidence_threshold", "")
|
|
128
|
+
else:
|
|
129
|
+
pid = getattr(p, "id", "")
|
|
130
|
+
name = getattr(p, "name", "")
|
|
131
|
+
method = getattr(p, "redaction_method", "")
|
|
132
|
+
threshold = getattr(p, "confidence_threshold", "")
|
|
133
|
+
|
|
134
|
+
table.add_row(str(pid), str(name), str(method), str(threshold))
|
|
135
|
+
|
|
136
|
+
console.print(table)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def print_error(message: str) -> None:
|
|
140
|
+
error_console.print(Panel(f"[bold red]{message}[/]", title="Error", border_style="red"))
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: expunct-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for the Expunct PII redaction API — redact, detect, and manage sensitive data from the command line.
|
|
5
|
+
Project-URL: Homepage, https://expunct.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.expunct.ai
|
|
7
|
+
Project-URL: Repository, https://github.com/expunct/cli
|
|
8
|
+
Author: Expunct
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: cli,pii,privacy,redaction,security
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: expunct>=0.1.1
|
|
25
|
+
Requires-Dist: rich>=13.0
|
|
26
|
+
Requires-Dist: typer>=0.15
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Expunct CLI
|
|
33
|
+
|
|
34
|
+
**Privacy infrastructure for modern applications.**
|
|
35
|
+
|
|
36
|
+
Redact PII, secrets, and sensitive data from text, logs, and files — before it reaches AI, analytics, or external APIs.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 🚀 Quick Start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install expunct-cli
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export EXPUNCT_API_KEY=your_api_key
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
expunct redact --text "John Smith email john@gmail.com"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Output:**
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
PERSON_1 email EMAIL_1
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## ✨ Why Expunct?
|
|
63
|
+
|
|
64
|
+
Modern applications constantly handle sensitive data:
|
|
65
|
+
|
|
66
|
+
* AI prompts sent to LLMs
|
|
67
|
+
* application logs
|
|
68
|
+
* customer support tickets
|
|
69
|
+
* analytics pipelines
|
|
70
|
+
|
|
71
|
+
Expunct helps you:
|
|
72
|
+
|
|
73
|
+
* 🔒 Detect PII (emails, phone numbers, names, etc.)
|
|
74
|
+
* 🧠 Detect secrets (API keys, tokens, credentials)
|
|
75
|
+
* 🧩 Apply policies (redact, mask, pseudonymize)
|
|
76
|
+
* ⚡ Sanitize data before it leaves your system
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 🧪 Examples
|
|
81
|
+
|
|
82
|
+
### Redact sensitive data
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
expunct redact --text "Contact me at john@gmail.com"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Detect entities without modifying text
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
expunct detect --text "My email is john@gmail.com"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Process a file
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
expunct redact logs.txt
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Redact binary files (PDF, DOCX, images, video, audio)
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
expunct redact report.pdf --output redacted_report.pdf
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Use with pipes (great for scripts & agents)
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
cat logs.txt | expunct redact
|
|
110
|
+
echo "My SSN is 123-45-6789" | expunct detect
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Cloud URI redaction
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
expunct redact --uri "gs://my-bucket/file.txt"
|
|
117
|
+
expunct redact --uri "s3://my-bucket/file.txt" --no-wait
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### JSON output (for scripting & AI agents)
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
expunct redact --text "My SSN is 123-45-6789" --json
|
|
124
|
+
expunct detect --text "Jane Doe" --json | jq '.findings[] | .entity_type'
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## 🧱 How It Works
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
Your App / Logs / Files
|
|
133
|
+
↓
|
|
134
|
+
Expunct
|
|
135
|
+
↓
|
|
136
|
+
Clean, safe data
|
|
137
|
+
↓
|
|
138
|
+
AI / APIs / Analytics
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Expunct acts as a **privacy layer** that removes sensitive data before it leaves your system.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 📖 Commands
|
|
146
|
+
|
|
147
|
+
### `expunct redact`
|
|
148
|
+
|
|
149
|
+
Redact PII from text, files, or URIs.
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
expunct redact --text "Call me at 555-1234"
|
|
153
|
+
expunct redact notes.txt
|
|
154
|
+
expunct redact scan.pdf -o redacted_scan.pdf
|
|
155
|
+
expunct redact --uri "gs://my-bucket/file.txt"
|
|
156
|
+
cat file.txt | expunct redact
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
| Flag | Description |
|
|
160
|
+
|------|-------------|
|
|
161
|
+
| `--text, -t` | Inline text to redact |
|
|
162
|
+
| `--uri, -u` | Cloud URI to redact |
|
|
163
|
+
| `--output, -o` | Output file path (required for binary formats) |
|
|
164
|
+
| `--language, -l` | Language code (default: `en`) |
|
|
165
|
+
| `--policy-id, -p` | Policy ID to apply |
|
|
166
|
+
| `--json` | Raw JSON output |
|
|
167
|
+
| `--wait/--no-wait` | Wait for URI jobs (default: `--wait`) |
|
|
168
|
+
| `--timeout` | Wait timeout in seconds (default: `300`) |
|
|
169
|
+
|
|
170
|
+
### `expunct detect`
|
|
171
|
+
|
|
172
|
+
Detect PII entities without redacting. Shows entity type, value, confidence, and location.
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
expunct detect --text "My name is Jane Doe and my SSN is 123-45-6789"
|
|
176
|
+
expunct detect document.txt
|
|
177
|
+
expunct detect --uri "gs://bucket/file.txt"
|
|
178
|
+
echo "test@email.com" | expunct detect
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### `expunct jobs`
|
|
182
|
+
|
|
183
|
+
Manage redaction jobs.
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
expunct jobs list
|
|
187
|
+
expunct jobs list --status completed --page 2
|
|
188
|
+
expunct jobs get JOB_ID
|
|
189
|
+
expunct jobs download JOB_ID -o output.pdf
|
|
190
|
+
expunct jobs wait JOB_ID --timeout 600
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### `expunct policies`
|
|
194
|
+
|
|
195
|
+
Manage redaction policies.
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
expunct policies list
|
|
199
|
+
expunct policies create --name "strict" --confidence-threshold 0.9
|
|
200
|
+
expunct policies get POLICY_ID
|
|
201
|
+
expunct policies update POLICY_ID --name "updated-name"
|
|
202
|
+
expunct policies delete POLICY_ID --yes
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### `expunct audit`
|
|
206
|
+
|
|
207
|
+
View audit logs.
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
expunct audit list
|
|
211
|
+
expunct audit list --event-type redaction --page-size 50
|
|
212
|
+
expunct audit list --json
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### `expunct config`
|
|
216
|
+
|
|
217
|
+
Manage CLI configuration.
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
expunct config set api_key YOUR_API_KEY
|
|
221
|
+
expunct config set base_url https://api.expunct.ai
|
|
222
|
+
expunct config get base_url
|
|
223
|
+
expunct config show
|
|
224
|
+
expunct config path
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## 🔑 Authentication
|
|
230
|
+
|
|
231
|
+
**Option A: Environment variable** (recommended for CI/scripts)
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
export EXPUNCT_API_KEY=your_api_key
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Option B: Config file**
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
expunct config set api_key YOUR_API_KEY
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Stored in `~/.expunct/config.json`:
|
|
244
|
+
|
|
245
|
+
```json
|
|
246
|
+
{
|
|
247
|
+
"api_key": "your_api_key",
|
|
248
|
+
"base_url": "https://api.expunct.ai",
|
|
249
|
+
"tenant_id": "your-tenant-id"
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Variable | Description |
|
|
254
|
+
|----------|-------------|
|
|
255
|
+
| `EXPUNCT_API_KEY` | API key (overrides config file) |
|
|
256
|
+
| `EXPUNCT_BASE_URL` | API base URL (overrides config file) |
|
|
257
|
+
| `EXPUNCT_TENANT_ID` | Tenant ID (overrides config file) |
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## ⚙️ Output Modes
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
# Default: human-readable with rich formatting
|
|
265
|
+
expunct redact --text "My email is john@gmail.com"
|
|
266
|
+
|
|
267
|
+
# JSON: machine-readable for piping and scripting
|
|
268
|
+
expunct redact --text "My email is john@gmail.com" --json
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## 🤖 Agent-Friendly Design
|
|
274
|
+
|
|
275
|
+
The CLI is designed to work well with AI agents and automation:
|
|
276
|
+
|
|
277
|
+
* **Deterministic output** with `--json` flag
|
|
278
|
+
* **Stdin piping** for chaining commands
|
|
279
|
+
* **Non-interactive** — no prompts in `--json` mode
|
|
280
|
+
* **Exit codes** — 0 for success, 1 for errors
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
# Pipe redacted text
|
|
284
|
+
expunct redact --text "My phone is 555-0100" --json | jq .redacted
|
|
285
|
+
|
|
286
|
+
# Chain with other tools
|
|
287
|
+
cat logs.txt | expunct redact | grep "ERROR"
|
|
288
|
+
|
|
289
|
+
# Use in scripts
|
|
290
|
+
expunct jobs list --json | jq '.jobs[].id'
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## 🖥️ Platform Support
|
|
296
|
+
|
|
297
|
+
The CLI is pure Python and works on **macOS**, **Linux**, and **Windows**:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
# All platforms via pip
|
|
301
|
+
pip install expunct-cli
|
|
302
|
+
|
|
303
|
+
# macOS via Homebrew (coming soon)
|
|
304
|
+
brew install expunct/tap/expunct-cli
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## 🔒 Built for Developers
|
|
310
|
+
|
|
311
|
+
Expunct is built on top of proven detection tools like Microsoft Presidio, with added:
|
|
312
|
+
|
|
313
|
+
* policy control
|
|
314
|
+
* hosted API
|
|
315
|
+
* scalable processing
|
|
316
|
+
* multi-format support (text, PDF, DOCX, images, video, audio)
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## 📚 Documentation
|
|
321
|
+
|
|
322
|
+
👉 [https://docs.expunct.ai](https://docs.expunct.ai)
|
|
323
|
+
|
|
324
|
+
## 🌐 Platform
|
|
325
|
+
|
|
326
|
+
👉 [https://expunct.ai](https://expunct.ai)
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## 💡 Roadmap
|
|
331
|
+
|
|
332
|
+
* Pseudonymization (reversible identity masking)
|
|
333
|
+
* Secret detection expansion
|
|
334
|
+
* Batch processing via CLI
|
|
335
|
+
* Directory processing
|
|
336
|
+
* Streaming mode
|
|
337
|
+
* Self-hosted / VPC deployment
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## 🤝 Contributing
|
|
342
|
+
|
|
343
|
+
Contributions welcome. Feel free to open issues or PRs.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## 📄 License
|
|
348
|
+
|
|
349
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
expunct_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
expunct_cli/client.py,sha256=tBrCA3knYZayFD5x5li1XfZ7NmlwmmaatMBnkeslL_A,1250
|
|
3
|
+
expunct_cli/main.py,sha256=3frt6VH8Asbbx6q676IrBa7E96v5JAA9GBnt6GUzzR0,1479
|
|
4
|
+
expunct_cli/output.py,sha256=1Cafvo4go7pcsLj91Dy-pFBp3BBgKD71USGuPeNxlHQ,4502
|
|
5
|
+
expunct_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
expunct_cli/commands/audit.py,sha256=P8N6PBgCT1jZtlI6j8VgpleAG50iWOj56AFB0UJDkBo,2036
|
|
7
|
+
expunct_cli/commands/config_cmd.py,sha256=MoBJ0h0nuyYILsJwq3HpIvLictqDwzNDSLl0fiF4aXI,2126
|
|
8
|
+
expunct_cli/commands/detect.py,sha256=tg8iiUbcGh-Oq1rJAJPwpp3AfAcUoOuIB_NlnkVRO3k,3611
|
|
9
|
+
expunct_cli/commands/jobs.py,sha256=GXRSF4NnSVQCCZqHW_bVHYY8O1_g3qrG_Q33cszDaGY,3455
|
|
10
|
+
expunct_cli/commands/policies.py,sha256=HrqQevnO0trx-bvJd4ITy_fMr4y3aZSIIge3fp_8TEA,6136
|
|
11
|
+
expunct_cli/commands/redact.py,sha256=2yh9wnM2HiNqcWJAUvkPdQ7CwdsxBG4h_mmpOClCoWc,4104
|
|
12
|
+
expunct_cli-0.1.0.dist-info/METADATA,sha256=GcxviZB88Az7V4UXYLDoyHdUc_QpF8G-PLZjzrnKxUg,7418
|
|
13
|
+
expunct_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
expunct_cli-0.1.0.dist-info/entry_points.txt,sha256=2GZLHMBftAnGc89oaGekw4MIuXXxfUWEUVW8nhiDQXE,49
|
|
15
|
+
expunct_cli-0.1.0.dist-info/RECORD,,
|