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.
@@ -1769,6 +1769,6 @@ def after_init_cli_run_handlers(
1769
1769
  def process_call_kwargs(context: HookContext, case: Case, kwargs: dict[str, Any]) -> None:
1770
1770
  """Called before every network call in CLI tests.
1771
1771
 
1772
- Aims to modify the argument passed to `case.call` / `case.call_wsgi` / `case.call_asgi`.
1772
+ Aims to modify the argument passed to `case.call`.
1773
1773
  Note that you need to modify `kwargs` in-place.
1774
1774
  """
@@ -1,5 +1,8 @@
1
1
  from __future__ import annotations
2
+
3
+ import base64
2
4
  import platform
5
+ import textwrap
3
6
  from dataclasses import dataclass, field
4
7
  from typing import TYPE_CHECKING
5
8
 
@@ -7,12 +10,14 @@ from junit_xml import TestCase, TestSuite, to_xml_report_file
7
10
 
8
11
  from ..models import Status
9
12
  from ..runner import events
10
- from ..runner.serialization import deduplicate_failures
13
+ from ..runner.serialization import SerializedCheck, SerializedError
14
+ from ..exceptions import prepare_response_payload, RuntimeErrorType
11
15
  from .handlers import EventHandler
12
-
16
+ from .reporting import group_by_case, TEST_CASE_ID_TITLE, split_traceback, get_runtime_error_suggestion
13
17
 
14
18
  if TYPE_CHECKING:
15
19
  from click.utils import LazyFile
20
+
16
21
  from .context import ExecutionContext
17
22
 
18
23
 
@@ -29,15 +34,79 @@ class JunitXMLHandler(EventHandler):
29
34
  allow_multiple_subelements=True,
30
35
  )
31
36
  if event.status == Status.failure:
32
- checks = deduplicate_failures(event.result.checks)
33
- for idx, check in enumerate(checks, 1):
34
- # `check.message` is always not empty for events with `failure` status
35
- test_case.add_failure_info(message=f"{idx}. {check.message}")
36
- if event.status == Status.error:
37
- test_case.add_error_info(
38
- message=event.result.errors[-1].exception, output=event.result.errors[-1].exception_with_traceback
39
- )
37
+ for idx, (code_sample, group) in enumerate(
38
+ group_by_case(event.result.checks, context.code_sample_style), 1
39
+ ):
40
+ checks = sorted(group, key=lambda c: c.name != "not_a_server_error")
41
+ test_case.add_failure_info(message=build_failure_message(idx, code_sample, checks))
42
+ elif event.status == Status.error:
43
+ test_case.add_error_info(message=build_error_message(context, event.result.errors[-1]))
44
+ elif event.status == Status.skip:
45
+ test_case.add_skipped_info(message=event.result.skip_reason)
40
46
  self.test_cases.append(test_case)
41
47
  if isinstance(event, events.Finished):
42
48
  test_suites = [TestSuite("schemathesis", test_cases=self.test_cases, hostname=platform.node())]
43
49
  to_xml_report_file(file_descriptor=self.file_handle, test_suites=test_suites, prettyprint=True)
50
+
51
+
52
+ def build_failure_message(idx: int, code_sample: str, checks: list[SerializedCheck]) -> str:
53
+ from ..transports.responses import get_reason
54
+
55
+ message = ""
56
+ for check_idx, check in enumerate(checks):
57
+ if check_idx == 0:
58
+ message += f"{idx}. {TEST_CASE_ID_TITLE}: {check.example.id}\n"
59
+ message += f"\n- {check.title}\n"
60
+ formatted_message = check.formatted_message
61
+ if formatted_message:
62
+ message += f"\n{formatted_message}\n"
63
+ if check_idx + 1 == len(checks):
64
+ if check.response is not None:
65
+ status_code = check.response.status_code
66
+ reason = get_reason(status_code)
67
+ message += f"\n[{check.response.status_code}] {reason}:\n"
68
+ response_body = check.response.body
69
+ if response_body is not None:
70
+ if not response_body:
71
+ message += "\n <EMPTY>\n"
72
+ else:
73
+ encoding = check.response.encoding or "utf8"
74
+ try:
75
+ payload = base64.b64decode(response_body).decode(encoding)
76
+ payload = prepare_response_payload(payload)
77
+ payload = textwrap.indent(f"\n`{payload}`\n", prefix=" ")
78
+ message += payload
79
+ except UnicodeDecodeError:
80
+ message += "\n <BINARY>\n"
81
+
82
+ message += f"\nReproduce with: \n\n {code_sample}"
83
+ return message
84
+
85
+
86
+ def build_error_message(context: ExecutionContext, error: SerializedError) -> str:
87
+ message = ""
88
+ if error.title:
89
+ if error.type == RuntimeErrorType.SCHEMA_GENERIC:
90
+ message = "Schema Error\n"
91
+ else:
92
+ message = f"{error.title}\n"
93
+ if error.message:
94
+ message += f"\n{error.message}\n"
95
+ elif error.message:
96
+ message = error.message
97
+ else:
98
+ message = error.exception
99
+ if error.extras:
100
+ extras = error.extras
101
+ elif context.show_trace and error.type.has_useful_traceback:
102
+ extras = split_traceback(error.exception_with_traceback)
103
+ else:
104
+ extras = []
105
+ if extras:
106
+ message += "\n"
107
+ for extra in extras:
108
+ message += f" {extra}\n"
109
+ suggestion = get_runtime_error_suggestion(error.type, bold=str)
110
+ if suggestion is not None:
111
+ message += f"\nTip: {suggestion}"
112
+ return message
@@ -7,14 +7,12 @@ import shutil
7
7
  import textwrap
8
8
  import time
9
9
  from importlib import metadata
10
- from itertools import groupby
11
10
  from queue import Queue
12
11
  from typing import Any, Generator, cast, TYPE_CHECKING
13
12
 
14
13
  import click
15
14
 
16
15
  from ... import service
17
- from ...code_samples import CodeSampleStyle
18
16
  from ...constants import (
19
17
  DISCORD_LINK,
20
18
  FALSE_VALUES,
@@ -37,10 +35,11 @@ from ...models import Status
37
35
  from ...runner import events
38
36
  from ...runner.events import InternalErrorType, SchemaErrorType
39
37
  from ...runner.probes import ProbeOutcome
40
- from ...runner.serialization import SerializedCheck, SerializedError, SerializedTestResult, deduplicate_failures
38
+ from ...runner.serialization import SerializedError, SerializedTestResult
41
39
  from ...service.models import AnalysisSuccess, UnknownExtension, ErrorState
42
40
  from ..context import ExecutionContext, FileReportContext, ServiceReportContext
43
41
  from ..handlers import EventHandler
42
+ from ..reporting import group_by_case, TEST_CASE_ID_TITLE, split_traceback, get_runtime_error_suggestion
44
43
 
45
44
  if TYPE_CHECKING:
46
45
  import requests
@@ -256,11 +255,11 @@ def _display_error(context: ExecutionContext, error: SerializedError) -> bool:
256
255
  if error.extras:
257
256
  extras = error.extras
258
257
  elif context.show_trace and error.type.has_useful_traceback:
259
- extras = _split_traceback(error.exception_with_traceback)
258
+ extras = split_traceback(error.exception_with_traceback)
260
259
  else:
261
260
  extras = []
262
261
  _display_extras(extras)
263
- suggestion = RUNTIME_ERROR_SUGGESTIONS.get(error.type)
262
+ suggestion = get_runtime_error_suggestion(error.type)
264
263
  _maybe_display_tip(suggestion)
265
264
  return display_full_traceback_message(error)
266
265
 
@@ -279,9 +278,6 @@ def display_failures(context: ExecutionContext, event: events.Finished) -> None:
279
278
  display_failures_for_single_test(context, result)
280
279
 
281
280
 
282
- TEST_CASE_ID_TITLE = "Test Case ID"
283
-
284
-
285
281
  def display_failures_for_single_test(context: ExecutionContext, result: SerializedTestResult) -> None:
286
282
  """Display a failure for a single method / path."""
287
283
  from ...transports.responses import get_reason
@@ -297,18 +293,9 @@ def display_failures_for_single_test(context: ExecutionContext, result: Serializ
297
293
  for check_idx, check in enumerate(checks):
298
294
  if check_idx == 0:
299
295
  click.secho(f"{idx}. {TEST_CASE_ID_TITLE}: {check.example.id}", bold=True)
300
- if check.context is not None:
301
- title = check.context.title
302
- if check.context.message:
303
- message = check.context.message
304
- else:
305
- message = None
306
- else:
307
- title = f"Custom check failed: `{check.name}`"
308
- message = check.message
309
- click.secho(f"\n- {title}", fg="red", bold=True)
296
+ click.secho(f"\n- {check.title}", fg="red", bold=True)
297
+ message = check.formatted_message
310
298
  if message:
311
- message = textwrap.indent(message, prefix=" ")
312
299
  click.secho(f"\n{message}", fg="red")
313
300
  if check_idx + 1 == len(checks):
314
301
  if check.response is not None:
@@ -316,9 +303,8 @@ def display_failures_for_single_test(context: ExecutionContext, result: Serializ
316
303
  reason = get_reason(status_code)
317
304
  response = bold(f"[{check.response.status_code}] {reason}")
318
305
  click.echo(f"\n{response}:")
319
-
320
306
  response_body = check.response.body
321
- if check.response is not None and response_body is not None:
307
+ if response_body is not None:
322
308
  if not response_body:
323
309
  click.echo("\n <EMPTY>")
324
310
  else:
@@ -336,26 +322,6 @@ def display_failures_for_single_test(context: ExecutionContext, result: Serializ
336
322
  )
337
323
 
338
324
 
339
- def group_by_case(
340
- checks: list[SerializedCheck], code_sample_style: CodeSampleStyle
341
- ) -> Generator[tuple[str, Generator[SerializedCheck, None, None]], None, None]:
342
- checks = deduplicate_failures(checks)
343
- checks = sorted(checks, key=lambda c: _by_unique_code_sample(c, code_sample_style))
344
- yield from groupby(checks, lambda c: _by_unique_code_sample(c, code_sample_style))
345
-
346
-
347
- def _by_unique_code_sample(check: SerializedCheck, code_sample_style: CodeSampleStyle) -> str:
348
- request_body = base64.b64decode(check.example.body).decode() if check.example.body is not None else None
349
- return code_sample_style.generate(
350
- method=check.example.method,
351
- url=check.example.url,
352
- body=request_body,
353
- headers=check.example.headers,
354
- verify=check.example.verify,
355
- extra_headers=check.example.extra_headers,
356
- )
357
-
358
-
359
325
  def display_application_logs(context: ExecutionContext, event: events.Finished) -> None:
360
326
  """Print logs captured during the application run."""
361
327
  if not event.has_logs:
@@ -436,7 +402,7 @@ def display_analysis(context: ExecutionContext) -> None:
436
402
  title = "Network Error"
437
403
  else:
438
404
  traceback = format_exception(exception, True)
439
- extras = _split_traceback(traceback)
405
+ extras = split_traceback(traceback)
440
406
  title = "Internal Error"
441
407
  message = f"We apologize for the inconvenience. This appears to be an internal issue.\nPlease, consider reporting the following details to our issue tracker:\n\n {ISSUE_TRACKER_URL}"
442
408
  suggestion = "Please update your CLI to the latest version and try again."
@@ -719,10 +685,6 @@ def should_skip_suggestion(context: ExecutionContext, event: events.InternalErro
719
685
  return event.subtype == SchemaErrorType.CONNECTION_OTHER and context.wait_for_schema is not None
720
686
 
721
687
 
722
- def _split_traceback(traceback: str) -> list[str]:
723
- return [entry for entry in traceback.splitlines() if entry]
724
-
725
-
726
688
  def _display_extras(extras: list[str]) -> None:
727
689
  if extras:
728
690
  click.echo()
@@ -743,7 +705,7 @@ def display_internal_error(context: ExecutionContext, event: events.InternalErro
743
705
  if event.type == InternalErrorType.SCHEMA:
744
706
  extras = event.extras
745
707
  elif context.show_trace:
746
- extras = _split_traceback(event.exception_with_traceback)
708
+ extras = split_traceback(event.exception_with_traceback)
747
709
  else:
748
710
  extras = [event.exception]
749
711
  _display_extras(extras)
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from itertools import groupby
5
+ from typing import Callable, Generator
6
+
7
+ import click
8
+
9
+ from ..code_samples import CodeSampleStyle
10
+ from ..exceptions import RuntimeErrorType
11
+ from ..runner.serialization import SerializedCheck, deduplicate_failures
12
+
13
+ TEST_CASE_ID_TITLE = "Test Case ID"
14
+
15
+
16
+ def group_by_case(
17
+ checks: list[SerializedCheck], code_sample_style: CodeSampleStyle
18
+ ) -> Generator[tuple[str, Generator[SerializedCheck, None, None]], None, None]:
19
+ checks = deduplicate_failures(checks)
20
+ checks = sorted(checks, key=lambda c: _by_unique_code_sample(c, code_sample_style))
21
+ yield from groupby(checks, lambda c: _by_unique_code_sample(c, code_sample_style))
22
+
23
+
24
+ def _by_unique_code_sample(check: SerializedCheck, code_sample_style: CodeSampleStyle) -> str:
25
+ request_body = base64.b64decode(check.example.body).decode() if check.example.body is not None else None
26
+ return code_sample_style.generate(
27
+ method=check.example.method,
28
+ url=check.example.url,
29
+ body=request_body,
30
+ headers=check.example.headers,
31
+ verify=check.example.verify,
32
+ extra_headers=check.example.extra_headers,
33
+ )
34
+
35
+
36
+ def split_traceback(traceback: str) -> list[str]:
37
+ return [entry for entry in traceback.splitlines() if entry]
38
+
39
+
40
+ def bold(option: str) -> str:
41
+ return click.style(option, bold=True)
42
+
43
+
44
+ def get_runtime_error_suggestion(error_type: RuntimeErrorType, bold: Callable[[str], str] = bold) -> str | None:
45
+ DISABLE_SSL_SUGGESTION = f"Bypass SSL verification with {bold('`--request-tls-verify=false`')}."
46
+ DISABLE_SCHEMA_VALIDATION_SUGGESTION = (
47
+ f"Bypass validation using {bold('`--validate-schema=false`')}. Caution: May cause unexpected errors."
48
+ )
49
+
50
+ def _format_health_check_suggestion(label: str) -> str:
51
+ return f"Bypass this health check using {bold(f'`--hypothesis-suppress-health-check={label}`')}."
52
+
53
+ RUNTIME_ERROR_SUGGESTIONS = {
54
+ RuntimeErrorType.CONNECTION_SSL: DISABLE_SSL_SUGGESTION,
55
+ RuntimeErrorType.HYPOTHESIS_DEADLINE_EXCEEDED: (
56
+ f"Adjust the deadline using {bold('`--hypothesis-deadline=MILLIS`')} or "
57
+ f"disable with {bold('`--hypothesis-deadline=None`')}."
58
+ ),
59
+ RuntimeErrorType.HYPOTHESIS_UNSATISFIABLE: "Examine the schema for inconsistencies and consider simplifying it.",
60
+ RuntimeErrorType.SCHEMA_BODY_IN_GET_REQUEST: DISABLE_SCHEMA_VALIDATION_SUGGESTION,
61
+ RuntimeErrorType.SCHEMA_INVALID_REGULAR_EXPRESSION: "Ensure your regex is compatible with Python's syntax.\n"
62
+ "For guidance, visit: https://docs.python.org/3/library/re.html",
63
+ RuntimeErrorType.HYPOTHESIS_UNSUPPORTED_GRAPHQL_SCALAR: "Define a custom strategy for it.\n"
64
+ "For guidance, visit: https://schemathesis.readthedocs.io/en/stable/graphql.html#custom-scalars",
65
+ RuntimeErrorType.HYPOTHESIS_HEALTH_CHECK_DATA_TOO_LARGE: _format_health_check_suggestion("data_too_large"),
66
+ RuntimeErrorType.HYPOTHESIS_HEALTH_CHECK_FILTER_TOO_MUCH: _format_health_check_suggestion("filter_too_much"),
67
+ RuntimeErrorType.HYPOTHESIS_HEALTH_CHECK_TOO_SLOW: _format_health_check_suggestion("too_slow"),
68
+ RuntimeErrorType.HYPOTHESIS_HEALTH_CHECK_LARGE_BASE_EXAMPLE: _format_health_check_suggestion(
69
+ "large_base_example"
70
+ ),
71
+ }
72
+ return RUNTIME_ERROR_SUGGESTIONS.get(error_type)
schemathesis/models.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  import datetime
3
4
  import inspect
4
5
  import textwrap
@@ -6,7 +7,7 @@ from collections import Counter
6
7
  from contextlib import contextmanager
7
8
  from dataclasses import dataclass, field
8
9
  from enum import Enum
9
- from functools import partial, lru_cache
10
+ from functools import lru_cache, partial
10
11
  from itertools import chain
11
12
  from logging import LogRecord
12
13
  from typing import (
@@ -25,51 +26,48 @@ from typing import (
25
26
  )
26
27
  from urllib.parse import quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
27
28
 
28
- from urllib3.exceptions import ReadTimeoutError
29
-
30
- from . import failures, serializers
29
+ from . import serializers
31
30
  from ._dependency_versions import IS_WERKZEUG_ABOVE_3
32
31
  from .auths import AuthStorage
33
32
  from .code_samples import CodeSampleStyle
34
- from .generation import DataGenerationMethod, GenerationConfig
35
33
  from .constants import (
36
- DEFAULT_RESPONSE_TIMEOUT,
34
+ NOT_SET,
37
35
  SCHEMATHESIS_TEST_CASE_HEADER,
38
36
  SERIALIZERS_SUGGESTION_MESSAGE,
39
37
  USER_AGENT,
40
- NOT_SET,
41
38
  )
42
39
  from .exceptions import (
43
- maybe_set_assertion_message,
44
40
  CheckFailed,
45
41
  FailureContext,
46
42
  OperationSchemaError,
47
43
  SerializationNotPossible,
44
+ SkipTest,
48
45
  deduplicate_failed_checks,
49
46
  get_grouped_exception,
50
- get_timeout_error,
47
+ maybe_set_assertion_message,
51
48
  prepare_response_payload,
52
- SkipTest,
53
49
  )
54
- from .internal.deprecation import deprecated_property
55
- from .internal.copy import fast_deepcopy
50
+ from .generation import DataGenerationMethod, GenerationConfig, generate_random_case_id
56
51
  from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, dispatch
52
+ from .internal.copy import fast_deepcopy
53
+ from .internal.deprecation import deprecated_property, deprecated_function
57
54
  from .parameters import Parameter, ParameterSet, PayloadAlternatives
58
55
  from .sanitization import sanitize_request, sanitize_response
59
- from .serializers import Serializer, SerializerContext
60
- from .transports import serialize_payload
56
+ from .serializers import Serializer
57
+ from .transports import ASGITransport, RequestsTransport, WSGITransport, serialize_payload
61
58
  from .types import Body, Cookies, FormData, Headers, NotSet, PathParameters, Query
62
- from .generation import generate_random_case_id
63
59
 
64
60
  if TYPE_CHECKING:
65
- import werkzeug
66
61
  import unittest
67
- from requests.structures import CaseInsensitiveDict
68
- from hypothesis import strategies as st
62
+
69
63
  import requests.auth
70
- from .transports.responses import GenericResponse, WSGIResponse
64
+ import werkzeug
65
+ from hypothesis import strategies as st
66
+ from requests.structures import CaseInsensitiveDict
67
+
71
68
  from .schemas import BaseSchema
72
69
  from .stateful import Stateful, StatefulTest
70
+ from .transports.responses import GenericResponse, WSGIResponse
73
71
 
74
72
 
75
73
  @dataclass
@@ -86,7 +84,7 @@ class CaseSource:
86
84
 
87
85
  def cant_serialize(media_type: str) -> NoReturn: # type: ignore
88
86
  """Reject the current example if we don't know how to send this data to the application."""
89
- from hypothesis import note, event, reject
87
+ from hypothesis import event, note, reject
90
88
 
91
89
  event_text = f"Can't serialize data to `{media_type}`."
92
90
  note(f"{event_text} {SERIALIZERS_SUGGESTION_MESSAGE}")
@@ -210,7 +208,7 @@ class Case:
210
208
 
211
209
  def prepare_code_sample_data(self, headers: dict[str, Any] | None) -> PreparedRequestData:
212
210
  base_url = self.get_full_base_url()
213
- kwargs = self.as_requests_kwargs(base_url, headers=headers)
211
+ kwargs = RequestsTransport().serialize_case(self, base_url=base_url, headers=headers)
214
212
  return prepare_request_data(kwargs)
215
213
 
216
214
  def get_code_to_reproduce(
@@ -286,40 +284,17 @@ class Case:
286
284
  return cls()
287
285
  return None
288
286
 
287
+ def _get_body(self) -> Body | NotSet:
288
+ return self.body
289
+
290
+ @deprecated_function(removed_in="4.0", replacement="Case.as_transport_kwargs")
289
291
  def as_requests_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
290
292
  """Convert the case into a dictionary acceptable by requests."""
291
- final_headers = self._get_headers(headers)
292
- if self.media_type and self.media_type != "multipart/form-data" and not isinstance(self.body, NotSet):
293
- # `requests` will handle multipart form headers with the proper `boundary` value.
294
- if "content-type" not in {header.lower() for header in final_headers}:
295
- final_headers["Content-Type"] = self.media_type
296
- base_url = self._get_base_url(base_url)
297
- formatted_path = self.formatted_path.lstrip("/")
298
- if not base_url.endswith("/"):
299
- base_url += "/"
300
- url = unquote(urljoin(base_url, quote(formatted_path)))
301
- extra: dict[str, Any]
302
- serializer = self._get_serializer()
303
- if serializer is not None and not isinstance(self.body, NotSet):
304
- context = SerializerContext(case=self)
305
- extra = serializer.as_requests(context, self.body)
306
- else:
307
- extra = {}
308
- if self._auth is not None:
309
- extra["auth"] = self._auth
310
- additional_headers = extra.pop("headers", None)
311
- if additional_headers:
312
- # Additional headers, needed for the serializer
313
- for key, value in additional_headers.items():
314
- final_headers.setdefault(key, value)
315
- return {
316
- "method": self.method,
317
- "url": url,
318
- "cookies": self.cookies,
319
- "headers": final_headers,
320
- "params": self.query,
321
- **extra,
322
- }
293
+ return RequestsTransport().serialize_case(self, base_url=base_url, headers=headers)
294
+
295
+ def as_transport_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
296
+ """Convert the test case into a dictionary acceptable by the underlying transport call."""
297
+ return self.operation.schema.transport.serialize_case(self, base_url=base_url, headers=headers)
323
298
 
324
299
  def call(
325
300
  self,
@@ -329,83 +304,21 @@ class Case:
329
304
  params: dict[str, Any] | None = None,
330
305
  cookies: dict[str, Any] | None = None,
331
306
  **kwargs: Any,
332
- ) -> requests.Response:
333
- import requests
334
-
335
- """Make a network call with `requests`."""
307
+ ) -> GenericResponse:
336
308
  hook_context = HookContext(operation=self.operation)
337
309
  dispatch("before_call", hook_context, self)
338
- data = self.as_requests_kwargs(base_url, headers)
339
- data.update(kwargs)
340
- if params is not None:
341
- _merge_dict_to(data, "params", params)
342
- if cookies is not None:
343
- _merge_dict_to(data, "cookies", cookies)
344
- data.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
345
- if session is None:
346
- validate_vanilla_requests_kwargs(data)
347
- session = requests.Session()
348
- close_session = True
349
- else:
350
- close_session = False
351
- verify = data.get("verify", True)
352
- try:
353
- with self.operation.schema.ratelimit():
354
- response = session.request(**data) # type: ignore
355
- except (requests.Timeout, requests.ConnectionError) as exc:
356
- if isinstance(exc, requests.ConnectionError):
357
- if not isinstance(exc.args[0], ReadTimeoutError):
358
- raise
359
- req = requests.Request(
360
- method=data["method"].upper(),
361
- url=data["url"],
362
- headers=data["headers"],
363
- files=data.get("files"),
364
- data=data.get("data") or {},
365
- json=data.get("json"),
366
- params=data.get("params") or {},
367
- auth=data.get("auth"),
368
- cookies=data["cookies"],
369
- hooks=data.get("hooks"),
370
- )
371
- request = session.prepare_request(req)
372
- else:
373
- request = cast(requests.PreparedRequest, exc.request)
374
- timeout = 1000 * data["timeout"] # It is defined and not empty, since the exception happened
375
- code_message = self._get_code_message(self.operation.schema.code_sample_style, request, verify=verify)
376
- message = f"The server failed to respond within the specified limit of {timeout:.2f}ms"
377
- raise get_timeout_error(timeout)(
378
- f"\n\n1. {failures.RequestTimeout.title}\n\n{message}\n\n{code_message}",
379
- context=failures.RequestTimeout(message=message, timeout=timeout),
380
- ) from None
381
- response.verify = verify # type: ignore[attr-defined]
310
+ response = self.operation.schema.transport.send(
311
+ self, session=session, base_url=base_url, headers=headers, params=params, cookies=cookies, **kwargs
312
+ )
382
313
  dispatch("after_call", hook_context, self, response)
383
- if close_session:
384
- session.close()
385
314
  return response
386
315
 
316
+ @deprecated_function(removed_in="4.0", replacement="Case.as_transport_kwargs")
387
317
  def as_werkzeug_kwargs(self, headers: dict[str, str] | None = None) -> dict[str, Any]:
388
318
  """Convert the case into a dictionary acceptable by werkzeug.Client."""
389
- final_headers = self._get_headers(headers)
390
- if self.media_type and not isinstance(self.body, NotSet):
391
- # If we need to send a payload, then the Content-Type header should be set
392
- final_headers["Content-Type"] = self.media_type
393
- extra: dict[str, Any]
394
- serializer = self._get_serializer()
395
- if serializer is not None and not isinstance(self.body, NotSet):
396
- context = SerializerContext(case=self)
397
- extra = serializer.as_werkzeug(context, self.body)
398
- else:
399
- extra = {}
400
- return {
401
- "method": self.method,
402
- "path": self.operation.schema.get_full_path(self.formatted_path),
403
- # Convert to a regular dictionary, as we use `CaseInsensitiveDict` which is not supported by Werkzeug
404
- "headers": dict(final_headers),
405
- "query_string": self.query,
406
- **extra,
407
- }
319
+ return WSGITransport(self.app).serialize_case(self, headers=headers)
408
320
 
321
+ @deprecated_function(removed_in="4.0", replacement="Case.call")
409
322
  def call_wsgi(
410
323
  self,
411
324
  app: Any = None,
@@ -413,10 +326,6 @@ class Case:
413
326
  query_string: dict[str, str] | None = None,
414
327
  **kwargs: Any,
415
328
  ) -> WSGIResponse:
416
- from .transports.responses import WSGIResponse
417
- import werkzeug
418
- import requests
419
-
420
329
  application = app or self.app
421
330
  if application is None:
422
331
  raise RuntimeError(
@@ -425,17 +334,11 @@ class Case:
425
334
  )
426
335
  hook_context = HookContext(operation=self.operation)
427
336
  dispatch("before_call", hook_context, self)
428
- data = self.as_werkzeug_kwargs(headers)
429
- if query_string is not None:
430
- _merge_dict_to(data, "query_string", query_string)
431
- client = werkzeug.Client(application, WSGIResponse)
432
- with cookie_handler(client, self.cookies), self.operation.schema.ratelimit():
433
- response = client.open(**data, **kwargs)
434
- requests_kwargs = self.as_requests_kwargs(base_url=self.get_full_base_url(), headers=headers)
435
- response.request = requests.Request(**requests_kwargs).prepare()
337
+ response = WSGITransport(application).send(self, headers=headers, params=query_string, **kwargs)
436
338
  dispatch("after_call", hook_context, self, response)
437
339
  return response
438
340
 
341
+ @deprecated_function(removed_in="4.0", replacement="Case.call")
439
342
  def call_asgi(
440
343
  self,
441
344
  app: Any = None,
@@ -443,19 +346,17 @@ class Case:
443
346
  headers: dict[str, str] | None = None,
444
347
  **kwargs: Any,
445
348
  ) -> requests.Response:
446
- from starlette_testclient import TestClient as ASGIClient
447
-
448
349
  application = app or self.app
449
350
  if application is None:
450
351
  raise RuntimeError(
451
352
  "ASGI application instance is required. "
452
353
  "Please, set `app` argument in the schema constructor or pass it to `call_asgi`"
453
354
  )
454
- if base_url is None:
455
- base_url = self.get_full_base_url()
456
-
457
- with ASGIClient(application) as client:
458
- return self.call(base_url=base_url, session=client, headers=headers, **kwargs)
355
+ hook_context = HookContext(operation=self.operation)
356
+ dispatch("before_call", hook_context, self)
357
+ response = ASGITransport(application).send(self, base_url=base_url, headers=headers, **kwargs)
358
+ dispatch("after_call", hook_context, self, response)
359
+ return response
459
360
 
460
361
  def validate_response(
461
362
  self,
@@ -560,12 +461,19 @@ class Case:
560
461
  self.validate_response(response, checks, code_sample_style=code_sample_style)
561
462
  return response
562
463
 
464
+ def _get_url(self, base_url: str | None) -> str:
465
+ base_url = self._get_base_url(base_url)
466
+ formatted_path = self.formatted_path.lstrip("/")
467
+ if not base_url.endswith("/"):
468
+ base_url += "/"
469
+ return unquote(urljoin(base_url, quote(formatted_path)))
470
+
563
471
  def get_full_url(self) -> str:
564
472
  """Make a full URL to the current API operation, including query parameters."""
565
473
  import requests
566
474
 
567
475
  base_url = self.base_url or "http://127.0.0.1"
568
- kwargs = self.as_requests_kwargs(base_url)
476
+ kwargs = RequestsTransport().serialize_case(self, base_url=base_url)
569
477
  request = requests.Request(**kwargs)
570
478
  prepared = requests.Session().prepare_request(request) # type: ignore
571
479
  return cast(str, prepared.url)
@@ -922,7 +830,7 @@ class Request:
922
830
  import requests
923
831
 
924
832
  base_url = case.get_full_base_url()
925
- kwargs = case.as_requests_kwargs(base_url)
833
+ kwargs = RequestsTransport().serialize_case(case, base_url=base_url)
926
834
  request = requests.Request(**kwargs)
927
835
  prepared = session.prepare_request(request) # type: ignore
928
836
  return cls.from_prepared_request(prepared)