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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ expunct = expunct_cli.main:app