maskflow-cli 0.6.0__tar.gz → 0.7.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 (73) hide show
  1. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/PKG-INFO +4 -2
  2. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/pyproject.toml +11 -2
  3. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/commands/explain_cmd.py +32 -2
  4. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/doctor.py +11 -0
  5. maskflow_cli-0.7.0/src/maskflow_cli/evidence.py +83 -0
  6. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/explain.py +20 -2
  7. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/test_cli_explain.py +41 -0
  8. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/.dockerignore +0 -0
  9. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/.gitignore +0 -0
  10. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/Dockerfile +0 -0
  11. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/README.md +0 -0
  12. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/examples/README.md +0 -0
  13. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/examples/generate_sample.py +0 -0
  14. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/examples/sample-llm-traffic.jsonl +0 -0
  15. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/packaging/README.md +0 -0
  16. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/packaging/_entry.py +0 -0
  17. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/packaging/maskflow.spec +0 -0
  18. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/__init__.py +0 -0
  19. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/app.py +0 -0
  20. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/commands/__init__.py +0 -0
  21. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/commands/config_cmd.py +0 -0
  22. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/commands/doctor_cmd.py +0 -0
  23. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/doctor_render.py +0 -0
  24. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/explain_render.py +0 -0
  25. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/render.py +0 -0
  26. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/__init__.py +0 -0
  27. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/aggregate.py +0 -0
  28. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/checkpoint.py +0 -0
  29. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/cmd.py +0 -0
  30. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/errors.py +0 -0
  31. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/fieldsel.py +0 -0
  32. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/pipeline.py +0 -0
  33. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/__init__.py +0 -0
  34. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/assets.py +0 -0
  35. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/build.py +0 -0
  36. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/csv_out.py +0 -0
  37. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/html.py +0 -0
  38. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/json_out.py +0 -0
  39. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/summary.py +0 -0
  40. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/severity.py +0 -0
  41. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/__init__.py +0 -0
  42. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/_api_common.py +0 -0
  43. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/_files.py +0 -0
  44. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/_http.py +0 -0
  45. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/_meta.py +0 -0
  46. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/base.py +0 -0
  47. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/csv.py +0 -0
  48. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/dir.py +0 -0
  49. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/helicone.py +0 -0
  50. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/jsonl.py +0 -0
  51. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/langfuse.py +0 -0
  52. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/langsmith.py +0 -0
  53. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/postgres.py +0 -0
  54. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/s3.py +0 -0
  55. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/spec.py +0 -0
  56. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/worker.py +0 -0
  57. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/conftest.py +0 -0
  58. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/fixtures/partial.toml +0 -0
  59. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/fixtures/typo.toml +0 -0
  60. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/fixtures/valid.toml +0 -0
  61. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/__init__.py +0 -0
  62. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/_fuzz_corpus.py +0 -0
  63. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/conftest.py +0 -0
  64. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_aggregate_and_severity.py +0 -0
  65. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_example_file.py +0 -0
  66. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_fieldsel.py +0 -0
  67. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_pipeline_and_report.py +0 -0
  68. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_report_no_pii_leak.py +0 -0
  69. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_sources_api.py +0 -0
  70. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_sources_files.py +0 -0
  71. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/test_cli_doctor.py +0 -0
  72. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/test_cli_show.py +0 -0
  73. {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/test_cli_validate.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: maskflow-cli
3
- Version: 0.6.0
3
+ Version: 0.7.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.7,>=0.6.0
8
+ Requires-Dist: maskflow-core[yaml]<0.8,>=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
@@ -13,6 +13,8 @@ Requires-Dist: tomli-w>=1.0
13
13
  Requires-Dist: typer>=0.12
14
14
  Provides-Extra: dev
15
15
  Requires-Dist: pytest>=8.0; extra == 'dev'
16
+ Provides-Extra: evidence
17
+ Requires-Dist: maskflow-evidence<0.2,>=0.1.0; extra == 'evidence'
16
18
  Provides-Extra: postgres
17
19
  Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
18
20
  Provides-Extra: s3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "maskflow-cli"
3
- version = "0.6.0"
3
+ version = "0.7.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.7",
17
+ "maskflow-core[yaml]>=0.6.0,<0.8",
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,
@@ -53,6 +53,14 @@ maskflow = "maskflow_cli.app:main"
53
53
  dev = [
54
54
  "pytest>=8.0",
55
55
  ]
56
+ # `maskflow explain --evidence` / `--evidence-file`: metadata-only evidence
57
+ # events (issue #41). Opt-in so a bare CLI install (and the standalone
58
+ # PyInstaller binary) doesn't require it; `maskflow_cli.evidence` imports it
59
+ # lazily and every other command runs without it. Base pull is the
60
+ # stdlib-only sinks; [webhook]/[otlp] stay opt-in on maskflow-evidence.
61
+ evidence = [
62
+ "maskflow-evidence>=0.1.0,<0.2",
63
+ ]
56
64
  # `maskflow scan s3://...` -- only pulled by an explicit
57
65
  # `pip install 'maskflow-cli[s3]'`, so boto3 (large) never taxes the common
58
66
  # jsonl/csv/dir scan or the rest of the CLI.
@@ -68,6 +76,7 @@ postgres = [
68
76
  maskflow-core = { workspace = true }
69
77
  maskflow-pack-intl = { workspace = true }
70
78
  maskflow-pack-india = { workspace = true }
79
+ maskflow-evidence = { workspace = true }
71
80
 
72
81
  [tool.uv]
73
82
  package = true
@@ -7,7 +7,8 @@ from pathlib import Path
7
7
  import typer
8
8
  from maskflow_core.config.resolve import ConfigResolutionError, resolve_config
9
9
 
10
- from ..explain import run_explain
10
+ from ..evidence import EvidenceExtraMissing, emit_explain_evidence
11
+ from ..explain import build_explain_result, detect_for_explain
11
12
  from ..explain_render import make_console, render_explain
12
13
 
13
14
  _CONFIG_OPTION = typer.Option(
@@ -19,6 +20,17 @@ _SET_OPTION = typer.Option(
19
20
  _FULL_OPTION = typer.Option(
20
21
  False, "--full", help="Show the entire matched value instead of truncating to 8 chars."
21
22
  )
23
+ _EVIDENCE_OPTION = typer.Option(
24
+ None,
25
+ "--evidence/--no-evidence",
26
+ help="Emit metadata-only evidence events for this run (default: the "
27
+ "[evidence] section of your .maskflowrc). Never contains a detected value.",
28
+ )
29
+ _EVIDENCE_FILE_OPTION = typer.Option(
30
+ None,
31
+ "--evidence-file",
32
+ help="Write evidence events (one JSON object per line) to this file. Implies --evidence.",
33
+ )
22
34
 
23
35
 
24
36
  def explain(
@@ -26,6 +38,8 @@ def explain(
26
38
  full: bool = _FULL_OPTION,
27
39
  config: Path | None = _CONFIG_OPTION,
28
40
  set_: list[str] = _SET_OPTION,
41
+ evidence: bool | None = _EVIDENCE_OPTION,
42
+ evidence_file: Path | None = _EVIDENCE_FILE_OPTION,
29
43
  ) -> None:
30
44
  """Show, span by span, why each piece of text was (or wasn't) detected
31
45
  as PII: the pattern/NER hit, checksum result, context boost, and the
@@ -39,5 +53,21 @@ def explain(
39
53
  typer.echo(line, err=True)
40
54
  raise typer.Exit(code=1) from exc
41
55
 
42
- result = run_explain(text, resolved.config, full=full)
56
+ accepted, rejected = detect_for_explain(text, resolved.config)
57
+ result = build_explain_result(text, accepted, rejected, resolved.config, full=full)
43
58
  render_explain(make_console(), result)
59
+
60
+ enabled = resolved.config.evidence.enabled if evidence is None else evidence
61
+ try:
62
+ emitted = emit_explain_evidence(
63
+ accepted,
64
+ rejected,
65
+ resolved.config,
66
+ enabled=enabled,
67
+ evidence_file=evidence_file,
68
+ )
69
+ except EvidenceExtraMissing as exc:
70
+ typer.echo(str(exc), err=True)
71
+ raise typer.Exit(code=1) from exc
72
+ if emitted and evidence_file is not None:
73
+ typer.echo(f"\n{emitted} evidence event(s) written to {evidence_file}", err=True)
@@ -127,6 +127,16 @@ def _check_config() -> tuple[ComponentCheck, ResolvedConfig | None]:
127
127
  return ComponentCheck(".maskflowrc", None, status, detail), resolved
128
128
 
129
129
 
130
+ def _check_evidence(resolved: ResolvedConfig | None) -> ComponentCheck:
131
+ """Reports whether the metadata-only evidence layer is on, and to where.
132
+ Off by default -- an informational line, never an error."""
133
+ section = resolved.config.evidence if resolved is not None else None
134
+ if section is None or not section.enabled:
135
+ return ComponentCheck("evidence", None, "ok", "disabled (default) -- no events emitted")
136
+ where = section.path if section.sink == "file" else (section.url or section.sink)
137
+ return ComponentCheck("evidence", None, "ok", f"enabled -- sink: {section.sink} ({where})")
138
+
139
+
130
140
  def _check_redis() -> ComponentCheck:
131
141
  # RedisMappingStore (maskflow_core.mapping_store) is an interface-only
132
142
  # stub -- every method raises NotImplementedError regardless of whether
@@ -177,6 +187,7 @@ def run_checks() -> DoctorReport:
177
187
  config_check, resolved = _check_config()
178
188
  components.append(config_check)
179
189
 
190
+ components.append(_check_evidence(resolved))
180
191
  components.append(_check_redis())
181
192
 
182
193
  entities = _entity_checks(resolved, spacy_ready=model_check.status == "ok")
@@ -0,0 +1,83 @@
1
+ """`maskflow explain --evidence`: emit one metadata-only evidence event per
2
+ detected (or near-miss) entity type for this run.
3
+
4
+ Thin glue over ``maskflow_evidence``, which is an **optional** dependency
5
+ (``pip install maskflow-cli[evidence]``) -- imported lazily so a bare CLI
6
+ install without it still runs every other command.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import secrets
12
+ from collections.abc import Sequence
13
+ from pathlib import Path
14
+
15
+ from maskflow_core.config.schema import RootConfig
16
+ from maskflow_core.entities import Span
17
+
18
+ _MISSING = (
19
+ "the --evidence / --evidence-file flags need the evidence extra -- "
20
+ "install it with: pip install 'maskflow-cli[evidence]'"
21
+ )
22
+
23
+
24
+ class EvidenceExtraMissing(RuntimeError):
25
+ """Raised when --evidence is requested but maskflow-evidence isn't installed."""
26
+
27
+ def __init__(self) -> None:
28
+ super().__init__(_MISSING)
29
+
30
+
31
+ def emit_explain_evidence(
32
+ accepted: Sequence[Span],
33
+ rejected: Sequence[Span],
34
+ root_config: RootConfig,
35
+ *,
36
+ enabled: bool,
37
+ evidence_file: Path | None,
38
+ ) -> int:
39
+ """Emit events for one `explain` run. Returns the count emitted.
40
+
41
+ ``enabled`` is the resolved on/off (``--evidence`` / ``[evidence]
42
+ enabled``); ``evidence_file`` forces a file sink at that path. Returns 0
43
+ without importing anything when evidence is off.
44
+ """
45
+ want = evidence_file is not None or enabled
46
+ if not want and not root_config.evidence.enabled:
47
+ return 0
48
+
49
+ try:
50
+ from dataclasses import replace
51
+
52
+ from maskflow_evidence import (
53
+ EventContext,
54
+ EvidenceConfig,
55
+ build_emitter,
56
+ events_from_spans,
57
+ )
58
+ except ModuleNotFoundError as exc:
59
+ if evidence_file is not None or enabled:
60
+ raise EvidenceExtraMissing() from exc
61
+ return 0 # [evidence] enabled in config but the extra isn't installed
62
+
63
+ config = EvidenceConfig.from_rootconfig(root_config)
64
+ if evidence_file is not None:
65
+ config = replace(config, enabled=True, sink="file", path=str(evidence_file))
66
+ elif enabled:
67
+ config = replace(config, enabled=True)
68
+ if not config.enabled:
69
+ return 0
70
+
71
+ ctx = EventContext(
72
+ session_id="cli_" + secrets.token_hex(8),
73
+ service=config.service,
74
+ environment=config.environment,
75
+ )
76
+ emitter = build_emitter(config)
77
+ try:
78
+ events = events_from_spans(accepted, ctx, dropped=rejected)
79
+ for event in events:
80
+ emitter.emit(event)
81
+ return len(events)
82
+ finally:
83
+ emitter.close()
@@ -70,16 +70,29 @@ def suggested_threshold(score: float) -> float:
70
70
  return math.floor(score * 20) / 20
71
71
 
72
72
 
73
- def run_explain(text: str, root_config: RootConfig, *, full: bool = False) -> ExplainResult:
73
+ def detect_for_explain(text: str, root_config: RootConfig) -> tuple[list[Span], list[Span]]:
74
+ """The `(accepted, rejected)` span split `explain` is built on. Exposed
75
+ so the command can reuse the same spans for `--evidence` without a
76
+ second detect() pass."""
74
77
  compiled = compile_config(root_config)
75
-
76
78
  accepted, rejected = detect(
77
79
  text,
78
80
  min_confidence=DEFAULT_MIN_CONFIDENCE,
79
81
  return_rejected=True,
80
82
  **compiled.detect_kwargs(),
81
83
  )
84
+ return list(accepted), list(rejected)
82
85
 
86
+
87
+ def build_explain_result(
88
+ text: str,
89
+ accepted: list[Span],
90
+ rejected: list[Span],
91
+ root_config: RootConfig,
92
+ *,
93
+ full: bool = False,
94
+ ) -> ExplainResult:
95
+ compiled = compile_config(root_config)
83
96
  masked = [_view(s, full=full, threshold=None) for s in accepted]
84
97
  near_misses = [
85
98
  _view(
@@ -90,3 +103,8 @@ def run_explain(text: str, root_config: RootConfig, *, full: bool = False) -> Ex
90
103
  for s in rejected
91
104
  ]
92
105
  return ExplainResult(text_length=len(text), masked=masked, near_misses=near_misses)
106
+
107
+
108
+ def run_explain(text: str, root_config: RootConfig, *, full: bool = False) -> ExplainResult:
109
+ accepted, rejected = detect_for_explain(text, root_config)
110
+ return build_explain_result(text, accepted, rejected, root_config, full=full)
@@ -69,3 +69,44 @@ def test_explain_bad_config_exits_nonzero(fixtures_dir: Path) -> None:
69
69
  result = runner.invoke(app, ["explain", "--config", str(fixtures_dir / "typo.toml"), "hello"])
70
70
  assert result.exit_code == 1
71
71
  assert "did you mean 'threshold'?" in result.stderr
72
+
73
+
74
+ # --- --evidence -------------------------------------------------------
75
+
76
+
77
+ def test_explain_evidence_file_writes_metadata_only(tmp_path: Path) -> None:
78
+ import json
79
+
80
+ out = tmp_path / "evidence.log"
81
+ result = runner.invoke(
82
+ app,
83
+ ["explain", "--evidence-file", str(out), "Email me at john.doe@example.com please"],
84
+ )
85
+ assert result.exit_code == 0
86
+ lines = out.read_text().strip().splitlines()
87
+ assert lines
88
+ record = json.loads(lines[0])
89
+ assert record["entity_type"] == "EMAIL"
90
+ assert record["action"] == "masked"
91
+ assert record["recognizer"] == "pattern:EMAIL"
92
+ # the value itself never appears
93
+ assert "john.doe@example.com" not in out.read_text()
94
+
95
+
96
+ def test_explain_no_evidence_by_default(tmp_path: Path) -> None:
97
+ # no --evidence, no [evidence] config -> nothing written anywhere
98
+ result = runner.invoke(app, ["explain", "Email me at john.doe@example.com please"])
99
+ assert result.exit_code == 0
100
+ assert not (tmp_path / "evidence.log").exists()
101
+
102
+
103
+ def test_explain_evidence_reports_near_misses_as_passed(tmp_path: Path) -> None:
104
+ import json
105
+
106
+ out = tmp_path / "e.log"
107
+ text = "Random unrelated text with a number 123456789 in it, nothing special."
108
+ result = runner.invoke(app, ["explain", "--evidence-file", str(out), text])
109
+ assert result.exit_code == 0
110
+ actions = {json.loads(line)["action"] for line in out.read_text().strip().splitlines()}
111
+ assert "passed" in actions
112
+ assert "123456789" not in out.read_text()
File without changes
File without changes
File without changes
File without changes