maskflow-cli 0.7.0__tar.gz → 0.8.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.
Files changed (76) hide show
  1. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/PKG-INFO +4 -2
  2. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/pyproject.toml +14 -2
  3. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/app.py +2 -0
  4. maskflow_cli-0.8.0/src/maskflow_cli/bench_render.py +88 -0
  5. maskflow_cli-0.8.0/src/maskflow_cli/commands/bench_cmd.py +68 -0
  6. maskflow_cli-0.8.0/tests/test_cli_bench.py +76 -0
  7. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/.dockerignore +0 -0
  8. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/.gitignore +0 -0
  9. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/Dockerfile +0 -0
  10. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/README.md +0 -0
  11. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/examples/README.md +0 -0
  12. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/examples/generate_sample.py +0 -0
  13. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/examples/sample-llm-traffic.jsonl +0 -0
  14. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/packaging/README.md +0 -0
  15. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/packaging/_entry.py +0 -0
  16. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/packaging/maskflow.spec +0 -0
  17. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/__init__.py +0 -0
  18. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/commands/__init__.py +0 -0
  19. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/commands/config_cmd.py +0 -0
  20. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/commands/doctor_cmd.py +0 -0
  21. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/commands/explain_cmd.py +0 -0
  22. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/doctor.py +0 -0
  23. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/doctor_render.py +0 -0
  24. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/evidence.py +0 -0
  25. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/explain.py +0 -0
  26. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/explain_render.py +0 -0
  27. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/render.py +0 -0
  28. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/__init__.py +0 -0
  29. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/aggregate.py +0 -0
  30. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/checkpoint.py +0 -0
  31. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/cmd.py +0 -0
  32. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/errors.py +0 -0
  33. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/fieldsel.py +0 -0
  34. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/pipeline.py +0 -0
  35. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/report/__init__.py +0 -0
  36. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/report/assets.py +0 -0
  37. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/report/build.py +0 -0
  38. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/report/csv_out.py +0 -0
  39. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/report/html.py +0 -0
  40. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/report/json_out.py +0 -0
  41. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/report/summary.py +0 -0
  42. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/severity.py +0 -0
  43. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/__init__.py +0 -0
  44. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/_api_common.py +0 -0
  45. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/_files.py +0 -0
  46. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/_http.py +0 -0
  47. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/_meta.py +0 -0
  48. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/base.py +0 -0
  49. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/csv.py +0 -0
  50. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/dir.py +0 -0
  51. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/helicone.py +0 -0
  52. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/jsonl.py +0 -0
  53. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/langfuse.py +0 -0
  54. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/langsmith.py +0 -0
  55. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/postgres.py +0 -0
  56. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/sources/s3.py +0 -0
  57. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/spec.py +0 -0
  58. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/src/maskflow_cli/scan/worker.py +0 -0
  59. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/conftest.py +0 -0
  60. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/fixtures/partial.toml +0 -0
  61. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/fixtures/typo.toml +0 -0
  62. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/fixtures/valid.toml +0 -0
  63. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/scan/__init__.py +0 -0
  64. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/scan/_fuzz_corpus.py +0 -0
  65. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/scan/conftest.py +0 -0
  66. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/scan/test_aggregate_and_severity.py +0 -0
  67. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/scan/test_example_file.py +0 -0
  68. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/scan/test_fieldsel.py +0 -0
  69. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/scan/test_pipeline_and_report.py +0 -0
  70. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/scan/test_report_no_pii_leak.py +0 -0
  71. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/scan/test_sources_api.py +0 -0
  72. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/scan/test_sources_files.py +0 -0
  73. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/test_cli_doctor.py +0 -0
  74. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/test_cli_explain.py +0 -0
  75. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/test_cli_show.py +0 -0
  76. {maskflow_cli-0.7.0 → maskflow_cli-0.8.0}/tests/test_cli_validate.py +0 -0
@@ -1,16 +1,18 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: maskflow-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Command-line interface for MaskFlow: .maskflowrc config validation and inspection
5
5
  License: MIT
6
6
  Requires-Python: >=3.10
7
7
  Requires-Dist: httpx>=0.27
8
- Requires-Dist: maskflow-core[yaml]<0.8,>=0.6.0
8
+ Requires-Dist: maskflow-core[yaml]<0.9,>=0.6.0
9
9
  Requires-Dist: maskflow-pack-india<0.6,>=0.1.0
10
10
  Requires-Dist: maskflow-pack-intl<0.4,>=0.3.0
11
11
  Requires-Dist: rich>=13.0
12
12
  Requires-Dist: tomli-w>=1.0
13
13
  Requires-Dist: typer>=0.12
14
+ Provides-Extra: bench
15
+ Requires-Dist: maskflow-bench<0.2,>=0.1.0; extra == 'bench'
14
16
  Provides-Extra: dev
15
17
  Requires-Dist: pytest>=8.0; extra == 'dev'
16
18
  Provides-Extra: evidence
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "maskflow-cli"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Command-line interface for MaskFlow: .maskflowrc config validation and inspection"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -14,7 +14,7 @@ dependencies = [
14
14
  # -- pack-intl/pack-india now require it, so the floor moves with theirs
15
15
  # even though this package doesn't import maskflow_core.recognizer
16
16
  # directly.
17
- "maskflow-core[yaml]>=0.6.0,<0.8",
17
+ "maskflow-core[yaml]>=0.6.0,<0.9",
18
18
  # maskflow_pack_intl is imported (side-effect only, to populate the
19
19
  # PIIType registry) for `maskflow config validate`'s soft entity-name
20
20
  # cross-check -- see app.py. pack-intl 0.1.0 itself requires core<0.3,
@@ -71,12 +71,24 @@ s3 = [
71
71
  postgres = [
72
72
  "psycopg[binary]>=3.1",
73
73
  ]
74
+ # `maskflow bench --my-data` (issue #36): the corpus-agnostic scoring core
75
+ # (JSONL loading, label canonicalization, strict/partial-overlap matching,
76
+ # report writers). Same policy as [evidence] above -- opt-in so a bare CLI
77
+ # install (and the standalone PyInstaller binary) doesn't require it;
78
+ # bench_cmd.py imports it lazily and every other command runs without it.
79
+ # maskflow-bench itself depends only on maskflow-core, not on the heavy
80
+ # competitor-comparison adapters (presidio/mask-privacy/anthropic) -- those
81
+ # stay dev-only in bench/, never installed here either way.
82
+ bench = [
83
+ "maskflow-bench>=0.1.0,<0.2",
84
+ ]
74
85
 
75
86
  [tool.uv.sources]
76
87
  maskflow-core = { workspace = true }
77
88
  maskflow-pack-intl = { workspace = true }
78
89
  maskflow-pack-india = { workspace = true }
79
90
  maskflow-evidence = { workspace = true }
91
+ maskflow-bench = { workspace = true }
80
92
 
81
93
  [tool.uv]
82
94
  package = true
@@ -6,6 +6,7 @@ import maskflow_pack_india # noqa: F401 -- import side effect registers pack-in
6
6
  import maskflow_pack_intl # noqa: F401 -- import side effect registers pack-intl's entity types
7
7
  import typer
8
8
 
9
+ from .commands.bench_cmd import bench
9
10
  from .commands.config_cmd import app as config_app
10
11
  from .commands.doctor_cmd import doctor
11
12
  from .commands.explain_cmd import explain
@@ -16,6 +17,7 @@ app.add_typer(config_app, name="config")
16
17
  app.command("doctor")(doctor)
17
18
  app.command("explain")(explain)
18
19
  app.command("scan")(scan)
20
+ app.command("bench")(bench)
19
21
 
20
22
 
21
23
  def main() -> None:
@@ -0,0 +1,88 @@
1
+ """Rich rendering for `maskflow bench --my-data`. Kept separate from
2
+ bench_cmd.py so the command body stays thin, same split as
3
+ doctor.py/doctor_render.py.
4
+
5
+ Only type-checked against maskflow-bench (TYPE_CHECKING guard below,
6
+ `from __future__ import annotations` keeps every annotation an
7
+ unevaluated string at runtime) -- this module never constructs a
8
+ PRFResult/AdapterRunResult itself, only duck-types over one handed in by
9
+ bench_cmd.py, so it never needs the optional [bench] extra installed just
10
+ to be imported (bench_cmd.py's own lazy import guards the actual need for
11
+ it)."""
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ from rich import box
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ if TYPE_CHECKING:
22
+ from maskflow_bench.matching import PRFResult
23
+ from maskflow_bench.runner import AdapterRunResult
24
+
25
+ # Same rationale as doctor_render.py's: a fixed width so output doesn't
26
+ # wrap unpredictably under a non-tty (CI logs, CliRunner in tests).
27
+ CONSOLE_WIDTH = 100
28
+
29
+
30
+ def _cell(prf: PRFResult | None, field: str) -> str:
31
+ """'—' when the metric is undefined (matching.PRFResult.f1's own
32
+ convention, see PR #97): no predictions and no gold for this type.
33
+ A real measured zero renders as "0.0%", not "—"."""
34
+ if prf is None:
35
+ return "—"
36
+ value = getattr(prf, field)
37
+ return "—" if value is None else f"{value:.1%}"
38
+
39
+
40
+ def _results_table(canonical_labels: tuple[str, ...], result: AdapterRunResult) -> Table:
41
+ table = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False)
42
+ table.add_column("Entity")
43
+ table.add_column("Strict P", justify="right")
44
+ table.add_column("Strict R", justify="right")
45
+ table.add_column("Strict F1", justify="right")
46
+ table.add_column("Partial P", justify="right")
47
+ table.add_column("Partial R", justify="right")
48
+ table.add_column("Partial F1", justify="right")
49
+ for label in canonical_labels:
50
+ strict = result.strict.get(label)
51
+ partial = result.partial.get(label)
52
+ table.add_row(
53
+ label,
54
+ _cell(strict, "precision"),
55
+ _cell(strict, "recall"),
56
+ _cell(strict, "f1"),
57
+ _cell(partial, "precision"),
58
+ _cell(partial, "recall"),
59
+ _cell(partial, "f1"),
60
+ )
61
+ return table
62
+
63
+
64
+ def render_my_data_result(
65
+ console: Console,
66
+ num_docs: int,
67
+ canonical_labels: tuple[str, ...],
68
+ result: AdapterRunResult,
69
+ ) -> None:
70
+ console.print("[bold]MaskFlow bench --my-data[/bold]")
71
+ console.print(f"{num_docs} document(s), {len(canonical_labels)} entity type(s) in your labels.")
72
+ console.print()
73
+ if not canonical_labels:
74
+ console.print(
75
+ '[yellow]No gold (value_class="positive") entities found -- nothing to '
76
+ "score. Check your file against docs/bench.md's schema.[/yellow]"
77
+ )
78
+ return
79
+ console.print(_results_table(canonical_labels, result))
80
+ console.print()
81
+ console.print(
82
+ '[dim]"—" means undefined (no predictions and no gold for that type); '
83
+ '"0.0%" is a measured zero. Strict = exact span match, partial = any overlap.[/dim]'
84
+ )
85
+
86
+
87
+ def make_console() -> Console:
88
+ return Console(width=CONSOLE_WIDTH)
@@ -0,0 +1,68 @@
1
+ """`maskflow bench --my-data <path>` -- scores MaskFlow's own detector
2
+ against a user's own labelled documents and prints per-entity
3
+ precision/recall/F1. Not a comparison tool (see docs/bench.md); the
4
+ multi-adapter comparison against Presidio/mask-privacy/etc. that produced
5
+ the published IndiaPII-Bench figures lives in bench/ (repo dev tooling).
6
+
7
+ `maskflow-bench` is an **optional** dependency (`pip install
8
+ 'maskflow-cli[bench]'`) -- imported lazily here, same policy as
9
+ `maskflow_cli.evidence`, so a bare CLI install (and the standalone
10
+ PyInstaller binary) doesn't require it and every other command runs
11
+ without it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import typer
19
+
20
+ from ..bench_render import make_console, render_my_data_result
21
+
22
+ _MISSING = (
23
+ "`maskflow bench` needs the bench extra -- install it with: pip install 'maskflow-cli[bench]'"
24
+ )
25
+
26
+ _MY_DATA_OPTION = typer.Option(
27
+ ..., "--my-data", exists=True, dir_okay=False, help="Path to your labelled JSONL file."
28
+ )
29
+ _OUT_OPTION = typer.Option(
30
+ None, "--out", help="Directory to also write results.json and results.md into."
31
+ )
32
+ _LIMIT_OPTION = typer.Option(
33
+ None, "--limit", help="Score only the first N documents (useful to smoke-test a large file)."
34
+ )
35
+
36
+
37
+ def bench(
38
+ my_data: Path = _MY_DATA_OPTION,
39
+ out: Path | None = _OUT_OPTION,
40
+ limit: int | None = _LIMIT_OPTION,
41
+ ) -> None:
42
+ """Runs MaskFlow's detector over your own labelled JSONL file and
43
+ reports per-entity precision/recall/F1 under both strict-span and
44
+ partial-overlap matching. See docs/bench.md for the file schema."""
45
+ try:
46
+ from maskflow_bench.loader import LoaderError
47
+ from maskflow_bench.report import write_report
48
+ from maskflow_bench.scorer import score_my_data
49
+ except ModuleNotFoundError as exc:
50
+ typer.echo(_MISSING, err=True)
51
+ raise typer.Exit(code=1) from exc
52
+
53
+ console = make_console()
54
+ try:
55
+ num_docs, labels, result = score_my_data(my_data, limit=limit)
56
+ except LoaderError as exc:
57
+ typer.echo(f"{my_data}:{exc}", err=True)
58
+ raise typer.Exit(code=1) from exc
59
+
60
+ if num_docs == 0:
61
+ typer.echo(f"{my_data}: no documents found -- is the file empty?", err=True)
62
+ raise typer.Exit(code=1)
63
+
64
+ render_my_data_result(console, num_docs, labels, result)
65
+
66
+ if out is not None:
67
+ write_report(out, "my-data", num_docs, labels, {"maskflow": result})
68
+ console.print(f"\nwrote {out / 'results.json'} and {out / 'results.md'}")
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from maskflow_bench.scorer import score_my_data
7
+ from maskflow_cli.app import app
8
+ from typer.testing import CliRunner
9
+
10
+ runner = CliRunner()
11
+
12
+
13
+ def _write_fixture(path: Path) -> None:
14
+ # PAN below is structurally well-formed (5 letters + 4 digits + 1
15
+ # letter) but fabricated for this test, not a real allotted number --
16
+ # synthetic fixture data only, per project policy.
17
+ rows = [
18
+ {
19
+ "id": "fixture-1",
20
+ "text": "Please update my PAN ABCDE1234F on file.",
21
+ "entities": [{"start": 22, "end": 32, "label": "PAN"}],
22
+ },
23
+ {
24
+ "id": "fixture-2",
25
+ "text": "No PII in this line at all.",
26
+ "entities": [],
27
+ },
28
+ ]
29
+ path.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
30
+
31
+
32
+ def test_my_data_prints_a_per_entity_table(tmp_path: Path) -> None:
33
+ fixture = tmp_path / "my.jsonl"
34
+ _write_fixture(fixture)
35
+
36
+ result = runner.invoke(app, ["bench", "--my-data", str(fixture)])
37
+
38
+ assert result.exit_code == 0
39
+ assert "PAN" in result.stdout
40
+ assert "2 document(s)" in result.stdout
41
+
42
+
43
+ def test_my_data_out_writes_results_matching_the_scorer_directly(tmp_path: Path) -> None:
44
+ fixture = tmp_path / "my.jsonl"
45
+ _write_fixture(fixture)
46
+ out_dir = tmp_path / "out"
47
+
48
+ result = runner.invoke(app, ["bench", "--my-data", str(fixture), "--out", str(out_dir)])
49
+
50
+ assert result.exit_code == 0
51
+ data = json.loads((out_dir / "results.json").read_text(encoding="utf-8"))
52
+ assert (out_dir / "results.md").exists()
53
+
54
+ num_docs, labels, expected = score_my_data(fixture)
55
+ assert data["num_docs"] == num_docs
56
+ assert data["canonical_labels"] == list(labels)
57
+ assert data["adapters"]["maskflow"]["available"] is True
58
+ assert data["adapters"]["maskflow"]["strict"]["PAN"]["tp"] == expected.strict["PAN"].tp
59
+
60
+
61
+ def test_my_data_malformed_line_reports_line_number_and_exits_nonzero(tmp_path: Path) -> None:
62
+ fixture = tmp_path / "bad.jsonl"
63
+ fixture.write_text(
64
+ json.dumps({"text": "ok", "entities": []}) + "\n" + json.dumps({"text": "missing ents"}),
65
+ encoding="utf-8",
66
+ )
67
+
68
+ result = runner.invoke(app, ["bench", "--my-data", str(fixture)])
69
+
70
+ assert result.exit_code != 0
71
+ assert "line 2" in result.stdout + result.stderr
72
+
73
+
74
+ def test_my_data_missing_file_exits_nonzero(tmp_path: Path) -> None:
75
+ result = runner.invoke(app, ["bench", "--my-data", str(tmp_path / "does-not-exist.jsonl")])
76
+ assert result.exit_code != 0
File without changes
File without changes
File without changes
File without changes