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 +6 -0
- snapfix/capture.py +84 -0
- snapfix/cli.py +97 -0
- snapfix/codegen.py +77 -0
- snapfix/config.py +51 -0
- snapfix/py.typed +0 -0
- snapfix/reconstruct.py +8 -0
- snapfix/scrubber.py +49 -0
- snapfix/serializer.py +166 -0
- snapfix/store.py +57 -0
- snapfix-0.1.0.dist-info/METADATA +275 -0
- snapfix-0.1.0.dist-info/RECORD +15 -0
- snapfix-0.1.0.dist-info/WHEEL +4 -0
- snapfix-0.1.0.dist-info/entry_points.txt +2 -0
- snapfix-0.1.0.dist-info/licenses/LICENSE +21 -0
snapfix/__init__.py
ADDED
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
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,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.
|