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
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Stubs for aiohttp HTTP client.
|
|
2
|
+
|
|
3
|
+
aiohttp is an async HTTP client that doesn't use http.client,
|
|
4
|
+
so it needs its own patching strategy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
import json
|
|
9
|
+
from typing import TYPE_CHECKING, Dict
|
|
10
|
+
|
|
11
|
+
from .._models import HttpRequest, HttpResponse
|
|
12
|
+
from .._recorder import create_request_from_aiohttp, create_response_from_aiohttp
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .._mock_context import HttpMockContext
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MockAiohttpStream:
|
|
19
|
+
"""Mock stream that wraps a BytesIO for aiohttp compatibility."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, data: bytes):
|
|
22
|
+
from io import BytesIO
|
|
23
|
+
self._content = BytesIO(data)
|
|
24
|
+
|
|
25
|
+
async def read(self, n: int = -1) -> bytes:
|
|
26
|
+
return self._content.read(n)
|
|
27
|
+
|
|
28
|
+
def feed_data(self, data: bytes):
|
|
29
|
+
pos = self._content.tell()
|
|
30
|
+
self._content.seek(0, 2) # End
|
|
31
|
+
self._content.write(data)
|
|
32
|
+
self._content.seek(pos)
|
|
33
|
+
|
|
34
|
+
def feed_eof(self):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MockAiohttpResponse:
|
|
39
|
+
"""Mock aiohttp ClientResponse for playback."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, request_model: HttpRequest, response_model: HttpResponse):
|
|
42
|
+
self._request_model = request_model
|
|
43
|
+
self._response_model = response_model
|
|
44
|
+
self._body = response_model.get_body_bytes()
|
|
45
|
+
self._read = False
|
|
46
|
+
|
|
47
|
+
self.status = response_model.status
|
|
48
|
+
self.reason = self._get_reason(response_model.status)
|
|
49
|
+
self._headers = self._build_headers(response_model.headers)
|
|
50
|
+
self._history = ()
|
|
51
|
+
self.cookies = self._build_cookies()
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _get_reason(status: int) -> str:
|
|
55
|
+
from http import HTTPStatus
|
|
56
|
+
try:
|
|
57
|
+
return HTTPStatus(status).phrase
|
|
58
|
+
except ValueError:
|
|
59
|
+
return "Unknown"
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _build_headers(headers_dict: Dict[str, str]):
|
|
63
|
+
from multidict import CIMultiDict, CIMultiDictProxy
|
|
64
|
+
headers = CIMultiDict()
|
|
65
|
+
for k, v in headers_dict.items():
|
|
66
|
+
headers.add(k, v)
|
|
67
|
+
return CIMultiDictProxy(headers)
|
|
68
|
+
|
|
69
|
+
def _build_cookies(self):
|
|
70
|
+
from http.cookies import SimpleCookie
|
|
71
|
+
cookies = SimpleCookie()
|
|
72
|
+
for header_value in self._headers.getall("Set-Cookie", []):
|
|
73
|
+
try:
|
|
74
|
+
cookies.load(header_value)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
return cookies
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def headers(self):
|
|
81
|
+
return self._headers
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def content(self):
|
|
85
|
+
stream = MockAiohttpStream(self._body)
|
|
86
|
+
stream.feed_eof()
|
|
87
|
+
return stream
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def url(self):
|
|
91
|
+
from yarl import URL
|
|
92
|
+
return URL(self._request_model.url)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def request_info(self):
|
|
96
|
+
from aiohttp import RequestInfo
|
|
97
|
+
from yarl import URL
|
|
98
|
+
return RequestInfo(
|
|
99
|
+
url=URL(self._request_model.url),
|
|
100
|
+
method=self._request_model.method,
|
|
101
|
+
headers=self._build_headers(self._request_model.headers),
|
|
102
|
+
real_url=URL(self._request_model.url),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def history(self):
|
|
107
|
+
return self._history
|
|
108
|
+
|
|
109
|
+
async def read(self) -> bytes:
|
|
110
|
+
self._read = True
|
|
111
|
+
return self._body
|
|
112
|
+
|
|
113
|
+
async def text(self, encoding: str = "utf-8", errors: str = "strict") -> str:
|
|
114
|
+
return self._body.decode(encoding, errors=errors)
|
|
115
|
+
|
|
116
|
+
async def json(self, encoding: str = "utf-8", loads=json.loads, **kwargs):
|
|
117
|
+
text = self._body.decode(encoding)
|
|
118
|
+
stripped = text.strip()
|
|
119
|
+
if not stripped:
|
|
120
|
+
return None
|
|
121
|
+
return loads(stripped)
|
|
122
|
+
|
|
123
|
+
def release(self):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def close(self):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
async def __aenter__(self):
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _serialize_headers(headers) -> Dict[str, str]:
|
|
137
|
+
"""Serialize aiohttp headers to dict."""
|
|
138
|
+
result = {}
|
|
139
|
+
for k, v in headers.items():
|
|
140
|
+
result.setdefault(str(k), v)
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def make_aiohttp_request_handler(mock_context: "HttpMockContext"):
|
|
145
|
+
"""
|
|
146
|
+
Create patched _request method for aiohttp.ClientSession.
|
|
147
|
+
|
|
148
|
+
This intercepts all aiohttp requests.
|
|
149
|
+
"""
|
|
150
|
+
import aiohttp.client
|
|
151
|
+
original = aiohttp.client.ClientSession._request
|
|
152
|
+
|
|
153
|
+
@functools.wraps(original)
|
|
154
|
+
async def _request(self, method, url, **kwargs):
|
|
155
|
+
from yarl import URL
|
|
156
|
+
|
|
157
|
+
# Build URL with params
|
|
158
|
+
str_url = str(url)
|
|
159
|
+
params = kwargs.get("params")
|
|
160
|
+
if params:
|
|
161
|
+
url_obj = URL(str_url)
|
|
162
|
+
url_obj = url_obj.update_query(params)
|
|
163
|
+
str_url = str(url_obj)
|
|
164
|
+
|
|
165
|
+
# Get headers
|
|
166
|
+
headers = kwargs.get("headers") or {}
|
|
167
|
+
headers = self._prepare_headers(headers)
|
|
168
|
+
|
|
169
|
+
# Handle auth
|
|
170
|
+
auth = kwargs.get("auth")
|
|
171
|
+
if auth is not None:
|
|
172
|
+
headers["AUTHORIZATION"] = auth.encode()
|
|
173
|
+
|
|
174
|
+
# Get body
|
|
175
|
+
data = kwargs.get("data")
|
|
176
|
+
if data is None:
|
|
177
|
+
data = kwargs.get("json")
|
|
178
|
+
if data is not None:
|
|
179
|
+
import json as json_module
|
|
180
|
+
data = json_module.dumps(data)
|
|
181
|
+
headers["Content-Type"] = "application/json"
|
|
182
|
+
|
|
183
|
+
# Create request model
|
|
184
|
+
http_request = create_request_from_aiohttp(
|
|
185
|
+
method=method,
|
|
186
|
+
url=str_url,
|
|
187
|
+
data=data,
|
|
188
|
+
headers=_serialize_headers(headers),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Check for excluded hosts
|
|
192
|
+
if mock_context.is_host_excluded(http_request.url):
|
|
193
|
+
from .._patcher import force_reset
|
|
194
|
+
with force_reset():
|
|
195
|
+
return await original(self, method, url, **kwargs)
|
|
196
|
+
|
|
197
|
+
# Check for recorded response
|
|
198
|
+
if mock_context.can_play_response_for(http_request):
|
|
199
|
+
response_model = mock_context.play_response(http_request)
|
|
200
|
+
mock_response = MockAiohttpResponse(http_request, response_model)
|
|
201
|
+
# Update cookies
|
|
202
|
+
self._cookie_jar.update_cookies(mock_response.cookies, mock_response.url)
|
|
203
|
+
return mock_response
|
|
204
|
+
|
|
205
|
+
# Record mode - make real request and read body
|
|
206
|
+
from .._patcher import force_reset
|
|
207
|
+
with force_reset():
|
|
208
|
+
response = await original(self, method, url, **kwargs)
|
|
209
|
+
# Read response body inside force_reset (uses same connection)
|
|
210
|
+
body = await response.read()
|
|
211
|
+
|
|
212
|
+
# Create response model
|
|
213
|
+
resp_model = create_response_from_aiohttp(response, body)
|
|
214
|
+
|
|
215
|
+
# Record the interaction
|
|
216
|
+
mock_context.record(http_request, resp_model)
|
|
217
|
+
|
|
218
|
+
return response
|
|
219
|
+
|
|
220
|
+
return _request
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""Mock HTTP connection classes for http.client patching.
|
|
2
|
+
|
|
3
|
+
These classes replace http.client.HTTPConnection and HTTPSConnection
|
|
4
|
+
to intercept all HTTP traffic that goes through the stdlib.
|
|
5
|
+
|
|
6
|
+
Most HTTP libraries (requests, urllib3, httplib2) use http.client
|
|
7
|
+
under the hood, so patching at this level covers them automatically.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import http.client
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from http.client import HTTPConnection, HTTPResponse, HTTPSConnection
|
|
13
|
+
from io import BytesIO
|
|
14
|
+
from typing import Any, Dict, Optional, TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from .._models import HttpRequest, HttpResponse as HttpResponseModel
|
|
17
|
+
from .._recorder import (
|
|
18
|
+
create_request_from_http_client,
|
|
19
|
+
create_response_from_http_client,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .._mock_context import HttpMockContext
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MockHTTPResponse(HTTPResponse):
|
|
27
|
+
"""
|
|
28
|
+
Mock HTTP response that doesn't require a real socket.
|
|
29
|
+
|
|
30
|
+
Used to return recorded responses during playback mode.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, response_model: HttpResponseModel):
|
|
34
|
+
"""
|
|
35
|
+
Initialize from an HttpResponse model.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
response_model: The recorded response data
|
|
39
|
+
"""
|
|
40
|
+
self.fp = None
|
|
41
|
+
self._response_model = response_model
|
|
42
|
+
body_bytes = response_model.get_body_bytes()
|
|
43
|
+
|
|
44
|
+
self.status = self.code = response_model.status
|
|
45
|
+
self.reason = self._get_reason_phrase(response_model.status)
|
|
46
|
+
self.version = 11 # HTTP/1.1
|
|
47
|
+
self.version_string = "HTTP/1.1" # urllib3 compatibility
|
|
48
|
+
self._content = BytesIO(body_bytes)
|
|
49
|
+
self._closed = False
|
|
50
|
+
self._original_response = self
|
|
51
|
+
|
|
52
|
+
# urllib3 specific
|
|
53
|
+
self.chunked = False
|
|
54
|
+
self.will_close = True
|
|
55
|
+
self.length_remaining = 0 # Already fully read
|
|
56
|
+
self._decoder = None
|
|
57
|
+
|
|
58
|
+
# Parse headers
|
|
59
|
+
self.headers = self.msg = self._build_headers(response_model.headers)
|
|
60
|
+
|
|
61
|
+
# Calculate length
|
|
62
|
+
content_length = response_model.headers.get(
|
|
63
|
+
"content-length",
|
|
64
|
+
response_model.headers.get("Content-Length")
|
|
65
|
+
)
|
|
66
|
+
if content_length:
|
|
67
|
+
try:
|
|
68
|
+
self.length = int(content_length)
|
|
69
|
+
except ValueError:
|
|
70
|
+
self.length = len(body_bytes)
|
|
71
|
+
else:
|
|
72
|
+
self.length = len(body_bytes)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _get_reason_phrase(status: int) -> str:
|
|
76
|
+
"""Get HTTP reason phrase for status code."""
|
|
77
|
+
from http import HTTPStatus
|
|
78
|
+
try:
|
|
79
|
+
return HTTPStatus(status).phrase
|
|
80
|
+
except ValueError:
|
|
81
|
+
return "Unknown"
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _build_headers(headers_dict: Dict[str, str]) -> http.client.HTTPMessage:
|
|
85
|
+
"""Build HTTPMessage from headers dict."""
|
|
86
|
+
from email.message import Message
|
|
87
|
+
msg = Message()
|
|
88
|
+
for key, value in headers_dict.items():
|
|
89
|
+
msg[key] = value
|
|
90
|
+
return msg
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def closed(self) -> bool:
|
|
94
|
+
return self._closed
|
|
95
|
+
|
|
96
|
+
def read(self, amt: Optional[int] = None) -> bytes:
|
|
97
|
+
return self._content.read(amt)
|
|
98
|
+
|
|
99
|
+
def read1(self, amt: Optional[int] = None) -> bytes:
|
|
100
|
+
return self._content.read(amt)
|
|
101
|
+
|
|
102
|
+
def readinto(self, b) -> int:
|
|
103
|
+
return self._content.readinto(b)
|
|
104
|
+
|
|
105
|
+
def readline(self, limit: int = -1) -> bytes:
|
|
106
|
+
return self._content.readline(limit)
|
|
107
|
+
|
|
108
|
+
def readlines(self, hint: int = -1):
|
|
109
|
+
return self._content.readlines(hint)
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
self._closed = True
|
|
113
|
+
|
|
114
|
+
def isclosed(self) -> bool:
|
|
115
|
+
return self._closed
|
|
116
|
+
|
|
117
|
+
def getcode(self) -> int:
|
|
118
|
+
return self.status
|
|
119
|
+
|
|
120
|
+
def info(self):
|
|
121
|
+
return self.headers
|
|
122
|
+
|
|
123
|
+
def getheaders(self):
|
|
124
|
+
return list(self._response_model.headers.items())
|
|
125
|
+
|
|
126
|
+
def getheader(self, name: str, default=None) -> Optional[str]:
|
|
127
|
+
return self._response_model.headers.get(
|
|
128
|
+
name,
|
|
129
|
+
self._response_model.headers.get(name.lower(), default)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def readable(self) -> bool:
|
|
133
|
+
return self._content.readable()
|
|
134
|
+
|
|
135
|
+
def seekable(self) -> bool:
|
|
136
|
+
return self._content.seekable()
|
|
137
|
+
|
|
138
|
+
def tell(self) -> int:
|
|
139
|
+
return self._content.tell()
|
|
140
|
+
|
|
141
|
+
def seek(self, pos: int, whence: int = 0) -> int:
|
|
142
|
+
return self._content.seek(pos, whence)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def data(self) -> bytes:
|
|
146
|
+
"""Return full response body (for urllib3 compatibility)."""
|
|
147
|
+
return self._content.getvalue()
|
|
148
|
+
|
|
149
|
+
def drain_conn(self) -> None:
|
|
150
|
+
"""Drain connection (no-op for mock)."""
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
def get_redirect_location(self) -> Optional[str]:
|
|
154
|
+
"""Get redirect location from response headers."""
|
|
155
|
+
# Check for redirect status codes
|
|
156
|
+
if 300 <= self.status < 400:
|
|
157
|
+
return self._response_model.headers.get(
|
|
158
|
+
"location",
|
|
159
|
+
self._response_model.headers.get("Location")
|
|
160
|
+
)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def release_conn(self) -> None:
|
|
164
|
+
"""Release connection back to pool (no-op for mock)."""
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
def retries(self) -> None:
|
|
168
|
+
"""Get retry info (not applicable for mock)."""
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def stream(self, amt: int = 65536, decode_content=None):
|
|
172
|
+
"""Stream response body in chunks."""
|
|
173
|
+
while True:
|
|
174
|
+
chunk = self._content.read(amt)
|
|
175
|
+
if not chunk:
|
|
176
|
+
break
|
|
177
|
+
yield chunk
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class MockFakeSocket:
|
|
181
|
+
"""Fake socket that does nothing (used when no real connection is needed)."""
|
|
182
|
+
|
|
183
|
+
def close(self):
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def settimeout(self, *args, **kwargs):
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
def fileno(self):
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class MockConnection:
|
|
194
|
+
"""
|
|
195
|
+
Base class for mock HTTP connections.
|
|
196
|
+
|
|
197
|
+
This class intercepts HTTP requests and either:
|
|
198
|
+
- Returns recorded responses (playback mode)
|
|
199
|
+
- Makes real requests and records them (record mode)
|
|
200
|
+
|
|
201
|
+
The mock_context attribute is set dynamically via subclassing
|
|
202
|
+
in PatcherBuilder._get_stub_with_context().
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
# Set via dynamic subclass creation
|
|
206
|
+
mock_context: Optional["HttpMockContext"] = None
|
|
207
|
+
|
|
208
|
+
# Override in subclasses
|
|
209
|
+
_base_class = None
|
|
210
|
+
_protocol = "http"
|
|
211
|
+
|
|
212
|
+
def __init__(self, host, port=None, timeout=None, **kwargs):
|
|
213
|
+
"""Initialize the mock connection."""
|
|
214
|
+
# Remove urllib3-specific parameters not supported by http.client
|
|
215
|
+
kwargs.pop("strict", None) # Python 2 legacy
|
|
216
|
+
kwargs.pop("cert_file", None)
|
|
217
|
+
kwargs.pop("key_file", None)
|
|
218
|
+
kwargs.pop("cert_reqs", None)
|
|
219
|
+
kwargs.pop("ca_certs", None)
|
|
220
|
+
kwargs.pop("ca_cert_dir", None)
|
|
221
|
+
kwargs.pop("ca_cert_data", None)
|
|
222
|
+
kwargs.pop("ssl_context", None)
|
|
223
|
+
kwargs.pop("ssl_version", None)
|
|
224
|
+
kwargs.pop("server_hostname", None)
|
|
225
|
+
kwargs.pop("assert_hostname", None)
|
|
226
|
+
kwargs.pop("assert_fingerprint", None)
|
|
227
|
+
kwargs.pop("socket_options", None)
|
|
228
|
+
kwargs.pop("proxy", None)
|
|
229
|
+
kwargs.pop("proxy_config", None)
|
|
230
|
+
kwargs.pop("blocksize", None)
|
|
231
|
+
kwargs.pop("_http2_probe", None)
|
|
232
|
+
kwargs.pop("_http2", None)
|
|
233
|
+
|
|
234
|
+
# Create real connection with patches temporarily disabled
|
|
235
|
+
from .._patcher import force_reset
|
|
236
|
+
with force_reset():
|
|
237
|
+
self._real_conn = self._base_class(host, port=port, timeout=timeout)
|
|
238
|
+
|
|
239
|
+
# Storage for pending request (between request() and getresponse())
|
|
240
|
+
self._pending_request: Optional[HttpRequest] = None
|
|
241
|
+
self._sock = None
|
|
242
|
+
|
|
243
|
+
def _port_suffix(self) -> str:
|
|
244
|
+
"""Get port suffix for URL (empty string for default ports)."""
|
|
245
|
+
port = self._real_conn.port
|
|
246
|
+
default_port = {"https": 443, "http": 80}[self._protocol]
|
|
247
|
+
return f":{port}" if port != default_port else ""
|
|
248
|
+
|
|
249
|
+
def _build_uri(self, url: str) -> str:
|
|
250
|
+
"""Build full URI from request URL."""
|
|
251
|
+
if url.startswith(("http://", "https://")):
|
|
252
|
+
return url
|
|
253
|
+
host = self._real_conn.host
|
|
254
|
+
return f"{self._protocol}://{host}{self._port_suffix()}{url}"
|
|
255
|
+
|
|
256
|
+
def _url_path(self, uri: str) -> str:
|
|
257
|
+
"""Extract path from full URI for real connection."""
|
|
258
|
+
prefix = f"{self._protocol}://{self._real_conn.host}{self._port_suffix()}"
|
|
259
|
+
return uri.replace(prefix, "", 1) or "/"
|
|
260
|
+
|
|
261
|
+
def request(self, method: str, url: str, body=None, headers=None, **kwargs) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Store request data for processing in getresponse().
|
|
264
|
+
|
|
265
|
+
The actual request is deferred until getresponse() is called,
|
|
266
|
+
allowing us to check for recorded responses first.
|
|
267
|
+
"""
|
|
268
|
+
# Normalize headers (convert bytes keys/values to strings)
|
|
269
|
+
headers_dict: Dict[str, str] = {}
|
|
270
|
+
if headers:
|
|
271
|
+
for k, v in (headers.items() if hasattr(headers, "items") else headers):
|
|
272
|
+
key = k.decode("utf-8") if isinstance(k, bytes) else str(k)
|
|
273
|
+
val = v.decode("utf-8") if isinstance(v, bytes) else str(v)
|
|
274
|
+
headers_dict[key] = val
|
|
275
|
+
|
|
276
|
+
# Build full URL
|
|
277
|
+
uri = self._build_uri(url)
|
|
278
|
+
|
|
279
|
+
# Store request for getresponse()
|
|
280
|
+
self._pending_request = create_request_from_http_client(
|
|
281
|
+
method=method,
|
|
282
|
+
url=uri,
|
|
283
|
+
body=body,
|
|
284
|
+
headers=headers_dict,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Set fake socket for playback mode
|
|
288
|
+
self._sock = MockFakeSocket()
|
|
289
|
+
|
|
290
|
+
def putrequest(self, method: str, url: str, *args, **kwargs) -> None:
|
|
291
|
+
"""Start building a request (alternative API)."""
|
|
292
|
+
self._pending_request = create_request_from_http_client(
|
|
293
|
+
method=method,
|
|
294
|
+
url=self._build_uri(url),
|
|
295
|
+
body=None,
|
|
296
|
+
headers={},
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def putheader(self, header: str, *values) -> None:
|
|
300
|
+
"""Add header to pending request."""
|
|
301
|
+
if self._pending_request:
|
|
302
|
+
self._pending_request.headers[header] = ", ".join(str(v) for v in values)
|
|
303
|
+
|
|
304
|
+
def endheaders(self, message_body=None, **kwargs) -> None:
|
|
305
|
+
"""Finish headers (body may be provided here)."""
|
|
306
|
+
if message_body is not None and self._pending_request:
|
|
307
|
+
self._pending_request.body = message_body
|
|
308
|
+
|
|
309
|
+
def send(self, data) -> None:
|
|
310
|
+
"""Append data to request body."""
|
|
311
|
+
if self._pending_request:
|
|
312
|
+
current = self._pending_request.body or b""
|
|
313
|
+
if isinstance(current, str):
|
|
314
|
+
current = current.encode()
|
|
315
|
+
if isinstance(data, str):
|
|
316
|
+
data = data.encode()
|
|
317
|
+
self._pending_request.body = current + data
|
|
318
|
+
|
|
319
|
+
def getresponse(self) -> MockHTTPResponse:
|
|
320
|
+
"""
|
|
321
|
+
Get response for the pending request.
|
|
322
|
+
|
|
323
|
+
Either returns a recorded response (playback) or makes
|
|
324
|
+
a real request and records it (record mode).
|
|
325
|
+
"""
|
|
326
|
+
req = self._pending_request
|
|
327
|
+
|
|
328
|
+
if req is None:
|
|
329
|
+
raise RuntimeError("No pending request - call request() first")
|
|
330
|
+
|
|
331
|
+
# Check if we should skip mocking (excluded host)
|
|
332
|
+
if self.mock_context and self.mock_context.is_host_excluded(req.url):
|
|
333
|
+
return self._make_real_request_and_return()
|
|
334
|
+
|
|
335
|
+
# In playback mode, try to get the recorded response
|
|
336
|
+
# This will raise NoMatchingRecordingError if not found
|
|
337
|
+
if self.mock_context and self.mock_context.mode == "playback":
|
|
338
|
+
response = self.mock_context.play_response(req)
|
|
339
|
+
self._pending_request = None
|
|
340
|
+
return MockHTTPResponse(response)
|
|
341
|
+
|
|
342
|
+
# Record mode - make real request
|
|
343
|
+
return self._make_real_request_and_record()
|
|
344
|
+
|
|
345
|
+
def _make_real_request_and_return(self) -> HTTPResponse:
|
|
346
|
+
"""Make real request without recording (for excluded hosts)."""
|
|
347
|
+
from .._patcher import force_reset
|
|
348
|
+
|
|
349
|
+
req = self._pending_request
|
|
350
|
+
self._pending_request = None
|
|
351
|
+
|
|
352
|
+
with force_reset():
|
|
353
|
+
self._real_conn.request(
|
|
354
|
+
method=req.method,
|
|
355
|
+
url=self._url_path(req.url),
|
|
356
|
+
body=req.body,
|
|
357
|
+
headers=req.headers,
|
|
358
|
+
)
|
|
359
|
+
return self._real_conn.getresponse()
|
|
360
|
+
|
|
361
|
+
def _make_real_request_and_record(self) -> MockHTTPResponse:
|
|
362
|
+
"""Make real request and record the response."""
|
|
363
|
+
from .._patcher import force_reset
|
|
364
|
+
|
|
365
|
+
req = self._pending_request
|
|
366
|
+
self._pending_request = None
|
|
367
|
+
|
|
368
|
+
with force_reset():
|
|
369
|
+
self._real_conn.request(
|
|
370
|
+
method=req.method,
|
|
371
|
+
url=self._url_path(req.url),
|
|
372
|
+
body=req.body,
|
|
373
|
+
headers=req.headers,
|
|
374
|
+
)
|
|
375
|
+
real_response = self._real_conn.getresponse()
|
|
376
|
+
|
|
377
|
+
# Create response model from real response
|
|
378
|
+
resp_model = create_response_from_http_client(real_response)
|
|
379
|
+
|
|
380
|
+
# Record the interaction
|
|
381
|
+
if self.mock_context:
|
|
382
|
+
self.mock_context.record(req, resp_model)
|
|
383
|
+
|
|
384
|
+
return MockHTTPResponse(resp_model)
|
|
385
|
+
|
|
386
|
+
def close(self) -> None:
|
|
387
|
+
"""Close the connection."""
|
|
388
|
+
self._real_conn.close()
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def is_closed(self) -> bool:
|
|
392
|
+
"""Check if connection is closed."""
|
|
393
|
+
return self._real_conn.sock is None
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def proxy(self):
|
|
397
|
+
"""Return proxy info (for urllib3 compatibility)."""
|
|
398
|
+
return getattr(self._real_conn, "proxy", None)
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def proxy_is_verified(self):
|
|
402
|
+
"""Return proxy verification status."""
|
|
403
|
+
return getattr(self._real_conn, "proxy_is_verified", None)
|
|
404
|
+
|
|
405
|
+
def connect(self, *args, **kwargs):
|
|
406
|
+
"""
|
|
407
|
+
Connect to the server.
|
|
408
|
+
|
|
409
|
+
In playback mode, we don't actually connect - we'll return
|
|
410
|
+
recorded responses later. In record mode, we need to connect.
|
|
411
|
+
"""
|
|
412
|
+
# In playback mode, don't actually connect
|
|
413
|
+
if self.mock_context and self.mock_context.mode == "playback":
|
|
414
|
+
# Set a fake socket so is_closed returns False
|
|
415
|
+
self._sock = MockFakeSocket()
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
# Record mode - need to make real connection
|
|
419
|
+
from .._patcher import force_reset
|
|
420
|
+
with force_reset():
|
|
421
|
+
return self._real_conn.connect(*args, **kwargs)
|
|
422
|
+
|
|
423
|
+
def set_debuglevel(self, *args, **kwargs):
|
|
424
|
+
self._real_conn.set_debuglevel(*args, **kwargs)
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def sock(self):
|
|
428
|
+
if self._real_conn.sock:
|
|
429
|
+
return self._real_conn.sock
|
|
430
|
+
return self._sock
|
|
431
|
+
|
|
432
|
+
@sock.setter
|
|
433
|
+
def sock(self, value):
|
|
434
|
+
if self._real_conn.sock:
|
|
435
|
+
self._real_conn.sock = value
|
|
436
|
+
|
|
437
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
438
|
+
"""Propagate attribute changes to real connection."""
|
|
439
|
+
# Skip for our internal attributes
|
|
440
|
+
if name in ("_real_conn", "_pending_request", "_sock", "mock_context"):
|
|
441
|
+
super().__setattr__(name, value)
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
# Try to set on real connection too
|
|
445
|
+
with suppress(AttributeError):
|
|
446
|
+
if hasattr(self, "_real_conn"):
|
|
447
|
+
setattr(self._real_conn, name, value)
|
|
448
|
+
|
|
449
|
+
super().__setattr__(name, value)
|
|
450
|
+
|
|
451
|
+
def __getattr__(self, name: str) -> Any:
|
|
452
|
+
"""Forward unknown attributes to real connection."""
|
|
453
|
+
if "_real_conn" in self.__dict__:
|
|
454
|
+
return getattr(self._real_conn, name)
|
|
455
|
+
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class MockHTTPConnection(MockConnection):
|
|
459
|
+
"""Mock for http.client.HTTPConnection (HTTP)."""
|
|
460
|
+
_base_class = HTTPConnection
|
|
461
|
+
_protocol = "http"
|
|
462
|
+
debuglevel = HTTPConnection.debuglevel
|
|
463
|
+
_http_vsn = HTTPConnection._http_vsn
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class MockHTTPSConnection(MockConnection):
|
|
467
|
+
"""Mock for http.client.HTTPSConnection (HTTPS)."""
|
|
468
|
+
_base_class = HTTPSConnection
|
|
469
|
+
_protocol = "https"
|
|
470
|
+
is_verified = True
|
|
471
|
+
debuglevel = HTTPSConnection.debuglevel
|
|
472
|
+
_http_vsn = HTTPSConnection._http_vsn
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# Copy static methods from HTTPConnection
|
|
476
|
+
for name, method in HTTPConnection.__dict__.items():
|
|
477
|
+
if isinstance(method, staticmethod):
|
|
478
|
+
setattr(MockConnection, name, method)
|