fixturify 0.1.9__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.
- fixturify/__init__.py +21 -0
- fixturify/_utils/__init__.py +7 -0
- fixturify/_utils/_constants.py +10 -0
- fixturify/_utils/_fixture_discovery.py +165 -0
- fixturify/_utils/_path_resolver.py +135 -0
- fixturify/http_d/__init__.py +80 -0
- fixturify/http_d/_config.py +214 -0
- fixturify/http_d/_decorator.py +267 -0
- fixturify/http_d/_exceptions.py +153 -0
- fixturify/http_d/_fixture_discovery.py +33 -0
- fixturify/http_d/_matcher.py +372 -0
- fixturify/http_d/_mock_context.py +154 -0
- fixturify/http_d/_models.py +205 -0
- fixturify/http_d/_patcher.py +524 -0
- fixturify/http_d/_player.py +222 -0
- fixturify/http_d/_recorder.py +1350 -0
- fixturify/http_d/_stubs/__init__.py +8 -0
- fixturify/http_d/_stubs/_aiohttp.py +220 -0
- fixturify/http_d/_stubs/_connection.py +478 -0
- fixturify/http_d/_stubs/_httpcore.py +269 -0
- fixturify/http_d/_stubs/_tornado.py +95 -0
- fixturify/http_d/_utils.py +194 -0
- fixturify/json_assert/__init__.py +13 -0
- fixturify/json_assert/_actual_saver.py +67 -0
- fixturify/json_assert/_assert.py +173 -0
- fixturify/json_assert/_comparator.py +183 -0
- fixturify/json_assert/_diff_formatter.py +265 -0
- fixturify/json_assert/_normalizer.py +83 -0
- fixturify/object_mapper/__init__.py +5 -0
- fixturify/object_mapper/_deserializers/__init__.py +19 -0
- fixturify/object_mapper/_deserializers/_base.py +186 -0
- fixturify/object_mapper/_deserializers/_dataclass.py +52 -0
- fixturify/object_mapper/_deserializers/_plain.py +55 -0
- fixturify/object_mapper/_deserializers/_pydantic_v1.py +38 -0
- fixturify/object_mapper/_deserializers/_pydantic_v2.py +41 -0
- fixturify/object_mapper/_deserializers/_sqlalchemy.py +72 -0
- fixturify/object_mapper/_deserializers/_sqlmodel.py +43 -0
- fixturify/object_mapper/_detectors/__init__.py +5 -0
- fixturify/object_mapper/_detectors/_type_detector.py +186 -0
- fixturify/object_mapper/_serializers/__init__.py +19 -0
- fixturify/object_mapper/_serializers/_base.py +260 -0
- fixturify/object_mapper/_serializers/_dataclass.py +55 -0
- fixturify/object_mapper/_serializers/_plain.py +49 -0
- fixturify/object_mapper/_serializers/_pydantic_v1.py +49 -0
- fixturify/object_mapper/_serializers/_pydantic_v2.py +49 -0
- fixturify/object_mapper/_serializers/_sqlalchemy.py +70 -0
- fixturify/object_mapper/_serializers/_sqlmodel.py +54 -0
- fixturify/object_mapper/mapper.py +256 -0
- fixturify/read_d/__init__.py +5 -0
- fixturify/read_d/_decorator.py +193 -0
- fixturify/read_d/_fixture_loader.py +88 -0
- fixturify/sql_d/__init__.py +7 -0
- fixturify/sql_d/_config.py +30 -0
- fixturify/sql_d/_decorator.py +373 -0
- fixturify/sql_d/_driver_registry.py +133 -0
- fixturify/sql_d/_executor.py +82 -0
- fixturify/sql_d/_fixture_discovery.py +55 -0
- fixturify/sql_d/_phase.py +10 -0
- fixturify/sql_d/_strategies/__init__.py +11 -0
- fixturify/sql_d/_strategies/_aiomysql.py +63 -0
- fixturify/sql_d/_strategies/_aiosqlite.py +29 -0
- fixturify/sql_d/_strategies/_asyncpg.py +34 -0
- fixturify/sql_d/_strategies/_base.py +118 -0
- fixturify/sql_d/_strategies/_mysql.py +70 -0
- fixturify/sql_d/_strategies/_psycopg.py +35 -0
- fixturify/sql_d/_strategies/_psycopg2.py +40 -0
- fixturify/sql_d/_strategies/_registry.py +109 -0
- fixturify/sql_d/_strategies/_sqlite.py +33 -0
- fixturify-0.1.9.dist-info/METADATA +122 -0
- fixturify-0.1.9.dist-info/RECORD +71 -0
- fixturify-0.1.9.dist-info/WHEEL +4 -0
fixturify/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""PyTools - A collection of reusable Python utility modules."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.8"
|
|
4
|
+
|
|
5
|
+
from fixturify.sql_d import sql, Phase, SqlTestConfig
|
|
6
|
+
from fixturify.read_d import read
|
|
7
|
+
from fixturify.http_d import http, HttpTestConfig
|
|
8
|
+
from fixturify.json_assert import JsonAssert
|
|
9
|
+
from fixturify.object_mapper import ObjectMapper
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"__version__",
|
|
13
|
+
"sql",
|
|
14
|
+
"Phase",
|
|
15
|
+
"SqlTestConfig",
|
|
16
|
+
"read",
|
|
17
|
+
"http",
|
|
18
|
+
"HttpTestConfig",
|
|
19
|
+
"JsonAssert",
|
|
20
|
+
"ObjectMapper",
|
|
21
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Internal utilities shared across modules."""
|
|
2
|
+
|
|
3
|
+
from fixturify._utils._constants import ENCODING
|
|
4
|
+
from fixturify._utils._fixture_discovery import find_fixture_by_type, is_pytest_available
|
|
5
|
+
from fixturify._utils._path_resolver import _PathResolver
|
|
6
|
+
|
|
7
|
+
__all__ = ["ENCODING", "_PathResolver", "find_fixture_by_type", "is_pytest_available"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Shared constants for PyTools modules."""
|
|
2
|
+
|
|
3
|
+
# All file I/O operations must use UTF-8 encoding for cross-platform consistency
|
|
4
|
+
ENCODING = "utf-8"
|
|
5
|
+
|
|
6
|
+
# Maximum depth for object traversal (prevents infinite recursion).
|
|
7
|
+
# This value is intentionally hardcoded at 100 for simplicity and predictability.
|
|
8
|
+
# Deeply nested structures beyond 100 levels typically indicate circular references
|
|
9
|
+
# or design issues that should be addressed differently.
|
|
10
|
+
MAX_DEPTH = 100
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Generic pytest fixture discovery by return type annotation.
|
|
2
|
+
|
|
3
|
+
This module provides a reusable fixture discovery mechanism that finds
|
|
4
|
+
pytest fixtures based on their return type annotation, not their name.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, Any, List, Optional, Type, TypeVar
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from _pytest.fixtures import FixtureRequest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_pytest_available() -> bool:
|
|
17
|
+
"""Check if pytest is installed."""
|
|
18
|
+
try:
|
|
19
|
+
import pytest # noqa: F401
|
|
20
|
+
|
|
21
|
+
return True
|
|
22
|
+
except ImportError:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_fixture_by_type(
|
|
27
|
+
request: "FixtureRequest",
|
|
28
|
+
config_type: Type[T],
|
|
29
|
+
) -> Optional[T]:
|
|
30
|
+
"""
|
|
31
|
+
Find a pytest fixture by its return TYPE annotation.
|
|
32
|
+
|
|
33
|
+
Scans ALL available fixtures (not just function dependencies) for fixtures
|
|
34
|
+
that return the specified type. This allows defining config fixtures in
|
|
35
|
+
conftest.py without needing to add them as test parameters.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
request: Pytest's FixtureRequest object
|
|
39
|
+
config_type: The type to search for (e.g., SqlTestConfig, HttpTestConfig)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Instance of config_type if a matching fixture is found, None otherwise
|
|
43
|
+
"""
|
|
44
|
+
# First pass: check fixture type annotations to find matching fixtures
|
|
45
|
+
# This scans ALL available fixtures, not just function dependencies
|
|
46
|
+
candidate_fixtures = _get_fixture_names_by_type(request, config_type)
|
|
47
|
+
|
|
48
|
+
# If we found candidates by annotation, try those
|
|
49
|
+
if candidate_fixtures:
|
|
50
|
+
for fixture_name in candidate_fixtures:
|
|
51
|
+
try:
|
|
52
|
+
value = request.getfixturevalue(fixture_name)
|
|
53
|
+
if isinstance(value, config_type):
|
|
54
|
+
return value
|
|
55
|
+
except Exception:
|
|
56
|
+
# Fixture failed to resolve, try next candidate
|
|
57
|
+
continue
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
# Fallback: iterate through function's fixture dependencies
|
|
61
|
+
# This maintains backward compatibility with unannotated fixtures
|
|
62
|
+
for fixture_name in request.fixturenames:
|
|
63
|
+
try:
|
|
64
|
+
value = request.getfixturevalue(fixture_name)
|
|
65
|
+
if isinstance(value, config_type):
|
|
66
|
+
return value
|
|
67
|
+
except Exception:
|
|
68
|
+
# Non-candidate fixture failed to resolve, skip it
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_fixture_names_by_type(
|
|
75
|
+
request: "FixtureRequest",
|
|
76
|
+
config_type: Type[T],
|
|
77
|
+
) -> List[str]:
|
|
78
|
+
"""
|
|
79
|
+
Get fixture names that are annotated as returning the specified type.
|
|
80
|
+
|
|
81
|
+
Scans ALL available fixtures in the fixture manager, not just the ones
|
|
82
|
+
in the function's dependency list.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
request: Pytest's FixtureRequest object
|
|
86
|
+
config_type: The type to search for
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
List of fixture names with matching return annotation
|
|
90
|
+
"""
|
|
91
|
+
candidates: List[str] = []
|
|
92
|
+
|
|
93
|
+
# Access the fixture manager to get all fixture definitions
|
|
94
|
+
fixturemanager = getattr(request, "_fixturemanager", None)
|
|
95
|
+
if fixturemanager is None:
|
|
96
|
+
return candidates
|
|
97
|
+
|
|
98
|
+
# Get the node for fixture resolution (getfixturedefs needs node, not nodeid)
|
|
99
|
+
node = getattr(request, "node", None)
|
|
100
|
+
if node is None:
|
|
101
|
+
return candidates
|
|
102
|
+
|
|
103
|
+
# _arg2fixturedefs contains ALL registered fixtures
|
|
104
|
+
# This is an internal pytest API but is stable
|
|
105
|
+
arg2fixturedefs = getattr(fixturemanager, "_arg2fixturedefs", None)
|
|
106
|
+
if arg2fixturedefs is None:
|
|
107
|
+
# Fallback to just checking fixturenames
|
|
108
|
+
return _get_from_fixturenames(request, fixturemanager, node, config_type)
|
|
109
|
+
|
|
110
|
+
# Scan ALL fixtures for ones that return the target type
|
|
111
|
+
for fixture_name in arg2fixturedefs.keys():
|
|
112
|
+
try:
|
|
113
|
+
# Get fixture definition(s) for this name
|
|
114
|
+
fixturedefs = fixturemanager.getfixturedefs(fixture_name, node)
|
|
115
|
+
if not fixturedefs:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Check the most recent fixture definition
|
|
119
|
+
fixturedef = fixturedefs[-1]
|
|
120
|
+
func = fixturedef.func
|
|
121
|
+
|
|
122
|
+
# Check return annotation
|
|
123
|
+
annotations = getattr(func, "__annotations__", {})
|
|
124
|
+
return_type = annotations.get("return")
|
|
125
|
+
|
|
126
|
+
if return_type is config_type:
|
|
127
|
+
candidates.append(fixture_name)
|
|
128
|
+
except Exception:
|
|
129
|
+
# Can't inspect this fixture, skip
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
return candidates
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _get_from_fixturenames(
|
|
136
|
+
request: "FixtureRequest",
|
|
137
|
+
fixturemanager: Any,
|
|
138
|
+
node: Any,
|
|
139
|
+
config_type: Type[T],
|
|
140
|
+
) -> List[str]:
|
|
141
|
+
"""
|
|
142
|
+
Fallback: Get fixtures from function's fixturenames only.
|
|
143
|
+
|
|
144
|
+
Used when _arg2fixturedefs is not available.
|
|
145
|
+
"""
|
|
146
|
+
candidates: List[str] = []
|
|
147
|
+
|
|
148
|
+
for fixture_name in getattr(request, "fixturenames", []):
|
|
149
|
+
try:
|
|
150
|
+
fixturedefs = fixturemanager.getfixturedefs(fixture_name, node)
|
|
151
|
+
if not fixturedefs:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
fixturedef = fixturedefs[-1]
|
|
155
|
+
func = fixturedef.func
|
|
156
|
+
|
|
157
|
+
annotations = getattr(func, "__annotations__", {})
|
|
158
|
+
return_type = annotations.get("return")
|
|
159
|
+
|
|
160
|
+
if return_type is config_type:
|
|
161
|
+
candidates.append(fixture_name)
|
|
162
|
+
except Exception:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
return candidates
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Path resolution utility for relative paths based on caller's file location."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Patterns to skip when walking the stack (debuggers, frameworks, internals)
|
|
9
|
+
_SKIP_PATH_PATTERNS = (
|
|
10
|
+
# VS Code debugger
|
|
11
|
+
"debugpy",
|
|
12
|
+
"_debugpy",
|
|
13
|
+
# PyCharm debugger
|
|
14
|
+
"pydevd",
|
|
15
|
+
"_pydevd",
|
|
16
|
+
"_pydev_",
|
|
17
|
+
"pydev",
|
|
18
|
+
# Frameworks
|
|
19
|
+
"site-packages",
|
|
20
|
+
# Python internals
|
|
21
|
+
"importlib",
|
|
22
|
+
"<frozen",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _PathResolver:
|
|
27
|
+
"""Resolves relative paths based on a reference file's location."""
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def resolve(relative_path: str, reference_file: Optional[str] = None) -> Path:
|
|
31
|
+
"""
|
|
32
|
+
Resolve a relative path based on a reference file's location.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
relative_path: Path like "../fixtures/data.json"
|
|
36
|
+
reference_file: Absolute path to the reference file (e.g., test file).
|
|
37
|
+
If None, uses the caller's file location.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Absolute Path object to the resolved file
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
FileNotFoundError: If resolved path doesn't exist
|
|
44
|
+
"""
|
|
45
|
+
if reference_file is None:
|
|
46
|
+
# Get caller's file path (skip this function and the immediate caller)
|
|
47
|
+
reference_file = _PathResolver._get_caller_file(stack_level=2)
|
|
48
|
+
|
|
49
|
+
reference_dir = Path(reference_file).parent
|
|
50
|
+
resolved = (reference_dir / relative_path).resolve()
|
|
51
|
+
|
|
52
|
+
if not resolved.exists():
|
|
53
|
+
raise FileNotFoundError(
|
|
54
|
+
f"File not found: {resolved} "
|
|
55
|
+
f"(resolved from '{relative_path}' relative to '{reference_file}')"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return resolved
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def resolve_without_check(
|
|
62
|
+
relative_path: str, reference_file: Optional[str] = None
|
|
63
|
+
) -> Path:
|
|
64
|
+
"""
|
|
65
|
+
Resolve a relative path without checking if the file exists.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
relative_path: Path like "../fixtures/data.json"
|
|
69
|
+
reference_file: Absolute path to the reference file.
|
|
70
|
+
If None, uses the caller's file location.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Absolute Path object to the resolved file (may not exist)
|
|
74
|
+
"""
|
|
75
|
+
if reference_file is None:
|
|
76
|
+
reference_file = _PathResolver._get_caller_file(stack_level=2)
|
|
77
|
+
|
|
78
|
+
reference_dir = Path(reference_file).parent
|
|
79
|
+
return (reference_dir / relative_path).resolve()
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _get_caller_file(stack_level: int = 1) -> str:
|
|
83
|
+
"""
|
|
84
|
+
Get the file path of the caller, skipping internal and debugger frames.
|
|
85
|
+
|
|
86
|
+
This method walks the stack and filters out:
|
|
87
|
+
- pytools internal frames (detected via module name, not file path)
|
|
88
|
+
- IDE debugger frames (VS Code debugpy, PyCharm pydevd)
|
|
89
|
+
- Framework frames (site-packages, importlib)
|
|
90
|
+
|
|
91
|
+
Using module names for pytools detection makes this independent of
|
|
92
|
+
installation location (works with editable installs, wheels, etc.)
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
stack_level: Number of valid caller frames to skip (1 = immediate caller)
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Absolute path to the caller's file
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
RuntimeError: If caller's file cannot be determined
|
|
102
|
+
"""
|
|
103
|
+
frame = inspect.currentframe()
|
|
104
|
+
try:
|
|
105
|
+
# Skip this function's frame
|
|
106
|
+
frame = frame.f_back
|
|
107
|
+
valid_frames_found = 0
|
|
108
|
+
|
|
109
|
+
while frame is not None:
|
|
110
|
+
filename = inspect.getfile(frame)
|
|
111
|
+
file_str = str(Path(filename).resolve())
|
|
112
|
+
|
|
113
|
+
# Get module name from frame globals (reliable way to identify package)
|
|
114
|
+
module_name = frame.f_globals.get("__name__", "")
|
|
115
|
+
|
|
116
|
+
# Skip pytools internal frames (using module name, not file path)
|
|
117
|
+
if module_name.startswith("fixturify.") or module_name == "fixturify":
|
|
118
|
+
frame = frame.f_back
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# Skip debugger and framework frames (using file path patterns)
|
|
122
|
+
if any(pattern in file_str for pattern in _SKIP_PATH_PATTERNS):
|
|
123
|
+
frame = frame.f_back
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Found a valid caller frame
|
|
127
|
+
valid_frames_found += 1
|
|
128
|
+
if valid_frames_found >= stack_level:
|
|
129
|
+
return file_str
|
|
130
|
+
|
|
131
|
+
frame = frame.f_back
|
|
132
|
+
|
|
133
|
+
raise RuntimeError("Cannot determine caller's file location")
|
|
134
|
+
finally:
|
|
135
|
+
del frame
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""HTTP mock decorator for recording and replaying HTTP interactions.
|
|
2
|
+
|
|
3
|
+
This module provides a test decorator that automatically records HTTP calls
|
|
4
|
+
on the first run and replays them on subsequent runs, making tests fast
|
|
5
|
+
and deterministic.
|
|
6
|
+
|
|
7
|
+
Supported HTTP clients:
|
|
8
|
+
- httpx (sync and async)
|
|
9
|
+
- requests
|
|
10
|
+
- urllib3
|
|
11
|
+
- http.client (stdlib)
|
|
12
|
+
- aiohttp (async)
|
|
13
|
+
- httplib2
|
|
14
|
+
- tornado (sync and async)
|
|
15
|
+
- boto3/botocore (AWS SDK)
|
|
16
|
+
- httpcore (low-level, used by httpx)
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
from fixturify.http_d import http
|
|
20
|
+
|
|
21
|
+
@http(path="./fixtures/api_calls.json")
|
|
22
|
+
def test_api_integration():
|
|
23
|
+
response = requests.get("https://api.example.com/users")
|
|
24
|
+
assert response.status_code == 200
|
|
25
|
+
|
|
26
|
+
# With config for header control
|
|
27
|
+
from fixturify.http_d import http, HttpTestConfig
|
|
28
|
+
|
|
29
|
+
config = HttpTestConfig(
|
|
30
|
+
ignore_request_headers=["Authorization"],
|
|
31
|
+
exclude_request_headers=["Authorization", "X-API-Key"],
|
|
32
|
+
exclude_response_headers=["Set-Cookie"],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@http(path="./fixtures/api.json", config=config)
|
|
36
|
+
def test_with_auth():
|
|
37
|
+
response = requests.get(
|
|
38
|
+
"https://api.example.com/data",
|
|
39
|
+
headers={"Authorization": "Bearer secret"}
|
|
40
|
+
)
|
|
41
|
+
assert response.status_code == 200
|
|
42
|
+
|
|
43
|
+
# Or use pytest fixture for auto-discovery
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def http_config() -> HttpTestConfig:
|
|
46
|
+
return HttpTestConfig(
|
|
47
|
+
ignore_request_headers=["Authorization"],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@http(path="./fixtures/api.json") # Config auto-discovered from fixture
|
|
51
|
+
def test_with_fixture_config():
|
|
52
|
+
...
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from fixturify.http_d._config import HttpTestConfig
|
|
56
|
+
from fixturify.http_d._decorator import http
|
|
57
|
+
from fixturify.http_d._exceptions import (
|
|
58
|
+
HttpMockError,
|
|
59
|
+
NoMatchingRecordingError,
|
|
60
|
+
RequestMismatchError,
|
|
61
|
+
UnusedRecordingsError,
|
|
62
|
+
)
|
|
63
|
+
from fixturify.http_d._models import HttpMapping, HttpMappings, HttpRequest, HttpResponse
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
# Main decorator
|
|
67
|
+
"http",
|
|
68
|
+
# Configuration
|
|
69
|
+
"HttpTestConfig",
|
|
70
|
+
# Exceptions
|
|
71
|
+
"HttpMockError",
|
|
72
|
+
"NoMatchingRecordingError",
|
|
73
|
+
"RequestMismatchError",
|
|
74
|
+
"UnusedRecordingsError",
|
|
75
|
+
# Models (for advanced usage)
|
|
76
|
+
"HttpRequest",
|
|
77
|
+
"HttpResponse",
|
|
78
|
+
"HttpMapping",
|
|
79
|
+
"HttpMappings",
|
|
80
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""HTTP mock configuration."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Set
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Default headers to ignore during request matching
|
|
8
|
+
DEFAULT_IGNORE_REQUEST_HEADERS: Set[str] = {
|
|
9
|
+
"user-agent",
|
|
10
|
+
"date",
|
|
11
|
+
"accept-encoding",
|
|
12
|
+
"content-length",
|
|
13
|
+
"connection",
|
|
14
|
+
"host",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# AWS signing headers to ignore when aws_mode is enabled
|
|
18
|
+
# These headers are dynamically generated and differ between requests
|
|
19
|
+
DEFAULT_IGNORE_REQUEST_HEADERS_AWS: Set[str] = {
|
|
20
|
+
"authorization",
|
|
21
|
+
"x-amz-date",
|
|
22
|
+
"x-amz-security-token",
|
|
23
|
+
"x-amz-content-sha256",
|
|
24
|
+
# SDK tracking headers - change with each request
|
|
25
|
+
"amz-sdk-invocation-id",
|
|
26
|
+
"amz-sdk-request",
|
|
27
|
+
# Checksum headers - can vary between requests
|
|
28
|
+
"x-amz-checksum-crc32",
|
|
29
|
+
"x-amz-checksum-crc32c",
|
|
30
|
+
"x-amz-checksum-sha1",
|
|
31
|
+
"x-amz-checksum-sha256",
|
|
32
|
+
"x-amz-sdk-checksum-algorithm",
|
|
33
|
+
# Expect header used for large uploads
|
|
34
|
+
"expect",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Default headers to exclude from recording (not saved to file)
|
|
38
|
+
DEFAULT_EXCLUDE_REQUEST_HEADERS: Set[str] = {
|
|
39
|
+
"user-agent",
|
|
40
|
+
"accept-encoding",
|
|
41
|
+
"connection",
|
|
42
|
+
"host",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
DEFAULT_EXCLUDE_RESPONSE_HEADERS: Set[str] = {
|
|
46
|
+
"date",
|
|
47
|
+
"server",
|
|
48
|
+
"x-request-id",
|
|
49
|
+
"x-correlation-id",
|
|
50
|
+
"set-cookie",
|
|
51
|
+
"transfer-encoding",
|
|
52
|
+
"content-length",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class HttpTestConfig:
|
|
58
|
+
"""
|
|
59
|
+
Configuration for HTTP mock decorator.
|
|
60
|
+
|
|
61
|
+
Controls how requests are matched during playback and which headers
|
|
62
|
+
are recorded/compared.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
ignore_request_headers: Headers to ignore when matching requests.
|
|
66
|
+
These headers can differ between actual and recorded requests.
|
|
67
|
+
Default: User-Agent, Date, Accept-Encoding, Content-Length,
|
|
68
|
+
Connection, Host.
|
|
69
|
+
|
|
70
|
+
ignore_response_headers: Headers to ignore when comparing responses
|
|
71
|
+
(for future use in response validation).
|
|
72
|
+
|
|
73
|
+
exclude_request_headers: Headers to exclude from recordings entirely.
|
|
74
|
+
These headers won't be saved to the JSON file.
|
|
75
|
+
Default: User-Agent, Accept-Encoding, Connection, Host.
|
|
76
|
+
|
|
77
|
+
exclude_response_headers: Headers to exclude from response recordings.
|
|
78
|
+
These headers won't be saved to the JSON file.
|
|
79
|
+
Default: Date, Server, X-Request-ID, X-Correlation-ID,
|
|
80
|
+
Set-Cookie, Transfer-Encoding, Content-Length.
|
|
81
|
+
|
|
82
|
+
exclude_hosts: Hosts to exclude from recording and playback entirely.
|
|
83
|
+
Requests to these hosts will ALWAYS make real HTTP calls,
|
|
84
|
+
bypassing recording and playback. Useful for local test servers
|
|
85
|
+
like FastAPI's TestClient (host: "testserver").
|
|
86
|
+
Example: ["testserver", "localhost:8000"]
|
|
87
|
+
|
|
88
|
+
match_request_body: Whether to include request body in matching.
|
|
89
|
+
Default: True.
|
|
90
|
+
|
|
91
|
+
strict_order: Whether requests must occur in the same order as
|
|
92
|
+
recorded. Default: False.
|
|
93
|
+
|
|
94
|
+
redact_request_body: JSON fields to redact in request bodies during
|
|
95
|
+
recording. Any matching field values are replaced with "*******".
|
|
96
|
+
These fields are also ignored during request body comparison in
|
|
97
|
+
playback. Default: [].
|
|
98
|
+
|
|
99
|
+
redact_response_body: JSON fields to redact in response bodies during
|
|
100
|
+
recording. Any matching field values are replaced with "*******".
|
|
101
|
+
Default: [].
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
# Create config to ignore auth headers
|
|
105
|
+
config = HttpTestConfig(
|
|
106
|
+
ignore_request_headers=["Authorization", "X-API-Key"],
|
|
107
|
+
exclude_request_headers=["Authorization"], # Don't save to file
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@http(path="./fixtures/api.json", config=config)
|
|
111
|
+
def test_api():
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
# Exclude local test server from mocking
|
|
115
|
+
config = HttpTestConfig(
|
|
116
|
+
exclude_hosts=["testserver", "localhost:8000"],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@http(path="./fixtures/api.json", config=config)
|
|
120
|
+
def test_with_fastapi():
|
|
121
|
+
# Calls to testserver go through normally (real calls)
|
|
122
|
+
# Calls to other hosts are recorded/played back
|
|
123
|
+
...
|
|
124
|
+
|
|
125
|
+
# Or use as a pytest fixture for auto-discovery:
|
|
126
|
+
@pytest.fixture
|
|
127
|
+
def http_config() -> HttpTestConfig:
|
|
128
|
+
return HttpTestConfig(
|
|
129
|
+
ignore_request_headers=["Authorization"],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@http(path="./fixtures/api.json") # Config auto-discovered
|
|
133
|
+
def test_api():
|
|
134
|
+
...
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
# Headers to ignore when matching (can differ between actual and recorded)
|
|
138
|
+
ignore_request_headers: List[str] = field(default_factory=list)
|
|
139
|
+
ignore_response_headers: List[str] = field(default_factory=list)
|
|
140
|
+
|
|
141
|
+
# Headers to exclude from recording (not saved to file)
|
|
142
|
+
exclude_request_headers: List[str] = field(default_factory=list)
|
|
143
|
+
exclude_response_headers: List[str] = field(default_factory=list)
|
|
144
|
+
|
|
145
|
+
# Hosts to exclude from recording/playback (always make real calls)
|
|
146
|
+
exclude_hosts: List[str] = field(default_factory=list)
|
|
147
|
+
|
|
148
|
+
# Matching options
|
|
149
|
+
match_request_body: bool = True
|
|
150
|
+
strict_order: bool = False
|
|
151
|
+
|
|
152
|
+
# Redaction options
|
|
153
|
+
redact_request_body: List[str] = field(default_factory=list)
|
|
154
|
+
redact_response_body: List[str] = field(default_factory=list)
|
|
155
|
+
|
|
156
|
+
# AWS mode: automatically ignore AWS signing headers
|
|
157
|
+
aws_mode: bool = False
|
|
158
|
+
|
|
159
|
+
def get_ignore_request_headers_set(self) -> Set[str]:
|
|
160
|
+
"""Get full set of headers to ignore during matching."""
|
|
161
|
+
result = DEFAULT_IGNORE_REQUEST_HEADERS.copy()
|
|
162
|
+
result.update(h.lower() for h in self.ignore_request_headers)
|
|
163
|
+
if self.aws_mode:
|
|
164
|
+
result.update(DEFAULT_IGNORE_REQUEST_HEADERS_AWS)
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
def get_ignore_response_headers_set(self) -> Set[str]:
|
|
168
|
+
"""Get full set of response headers to ignore during matching."""
|
|
169
|
+
return {h.lower() for h in self.ignore_response_headers}
|
|
170
|
+
|
|
171
|
+
def get_exclude_request_headers_set(self) -> Set[str]:
|
|
172
|
+
"""Get full set of request headers to exclude from recording."""
|
|
173
|
+
result = DEFAULT_EXCLUDE_REQUEST_HEADERS.copy()
|
|
174
|
+
result.update(h.lower() for h in self.exclude_request_headers)
|
|
175
|
+
if self.aws_mode:
|
|
176
|
+
result.update(DEFAULT_IGNORE_REQUEST_HEADERS_AWS)
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
def get_exclude_response_headers_set(self) -> Set[str]:
|
|
180
|
+
"""Get full set of response headers to exclude from recording."""
|
|
181
|
+
result = DEFAULT_EXCLUDE_RESPONSE_HEADERS.copy()
|
|
182
|
+
result.update(h.lower() for h in self.exclude_response_headers)
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
def get_exclude_hosts_set(self) -> Set[str]:
|
|
186
|
+
"""Get set of hosts to exclude from recording/playback."""
|
|
187
|
+
return {h.lower() for h in self.exclude_hosts}
|
|
188
|
+
|
|
189
|
+
def is_host_excluded(self, url: str) -> bool:
|
|
190
|
+
"""
|
|
191
|
+
Check if a URL's host is in the excluded hosts list.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
url: Full URL string (e.g., "http://testserver/api/v1")
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
True if the host should be excluded from recording/playback.
|
|
198
|
+
"""
|
|
199
|
+
from urllib.parse import urlparse
|
|
200
|
+
|
|
201
|
+
exclude_hosts = self.get_exclude_hosts_set()
|
|
202
|
+
if not exclude_hosts:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
parsed = urlparse(url)
|
|
206
|
+
# Check both host and host:port
|
|
207
|
+
host = parsed.netloc.lower()
|
|
208
|
+
hostname = parsed.hostname.lower() if parsed.hostname else ""
|
|
209
|
+
|
|
210
|
+
return host in exclude_hosts or hostname in exclude_hosts
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# Default config instance
|
|
214
|
+
DEFAULT_CONFIG = HttpTestConfig()
|