maskflow-cli 0.2.0__tar.gz

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,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .env
6
+ node_modules/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .DS_Store
11
+ .pytest_cache/
12
+ .idea/
13
+ .coverage
14
+ .mypy_cache/
15
+ .ruff_cache/
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.5
2
+ Name: maskflow-cli
3
+ Version: 0.2.0
4
+ Summary: Command-line interface for MaskFlow: .maskflowrc config validation and inspection
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: maskflow-core[yaml]<0.5,>=0.4.0
8
+ Requires-Dist: maskflow-pack-india<0.2,>=0.1.0
9
+ Requires-Dist: maskflow-pack-intl<0.3,>=0.2.0
10
+ Requires-Dist: rich>=13.0
11
+ Requires-Dist: tomli-w>=1.0
12
+ Requires-Dist: typer>=0.12
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8.0; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # maskflow-cli
18
+
19
+ Command-line interface for [MaskFlow](https://github.com/):
20
+
21
+ ```
22
+ maskflow config validate
23
+ maskflow config show --resolved
24
+ maskflow doctor
25
+ maskflow explain "<text>"
26
+ ```
27
+
28
+ `maskflow doctor` checks installed versions, spaCy model presence (and
29
+ which entities that consequently disables), and `.maskflowrc` validity,
30
+ then reports enabled/disabled status for every registered entity. It
31
+ exits 0 only when every check passes.
32
+
33
+ `maskflow explain "<text>"` shows, span by span, why each piece of text
34
+ was (or wasn't) detected as PII -- the pattern/NER hit, checksum result,
35
+ context boost, and the threshold decision behind it. Spans that scored
36
+ below their entity's threshold are listed separately as NEAREST MISSES,
37
+ with the `.maskflowrc` change that would catch them. Matched text is
38
+ truncated to 8 characters unless `--full` is passed. Accepts the same
39
+ `--config`/`--set` overrides as `maskflow config`, so explanations reflect
40
+ the same resolved config a real `mask()` call would use.
41
+
42
+ See `docs/configuration.md` in the repo root for the full config reference.
@@ -0,0 +1,26 @@
1
+ # maskflow-cli
2
+
3
+ Command-line interface for [MaskFlow](https://github.com/):
4
+
5
+ ```
6
+ maskflow config validate
7
+ maskflow config show --resolved
8
+ maskflow doctor
9
+ maskflow explain "<text>"
10
+ ```
11
+
12
+ `maskflow doctor` checks installed versions, spaCy model presence (and
13
+ which entities that consequently disables), and `.maskflowrc` validity,
14
+ then reports enabled/disabled status for every registered entity. It
15
+ exits 0 only when every check passes.
16
+
17
+ `maskflow explain "<text>"` shows, span by span, why each piece of text
18
+ was (or wasn't) detected as PII -- the pattern/NER hit, checksum result,
19
+ context boost, and the threshold decision behind it. Spans that scored
20
+ below their entity's threshold are listed separately as NEAREST MISSES,
21
+ with the `.maskflowrc` change that would catch them. Matched text is
22
+ truncated to 8 characters unless `--full` is passed. Accepts the same
23
+ `--config`/`--set` overrides as `maskflow config`, so explanations reflect
24
+ the same resolved config a real `mask()` call would use.
25
+
26
+ See `docs/configuration.md` in the repo root for the full config reference.
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "maskflow-cli"
3
+ version = "0.2.0"
4
+ description = "Command-line interface for MaskFlow: .maskflowrc config validation and inspection"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ # The config engine (schema/discovery/merge/validate) lives in
10
+ # maskflow_core.config, which only exists from core 0.3.0 -- [yaml]
11
+ # since the CLI, unlike the SDK, should accept a YAML .maskflowrc out
12
+ # of the box (it's already a heavier, more optional install than the SDK).
13
+ "maskflow-core[yaml]>=0.4.0,<0.5",
14
+ # maskflow_pack_intl is imported (side-effect only, to populate the
15
+ # PIIType registry) for `maskflow config validate`'s soft entity-name
16
+ # cross-check -- see app.py. pack-intl 0.1.0 itself requires core<0.3,
17
+ # unsatisfiable alongside core>=0.3.0 above -- 0.2.0 is the first
18
+ # version that requires (and works with) the new core.
19
+ "maskflow-pack-intl>=0.2.0,<0.3",
20
+ # Same reasoning as pack-intl above, for AADHAAR/PAN/GSTIN/IFSC/UPI_VPA
21
+ # -- also makes `maskflow doctor`/`maskflow explain` aware of them.
22
+ "maskflow-pack-india>=0.1.0,<0.2",
23
+ "typer>=0.12",
24
+ # CLI-only: pretty-prints `maskflow config show`'s plain TOML dump.
25
+ "tomli-w>=1.0",
26
+ # CLI-only: `maskflow doctor`'s health/entity tables.
27
+ "rich>=13.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ maskflow = "maskflow_cli.app:main"
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ ]
37
+
38
+ [tool.uv.sources]
39
+ maskflow-core = { workspace = true }
40
+ maskflow-pack-intl = { workspace = true }
41
+ maskflow-pack-india = { workspace = true }
42
+
43
+ [tool.uv]
44
+ package = true
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/maskflow_cli"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,24 @@
1
+ """Root CLI: `maskflow ...` (console script entry point, see pyproject.toml)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import maskflow_pack_india # noqa: F401 -- import side effect registers pack-india's entity types
6
+ import maskflow_pack_intl # noqa: F401 -- import side effect registers pack-intl's entity types
7
+ import typer
8
+
9
+ from .commands.config_cmd import app as config_app
10
+ from .commands.doctor_cmd import doctor
11
+ from .commands.explain_cmd import explain
12
+
13
+ app = typer.Typer(help="MaskFlow: reversible PII masking for LLM calls.", no_args_is_help=True)
14
+ app.add_typer(config_app, name="config")
15
+ app.command("doctor")(doctor)
16
+ app.command("explain")(explain)
17
+
18
+
19
+ def main() -> None:
20
+ app()
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()
@@ -0,0 +1,66 @@
1
+ """`maskflow config validate` / `maskflow config show [--resolved]`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from maskflow_core.config.resolve import ConfigResolutionError, ResolvedConfig, resolve_config
9
+
10
+ from ..render import format_resolved, format_show
11
+
12
+ app = typer.Typer(help="Inspect and validate .maskflowrc configuration.")
13
+
14
+ _CONFIG_OPTION = typer.Option(
15
+ None, "--config", help="Explicit config file path, bypassing project/user file discovery."
16
+ )
17
+ _SET_OPTION = typer.Option(
18
+ [], "--set", help="Override a resolved value, e.g. --set entities.AADHAAR.threshold=0.7"
19
+ )
20
+
21
+
22
+ def _resolve_or_exit(config: Path | None, overrides: list[str]) -> ResolvedConfig:
23
+ try:
24
+ return resolve_config(config_path_override=config, cli_sets=list(overrides))
25
+ except ConfigResolutionError as exc:
26
+ for line in str(exc).splitlines():
27
+ typer.echo(line, err=True)
28
+ raise typer.Exit(code=1) from exc
29
+
30
+
31
+ @app.command("validate")
32
+ def validate(
33
+ config: Path | None = _CONFIG_OPTION,
34
+ set_: list[str] = _SET_OPTION,
35
+ ) -> None:
36
+ """Validate the resolved .maskflowrc, reporting every problem found."""
37
+ resolved = _resolve_or_exit(config, set_)
38
+
39
+ for warning in resolved.warnings:
40
+ typer.echo(f"WARNING: {warning.message}", err=True)
41
+
42
+ sources = []
43
+ if resolved.user_file is not None:
44
+ sources.append(f"user file: {resolved.user_file}")
45
+ if resolved.project_file is not None:
46
+ sources.append(f"project file: {resolved.project_file}")
47
+ suffix = f" ({', '.join(sources)})" if sources else " (no config file found -- using defaults)"
48
+ typer.echo(f"Config is valid.{suffix}")
49
+
50
+
51
+ @app.command("show")
52
+ def show(
53
+ resolved: bool = typer.Option(
54
+ False, "--resolved", help="Annotate every value with where it came from."
55
+ ),
56
+ config: Path | None = _CONFIG_OPTION,
57
+ set_: list[str] = _SET_OPTION,
58
+ ) -> None:
59
+ """Print the merged, validated config -- plain TOML by default, or one
60
+ annotated `path = value (origin)` line per field with --resolved."""
61
+ resolved_config = _resolve_or_exit(config, set_)
62
+
63
+ if resolved:
64
+ typer.echo(format_resolved(resolved_config))
65
+ else:
66
+ typer.echo(format_show(resolved_config), nl=False)
@@ -0,0 +1,20 @@
1
+ """`maskflow doctor`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from ..doctor import run_checks
8
+ from ..doctor_render import make_console, render_report
9
+
10
+
11
+ def doctor() -> None:
12
+ """Check installed versions, spaCy model presence, and .maskflowrc
13
+ validity, and report which entities are consequently enabled/disabled.
14
+ Exits 0 only when every check passes."""
15
+ console = make_console()
16
+ report = run_checks()
17
+ render_report(console, report)
18
+
19
+ if not report.healthy:
20
+ raise typer.Exit(code=1)
@@ -0,0 +1,43 @@
1
+ """`maskflow explain "<text>"`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from maskflow_core.config.resolve import ConfigResolutionError, resolve_config
9
+
10
+ from ..explain import run_explain
11
+ from ..explain_render import make_console, render_explain
12
+
13
+ _CONFIG_OPTION = typer.Option(
14
+ None, "--config", help="Explicit config file path, bypassing project/user file discovery."
15
+ )
16
+ _SET_OPTION = typer.Option(
17
+ [], "--set", help="Override a resolved value, e.g. --set entities.SSN.threshold=0.3"
18
+ )
19
+ _FULL_OPTION = typer.Option(
20
+ False, "--full", help="Show the entire matched value instead of truncating to 8 chars."
21
+ )
22
+
23
+
24
+ def explain(
25
+ text: str = typer.Argument(..., help="Text to analyze -- never written to any log."),
26
+ full: bool = _FULL_OPTION,
27
+ config: Path | None = _CONFIG_OPTION,
28
+ set_: list[str] = _SET_OPTION,
29
+ ) -> None:
30
+ """Show, span by span, why each piece of text was (or wasn't) detected
31
+ as PII: the pattern/NER hit, checksum result, context boost, and the
32
+ threshold decision behind it. Spans that scored below their entity's
33
+ threshold are listed separately as NEAREST MISSES, with the
34
+ .maskflowrc change that would catch them."""
35
+ try:
36
+ resolved = resolve_config(config_path_override=config, cli_sets=list(set_))
37
+ except ConfigResolutionError as exc:
38
+ for line in str(exc).splitlines():
39
+ typer.echo(line, err=True)
40
+ raise typer.Exit(code=1) from exc
41
+
42
+ result = run_explain(text, resolved.config, full=full)
43
+ render_explain(make_console(), result)
@@ -0,0 +1,184 @@
1
+ """Data gathering for `maskflow doctor` -- pure checks, no Rich/Typer here,
2
+ so the logic is testable without rendering. See doctor_render.py for the
3
+ table output and commands/doctor_cmd.py for the Typer command + exit code.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from importlib import metadata
10
+ from typing import Literal
11
+
12
+ from maskflow_core.config.resolve import ConfigResolutionError, ResolvedConfig, resolve_config
13
+ from maskflow_core.entities import PIIType
14
+ from maskflow_core.ner import MODEL_NAME
15
+ from maskflow_core.registry import NER_RECOGNIZERS, PATTERNS
16
+
17
+ Status = Literal["ok", "warn", "error"]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ComponentCheck:
22
+ name: str
23
+ version: str | None
24
+ status: Status
25
+ detail: str = ""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class EntityCheck:
30
+ name: str
31
+ detector: str
32
+ enabled: bool
33
+ reason: str = ""
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class DoctorReport:
38
+ components: list[ComponentCheck] = field(default_factory=list)
39
+ entities: list[EntityCheck] = field(default_factory=list)
40
+
41
+ @property
42
+ def error_count(self) -> int:
43
+ return sum(1 for c in self.components if c.status == "error")
44
+
45
+ @property
46
+ def warning_count(self) -> int:
47
+ return sum(1 for c in self.components if c.status == "warn")
48
+
49
+ @property
50
+ def healthy(self) -> bool:
51
+ return self.error_count == 0
52
+
53
+
54
+ def _version(dist_name: str) -> str | None:
55
+ try:
56
+ return metadata.version(dist_name)
57
+ except metadata.PackageNotFoundError:
58
+ return None
59
+
60
+
61
+ def _check_packages() -> list[ComponentCheck]:
62
+ checks: list[ComponentCheck] = []
63
+
64
+ for dist_name in ("maskflow-core", "maskflow-cli"):
65
+ version = _version(dist_name)
66
+ if version is None:
67
+ checks.append(ComponentCheck(dist_name, None, "error", "not installed"))
68
+ else:
69
+ checks.append(ComponentCheck(dist_name, version, "ok"))
70
+
71
+ installed_packs = sorted(
72
+ {
73
+ name
74
+ for dist in metadata.distributions()
75
+ if (name := dist.metadata.get("Name")) and name.startswith("maskflow-pack-")
76
+ }
77
+ )
78
+ for pack in installed_packs:
79
+ checks.append(ComponentCheck(pack, _version(pack), "ok"))
80
+
81
+ return checks
82
+
83
+
84
+ def _check_spacy() -> tuple[ComponentCheck, ComponentCheck]:
85
+ model_name = f"spaCy model ({MODEL_NAME})"
86
+ try:
87
+ import spacy
88
+ except ImportError:
89
+ return (
90
+ ComponentCheck(
91
+ "spaCy", None, "error", "not installed (pip install maskflow-core[nlp])"
92
+ ),
93
+ ComponentCheck(model_name, None, "error", "unavailable -- spaCy not installed"),
94
+ )
95
+
96
+ spacy_check = ComponentCheck("spaCy", spacy.__version__, "ok")
97
+ try:
98
+ spacy.load(MODEL_NAME)
99
+ except OSError:
100
+ model_check = ComponentCheck(
101
+ model_name, None, "error", f"MISSING (python -m spacy download {MODEL_NAME})"
102
+ )
103
+ else:
104
+ model_check = ComponentCheck(model_name, _version(MODEL_NAME), "ok")
105
+ return spacy_check, model_check
106
+
107
+
108
+ def _check_config() -> tuple[ComponentCheck, ResolvedConfig | None]:
109
+ try:
110
+ resolved = resolve_config()
111
+ except ConfigResolutionError as exc:
112
+ detail = f"{len(exc.errors)} error(s) -- run `maskflow config validate`"
113
+ return ComponentCheck(".maskflowrc", None, "error", detail), None
114
+
115
+ if resolved.project_file is not None:
116
+ detail = f"valid (project file: {resolved.project_file})"
117
+ elif resolved.user_file is not None:
118
+ detail = f"valid (user file: {resolved.user_file})"
119
+ else:
120
+ detail = "valid (no config file found -- using defaults)"
121
+
122
+ status: Status = "ok"
123
+ if resolved.warnings:
124
+ status = "warn"
125
+ detail += f" -- {len(resolved.warnings)} warning(s), see `maskflow config validate`"
126
+
127
+ return ComponentCheck(".maskflowrc", None, status, detail), resolved
128
+
129
+
130
+ def _check_redis() -> ComponentCheck:
131
+ # RedisMappingStore (maskflow_core.mapping_store) is an interface-only
132
+ # stub -- every method raises NotImplementedError regardless of whether
133
+ # a `redis` client or server is reachable, so there is nothing to probe
134
+ # yet. Always the same informational warning until that ships for real.
135
+ return ComponentCheck(
136
+ "Redis", None, "warn", "RedisMappingStore not implemented yet -- using in-memory store"
137
+ )
138
+
139
+
140
+ def _entity_checks(resolved: ResolvedConfig | None, spacy_ready: bool) -> list[EntityCheck]:
141
+ ner_label_by_type: dict[PIIType, str] = {
142
+ mapping.pii_type: label for label, mapping in NER_RECOGNIZERS.items()
143
+ }
144
+ all_types = set(PATTERNS) | set(ner_label_by_type)
145
+
146
+ checks: list[EntityCheck] = []
147
+ for pii_type in sorted(all_types):
148
+ detector = (
149
+ f"ner:{ner_label_by_type[pii_type]}" if pii_type in ner_label_by_type else "pattern"
150
+ )
151
+
152
+ entity_config = (
153
+ resolved.config.entities.get(str(pii_type)) if resolved is not None else None
154
+ )
155
+ if entity_config is not None and not entity_config.enabled:
156
+ reason = ".maskflowrc: entities.<X>.enabled = false"
157
+ checks.append(EntityCheck(str(pii_type), detector, False, reason))
158
+ continue
159
+
160
+ if pii_type in ner_label_by_type and not spacy_ready:
161
+ checks.append(EntityCheck(str(pii_type), detector, False, "spaCy model unavailable"))
162
+ continue
163
+
164
+ checks.append(EntityCheck(str(pii_type), detector, True))
165
+
166
+ return checks
167
+
168
+
169
+ def run_checks() -> DoctorReport:
170
+ components: list[ComponentCheck] = []
171
+ components.extend(_check_packages())
172
+
173
+ spacy_check, model_check = _check_spacy()
174
+ components.append(spacy_check)
175
+ components.append(model_check)
176
+
177
+ config_check, resolved = _check_config()
178
+ components.append(config_check)
179
+
180
+ components.append(_check_redis())
181
+
182
+ entities = _entity_checks(resolved, spacy_ready=model_check.status == "ok")
183
+
184
+ return DoctorReport(components=components, entities=entities)
@@ -0,0 +1,83 @@
1
+ """Rich rendering for `maskflow doctor`. Kept separate from doctor.py's pure
2
+ data gathering so the checks themselves are testable without a console."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from rich import box
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from .doctor import ComponentCheck, DoctorReport, EntityCheck
11
+
12
+ # A fixed width rather than terminal auto-detection: `maskflow doctor`'s
13
+ # longer detail strings (e.g. "MISSING (python -m spacy download ...)")
14
+ # would otherwise wrap unpredictably under a non-tty (CI logs, CliRunner in
15
+ # tests) where Rich falls back to an 80-column default.
16
+ CONSOLE_WIDTH = 100
17
+
18
+ _STATUS_SYMBOL: dict[str, str] = {"ok": "✓", "warn": "⚠", "error": "✗"}
19
+ _STATUS_COLOR: dict[str, str] = {"ok": "green", "warn": "yellow", "error": "red"}
20
+
21
+
22
+ def _component_status(check: ComponentCheck) -> str:
23
+ symbol = _STATUS_SYMBOL[check.status]
24
+ color = _STATUS_COLOR[check.status]
25
+ text = f"{symbol} {check.detail}".strip() if check.detail else symbol
26
+ return f"[{color}]{text}[/{color}]"
27
+
28
+
29
+ def _component_table(components: list[ComponentCheck]) -> Table:
30
+ table = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False)
31
+ table.add_column("Component")
32
+ table.add_column("Version")
33
+ table.add_column("Status", no_wrap=False, overflow="fold")
34
+ for check in components:
35
+ table.add_row(check.name, check.version or "—", _component_status(check))
36
+ return table
37
+
38
+
39
+ def _entity_status(check: EntityCheck) -> str:
40
+ if check.enabled:
41
+ return "[green]✓ enabled[/green]"
42
+ return f"[red]✗ disabled — {check.reason}[/red]"
43
+
44
+
45
+ def _entity_table(entities: list[EntityCheck]) -> Table:
46
+ table = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False)
47
+ table.add_column("Entity")
48
+ table.add_column("Detector")
49
+ table.add_column("Status", overflow="fold")
50
+ for check in entities:
51
+ table.add_row(check.name, check.detector, _entity_status(check))
52
+ return table
53
+
54
+
55
+ def _summary_line(report: DoctorReport) -> str:
56
+ active = sum(1 for e in report.entities if e.enabled)
57
+ total = len(report.entities)
58
+ disabled_note = "" if active == total else f" {total - active} disabled."
59
+ return f"{active} of {total} entities active.{disabled_note}"
60
+
61
+
62
+ def render_report(console: Console, report: DoctorReport) -> None:
63
+ console.print("[bold]MaskFlow Doctor[/bold]")
64
+ console.print(_component_table(report.components))
65
+ console.print()
66
+ console.print(_entity_table(report.entities))
67
+ console.print()
68
+ console.print(_summary_line(report))
69
+
70
+ errors = report.error_count
71
+ warnings = report.warning_count
72
+ if errors == 0 and warnings == 0:
73
+ console.print("[bold green]✓ All checks passed.[/bold green]")
74
+ elif errors == 0:
75
+ console.print(f"[bold yellow]⚠ Healthy with {warnings} warning(s).[/bold yellow]")
76
+ else:
77
+ console.print(
78
+ f"[bold red]✗ Not fully healthy — {errors} error(s), {warnings} warning(s).[/bold red]"
79
+ )
80
+
81
+
82
+ def make_console() -> Console:
83
+ return Console(width=CONSOLE_WIDTH)
@@ -0,0 +1,92 @@
1
+ """Data assembly for `maskflow explain` -- compiles the resolved
2
+ .maskflowrc config, runs detect(return_rejected=True), and turns the
3
+ resulting Spans into renderable views. No Rich/Typer here, so this is
4
+ testable without a console; see explain_render.py for the console output
5
+ and commands/explain_cmd.py for the Typer command.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass
12
+
13
+ from maskflow_core.config.engine import compile_config
14
+ from maskflow_core.config.schema import RootConfig
15
+ from maskflow_core.detection import DEFAULT_MIN_CONFIDENCE, detect
16
+ from maskflow_core.entities import ExplanationStep, Span
17
+
18
+ # How much of a matched value maskflow explain shows by default -- never the
19
+ # whole thing unless --full is passed (CLAUDE.md rule 1: never print raw PII
20
+ # by default, even in a diagnostic tool).
21
+ TRUNCATE_LEN = 8
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SpanView:
26
+ entity_type: str
27
+ score: float
28
+ validated: bool
29
+ start: int
30
+ end: int
31
+ display_text: str
32
+ truncated: bool
33
+ steps: tuple[ExplanationStep, ...]
34
+ # None for a masked span; the entity's active threshold for a near miss.
35
+ threshold: float | None = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ExplainResult:
40
+ text_length: int
41
+ masked: list[SpanView]
42
+ near_misses: list[SpanView]
43
+
44
+
45
+ def _display_text(text: str, full: bool) -> tuple[str, bool]:
46
+ if full or len(text) <= TRUNCATE_LEN:
47
+ return text, False
48
+ return text[:TRUNCATE_LEN] + "…", True
49
+
50
+
51
+ def _view(span: Span, *, full: bool, threshold: float | None) -> SpanView:
52
+ display_text, truncated = _display_text(span.text, full)
53
+ return SpanView(
54
+ entity_type=str(span.entity_type),
55
+ score=span.score,
56
+ validated=span.validated,
57
+ start=span.start,
58
+ end=span.end,
59
+ display_text=display_text,
60
+ truncated=truncated,
61
+ steps=tuple(span.explanation),
62
+ threshold=threshold,
63
+ )
64
+
65
+
66
+ def suggested_threshold(score: float) -> float:
67
+ """The largest multiple of 0.05 at or below `score` -- low enough that
68
+ setting entities.<TYPE>.threshold to this value would have caught this
69
+ exact span, with a small margin against float rounding."""
70
+ return math.floor(score * 20) / 20
71
+
72
+
73
+ def run_explain(text: str, root_config: RootConfig, *, full: bool = False) -> ExplainResult:
74
+ compiled = compile_config(root_config)
75
+
76
+ accepted, rejected = detect(
77
+ text,
78
+ min_confidence=DEFAULT_MIN_CONFIDENCE,
79
+ return_rejected=True,
80
+ **compiled.detect_kwargs(),
81
+ )
82
+
83
+ masked = [_view(s, full=full, threshold=None) for s in accepted]
84
+ near_misses = [
85
+ _view(
86
+ s,
87
+ full=full,
88
+ threshold=compiled.per_entity_threshold.get(s.entity_type, DEFAULT_MIN_CONFIDENCE),
89
+ )
90
+ for s in rejected
91
+ ]
92
+ return ExplainResult(text_length=len(text), masked=masked, near_misses=near_misses)
@@ -0,0 +1,113 @@
1
+ """Rich rendering for `maskflow explain`. Kept separate from explain.py's
2
+ pure data assembly so the view-building logic is testable without a
3
+ console."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from maskflow_core.entities import ExplanationStep
8
+ from rich.console import Console
9
+
10
+ from .explain import ExplainResult, SpanView, suggested_threshold
11
+
12
+ # Fixed width for the same reason as doctor_render.py: predictable wrapping
13
+ # under a non-tty (CI logs, CliRunner in tests).
14
+ CONSOLE_WIDTH = 100
15
+
16
+
17
+ def _format_delta(delta: float) -> str:
18
+ return f"+{delta}" if delta >= 0 else str(delta)
19
+
20
+
21
+ def _step_line(step: ExplanationStep) -> str:
22
+ if step.rule.startswith(("pattern:", "ner:")):
23
+ return f"{step.rule} {step.outcome}"
24
+ if step.rule == "checksum":
25
+ return f"checksum {step.outcome} ({_format_delta(step.delta)})"
26
+ if step.rule == "context":
27
+ if step.outcome == "not_configured":
28
+ return "context: no context keywords configured for this entity"
29
+ if step.outcome == "boosted":
30
+ return f"context: {step.detail} ({_format_delta(step.delta)})"
31
+ return f"context: {step.detail}"
32
+ if step.rule == "threshold":
33
+ return f"threshold: {step.detail}"
34
+ if step.rule == "overlap:contained":
35
+ return f"overlap: preferred {step.detail}"
36
+ if step.rule == "merge":
37
+ return f"merge: {step.detail}"
38
+ return f"{step.rule}: {step.outcome} {step.detail}".strip()
39
+
40
+
41
+ def _render_span(console: Console, label: str, view: SpanView) -> None:
42
+ header = f"[{label}] {view.entity_type:<16} score {view.score:.2f} "
43
+ if view.threshold is not None:
44
+ header += f"threshold {view.threshold:.2f} "
45
+ else:
46
+ header += f"validated {'✓' if view.validated else '—'} "
47
+ header += f"span {view.start}:{view.end}"
48
+ # markup=False: `header`/`match_line`/step text are literal (may contain
49
+ # bracket labels like "[a]" or an actual matched value) -- Rich's markup
50
+ # parser would otherwise try to interpret "[a]" as a style tag and drop it.
51
+ console.print(header, markup=False)
52
+
53
+ match_line = f' match "{view.display_text}"'
54
+ if view.truncated:
55
+ match_line += " (--full to show entire match)"
56
+ console.print(match_line, markup=False)
57
+
58
+ for step in view.steps:
59
+ if step.rule == "threshold":
60
+ continue # rendered as the closing "dropped --" line instead
61
+ console.print(f" │ {_step_line(step)}", markup=False)
62
+
63
+ if view.threshold is not None:
64
+ console.print(
65
+ f" └ [red]dropped — score {view.score:.2f} < threshold {view.threshold:.2f}[/red]"
66
+ )
67
+ else:
68
+ console.print(" └ [green]masked[/green]")
69
+ console.print()
70
+
71
+
72
+ def _render_fixit(console: Console, view: SpanView) -> None:
73
+ assert view.threshold is not None
74
+ suggestion = suggested_threshold(view.score)
75
+ console.print(
76
+ f" Not detected: {view.entity_type} scored {view.score:.2f}, "
77
+ f"below threshold {view.threshold:.2f}."
78
+ )
79
+ console.print(" To catch it, add to .maskflowrc (project root):\n")
80
+ console.print(f" [entities.{view.entity_type}]", markup=False)
81
+ console.print(f" threshold = {suggestion:g}")
82
+ console.print()
83
+
84
+
85
+ def render_explain(console: Console, result: ExplainResult) -> None:
86
+ if not result.masked and not result.near_misses:
87
+ console.print(f"Analyzed {result.text_length} chars. No PII detected.")
88
+ return
89
+
90
+ near_miss_word = "near miss" if len(result.near_misses) == 1 else "near misses"
91
+ console.print(
92
+ f"Analyzed {result.text_length} chars. "
93
+ f"{len(result.masked)} span(s) masked, {len(result.near_misses)} {near_miss_word}.\n"
94
+ )
95
+
96
+ for i, view in enumerate(result.masked, start=1):
97
+ _render_span(console, str(i), view)
98
+
99
+ if result.near_misses:
100
+ console.print("[bold]NEAREST MISSES[/bold] — below threshold, not masked\n")
101
+ for i, view in enumerate(result.near_misses):
102
+ label = chr(ord("a") + i)
103
+ _render_span(console, label, view)
104
+ _render_fixit(console, view)
105
+
106
+ console.print(
107
+ f"{len(result.masked)} span(s) masked · {len(result.near_misses)} near miss(es) shown "
108
+ "· no raw PII written to any log"
109
+ )
110
+
111
+
112
+ def make_console() -> Console:
113
+ return Console(width=CONSOLE_WIDTH)
@@ -0,0 +1,77 @@
1
+ """Rendering for `maskflow config show`/`show --resolved`. Both redact
2
+ exclusions.values in every code path -- those are free-form user text that
3
+ could itself be PII-shaped, so the raw literal is never printed (see
4
+ CLAUDE.md rule 1: never log/print PII).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import asdict
11
+ from typing import Any
12
+
13
+ import tomli_w
14
+ from maskflow_core.config.merge import flatten_leaves
15
+ from maskflow_core.config.resolve import ResolvedConfig
16
+
17
+ _SECTION_ORDER = {"maskflow": 0, "entities": 1, "custom": 2, "exclusions": 3}
18
+
19
+
20
+ def _redact_value(value: str) -> str:
21
+ if len(value) <= 2:
22
+ return "*" * len(value)
23
+ return value[0] + "*" * (len(value) - 2) + value[-1]
24
+
25
+
26
+ def _redact_values_list(values: list[str]) -> list[str]:
27
+ return [_redact_value(v) for v in values]
28
+
29
+
30
+ def _sort_key(path: tuple[str, ...]) -> tuple[Any, ...]:
31
+ return (_SECTION_ORDER.get(path[0], 99), *path[1:])
32
+
33
+
34
+ def format_resolved(resolved: ResolvedConfig) -> str:
35
+ """One aligned line per leaf field: `path = value (origin)`."""
36
+ leaves = flatten_leaves(asdict(resolved.config))
37
+ leaves.sort(key=lambda item: _sort_key(item[0]))
38
+
39
+ rows: list[tuple[str, str, str]] = []
40
+ for path, value in leaves:
41
+ if path[0] == "exclusions" and path[-1] == "values":
42
+ value = _redact_values_list(value)
43
+ prov = resolved.provenance.get(path)
44
+ origin = prov.describe() if prov is not None else "default"
45
+ rows.append((".".join(path), json.dumps(value), origin))
46
+
47
+ if not rows:
48
+ return ""
49
+
50
+ key_width = max(len(key) for key, _, _ in rows)
51
+ value_width = max(len(value) for _, value, _ in rows)
52
+ return "\n".join(
53
+ f"{key.ljust(key_width)} = {value.ljust(value_width)} ({origin})"
54
+ for key, value, origin in rows
55
+ )
56
+
57
+
58
+ def _strip_none(value: Any) -> Any:
59
+ """TOML has no null type -- drop None-valued keys (e.g. an unset
60
+ EntityConfig.threshold/strategy, meaning "inherit the default") rather
61
+ than fail to serialize them."""
62
+ if isinstance(value, dict):
63
+ return {k: _strip_none(v) for k, v in value.items() if v is not None}
64
+ if isinstance(value, list):
65
+ return [_strip_none(v) for v in value]
66
+ return value
67
+
68
+
69
+ def format_show(resolved: ResolvedConfig) -> str:
70
+ """Plain TOML dump of the merged, validated config -- for inspection,
71
+ not guaranteed round-trippable: exclusions.values is redacted, so it
72
+ won't read back as the original values, and unset fields (None) are
73
+ omitted rather than serialized."""
74
+ data = asdict(resolved.config)
75
+ if data.get("exclusions", {}).get("values"):
76
+ data["exclusions"]["values"] = _redact_values_list(data["exclusions"]["values"])
77
+ return tomli_w.dumps(_strip_none(data))
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+
8
+ @pytest.fixture
9
+ def fixtures_dir() -> Path:
10
+ return Path(__file__).parent / "fixtures"
@@ -0,0 +1,2 @@
1
+ [entities.PAN]
2
+ enabled = true
@@ -0,0 +1,8 @@
1
+ [maskflow]
2
+ packs = ["india"]
3
+
4
+ [entities.PAN]
5
+ threshod = 0.5
6
+
7
+ [exclusions]
8
+ values = ["test@example.com"]
@@ -0,0 +1,17 @@
1
+ [maskflow]
2
+ packs = ["india"]
3
+ default_strategy = "replace"
4
+
5
+ [entities.AADHAAR]
6
+ enabled = true
7
+ threshold = 0.6
8
+ strategy = "mask"
9
+
10
+ [custom.EMPLOYEE_ID]
11
+ pattern = '\bEMP-\d{6}\b'
12
+ score = 0.9
13
+ context = ["employee"]
14
+
15
+ [exclusions]
16
+ values = ["test@example.com"]
17
+ patterns = ['\bDEMO-\d+\b']
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import maskflow_cli.commands.doctor_cmd as doctor_cmd_module
6
+ import maskflow_cli.doctor as doctor_module
7
+ import maskflow_pack_intl # noqa: F401 -- side effect: populates PATTERNS/NER_RECOGNIZERS
8
+ import pytest
9
+ from maskflow_cli.app import app
10
+ from maskflow_cli.doctor import ComponentCheck, DoctorReport, EntityCheck
11
+ from typer.testing import CliRunner
12
+
13
+ runner = CliRunner()
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class _FakeEntityConfig:
18
+ enabled: bool
19
+
20
+
21
+ def test_doctor_exits_nonzero_when_a_component_errors(monkeypatch: pytest.MonkeyPatch) -> None:
22
+ monkeypatch.setattr(
23
+ doctor_module,
24
+ "_check_spacy",
25
+ lambda: (
26
+ ComponentCheck("spaCy", None, "error", "not installed"),
27
+ ComponentCheck("spaCy model (en_core_web_sm)", None, "error", "unavailable"),
28
+ ),
29
+ )
30
+ result = runner.invoke(app, ["doctor"], env={"HOME": "/nonexistent-home"})
31
+ assert result.exit_code == 1
32
+ assert "MaskFlow Doctor" in result.stdout
33
+ assert "Not fully healthy" in result.stdout
34
+ assert "PERSON_NAME" in result.stdout
35
+ assert "disabled" in result.stdout
36
+
37
+
38
+ def test_doctor_exits_zero_when_fully_healthy(monkeypatch: pytest.MonkeyPatch) -> None:
39
+ fake_report = DoctorReport(
40
+ components=[ComponentCheck("maskflow-core", "0.3.0", "ok")],
41
+ entities=[EntityCheck("EMAIL", "pattern", True)],
42
+ )
43
+ monkeypatch.setattr(doctor_cmd_module, "run_checks", lambda: fake_report)
44
+ result = runner.invoke(app, ["doctor"])
45
+ assert result.exit_code == 0
46
+ assert "All checks passed" in result.stdout
47
+
48
+
49
+ def test_entity_disabled_via_maskflowrc_reports_that_reason() -> None:
50
+ resolved = doctor_module.ResolvedConfig(
51
+ config=type("Cfg", (), {"entities": {"EMAIL": _FakeEntityConfig(enabled=False)}})(),
52
+ provenance={},
53
+ project_file=None,
54
+ user_file=None,
55
+ )
56
+ checks = doctor_module._entity_checks(resolved, spacy_ready=True)
57
+ email = next(c for c in checks if c.name == "EMAIL")
58
+ assert not email.enabled
59
+ assert "maskflowrc" in email.reason
60
+
61
+
62
+ def test_entity_disabled_via_missing_spacy_model() -> None:
63
+ checks = doctor_module._entity_checks(None, spacy_ready=False)
64
+ person = next(c for c in checks if c.name == "PERSON_NAME")
65
+ assert not person.enabled
66
+ assert "spaCy" in person.reason
67
+
68
+ email = next(c for c in checks if c.name == "EMAIL")
69
+ assert email.enabled
70
+ assert email.detector == "pattern"
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from maskflow_cli.app import app
6
+ from typer.testing import CliRunner
7
+
8
+ runner = CliRunner()
9
+
10
+
11
+ def test_explain_masks_a_detected_email_and_truncates_the_match() -> None:
12
+ result = runner.invoke(app, ["explain", "Email me at john.doe@example.com please"])
13
+ assert result.exit_code == 0
14
+ assert "EMAIL" in result.stdout
15
+ assert "masked" in result.stdout
16
+ # Truncated to 8 chars + an ellipsis by default -- the full address
17
+ # (>8 chars) must never appear verbatim.
18
+ assert "john.doe@example.com" not in result.stdout
19
+ assert "john.doe" in result.stdout
20
+
21
+
22
+ def test_explain_full_flag_shows_the_entire_match() -> None:
23
+ result = runner.invoke(app, ["explain", "--full", "Email me at john.doe@example.com please"])
24
+ assert result.exit_code == 0
25
+ assert "john.doe@example.com" in result.stdout
26
+
27
+
28
+ def test_explain_reports_a_near_miss_with_a_maskflowrc_snippet() -> None:
29
+ result = runner.invoke(
30
+ app, ["explain", "Random unrelated text with a number 123456789 in it, nothing special."]
31
+ )
32
+ assert result.exit_code == 0
33
+ assert "NEAREST MISSES" in result.stdout
34
+ assert "[entities.SSN]" in result.stdout
35
+ assert "threshold = 0.35" in result.stdout
36
+ # The bare 9-digit run itself (>8 chars) must still be truncated here too.
37
+ assert "123456789" not in result.stdout
38
+
39
+
40
+ def test_explain_no_pii_detected() -> None:
41
+ result = runner.invoke(app, ["explain", "nothing interesting here at all"])
42
+ assert result.exit_code == 0
43
+ assert "No PII detected" in result.stdout
44
+
45
+
46
+ def test_explain_set_override_disables_an_entity() -> None:
47
+ result = runner.invoke(
48
+ app, ["explain", "--set", "entities.EMAIL.enabled=false", "Email me at a@b.com"]
49
+ )
50
+ assert result.exit_code == 0
51
+ assert "EMAIL" not in result.stdout
52
+
53
+
54
+ def test_explain_lowering_threshold_via_set_promotes_a_near_miss_to_masked() -> None:
55
+ text = "Random unrelated text with a number 123456789 in it, nothing special."
56
+ result = runner.invoke(app, ["explain", "--set", "entities.SSN.threshold=0.3", text])
57
+ assert result.exit_code == 0
58
+ assert "NEAREST MISSES" not in result.stdout
59
+ assert "SSN" in result.stdout
60
+ assert "masked" in result.stdout
61
+
62
+
63
+ def test_explain_bad_config_exits_nonzero(fixtures_dir: Path) -> None:
64
+ result = runner.invoke(
65
+ app, ["explain", "--config", str(fixtures_dir / "typo.toml"), "hello"]
66
+ )
67
+ assert result.exit_code == 1
68
+ assert "did you mean 'threshold'?" in result.stderr
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from maskflow_cli.app import app
6
+ from typer.testing import CliRunner
7
+
8
+ runner = CliRunner()
9
+
10
+
11
+ def test_show_plain_toml(fixtures_dir: Path) -> None:
12
+ result = runner.invoke(app, ["config", "show", "--config", str(fixtures_dir / "valid.toml")])
13
+ assert result.exit_code == 0
14
+ assert 'default_strategy = "replace"' in result.stdout
15
+ # exclusions.values must never appear verbatim in output.
16
+ assert "test@example.com" not in result.stdout
17
+
18
+
19
+ def test_show_resolved_annotates_provenance(fixtures_dir: Path) -> None:
20
+ result = runner.invoke(
21
+ app, ["config", "show", "--resolved", "--config", str(fixtures_dir / "valid.toml")]
22
+ )
23
+ assert result.exit_code == 0
24
+ assert "entities.AADHAAR.threshold = 0.6" in result.stdout
25
+ assert "project file:" in result.stdout
26
+ assert str(fixtures_dir / "valid.toml") in result.stdout
27
+ assert "test@example.com" not in result.stdout
28
+
29
+
30
+ def test_show_resolved_defaults_marked_default(fixtures_dir: Path) -> None:
31
+ # partial.toml only sets entities.PAN.enabled -- threshold/strategy
32
+ # (and the whole maskflow/exclusions sections) fall back to defaults.
33
+ result = runner.invoke(
34
+ app, ["config", "show", "--resolved", "--config", str(fixtures_dir / "partial.toml")]
35
+ )
36
+ assert result.exit_code == 0
37
+ line = next(
38
+ line for line in result.stdout.splitlines() if line.startswith("entities.PAN.threshold")
39
+ )
40
+ assert "null" in line
41
+ assert "(default)" in line
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from maskflow_cli.app import app
6
+ from typer.testing import CliRunner
7
+
8
+ runner = CliRunner()
9
+
10
+
11
+ def test_validate_no_config_is_valid() -> None:
12
+ result = runner.invoke(app, ["config", "validate"], env={"HOME": "/nonexistent-home"})
13
+ assert result.exit_code == 0
14
+ assert "Config is valid" in result.stdout
15
+
16
+
17
+ def test_validate_valid_fixture(fixtures_dir: Path) -> None:
18
+ result = runner.invoke(
19
+ app, ["config", "validate", "--config", str(fixtures_dir / "valid.toml")]
20
+ )
21
+ assert result.exit_code == 0
22
+ assert "Config is valid" in result.stdout
23
+ # entity-name soft cross-check warning for custom.EMPLOYEE_ID -- a
24
+ # genuinely custom type, not something any pack registers. AADHAAR (also
25
+ # in this fixture) no longer triggers this warning now that pack-india
26
+ # is wired in, so this assertion only holds because of EMPLOYEE_ID.
27
+ assert "WARNING" in result.stderr
28
+ assert "EMPLOYEE_ID" in result.stderr
29
+ assert "AADHAAR" not in result.stderr
30
+
31
+
32
+ def test_validate_typo_exits_nonzero(fixtures_dir: Path) -> None:
33
+ result = runner.invoke(app, ["config", "validate", "--config", str(fixtures_dir / "typo.toml")])
34
+ assert result.exit_code == 1
35
+ assert "did you mean 'threshold'?" in result.stderr
36
+
37
+
38
+ def test_validate_set_override(fixtures_dir: Path) -> None:
39
+ result = runner.invoke(
40
+ app,
41
+ [
42
+ "config",
43
+ "validate",
44
+ "--config",
45
+ str(fixtures_dir / "valid.toml"),
46
+ "--set",
47
+ "entities.AADHAAR.threshold=0.99",
48
+ ],
49
+ )
50
+ assert result.exit_code == 0
51
+
52
+
53
+ def test_validate_set_bad_pattern_rejected(fixtures_dir: Path) -> None:
54
+ result = runner.invoke(
55
+ app,
56
+ [
57
+ "config",
58
+ "validate",
59
+ "--set",
60
+ "custom.BAD.pattern=(a+)+",
61
+ "--set",
62
+ "custom.BAD.score=0.9",
63
+ ],
64
+ )
65
+ assert result.exit_code == 1
66
+ assert "unsafe pattern" in result.stderr