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.
- pytest_fixedpoint-0.1.3/.github/workflows/ci.yml +32 -0
- pytest_fixedpoint-0.1.3/.github/workflows/python-publish.yml +68 -0
- pytest_fixedpoint-0.1.3/.gitignore +8 -0
- pytest_fixedpoint-0.1.3/.pre-commit-config.yaml +7 -0
- pytest_fixedpoint-0.1.3/PKG-INFO +10 -0
- pytest_fixedpoint-0.1.3/README.md +145 -0
- pytest_fixedpoint-0.1.3/pyproject.toml +47 -0
- pytest_fixedpoint-0.1.3/setup.cfg +4 -0
- pytest_fixedpoint-0.1.3/src/fixedpoint/__init__.py +28 -0
- pytest_fixedpoint-0.1.3/src/fixedpoint/_cassette.py +105 -0
- pytest_fixedpoint-0.1.3/src/fixedpoint/_interceptor.py +37 -0
- pytest_fixedpoint-0.1.3/src/fixedpoint/_registry.py +47 -0
- pytest_fixedpoint-0.1.3/src/fixedpoint/_serializer.py +64 -0
- pytest_fixedpoint-0.1.3/src/fixedpoint/_types.py +17 -0
- pytest_fixedpoint-0.1.3/src/fixedpoint/plugin.py +70 -0
- pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/PKG-INFO +10 -0
- pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/SOURCES.txt +28 -0
- pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/dependency_links.txt +1 -0
- pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/entry_points.txt +2 -0
- pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/requires.txt +2 -0
- pytest_fixedpoint-0.1.3/src/pytest_fixedpoint.egg-info/top_level.txt +1 -0
- pytest_fixedpoint-0.1.3/tests/__init__.py +0 -0
- pytest_fixedpoint-0.1.3/tests/conftest.py +1 -0
- pytest_fixedpoint-0.1.3/tests/sample_app/__init__.py +0 -0
- pytest_fixedpoint-0.1.3/tests/sample_app/math_funcs.py +20 -0
- pytest_fixedpoint-0.1.3/tests/test_cassette.py +119 -0
- pytest_fixedpoint-0.1.3/tests/test_interceptor.py +82 -0
- pytest_fixedpoint-0.1.3/tests/test_plugin.py +226 -0
- pytest_fixedpoint-0.1.3/tests/test_registry.py +54 -0
- 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,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,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)
|