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.
- schemathesis/__init__.py +35 -27
- schemathesis/auths.py +85 -54
- schemathesis/checks.py +65 -36
- schemathesis/cli/commands/run/__init__.py +32 -27
- schemathesis/cli/commands/run/context.py +6 -1
- schemathesis/cli/commands/run/events.py +7 -1
- schemathesis/cli/commands/run/executor.py +12 -7
- schemathesis/cli/commands/run/handlers/output.py +188 -80
- schemathesis/cli/commands/run/validation.py +21 -6
- schemathesis/cli/constants.py +1 -1
- schemathesis/config/__init__.py +2 -1
- schemathesis/config/_generation.py +12 -13
- schemathesis/config/_operations.py +14 -0
- schemathesis/config/_phases.py +41 -5
- schemathesis/config/_projects.py +33 -1
- schemathesis/config/_report.py +6 -2
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +49 -1
- schemathesis/core/errors.py +15 -19
- schemathesis/core/transport.py +117 -2
- schemathesis/engine/context.py +1 -0
- schemathesis/engine/errors.py +61 -2
- schemathesis/engine/events.py +10 -2
- schemathesis/engine/phases/probes.py +3 -0
- schemathesis/engine/phases/stateful/__init__.py +2 -1
- schemathesis/engine/phases/stateful/_executor.py +38 -5
- schemathesis/engine/phases/stateful/context.py +2 -2
- schemathesis/engine/phases/unit/_executor.py +36 -7
- schemathesis/generation/__init__.py +0 -3
- schemathesis/generation/case.py +153 -28
- schemathesis/generation/coverage.py +1 -1
- schemathesis/generation/hypothesis/builder.py +43 -19
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +0 -8
- schemathesis/generation/overrides.py +11 -27
- schemathesis/generation/stateful/__init__.py +17 -0
- schemathesis/generation/stateful/state_machine.py +32 -108
- schemathesis/graphql/loaders.py +152 -8
- schemathesis/hooks.py +63 -39
- schemathesis/openapi/checks.py +82 -20
- schemathesis/openapi/generation/filters.py +9 -2
- schemathesis/openapi/loaders.py +134 -8
- schemathesis/pytest/lazy.py +4 -31
- schemathesis/pytest/loaders.py +24 -0
- schemathesis/pytest/plugin.py +38 -6
- schemathesis/schemas.py +161 -94
- schemathesis/specs/graphql/scalars.py +37 -3
- schemathesis/specs/graphql/schemas.py +18 -9
- schemathesis/specs/openapi/_hypothesis.py +53 -34
- schemathesis/specs/openapi/checks.py +111 -47
- schemathesis/specs/openapi/expressions/nodes.py +1 -1
- schemathesis/specs/openapi/formats.py +30 -3
- schemathesis/specs/openapi/media_types.py +44 -1
- schemathesis/specs/openapi/negative/__init__.py +5 -3
- schemathesis/specs/openapi/negative/mutations.py +2 -2
- schemathesis/specs/openapi/parameters.py +0 -3
- schemathesis/specs/openapi/schemas.py +14 -93
- schemathesis/specs/openapi/stateful/__init__.py +2 -1
- schemathesis/specs/openapi/stateful/links.py +1 -63
- schemathesis/transport/__init__.py +54 -16
- schemathesis/transport/prepare.py +31 -7
- schemathesis/transport/requests.py +21 -9
- schemathesis/transport/serialization.py +0 -4
- schemathesis/transport/wsgi.py +15 -8
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/METADATA +45 -87
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/RECORD +69 -71
- schemathesis/contrib/__init__.py +0 -9
- schemathesis/contrib/openapi/__init__.py +0 -9
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
- schemathesis/generation/targets.py +0 -69
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/licenses/LICENSE +0 -0
schemathesis/config/_phases.py
CHANGED
@@ -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:
|
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:
|
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
|
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=
|
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", {})),
|
schemathesis/config/_projects.py
CHANGED
@@ -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]
|
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:
|
schemathesis/config/_report.py
CHANGED
@@ -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
|
-
|
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
|
schemathesis/config/schema.json
CHANGED
@@ -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/
|
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
|
},
|
schemathesis/core/errors.py
CHANGED
@@ -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
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
-
|
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:
|
437
|
+
error: BaseException,
|
442
438
|
*,
|
443
439
|
with_traceback: bool = False,
|
444
440
|
skip_frames: int = 0,
|
schemathesis/core/transport.py
CHANGED
@@ -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
|
-
"""
|
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
|
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
|