Cleanprompt on the Command Line: Single Commands and Edge Cases#
A reference for the command line, organised as the ordinary form first, then the things that bite. The second half is the longer one, because the failures a redaction tool can have at the shell are not evenly distributed: almost all of them are about how the text gets in.
The command line is reachable three ways, all the same code:
scikitplot cleanprompt doctor # the project-wide CLI
python -m scikitplot.cleanprompt doctor
python -m scikitplot cleanprompt doctor
Two frontends parse it — click when installed, argparse otherwise — and
--frontend forces either. They are required to read a command line the same
way; this example asserts that rather than claiming it.
Exit codes, which is what a script actually consumes:
Status |
Meaning |
|---|---|
|
the command did what it was asked |
|
a handled error: no vault, unreadable file, wrong passphrase,
|
|
a usage error: unknown option, unknown command, an abbreviation |
|
|
|
an optional tier is required and is not installed |
|
interrupted |
|
the reader closed the pipe, as |
Every value printed below is reserved and cannot reach anybody.
# Authors: The scikit-plots developers
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
_WORKSPACE = tempfile.TemporaryDirectory(prefix="scikitplot-cleanprompt-cli-")
_HOME = Path(_WORKSPACE.name)
_VAULT = _HOME / "vault.json"
os.environ["CLEANPROMPT_VAULT"] = str(_VAULT)
def _cli() -> list[str]:
"""Return the command prefix that reaches this submodule's CLI."""
executable = shutil.which("scikitplot")
if executable:
return [executable, "cleanprompt"]
return [sys.executable, "-m", "scikitplot.cleanprompt"]
CLI = _cli()
def _mask(text: str) -> str:
return text.replace(_WORKSPACE.name, "<workspace>")
def _echo(argument: str) -> str:
single_line = _mask(argument).replace("\n", "⏎")
if len(single_line) > 52:
single_line = single_line[:49] + "..."
if single_line == "" or " " in argument:
return '"{0}"'.format(single_line)
return single_line
def run(
*arguments: str,
stdin_text: str | None = None,
limit: int = 0,
expect: int | None = None,
) -> subprocess.CompletedProcess:
"""Run one CLI command and show the command, both streams and the status."""
completed = subprocess.run(
[*CLI, *arguments],
input=stdin_text if stdin_text is not None else "",
capture_output=True,
text=True,
)
print("$ cleanprompt", " ".join(_echo(one) for one in arguments))
for label, stream in (("out", completed.stdout), ("err", completed.stderr)):
if not stream:
continue
lines = _mask(stream).rstrip("\n").split("\n")
if limit and len(lines) > limit:
lines = lines[:limit] + ["... ({0} more)".format(len(lines) - limit)]
print(" {0} │ {1}".format(label, "\n │ ".join(lines)))
print(" exit │", completed.returncode)
if expect is not None:
assert completed.returncode == expect, "expected exit {0}".format(expect)
return completed
Part one — the ordinary forms#
1. The four ways to give it text#
One command, four input routes. They exist because a shell is not a good place to type a paragraph, and most of the edge cases later in this example are somebody discovering that.
PROMPT = "Mail ada@example.com about the outage on 192.0.2.10."
As an argument. Fine for short text. Quote it.
run("encode", "--quiet", "--vault-mode", "overwrite", PROMPT)
$ cleanprompt encode --quiet --vault-mode overwrite "Mail ada@example.com about the outage on 192.0.2.10."
out │ Mail [EMAIL-1] about the outage on [IPV4-1].
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'encode', '--quiet', '--vault-mode', 'overwrite', 'Mail ada@example.com about the outage on 192.0.2.10.'], returncode=0, stdout='Mail [EMAIL-1] about the outage on [IPV4-1].\n', stderr='')
From a file, with --in. No quoting rules apply at all.
source = _HOME / "prompt.txt"
source.write_text(PROMPT, encoding="utf-8")
run("encode", "--quiet", "--vault-mode", "overwrite", "--in", str(source))
$ cleanprompt encode --quiet --vault-mode overwrite --in <workspace>/prompt.txt
out │ Mail [EMAIL-1] about the outage on [IPV4-1].
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'encode', '--quiet', '--vault-mode', 'overwrite', '--in', '/tmp/scikitplot-cleanprompt-cli-7_bf2yvl/prompt.txt'], returncode=0, stdout='Mail [EMAIL-1] about the outage on [IPV4-1].\n', stderr='')
From standard input, with --in - or by omitting the text. This is
the route a pipeline uses.
run("encode", "--quiet", "--vault-mode", "overwrite", "--in", "-", stdin_text=PROMPT)
$ cleanprompt encode --quiet --vault-mode overwrite --in -
out │ Mail [EMAIL-1] about the outage on [IPV4-1].
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'encode', '--quiet', '--vault-mode', 'overwrite', '--in', '-'], returncode=0, stdout='Mail [EMAIL-1] about the outage on [IPV4-1].\n', stderr='')
From a heredoc, which is standard input with the shell’s quoting turned off entirely. This is the answer to “how do I paste a paragraph”:
cleanprompt encode --quiet <<'EOF'
Mail ada@example.com about the outage on 192.0.2.10.
It has (parentheses), $dollars, `backticks` and 'quotes'.
EOF
The quoted delimiter <<'EOF' is the load-bearing part: it stops the shell
expanding anything between the markers. Without the quotes, $dollars
becomes empty and ` `backticks` ` are executed.
AWKWARD = (
"Mail ada@example.com about the outage on 192.0.2.10.\n"
"It has (parentheses), $dollars, `backticks` and 'quotes'."
)
run("encode", "--quiet", "--vault-mode", "overwrite", stdin_text=AWKWARD)
$ cleanprompt encode --quiet --vault-mode overwrite
out │ Mail [EMAIL-1] about the outage on [IPV4-1].
│ It has (parentheses), $dollars, `backticks` and 'quotes'.
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'encode', '--quiet', '--vault-mode', 'overwrite'], returncode=0, stdout="Mail [EMAIL-1] about the outage on [IPV4-1].\nIt has (parentheses), $dollars, `backticks` and 'quotes'.\n", stderr='')
2. Where the output goes#
The prompt goes to standard output; every report, warning and summary
goes to standard error. That split is the whole reason the command is
usable in a pipeline: -q silences the summary, and stdout carries nothing
but the text to paste.
bare = run("encode", "--quiet", "--vault-mode", "overwrite", PROMPT)
print()
print("stdout is exactly the prompt:", repr(bare.stdout))
print("stderr is empty under --quiet:", bare.stderr == "")
$ cleanprompt encode --quiet --vault-mode overwrite "Mail ada@example.com about the outage on 192.0.2.10."
out │ Mail [EMAIL-1] about the outage on [IPV4-1].
exit │ 0
stdout is exactly the prompt: 'Mail [EMAIL-1] about the outage on [IPV4-1].\n'
stderr is empty under --quiet: True
Without -q the same stdout is produced and the summary appears beside it.
Redirecting one does not affect the other:
cleanprompt encode --in draft.txt > clean.txt # keep the summary on screen
cleanprompt encode --in draft.txt 2>/dev/null # keep only the prompt
cleanprompt encode --in draft.txt | pbcopy # straight to the clipboard
cleanprompt encode --in draft.txt --out clean.txt # no shell redirection
run("encode", "--vault-mode", "overwrite", "--out", str(_HOME / "clean.txt"), PROMPT)
print("file written:", (_HOME / "clean.txt").read_text(encoding="utf-8").strip())
$ cleanprompt encode --vault-mode overwrite --out <workspace>/clean.txt "Mail ada@example.com about the outage on 192.0.2.10."
err │ vault: <workspace>/vault.json (overwrite, 2 value(s))
│ warning: Names, organisations, places and other named entities not detected
│ spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)
exit │ 0
file written: Mail [EMAIL-1] about the outage on [IPV4-1].
3. The commands, and when to reach for each#
encode and decode are the pair. The rest answer other questions.
run("kinds", limit=6)
$ cleanprompt kinds
out │ kinds:
│ AWS_ACCESS_KEY:
│ intent: An AWS access key identifier, recognised by its documented four-character resource-type prefix and sixteen-character body.
│ priority: 95
│ enabled_by_default: yes
│ validated: no
│ ... (84 more)
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'kinds'], returncode=0, stdout='kinds:\n AWS_ACCESS_KEY:\n intent: An AWS access key identifier, recognised by its documented four-character resource-type prefix and sixteen-character body.\n priority: 95\n enabled_by_default: yes\n validated: no\n confidence: 1.0\n CREDIT_CARD:\n intent: A payment card number in the common four-group layout. Luhn decides, so long order numbers and identifiers are not swept in.\n priority: 90\n enabled_by_default: yes\n validated: yes\n confidence: 1.0\n EMAIL:\n intent: An email address, following the RFC 5322 dot-atom local part and an RFC 1035 label-based domain. The top-level label is letters, digits or hyphen only; the upstream class \'[A-Z|a-z]\' admitted a literal pipe, which is defect CP-005.\n priority: 80\n enabled_by_default: yes\n validated: no\n confidence: 1.0\n IBAN:\n intent: An international bank account number. The structural shape alone is far too permissive, so the mod-97 checksum decides.\n priority: 90\n enabled_by_default: yes\n validated: yes\n confidence: 1.0\n IPV4:\n intent: A dotted-quad IPv4 address. The octet range is checked by a validator rather than by an unreadable alternation. The guards reject a longer dotted run such as \'1.2.3.4.5\' without rejecting an address at the end of a sentence: the trailing dot only disqualifies the match when a digit follows it (CP-028).\n priority: 60\n enabled_by_default: yes\n validated: yes\n confidence: 1.0\n IPV6:\n intent: An IPv6 address in its common full or partially compressed forms. Placed above IPv4 so that an embedded dotted quad is not split. The trailing dot is rejected only when something follows it, so an address ending a sentence is still found (CP-028).\n priority: 65\n enabled_by_default: yes\n validated: no\n confidence: 1.0\n JWT:\n intent: A JSON Web Token. Anchored on the base64 of \'{"\' so that an arbitrary three-part dotted identifier is not mistaken for one.\n priority: 95\n enabled_by_default: yes\n validated: no\n confidence: 1.0\n MAC:\n intent: A hardware (MAC) address in colon or hyphen notation. Ranked above IPV6 because the two shapes collide: six colon-separated pairs of hex digits is a valid IPv6 fragment and an exact MAC, and equal-length spans are arbitrated by priority.\n priority: 70\n enabled_by_default: yes\n validated: no\n confidence: 1.0\n PHONE:\n intent: A telephone number written with separators, or with an international prefix. A validator enforces a 7-to-15 digit count and rejects ISO-8601 dates, which the upstream expression matched (defect CP-013).\n priority: 40\n enabled_by_default: yes\n validated: yes\n confidence: 0.85\n PRIVATE_KEY:\n intent: A PEM-armoured private key block. Highest priority because the body contains base64 that other patterns would otherwise carve up.\n priority: 100\n enabled_by_default: yes\n validated: no\n confidence: 1.0\n SSN_US:\n intent: A United States Social Security number in its hyphenated form. Only the hyphenated shape is matched, because a bare nine-digit run is indistinguishable from countless other identifiers.\n priority: 85\n enabled_by_default: yes\n validated: yes\n confidence: 1.0\n TITLE_CASE:\n intent: A run of two or more capitalised words, which is what most personal and organisation names look like. DISABLED BY DEFAULT and deliberately so: it is the only pattern here whose precision is not high. It exists because without the optional \'ner\' tier there is otherwise nothing at all that can find a person\'s name, and a redaction tool that silently cannot see names is worse than one that over-redacts visibly. Enable it explicitly, or use the suggestion surface instead, which shows candidates and lets a person choose.\n priority: 20\n enabled_by_default: no\n validated: yes\n confidence: 0.45\n URL:\n intent: An absolute http, https or ftp URL, built from the RFC 3986 unreserved and sub-delimiter sets written out explicitly. The upstream class \'[$-_@.&+]\' was read by the engine as the range 0x24 to 0x5F, which is defect CP-004. A URL may not end on sentence punctuation, so a trailing full stop, comma or bracket is left in the text instead of being swallowed into the placeholder (CP-029).\n priority: 70\n enabled_by_default: yes\n validated: no\n confidence: 1.0\nprofiles:\n balanced:\n - case_insensitive\n - kinds\n minimal:\n - case_insensitive\n - kinds\n strict:\n - case_insensitive\n - kinds\nstatus: ok\n', stderr='')
inspect is a dry run: it reports what would be removed and writes
nothing at all — no vault, no output file. Use it before trusting a policy.
run("inspect", "--format", "json", PROMPT, limit=12)
$ cleanprompt inspect --format json "Mail ada@example.com about the outage on 192.0.2.10."
out │ {
│ "outcome": "warning",
│ "headline": "redacted 2 value(s): EMAIL=1, IPV4=1",
│ "detail": "Placeholders are stable; restore the reply with the same vault. Note that names, organisations, places and other named entities are still not being detected.",
│ "would_redact": [
│ {
│ "placeholder": "[EMAIL-1]",
│ "kind": "EMAIL",
│ "occurrences": 1,
│ "detector": "regex:EMAIL",
│ "confidence": 1.0
│ },
│ ... (118 more)
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'inspect', '--format', 'json', 'Mail ada@example.com about the outage on 192.0.2.10.'], returncode=0, stdout='{\n "outcome": "warning",\n "headline": "redacted 2 value(s): EMAIL=1, IPV4=1",\n "detail": "Placeholders are stable; restore the reply with the same vault. Note that names, organisations, places and other named entities are still not being detected.",\n "would_redact": [\n {\n "placeholder": "[EMAIL-1]",\n "kind": "EMAIL",\n "occurrences": 1,\n "detector": "regex:EMAIL",\n "confidence": 1.0\n },\n {\n "placeholder": "[IPV4-1]",\n "kind": "IPV4",\n "occurrences": 1,\n "detector": "regex:IPV4",\n "confidence": 1.0\n }\n ],\n "suggestions": [\n {\n "text": "Mail",\n "count": 1,\n "reason": "capitalised word run, not matched by any active detector",\n "first_offset": 0\n }\n ],\n "diagnosis": {\n "headline": "12 structural detectors are active. Names, organisations and places are NOT being detected.",\n "healthy": false,\n "ner_active": false,\n "active_kinds": [\n "AWS_ACCESS_KEY",\n "CREDIT_CARD",\n "EMAIL",\n "IBAN",\n "IPV4",\n "IPV6",\n "JWT",\n "MAC",\n "PHONE",\n "PRIVATE_KEY",\n "SSN_US",\n "URL"\n ],\n "inactive_kinds": [\n "TITLE_CASE"\n ],\n "tiers": {\n "ner": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "spacy",\n "version": "3.8.16",\n "supported": "spacy>=3.4,<5",\n "detail": "spacy 3.8.16 satisfies spacy>=3.4,<5",\n "install_hint": "pip install \\"spacy>=3.4,<5\\"",\n "purpose": "named-entity detection"\n },\n "nltk": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "nltk",\n "version": "3.10.3",\n "supported": "nltk>=3.6,<4",\n "detail": "nltk 3.10.3 satisfies nltk>=3.6,<4",\n "install_hint": "pip install \\"nltk>=3.6,<4\\"",\n "purpose": "named-entity detection without spaCy (English only)"\n },\n "web": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "flask",\n "version": "3.1.3",\n "supported": "flask>=2.2,<4",\n "detail": "flask 3.1.3 satisfies flask>=2.2,<4",\n "install_hint": "pip install \\"flask>=2.2,<4\\"",\n "purpose": "the local web interface"\n },\n "crypto": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "cryptography",\n "version": "50.0.2",\n "supported": "cryptography>=41",\n "detail": "cryptography 50.0.2 satisfies cryptography>=41",\n "install_hint": "pip install \\"cryptography>=41\\"",\n "purpose": "the Fernet vault cipher (--cipher fernet); encryption itself needs no tier"\n }\n },\n "blind_spots": [\n {\n "category": "Names, organisations, places and other named entities",\n "reason": "named-entity detection is not running, and no regular expression can recognise a person\'s name",\n "remedy": "spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)",\n "examples": [\n "a person\'s full name",\n "a company or institution",\n "a city or country"\n ],\n "severity": "high"\n },\n {\n "category": "Capitalised multi-word names (partial cover)",\n "reason": "the TITLE_CASE pattern is available but not enabled; it finds runs of capitalised words without needing spaCy, at the cost of false positives",\n "remedy": "add TITLE_CASE to the detection kinds (--kinds ... TITLE_CASE), or use --suggest to review candidates and hide them explicitly",\n "examples": [\n "Mustafa Kemal",\n "Acme Corporation"\n ],\n "severity": "medium"\n },\n {\n "category": "Disabled structural patterns",\n "reason": "1 built-in pattern(s) are not enabled: TITLE_CASE",\n "remedy": "drop --kinds to enable every built-in pattern",\n "examples": [\n "TITLE_CASE"\n ],\n "severity": "medium"\n }\n ]\n },\n "actions": [\n "spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)",\n "Review 1 suggested term(s) and hide any that matter."\n ],\n "status": "ok"\n}\n', stderr='')
scan is the same detection with a script’s interface: status 3 when
it finds something, 0 when it does not. This is the CI gate.
run("scan", "--kinds", "EMAIL", "nothing sensitive here", limit=2, expect=0)
run("scan", "--kinds", "EMAIL", "mail ada@example.com", limit=2, expect=3)
$ cleanprompt scan --kinds EMAIL "nothing sensitive here"
out │ clean: yes
│ findings: 0
│ ... (32 more)
exit │ 0
$ cleanprompt scan --kinds EMAIL "mail ada@example.com"
out │ clean: no
│ findings: 1
│ ... (34 more)
exit │ 3
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'scan', '--kinds', 'EMAIL', 'mail ada@example.com'], returncode=3, stdout="clean: no\nfindings: 1\nmax_findings: 0\nby_kind:\n EMAIL: 1\nplaceholders:\n - [EMAIL-1]\nblind_spots:\n category: Names, organisations, places and other named entities\n reason: named-entity detection is not running, and no regular expression can recognise a person's name\n remedy: spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)\n examples:\n - a person's full name\n - a company or institution\n - a city or country\n severity: high\n\n category: Capitalised multi-word names (partial cover)\n reason: the TITLE_CASE pattern is available but not enabled; it finds runs of capitalised words without needing spaCy, at the cost of false positives\n remedy: add TITLE_CASE to the detection kinds (--kinds ... TITLE_CASE), or use --suggest to review candidates and hide them explicitly\n examples:\n - Mustafa Kemal\n - Acme Corporation\n severity: medium\n\n category: Disabled structural patterns\n reason: 12 built-in pattern(s) are not enabled: AWS_ACCESS_KEY, CREDIT_CARD, IBAN, IPV4, IPV6, JWT, MAC, PHONE, PRIVATE_KEY, SSN_US, TITLE_CASE, URL\n remedy: drop --kinds to enable every built-in pattern\n examples:\n - AWS_ACCESS_KEY\n - CREDIT_CARD\n - IBAN\n - IPV4\n severity: medium\n\nstatus: found\n", stderr='')
doctor reports what is active and what is blind. A redaction tool
that finds nothing looks exactly like a clean bill of health, so this command
exists to make the difference visible.
run("doctor", "--format", "json", limit=6)
$ cleanprompt doctor --format json
out │ {
│ "headline": "12 structural detectors are active. Names, organisations and places are NOT being detected.",
│ "healthy": false,
│ "tiers": {
│ "ner": {
│ "status": "AVAILABLE",
│ ... (241 more)
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'doctor', '--format', 'json'], returncode=0, stdout='{\n "headline": "12 structural detectors are active. Names, organisations and places are NOT being detected.",\n "healthy": false,\n "tiers": {\n "ner": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "spacy",\n "version": "3.8.16",\n "supported": "spacy>=3.4,<5",\n "detail": "spacy 3.8.16 satisfies spacy>=3.4,<5",\n "install_hint": "pip install \\"spacy>=3.4,<5\\"",\n "purpose": "named-entity detection"\n },\n "nltk": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "nltk",\n "version": "3.10.3",\n "supported": "nltk>=3.6,<4",\n "detail": "nltk 3.10.3 satisfies nltk>=3.6,<4",\n "install_hint": "pip install \\"nltk>=3.6,<4\\"",\n "purpose": "named-entity detection without spaCy (English only)"\n },\n "web": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "flask",\n "version": "3.1.3",\n "supported": "flask>=2.2,<4",\n "detail": "flask 3.1.3 satisfies flask>=2.2,<4",\n "install_hint": "pip install \\"flask>=2.2,<4\\"",\n "purpose": "the local web interface"\n },\n "crypto": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "cryptography",\n "version": "50.0.2",\n "supported": "cryptography>=41",\n "detail": "cryptography 50.0.2 satisfies cryptography>=41",\n "install_hint": "pip install \\"cryptography>=41\\"",\n "purpose": "the Fernet vault cipher (--cipher fernet); encryption itself needs no tier"\n }\n },\n "detection": {\n "active_kinds": [\n "AWS_ACCESS_KEY",\n "CREDIT_CARD",\n "EMAIL",\n "IBAN",\n "IPV4",\n "IPV6",\n "JWT",\n "MAC",\n "PHONE",\n "PRIVATE_KEY",\n "SSN_US",\n "URL"\n ],\n "inactive_kinds": [\n "TITLE_CASE"\n ],\n "ner_active": false,\n "ner_requested": false,\n "ner_ready": false,\n "ner_error": null,\n "ner_remedy": null,\n "total_patterns": 13\n },\n "blind_spots": [\n {\n "category": "Names, organisations, places and other named entities",\n "reason": "named-entity detection is not running, and no regular expression can recognise a person\'s name",\n "remedy": "spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)",\n "examples": [\n "a person\'s full name",\n "a company or institution",\n "a city or country"\n ],\n "severity": "high"\n },\n {\n "category": "Capitalised multi-word names (partial cover)",\n "reason": "the TITLE_CASE pattern is available but not enabled; it finds runs of capitalised words without needing spaCy, at the cost of false positives",\n "remedy": "add TITLE_CASE to the detection kinds (--kinds ... TITLE_CASE), or use --suggest to review candidates and hide them explicitly",\n "examples": [\n "Mustafa Kemal",\n "Acme Corporation"\n ],\n "severity": "medium"\n },\n {\n "category": "Disabled structural patterns",\n "reason": "1 built-in pattern(s) are not enabled: TITLE_CASE",\n "remedy": "drop --kinds to enable every built-in pattern",\n "examples": [\n "TITLE_CASE"\n ],\n "severity": "medium"\n }\n ],\n "entity_engines": {\n "mode": "auto",\n "language": "en",\n "selected": [\n "spacy"\n ],\n "ready": true,\n "engines": {\n "spacy": {\n "installed": true,\n "language_supported": true,\n "assets_checked": true,\n "assets_ready": true,\n "ready": true,\n "status": "AVAILABLE",\n "reason": "spaCy 3.8.16 with model \'en_core_web_sm\'",\n "remedy": "",\n "model": "en_core_web_sm",\n "missing": [],\n "version": "3.8.16",\n "usable": true,\n "languages": [\n "*"\n ],\n "needs_model": true,\n "summary": "statistical models, many languages, best quality",\n "install_hint": "pip install \\"spacy>=3.4,<5\\"",\n "detail": "spaCy 3.8.16 with model \'en_core_web_sm\'"\n },\n "nltk": {\n "installed": true,\n "language_supported": true,\n "assets_checked": true,\n "assets_ready": true,\n "ready": true,\n "status": "AVAILABLE",\n "reason": "NLTK 3.10.3 with its data packages",\n "remedy": "",\n "model": null,\n "missing": [],\n "version": "3.10.3",\n "usable": true,\n "languages": [\n "en"\n ],\n "needs_model": true,\n "summary": "classic chunker, English only, small download",\n "install_hint": "pip install \\"nltk>=3.6,<4\\"",\n "detail": "NLTK 3.10.3 with its data packages"\n }\n },\n "canonical_labels": [\n "PERSON",\n "ORG",\n "GPE",\n "LOC",\n "FAC",\n "NORP",\n "EVENT",\n "WORK_OF_ART",\n "PRODUCT",\n "LAW",\n "LANGUAGE",\n "MISC"\n ]\n },\n "nltk_corpora": {\n "available": true,\n "missing": [],\n "install_hint": "",\n "checked": true\n },\n "languages": {\n "requested": "en",\n "supported": true,\n "model": "en_core_web_sm",\n "model_installed": true,\n "model_version": "3.8.0",\n "install_hint": "python -m spacy download en_core_web_sm",\n "resolution": {\n "language": "en",\n "requested_size": "sm",\n "resolved_size": "sm",\n "fallback": false\n },\n "installed_models": {\n "en_core_web_sm": "3.8.0"\n },\n "supported_languages": {\n "ca": "Catalan",\n "da": "Danish",\n "de": "German",\n "el": "Greek",\n "en": "English",\n "es": "Spanish",\n "fi": "Finnish",\n "fr": "French",\n "hr": "Croatian",\n "it": "Italian",\n "ja": "Japanese",\n "ko": "Korean",\n "lt": "Lithuanian",\n "mk": "Macedonian",\n "nb": "Norwegian Bokmal",\n "nl": "Dutch",\n "pl": "Polish",\n "pt": "Portuguese",\n "ro": "Romanian",\n "ru": "Russian",\n "sl": "Slovenian",\n "sv": "Swedish",\n "uk": "Ukrainian",\n "zh": "Chinese"\n }\n },\n "configuration": {\n "profile": null,\n "config_file": null,\n "ner_engine": "auto",\n "language": "en",\n "model_size": "sm",\n "policy_fingerprint": "33ad5234bfede14e",\n "grammar_fingerprint": "0e7c5e996585e57b",\n "vault_path": "/tmp/scikitplot-cleanprompt-cli-7_bf2yvl/vault.json",\n "vault_exists": true,\n "case_insensitive": false,\n "overlap": "LONGEST_WINS",\n "allow": [],\n "limits": {\n "max_input_chars": 1000000,\n "max_spans": 100000,\n "max_entries": 50000,\n "max_literal_terms": 10000,\n "max_literal_length": 4096\n }\n },\n "environment": {\n "python": "3.12.15",\n "platform": "linux",\n "cli_frontend": "click",\n "vault_key_set": false,\n "web_secret_key_set": false\n },\n "status": "degraded"\n}\n', stderr='')
Part two — the edge cases#
4. The shell eats the text before the program sees it#
The most common first failure, and it is not this program’s error message:
$ cleanprompt encode Our customer (Acme Ltd) wrote from ada@example.com
bash: syntax error near unexpected token `('
bash: is the clue — the shell failed, so the program never ran. Three
fixes, in increasing order of robustness:
cleanprompt encode 'Our customer (Acme Ltd) wrote from ada@example.com'
cleanprompt encode --in draft.txt
cleanprompt encode <<'EOF'
Our customer (Acme Ltd) wrote from ada@example.com
EOF
Single quotes are enough for parentheses. For a paragraph with quotes of its own, use a file or a heredoc: there is no quoting to get wrong.
run(
"encode",
"--quiet",
"--vault-mode",
"overwrite",
"Our customer (Acme Ltd) wrote from ada@example.com",
)
$ cleanprompt encode --quiet --vault-mode overwrite "Our customer (Acme Ltd) wrote from ada@example.com"
out │ Our customer (Acme Ltd) wrote from [EMAIL-1]
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'encode', '--quiet', '--vault-mode', 'overwrite', 'Our customer (Acme Ltd) wrote from ada@example.com'], returncode=0, stdout='Our customer (Acme Ltd) wrote from [EMAIL-1]\n', stderr='')
5. Text that starts with a dash#
A value beginning with - looks like an option. -- ends the options
and everything after it is text — the POSIX convention, honoured identically
by both frontends.
run("inspect", "--secret is ada@example.com", limit=4, expect=2)
$ cleanprompt inspect "--secret is ada@example.com"
err │ Usage: python -m scikitplot.cleanprompt inspect [OPTIONS] [TEXT_ARGS]...
│ Try 'python -m scikitplot.cleanprompt inspect --help' for help.
│
│ Error: No such option '--secret is ada@example.com'.
exit │ 2
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'inspect', '--secret is ada@example.com'], returncode=2, stdout='', stderr="Usage: python -m scikitplot.cleanprompt inspect [OPTIONS] [TEXT_ARGS]...\nTry 'python -m scikitplot.cleanprompt inspect --help' for help.\n\nError: No such option '--secret is ada@example.com'.\n")
With the delimiter, the same text is read as text.
run("inspect", "--", "--secret is ada@example.com", limit=4, expect=0)
$ cleanprompt inspect -- "--secret is ada@example.com"
out │ outcome: warning
│ headline: redacted 1 value(s): EMAIL=1
│ detail: Placeholders are stable; restore the reply with the same vault. Note that names, organisations, places and other named entities are still not being detected.
│ would_redact:
│ ... (91 more)
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'inspect', '--', '--secret is ada@example.com'], returncode=0, stdout='outcome: warning\nheadline: redacted 1 value(s): EMAIL=1\ndetail: Placeholders are stable; restore the reply with the same vault. Note that names, organisations, places and other named entities are still not being detected.\nwould_redact:\n placeholder: [EMAIL-1]\n kind: EMAIL\n occurrences: 1\n detector: regex:EMAIL\n confidence: 1.0\n\nsuggestions: -\ndiagnosis:\n headline: 12 structural detectors are active. Names, organisations and places are NOT being detected.\n healthy: no\n ner_active: no\n active_kinds:\n - AWS_ACCESS_KEY\n - CREDIT_CARD\n - EMAIL\n - IBAN\n - IPV4\n - IPV6\n - JWT\n - MAC\n - PHONE\n - PRIVATE_KEY\n - SSN_US\n - URL\n inactive_kinds:\n - TITLE_CASE\n tiers:\n ner:\n status: AVAILABLE\n available: yes\n distribution: spacy\n version: 3.8.16\n supported: spacy>=3.4,<5\n detail: spacy 3.8.16 satisfies spacy>=3.4,<5\n install_hint: pip install "spacy>=3.4,<5"\n purpose: named-entity detection\n nltk:\n status: AVAILABLE\n available: yes\n distribution: nltk\n version: 3.10.3\n supported: nltk>=3.6,<4\n detail: nltk 3.10.3 satisfies nltk>=3.6,<4\n install_hint: pip install "nltk>=3.6,<4"\n purpose: named-entity detection without spaCy (English only)\n web:\n status: AVAILABLE\n available: yes\n distribution: flask\n version: 3.1.3\n supported: flask>=2.2,<4\n detail: flask 3.1.3 satisfies flask>=2.2,<4\n install_hint: pip install "flask>=2.2,<4"\n purpose: the local web interface\n crypto:\n status: AVAILABLE\n available: yes\n distribution: cryptography\n version: 50.0.2\n supported: cryptography>=41\n detail: cryptography 50.0.2 satisfies cryptography>=41\n install_hint: pip install "cryptography>=41"\n purpose: the Fernet vault cipher (--cipher fernet); encryption itself needs no tier\n blind_spots:\n category: Names, organisations, places and other named entities\n reason: named-entity detection is not running, and no regular expression can recognise a person\'s name\n remedy: spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)\n examples:\n - a person\'s full name\n - a company or institution\n - a city or country\n severity: high\n\n category: Capitalised multi-word names (partial cover)\n reason: the TITLE_CASE pattern is available but not enabled; it finds runs of capitalised words without needing spaCy, at the cost of false positives\n remedy: add TITLE_CASE to the detection kinds (--kinds ... TITLE_CASE), or use --suggest to review candidates and hide them explicitly\n examples:\n - Mustafa Kemal\n - Acme Corporation\n severity: medium\n\n category: Disabled structural patterns\n reason: 1 built-in pattern(s) are not enabled: TITLE_CASE\n remedy: drop --kinds to enable every built-in pattern\n examples:\n - TITLE_CASE\n severity: medium\n\nactions:\n - spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)\nstatus: ok\n', stderr='')
Only the first -- is consumed, so a literal -- inside the text
survives, and text that is exactly -- is passed through.
run("encode", "--quiet", "--vault-mode", "overwrite", "--", "use -- to end options")
run("encode", "--quiet", "--vault-mode", "overwrite", "--", "--")
$ cleanprompt encode --quiet --vault-mode overwrite -- "use -- to end options"
out │ use -- to end options
exit │ 0
$ cleanprompt encode --quiet --vault-mode overwrite -- --
out │ --
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'encode', '--quiet', '--vault-mode', 'overwrite', '--', '--'], returncode=0, stdout='--\n', stderr='')
The one place the two frontends still differ is a dash-leading option value given in separated form. Use the attached spelling, which both accept:
cleanprompt encode --hide=-secret "the -secret token" # both frontends
cleanprompt encode --hide -secret "the -secret token" # click only
Closing the gap would mean overriding a private argparse method that has moved between releases, so it is documented rather than papered over.
run("encode", "--quiet", "--vault-mode", "overwrite", "--hide=-secret", "the -secret token")
for frontend in ("click", "argparse"):
result = subprocess.run(
[*CLI, "--frontend", frontend, "encode", "-q", "--vault-mode", "overwrite",
"--hide", "-secret", "the -secret token"],
capture_output=True,
text=True,
input="",
)
print("{0:<9} separated --hide: exit {1}".format(frontend, result.returncode))
$ cleanprompt encode --quiet --vault-mode overwrite --hide=-secret "the -secret token"
out │ the [CUSTOM-1] token
exit │ 0
click separated --hide: exit 0
argparse separated --hide: exit 2
6. A mistyped option must not become your prompt#
This is the failure mode worth being strict about. If a mistyped flag were
accepted as text, the tool would quietly redact the flag and send the real
prompt — which is still sitting in the next argument — to the model. Both
frontends refuse with status 2.
run("inspect", "--nosuchoption", "ada@example.com", limit=4, expect=2)
$ cleanprompt inspect --nosuchoption ada@example.com
err │ Usage: python -m scikitplot.cleanprompt inspect [OPTIONS] [TEXT_ARGS]...
│ Try 'python -m scikitplot.cleanprompt inspect --help' for help.
│
│ Error: No such option '--nosuchoption'.
exit │ 2
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'inspect', '--nosuchoption', 'ada@example.com'], returncode=2, stdout='', stderr="Usage: python -m scikitplot.cleanprompt inspect [OPTIONS] [TEXT_ARGS]...\nTry 'python -m scikitplot.cleanprompt inspect --help' for help.\n\nError: No such option '--nosuchoption'.\n")
Abbreviations are refused for the same reason. --form is an unambiguous
prefix of --format today; it stops being one the day an option called
--formal is added, and a script that worked for a year then breaks on an
upgrade it never used.
run("inspect", "--form", "json", "ada@example.com", limit=4, expect=2)
run("inspect", "--format", "json", "ada@example.com", limit=2, expect=0)
$ cleanprompt inspect --form json ada@example.com
err │ Usage: python -m scikitplot.cleanprompt inspect [OPTIONS] [TEXT_ARGS]...
│ Try 'python -m scikitplot.cleanprompt inspect --help' for help.
│
│ Error: No such option '--form'. (Did you mean one of: '--format', '--log-format'?)
exit │ 2
$ cleanprompt inspect --format json ada@example.com
out │ {
│ "outcome": "warning",
│ ... (113 more)
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'inspect', '--format', 'json', 'ada@example.com'], returncode=0, stdout='{\n "outcome": "warning",\n "headline": "redacted 1 value(s): EMAIL=1",\n "detail": "Placeholders are stable; restore the reply with the same vault. Note that names, organisations, places and other named entities are still not being detected.",\n "would_redact": [\n {\n "placeholder": "[EMAIL-1]",\n "kind": "EMAIL",\n "occurrences": 1,\n "detector": "regex:EMAIL",\n "confidence": 1.0\n }\n ],\n "suggestions": [],\n "diagnosis": {\n "headline": "12 structural detectors are active. Names, organisations and places are NOT being detected.",\n "healthy": false,\n "ner_active": false,\n "active_kinds": [\n "AWS_ACCESS_KEY",\n "CREDIT_CARD",\n "EMAIL",\n "IBAN",\n "IPV4",\n "IPV6",\n "JWT",\n "MAC",\n "PHONE",\n "PRIVATE_KEY",\n "SSN_US",\n "URL"\n ],\n "inactive_kinds": [\n "TITLE_CASE"\n ],\n "tiers": {\n "ner": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "spacy",\n "version": "3.8.16",\n "supported": "spacy>=3.4,<5",\n "detail": "spacy 3.8.16 satisfies spacy>=3.4,<5",\n "install_hint": "pip install \\"spacy>=3.4,<5\\"",\n "purpose": "named-entity detection"\n },\n "nltk": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "nltk",\n "version": "3.10.3",\n "supported": "nltk>=3.6,<4",\n "detail": "nltk 3.10.3 satisfies nltk>=3.6,<4",\n "install_hint": "pip install \\"nltk>=3.6,<4\\"",\n "purpose": "named-entity detection without spaCy (English only)"\n },\n "web": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "flask",\n "version": "3.1.3",\n "supported": "flask>=2.2,<4",\n "detail": "flask 3.1.3 satisfies flask>=2.2,<4",\n "install_hint": "pip install \\"flask>=2.2,<4\\"",\n "purpose": "the local web interface"\n },\n "crypto": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "cryptography",\n "version": "50.0.2",\n "supported": "cryptography>=41",\n "detail": "cryptography 50.0.2 satisfies cryptography>=41",\n "install_hint": "pip install \\"cryptography>=41\\"",\n "purpose": "the Fernet vault cipher (--cipher fernet); encryption itself needs no tier"\n }\n },\n "blind_spots": [\n {\n "category": "Names, organisations, places and other named entities",\n "reason": "named-entity detection is not running, and no regular expression can recognise a person\'s name",\n "remedy": "spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)",\n "examples": [\n "a person\'s full name",\n "a company or institution",\n "a city or country"\n ],\n "severity": "high"\n },\n {\n "category": "Capitalised multi-word names (partial cover)",\n "reason": "the TITLE_CASE pattern is available but not enabled; it finds runs of capitalised words without needing spaCy, at the cost of false positives",\n "remedy": "add TITLE_CASE to the detection kinds (--kinds ... TITLE_CASE), or use --suggest to review candidates and hide them explicitly",\n "examples": [\n "Mustafa Kemal",\n "Acme Corporation"\n ],\n "severity": "medium"\n },\n {\n "category": "Disabled structural patterns",\n "reason": "1 built-in pattern(s) are not enabled: TITLE_CASE",\n "remedy": "drop --kinds to enable every built-in pattern",\n "examples": [\n "TITLE_CASE"\n ],\n "severity": "medium"\n }\n ]\n },\n "actions": [\n "spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)"\n ],\n "status": "ok"\n}\n', stderr='')
7. Both frontends read the same command line#
A divergence here is a bug, not a preference. The check is mechanical.
CASES = [
["inspect", "--format", "json", "ada@example.com"],
["inspect", "--form", "json", "ada@example.com"],
["inspect", "--", "--secret is ada@example.com"],
["inspect", "--nosuchoption", "x"],
["kinds"],
["-V"],
]
divergences = []
for case in CASES:
statuses = {}
for frontend in ("argparse", "click"):
completed = subprocess.run(
[*CLI, "--frontend", frontend, *case],
capture_output=True,
text=True,
input="",
)
statuses[frontend] = completed.returncode
agree = statuses["argparse"] == statuses["click"]
if not agree:
divergences.append((case, statuses))
print(
"{0:<6} argparse={1} click={2} {3}".format(
"agree" if agree else "DIFFER",
statuses["argparse"],
statuses["click"],
" ".join(case),
)
)
assert divergences == [], divergences
agree argparse=0 click=0 inspect --format json ada@example.com
agree argparse=2 click=2 inspect --form json ada@example.com
agree argparse=0 click=0 inspect -- --secret is ada@example.com
agree argparse=2 click=2 inspect --nosuchoption x
agree argparse=0 click=0 kinds
agree argparse=0 click=0 -V
8. Pipes, and a reader that leaves early#
| head closes the pipe as soon as it has what it wants. That is not an
error this program can act on, so it exits quietly with the conventional
141 rather than printing error: [Errno 32] Broken pipe over a
perfectly correct command line.
writer = subprocess.Popen(
[*CLI, "kinds", "--format", "json"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
first_line = writer.stdout.readline() if writer.stdout else ""
if writer.stdout:
writer.stdout.close()
pipe_errors = writer.stderr.read() if writer.stderr else ""
writer.wait(timeout=60)
print("first line read:", first_line.strip())
print("stderr from the writer:", repr(pipe_errors))
print("writer status:", writer.returncode)
assert "Broken pipe" not in pipe_errors
assert "Exception ignored" not in pipe_errors
first line read: {
stderr from the writer: ''
writer status: 0
9. Degenerate input#
Empty text, whitespace, and a text that is already redacted. None of these is an error; all of them have to behave predictably.
The third is invariant I6, idempotence: running encode on its own
output must not re-redact the placeholders into [CUSTOM-1] nonsense.
run("encode", "--quiet", "--vault-mode", "overwrite", "", expect=0)
run("encode", "--quiet", "--vault-mode", "overwrite", " ", expect=0)
once = run("encode", "--quiet", "--vault-mode", "overwrite", PROMPT).stdout
twice = run("encode", "--quiet", "--vault-mode", "append", "--in", "-", stdin_text=once)
print()
print("idempotent:", once.strip() == twice.stdout.strip())
assert once.strip() == twice.stdout.strip()
$ cleanprompt encode --quiet --vault-mode overwrite ""
exit │ 0
$ cleanprompt encode --quiet --vault-mode overwrite " "
out │
exit │ 0
$ cleanprompt encode --quiet --vault-mode overwrite "Mail ada@example.com about the outage on 192.0.2.10."
out │ Mail [EMAIL-1] about the outage on [IPV4-1].
exit │ 0
$ cleanprompt encode --quiet --vault-mode append --in -
out │ Mail [EMAIL-1] about the outage on [IPV4-1].
exit │ 0
idempotent: True
10. A value at the end of a sentence#
Worth its own case because it was a real defect twice, in opposite directions: a sentence-final IP address was not detected at all, and a URL swallowed the sentence’s full stop into the placeholder. Both are checked by a class-level test now, so a pattern added next year is checked the same way.
run(
"inspect",
"--format",
"json",
"The host is 192.0.2.10. See https://example.com/docs.",
limit=14,
)
$ cleanprompt inspect --format json "The host is 192.0.2.10. See https://example.com/d..."
out │ {
│ "outcome": "warning",
│ "headline": "redacted 2 value(s): IPV4=1, URL=1",
│ "detail": "Placeholders are stable; restore the reply with the same vault. Note that names, organisations, places and other named entities are still not being detected.",
│ "would_redact": [
│ {
│ "placeholder": "[IPV4-1]",
│ "kind": "IPV4",
│ "occurrences": 1,
│ "detector": "regex:IPV4",
│ "confidence": 1.0
│ },
│ {
│ "placeholder": "[URL-1]",
│ ... (116 more)
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'inspect', '--format', 'json', 'The host is 192.0.2.10. See https://example.com/docs.'], returncode=0, stdout='{\n "outcome": "warning",\n "headline": "redacted 2 value(s): IPV4=1, URL=1",\n "detail": "Placeholders are stable; restore the reply with the same vault. Note that names, organisations, places and other named entities are still not being detected.",\n "would_redact": [\n {\n "placeholder": "[IPV4-1]",\n "kind": "IPV4",\n "occurrences": 1,\n "detector": "regex:IPV4",\n "confidence": 1.0\n },\n {\n "placeholder": "[URL-1]",\n "kind": "URL",\n "occurrences": 1,\n "detector": "regex:URL",\n "confidence": 1.0\n }\n ],\n "suggestions": [\n {\n "text": "See",\n "count": 1,\n "reason": "capitalised word run, not matched by any active detector",\n "first_offset": 24\n }\n ],\n "diagnosis": {\n "headline": "12 structural detectors are active. Names, organisations and places are NOT being detected.",\n "healthy": false,\n "ner_active": false,\n "active_kinds": [\n "AWS_ACCESS_KEY",\n "CREDIT_CARD",\n "EMAIL",\n "IBAN",\n "IPV4",\n "IPV6",\n "JWT",\n "MAC",\n "PHONE",\n "PRIVATE_KEY",\n "SSN_US",\n "URL"\n ],\n "inactive_kinds": [\n "TITLE_CASE"\n ],\n "tiers": {\n "ner": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "spacy",\n "version": "3.8.16",\n "supported": "spacy>=3.4,<5",\n "detail": "spacy 3.8.16 satisfies spacy>=3.4,<5",\n "install_hint": "pip install \\"spacy>=3.4,<5\\"",\n "purpose": "named-entity detection"\n },\n "nltk": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "nltk",\n "version": "3.10.3",\n "supported": "nltk>=3.6,<4",\n "detail": "nltk 3.10.3 satisfies nltk>=3.6,<4",\n "install_hint": "pip install \\"nltk>=3.6,<4\\"",\n "purpose": "named-entity detection without spaCy (English only)"\n },\n "web": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "flask",\n "version": "3.1.3",\n "supported": "flask>=2.2,<4",\n "detail": "flask 3.1.3 satisfies flask>=2.2,<4",\n "install_hint": "pip install \\"flask>=2.2,<4\\"",\n "purpose": "the local web interface"\n },\n "crypto": {\n "status": "AVAILABLE",\n "available": true,\n "distribution": "cryptography",\n "version": "50.0.2",\n "supported": "cryptography>=41",\n "detail": "cryptography 50.0.2 satisfies cryptography>=41",\n "install_hint": "pip install \\"cryptography>=41\\"",\n "purpose": "the Fernet vault cipher (--cipher fernet); encryption itself needs no tier"\n }\n },\n "blind_spots": [\n {\n "category": "Names, organisations, places and other named entities",\n "reason": "named-entity detection is not running, and no regular expression can recognise a person\'s name",\n "remedy": "spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)",\n "examples": [\n "a person\'s full name",\n "a company or institution",\n "a city or country"\n ],\n "severity": "high"\n },\n {\n "category": "Capitalised multi-word names (partial cover)",\n "reason": "the TITLE_CASE pattern is available but not enabled; it finds runs of capitalised words without needing spaCy, at the cost of false positives",\n "remedy": "add TITLE_CASE to the detection kinds (--kinds ... TITLE_CASE), or use --suggest to review candidates and hide them explicitly",\n "examples": [\n "Mustafa Kemal",\n "Acme Corporation"\n ],\n "severity": "medium"\n },\n {\n "category": "Disabled structural patterns",\n "reason": "1 built-in pattern(s) are not enabled: TITLE_CASE",\n "remedy": "drop --kinds to enable every built-in pattern",\n "examples": [\n "TITLE_CASE"\n ],\n "severity": "medium"\n }\n ]\n },\n "actions": [\n "spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)",\n "Review 1 suggested term(s) and hide any that matter."\n ],\n "status": "ok"\n}\n', stderr='')
11. decode when the model did not return the label you issued#
Between encode and decode the text passes through a language model,
and a model rewrites tokens: it lower-cases the label, swaps the hyphen for
an underscore or a space, escapes the brackets for Markdown, or wraps it
across a line.
decode accepts that bounded set and reports every repair. It does
not accept an invented label, and ordinary prose like [note 2] is
returned untouched.
run("encode", "--quiet", "--vault-mode", "overwrite", "Mail ada@example.com or bob@example.com")
run("decode", "I wrote to [email_1] and [EMAIL 2]; see [note 2] for the rest.")
$ cleanprompt encode --quiet --vault-mode overwrite "Mail ada@example.com or bob@example.com"
out │ Mail [EMAIL-1] or [EMAIL-2]
exit │ 0
$ cleanprompt decode "I wrote to [email_1] and [EMAIL 2]; see [note 2] ..."
out │ I wrote to ada@example.com and bob@example.com; see [note 2] for the rest.
err │ restored 2 placeholder(s); 2 repaired: [email_1] -> [EMAIL-1], [EMAIL 2] -> [EMAIL-2]
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'decode', 'I wrote to [email_1] and [EMAIL 2]; see [note 2] for the rest.'], returncode=0, stdout='I wrote to ada@example.com and bob@example.com; see [note 2] for the rest.\n', stderr='restored 2 placeholder(s); 2 repaired: [email_1] -> [EMAIL-1], [EMAIL 2] -> [EMAIL-2]\n')
--exact turns the leniency off for a caller who depends on the strict
reading, and --strict turns an unknown placeholder into an error instead
of leaving it in place.
run("decode", "--exact", "I wrote to [email_1].")
run("decode", "--strict", "what about [EMAIL-9]?", expect=1)
$ cleanprompt decode --exact "I wrote to [email_1]."
out │ I wrote to [email_1].
err │ restored 0 placeholder(s); 2 vault entr(y/ies) unused
│ nothing was restored: check the reply still contains the placeholders as they were issued
exit │ 0
$ cleanprompt decode --strict "what about [EMAIL-9]?"
err │ error: placeholder '[EMAIL-9]' is not present in the vault
exit │ 1
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'decode', '--strict', 'what about [EMAIL-9]?'], returncode=1, stdout='', stderr="error: placeholder '[EMAIL-9]' is not present in the vault\n")
12. Cleanup#
run("forget", "--force")
os.environ.pop("CLEANPROMPT_VAULT", None)
_WORKSPACE.cleanup()
print("Temporary workspace cleaned:", not Path(_WORKSPACE.name).exists())
$ cleanprompt forget --force
err │ deleted <workspace>/vault.json
│ it held 2 value(s): EMAIL=2
│ note: this unlinks the file. On a journalling filesystem or an SSD the bytes may survive; use --encrypt with a key you hold for values that must not be recoverable.
exit │ 0
Temporary workspace cleaned: True
Total running time of the script: (0 minutes 33.832 seconds)
Related examples
Cleanprompt Basics: Hide a Value, Then Get It Back
Cleanprompt Recipes: CI Gates, Notebooks, Pipelines and Agents
Cleanprompt Moderate: Policies, Entities, and the Vault’s Lifecycle
Cleanprompt from Python: Single Calls and Edge Cases