Capture real Python objects, scrub sensitive fields, emit pytest fixtures.
Project description
snapfix
Capture real Python objects from staging, scrub sensitive fields, emit @pytest.fixture files automatically.
The problem
You have a bug that only reproduces with real data. You need a test. You don't want to hand-build a factory that misses the edge case, and you don't want to copy-paste a production payload and accidentally commit a customer's email address.
snapfix solves this with one decorator.
Install
pip install snapfix
Python 3.10+ required. No other required dependencies.
Quickstart
# In your service code (staging or development only)
from snapfix import capture
@capture("invoice_response", scrub=["billing_name"])
def fetch_invoice(invoice_id: str) -> dict:
return external_api.get(f"/invoices/{invoice_id}")
Call the function once against staging. snapfix writes this to tests/fixtures/snapfix_invoice_response.py:
# Generated by snapfix -- do not edit manually
# Captured : 2026-03-23T14:22:01
# Scrubbed : customer.email, meta.token, billing_name
import pytest
from snapfix import reconstruct
@pytest.fixture
def invoice_response():
return reconstruct({
"id": "INV-8821",
"customer": {
"email": "***SCRUBBED***",
"billing_name": "***SCRUBBED***",
"plan": "pro",
},
"amount": {"__snapfix_type__": "decimal", "value": "149.99"},
"issued_at": {"__snapfix_type__": "datetime", "value": "2026-03-01T09:00:00"},
"meta": {
"token": "***SCRUBBED***",
"retry": 0,
},
})
Use it in any test:
def test_invoice_total(invoice_response):
assert invoice_response["amount"].quantize(Decimal("0.01")) == Decimal("149.99")
That's it. No factory definition. No manual scrubbing. No pasted JSON.
PII scrubbing
⚠ Important limitation: snapfix scrubs fields by key name only. It does NOT detect PII in field values. An email address stored as
response["tags"][0]or inside a list will not be scrubbed. Always review generated fixtures before committing them.
Default scrubbed field names
Any key whose name contains one of these strings (case-insensitive, substring match) is scrubbed:
email · password · passwd · token · secret · api_key · apikey ·
access_token · refresh_token · ssn · credit_card · card_number ·
cvv · phone · mobile · dob · date_of_birth · address ·
ip_address · authorization · auth · bearer
customer_email → scrubbed (substring match on email)
billing_phone_number → scrubbed (substring match on phone)
retry_count → not scrubbed
Adding custom fields
@capture("order", scrub=["customer_id", "tax_number"])
def fetch_order(order_id: str) -> dict: ...
Replacement values
| Field value type | Replacement |
|---|---|
str |
"***SCRUBBED***" |
int / float |
-1 |
None |
"***SCRUBBED***" |
Supported types
snapfix serializes the following Python types and restores them correctly via reconstruct():
| Type | Serialized as | Restored on reconstruct() |
|---|---|---|
dict, list, str, int, float, bool, None |
JSON native | Same |
datetime.datetime |
ISO 8601 string + marker | datetime |
datetime.date |
ISO 8601 string + marker | date |
datetime.time |
ISO 8601 string + marker | time |
datetime.timedelta |
total seconds + marker | timedelta |
uuid.UUID |
string + marker | UUID |
decimal.Decimal |
string + marker | Decimal |
bytes |
base64 + marker | bytes |
bytearray |
base64 + marker | bytearray |
pathlib.Path |
string + marker | Path |
enum.Enum |
.value + marker |
value only (enum class not preserved) |
tuple |
list + marker | list (documented: tuples become lists) |
set / frozenset |
sorted list + marker | set / frozenset |
dataclass |
dataclasses.asdict() then recurse |
dict |
pydantic.BaseModel |
.model_dump() then recurse |
dict |
| Circular reference | {"__snapfix_circular__": true} |
sentinel dict |
| Unserializable type | {"__snapfix_unserializable__": true, ...} |
sentinel dict |
Note:
tuple→liston roundtrip is intentional. JSON has no tuple type. If your tests depend on the exact type, assertisinstance(result, list)rather thantuple.
Configuration
Environment variables
| Variable | Default | Description |
|---|---|---|
SNAPFIX_OUTPUT_DIR |
tests/fixtures |
Where fixture files are written |
SNAPFIX_MAX_DEPTH |
10 |
Maximum serialization depth |
SNAPFIX_MAX_SIZE |
500000 |
Maximum payload size in bytes |
SNAPFIX_ENABLED |
true |
Set to false to disable all capture |
snapfix.yaml (project root)
snapfix:
output_dir: tests/fixtures
max_depth: 10
max_size_bytes: 500000
enabled: true
Priority order (highest to lowest)
- Decorator parameters:
@capture(name, scrub=[...], max_depth=5) - Environment variables
snapfix.yaml- Built-in defaults
Disabling in production
Set SNAPFIX_ENABLED=false in your production environment. The decorator becomes a no-op — zero overhead, no files written, no exceptions swallowed.
CLI
snapfix list # List all captured fixtures with metadata
snapfix show <name> # Print a fixture to stdout
snapfix clear <name> # Delete one fixture (prompts for confirmation)
snapfix clear-all # Delete all fixtures (prompts for confirmation)
All commands accept --dir <path> to target a non-default fixture directory.
Decorator reference
@capture(
name, # str — fixture name; becomes the function name in the output file
scrub=None, # list[str] | None — extra field names to scrub (merged with defaults)
max_depth=None, # int | None — override max serialization depth
max_size_bytes=None, # int | None — override max payload size
config=None, # SnapfixConfig | None — full config override
)
Works on both sync and async functions. The decorator is transparent:
- The return value is always preserved unchanged.
- If the wrapped function raises, the exception propagates normally and no file is written.
- If serialization fails for any reason, a
warnings.warn()is emitted and execution continues.
FAQ
Is it safe to use against production traffic? No. Use snapfix against staging or a development environment. Production traffic contains real customer data. Even with scrubbing enabled, value-level PII (email addresses in list fields, etc.) will not be caught.
Does it slow down my application?
Serialization adds latency proportional to payload size. For typical API responses (< 50 KB), the overhead is negligible in staging. Do not enable SNAPFIX_ENABLED=true in a production critical path.
What happens if the object is too large?
If the serialized payload exceeds max_size_bytes, the fixture is not written and a warning is emitted. Increase SNAPFIX_MAX_SIZE or add depth limiting with max_depth.
What happens if a field contains an unserializable type?
It is replaced with a sentinel dict: {"__snapfix_unserializable__": true, "__snapfix_repr__": "...", "__snapfix_type_name__": "..."}. The rest of the object is still captured. reconstruct() returns the sentinel as-is.
Can I regenerate a fixture? Yes. Re-run the decorated function. The fixture file is overwritten in place.
Does it work with pytest parametrize?
The generated file is a standard @pytest.fixture. It works with everything pytest supports.
Development
git clone https://github.com/yourname/snapfix
cd snapfix
pip install -e ".[dev]"
pytest
ruff check src/
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file snapfix-0.1.0.tar.gz.
File metadata
- Download URL: snapfix-0.1.0.tar.gz
- Upload date:
- Size: 16.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
99ca0cbe4a2263c3ee7f88bba975a0e4f9230ecce3a315ca11952b982e263e5b
|
|
| MD5 |
82e6f772dd021a689be362303bf2fe11
|
|
| BLAKE2b-256 |
2108640922f2929df932c0db1b1f8e12245d4a660e6d3c085e9eb42e3f33faa0
|
Provenance
The following attestation bundles were made for snapfix-0.1.0.tar.gz:
Publisher:
release.yml on hacky1997/snapfix
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
snapfix-0.1.0.tar.gz -
Subject digest:
99ca0cbe4a2263c3ee7f88bba975a0e4f9230ecce3a315ca11952b982e263e5b - Sigstore transparency entry: 1157630075
- Sigstore integration time:
-
Permalink:
hacky1997/snapfix@e7eb43ff276a66af757be42b9c92340a82531101 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/hacky1997
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e7eb43ff276a66af757be42b9c92340a82531101 -
Trigger Event:
push
-
Statement type:
File details
Details for the file snapfix-0.1.0-py3-none-any.whl.
File metadata
- Download URL: snapfix-0.1.0-py3-none-any.whl
- Upload date:
- Size: 13.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1f38ccdcac9280a3a3df74fbf8f6d41ae8a7201bd3969e38c4200c677e2fd62b
|
|
| MD5 |
bf558d35bd01b91bf9323548b06834e4
|
|
| BLAKE2b-256 |
107340045b8e29a1f4e869c5cacfe6c36288172892b389714267f12c0ca53511
|
Provenance
The following attestation bundles were made for snapfix-0.1.0-py3-none-any.whl:
Publisher:
release.yml on hacky1997/snapfix
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
snapfix-0.1.0-py3-none-any.whl -
Subject digest:
1f38ccdcac9280a3a3df74fbf8f6d41ae8a7201bd3969e38c4200c677e2fd62b - Sigstore transparency entry: 1157630132
- Sigstore integration time:
-
Permalink:
hacky1997/snapfix@e7eb43ff276a66af757be42b9c92340a82531101 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/hacky1997
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e7eb43ff276a66af757be42b9c92340a82531101 -
Trigger Event:
push
-
Statement type: