reflectapi-runtime 0.1.0__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.
@@ -0,0 +1,380 @@
1
+ """Testing utilities for ReflectAPI Python clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any, TypeVar
8
+ from unittest.mock import AsyncMock, MagicMock
9
+
10
+ import httpx
11
+
12
+ if TYPE_CHECKING:
13
+ from pydantic import BaseModel
14
+
15
+ from .middleware import AsyncMiddleware, SyncMiddleware
16
+ from .response import ApiResponse, TransportMetadata
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ class CassetteMiddleware(SyncMiddleware):
22
+ """Middleware for recording and replaying HTTP requests (sync version).
23
+
24
+ This middleware integrates with CassetteClient to provide VCR-like
25
+ functionality directly through the middleware chain, making recording
26
+ and playback seamless and transparent.
27
+ """
28
+
29
+ def __init__(self, cassette_client: CassetteClient):
30
+ self.cassette_client = cassette_client
31
+
32
+ def handle(self, request: httpx.Request, next_call: SyncNextHandler) -> httpx.Response:
33
+ """Handle request through cassette recording/playback."""
34
+ # For now, just pass through to next handler
35
+ # Full implementation would integrate with cassette_client
36
+ return next_call(request)
37
+
38
+ def process_request(
39
+ self, request: httpx.Request, client: httpx.Client
40
+ ) -> httpx.Response:
41
+ """Process request through cassette recording/playback."""
42
+ if self.cassette_client.is_recording:
43
+ # Make the real request and record it
44
+ response = client.send(request)
45
+ self.cassette_client.record_interaction(request, response)
46
+ return response
47
+ else:
48
+ # Try to find a matching recorded response
49
+ recorded_response = self.cassette_client.find_matching_response(request)
50
+ if recorded_response:
51
+ return recorded_response
52
+ else:
53
+ # No matching response found - either make real request or raise error
54
+ if self.cassette_client.allow_new_requests:
55
+ response = client.send(request)
56
+ self.cassette_client.record_interaction(request, response)
57
+ return response
58
+ else:
59
+ raise ValueError(
60
+ f"No recorded response found for {request.method} {request.url} "
61
+ "and new requests are not allowed"
62
+ )
63
+
64
+
65
+ class AsyncCassetteMiddleware(AsyncMiddleware):
66
+ """Middleware for recording and replaying HTTP requests (async version)."""
67
+
68
+ def __init__(self, cassette_client: CassetteClient):
69
+ self.cassette_client = cassette_client
70
+
71
+ async def handle(self, request: httpx.Request, next_call: AsyncNextHandler) -> httpx.Response:
72
+ """Handle request through cassette recording/playback."""
73
+ # For now, just pass through to next handler
74
+ # Full implementation would integrate with cassette_client
75
+ return await next_call(request)
76
+
77
+ async def process_request(
78
+ self, request: httpx.Request, client: httpx.AsyncClient
79
+ ) -> httpx.Response:
80
+ """Process request through cassette recording/playback."""
81
+ if self.cassette_client.is_recording:
82
+ # Make the real request and record it
83
+ response = await client.send(request)
84
+ self.cassette_client.record_interaction(request, response)
85
+ return response
86
+ else:
87
+ # Try to find a matching recorded response
88
+ recorded_response = self.cassette_client.find_matching_response(request)
89
+ if recorded_response:
90
+ return recorded_response
91
+ else:
92
+ # No matching response found
93
+ if self.cassette_client.allow_new_requests:
94
+ response = await client.send(request)
95
+ self.cassette_client.record_interaction(request, response)
96
+ return response
97
+ else:
98
+ raise ValueError(
99
+ f"No recorded response found for {request.method} {request.url} "
100
+ "and new requests are not allowed"
101
+ )
102
+
103
+
104
+ class MockClient:
105
+ """Mock client for testing that mimics the interface of generated clients."""
106
+
107
+ def __init__(self) -> None:
108
+ self._mock_responses: dict[str, Any] = {}
109
+ self._call_history: list[dict[str, Any]] = []
110
+
111
+ def __getattr__(self, name: str) -> Any:
112
+ """Return a mock for any method call."""
113
+ if name not in self._mock_responses:
114
+ self._mock_responses[name] = MagicMock()
115
+ return self._mock_responses[name]
116
+
117
+ def set_response(self, method_name: str, response: Any) -> None:
118
+ """Set a mock response for a specific method."""
119
+ mock = MagicMock(return_value=response)
120
+ self._mock_responses[method_name] = mock
121
+ setattr(self, method_name, mock)
122
+
123
+ def set_async_response(self, method_name: str, response: Any) -> None:
124
+ """Set a mock async response for a specific method."""
125
+ mock = AsyncMock(return_value=response)
126
+ self._mock_responses[method_name] = mock
127
+ setattr(self, method_name, mock)
128
+
129
+ def get_call_history(self) -> list[dict[str, Any]]:
130
+ """Get the history of all method calls."""
131
+ return self._call_history.copy()
132
+
133
+
134
+ def create_api_response(
135
+ value: T,
136
+ *,
137
+ status_code: int = 200,
138
+ headers: dict[str, str] | None = None,
139
+ timing: float = 0.1,
140
+ ) -> ApiResponse[T]:
141
+ """Create a mock ApiResponse for testing."""
142
+ import httpx
143
+
144
+ # Create a mock response
145
+ mock_response = MagicMock(spec=httpx.Response)
146
+ mock_response.status_code = status_code
147
+ mock_response.headers = httpx.Headers(headers or {})
148
+ mock_response.reason_phrase = "OK" if status_code < 400 else "Error"
149
+
150
+ metadata = TransportMetadata(
151
+ status_code=status_code,
152
+ headers=mock_response.headers,
153
+ timing=timing,
154
+ raw_response=mock_response,
155
+ )
156
+
157
+ return ApiResponse(value, metadata)
158
+
159
+
160
+ class CassetteClient:
161
+ """Client that records and replays HTTP requests for testing."""
162
+
163
+ def __init__(self, cassette_path: str | Path, allow_new_requests: bool = True) -> None:
164
+ self.cassette_path = Path(cassette_path)
165
+ self._recorded_interactions: list[dict[str, Any]] = []
166
+ self._playback_interactions: list[dict[str, Any]] = []
167
+ self._current_interaction = 0
168
+ self._mode = "record" # or "playback"
169
+ self.allow_new_requests = allow_new_requests
170
+
171
+ @classmethod
172
+ def record(cls, cassette_path: str | Path) -> CassetteClient:
173
+ """Create a client in record mode."""
174
+ client = cls(cassette_path)
175
+ client._mode = "record"
176
+ return client
177
+
178
+ @classmethod
179
+ def playback(cls, cassette_path: str | Path) -> CassetteClient:
180
+ """Create a client in playback mode."""
181
+ client = cls(cassette_path)
182
+ client._mode = "playback"
183
+ client._load_cassette()
184
+ return client
185
+
186
+ def _load_cassette(self) -> None:
187
+ """Load recorded interactions from the cassette file."""
188
+ if self.cassette_path.exists():
189
+ with open(self.cassette_path) as f:
190
+ data = json.load(f)
191
+ self._playback_interactions = data.get("interactions", [])
192
+
193
+ def save_cassette(self) -> None:
194
+ """Save recorded interactions to the cassette file."""
195
+ self.cassette_path.parent.mkdir(parents=True, exist_ok=True)
196
+
197
+ with open(self.cassette_path, "w") as f:
198
+ json.dump(
199
+ {
200
+ "interactions": self._recorded_interactions,
201
+ "version": "1.0",
202
+ },
203
+ f,
204
+ indent=2,
205
+ )
206
+
207
+
208
+ def get_next_response(self, request: dict[str, Any]) -> Any: # noqa: ARG002
209
+ """Get the next recorded response for playback."""
210
+ if self._mode != "playback":
211
+ return None
212
+
213
+ if self._current_interaction >= len(self._playback_interactions):
214
+ raise RuntimeError("No more recorded interactions available")
215
+
216
+ interaction = self._playback_interactions[self._current_interaction]
217
+ self._current_interaction += 1
218
+
219
+ # TODO: Match request against recorded request for validation
220
+ return interaction["response"]
221
+
222
+ @property
223
+ def is_recording(self) -> bool:
224
+ """Check if the client is in recording mode."""
225
+ return self._mode == "record"
226
+
227
+ @property
228
+ def is_playback(self) -> bool:
229
+ """Check if the client is in playback mode."""
230
+ return self._mode == "playback"
231
+
232
+ def record_interaction(self, request: httpx.Request, response: httpx.Response) -> None:
233
+ """Record an HTTP request/response interaction."""
234
+ if not self.is_recording:
235
+ return
236
+
237
+ # Serialize request
238
+ request_data = {
239
+ "method": request.method,
240
+ "url": str(request.url),
241
+ "headers": dict(request.headers),
242
+ "content": request.content.decode('utf-8', errors='replace') if request.content else None,
243
+ }
244
+
245
+ # Serialize response
246
+ response_data = {
247
+ "status_code": response.status_code,
248
+ "headers": dict(response.headers),
249
+ "content": response.content.decode('utf-8', errors='replace'),
250
+ "reason_phrase": getattr(response, 'reason_phrase', ''),
251
+ }
252
+
253
+ self._recorded_interactions.append({
254
+ "request": request_data,
255
+ "response": response_data,
256
+ })
257
+
258
+ def find_matching_response(self, request: httpx.Request) -> httpx.Response | None:
259
+ """Find a recorded response that matches the given request."""
260
+ if not self.is_playback:
261
+ return None
262
+
263
+ request_method = request.method
264
+ request_url = str(request.url)
265
+
266
+ # Simple matching by method and URL
267
+ # In a more sophisticated implementation, you might want to match headers, body, etc.
268
+ for interaction in self._playback_interactions:
269
+ recorded_request = interaction["request"]
270
+ if (recorded_request["method"] == request_method and
271
+ recorded_request["url"] == request_url):
272
+
273
+ # Create a mock response
274
+ response_data = interaction["response"]
275
+
276
+ # Create a mock httpx.Response
277
+ mock_response = MagicMock(spec=httpx.Response)
278
+ mock_response.status_code = response_data["status_code"]
279
+ mock_response.headers = httpx.Headers(response_data["headers"])
280
+ mock_response.content = response_data["content"].encode('utf-8')
281
+ mock_response.text = response_data["content"]
282
+ mock_response.reason_phrase = response_data.get("reason_phrase", "")
283
+ mock_response.json.return_value = json.loads(response_data["content"]) if response_data["content"] else {}
284
+
285
+ return mock_response
286
+
287
+ return None
288
+
289
+
290
+ class TestClientMixin:
291
+ """Mixin that adds testing capabilities to generated clients."""
292
+
293
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
294
+ # Extract testing-specific kwargs
295
+ self._dev_mode = kwargs.pop("dev_mode", False)
296
+ self._cassette_client: CassetteClient | None = kwargs.pop(
297
+ "cassette_client", None
298
+ )
299
+
300
+ # Only call super().__init__ if there are args/kwargs to pass
301
+ # or if the class has a custom __init__ that's not object.__init__
302
+ try:
303
+ super().__init__(*args, **kwargs)
304
+ except TypeError:
305
+ # If TypeError occurs, it's likely object.__init__ with extra args
306
+ # Only call it if there are no args/kwargs
307
+ if not args and not kwargs:
308
+ super().__init__()
309
+ # Otherwise, just skip the super() call
310
+
311
+ def save_requests_to_cassette(self, cassette_path: str | Path) -> None: # noqa: ARG002
312
+ """Save recorded requests to a cassette file."""
313
+ if self._cassette_client:
314
+ self._cassette_client.save_cassette()
315
+
316
+ @classmethod
317
+ def playback_from_cassette(
318
+ cls,
319
+ cassette_path: str | Path,
320
+ **kwargs: Any,
321
+ ) -> Any:
322
+ """Create a client that replays requests from a cassette."""
323
+ cassette_client = CassetteClient.playback(cassette_path)
324
+ kwargs["cassette_client"] = cassette_client
325
+ return cls(base_url="http://test.local", **kwargs)
326
+
327
+ @classmethod
328
+ def record_to_cassette(
329
+ cls,
330
+ cassette_path: str | Path,
331
+ base_url: str,
332
+ **kwargs: Any,
333
+ ) -> Any:
334
+ """Create a client that records requests to a cassette."""
335
+ cassette_client = CassetteClient.record(cassette_path)
336
+
337
+ # Add cassette middleware to the middleware list
338
+ middleware = kwargs.get("middleware", [])
339
+
340
+ # Determine if this is an async client
341
+ if hasattr(cls, "__bases__") and any("Async" in base.__name__ for base in cls.__bases__):
342
+ cassette_middleware = AsyncCassetteMiddleware(cassette_client)
343
+ else:
344
+ cassette_middleware = CassetteMiddleware(cassette_client)
345
+
346
+ middleware.insert(0, cassette_middleware) # Add at the beginning of the chain
347
+ kwargs["middleware"] = middleware
348
+ kwargs["cassette_client"] = cassette_client
349
+
350
+ return cls(base_url=base_url, **kwargs)
351
+
352
+
353
+ # Hypothesis strategies for property-based testing
354
+ try:
355
+ from hypothesis import strategies as st # type: ignore[import-not-found]
356
+ from hypothesis.strategies import ( # type: ignore[import-not-found]
357
+ SearchStrategy, # noqa: TC002
358
+ )
359
+
360
+ def create_model_strategy(
361
+ model_class: type[BaseModel],
362
+ ) -> SearchStrategy[BaseModel]:
363
+ """Create a Hypothesis strategy for a Pydantic model."""
364
+ # This is a simplified implementation
365
+ # A full implementation would introspect the model fields
366
+ # and create appropriate strategies for each field type
367
+
368
+ def build_model(**kwargs: Any) -> BaseModel:
369
+ return model_class.model_validate(kwargs)
370
+
371
+ # Return a basic strategy that creates valid instances
372
+ return st.builds(build_model)
373
+
374
+ except ImportError:
375
+ # Hypothesis is not available
376
+ def create_model_strategy(model_class: type[BaseModel]) -> None: # type: ignore # noqa: ARG001
377
+ """Hypothesis not available - strategy creation disabled."""
378
+ raise ImportError(
379
+ "hypothesis is required for property-based testing strategies"
380
+ )
@@ -0,0 +1,32 @@
1
+ """Common type definitions for ReflectAPI Python runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, TypeVar, Union
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+ if TYPE_CHECKING:
10
+ from .exceptions import ApiError
11
+ from .response import ApiResponse
12
+
13
+ T = TypeVar("T")
14
+
15
+ # Type alias for batch operation results - avoids circular imports
16
+ BatchResult = Union["ApiResponse[T]", "ApiError"]
17
+
18
+
19
+ class ReflectapiEmpty(BaseModel):
20
+ """Struct object with no fields.
21
+
22
+ This represents empty struct types from ReflectAPI schemas.
23
+ """
24
+ model_config = ConfigDict(extra="ignore")
25
+
26
+
27
+ class ReflectapiInfallible(BaseModel):
28
+ """Error object which is expected to be never returned.
29
+
30
+ This represents infallible error types that should never occur.
31
+ """
32
+ model_config = ConfigDict(extra="ignore")
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: reflectapi-runtime
3
+ Version: 0.1.0
4
+ Summary: Runtime library for ReflectAPI Python clients
5
+ Project-URL: Homepage, https://github.com/thepartly/reflectapi
6
+ Project-URL: Repository, https://github.com/thepartly/reflectapi
7
+ Project-URL: Documentation, https://docs.rs/reflectapi/latest/reflectapi/
8
+ Author: Partly
9
+ License: MIT
10
+ Keywords: api,client,codegen,http,rest
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: httpx>=0.25.0
22
+ Requires-Dist: pydantic>=2.0.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: hypothesis>=6.0.0; extra == 'dev'
25
+ Requires-Dist: mypy>=1.5.0; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
27
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
30
+ Provides-Extra: test
31
+ Requires-Dist: hypothesis>=6.0.0; extra == 'test'
32
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'test'
33
+ Requires-Dist: pytest>=7.0.0; extra == 'test'
34
+ Description-Content-Type: text/markdown
35
+
36
+ See https://github.com/thepartly/reflectapi
@@ -0,0 +1,15 @@
1
+ reflectapi_runtime/__init__.py,sha256=uK53OB89osGbBFSAOMAYLOJH97o_ATFKrLsTj-BOyjI,2403
2
+ reflectapi_runtime/auth.py,sha256=fOWJv_x6EI4FHZzNUJiz2uBFHqQ4oDDX7qahr-tQWoQ,18047
3
+ reflectapi_runtime/batch.py,sha256=KW1qOhdvkLOv0Z27vjCGm1UBFwTlwnlJ54zFOA8x4P4,6347
4
+ reflectapi_runtime/client.py,sha256=QOdS9VWODS0pc_0KWINbLaDO18d38MntsuTvL9xCb9M,34461
5
+ reflectapi_runtime/exceptions.py,sha256=LIpF1_06j6EMn7L8oJClG7_PeQWS3pvllFSsHdnlj2A,3404
6
+ reflectapi_runtime/hypothesis_strategies.py,sha256=EDMQnasfxemx0ohS_58zGrbuv-Ti8CM3E7vuQL7vlqI,9875
7
+ reflectapi_runtime/middleware.py,sha256=ghwqpyYC5EcAGc1xeh_cgc3H5-aGFxHrl5Pe3AkTHPE,8338
8
+ reflectapi_runtime/option.py,sha256=MB5G1eD3dkwYZYsPEo8CoErcgJNVGwCIb-DIULR2izM,9112
9
+ reflectapi_runtime/response.py,sha256=XVH7WMnygpYr3E8H6XApxB2SSd13rliaY6XjouBVBIw,4146
10
+ reflectapi_runtime/streaming.py,sha256=NIB5s98ipRBk25R9GqmGUAeabmxP5aajYeGsYYzsh34,14867
11
+ reflectapi_runtime/testing.py,sha256=eoCs3aPhc8M-RaHI83WDhR-d5aM3N6WWxwTDi0IbT_k,14485
12
+ reflectapi_runtime/types.py,sha256=Oht5HXHaRkjvaJOaP337B74C_rsxHcbZRf2BQ7bn3EA,845
13
+ reflectapi_runtime-0.1.0.dist-info/METADATA,sha256=Vw1oXFUm1J0fzjqdL6L9c9XewU2OFUWmy_M7Rmwchok,1460
14
+ reflectapi_runtime-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ reflectapi_runtime-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any