schemathesis 4.0.0a11__py3-none-any.whl → 4.0.0b1__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 (73) hide show
  1. schemathesis/__init__.py +35 -27
  2. schemathesis/auths.py +85 -54
  3. schemathesis/checks.py +65 -36
  4. schemathesis/cli/commands/run/__init__.py +32 -27
  5. schemathesis/cli/commands/run/context.py +6 -1
  6. schemathesis/cli/commands/run/events.py +7 -1
  7. schemathesis/cli/commands/run/executor.py +12 -7
  8. schemathesis/cli/commands/run/handlers/output.py +188 -80
  9. schemathesis/cli/commands/run/validation.py +21 -6
  10. schemathesis/cli/constants.py +1 -1
  11. schemathesis/config/__init__.py +2 -1
  12. schemathesis/config/_generation.py +12 -13
  13. schemathesis/config/_operations.py +14 -0
  14. schemathesis/config/_phases.py +41 -5
  15. schemathesis/config/_projects.py +33 -1
  16. schemathesis/config/_report.py +6 -2
  17. schemathesis/config/_warnings.py +25 -0
  18. schemathesis/config/schema.json +49 -1
  19. schemathesis/core/errors.py +15 -19
  20. schemathesis/core/transport.py +117 -2
  21. schemathesis/engine/context.py +1 -0
  22. schemathesis/engine/errors.py +61 -2
  23. schemathesis/engine/events.py +10 -2
  24. schemathesis/engine/phases/probes.py +3 -0
  25. schemathesis/engine/phases/stateful/__init__.py +2 -1
  26. schemathesis/engine/phases/stateful/_executor.py +38 -5
  27. schemathesis/engine/phases/stateful/context.py +2 -2
  28. schemathesis/engine/phases/unit/_executor.py +36 -7
  29. schemathesis/generation/__init__.py +0 -3
  30. schemathesis/generation/case.py +153 -28
  31. schemathesis/generation/coverage.py +1 -1
  32. schemathesis/generation/hypothesis/builder.py +43 -19
  33. schemathesis/generation/metrics.py +93 -0
  34. schemathesis/generation/modes.py +0 -8
  35. schemathesis/generation/overrides.py +11 -27
  36. schemathesis/generation/stateful/__init__.py +17 -0
  37. schemathesis/generation/stateful/state_machine.py +32 -108
  38. schemathesis/graphql/loaders.py +152 -8
  39. schemathesis/hooks.py +63 -39
  40. schemathesis/openapi/checks.py +82 -20
  41. schemathesis/openapi/generation/filters.py +9 -2
  42. schemathesis/openapi/loaders.py +134 -8
  43. schemathesis/pytest/lazy.py +4 -31
  44. schemathesis/pytest/loaders.py +24 -0
  45. schemathesis/pytest/plugin.py +38 -6
  46. schemathesis/schemas.py +161 -94
  47. schemathesis/specs/graphql/scalars.py +37 -3
  48. schemathesis/specs/graphql/schemas.py +18 -9
  49. schemathesis/specs/openapi/_hypothesis.py +53 -34
  50. schemathesis/specs/openapi/checks.py +111 -47
  51. schemathesis/specs/openapi/expressions/nodes.py +1 -1
  52. schemathesis/specs/openapi/formats.py +30 -3
  53. schemathesis/specs/openapi/media_types.py +44 -1
  54. schemathesis/specs/openapi/negative/__init__.py +5 -3
  55. schemathesis/specs/openapi/negative/mutations.py +2 -2
  56. schemathesis/specs/openapi/parameters.py +0 -3
  57. schemathesis/specs/openapi/schemas.py +14 -93
  58. schemathesis/specs/openapi/stateful/__init__.py +2 -1
  59. schemathesis/specs/openapi/stateful/links.py +1 -63
  60. schemathesis/transport/__init__.py +54 -16
  61. schemathesis/transport/prepare.py +31 -7
  62. schemathesis/transport/requests.py +21 -9
  63. schemathesis/transport/serialization.py +0 -4
  64. schemathesis/transport/wsgi.py +15 -8
  65. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/METADATA +45 -87
  66. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/RECORD +69 -71
  67. schemathesis/contrib/__init__.py +0 -9
  68. schemathesis/contrib/openapi/__init__.py +0 -9
  69. schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
  70. schemathesis/generation/targets.py +0 -69
  71. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/WHEEL +0 -0
  72. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/entry_points.txt +0 -0
  73. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -39,24 +39,59 @@ class PhaseConfig(DiffBase):
39
39
  )
40
40
 
41
41
 
42
+ @dataclass(repr=False)
43
+ class ExamplesPhaseConfig(DiffBase):
44
+ enabled: bool
45
+ fill_missing: bool
46
+ generation: GenerationConfig
47
+ checks: ChecksConfig
48
+
49
+ __slots__ = ("enabled", "fill_missing", "generation", "checks")
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ enabled: bool = True,
55
+ fill_missing: bool = False,
56
+ generation: GenerationConfig | None = None,
57
+ checks: ChecksConfig | None = None,
58
+ ) -> None:
59
+ self.enabled = enabled
60
+ self.fill_missing = fill_missing
61
+ self.generation = generation or GenerationConfig()
62
+ self.checks = checks or ChecksConfig()
63
+
64
+ @classmethod
65
+ def from_dict(cls, data: dict[str, Any]) -> ExamplesPhaseConfig:
66
+ return cls(
67
+ enabled=data.get("enabled", True),
68
+ fill_missing=data.get("fill-missing", False),
69
+ generation=GenerationConfig.from_dict(data.get("generation", {})),
70
+ checks=ChecksConfig.from_dict(data.get("checks", {})),
71
+ )
72
+
73
+
42
74
  @dataclass(repr=False)
43
75
  class CoveragePhaseConfig(DiffBase):
44
76
  enabled: bool
77
+ generate_duplicate_query_parameters: bool
45
78
  generation: GenerationConfig
46
79
  checks: ChecksConfig
47
80
  unexpected_methods: set[str]
48
81
 
49
- __slots__ = ("enabled", "generation", "checks", "unexpected_methods")
82
+ __slots__ = ("enabled", "generate_duplicate_query_parameters", "generation", "checks", "unexpected_methods")
50
83
 
51
84
  def __init__(
52
85
  self,
53
86
  *,
54
87
  enabled: bool = True,
88
+ generate_duplicate_query_parameters: bool = False,
55
89
  generation: GenerationConfig | None = None,
56
90
  checks: ChecksConfig | None = None,
57
91
  unexpected_methods: set[str] | None = None,
58
92
  ) -> None:
59
93
  self.enabled = enabled
94
+ self.generate_duplicate_query_parameters = generate_duplicate_query_parameters
60
95
  self.unexpected_methods = unexpected_methods or DEFAULT_UNEXPECTED_METHODS
61
96
  self.generation = generation or GenerationConfig()
62
97
  self.checks = checks or ChecksConfig()
@@ -65,6 +100,7 @@ class CoveragePhaseConfig(DiffBase):
65
100
  def from_dict(cls, data: dict[str, Any]) -> CoveragePhaseConfig:
66
101
  return cls(
67
102
  enabled=data.get("enabled", True),
103
+ generate_duplicate_query_parameters=data.get("generate-duplicate-query-parameters", False),
68
104
  unexpected_methods={method.lower() for method in data.get("unexpected-methods", [])}
69
105
  if "unexpected-methods" in data
70
106
  else None,
@@ -107,7 +143,7 @@ class StatefulPhaseConfig(DiffBase):
107
143
 
108
144
  @dataclass(repr=False)
109
145
  class PhasesConfig(DiffBase):
110
- examples: PhaseConfig
146
+ examples: ExamplesPhaseConfig
111
147
  coverage: CoveragePhaseConfig
112
148
  fuzzing: PhaseConfig
113
149
  stateful: StatefulPhaseConfig
@@ -117,12 +153,12 @@ class PhasesConfig(DiffBase):
117
153
  def __init__(
118
154
  self,
119
155
  *,
120
- examples: PhaseConfig | None = None,
156
+ examples: ExamplesPhaseConfig | None = None,
121
157
  coverage: CoveragePhaseConfig | None = None,
122
158
  fuzzing: PhaseConfig | None = None,
123
159
  stateful: StatefulPhaseConfig | None = None,
124
160
  ) -> None:
125
- self.examples = examples or PhaseConfig()
161
+ self.examples = examples or ExamplesPhaseConfig()
126
162
  self.coverage = coverage or CoveragePhaseConfig()
127
163
  self.fuzzing = fuzzing or PhaseConfig()
128
164
  self.stateful = stateful or StatefulPhaseConfig()
@@ -138,7 +174,7 @@ class PhasesConfig(DiffBase):
138
174
  @classmethod
139
175
  def from_dict(cls, data: dict[str, Any]) -> PhasesConfig:
140
176
  return cls(
141
- examples=PhaseConfig.from_dict(data.get("examples", {})),
177
+ examples=ExamplesPhaseConfig.from_dict(data.get("examples", {})),
142
178
  coverage=CoveragePhaseConfig.from_dict(data.get("coverage", {})),
143
179
  fuzzing=PhaseConfig.from_dict(data.get("fuzzing", {})),
144
180
  stateful=StatefulPhaseConfig.from_dict(data.get("stateful", {})),
@@ -17,6 +17,7 @@ from schemathesis.config._parameters import load_parameters
17
17
  from schemathesis.config._phases import PhasesConfig
18
18
  from schemathesis.config._rate_limit import build_limiter
19
19
  from schemathesis.config._report import ReportsConfig
20
+ from schemathesis.config._warnings import SchemathesisWarning, resolve_warnings
20
21
  from schemathesis.core import HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER, hooks
21
22
  from schemathesis.core.validation import validate_base_url
22
23
 
@@ -57,6 +58,7 @@ class ProjectConfig(DiffBase):
57
58
  request_cert: str | None
58
59
  request_cert_key: str | None
59
60
  parameters: dict[str, Any]
61
+ warnings: list[SchemathesisWarning] | None
60
62
  auth: AuthConfig
61
63
  checks: ChecksConfig
62
64
  phases: PhasesConfig
@@ -78,6 +80,7 @@ class ProjectConfig(DiffBase):
78
80
  "request_cert",
79
81
  "request_cert_key",
80
82
  "parameters",
83
+ "warnings",
81
84
  "auth",
82
85
  "checks",
83
86
  "phases",
@@ -101,6 +104,7 @@ class ProjectConfig(DiffBase):
101
104
  request_cert: str | None = None,
102
105
  request_cert_key: str | None = None,
103
106
  parameters: dict[str, Any] | None = None,
107
+ warnings: bool | list[SchemathesisWarning] | None = None,
104
108
  auth: AuthConfig | None = None,
105
109
  checks: ChecksConfig | None = None,
106
110
  phases: PhasesConfig | None = None,
@@ -133,6 +137,7 @@ class ProjectConfig(DiffBase):
133
137
  self.request_cert = request_cert
134
138
  self.request_cert_key = request_cert_key
135
139
  self.parameters = parameters or {}
140
+ self._set_warnings(warnings)
136
141
  self.auth = auth or AuthConfig()
137
142
  self.checks = checks or ChecksConfig()
138
143
  self.phases = phases or PhasesConfig()
@@ -157,6 +162,7 @@ class ProjectConfig(DiffBase):
157
162
  request_cert_key=resolve(data.get("request-cert-key")),
158
163
  parameters=load_parameters(data),
159
164
  auth=AuthConfig.from_dict(data.get("auth", {})),
165
+ warnings=resolve_warnings(data.get("warnings")),
160
166
  checks=ChecksConfig.from_dict(data.get("checks", {})),
161
167
  phases=PhasesConfig.from_dict(data.get("phases", {})),
162
168
  generation=GenerationConfig.from_dict(data.get("generation", {})),
@@ -165,6 +171,14 @@ class ProjectConfig(DiffBase):
165
171
  ),
166
172
  )
167
173
 
174
+ def _set_warnings(self, warnings: bool | list[SchemathesisWarning] | None) -> None:
175
+ if warnings is False:
176
+ self.warnings = []
177
+ elif warnings is True:
178
+ self.warnings = list(SchemathesisWarning)
179
+ else:
180
+ self.warnings = warnings
181
+
168
182
  def update(
169
183
  self,
170
184
  *,
@@ -178,8 +192,10 @@ class ProjectConfig(DiffBase):
178
192
  tls_verify: bool | str | None = None,
179
193
  request_cert: str | None = None,
180
194
  request_cert_key: str | None = None,
195
+ parameters: dict[str, Any] | None = None,
181
196
  proxy: str | None = None,
182
197
  suppress_health_check: list[HealthCheck] | None = None,
198
+ warnings: bool | list[SchemathesisWarning] | None = None,
183
199
  ) -> None:
184
200
  if base_url is not None:
185
201
  _validate_base_url(base_url)
@@ -220,9 +236,15 @@ class ProjectConfig(DiffBase):
220
236
  if proxy is not None:
221
237
  self.proxy = proxy
222
238
 
239
+ if parameters is not None:
240
+ self.parameters = parameters
241
+
223
242
  if suppress_health_check is not None:
224
243
  self.suppress_health_check = suppress_health_check
225
244
 
245
+ if warnings is not None:
246
+ self._set_warnings(warnings)
247
+
226
248
  def auth_for(self, *, operation: APIOperation | None = None) -> tuple[str, str] | None:
227
249
  """Get auth credentials, prioritizing operation-specific configs."""
228
250
  if operation is not None:
@@ -233,7 +255,7 @@ class ProjectConfig(DiffBase):
233
255
  return self.auth.basic
234
256
  return None
235
257
 
236
- def headers_for(self, *, operation: APIOperation | None = None) -> dict[str, str] | None:
258
+ def headers_for(self, *, operation: APIOperation | None = None) -> dict[str, str]:
237
259
  """Get explicitly configured headers."""
238
260
  headers = self.headers.copy() if self.headers else {}
239
261
  if operation is not None:
@@ -291,6 +313,16 @@ class ProjectConfig(DiffBase):
291
313
  return self.rate_limit
292
314
  return None
293
315
 
316
+ def warnings_for(self, *, operation: APIOperation | None = None) -> list[SchemathesisWarning]:
317
+ # Operation can be absent on some non-fatal errors due to schema parsing
318
+ if operation is not None:
319
+ config = self.operations.get_for_operation(operation=operation)
320
+ if config.warnings is not None:
321
+ return config.warnings
322
+ if self.warnings is None:
323
+ return list(SchemathesisWarning)
324
+ return self.warnings
325
+
294
326
  def phases_for(self, *, operation: APIOperation | None) -> PhasesConfig:
295
327
  configs = []
296
328
  if operation is not None:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import datetime
3
4
  from dataclasses import dataclass
4
5
  from enum import Enum
5
6
  from pathlib import Path
@@ -55,8 +56,9 @@ class ReportsConfig(DiffBase):
55
56
  junit: ReportConfig
56
57
  vcr: ReportConfig
57
58
  har: ReportConfig
59
+ _timestamp: str
58
60
 
59
- __slots__ = ("directory", "preserve_bytes", "junit", "vcr", "har")
61
+ __slots__ = ("directory", "preserve_bytes", "junit", "vcr", "har", "_timestamp")
60
62
 
61
63
  def __init__(
62
64
  self,
@@ -72,6 +74,7 @@ class ReportsConfig(DiffBase):
72
74
  self.junit = junit or ReportConfig()
73
75
  self.vcr = vcr or ReportConfig()
74
76
  self.har = har or ReportConfig()
77
+ self._timestamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%SZ")
75
78
 
76
79
  @classmethod
77
80
  def from_dict(cls, data: dict[str, Any]) -> ReportsConfig:
@@ -113,4 +116,5 @@ class ReportsConfig(DiffBase):
113
116
  report: ReportConfig = getattr(self, format.value)
114
117
  if report.path is not None:
115
118
  return report.path
116
- return self.directory / f"{format.value}.{format.extension}"
119
+
120
+ return self.directory / f"{format.value}-{self._timestamp}.{format.extension}"
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+
5
+ from schemathesis.config._env import resolve
6
+
7
+
8
+ class SchemathesisWarning(str, enum.Enum):
9
+ MISSING_AUTH = "missing_auth"
10
+ MISSING_TEST_DATA = "missing_test_data"
11
+ VALIDATION_MISMATCH = "validation_mismatch"
12
+
13
+ @classmethod
14
+ def from_str(cls, value: str) -> SchemathesisWarning:
15
+ return {
16
+ "missing_auth": cls.MISSING_AUTH,
17
+ "missing_test_data": cls.MISSING_TEST_DATA,
18
+ "validation_mismatch": cls.VALIDATION_MISMATCH,
19
+ }[value.lower()]
20
+
21
+
22
+ def resolve_warnings(value: bool | list[str] | None) -> bool | list[SchemathesisWarning] | None:
23
+ if isinstance(value, list):
24
+ return [SchemathesisWarning.from_str(resolve(item)) for item in value]
25
+ return value
@@ -121,6 +121,9 @@
121
121
  },
122
122
  "request-cert-key": {
123
123
  "type": "string"
124
+ },
125
+ "warnings": {
126
+ "$ref": "#/$defs/WarningConfig"
124
127
  }
125
128
  },
126
129
  "$defs": {
@@ -242,7 +245,7 @@
242
245
  "additionalProperties": false,
243
246
  "properties": {
244
247
  "examples": {
245
- "$ref": "#/$defs/PhaseConfig"
248
+ "$ref": "#/$defs/ExamplesPhaseConfig"
246
249
  },
247
250
  "coverage": {
248
251
  "$ref": "#/$defs/CoveragePhaseConfig"
@@ -270,6 +273,24 @@
270
273
  }
271
274
  }
272
275
  },
276
+ "ExamplesPhaseConfig": {
277
+ "type": "object",
278
+ "additionalProperties": false,
279
+ "properties": {
280
+ "enabled": {
281
+ "type": "boolean"
282
+ },
283
+ "fill-missing": {
284
+ "type": "boolean"
285
+ },
286
+ "generation": {
287
+ "$ref": "#/$defs/GenerationConfig"
288
+ },
289
+ "checks": {
290
+ "$ref": "#/$defs/ChecksConfig"
291
+ }
292
+ }
293
+ },
273
294
  "StatefulPhaseConfig": {
274
295
  "type": "object",
275
296
  "additionalProperties": false,
@@ -296,6 +317,9 @@
296
317
  "enabled": {
297
318
  "type": "boolean"
298
319
  },
320
+ "generate-duplicate-query-parameters": {
321
+ "type": "boolean"
322
+ },
299
323
  "unexpected-methods": {
300
324
  "type": "array",
301
325
  "items": {
@@ -494,12 +518,33 @@
494
518
  },
495
519
  "request-cert-key": {
496
520
  "type": "string"
521
+ },
522
+ "warnings": {
523
+ "$ref": "#/$defs/WarningConfig"
497
524
  }
498
525
  },
499
526
  "required": [
500
527
  "title"
501
528
  ]
502
529
  },
530
+ "WarningConfig": {
531
+ "anyOf": [
532
+ {
533
+ "type": "boolean"
534
+ },
535
+ {
536
+ "type": "array",
537
+ "uniqueItems": true,
538
+ "items": {
539
+ "enum": [
540
+ "missing_auth",
541
+ "missing_test_data",
542
+ "validation_mismatch"
543
+ ]
544
+ }
545
+ }
546
+ ]
547
+ },
503
548
  "OperationConfig": {
504
549
  "type": "object",
505
550
  "additionalProperties": false,
@@ -541,6 +586,9 @@
541
586
  "request-cert-key": {
542
587
  "type": "string"
543
588
  },
589
+ "warnings": {
590
+ "$ref": "#/$defs/WarningConfig"
591
+ },
544
592
  "checks": {
545
593
  "$ref": "#/$defs/ChecksConfig"
546
594
  },
@@ -20,10 +20,16 @@ if TYPE_CHECKING:
20
20
 
21
21
 
22
22
  SCHEMA_ERROR_SUGGESTION = "Ensure that the definition complies with the OpenAPI specification"
23
- SERIALIZERS_SUGGESTION_MESSAGE = (
24
- "You can register your own serializer with `schemathesis.serializer` "
25
- "and Schemathesis will be able to make API calls with this media type. \n"
26
- "See https://schemathesis.readthedocs.io/en/stable/how.html#payload-serialization for more information."
23
+ SERIALIZERS_DOCUMENTATION_URL = "https://schemathesis.readthedocs.io/en/latest/guides/custom-serializers/"
24
+ SERIALIZERS_SUGGESTION_MESSAGE = f"Check your schema or add custom serializers: {SERIALIZERS_DOCUMENTATION_URL}"
25
+ SERIALIZATION_NOT_POSSIBLE_MESSAGE = f"No supported serializers for media types: {{}}\n{SERIALIZERS_SUGGESTION_MESSAGE}"
26
+ SERIALIZATION_FOR_TYPE_IS_NOT_POSSIBLE_MESSAGE = (
27
+ f"Cannot serialize to '{{}}' (unsupported media type)\n{SERIALIZERS_SUGGESTION_MESSAGE}"
28
+ )
29
+ RECURSIVE_REFERENCE_ERROR_MESSAGE = (
30
+ "Currently, Schemathesis can't generate data for this operation due to "
31
+ "recursive references in the operation definition. See more information in "
32
+ "this issue - https://github.com/schemathesis/schemathesis/issues/947"
27
33
  )
28
34
 
29
35
 
@@ -277,19 +283,6 @@ class UnboundPrefix(SerializationError):
277
283
  super().__init__(UNBOUND_PREFIX_MESSAGE_TEMPLATE.format(prefix=prefix))
278
284
 
279
285
 
280
- SERIALIZATION_NOT_POSSIBLE_MESSAGE = (
281
- f"Schemathesis can't serialize data to any of the defined media types: {{}} \n{SERIALIZERS_SUGGESTION_MESSAGE}"
282
- )
283
- SERIALIZATION_FOR_TYPE_IS_NOT_POSSIBLE_MESSAGE = (
284
- f"Schemathesis can't serialize data to {{}} \n{SERIALIZERS_SUGGESTION_MESSAGE}"
285
- )
286
- RECURSIVE_REFERENCE_ERROR_MESSAGE = (
287
- "Currently, Schemathesis can't generate data for this operation due to "
288
- "recursive references in the operation definition. See more information in "
289
- "this issue - https://github.com/schemathesis/schemathesis/issues/947"
290
- )
291
-
292
-
293
286
  class SerializationNotPossible(SerializationError):
294
287
  """Not possible to serialize data to specified media type(s).
295
288
 
@@ -403,7 +396,10 @@ def get_request_error_extras(exc: RequestException) -> list[str]:
403
396
  return [reason.strip()]
404
397
  return [" ".join(map(_clean_inner_request_message, inner.args))]
405
398
  if isinstance(exc, ChunkedEncodingError):
406
- return [str(exc.args[0].args[1])]
399
+ args = exc.args[0].args
400
+ if len(args) == 1:
401
+ return [str(args[0])]
402
+ return [str(args[1])]
407
403
  return []
408
404
 
409
405
 
@@ -438,7 +434,7 @@ def split_traceback(traceback: str) -> list[str]:
438
434
 
439
435
 
440
436
  def format_exception(
441
- error: Exception,
437
+ error: BaseException,
442
438
  *,
443
439
  with_traceback: bool = False,
444
440
  skip_frames: int = 0,
@@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, Any, Mapping
7
7
  from schemathesis.core.version import SCHEMATHESIS_VERSION
8
8
 
9
9
  if TYPE_CHECKING:
10
+ import httpx
10
11
  import requests
12
+ from werkzeug.test import TestResponse
13
+
14
+ from schemathesis.generation.overrides import Override
11
15
 
12
16
  USER_AGENT = f"schemathesis/{SCHEMATHESIS_VERSION}"
13
17
  DEFAULT_RESPONSE_TIMEOUT = 10
@@ -27,7 +31,31 @@ def prepare_urlencoded(data: Any) -> Any:
27
31
 
28
32
 
29
33
  class Response:
30
- """Unified response for both testing and reporting purposes."""
34
+ """HTTP response wrapper that normalizes different transport implementations.
35
+
36
+ Provides a consistent interface for accessing response data whether the request
37
+ was made via HTTP, ASGI, or WSGI transports.
38
+ """
39
+
40
+ status_code: int
41
+ """HTTP status code (e.g., 200, 404, 500)."""
42
+ headers: dict[str, list[str]]
43
+ """Response headers with lowercase keys and list values."""
44
+ content: bytes
45
+ """Raw response body as bytes."""
46
+ request: requests.PreparedRequest
47
+ """The request that generated this response."""
48
+ elapsed: float
49
+ """Response time in seconds."""
50
+ verify: bool
51
+ """Whether TLS verification was enabled for the request."""
52
+ message: str
53
+ """HTTP status message (e.g., "OK", "Not Found")."""
54
+ http_version: str
55
+ """HTTP protocol version ("1.0" or "1.1")."""
56
+ encoding: str | None
57
+ """Character encoding for text content, if detected."""
58
+ _override: Override | None
31
59
 
32
60
  __slots__ = (
33
61
  "status_code",
@@ -41,6 +69,7 @@ class Response:
41
69
  "http_version",
42
70
  "encoding",
43
71
  "_encoded_body",
72
+ "_override",
44
73
  )
45
74
 
46
75
  def __init__(
@@ -54,6 +83,7 @@ class Response:
54
83
  message: str = "",
55
84
  http_version: str = "1.1",
56
85
  encoding: str | None = None,
86
+ _override: Override | None = None,
57
87
  ):
58
88
  self.status_code = status_code
59
89
  self.headers = {key.lower(): value for key, value in headers.items()}
@@ -67,9 +97,24 @@ class Response:
67
97
  self.message = message
68
98
  self.http_version = http_version
69
99
  self.encoding = encoding
100
+ self._override = _override
70
101
 
71
102
  @classmethod
72
- def from_requests(cls, response: requests.Response, verify: bool) -> Response:
103
+ def from_any(cls, response: Response | httpx.Response | requests.Response | TestResponse) -> Response:
104
+ import httpx
105
+ import requests
106
+ from werkzeug.test import TestResponse
107
+
108
+ if isinstance(response, requests.Response):
109
+ return Response.from_requests(response, verify=True)
110
+ elif isinstance(response, httpx.Response):
111
+ return Response.from_httpx(response, verify=True)
112
+ elif isinstance(response, TestResponse):
113
+ return Response.from_wsgi(response)
114
+ return response
115
+
116
+ @classmethod
117
+ def from_requests(cls, response: requests.Response, verify: bool, _override: Override | None = None) -> Response:
73
118
  raw = response.raw
74
119
  raw_headers = raw.headers if raw is not None else {}
75
120
  headers = {name: response.raw.headers.getlist(name) for name in raw_headers.keys()}
@@ -86,23 +131,93 @@ class Response:
86
131
  encoding=response.encoding,
87
132
  http_version=http_version,
88
133
  verify=verify,
134
+ _override=_override,
135
+ )
136
+
137
+ @classmethod
138
+ def from_httpx(cls, response: httpx.Response, verify: bool) -> Response:
139
+ import requests
140
+
141
+ request = requests.Request(
142
+ method=response.request.method,
143
+ url=str(response.request.url),
144
+ headers=dict(response.request.headers),
145
+ params=dict(response.request.url.params),
146
+ data=response.request.content,
147
+ ).prepare()
148
+ return Response(
149
+ status_code=response.status_code,
150
+ headers={key: [value] for key, value in response.headers.items()},
151
+ content=response.content,
152
+ request=request,
153
+ elapsed=response.elapsed.total_seconds(),
154
+ message=response.reason_phrase,
155
+ encoding=response.encoding,
156
+ http_version=response.http_version,
157
+ verify=verify,
158
+ )
159
+
160
+ @classmethod
161
+ def from_wsgi(cls, response: TestResponse) -> Response:
162
+ import http.client
163
+
164
+ import requests
165
+
166
+ reason = http.client.responses.get(response.status_code, "Unknown")
167
+ data = response.get_data()
168
+ if response.response == []:
169
+ # Werkzeug <3.0 had `charset` attr, newer versions always have UTF-8
170
+ encoding = response.mimetype_params.get("charset", getattr(response, "charset", "utf-8"))
171
+ else:
172
+ encoding = None
173
+ request = requests.Request(
174
+ method=response.request.method,
175
+ url=str(response.request.url),
176
+ headers=dict(response.request.headers),
177
+ params=dict(response.request.args),
178
+ # Request body is not available
179
+ data=b"",
180
+ ).prepare()
181
+ return Response(
182
+ status_code=response.status_code,
183
+ headers={name: response.headers.getlist(name) for name in response.headers.keys()},
184
+ content=data,
185
+ request=request,
186
+ # Elapsed time is not available
187
+ elapsed=0.0,
188
+ message=reason,
189
+ encoding=encoding,
190
+ http_version="1.1",
191
+ verify=False,
89
192
  )
90
193
 
91
194
  @property
92
195
  def text(self) -> str:
196
+ """Decode response content as text using the detected or default encoding."""
93
197
  return self.content.decode(self.encoding if self.encoding else "utf-8")
94
198
 
95
199
  def json(self) -> Any:
200
+ """Parse response content as JSON.
201
+
202
+ Returns:
203
+ Parsed JSON data (dict, list, or primitive types)
204
+
205
+ Raises:
206
+ json.JSONDecodeError: If content is not valid JSON
207
+
208
+ """
96
209
  if self._json is None:
97
210
  self._json = json.loads(self.text)
98
211
  return self._json
99
212
 
100
213
  @property
101
214
  def body_size(self) -> int | None:
215
+ """Size of response body in bytes, or None if no content."""
102
216
  return len(self.content) if self.content else None
103
217
 
104
218
  @property
105
219
  def encoded_body(self) -> str | None:
220
+ """Base64-encoded response body for binary-safe serialization."""
106
221
  if self._encoded_body is None and self.content:
107
222
  self._encoded_body = base64.b64encode(self.content).decode()
108
223
  return self._encoded_body
@@ -80,6 +80,7 @@ class EngineContext:
80
80
  import requests
81
81
 
82
82
  session = requests.Session()
83
+ session.headers = {}
83
84
  config = self.config
84
85
 
85
86
  session.verify = config.tls_verify_for(operation=operation)