Metadata-Version: 2.4
Name: snapfix
Version: 0.3.2
Summary: Capture real Python objects, scrub sensitive fields, emit pytest fixtures.
Project-URL: Homepage, https://github.com/hacky1997/snapfix
Project-URL: Documentation, https://github.com/hacky1997/snapfix#readme
Project-URL: Issues, https://github.com/hacky1997/snapfix/issues
Project-URL: Changelog, https://github.com/hacky1997/snapfix/blob/main/CHANGELOG.md
License: MIT
License-File: LICENSE
Keywords: capture,fixtures,pii,pytest,scrubbing,testing
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Software Development :: Testing
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: pyyaml>=6.0
Requires-Dist: typer>=0.12
Provides-Extra: dev
Requires-Dist: hypothesis>=6.0; extra == 'dev'
Requires-Dist: mypy>=1.0; extra == 'dev'
Requires-Dist: pydantic>=2.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# 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

```bash
pip install snapfix
```

Python 3.10+ required. No other required dependencies.

---

## Quickstart

```python
# 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`:

```python
# 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:

```python
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

```python
@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` → `list` on roundtrip is intentional. JSON has no tuple type.
> If your tests depend on the exact type, assert `isinstance(result, list)` rather than `tuple`.

---

## 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)

```yaml
snapfix:
  output_dir: tests/fixtures
  max_depth: 10
  max_size_bytes: 500000
  enabled: true
```

### Priority order (highest to lowest)

1. Decorator parameters: `@capture(name, scrub=[...], max_depth=5)`
2. Environment variables
3. `snapfix.yaml`
4. 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

```python
@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

```bash
git clone https://github.com/yourname/snapfix
cd snapfix
pip install -e ".[dev]"
pytest
ruff check src/
```

---

## License

MIT
