Cleanprompt Packs and Formats: Records, Configs and Whole Folders#
Patterns find values by their shape: an address looks like an address. A record says what its values are in its keys, and a pattern cannot read a key:
{"mrn": "00412345"} an eight-digit number, to a pattern
DB_PASSWORD=pw-1 four characters, to a pattern
member_id,M-00412 nothing at all, to a pattern
Packs close that gap. A pack is a validated YAML definition of what to hide
in one domain — personal, addressbook, patient, finance,
secrets, records, email, cloud, and pandas/numpy/
sklearn for code. A format says how a kind of file is read — CSV by its
header, JSON by its keys, .env by KEY=value, a Word file by extracting
its text — and which packs suit it. FluentCleanPrompt combines them
into one immutable plan, and Cleaner applies the plan to text, files,
folders and zip archives under one shared vault.
Every value in this example is reserved and cannot reach anybody.
# Authors: The scikit-plots developers
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import warnings
import zipfile
from pathlib import Path
from scikitplot.cleanprompt import (
CleanPromptError,
FluentCleanPrompt,
PackError,
PatternRiskWarning,
analyse_pattern,
builtin_catalog,
encode,
load_custom,
)
_WORKSPACE = tempfile.TemporaryDirectory(prefix="scikitplot-cleanprompt-packs-")
_HOME = Path(_WORKSPACE.name)
os.environ["CLEANPROMPT_VAULT"] = str(_HOME / "vault.json")
def show(label: str, text: str) -> None:
"""Print a labelled block with the workspace path masked."""
print("── {0} ──".format(label))
print(text.replace(_WORKSPACE.name, "<workspace>").rstrip("\n"))
print()
1. The leak packs exist to close#
A patient record through the pattern engine alone. Nothing in it has the shape of an address, a card or a telephone number, so nothing is hidden.
RECORD = (
'{"patient": {"mrn": "00412345", "name": "Marion Holt", '
'"diagnosis": "E11.9", "insurer_id": "ZX-4471-09", "age": 61}}'
)
patterns_only, _ = encode(RECORD)
show("patterns only", patterns_only)
assert "00412345" in patterns_only # the measured defect, CP-048
cleaner = FluentCleanPrompt().materialize() # packs('auto'): the format chooses
safe = cleaner.encode_text(RECORD, "json")
show("with packs", safe.text)
for secret in ("00412345", "Marion Holt", "E11.9", "ZX-4471-09"):
assert secret not in safe.text
assert cleaner.decode(safe.text) == RECORD
print("report:", json.dumps(safe.report["kinds"]))
── patterns only ──
{"patient": {"mrn": "00412345", "name": "Marion Holt", "diagnosis": "E11.9", "insurer_id": "ZX-4471-09", "age": 61}}
── with packs ──
{"patient": {"mrn": "[MRN-1]", "name": "[PERSON-1]", "diagnosis": "[DIAGNOSIS-1]", "insurer_id": "[INSURANCE_ID-1]", "age": 61}}
report: {"DIAGNOSIS": 1, "INSURANCE_ID": 1, "MRN": 1, "PERSON": 1}
The output is still JSON, and it stays JSON even for numbers: a hidden JSON
number becomes a reserved negative number rather than [MRN-1], which no
parser would open and which a model would “repair” into a string.
numbers = FluentCleanPrompt().packs("patient").materialize()
numeric = numbers.encode_text('{"mrn": 20417, "visits": 3}', "json")
show("a hidden number", numeric.text)
json.loads(numeric.text)
assert numbers.decode(numeric.text) == '{"mrn": 20417, "visits": 3}'
── a hidden number ──
{"mrn": -9900000001, "visits": 3}
2. What is available#
Everything is data, compiled into a JSON file the standard library reads, so listing it needs nothing installed.
catalog = builtin_catalog()
for spec in catalog.resolve_packs("all"):
print(
"pack {0:<12} fields={1:<2} patterns={2} {3}".format(
spec.name, len(spec.fields), len(spec.patterns), spec.summary[:60]
)
)
print()
for name in ("csv", "json", "env", "notebook", "docx", "archive"):
spec = catalog.formats[name]
print(
"format {0:<9} {1:<16} round trip={2!s:<5} auto packs={3}".format(
name, ",".join(spec.extensions), spec.round_trip, ",".join(spec.packs)
)
)
pack personal fields=9 patterns=3 Names, contact details, birth dates and national identifiers
pack addressbook fields=7 patterns=1 Contact entries - organisation, role, postal and web details
pack cloud fields=2 patterns=2 Drive, bucket, project, tenant and account identifiers, and
pack email fields=3 patterns=1 Message headers - senders, recipients, message ids and routi
pack finance fields=4 patterns=2 Account and routing numbers, salaries, tax identifiers and c
pack numpy fields=0 patterns=0 NumPy field-naming arguments and dtype codes, for notebooks
pack pandas fields=0 patterns=0 pandas column-naming arguments and dtype spellings, for note
pack patient fields=9 patterns=4 Clinical identifiers, dates, diagnoses and medications in pa
pack records fields=3 patterns=2 Customer, account, employee, student and device identifiers
pack secrets fields=1 patterns=7 Passwords, tokens, API keys and connection strings in code a
pack sklearn fields=0 patterns=0 scikit-learn column lists in ColumnTransformer and friends,
format csv .csv round trip=True auto packs=finance,patient,personal,records,secrets
format json .geojson,.har,.json round trip=True auto packs=finance,patient,personal,records,secrets
format env .env,.envrc,.flaskenv round trip=True auto packs=cloud,personal,secrets
format notebook .ipynb round trip=True auto packs=numpy,pandas,secrets,sklearn
format docx .docm,.docx round trip=False auto packs=finance,patient,personal,secrets
format archive .zip round trip=True auto packs=finance,patient,personal,secrets
3. Three ways to choose#
all for everything, one pack for one domain, or any combination. A pack’s
requirements come with it: addressbook needs personal.
CONTACTS = "name,email,title,org\nMarion Holt,marion@example.com,Analyst,Acme Ltd\n"
for selection in (("all",), ("addressbook",), ("personal", "finance")):
one = FluentCleanPrompt().packs(*selection).materialize()
encoded = one.encode_text(CONTACTS, "csv")
print("{0:<22} packs={1}".format(" + ".join(selection), encoded.report["packs"]))
print(" ", encoded.text.splitlines()[1])
assert one.decode(encoded.text) == CONTACTS
all packs=['personal', 'addressbook', 'cloud', 'email', 'finance', 'numpy', 'pandas', 'patient', 'records', 'secrets', 'sklearn']
[PERSON-1],[EMAIL-1],[JOB-1],[ORG-1]
addressbook packs=['personal', 'addressbook']
[PERSON-1],[EMAIL-1],[JOB-1],[ORG-1]
personal + finance packs=['personal', 'finance']
[PERSON-1],[EMAIL-1],Analyst,Acme Ltd
4. A plan is immutable, validated and fingerprinted#
Each setter returns a new builder. Setting one domain twice is refused unless you say what you meant, and a broken plan reports every problem at once.
base = FluentCleanPrompt().packs("patient")
print(base.packs("finance", conflict="extend").plan().packs)
try:
base.packs("finance")
except CleanPromptError as error:
print("refused:", error)
problems = FluentCleanPrompt().packs("patinet").formats("docz").style("fancy").validate()
for problem in problems:
print("problem:", problem.splitlines()[0])
assert len(problems) == 3
first = FluentCleanPrompt().packs("patient", "finance").formats("csv", ".json").plan()
second = FluentCleanPrompt().formats(".json", "csv").packs("finance", "patient").plan()
assert first.fingerprint() == second.fingerprint()
print("same plan, any order:", first.fingerprint()[:16])
('finance', 'patient')
refused: packs is already set to ('patient',); pass conflict='replace' or conflict='extend'
problem: style: 'fancy' is not one of placeholder, surrogate
problem: formats: unknown format 'docz'; did you mean docx, .docx, .docm? Known formats: archive, csv, docx, email, env, html, ini, javascript, json, jsonl, markdown, notebook, pdf, pptx, python, rlang, rst, shell, sql, text, tsv, vcard, xlsx, yaml
problem: packs: unknown pack 'patinet'; did you mean patient? Known packs: addressbook, cloud, email, finance, numpy, pandas, patient, personal, records, secrets, sklearn
same plan, any order: c63c7e3e49f2e48f
5. Your own pack, from a JSON file#
A custom definition is validated exactly like a built-in: unknown keys are errors, and every pattern’s examples are executed before it is used. JSON needs nothing installed; YAML needs PyYAML and says so when it is missing.
A pack file is trusted configuration, like code. Its regular expressions
run on every document you encode with it. Validation checks that they mean
what their examples say; it cannot prove that one finishes quickly on every
input. Python’s re backtracks, so a pattern such as ^(a+)+$ takes
time that doubles with each extra character of a near-match — a document
could stall the run. Such shapes are reported when the pack loads (shown
right after this pack). Load pack files only from people you would accept
code from, and prefer a bounded form (\d{6} rather than \d+)
wherever the identifier has a fixed shape — as the one below does.
hr_pack = {
"name": "hr",
"version": 1,
"summary": "Our HR identifiers.",
"requires": ["personal"],
"fields": [{"names": ["badge_id", "employee_number"], "kind": "EMPLOYEE", "role": "id"}],
"patterns": [
{
"kind": "EMPLOYEE",
"pattern": r"(?i)\bbadge\s*:?\s*(?P<value>EMP-\d{6})\b",
"intent": "A badge number written with its label.",
"examples_yes": ["badge: EMP-004121"],
"examples_no": ["EMP-12"],
}
],
}
hr_path = _HOME / "hr.json"
hr_path.write_text(json.dumps(hr_pack), encoding="utf-8")
print("loaded:", sorted(load_custom(hr_path).packs))
hr = FluentCleanPrompt().custom(str(hr_path)).packs("hr").materialize()
roster = hr.encode_text("badge_id,full_name\nEMP-004121,Marion Holt\n", "csv")
note = hr.encode_text("Visitor badge: EMP-004121 signed in.\n", "text")
show("roster", roster.text)
show("note", note.text)
assert "[EMPLOYEE-1]" in roster.text and "[EMPLOYEE-1]" in note.text # one value, one label
try:
import yaml # noqa: F401
except ImportError:
print("SKIP: PyYAML is not installed, so the YAML form is not shown")
else:
yaml_path = _HOME / "hr.yaml"
yaml_path.write_text(yaml.safe_dump(hr_pack), encoding="utf-8")
print("YAML loads the same:", sorted(load_custom(yaml_path).packs) == ["hr"])
loaded: ['hr']
── roster ──
badge_id,full_name
[EMPLOYEE-1],[PERSON-1]
── note ──
Visitor badge: [EMPLOYEE-1] signed in.
YAML loads the same: True
A pattern that could stall a run is reported when it loads. This ticket
pattern repeats [A-Z]+ inside a repeated group with nothing required
between repetitions, so a near-miss such as AAAA...A! can be split in
exponentially many ways. The default is to warn: the pack loads, and the
warning names the pack and pattern, suggests rewrites, and lists the ways to
proceed. pattern_risk="refuse" (--pattern-risk refuse) stops instead,
and risk: accepted with a risk_reason in the pack accepts one
pattern deliberately.
risky = {
"name": "tickets",
"version": 1,
"summary": "Ticket ids.",
"patterns": [
{
"kind": "TICKET",
"pattern": r"\b(?:[A-Z]+\d*)+-\d+\b",
"intent": "A ticket id.",
"examples_yes": ["ABC-12"],
}
],
}
risky_path = _HOME / "tickets.json"
risky_path.write_text(json.dumps(risky), encoding="utf-8")
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", PatternRiskWarning)
load_custom(risky_path)
(finding,) = [w for w in caught if w.category is PatternRiskWarning]
print(str(finding.message).splitlines()[0][:110])
try:
load_custom(risky_path, pattern_risk="refuse")
except PackError as exc:
print("refused:", exc.problems[0][:70], "...")
risky["patterns"][0].update(risk="accepted", risk_reason="inputs are single ticket ids")
risky_path.write_text(json.dumps(risky), encoding="utf-8")
with warnings.catch_warnings():
warnings.simplefilter("error", PatternRiskWarning)
load_custom(risky_path, pattern_risk="refuse") # accepted: silent, even under refuse
print("accepted in the pack: loads silently")
print("a rewrite for plain ids such as ABC-12 is clean:", analyse_pattern(r"\b[A-Z]+\d*-\d+\b") == ())
tickets.json: pack tickets, pattern TICKET: nested-quantifier (high) in `(?:[A-Z]+\d*)+`: a repetition of `[A-
refused: tickets.json: pack tickets, pattern TICKET: nested-quantifier (high) i ...
accepted in the pack: loads silently
a rewrite for plain ids such as ABC-12 is clean: True
6. A whole folder#
Every file a selected format reads is encoded into a mirror folder under one vault. A file no format reads is skipped, one that cannot be read safely is refused; neither is written, so nothing unexamined leaves.
def _docx(paragraphs):
namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
body = "".join("<w:p><w:r><w:t>{0}</w:t></w:r></w:p>".format(p) for p in paragraphs)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr(
"word/document.xml",
'<w:document xmlns:w="{0}"><w:body>{1}</w:body></w:document>'.format(namespace, body),
)
return buffer.getvalue()
project = _HOME / "project"
(project / "config").mkdir(parents=True)
(project / ".git").mkdir()
(project / "contacts.csv").write_text(
"name,phone,member_id\nMarion Holt,+1 555 010 4477,M-00412\n", encoding="utf-8"
)
(project / "patient.json").write_text(RECORD, encoding="utf-8")
(project / "config" / ".env").write_text(
"DB_HOST=db.internal.example\nDB_PASSWORD=pw-correct-horse\n", encoding="utf-8"
)
(project / "visit.docx").write_bytes(_docx(["Seen today. MRN: 00412345.", "Follow up in two weeks."]))
(project / "broken.json").write_text("{", encoding="utf-8")
(project / "logo.png").write_bytes(b"\x89PNG")
(project / ".git" / "config").write_text("[remote]\n", encoding="utf-8")
team = FluentCleanPrompt().materialize()
for item in team.encode_tree(project, _HOME / "safe"):
print("{0:<8} {1:<18} {2}".format(item.status, item.relative, item.reason or item.output))
show("safe/visit.docx.txt", (_HOME / "safe" / "visit.docx.txt").read_text(encoding="utf-8"))
show("safe/config/.env", (_HOME / "safe" / "config" / ".env").read_text(encoding="utf-8"))
skipped .git/ folder is never read
refused broken.json not valid JSON, so no field could be located: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
encoded contacts.csv contacts.csv
skipped logo.png no selected format reads this file
encoded patient.json patient.json
encoded visit.docx visit.docx.txt
encoded config/.env config/.env
── safe/visit.docx.txt ──
Seen today. MRN: [MRN-1].
Follow up in two weeks.
── safe/config/.env ──
DB_HOST=[HOST-1]
DB_PASSWORD=[SECRET-1]
The Word note and the JSON record name the same patient number, and both say
[MRN-1]: the vault is shared, and a labelled pattern hides the value, not
its label. Decoding the folder restores every round-trip file byte for byte.
list(team.decode_tree(_HOME / "safe", _HOME / "back"))
for name in ("contacts.csv", "patient.json", "config/.env"):
assert (_HOME / "back" / name).read_bytes() == (project / name).read_bytes()
print("restored byte for byte: contacts.csv, patient.json, config/.env")
restored byte for byte: contacts.csv, patient.json, config/.env
7. A zip archive#
Members are handled like files. A member whose name climbs out of the archive is refused, a nested archive is not opened, and the output is deterministic.
bundle = _HOME / "bundle.zip"
with zipfile.ZipFile(bundle, "w") as archive:
archive.write(project / "contacts.csv", "contacts.csv")
archive.write(project / "patient.json", "records/patient.json")
archive.writestr("../escape.txt", "x")
for item in team.encode_archive(bundle, _HOME / "bundle.safe.zip"):
print("{0:<8} {1:<22} {2}".format(item.status, item.relative, item.reason))
with zipfile.ZipFile(_HOME / "bundle.safe.zip") as archive:
print("written:", archive.namelist())
refused ../escape.txt its name climbs out of the archive with '..'
encoded contacts.csv
encoded records/patient.json
written: ['contacts.csv', 'records/patient.json']
8. With the corpus submodule#
scikitplot.corpus reads documents; cleanprompt makes them safe before
they are embedded, indexed or prompted. Either works without the other.
try:
from scikitplot.corpus import CorpusDocument
except ImportError as error: # the bridge is optional in both directions
print("SKIP: scikitplot.corpus is not importable here:", error)
else:
from scikitplot.cleanprompt import redact_documents
documents = [
CorpusDocument.create("visit.txt", 0, "Seen today. MRN: 00412345.", metadata={"patient_name": "Marion Holt"})
]
(safe_doc,) = redact_documents(documents, team)
print(safe_doc.text, safe_doc.metadata)
assert "00412345" not in safe_doc.text
team.clear()
Seen today. MRN: [MRN-1]. {'patient_name': '[PERSON-1]'}
9. The same from the command line#
packs lists and checks definitions; batch encodes a folder or a zip
and writes the vault that batch --decode and decode read.
def _cli() -> list[str]:
executable = shutil.which("scikitplot")
if executable:
return [executable, "cleanprompt"]
return [sys.executable, "-m", "scikitplot.cleanprompt"]
def run(*arguments: str, limit: int = 12):
completed = subprocess.run([*_cli(), *arguments], input="", capture_output=True, text=True)
shown = " ".join(one.replace(_WORKSPACE.name, "<workspace>") for one in arguments)
print("$ cleanprompt", shown)
lines = completed.stdout.replace(_WORKSPACE.name, "<workspace>").rstrip("\n").split("\n")
if len(lines) > limit:
lines = lines[:limit] + ["... ({0} more)".format(len(lines) - limit)]
print(" " + "\n ".join(lines))
print(" exit", completed.returncode)
return completed
run("packs", "--check")
done = run("batch", str(project), "--out", str(_HOME / "cli-safe"), "--pack", "all", "-f", "json", limit=6)
assert done.returncode == 1 # broken.json was refused, and a pipeline must see that
run("batch", str(_HOME / "cli-safe"), "--decode", "--out", str(_HOME / "cli-back"), limit=4)
assert (_HOME / "cli-back" / "patient.json").read_text(encoding="utf-8") == RECORD
$ cleanprompt packs --check
problems: -
compiled: matches the YAML
pattern_risk: -
status: ok
exit 0
$ cleanprompt batch <workspace>/project --out <workspace>/cli-safe --pack all -f json
{
"items": [
{
"status": "skipped",
"relative": ".git/",
"format": null,
... (80 more)
exit 1
$ cleanprompt batch <workspace>/cli-safe --decode --out <workspace>/cli-back
items:
status: decoded
relative: contacts.csv
format: -
... (32 more)
exit 0
10. Cleanup#
os.environ.pop("CLEANPROMPT_VAULT", None)
_WORKSPACE.cleanup()
print("Temporary workspace cleaned:", not Path(_WORKSPACE.name).exists())
Temporary workspace cleaned: True
Total running time of the script: (0 minutes 2.583 seconds)
Related examples
Cleanprompt as a Gate: Any Model, Any Agent, Nothing Private Leaves
Cleanprompt for Notebooks: Sending an Analysis Without Sending the Data
Cleanprompt Recipes: CI Gates, Notebooks, Pipelines and Agents
Cleanprompt Moderate: Policies, Entities, and the Vault’s Lifecycle