schemathesis 3.26.2__py3-none-any.whl → 3.27.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.
@@ -21,6 +21,8 @@ from jsonschema.exceptions import SchemaError as JsonSchemaError
21
21
  from jsonschema.exceptions import ValidationError
22
22
  from requests.auth import HTTPDigestAuth, _basic_auth_str
23
23
 
24
+ from schemathesis.transports import RequestsTransport
25
+
24
26
  from ... import failures, hooks
25
27
  from ..._compat import MultipleFailures
26
28
  from ..._hypothesis import (
@@ -849,7 +851,7 @@ def _network_test(
849
851
  response = case.call(**kwargs)
850
852
  except CheckFailed as exc:
851
853
  check_name = "request_timeout"
852
- requests_kwargs = case.as_requests_kwargs(base_url=case.get_full_base_url(), headers=headers)
854
+ requests_kwargs = RequestsTransport().serialize_case(case, base_url=case.get_full_base_url(), headers=headers)
853
855
  request = requests.Request(**requests_kwargs).prepare()
854
856
  elapsed = cast(float, timeout) # It is defined and not empty, since the exception happened
855
857
  check_result = result.add_failure(
@@ -939,14 +941,14 @@ def _wsgi_test(
939
941
  feedback: Feedback,
940
942
  max_response_time: int | None,
941
943
  ) -> WSGIResponse:
944
+ from ...transports.responses import WSGIResponse
945
+
942
946
  with catching_logs(LogCaptureHandler(), level=logging.DEBUG) as recorded:
943
- start = time.monotonic()
944
947
  hook_context = HookContext(operation=case.operation)
945
- kwargs = {"headers": headers}
948
+ kwargs: dict[str, Any] = {"headers": headers}
946
949
  hooks.dispatch("process_call_kwargs", hook_context, case, kwargs)
947
- response = case.call_wsgi(**kwargs)
948
- elapsed = time.monotonic() - start
949
- context = TargetContext(case=case, response=response, response_time=elapsed)
950
+ response = cast(WSGIResponse, case.call(**kwargs))
951
+ context = TargetContext(case=case, response=response, response_time=response.elapsed.total_seconds())
950
952
  run_targets(targets, context)
951
953
  result.logs.extend(recorded.records)
952
954
  status = Status.success
@@ -967,7 +969,7 @@ def _wsgi_test(
967
969
  finally:
968
970
  feedback.add_test_case(case, response)
969
971
  if store_interactions:
970
- result.store_wsgi_response(case, response, headers, elapsed, status, check_results)
972
+ result.store_wsgi_response(case, response, headers, response.elapsed.total_seconds(), status, check_results)
971
973
  return response
972
974
 
973
975
 
@@ -1037,7 +1039,7 @@ def _asgi_test(
1037
1039
  hook_context = HookContext(operation=case.operation)
1038
1040
  kwargs: dict[str, Any] = {"headers": headers}
1039
1041
  hooks.dispatch("process_call_kwargs", hook_context, case, kwargs)
1040
- response = case.call_asgi(**kwargs)
1042
+ response = case.call(**kwargs)
1041
1043
  context = TargetContext(case=case, response=response, response_time=response.elapsed.total_seconds())
1042
1044
  run_targets(targets, context)
1043
1045
  status = Status.success
@@ -4,28 +4,30 @@ They all consist of primitive types and don't have references to schemas, app, e
4
4
  """
5
5
 
6
6
  from __future__ import annotations
7
+
7
8
  import logging
8
9
  import re
10
+ import textwrap
9
11
  from dataclasses import dataclass, field
10
- from typing import Any, TYPE_CHECKING, cast
12
+ from typing import TYPE_CHECKING, Any, cast
11
13
 
12
- from ..transports import serialize_payload
13
14
  from ..code_samples import get_excluded_headers
14
15
  from ..exceptions import (
16
+ BodyInGetRequestError,
17
+ DeadlineExceeded,
15
18
  FailureContext,
16
19
  InternalError,
17
- make_unique_by_key,
18
- format_exception,
19
- extract_requests_exception_details,
20
- RuntimeErrorType,
21
- DeadlineExceeded,
22
- OperationSchemaError,
23
- BodyInGetRequestError,
24
20
  InvalidRegularExpression,
21
+ OperationSchemaError,
22
+ RuntimeErrorType,
25
23
  SerializationError,
26
24
  UnboundPrefixError,
25
+ extract_requests_exception_details,
26
+ format_exception,
27
+ make_unique_by_key,
27
28
  )
28
29
  from ..models import Case, Check, Interaction, Request, Response, Status, TestResult
30
+ from ..transports import serialize_payload
29
31
 
30
32
  if TYPE_CHECKING:
31
33
  import hypothesis.errors
@@ -108,6 +110,7 @@ class SerializedCheck:
108
110
  @classmethod
109
111
  def from_check(cls, check: Check) -> SerializedCheck:
110
112
  import requests
113
+
111
114
  from ..transports.responses import WSGIResponse
112
115
 
113
116
  if check.response is not None:
@@ -140,6 +143,25 @@ class SerializedCheck:
140
143
  history=history,
141
144
  )
142
145
 
146
+ @property
147
+ def title(self) -> str:
148
+ if self.context is not None:
149
+ return self.context.title
150
+ return f"Custom check failed: `{self.name}`"
151
+
152
+ @property
153
+ def formatted_message(self) -> str | None:
154
+ if self.context is not None:
155
+ if self.context.message:
156
+ message = self.context.message
157
+ else:
158
+ message = None
159
+ else:
160
+ message = self.message
161
+ if message is not None:
162
+ message = textwrap.indent(message, prefix=" ")
163
+ return message
164
+
143
165
 
144
166
  def _get_headers(headers: dict[str, Any] | CaseInsensitiveDict) -> dict[str, str]:
145
167
  return {key: value[0] for key, value in headers.items() if key not in get_excluded_headers()}
@@ -203,8 +225,8 @@ class SerializedError:
203
225
 
204
226
  @classmethod
205
227
  def from_exception(cls, exception: Exception) -> SerializedError:
206
- import requests
207
228
  import hypothesis.errors
229
+ import requests
208
230
  from hypothesis import HealthCheck
209
231
 
210
232
  title = "Runtime Error"
schemathesis/schemas.py CHANGED
@@ -8,11 +8,13 @@ They give only static definitions of paths.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
11
+
11
12
  from collections.abc import Mapping, MutableMapping
12
13
  from contextlib import nullcontext
13
14
  from dataclasses import dataclass, field
14
15
  from functools import lru_cache
15
16
  from typing import (
17
+ TYPE_CHECKING,
16
18
  Any,
17
19
  Callable,
18
20
  ContextManager,
@@ -22,7 +24,6 @@ from typing import (
22
24
  NoReturn,
23
25
  Sequence,
24
26
  TypeVar,
25
- TYPE_CHECKING,
26
27
  )
27
28
  from urllib.parse import quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
28
29
 
@@ -30,23 +31,23 @@ import hypothesis
30
31
  from hypothesis.strategies import SearchStrategy
31
32
  from pyrate_limiter import Limiter
32
33
 
33
- from .constants import NOT_SET
34
+ from ._dependency_versions import IS_PYRATE_LIMITER_ABOVE_3
34
35
  from ._hypothesis import create_test
35
36
  from .auths import AuthStorage
36
37
  from .code_samples import CodeSampleStyle
38
+ from .constants import NOT_SET
39
+ from .exceptions import OperationSchemaError, UsageError
37
40
  from .generation import (
38
41
  DEFAULT_DATA_GENERATION_METHODS,
39
42
  DataGenerationMethod,
40
43
  DataGenerationMethodInput,
41
44
  GenerationConfig,
42
45
  )
43
- from ._dependency_versions import IS_PYRATE_LIMITER_ABOVE_3
44
- from .exceptions import OperationSchemaError, UsageError
45
46
  from .hooks import HookContext, HookDispatcher, HookScope, dispatch
46
- from .internal.result import Result, Ok
47
+ from .internal.result import Ok, Result
47
48
  from .models import APIOperation, Case
48
- from .stateful.state_machine import APIStateMachine
49
49
  from .stateful import Stateful, StatefulTest
50
+ from .stateful.state_machine import APIStateMachine
50
51
  from .types import (
51
52
  Body,
52
53
  Cookies,
@@ -58,9 +59,10 @@ from .types import (
58
59
  PathParameters,
59
60
  Query,
60
61
  )
61
- from .utils import PARAMETRIZE_MARKER, GivenInput, given_proxy, combine_strategies
62
+ from .utils import PARAMETRIZE_MARKER, GivenInput, combine_strategies, given_proxy
62
63
 
63
64
  if TYPE_CHECKING:
65
+ from .transports import Transport
64
66
  from .transports.responses import GenericResponse
65
67
 
66
68
 
@@ -75,6 +77,7 @@ def get_full_path(base_path: str, path: str) -> str:
75
77
  @dataclass(eq=False)
76
78
  class BaseSchema(Mapping):
77
79
  raw_schema: dict[str, Any]
80
+ transport: Transport
78
81
  location: str | None = None
79
82
  base_url: str | None = None
80
83
  method: Filter | None = None
@@ -346,6 +349,7 @@ class BaseSchema(Mapping):
346
349
  code_sample_style=code_sample_style, # type: ignore
347
350
  rate_limiter=rate_limiter, # type: ignore
348
351
  sanitize_output=sanitize_output, # type: ignore
352
+ transport=self.transport,
349
353
  )
350
354
 
351
355
  def get_local_hook_dispatcher(self) -> HookDispatcher | None:
@@ -234,6 +234,7 @@ def from_dict(
234
234
  :return: GraphQLSchema
235
235
  """
236
236
  from .schemas import GraphQLSchema
237
+ from ... import transports
237
238
 
238
239
  _code_sample_style = CodeSampleStyle.from_str(code_sample_style)
239
240
  hook_context = HookContext()
@@ -252,6 +253,7 @@ def from_dict(
252
253
  code_sample_style=_code_sample_style,
253
254
  rate_limiter=rate_limiter,
254
255
  sanitize_output=sanitize_output,
256
+ transport=transports.get(app),
255
257
  ) # type: ignore
256
258
  dispatch("after_load_schema", hook_context, instance)
257
259
  return instance
@@ -20,7 +20,6 @@ from typing import (
20
20
  from urllib.parse import urlsplit, urlunsplit
21
21
 
22
22
  import graphql
23
- import requests
24
23
  from graphql import GraphQLNamedType
25
24
  from hypothesis import strategies as st
26
25
  from hypothesis.strategies import SearchStrategy
@@ -60,39 +59,15 @@ class RootType(enum.Enum):
60
59
 
61
60
  @dataclass(repr=False)
62
61
  class GraphQLCase(Case):
63
- def as_requests_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
64
- final_headers = self._get_headers(headers)
62
+ def _get_url(self, base_url: str | None) -> str:
65
63
  base_url = self._get_base_url(base_url)
66
64
  # Replace the path, in case if the user provided any path parameters via hooks
67
65
  parts = list(urlsplit(base_url))
68
66
  parts[2] = self.formatted_path
69
- kwargs: dict[str, Any] = {
70
- "method": self.method,
71
- "url": urlunsplit(parts),
72
- "headers": final_headers,
73
- "cookies": self.cookies,
74
- "params": self.query,
75
- }
76
- # There is no direct way to have bytes here, but it is a useful pattern to support.
77
- # It also unifies GraphQLCase with its Open API counterpart where bytes may come from external examples
78
- if isinstance(self.body, bytes):
79
- kwargs["data"] = self.body
80
- # Assume that the payload is JSON, not raw GraphQL queries
81
- kwargs["headers"].setdefault("Content-Type", "application/json")
82
- else:
83
- kwargs["json"] = {"query": self.body}
84
- return kwargs
85
-
86
- def as_werkzeug_kwargs(self, headers: dict[str, str] | None = None) -> dict[str, Any]:
87
- final_headers = self._get_headers(headers)
88
- return {
89
- "method": self.method,
90
- "path": self.operation.schema.get_full_path(self.formatted_path),
91
- # Convert to a regular dictionary, as we use `CaseInsensitiveDict` which is not supported by Werkzeug
92
- "headers": dict(final_headers),
93
- "query_string": self.query,
94
- "json": {"query": self.body},
95
- }
67
+ return urlunsplit(parts)
68
+
69
+ def _get_body(self) -> Body | NotSet:
70
+ return self.body if isinstance(self.body, (NotSet, bytes)) else {"query": self.body}
96
71
 
97
72
  def validate_response(
98
73
  self,
@@ -107,15 +82,6 @@ class GraphQLCase(Case):
107
82
  checks = tuple(check for check in checks if check not in excluded_checks)
108
83
  return super().validate_response(response, checks, code_sample_style=code_sample_style)
109
84
 
110
- def call_asgi(
111
- self,
112
- app: Any = None,
113
- base_url: str | None = None,
114
- headers: dict[str, str] | None = None,
115
- **kwargs: Any,
116
- ) -> requests.Response:
117
- return super().call_asgi(app=app, base_url=base_url, headers=headers, **kwargs)
118
-
119
85
 
120
86
  C = TypeVar("C", bound=Case)
121
87
 
@@ -287,7 +253,7 @@ class GraphQLSchema(BaseSchema):
287
253
  cookies=cookies,
288
254
  query=query,
289
255
  body=body,
290
- media_type=media_type,
256
+ media_type=media_type or "application/json",
291
257
  generation_time=0.0,
292
258
  )
293
259
 
@@ -373,6 +339,7 @@ def get_case_strategy(
373
339
  operation=operation,
374
340
  data_generation_method=data_generation_method,
375
341
  generation_time=time.monotonic() - start,
342
+ media_type="application/json",
376
343
  ) # type: ignore
377
344
  context = auths.AuthContext(
378
345
  operation=operation,
@@ -304,6 +304,7 @@ def from_dict(
304
304
  :param dict raw_schema: A schema to load.
305
305
  """
306
306
  from .schemas import OpenApi30, SwaggerV20
307
+ from ... import transports
307
308
 
308
309
  if not isinstance(raw_schema, dict):
309
310
  raise SchemaError(SchemaErrorType.OPEN_API_INVALID_SCHEMA, SCHEMA_INVALID_ERROR)
@@ -338,6 +339,7 @@ def from_dict(
338
339
  location=location,
339
340
  rate_limiter=rate_limiter,
340
341
  sanitize_output=sanitize_output,
342
+ transport=transports.get(app),
341
343
  )
342
344
  dispatch("after_load_schema", hook_context, instance)
343
345
  return instance
@@ -379,6 +381,7 @@ def from_dict(
379
381
  location=location,
380
382
  rate_limiter=rate_limiter,
381
383
  sanitize_output=sanitize_output,
384
+ transport=transports.get(app),
382
385
  )
383
386
  dispatch("after_load_schema", hook_context, instance)
384
387
  return instance
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import time
4
4
  import re
5
5
  from dataclasses import dataclass
6
- from typing import TYPE_CHECKING, Any, Callable, ClassVar
6
+ from typing import TYPE_CHECKING, Any, ClassVar
7
7
 
8
8
  from hypothesis.errors import InvalidDefinition
9
9
  from hypothesis.stateful import RuleBasedStateMachine
@@ -189,13 +189,12 @@ class APIStateMachine(RuleBasedStateMachine):
189
189
  :return: Response from the application under test.
190
190
 
191
191
  Note that WSGI/ASGI applications are detected automatically in this method. Depending on the result of this
192
- detection the state machine will call ``call``, ``call_wsgi`` or ``call_asgi`` methods.
192
+ detection the state machine will call the ``call`` method.
193
193
 
194
194
  Usually, you don't need to override this method unless you are building a different state machine on top of this
195
195
  one and want to customize the transport layer itself.
196
196
  """
197
- method = self._get_call_method(case)
198
- return method(**kwargs)
197
+ return case.call(**kwargs)
199
198
 
200
199
  def get_call_kwargs(self, case: Case) -> dict[str, Any]:
201
200
  """Create custom keyword arguments that will be passed to the :meth:`Case.call` method.
@@ -214,15 +213,6 @@ class APIStateMachine(RuleBasedStateMachine):
214
213
  """
215
214
  return {}
216
215
 
217
- def _get_call_method(self, case: Case) -> Callable:
218
- if case.app is not None:
219
- from starlette.applications import Starlette
220
-
221
- if isinstance(case.app, Starlette):
222
- return case.call_asgi
223
- return case.call_wsgi
224
- return case.call
225
-
226
216
  def validate_response(
227
217
  self, response: GenericResponse, case: Case, additional_checks: tuple[CheckFunction, ...] = ()
228
218
  ) -> None:
@@ -1,5 +1,311 @@
1
+ from __future__ import annotations
2
+
1
3
  import base64
4
+ import time
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass
7
+ from datetime import timedelta
8
+ from typing import TYPE_CHECKING, Any, Generator, Protocol, TypeVar, cast
9
+ from urllib.parse import urlparse
10
+
11
+ from .. import failures
12
+ from .._dependency_versions import IS_WERKZEUG_ABOVE_3
13
+ from ..constants import DEFAULT_RESPONSE_TIMEOUT
14
+ from ..exceptions import get_timeout_error
15
+ from ..serializers import SerializerContext
16
+ from ..types import Cookies, NotSet
17
+
18
+ if TYPE_CHECKING:
19
+ import requests
20
+ import werkzeug
21
+ from _typeshed.wsgi import WSGIApplication
22
+ from starlette_testclient._testclient import ASGI2App, ASGI3App
23
+
24
+ from ..models import Case
25
+ from .responses import WSGIResponse
2
26
 
3
27
 
4
28
  def serialize_payload(payload: bytes) -> str:
5
29
  return base64.b64encode(payload).decode()
30
+
31
+
32
+ def get(app: Any) -> Transport:
33
+ """Get transport to send the data to the application."""
34
+ from starlette.applications import Starlette
35
+
36
+ if app is None:
37
+ return RequestsTransport()
38
+ if isinstance(app, Starlette):
39
+ return ASGITransport(app=app)
40
+ return WSGITransport(app=app)
41
+
42
+
43
+ S = TypeVar("S", contravariant=True)
44
+ R = TypeVar("R", covariant=True)
45
+
46
+
47
+ class Transport(Protocol[S, R]):
48
+ def serialize_case(
49
+ self,
50
+ case: Case,
51
+ *,
52
+ base_url: str | None = None,
53
+ headers: dict[str, Any] | None = None,
54
+ params: dict[str, Any] | None = None,
55
+ cookies: dict[str, Any] | None = None,
56
+ ) -> dict[str, Any]:
57
+ raise NotImplementedError
58
+
59
+ def send(
60
+ self,
61
+ case: Case,
62
+ *,
63
+ session: S | None = None,
64
+ base_url: str | None = None,
65
+ headers: dict[str, Any] | None = None,
66
+ params: dict[str, Any] | None = None,
67
+ cookies: dict[str, Any] | None = None,
68
+ **kwargs: Any,
69
+ ) -> R:
70
+ raise NotImplementedError
71
+
72
+
73
+ class RequestsTransport:
74
+ def serialize_case(
75
+ self,
76
+ case: Case,
77
+ *,
78
+ base_url: str | None = None,
79
+ headers: dict[str, Any] | None = None,
80
+ params: dict[str, Any] | None = None,
81
+ cookies: dict[str, Any] | None = None,
82
+ ) -> dict[str, Any]:
83
+ final_headers = case._get_headers(headers)
84
+ if case.media_type and case.media_type != "multipart/form-data" and not isinstance(case.body, NotSet):
85
+ # `requests` will handle multipart form headers with the proper `boundary` value.
86
+ if "content-type" not in final_headers:
87
+ final_headers["Content-Type"] = case.media_type
88
+ url = case._get_url(base_url)
89
+ serializer = case._get_serializer()
90
+ if serializer is not None and not isinstance(case.body, NotSet):
91
+ context = SerializerContext(case=case)
92
+ extra = serializer.as_requests(context, case._get_body())
93
+ else:
94
+ extra = {}
95
+ if case._auth is not None:
96
+ extra["auth"] = case._auth
97
+ additional_headers = extra.pop("headers", None)
98
+ if additional_headers:
99
+ # Additional headers, needed for the serializer
100
+ for key, value in additional_headers.items():
101
+ final_headers.setdefault(key, value)
102
+ data = {
103
+ "method": case.method,
104
+ "url": url,
105
+ "cookies": case.cookies,
106
+ "headers": final_headers,
107
+ "params": case.query,
108
+ **extra,
109
+ }
110
+ if params is not None:
111
+ _merge_dict_to(data, "params", params)
112
+ if cookies is not None:
113
+ _merge_dict_to(data, "cookies", cookies)
114
+ return data
115
+
116
+ def send(
117
+ self,
118
+ case: Case,
119
+ *,
120
+ session: requests.Session | None = None,
121
+ base_url: str | None = None,
122
+ headers: dict[str, Any] | None = None,
123
+ params: dict[str, Any] | None = None,
124
+ cookies: dict[str, Any] | None = None,
125
+ **kwargs: Any,
126
+ ) -> requests.Response:
127
+ import requests
128
+ from urllib3.exceptions import ReadTimeoutError
129
+
130
+ data = self.serialize_case(case, base_url=base_url, headers=headers, params=params, cookies=cookies)
131
+ data.update(kwargs)
132
+ data.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
133
+ if session is None:
134
+ validate_vanilla_requests_kwargs(data)
135
+ session = requests.Session()
136
+ close_session = True
137
+ else:
138
+ close_session = False
139
+ verify = data.get("verify", True)
140
+ try:
141
+ with case.operation.schema.ratelimit():
142
+ response = session.request(**data) # type: ignore
143
+ except (requests.Timeout, requests.ConnectionError) as exc:
144
+ if isinstance(exc, requests.ConnectionError):
145
+ if not isinstance(exc.args[0], ReadTimeoutError):
146
+ raise
147
+ req = requests.Request(
148
+ method=data["method"].upper(),
149
+ url=data["url"],
150
+ headers=data["headers"],
151
+ files=data.get("files"),
152
+ data=data.get("data") or {},
153
+ json=data.get("json"),
154
+ params=data.get("params") or {},
155
+ auth=data.get("auth"),
156
+ cookies=data["cookies"],
157
+ hooks=data.get("hooks"),
158
+ )
159
+ request = session.prepare_request(req)
160
+ else:
161
+ request = cast(requests.PreparedRequest, exc.request)
162
+ timeout = 1000 * data["timeout"] # It is defined and not empty, since the exception happened
163
+ code_message = case._get_code_message(case.operation.schema.code_sample_style, request, verify=verify)
164
+ message = f"The server failed to respond within the specified limit of {timeout:.2f}ms"
165
+ raise get_timeout_error(timeout)(
166
+ f"\n\n1. {failures.RequestTimeout.title}\n\n{message}\n\n{code_message}",
167
+ context=failures.RequestTimeout(message=message, timeout=timeout),
168
+ ) from None
169
+ response.verify = verify # type: ignore[attr-defined]
170
+ if close_session:
171
+ session.close()
172
+ return response
173
+
174
+
175
+ def _merge_dict_to(data: dict[str, Any], data_key: str, new: dict[str, Any]) -> None:
176
+ original = data[data_key] or {}
177
+ for key, value in new.items():
178
+ original[key] = value
179
+ data[data_key] = original
180
+
181
+
182
+ def validate_vanilla_requests_kwargs(data: dict[str, Any]) -> None:
183
+ """Check arguments for `requests.Session.request`.
184
+
185
+ Some arguments can be valid for cases like ASGI integration, but at the same time they won't work for the regular
186
+ `requests` calls. In such cases we need to avoid an obscure error message, that comes from `requests`.
187
+ """
188
+ url = data["url"]
189
+ if not urlparse(url).netloc:
190
+ raise RuntimeError(
191
+ "The URL should be absolute, so Schemathesis knows where to send the data. \n"
192
+ f"If you use the ASGI integration, please supply your test client "
193
+ f"as the `session` argument to `call`.\nURL: {url}"
194
+ )
195
+
196
+
197
+ @dataclass
198
+ class ASGITransport(RequestsTransport):
199
+ app: ASGI2App | ASGI3App
200
+
201
+ def send(
202
+ self,
203
+ case: Case,
204
+ *,
205
+ session: requests.Session | None = None,
206
+ base_url: str | None = None,
207
+ headers: dict[str, Any] | None = None,
208
+ params: dict[str, Any] | None = None,
209
+ cookies: dict[str, Any] | None = None,
210
+ **kwargs: Any,
211
+ ) -> requests.Response:
212
+ from starlette_testclient import TestClient as ASGIClient
213
+
214
+ if base_url is None:
215
+ base_url = case.get_full_base_url()
216
+ with ASGIClient(self.app) as client:
217
+ return super().send(
218
+ case, session=client, base_url=base_url, headers=headers, params=params, cookies=cookies, **kwargs
219
+ )
220
+
221
+
222
+ @dataclass
223
+ class WSGITransport:
224
+ app: WSGIApplication
225
+
226
+ def serialize_case(
227
+ self,
228
+ case: Case,
229
+ *,
230
+ base_url: str | None = None,
231
+ headers: dict[str, Any] | None = None,
232
+ params: dict[str, Any] | None = None,
233
+ cookies: dict[str, Any] | None = None,
234
+ ) -> dict[str, Any]:
235
+ final_headers = case._get_headers(headers)
236
+ if case.media_type and not isinstance(case.body, NotSet):
237
+ # If we need to send a payload, then the Content-Type header should be set
238
+ final_headers["Content-Type"] = case.media_type
239
+ extra: dict[str, Any]
240
+ serializer = case._get_serializer()
241
+ if serializer is not None and not isinstance(case.body, NotSet):
242
+ context = SerializerContext(case=case)
243
+ extra = serializer.as_werkzeug(context, case._get_body())
244
+ else:
245
+ extra = {}
246
+ data = {
247
+ "method": case.method,
248
+ "path": case.operation.schema.get_full_path(case.formatted_path),
249
+ # Convert to a regular dictionary, as we use `CaseInsensitiveDict` which is not supported by Werkzeug
250
+ "headers": dict(final_headers),
251
+ "query_string": case.query,
252
+ **extra,
253
+ }
254
+ if params is not None:
255
+ _merge_dict_to(data, "query_string", params)
256
+ return data
257
+
258
+ def send(
259
+ self,
260
+ case: Case,
261
+ *,
262
+ session: Any = None,
263
+ base_url: str | None = None,
264
+ headers: dict[str, Any] | None = None,
265
+ params: dict[str, Any] | None = None,
266
+ cookies: dict[str, Any] | None = None,
267
+ **kwargs: Any,
268
+ ) -> WSGIResponse:
269
+ import requests
270
+ import werkzeug
271
+
272
+ from .responses import WSGIResponse
273
+
274
+ application = kwargs.pop("app", self.app) or self.app
275
+ data = self.serialize_case(case, headers=headers, params=params)
276
+ data.update(kwargs)
277
+ client = werkzeug.Client(application, WSGIResponse)
278
+ cookies = {**(case.cookies or {}), **(cookies or {})}
279
+ with cookie_handler(client, cookies), case.operation.schema.ratelimit():
280
+ start = time.monotonic()
281
+ response = client.open(**data)
282
+ elapsed = time.monotonic() - start
283
+ requests_kwargs = RequestsTransport().serialize_case(
284
+ case,
285
+ base_url=case.get_full_base_url(),
286
+ headers=headers,
287
+ params=params,
288
+ cookies=cookies,
289
+ )
290
+ response.request = requests.Request(**requests_kwargs).prepare()
291
+ response.elapsed = timedelta(seconds=elapsed)
292
+ return response
293
+
294
+
295
+ @contextmanager
296
+ def cookie_handler(client: werkzeug.Client, cookies: Cookies | None) -> Generator[None, None, None]:
297
+ """Set cookies required for a call."""
298
+ if not cookies:
299
+ yield
300
+ else:
301
+ for key, value in cookies.items():
302
+ if IS_WERKZEUG_ABOVE_3:
303
+ client.set_cookie(key=key, value=value, domain="localhost")
304
+ else:
305
+ client.set_cookie("localhost", key=key, value=value)
306
+ yield
307
+ for key in cookies:
308
+ if IS_WERKZEUG_ABOVE_3:
309
+ client.delete_cookie(key=key, domain="localhost")
310
+ else:
311
+ client.delete_cookie("localhost", key=key)