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.
Files changed (71) hide show
  1. fixturify/__init__.py +21 -0
  2. fixturify/_utils/__init__.py +7 -0
  3. fixturify/_utils/_constants.py +10 -0
  4. fixturify/_utils/_fixture_discovery.py +165 -0
  5. fixturify/_utils/_path_resolver.py +135 -0
  6. fixturify/http_d/__init__.py +80 -0
  7. fixturify/http_d/_config.py +214 -0
  8. fixturify/http_d/_decorator.py +267 -0
  9. fixturify/http_d/_exceptions.py +153 -0
  10. fixturify/http_d/_fixture_discovery.py +33 -0
  11. fixturify/http_d/_matcher.py +372 -0
  12. fixturify/http_d/_mock_context.py +154 -0
  13. fixturify/http_d/_models.py +205 -0
  14. fixturify/http_d/_patcher.py +524 -0
  15. fixturify/http_d/_player.py +222 -0
  16. fixturify/http_d/_recorder.py +1350 -0
  17. fixturify/http_d/_stubs/__init__.py +8 -0
  18. fixturify/http_d/_stubs/_aiohttp.py +220 -0
  19. fixturify/http_d/_stubs/_connection.py +478 -0
  20. fixturify/http_d/_stubs/_httpcore.py +269 -0
  21. fixturify/http_d/_stubs/_tornado.py +95 -0
  22. fixturify/http_d/_utils.py +194 -0
  23. fixturify/json_assert/__init__.py +13 -0
  24. fixturify/json_assert/_actual_saver.py +67 -0
  25. fixturify/json_assert/_assert.py +173 -0
  26. fixturify/json_assert/_comparator.py +183 -0
  27. fixturify/json_assert/_diff_formatter.py +265 -0
  28. fixturify/json_assert/_normalizer.py +83 -0
  29. fixturify/object_mapper/__init__.py +5 -0
  30. fixturify/object_mapper/_deserializers/__init__.py +19 -0
  31. fixturify/object_mapper/_deserializers/_base.py +186 -0
  32. fixturify/object_mapper/_deserializers/_dataclass.py +52 -0
  33. fixturify/object_mapper/_deserializers/_plain.py +55 -0
  34. fixturify/object_mapper/_deserializers/_pydantic_v1.py +38 -0
  35. fixturify/object_mapper/_deserializers/_pydantic_v2.py +41 -0
  36. fixturify/object_mapper/_deserializers/_sqlalchemy.py +72 -0
  37. fixturify/object_mapper/_deserializers/_sqlmodel.py +43 -0
  38. fixturify/object_mapper/_detectors/__init__.py +5 -0
  39. fixturify/object_mapper/_detectors/_type_detector.py +186 -0
  40. fixturify/object_mapper/_serializers/__init__.py +19 -0
  41. fixturify/object_mapper/_serializers/_base.py +260 -0
  42. fixturify/object_mapper/_serializers/_dataclass.py +55 -0
  43. fixturify/object_mapper/_serializers/_plain.py +49 -0
  44. fixturify/object_mapper/_serializers/_pydantic_v1.py +49 -0
  45. fixturify/object_mapper/_serializers/_pydantic_v2.py +49 -0
  46. fixturify/object_mapper/_serializers/_sqlalchemy.py +70 -0
  47. fixturify/object_mapper/_serializers/_sqlmodel.py +54 -0
  48. fixturify/object_mapper/mapper.py +256 -0
  49. fixturify/read_d/__init__.py +5 -0
  50. fixturify/read_d/_decorator.py +193 -0
  51. fixturify/read_d/_fixture_loader.py +88 -0
  52. fixturify/sql_d/__init__.py +7 -0
  53. fixturify/sql_d/_config.py +30 -0
  54. fixturify/sql_d/_decorator.py +373 -0
  55. fixturify/sql_d/_driver_registry.py +133 -0
  56. fixturify/sql_d/_executor.py +82 -0
  57. fixturify/sql_d/_fixture_discovery.py +55 -0
  58. fixturify/sql_d/_phase.py +10 -0
  59. fixturify/sql_d/_strategies/__init__.py +11 -0
  60. fixturify/sql_d/_strategies/_aiomysql.py +63 -0
  61. fixturify/sql_d/_strategies/_aiosqlite.py +29 -0
  62. fixturify/sql_d/_strategies/_asyncpg.py +34 -0
  63. fixturify/sql_d/_strategies/_base.py +118 -0
  64. fixturify/sql_d/_strategies/_mysql.py +70 -0
  65. fixturify/sql_d/_strategies/_psycopg.py +35 -0
  66. fixturify/sql_d/_strategies/_psycopg2.py +40 -0
  67. fixturify/sql_d/_strategies/_registry.py +109 -0
  68. fixturify/sql_d/_strategies/_sqlite.py +33 -0
  69. fixturify-0.1.9.dist-info/METADATA +122 -0
  70. fixturify-0.1.9.dist-info/RECORD +71 -0
  71. 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()