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.
- maskflow_cli-0.2.0/.gitignore +15 -0
- maskflow_cli-0.2.0/PKG-INFO +42 -0
- maskflow_cli-0.2.0/README.md +26 -0
- maskflow_cli-0.2.0/pyproject.toml +51 -0
- maskflow_cli-0.2.0/src/maskflow_cli/__init__.py +1 -0
- maskflow_cli-0.2.0/src/maskflow_cli/app.py +24 -0
- maskflow_cli-0.2.0/src/maskflow_cli/commands/__init__.py +0 -0
- maskflow_cli-0.2.0/src/maskflow_cli/commands/config_cmd.py +66 -0
- maskflow_cli-0.2.0/src/maskflow_cli/commands/doctor_cmd.py +20 -0
- maskflow_cli-0.2.0/src/maskflow_cli/commands/explain_cmd.py +43 -0
- maskflow_cli-0.2.0/src/maskflow_cli/doctor.py +184 -0
- maskflow_cli-0.2.0/src/maskflow_cli/doctor_render.py +83 -0
- maskflow_cli-0.2.0/src/maskflow_cli/explain.py +92 -0
- maskflow_cli-0.2.0/src/maskflow_cli/explain_render.py +113 -0
- maskflow_cli-0.2.0/src/maskflow_cli/render.py +77 -0
- maskflow_cli-0.2.0/tests/conftest.py +10 -0
- maskflow_cli-0.2.0/tests/fixtures/partial.toml +2 -0
- maskflow_cli-0.2.0/tests/fixtures/typo.toml +8 -0
- maskflow_cli-0.2.0/tests/fixtures/valid.toml +17 -0
- maskflow_cli-0.2.0/tests/test_cli_doctor.py +70 -0
- maskflow_cli-0.2.0/tests/test_cli_explain.py +68 -0
- maskflow_cli-0.2.0/tests/test_cli_show.py +41 -0
- maskflow_cli-0.2.0/tests/test_cli_validate.py +66 -0
|
@@ -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()
|
|
File without changes
|
|
@@ -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,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
|