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
@@ -0,0 +1,8 @@
1
+ """HTTP client stubs for mocking."""
2
+
3
+ from ._connection import MockHTTPConnection, MockHTTPSConnection
4
+
5
+ __all__ = [
6
+ "MockHTTPConnection",
7
+ "MockHTTPSConnection",
8
+ ]
@@ -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)