snapfix 0.1.0__py3-none-any.whl

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.
snapfix/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from snapfix.capture import capture
2
+ from snapfix.config import DEFAULT_SCRUB_FIELDS, SnapfixConfig
3
+ from snapfix.reconstruct import reconstruct
4
+
5
+ __all__ = ["capture", "reconstruct", "SnapfixConfig", "DEFAULT_SCRUB_FIELDS"]
6
+ __version__ = "0.1.0"
snapfix/capture.py ADDED
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import datetime
5
+ import functools
6
+ import pathlib
7
+ import warnings
8
+ from collections.abc import Callable
9
+
10
+ from snapfix.codegen import SnapfixCodegen
11
+ from snapfix.config import SnapfixConfig
12
+ from snapfix.scrubber import SnapfixScrubber
13
+ from snapfix.serializer import SnapfixSerializer
14
+ from snapfix.store import SnapfixStore
15
+
16
+ _default_config: SnapfixConfig | None = None
17
+
18
+
19
+ def _get_config(cfg: SnapfixConfig | None) -> SnapfixConfig:
20
+ global _default_config
21
+ if cfg is not None:
22
+ return cfg
23
+ if _default_config is None:
24
+ yaml_path = pathlib.Path("snapfix.yaml")
25
+ _default_config = SnapfixConfig.from_yaml(yaml_path)
26
+ return _default_config
27
+
28
+
29
+ def capture(
30
+ name: str,
31
+ scrub: list[str] | None = None,
32
+ max_depth: int | None = None,
33
+ max_size_bytes: int | None = None,
34
+ config: SnapfixConfig | None = None,
35
+ ) -> Callable:
36
+ """Decorator: capture function return value and emit a pytest fixture."""
37
+ def decorator(fn: Callable) -> Callable:
38
+ if asyncio.iscoroutinefunction(fn):
39
+ @functools.wraps(fn)
40
+ async def async_wrapper(*args, **kwargs):
41
+ result = await fn(*args, **kwargs)
42
+ _record(name, result, scrub, max_depth, max_size_bytes, config)
43
+ return result
44
+ return async_wrapper
45
+ else:
46
+ @functools.wraps(fn)
47
+ def sync_wrapper(*args, **kwargs):
48
+ result = fn(*args, **kwargs)
49
+ _record(name, result, scrub, max_depth, max_size_bytes, config)
50
+ return result
51
+ return sync_wrapper
52
+ return decorator
53
+
54
+
55
+ def _record(name, obj, extra_scrub, max_depth, max_size_bytes, cfg_override):
56
+ cfg = _get_config(cfg_override)
57
+ if not cfg.enabled:
58
+ return
59
+ effective_depth = max_depth if max_depth is not None else cfg.max_depth
60
+ effective_size = max_size_bytes if max_size_bytes is not None else cfg.max_size_bytes
61
+ scrub_fields = list(cfg.default_scrub_fields)
62
+ if extra_scrub:
63
+ scrub_fields = list(set(scrub_fields) | set(extra_scrub))
64
+
65
+ serializer = SnapfixSerializer(max_depth=effective_depth, max_size_bytes=effective_size)
66
+ scrubber = SnapfixScrubber(scrub_fields)
67
+ codegen = SnapfixCodegen()
68
+ store = SnapfixStore(cfg.output_dir)
69
+
70
+ try:
71
+ serialized = serializer.serialize(obj)
72
+ scrubbed, scrubbed_keys = scrubber.scrub(serialized)
73
+ source = codegen.generate(
74
+ name=name,
75
+ data=scrubbed,
76
+ scrubbed_fields=scrubbed_keys,
77
+ captured_at=datetime.datetime.utcnow(),
78
+ )
79
+ store.write(name, source, {
80
+ "scrubbed_fields": scrubbed_keys,
81
+ "captured_at": str(datetime.datetime.utcnow()),
82
+ })
83
+ except Exception as e:
84
+ warnings.warn(f"snapfix: failed to capture '{name}': {e}", stacklevel=3)
snapfix/cli.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+
5
+ import typer
6
+
7
+ from snapfix.config import SnapfixConfig
8
+ from snapfix.store import SnapfixStore
9
+
10
+ app = typer.Typer(
11
+ name="snapfix",
12
+ help="Capture real Python objects, scrub PII, emit pytest fixtures.",
13
+ no_args_is_help=True,
14
+ add_completion=False,
15
+ )
16
+
17
+
18
+ def _store(output_dir: pathlib.Path | None = None) -> SnapfixStore:
19
+ cfg = SnapfixConfig.from_env()
20
+ return SnapfixStore(output_dir or cfg.output_dir)
21
+
22
+
23
+ @app.command("list")
24
+ def list_fixtures(
25
+ output_dir: pathlib.Path | None = typer.Option(
26
+ None, "--dir", "-d", help="Fixture output directory (default: from config)."
27
+ ),
28
+ ) -> None:
29
+ """List all captured fixtures."""
30
+ store = _store(output_dir)
31
+ entries = store.list()
32
+ if not entries:
33
+ typer.echo("No fixtures captured yet.")
34
+ raise typer.Exit(0)
35
+ for e in entries:
36
+ path = e.get("path", "?")
37
+ scrubbed = e.get("scrubbed_fields", [])
38
+ captured = e.get("captured_at", "")
39
+ typer.echo(f" {path}")
40
+ if captured:
41
+ typer.echo(f" captured : {captured}")
42
+ if scrubbed:
43
+ typer.echo(f" scrubbed : {', '.join(scrubbed)}")
44
+
45
+
46
+ @app.command("show")
47
+ def show_fixture(
48
+ name: str = typer.Argument(..., help="Fixture name (without snapfix_ prefix)."),
49
+ output_dir: pathlib.Path | None = typer.Option(None, "--dir", "-d"),
50
+ ) -> None:
51
+ """Print a captured fixture to stdout."""
52
+ store = _store(output_dir)
53
+ idx = store._load_index()
54
+ if name not in idx:
55
+ typer.echo(f"No fixture named '{name}'.", err=True)
56
+ raise typer.Exit(1)
57
+ p = pathlib.Path(idx[name]["path"])
58
+ if not p.exists():
59
+ typer.echo(f"File missing: {p}", err=True)
60
+ raise typer.Exit(1)
61
+ typer.echo(p.read_text())
62
+
63
+
64
+ @app.command("clear")
65
+ def clear_fixture(
66
+ name: str = typer.Argument(..., help="Fixture name to delete."),
67
+ output_dir: pathlib.Path | None = typer.Option(None, "--dir", "-d"),
68
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
69
+ ) -> None:
70
+ """Delete a captured fixture."""
71
+ store = _store(output_dir)
72
+ if not store.exists(name):
73
+ typer.echo(f"No fixture named '{name}'.", err=True)
74
+ raise typer.Exit(1)
75
+ if not yes:
76
+ typer.confirm(f"Delete fixture '{name}'?", abort=True)
77
+ deleted = store.delete(name)
78
+ typer.echo(f"Deleted '{name}': {deleted}")
79
+
80
+
81
+ @app.command("clear-all")
82
+ def clear_all(
83
+ output_dir: pathlib.Path | None = typer.Option(None, "--dir", "-d"),
84
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
85
+ ) -> None:
86
+ """Delete all captured fixtures."""
87
+ store = _store(output_dir)
88
+ entries = store.list()
89
+ if not entries:
90
+ typer.echo("Nothing to clear.")
91
+ raise typer.Exit(0)
92
+ if not yes:
93
+ typer.confirm(f"Delete all {len(entries)} fixture(s)?", abort=True)
94
+ for e in entries:
95
+ name = pathlib.Path(e["path"]).stem.removeprefix("snapfix_")
96
+ store.delete(name)
97
+ typer.echo(f"Cleared {len(entries)} fixture(s).")
snapfix/codegen.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import datetime
5
+ import re
6
+ import textwrap
7
+ from typing import Any
8
+
9
+ _FIXTURE_HEADER = (
10
+ "# Generated by snapfix -- do not edit manually\n"
11
+ "# Regenerate: re-run the @snapfix.capture decorated function\n"
12
+ "# Captured : {captured_at}\n"
13
+ "# Scrubbed : {scrubbed_fields}\n"
14
+ "import pytest\n"
15
+ "from snapfix import reconstruct\n"
16
+ "\n"
17
+ )
18
+
19
+
20
+ def _to_literal(obj: Any, indent: int = 2) -> str:
21
+ """Render a JSON-serialisable object as a pretty-printed Python literal."""
22
+ ind = " " * indent
23
+ if obj is None:
24
+ return "None"
25
+ if isinstance(obj, bool):
26
+ return "True" if obj else "False"
27
+ if isinstance(obj, (int, float, str)):
28
+ return repr(obj)
29
+ if isinstance(obj, list):
30
+ if not obj:
31
+ return "[]"
32
+ items = ",\n".join(ind + " " + _to_literal(x, indent + 4) for x in obj)
33
+ return "[\n" + items + ",\n" + ind + "]"
34
+ if isinstance(obj, dict):
35
+ if not obj:
36
+ return "{}"
37
+ parts = []
38
+ for k, v in obj.items():
39
+ parts.append(ind + " " + repr(k) + ": " + _to_literal(v, indent + 4))
40
+ return "{\n" + ",\n".join(parts) + ",\n" + ind + "}"
41
+ return repr(obj)
42
+
43
+
44
+ class SnapfixCodegen:
45
+ def generate(
46
+ self,
47
+ name: str,
48
+ data: Any,
49
+ scrubbed_fields: list[str],
50
+ captured_at: datetime.datetime,
51
+ ) -> str:
52
+ fn_name = _sanitize(name)
53
+ header = _FIXTURE_HEADER.format(
54
+ captured_at=captured_at.isoformat(),
55
+ scrubbed_fields=", ".join(scrubbed_fields) or "none",
56
+ )
57
+ body = _to_literal(data)
58
+ source = (
59
+ header
60
+ + "@pytest.fixture\n"
61
+ + f"def {fn_name}():\n"
62
+ + " return reconstruct(\n"
63
+ + textwrap.indent(body, " ")
64
+ + "\n )\n"
65
+ )
66
+ try:
67
+ ast.parse(source)
68
+ except SyntaxError as e:
69
+ raise ValueError(f"Generated invalid Python source: {e}") from e
70
+ return source
71
+
72
+
73
+ def _sanitize(name: str) -> str:
74
+ s = re.sub(r"[^a-zA-Z0-9_]", "_", name)
75
+ if s and s[0].isdigit():
76
+ s = "_" + s
77
+ return s or "_unnamed"
snapfix/config.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pathlib
5
+ from dataclasses import dataclass, field
6
+
7
+ import yaml
8
+
9
+ DEFAULT_SCRUB_FIELDS: list[str] = [
10
+ "email","password","passwd","token","secret","api_key","apikey",
11
+ "access_token","refresh_token","ssn","credit_card","card_number",
12
+ "cvv","phone","mobile","dob","date_of_birth","address","ip_address",
13
+ "authorization","auth","bearer",
14
+ ]
15
+
16
+ @dataclass
17
+ class SnapfixConfig:
18
+ output_dir: pathlib.Path = pathlib.Path("tests/fixtures")
19
+ default_scrub_fields: list[str] = field(default_factory=lambda: list(DEFAULT_SCRUB_FIELDS))
20
+ max_depth: int = 10
21
+ max_size_bytes: int = 500_000
22
+ enabled: bool = True
23
+
24
+ @classmethod
25
+ def from_env(cls) -> SnapfixConfig:
26
+ return cls(
27
+ output_dir=pathlib.Path(os.environ.get("SNAPFIX_OUTPUT_DIR", "tests/fixtures")),
28
+ max_depth=int(os.environ.get("SNAPFIX_MAX_DEPTH", 10)),
29
+ max_size_bytes=int(os.environ.get("SNAPFIX_MAX_SIZE", 500_000)),
30
+ enabled=os.environ.get("SNAPFIX_ENABLED", "true").lower() != "false",
31
+ )
32
+
33
+ @classmethod
34
+ def from_yaml(cls, path: pathlib.Path) -> SnapfixConfig:
35
+ if not path.exists():
36
+ return cls.from_env()
37
+ try:
38
+ data = yaml.safe_load(path.read_text()) or {}
39
+ except Exception:
40
+ return cls.from_env()
41
+ sf = data.get("snapfix", {})
42
+ cfg = cls.from_env()
43
+ if "output_dir" in sf:
44
+ cfg.output_dir = pathlib.Path(sf["output_dir"])
45
+ if "max_depth" in sf:
46
+ cfg.max_depth = int(sf["max_depth"])
47
+ if "max_size_bytes" in sf:
48
+ cfg.max_size_bytes = int(sf["max_size_bytes"])
49
+ if "enabled" in sf:
50
+ cfg.enabled = bool(sf["enabled"])
51
+ return cfg
snapfix/py.typed ADDED
File without changes
snapfix/reconstruct.py ADDED
@@ -0,0 +1,8 @@
1
+ from snapfix.serializer import SnapfixSerializer
2
+
3
+ _global_serializer = SnapfixSerializer()
4
+
5
+
6
+ def reconstruct(data):
7
+ """Restore __snapfix_type__ markers in a captured fixture to Python types."""
8
+ return _global_serializer.deserialize(data)
snapfix/scrubber.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ _SCRUBBED_STR = "***SCRUBBED***"
6
+ _SCRUBBED_NUM = -1
7
+
8
+
9
+ class SnapfixScrubber:
10
+ def __init__(self, fields: list[str], *, numeric_replacement: int = _SCRUBBED_NUM):
11
+ self._fields = [f.lower() for f in fields]
12
+ self._numeric_replacement = numeric_replacement
13
+
14
+ def _is_sensitive(self, key: str) -> bool:
15
+ k = str(key).lower()
16
+ return any(f in k for f in self._fields)
17
+
18
+ def _scrub_value(self, value: Any) -> Any:
19
+ if isinstance(value, (int, float)):
20
+ return self._numeric_replacement
21
+ return _SCRUBBED_STR
22
+
23
+ def scrub(self, data: Any, _scrubbed: list[str] | None = None) -> tuple[Any, list[str]]:
24
+ """Returns (scrubbed_copy, list_of_scrubbed_key_paths).
25
+ Does NOT mutate the input.
26
+ """
27
+ top_level = _scrubbed is None
28
+ if top_level:
29
+ _scrubbed = []
30
+ result = self._scrub_node(data, _scrubbed, path="")
31
+ return result, _scrubbed
32
+
33
+ def _scrub_node(self, data: Any, scrubbed: list[str], path: str) -> Any:
34
+ if isinstance(data, dict):
35
+ out = {}
36
+ for k, v in data.items():
37
+ key_path = f"{path}.{k}" if path else str(k)
38
+ if self._is_sensitive(k):
39
+ out[k] = self._scrub_value(v)
40
+ scrubbed.append(key_path)
41
+ else:
42
+ out[k] = self._scrub_node(v, scrubbed, key_path)
43
+ return out
44
+ if isinstance(data, list):
45
+ return [
46
+ self._scrub_node(item, scrubbed, f"{path}[{i}]")
47
+ for i, item in enumerate(data)
48
+ ]
49
+ return data
snapfix/serializer.py ADDED
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import dataclasses
5
+ import datetime
6
+ import decimal
7
+ import enum
8
+ import json
9
+ import math
10
+ import pathlib
11
+ import uuid
12
+ from typing import Any
13
+
14
+ _MARKER = "__snapfix_type__"
15
+ _UNSZ = "__snapfix_unserializable__"
16
+ _CIRC = "__snapfix_circular__"
17
+ _TRUNC = "__snapfix_truncated__"
18
+ _DEPTH = "__snapfix_maxdepth__"
19
+
20
+
21
+ class SnapfixSerializer:
22
+ def __init__(self, max_depth: int = 10, max_size_bytes: int = 500_000):
23
+ self.max_depth = max_depth
24
+ self.max_size_bytes = max_size_bytes
25
+ self._size_counter = 0
26
+
27
+ def serialize(self, obj: Any, _depth: int = 0, _seen: set | None = None) -> Any:
28
+ if _seen is None:
29
+ _seen = set()
30
+ self._size_counter = 0
31
+
32
+ result = self._dispatch(obj, _depth, _seen)
33
+
34
+ try:
35
+ self._size_counter += len(json.dumps(result, default=str))
36
+ except Exception:
37
+ self._size_counter += 1024
38
+ if self._size_counter > self.max_size_bytes:
39
+ return {_TRUNC: True, "__snapfix_size__": self._size_counter}
40
+
41
+ return result
42
+
43
+ def _dispatch(self, obj: Any, depth: int, seen: set) -> Any:
44
+ # depth guard (also in serialize() but _dispatch calls itself directly)
45
+ if depth > self.max_depth:
46
+ return {_DEPTH: True, "__snapfix_repr__": repr(obj)[:200]}
47
+ # circular reference guard — must be per-_dispatch call, not just in serialize()
48
+ if isinstance(obj, (dict, list, tuple, set, frozenset)):
49
+ try:
50
+ obj_id = id(obj)
51
+ if obj_id in seen:
52
+ return {_CIRC: True}
53
+ seen.add(obj_id)
54
+ except Exception:
55
+ pass
56
+ # Singletons / primitives
57
+ if obj is None or isinstance(obj, bool):
58
+ return obj
59
+ if isinstance(obj, int):
60
+ return obj
61
+ if isinstance(obj, str):
62
+ return obj
63
+ if isinstance(obj, float):
64
+ if math.isnan(obj):
65
+ return {_MARKER: "float", "value": "nan"}
66
+ if math.isinf(obj):
67
+ return {_MARKER: "float", "value": "inf" if obj > 0 else "-inf"}
68
+ return obj
69
+ if isinstance(obj, datetime.datetime):
70
+ return {_MARKER: "datetime", "value": obj.isoformat()}
71
+ if isinstance(obj, datetime.date):
72
+ return {_MARKER: "date", "value": obj.isoformat()}
73
+ if isinstance(obj, datetime.time):
74
+ return {_MARKER: "time", "value": obj.isoformat()}
75
+ if isinstance(obj, datetime.timedelta):
76
+ return {_MARKER: "timedelta", "value": obj.total_seconds()}
77
+ if isinstance(obj, uuid.UUID):
78
+ return {_MARKER: "uuid", "value": str(obj)}
79
+ if isinstance(obj, decimal.Decimal):
80
+ return {_MARKER: "decimal", "value": str(obj)}
81
+ if isinstance(obj, bytes):
82
+ return {_MARKER: "bytes", "value": base64.b64encode(obj).decode()}
83
+ if isinstance(obj, bytearray):
84
+ return {_MARKER: "bytearray", "value": base64.b64encode(bytes(obj)).decode()}
85
+ if isinstance(obj, pathlib.PurePath):
86
+ return {_MARKER: "path", "value": str(obj)}
87
+ if isinstance(obj, enum.Enum):
88
+ return {_MARKER: "enum", "cls": type(obj).__name__,
89
+ "value": self._dispatch(obj.value, depth + 1, seen)}
90
+ if isinstance(obj, (set, frozenset)):
91
+ tag = "frozenset" if isinstance(obj, frozenset) else "set"
92
+ try:
93
+ items = sorted([self._dispatch(x, depth + 1, seen) for x in obj], key=str)
94
+ except TypeError:
95
+ items = [self._dispatch(x, depth + 1, seen) for x in obj]
96
+ return {_MARKER: tag, "value": items}
97
+ if isinstance(obj, tuple):
98
+ return {_MARKER: "tuple",
99
+ "value": [self._dispatch(x, depth + 1, seen) for x in obj]}
100
+ if isinstance(obj, list):
101
+ return [self._dispatch(x, depth + 1, seen) for x in obj]
102
+ if isinstance(obj, dict):
103
+ return {str(k): self._dispatch(v, depth + 1, seen) for k, v in obj.items()}
104
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
105
+ try:
106
+ return self._dispatch(dataclasses.asdict(obj), depth, seen)
107
+ except Exception:
108
+ pass
109
+ # pydantic v2
110
+ try:
111
+ return self._dispatch(obj.model_dump(), depth, seen)
112
+ except AttributeError:
113
+ pass
114
+ # pydantic v1
115
+ try:
116
+ return self._dispatch(obj.dict(), depth, seen)
117
+ except AttributeError:
118
+ pass
119
+ # __dict__ fallback
120
+ try:
121
+ return self._dispatch(vars(obj), depth, seen)
122
+ except TypeError:
123
+ pass
124
+ return {_UNSZ: True, "__snapfix_repr__": repr(obj)[:200],
125
+ "__snapfix_type_name__": type(obj).__name__}
126
+
127
+ def deserialize(self, data: Any) -> Any:
128
+ if data is None or isinstance(data, (bool, int, float, str)):
129
+ return data
130
+ if isinstance(data, list):
131
+ return [self.deserialize(x) for x in data]
132
+ if isinstance(data, dict):
133
+ marker = data.get(_MARKER)
134
+ if marker == "datetime":
135
+ return datetime.datetime.fromisoformat(data["value"])
136
+ if marker == "date":
137
+ return datetime.date.fromisoformat(data["value"])
138
+ if marker == "time":
139
+ return datetime.time.fromisoformat(data["value"])
140
+ if marker == "timedelta":
141
+ return datetime.timedelta(seconds=float(data["value"]))
142
+ if marker == "uuid":
143
+ return uuid.UUID(data["value"])
144
+ if marker == "decimal":
145
+ return decimal.Decimal(data["value"])
146
+ if marker == "bytes":
147
+ return base64.b64decode(data["value"].encode())
148
+ if marker == "bytearray":
149
+ return bytearray(base64.b64decode(data["value"].encode()))
150
+ if marker == "path":
151
+ return pathlib.Path(data["value"])
152
+ if marker == "enum":
153
+ return self.deserialize(data["value"])
154
+ if marker in ("set", "frozenset"):
155
+ items = [self.deserialize(x) for x in data["value"]]
156
+ return frozenset(items) if marker == "frozenset" else set(items)
157
+ if marker == "tuple":
158
+ # Tuples become lists on roundtrip — documented behavior matching JSON's type system
159
+ return [self.deserialize(x) for x in data["value"]]
160
+ if marker == "float":
161
+ v = data["value"]
162
+ return float("nan") if v == "nan" else float(v)
163
+ if any(k in data for k in (_UNSZ, _CIRC, _TRUNC, _DEPTH)):
164
+ return data
165
+ return {k: self.deserialize(v) for k, v in data.items()}
166
+ return data
snapfix/store.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import builtins
4
+ import json
5
+ import pathlib
6
+ import re
7
+ from typing import Any
8
+
9
+
10
+ class SnapfixStore:
11
+ def __init__(self, output_dir: pathlib.Path):
12
+ self.output_dir = pathlib.Path(output_dir)
13
+ self.output_dir.mkdir(parents=True, exist_ok=True)
14
+ self._index_path = self.output_dir / ".snapfix_index.json"
15
+
16
+ def _load_index(self) -> dict:
17
+ if self._index_path.exists():
18
+ try:
19
+ return json.loads(self._index_path.read_text())
20
+ except Exception:
21
+ return {}
22
+ return {}
23
+
24
+ def _save_index(self, idx: dict):
25
+ tmp = self._index_path.with_suffix(".tmp")
26
+ tmp.write_text(json.dumps(idx, indent=2, default=str))
27
+ tmp.replace(self._index_path)
28
+
29
+ def write(self, name: str, source: str, metadata: dict[str, Any]) -> pathlib.Path:
30
+ safe = _sanitize(name)
31
+ path = self.output_dir / f"snapfix_{safe}.py"
32
+ tmp = path.with_suffix(".tmp")
33
+ tmp.write_text(source, encoding="utf-8")
34
+ tmp.replace(path)
35
+ idx = self._load_index()
36
+ idx[name] = {"path": str(path), **metadata}
37
+ self._save_index(idx)
38
+ return path
39
+
40
+ def list(self) -> builtins.list[dict]:
41
+ return list(self._load_index().values())
42
+
43
+ def exists(self, name: str) -> bool:
44
+ return name in self._load_index()
45
+
46
+ def delete(self, name: str) -> bool:
47
+ idx = self._load_index()
48
+ entry = idx.pop(name, None)
49
+ if entry and pathlib.Path(entry["path"]).exists():
50
+ pathlib.Path(entry["path"]).unlink()
51
+ self._save_index(idx)
52
+ return entry is not None
53
+
54
+
55
+ def _sanitize(name: str) -> str:
56
+ s = re.sub(r"[^a-zA-Z0-9_]", "_", name)
57
+ return ("_" + s) if s and s[0].isdigit() else s or "_unnamed"
@@ -0,0 +1,275 @@
1
+ Metadata-Version: 2.4
2
+ Name: snapfix
3
+ Version: 0.1.0
4
+ Summary: Capture real Python objects, scrub sensitive fields, emit pytest fixtures.
5
+ Project-URL: Homepage, https://github.com/yourname/snapfix
6
+ Project-URL: Documentation, https://github.com/yourname/snapfix#readme
7
+ Project-URL: Issues, https://github.com/yourname/snapfix/issues
8
+ Project-URL: Changelog, https://github.com/yourname/snapfix/blob/main/CHANGELOG.md
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: capture,fixtures,pii,pytest,scrubbing,testing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: typer>=0.12
25
+ Provides-Extra: dev
26
+ Requires-Dist: hypothesis>=6.0; extra == 'dev'
27
+ Requires-Dist: mypy>=1.0; extra == 'dev'
28
+ Requires-Dist: pydantic>=2.0; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
30
+ Requires-Dist: pytest>=7.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.4; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # snapfix
35
+
36
+ **Capture real Python objects from staging, scrub sensitive fields, emit `@pytest.fixture` files automatically.**
37
+
38
+ ---
39
+
40
+ ## The problem
41
+
42
+ 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.
43
+
44
+ snapfix solves this with one decorator.
45
+
46
+ ---
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install snapfix
52
+ ```
53
+
54
+ Python 3.10+ required. No other required dependencies.
55
+
56
+ ---
57
+
58
+ ## Quickstart
59
+
60
+ ```python
61
+ # In your service code (staging or development only)
62
+ from snapfix import capture
63
+
64
+ @capture("invoice_response", scrub=["billing_name"])
65
+ def fetch_invoice(invoice_id: str) -> dict:
66
+ return external_api.get(f"/invoices/{invoice_id}")
67
+ ```
68
+
69
+ Call the function once against staging. snapfix writes this to `tests/fixtures/snapfix_invoice_response.py`:
70
+
71
+ ```python
72
+ # Generated by snapfix -- do not edit manually
73
+ # Captured : 2026-03-23T14:22:01
74
+ # Scrubbed : customer.email, meta.token, billing_name
75
+ import pytest
76
+ from snapfix import reconstruct
77
+
78
+ @pytest.fixture
79
+ def invoice_response():
80
+ return reconstruct({
81
+ "id": "INV-8821",
82
+ "customer": {
83
+ "email": "***SCRUBBED***",
84
+ "billing_name": "***SCRUBBED***",
85
+ "plan": "pro",
86
+ },
87
+ "amount": {"__snapfix_type__": "decimal", "value": "149.99"},
88
+ "issued_at": {"__snapfix_type__": "datetime", "value": "2026-03-01T09:00:00"},
89
+ "meta": {
90
+ "token": "***SCRUBBED***",
91
+ "retry": 0,
92
+ },
93
+ })
94
+ ```
95
+
96
+ Use it in any test:
97
+
98
+ ```python
99
+ def test_invoice_total(invoice_response):
100
+ assert invoice_response["amount"].quantize(Decimal("0.01")) == Decimal("149.99")
101
+ ```
102
+
103
+ That's it. No factory definition. No manual scrubbing. No pasted JSON.
104
+
105
+ ---
106
+
107
+ ## PII scrubbing
108
+
109
+ > **⚠ Important limitation:** snapfix scrubs fields by **key name only**. It does NOT detect PII
110
+ > in field values. An email address stored as `response["tags"][0]` or inside a list
111
+ > will **not** be scrubbed. Always review generated fixtures before committing them.
112
+
113
+ ### Default scrubbed field names
114
+
115
+ Any key whose name contains one of these strings (case-insensitive, substring match) is scrubbed:
116
+
117
+ `email` · `password` · `passwd` · `token` · `secret` · `api_key` · `apikey` ·
118
+ `access_token` · `refresh_token` · `ssn` · `credit_card` · `card_number` ·
119
+ `cvv` · `phone` · `mobile` · `dob` · `date_of_birth` · `address` ·
120
+ `ip_address` · `authorization` · `auth` · `bearer`
121
+
122
+ `customer_email` → scrubbed (substring match on `email`)
123
+ `billing_phone_number` → scrubbed (substring match on `phone`)
124
+ `retry_count` → **not** scrubbed
125
+
126
+ ### Adding custom fields
127
+
128
+ ```python
129
+ @capture("order", scrub=["customer_id", "tax_number"])
130
+ def fetch_order(order_id: str) -> dict: ...
131
+ ```
132
+
133
+ ### Replacement values
134
+
135
+ | Field value type | Replacement |
136
+ |---|---|
137
+ | `str` | `"***SCRUBBED***"` |
138
+ | `int` / `float` | `-1` |
139
+ | `None` | `"***SCRUBBED***"` |
140
+
141
+ ---
142
+
143
+ ## Supported types
144
+
145
+ snapfix serializes the following Python types and restores them correctly via `reconstruct()`:
146
+
147
+ | Type | Serialized as | Restored on `reconstruct()` |
148
+ |---|---|---|
149
+ | `dict`, `list`, `str`, `int`, `float`, `bool`, `None` | JSON native | Same |
150
+ | `datetime.datetime` | ISO 8601 string + marker | `datetime` |
151
+ | `datetime.date` | ISO 8601 string + marker | `date` |
152
+ | `datetime.time` | ISO 8601 string + marker | `time` |
153
+ | `datetime.timedelta` | total seconds + marker | `timedelta` |
154
+ | `uuid.UUID` | string + marker | `UUID` |
155
+ | `decimal.Decimal` | string + marker | `Decimal` |
156
+ | `bytes` | base64 + marker | `bytes` |
157
+ | `bytearray` | base64 + marker | `bytearray` |
158
+ | `pathlib.Path` | string + marker | `Path` |
159
+ | `enum.Enum` | `.value` + marker | value only (enum class not preserved) |
160
+ | `tuple` | list + marker | `list` (documented: tuples become lists) |
161
+ | `set` / `frozenset` | sorted list + marker | `set` / `frozenset` |
162
+ | `dataclass` | `dataclasses.asdict()` then recurse | `dict` |
163
+ | `pydantic.BaseModel` | `.model_dump()` then recurse | `dict` |
164
+ | Circular reference | `{"__snapfix_circular__": true}` | sentinel dict |
165
+ | Unserializable type | `{"__snapfix_unserializable__": true, ...}` | sentinel dict |
166
+
167
+ > **Note:** `tuple` → `list` on roundtrip is intentional. JSON has no tuple type.
168
+ > If your tests depend on the exact type, assert `isinstance(result, list)` rather than `tuple`.
169
+
170
+ ---
171
+
172
+ ## Configuration
173
+
174
+ ### Environment variables
175
+
176
+ | Variable | Default | Description |
177
+ |---|---|---|
178
+ | `SNAPFIX_OUTPUT_DIR` | `tests/fixtures` | Where fixture files are written |
179
+ | `SNAPFIX_MAX_DEPTH` | `10` | Maximum serialization depth |
180
+ | `SNAPFIX_MAX_SIZE` | `500000` | Maximum payload size in bytes |
181
+ | `SNAPFIX_ENABLED` | `true` | Set to `false` to disable all capture |
182
+
183
+ ### `snapfix.yaml` (project root)
184
+
185
+ ```yaml
186
+ snapfix:
187
+ output_dir: tests/fixtures
188
+ max_depth: 10
189
+ max_size_bytes: 500000
190
+ enabled: true
191
+ ```
192
+
193
+ ### Priority order (highest to lowest)
194
+
195
+ 1. Decorator parameters: `@capture(name, scrub=[...], max_depth=5)`
196
+ 2. Environment variables
197
+ 3. `snapfix.yaml`
198
+ 4. Built-in defaults
199
+
200
+ ### Disabling in production
201
+
202
+ Set `SNAPFIX_ENABLED=false` in your production environment. The decorator becomes a no-op — zero overhead, no files written, no exceptions swallowed.
203
+
204
+ ---
205
+
206
+ ## CLI
207
+
208
+ ```
209
+ snapfix list # List all captured fixtures with metadata
210
+ snapfix show <name> # Print a fixture to stdout
211
+ snapfix clear <name> # Delete one fixture (prompts for confirmation)
212
+ snapfix clear-all # Delete all fixtures (prompts for confirmation)
213
+ ```
214
+
215
+ All commands accept `--dir <path>` to target a non-default fixture directory.
216
+
217
+ ---
218
+
219
+ ## Decorator reference
220
+
221
+ ```python
222
+ @capture(
223
+ name, # str — fixture name; becomes the function name in the output file
224
+ scrub=None, # list[str] | None — extra field names to scrub (merged with defaults)
225
+ max_depth=None, # int | None — override max serialization depth
226
+ max_size_bytes=None, # int | None — override max payload size
227
+ config=None, # SnapfixConfig | None — full config override
228
+ )
229
+ ```
230
+
231
+ Works on both **sync** and **async** functions. The decorator is transparent:
232
+
233
+ - The return value is always preserved unchanged.
234
+ - If the wrapped function raises, the exception propagates normally and **no file is written**.
235
+ - If serialization fails for any reason, a `warnings.warn()` is emitted and execution continues.
236
+
237
+ ---
238
+
239
+ ## FAQ
240
+
241
+ **Is it safe to use against production traffic?**
242
+ 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.
243
+
244
+ **Does it slow down my application?**
245
+ 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.
246
+
247
+ **What happens if the object is too large?**
248
+ 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`.
249
+
250
+ **What happens if a field contains an unserializable type?**
251
+ 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.
252
+
253
+ **Can I regenerate a fixture?**
254
+ Yes. Re-run the decorated function. The fixture file is overwritten in place.
255
+
256
+ **Does it work with pytest parametrize?**
257
+ The generated file is a standard `@pytest.fixture`. It works with everything pytest supports.
258
+
259
+ ---
260
+
261
+ ## Development
262
+
263
+ ```bash
264
+ git clone https://github.com/yourname/snapfix
265
+ cd snapfix
266
+ pip install -e ".[dev]"
267
+ pytest
268
+ ruff check src/
269
+ ```
270
+
271
+ ---
272
+
273
+ ## License
274
+
275
+ MIT
@@ -0,0 +1,15 @@
1
+ snapfix/__init__.py,sha256=Bzr3NPmzBsIXucS5Z0TIkluupKQfwWVYoxazUwpZTMU,244
2
+ snapfix/capture.py,sha256=JD2nk7yycb40WY2kWefnIqdH3grgm95dMQkqlebAcxE,2911
3
+ snapfix/cli.py,sha256=f-yO_hM8e1lUpiuw8R_CM8n-2h36oH9GQo84mSAS3KI,3103
4
+ snapfix/codegen.py,sha256=21mqrE61M20evmH2_YuO7NrXQ5kSq8qKWIusm5CcNeI,2222
5
+ snapfix/config.py,sha256=2nsPZCNkC3YkgHQttbn-O5tEk9y10UpvcSo2B4cMXlU,1782
6
+ snapfix/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ snapfix/reconstruct.py,sha256=yeW6v1TrGj_kp05cwOumfk3WVMbw3cWN2fmxtGuk-tc,246
8
+ snapfix/scrubber.py,sha256=27UdoQEyE2dwrCmD18TfH4SY5y5sRowaqVhi7R0PczY,1696
9
+ snapfix/serializer.py,sha256=eKKqV4A_wVVQ0Uf1491FqYRV3v5jO1fiDEtEZBMe9Vs,6885
10
+ snapfix/store.py,sha256=asdWzSDjoA7qWbRFBhMkad0SuJlwEKE0uqAJfb_zysw,1808
11
+ snapfix-0.1.0.dist-info/METADATA,sha256=BpXFJDvxsX2VtGNriW-AY-D0xKOzniMiMMMfm-_nD28,9314
12
+ snapfix-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ snapfix-0.1.0.dist-info/entry_points.txt,sha256=BoQGQT91XVV6iFkvWWM4LLczC1XPf42t2xN-0xJUaAg,44
14
+ snapfix-0.1.0.dist-info/licenses/LICENSE,sha256=9DgUw9Co75aF5aXEMg2R_xTrOh9cqo0bTsVPuMAC0Mg,1077
15
+ snapfix-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ snapfix = snapfix.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 snapfix contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.