pytest-fixedpoint 0.1.3__tar.gz

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.
Files changed (30) hide show
  1. pytest_fixedpoint-0.1.3/.github/workflows/ci.yml +32 -0
  2. pytest_fixedpoint-0.1.3/.github/workflows/python-publish.yml +68 -0
  3. pytest_fixedpoint-0.1.3/.gitignore +8 -0
  4. pytest_fixedpoint-0.1.3/.pre-commit-config.yaml +7 -0
  5. pytest_fixedpoint-0.1.3/PKG-INFO +10 -0
  6. pytest_fixedpoint-0.1.3/README.md +145 -0
  7. pytest_fixedpoint-0.1.3/pyproject.toml +47 -0
  8. pytest_fixedpoint-0.1.3/setup.cfg +4 -0
  9. pytest_fixedpoint-0.1.3/src/fixedpoint/__init__.py +28 -0
  10. pytest_fixedpoint-0.1.3/src/fixedpoint/_cassette.py +105 -0
  11. pytest_fixedpoint-0.1.3/src/fixedpoint/_interceptor.py +37 -0
  12. pytest_fixedpoint-0.1.3/src/fixedpoint/_registry.py +47 -0
  13. pytest_fixedpoint-0.1.3/src/fixedpoint/_serializer.py +64 -0
  14. pytest_fixedpoint-0.1.3/src/fixedpoint/_types.py +17 -0
  15. pytest_fixedpoint-0.1.3/src/fixedpoint/plugin.py +70 -0
  16. pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/PKG-INFO +10 -0
  17. pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/SOURCES.txt +28 -0
  18. pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/dependency_links.txt +1 -0
  19. pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/entry_points.txt +2 -0
  20. pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/requires.txt +2 -0
  21. pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/top_level.txt +1 -0
  22. pytest_fixedpoint-0.1.3/tests/__init__.py +0 -0
  23. pytest_fixedpoint-0.1.3/tests/conftest.py +1 -0
  24. pytest_fixedpoint-0.1.3/tests/sample_app/__init__.py +0 -0
  25. pytest_fixedpoint-0.1.3/tests/sample_app/math_funcs.py +20 -0
  26. pytest_fixedpoint-0.1.3/tests/test_cassette.py +119 -0
  27. pytest_fixedpoint-0.1.3/tests/test_interceptor.py +82 -0
  28. pytest_fixedpoint-0.1.3/tests/test_plugin.py +226 -0
  29. pytest_fixedpoint-0.1.3/tests/test_registry.py +54 -0
  30. pytest_fixedpoint-0.1.3/tests/test_serializer.py +101 -0
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/ruff-action@v3
15
+ with:
16
+ args: check
17
+ - uses: astral-sh/ruff-action@v3
18
+ with:
19
+ args: format --check
20
+
21
+ test:
22
+ runs-on: ubuntu-latest
23
+ strategy:
24
+ matrix:
25
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: actions/setup-python@v5
29
+ with:
30
+ python-version: ${{ matrix.python-version }}
31
+ - run: pip install -e .
32
+ - run: pytest tests/ -v
@@ -0,0 +1,68 @@
1
+ # This workflow will upload a Python Package using Twine when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3
+ # This workflow uses actions that are not certified by GitHub.
4
+ # They are provided by a third-party and are governed by
5
+ # separate terms of service, privacy policy, and support
6
+ # documentation.
7
+ name: Upload Python Package
8
+ on:
9
+ release:
10
+ types: [published]
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ jobs:
15
+ deploy:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v6
19
+ with:
20
+ fetch-depth: 0
21
+ - name: Configure Git Credentials
22
+ run: |
23
+ git config user.name github-actions[bot]
24
+ git config user.email 41898282+github-actions[bot]@users.noreply.github.com
25
+ - name: Set up uv
26
+ uses: astral-sh/setup-uv@v5
27
+ with:
28
+ python-version: '3.12'
29
+ - name: Install dependencies
30
+ run: uv sync
31
+ - name: Build package
32
+ run: uv run --with build python -m build
33
+ - name: Publish package
34
+ uses: pypa/gh-action-pypi-publish@release/v1
35
+ with:
36
+ user: __token__
37
+ password: ${{ secrets.PYPI_API_TOKEN }}
38
+ attestations: false
39
+
40
+ cd:
41
+ needs: deploy
42
+ runs-on: ubuntu-latest
43
+ steps:
44
+ - uses: actions/checkout@v6
45
+ - name: Set up uv
46
+ uses: astral-sh/setup-uv@v5
47
+ with:
48
+ python-version: '3.12'
49
+ - name: Install released package
50
+ run: uv pip install --system ${{ github.event.repository.name }}==${{ github.event.release.tag_name }}
51
+ - name: Verify installation
52
+ run: python -c "from importlib.metadata import version; print(version('${{ github.event.repository.name }}'))"
53
+ - name: Deploy to staging
54
+ run: |
55
+ echo "Deploying version ${{ github.event.release.tag_name }} to staging..."
56
+ # Add your staging deployment commands here
57
+ env:
58
+ DEPLOY_ENV: staging
59
+ - name: Run smoke tests
60
+ run: |
61
+ uv run python -c "print('Running smoke tests for version ${{ github.event.release.tag_name }}...')"
62
+ # Add smoke tests against the deployed environment here
63
+ - name: Deploy to production
64
+ run: |
65
+ echo "Deploying version ${{ github.event.release.tag_name }} to production..."
66
+ # Add your production deployment commands here
67
+ env:
68
+ DEPLOY_ENV: production
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ cassettes/
@@ -0,0 +1,7 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.9.10
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-fixedpoint
3
+ Version: 0.1.3
4
+ Summary: Pytest plugin for recording and replaying deterministic function calls
5
+ License-Expression: MIT
6
+ Classifier: Framework :: Pytest
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: pytest>=7.0
10
+ Requires-Dist: pyyaml>=6.0
@@ -0,0 +1,145 @@
1
+ # fixed-point
2
+
3
+ * Fixed-Point is a pytest plugin designed to record and replay deterministic function calls, making your tests stable and reproducible.
4
+ * Flaky tests are the bane of every developer's existence. I prefer not to rely on retries, mocks that drift from reality, or fragile test setups to keep things green.
5
+ * To tackle this problem, Fixed-Point captures real function outputs and replays them faithfully, so your tests always land on the same answer — a fixed point.
6
+ * It boasts simplicity, making it exceptionally easy to adopt in any pytest project.
7
+ * Fixed-Point serves as a specialized testing tool, particularly tailored for pinning down the behavior of functions that talk to external services. If you encounter any cases not covered by the plugin, please don't hesitate to create an issue for further assistance.
8
+
9
+ ## Installation
10
+
11
+ You can install pytest-fixedpoint using pip, the Python package manager:
12
+
13
+ ```
14
+ pip install pytest-fixedpoint
15
+ ```
16
+
17
+ ## Getting Started
18
+
19
+ To start using fixed-point in your project, simply decorate the functions you want to pin down with `@recordable`:
20
+
21
+ ```python
22
+ from fixedpoint import recordable
23
+
24
+ @recordable
25
+ def call_external_api(query):
26
+ return external_service.search(query)
27
+
28
+ def test_search(fixedpoint):
29
+ result = call_external_api("hello")
30
+ assert result == expected
31
+ ```
32
+
33
+ The first time you run with `--fixedpoint=record_once`, it captures real outputs. Every subsequent run replays them — no network calls, no flakiness.
34
+
35
+ ## Modes
36
+
37
+ Fixed-Point supports 4 operational modes via the `--fixedpoint` CLI option:
38
+
39
+ | Mode | Behavior |
40
+ |------|----------|
41
+ | `off` | Default. Plugin is disabled, functions execute normally. |
42
+ | `record_once` | Record new calls, replay existing ones. Ideal for initial setup. |
43
+ | `replay` | Replay only. Fails with `CassetteNotFoundError` if no recording exists. Perfect for CI. |
44
+ | `rewrite` | Always re-execute and overwrite recordings. Use when the real behavior has changed. |
45
+
46
+ ```bash
47
+ # Record cassettes for the first time
48
+ pytest tests/ --fixedpoint=record_once
49
+
50
+ # Replay from cassettes (safe for CI)
51
+ pytest tests/ --fixedpoint=replay
52
+
53
+ # Force re-record everything
54
+ pytest tests/ --fixedpoint=rewrite
55
+
56
+ # Disable (default)
57
+ pytest tests/
58
+ ```
59
+
60
+ ## Cassettes
61
+
62
+ Recordings are stored as human-readable YAML files in `tests/cassettes/`:
63
+
64
+ ```
65
+ tests/cassettes/
66
+ test_module_name/
67
+ test_function_name.yaml
68
+ ```
69
+
70
+ A cassette file looks like:
71
+
72
+ ```yaml
73
+ version: 1
74
+ calls:
75
+ myapp.api.fetch_user:
76
+ - args: [42]
77
+ kwargs: {}
78
+ return: {"name": "Alice", "age": 30}
79
+ ```
80
+
81
+ Cassettes are committed to your repo so the whole team replays the same results.
82
+
83
+ ## Supported Types
84
+
85
+ The serializer handles these types out of the box:
86
+
87
+ | Type | Serialized As |
88
+ |------|---------------|
89
+ | `None`, `bool`, `int`, `float`, `str` | Direct values |
90
+ | `bytes` | `{"__bytes__": "<base64>"}` |
91
+ | `tuple` | `{"__tuple__": [...]}` |
92
+ | `set` | `{"__set__": [...]}` |
93
+ | `list`, `dict` | Direct JSON arrays/objects |
94
+ | `dataclass` | `{"__dataclass__": "module.Class", ...fields}` |
95
+
96
+ ## Error Handling
97
+
98
+ Fixed-Point raises clear errors when things don't match:
99
+
100
+ | Exception | When |
101
+ |-----------|------|
102
+ | `CassetteNotFoundError` | No cassette file found in `replay` mode |
103
+ | `CassetteMismatchError` | Recorded args don't match actual call, or too many calls |
104
+ | `SerializationError` | Unsupported type passed to a `@recordable` function |
105
+
106
+ When you see a `CassetteMismatchError`, the error message includes a hint:
107
+
108
+ > Run with `--fixedpoint=rewrite` to re-record
109
+
110
+ ## Comparison
111
+
112
+ ### vs VCR.py / responses / requests-mock
113
+
114
+ VCR-style tools record at the **HTTP layer** — every header, cookie, content-type, and redirect ends up in your cassette. This means:
115
+
116
+ * Cassettes are bloated with details you don't care about (auth tokens, timestamps, trace IDs).
117
+ * An unrelated header change breaks your tests even though the actual data hasn't changed.
118
+ * You're testing HTTP plumbing, not your application logic.
119
+
120
+ Fixed-Point records at the **function layer**. You choose exactly which functions to pin down with `@recordable`, and the cassette only contains the args and return values — nothing more.
121
+
122
+ ### vs unittest.mock / monkeypatch
123
+
124
+ Mocking is powerful, but it comes at a cost:
125
+
126
+ * You have to **manually write** the return values. Guess wrong and your mock drifts from reality.
127
+ * As your code evolves, you spend more time maintaining mocks than writing actual tests.
128
+ * Mocks tell you nothing about what the real function actually returned — they only tell you what you *assumed* it would return.
129
+
130
+ Fixed-Point records **real outputs** on the first run. No guessing, no hand-crafting fixtures. When reality changes, just `--fixedpoint=rewrite` and you're back in sync.
131
+
132
+ ### Summary
133
+
134
+ | | VCR.py | mock | fixed-point |
135
+ |---|--------|------|-------------|
136
+ | Records at | HTTP layer | N/A (manual) | Function layer |
137
+ | Setup effort | Low | High | Low |
138
+ | Cassette noise | High (headers, cookies, etc.) | N/A | Low (args + return only) |
139
+ | Stays in sync with reality | Fragile | Drifts over time | `rewrite` to refresh |
140
+
141
+ ## Why fixed-point?
142
+
143
+ * In math, a fixed point is a value that stays the same no matter what function you throw at it. This project does the same for your tests — run them once, pin the result, replay forever.
144
+ * But honestly, I named it "fixed-point" because of my wife. She's my fixed point — always cute, always kind, and somehow always right. No matter how chaotic things get, she's the constant I can count on.
145
+ * So this one's for her. And if it makes your tests less flaky along the way, that's a nice bonus too.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "setuptools-scm>=6.2"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pytest-fixedpoint"
7
+ dynamic = ["version"]
8
+ description = "Pytest plugin for recording and replaying deterministic function calls"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ dependencies = [
12
+ "pytest>=7.0",
13
+ "pyyaml>=6.0",
14
+ ]
15
+ classifiers = [
16
+ "Framework :: Pytest",
17
+ "Programming Language :: Python :: 3",
18
+ ]
19
+
20
+ [project.entry-points.pytest11]
21
+ fixedpoint = "fixedpoint.plugin"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
25
+
26
+ [tool.setuptools_scm]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+
31
+ [tool.ruff]
32
+ target-version = "py310"
33
+ line-length = 88
34
+
35
+ [tool.ruff.lint]
36
+ select = [
37
+ "E", # pycodestyle errors
38
+ "W", # pycodestyle warnings
39
+ "F", # pyflakes
40
+ "I", # isort
41
+ "UP", # pyupgrade
42
+ "B", # flake8-bugbear
43
+ "SIM", # flake8-simplify
44
+ ]
45
+
46
+ [tool.ruff.lint.isort]
47
+ known-first-party = ["fixedpoint"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,28 @@
1
+ from fixedpoint._registry import recordable
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
6
+ class FixedPointError(Exception):
7
+ pass
8
+
9
+
10
+ class CassetteNotFoundError(FixedPointError):
11
+ pass
12
+
13
+
14
+ class CassetteMismatchError(FixedPointError):
15
+ pass
16
+
17
+
18
+ class SerializationError(FixedPointError):
19
+ pass
20
+
21
+
22
+ __all__ = [
23
+ "recordable",
24
+ "FixedPointError",
25
+ "CassetteNotFoundError",
26
+ "CassetteMismatchError",
27
+ "SerializationError",
28
+ ]
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from fixedpoint._serializer import deserialize_value, serialize_value
9
+ from fixedpoint._types import CallRecord, CassetteData
10
+
11
+
12
+ class Cassette:
13
+ def __init__(self, path: Path) -> None:
14
+ self._path = path
15
+ self._data = CassetteData()
16
+ self._replay_counters: dict[str, int] = {}
17
+ self._dirty = False
18
+
19
+ @classmethod
20
+ def load(cls, path: Path) -> Cassette:
21
+ c = cls(path)
22
+ raw = yaml.safe_load(path.read_text())
23
+ if not isinstance(raw, dict):
24
+ _raise_mismatch(f"Invalid cassette format in {path}")
25
+ version = raw.get("version", 1)
26
+ c._data.version = version
27
+ for func_key, entries in raw.get("calls", {}).items():
28
+ records = []
29
+ for entry in entries:
30
+ records.append(
31
+ CallRecord(
32
+ args=entry["args"],
33
+ kwargs=entry.get("kwargs", {}),
34
+ return_value=entry["return"],
35
+ )
36
+ )
37
+ c._data.calls[func_key] = records
38
+ return c
39
+
40
+ def has_calls(self, func_key: str) -> bool:
41
+ calls = self._data.calls.get(func_key)
42
+ if not calls:
43
+ return False
44
+ idx = self._replay_counters.get(func_key, 0)
45
+ return idx < len(calls)
46
+
47
+ def record_call(
48
+ self, func_key: str, args: tuple, kwargs: dict, return_value: Any
49
+ ) -> None:
50
+ record = CallRecord(
51
+ args=serialize_value(args),
52
+ kwargs=serialize_value(kwargs),
53
+ return_value=serialize_value(return_value),
54
+ )
55
+ self._data.calls.setdefault(func_key, []).append(record)
56
+ self._dirty = True
57
+
58
+ def replay_call(self, func_key: str, args: tuple, kwargs: dict) -> Any:
59
+ calls = self._data.calls.get(func_key)
60
+ if not calls:
61
+ _raise_mismatch(f"No recorded calls for {func_key}")
62
+ idx = self._replay_counters.get(func_key, 0)
63
+ if idx >= len(calls):
64
+ _raise_mismatch(
65
+ f"Too many calls to {func_key}: expected {len(calls)}, "
66
+ f"got call #{idx + 1}"
67
+ )
68
+ record = calls[idx]
69
+ serialized_args = serialize_value(args)
70
+ serialized_kwargs = serialize_value(kwargs)
71
+ if serialized_args != record.args or serialized_kwargs != record.kwargs:
72
+ _raise_mismatch(
73
+ f"Argument mismatch for {func_key} call #{idx + 1}.\n"
74
+ f" Expected args={record.args} kwargs={record.kwargs}\n"
75
+ f" Got args={serialized_args} kwargs={serialized_kwargs}\n"
76
+ f" Hint: Run with --fixedpoint=rewrite to re-record."
77
+ )
78
+ self._replay_counters[func_key] = idx + 1
79
+ return deserialize_value(record.return_value)
80
+
81
+ def save(self) -> None:
82
+ if not self._dirty:
83
+ return
84
+ self._path.parent.mkdir(parents=True, exist_ok=True)
85
+ raw: dict = {
86
+ "version": self._data.version,
87
+ "calls": {
88
+ key: [
89
+ {
90
+ "args": r.args,
91
+ "kwargs": r.kwargs,
92
+ "return": r.return_value,
93
+ }
94
+ for r in records
95
+ ]
96
+ for key, records in self._data.calls.items()
97
+ },
98
+ }
99
+ self._path.write_text(yaml.dump(raw, default_flow_style=False, sort_keys=False))
100
+
101
+
102
+ def _raise_mismatch(msg: str) -> None:
103
+ from fixedpoint import CassetteMismatchError
104
+
105
+ raise CassetteMismatchError(msg)
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fixedpoint._cassette import Cassette
6
+ from fixedpoint._registry import set_active_interceptor
7
+
8
+
9
+ class Interceptor:
10
+ def __init__(self, cassette: Cassette, mode: str) -> None:
11
+ self._cassette = cassette
12
+ self._mode = mode
13
+
14
+ def install(self) -> None:
15
+ set_active_interceptor(self)
16
+
17
+ def uninstall(self) -> None:
18
+ set_active_interceptor(None)
19
+
20
+ def intercept(self, key: str, original: Any, args: tuple, kwargs: dict) -> Any:
21
+ mode = self._mode
22
+ cassette = self._cassette
23
+
24
+ if mode in ("replay", "record_once") and cassette.has_calls(key):
25
+ return cassette.replay_call(key, args, kwargs)
26
+ if mode in ("record_once", "rewrite"):
27
+ result = original(*args, **kwargs)
28
+ cassette.record_call(key, args, kwargs, result)
29
+ return result
30
+ if mode == "replay":
31
+ from fixedpoint import CassetteNotFoundError
32
+
33
+ raise CassetteNotFoundError(
34
+ f"No cassette entry for {key} and mode is 'replay'.\n"
35
+ f" Hint: Run with --fixedpoint=record_once to create it."
36
+ )
37
+ return original(*args, **kwargs)
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import warnings
5
+ from collections.abc import Callable
6
+ from typing import Any, TypeVar
7
+
8
+ _REGISTRY: dict[str, Callable] = {}
9
+ _active_interceptor: Any = None
10
+
11
+ F = TypeVar("F", bound=Callable)
12
+
13
+
14
+ def recordable(fn: F) -> F:
15
+ key = f"{fn.__module__}.{fn.__qualname__}"
16
+ if "<locals>" in key:
17
+ warnings.warn(
18
+ f"fixedpoint: {key} is a locally-defined function. "
19
+ "Cassettes may break if the enclosing function is renamed.",
20
+ stacklevel=2,
21
+ )
22
+
23
+ @functools.wraps(fn)
24
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
25
+ interceptor = _active_interceptor
26
+ if interceptor is not None:
27
+ return interceptor.intercept(key, fn, args, kwargs)
28
+ return fn(*args, **kwargs)
29
+
30
+ wrapper.__wrapped__ = fn # type: ignore[attr-defined]
31
+ wrapper.__fixedpoint_recordable__ = True # type: ignore[attr-defined]
32
+ wrapper.__fixedpoint_key__ = key # type: ignore[attr-defined]
33
+ _REGISTRY[key] = fn
34
+ return wrapper # type: ignore[return-value]
35
+
36
+
37
+ def get_registry() -> dict[str, Callable]:
38
+ return _REGISTRY
39
+
40
+
41
+ def clear_registry() -> None:
42
+ _REGISTRY.clear()
43
+
44
+
45
+ def set_active_interceptor(interceptor: Any) -> None:
46
+ global _active_interceptor
47
+ _active_interceptor = interceptor
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import dataclasses
5
+ import importlib
6
+ from typing import Any
7
+
8
+
9
+ def serialize_value(value: Any) -> Any:
10
+ if value is None or isinstance(value, (bool, int, float, str)):
11
+ return value
12
+ if isinstance(value, bytes):
13
+ return {"__bytes__": base64.b64encode(value).decode("ascii")}
14
+ if isinstance(value, tuple):
15
+ return {"__tuple__": [serialize_value(v) for v in value]}
16
+ if isinstance(value, set):
17
+ return {"__set__": sorted(serialize_value(v) for v in value)}
18
+ if isinstance(value, list):
19
+ return [serialize_value(v) for v in value]
20
+ if isinstance(value, dict):
21
+ for k in value:
22
+ if not isinstance(k, str):
23
+ _raise(f"Dict keys must be strings, got {type(k).__name__}")
24
+ return {k: serialize_value(v) for k, v in value.items()}
25
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
26
+ cls = type(value)
27
+ data: dict[str, Any] = {
28
+ "__dataclass__": f"{cls.__module__}.{cls.__qualname__}",
29
+ }
30
+ for f in dataclasses.fields(value):
31
+ data[f.name] = serialize_value(getattr(value, f.name))
32
+ return data
33
+ _raise(f"Cannot serialize type {type(value).__qualname__}")
34
+
35
+
36
+ def deserialize_value(data: Any) -> Any:
37
+ if data is None or isinstance(data, (bool, int, float, str)):
38
+ return data
39
+ if isinstance(data, list):
40
+ return [deserialize_value(v) for v in data]
41
+ if isinstance(data, dict):
42
+ if "__bytes__" in data:
43
+ return base64.b64decode(data["__bytes__"])
44
+ if "__tuple__" in data:
45
+ return tuple(deserialize_value(v) for v in data["__tuple__"])
46
+ if "__set__" in data:
47
+ return {deserialize_value(v) for v in data["__set__"]}
48
+ if "__dataclass__" in data:
49
+ fqn = data["__dataclass__"]
50
+ module_name, _, class_name = fqn.rpartition(".")
51
+ mod = importlib.import_module(module_name)
52
+ cls = getattr(mod, class_name)
53
+ fields = {
54
+ f.name: deserialize_value(data[f.name]) for f in dataclasses.fields(cls)
55
+ }
56
+ return cls(**fields)
57
+ return {k: deserialize_value(v) for k, v in data.items()}
58
+ _raise(f"Cannot deserialize type {type(data).__qualname__}")
59
+
60
+
61
+ def _raise(msg: str) -> None:
62
+ from fixedpoint import SerializationError
63
+
64
+ raise SerializationError(msg)
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class CallRecord:
9
+ args: Any
10
+ kwargs: Any
11
+ return_value: Any
12
+
13
+
14
+ @dataclass
15
+ class CassetteData:
16
+ version: int = 1
17
+ calls: dict[str, list[CallRecord]] = field(default_factory=dict)