schemathesis 4.0.0a11__py3-none-any.whl → 4.0.0a12__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 +28 -25
- schemathesis/auths.py +65 -24
- schemathesis/checks.py +60 -36
- schemathesis/cli/commands/run/__init__.py +23 -21
- 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 +175 -80
- schemathesis/cli/commands/run/validation.py +21 -6
- 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 +28 -0
- schemathesis/config/_report.py +6 -2
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +49 -1
- schemathesis/core/errors.py +5 -2
- schemathesis/core/transport.py +36 -1
- schemathesis/engine/context.py +1 -0
- schemathesis/engine/errors.py +60 -1
- 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 +1 -0
- schemathesis/generation/coverage.py +1 -1
- schemathesis/generation/hypothesis/builder.py +31 -7
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +0 -8
- schemathesis/generation/stateful/__init__.py +4 -0
- schemathesis/generation/stateful/state_machine.py +1 -0
- schemathesis/graphql/loaders.py +138 -4
- schemathesis/hooks.py +62 -35
- schemathesis/openapi/loaders.py +120 -4
- schemathesis/pytest/loaders.py +24 -0
- schemathesis/pytest/plugin.py +22 -0
- schemathesis/schemas.py +9 -6
- schemathesis/specs/graphql/scalars.py +37 -3
- schemathesis/specs/graphql/schemas.py +12 -3
- schemathesis/specs/openapi/_hypothesis.py +14 -20
- schemathesis/specs/openapi/checks.py +21 -18
- schemathesis/specs/openapi/formats.py +30 -3
- schemathesis/specs/openapi/media_types.py +44 -1
- schemathesis/specs/openapi/schemas.py +8 -2
- schemathesis/specs/openapi/stateful/__init__.py +2 -1
- schemathesis/transport/__init__.py +54 -16
- schemathesis/transport/prepare.py +31 -7
- schemathesis/transport/requests.py +9 -8
- schemathesis/transport/wsgi.py +8 -8
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0a12.dist-info}/METADATA +44 -90
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0a12.dist-info}/RECORD +58 -60
- 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.0a12.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0a12.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0a12.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
|
*,
|
@@ -180,6 +194,7 @@ class ProjectConfig(DiffBase):
|
|
180
194
|
request_cert_key: str | None = None,
|
181
195
|
proxy: str | None = None,
|
182
196
|
suppress_health_check: list[HealthCheck] | None = None,
|
197
|
+
warnings: bool | list[SchemathesisWarning] | None = None,
|
183
198
|
) -> None:
|
184
199
|
if base_url is not None:
|
185
200
|
_validate_base_url(base_url)
|
@@ -223,6 +238,9 @@ class ProjectConfig(DiffBase):
|
|
223
238
|
if suppress_health_check is not None:
|
224
239
|
self.suppress_health_check = suppress_health_check
|
225
240
|
|
241
|
+
if warnings is not None:
|
242
|
+
self._set_warnings(warnings)
|
243
|
+
|
226
244
|
def auth_for(self, *, operation: APIOperation | None = None) -> tuple[str, str] | None:
|
227
245
|
"""Get auth credentials, prioritizing operation-specific configs."""
|
228
246
|
if operation is not None:
|
@@ -291,6 +309,16 @@ class ProjectConfig(DiffBase):
|
|
291
309
|
return self.rate_limit
|
292
310
|
return None
|
293
311
|
|
312
|
+
def warnings_for(self, *, operation: APIOperation | None = None) -> list[SchemathesisWarning]:
|
313
|
+
# Operation can be absent on some non-fatal errors due to schema parsing
|
314
|
+
if operation is not None:
|
315
|
+
config = self.operations.get_for_operation(operation=operation)
|
316
|
+
if config.warnings is not None:
|
317
|
+
return config.warnings
|
318
|
+
if self.warnings is None:
|
319
|
+
return list(SchemathesisWarning)
|
320
|
+
return self.warnings
|
321
|
+
|
294
322
|
def phases_for(self, *, operation: APIOperation | None) -> PhasesConfig:
|
295
323
|
configs = []
|
296
324
|
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
@@ -403,7 +403,10 @@ def get_request_error_extras(exc: RequestException) -> list[str]:
|
|
403
403
|
return [reason.strip()]
|
404
404
|
return [" ".join(map(_clean_inner_request_message, inner.args))]
|
405
405
|
if isinstance(exc, ChunkedEncodingError):
|
406
|
-
|
406
|
+
args = exc.args[0].args
|
407
|
+
if len(args) == 1:
|
408
|
+
return [str(args[0])]
|
409
|
+
return [str(args[1])]
|
407
410
|
return []
|
408
411
|
|
409
412
|
|
@@ -438,7 +441,7 @@ def split_traceback(traceback: str) -> list[str]:
|
|
438
441
|
|
439
442
|
|
440
443
|
def format_exception(
|
441
|
-
error:
|
444
|
+
error: BaseException,
|
442
445
|
*,
|
443
446
|
with_traceback: bool = False,
|
444
447
|
skip_frames: int = 0,
|
schemathesis/core/transport.py
CHANGED
@@ -27,7 +27,30 @@ def prepare_urlencoded(data: Any) -> Any:
|
|
27
27
|
|
28
28
|
|
29
29
|
class Response:
|
30
|
-
"""
|
30
|
+
"""HTTP response wrapper that normalizes different transport implementations.
|
31
|
+
|
32
|
+
Provides a consistent interface for accessing response data whether the request
|
33
|
+
was made via HTTP, ASGI, or WSGI transports.
|
34
|
+
"""
|
35
|
+
|
36
|
+
status_code: int
|
37
|
+
"""HTTP status code (e.g., 200, 404, 500)."""
|
38
|
+
headers: dict[str, list[str]]
|
39
|
+
"""Response headers with lowercase keys and list values."""
|
40
|
+
content: bytes
|
41
|
+
"""Raw response body as bytes."""
|
42
|
+
request: requests.PreparedRequest
|
43
|
+
"""The request that generated this response."""
|
44
|
+
elapsed: float
|
45
|
+
"""Response time in seconds."""
|
46
|
+
verify: bool
|
47
|
+
"""Whether TLS verification was enabled for the request."""
|
48
|
+
message: str
|
49
|
+
"""HTTP status message (e.g., "OK", "Not Found")."""
|
50
|
+
http_version: str
|
51
|
+
"""HTTP protocol version ("1.0" or "1.1")."""
|
52
|
+
encoding: str | None
|
53
|
+
"""Character encoding for text content, if detected."""
|
31
54
|
|
32
55
|
__slots__ = (
|
33
56
|
"status_code",
|
@@ -90,19 +113,31 @@ class Response:
|
|
90
113
|
|
91
114
|
@property
|
92
115
|
def text(self) -> str:
|
116
|
+
"""Decode response content as text using the detected or default encoding."""
|
93
117
|
return self.content.decode(self.encoding if self.encoding else "utf-8")
|
94
118
|
|
95
119
|
def json(self) -> Any:
|
120
|
+
"""Parse response content as JSON.
|
121
|
+
|
122
|
+
Returns:
|
123
|
+
Parsed JSON data (dict, list, or primitive types)
|
124
|
+
|
125
|
+
Raises:
|
126
|
+
json.JSONDecodeError: If content is not valid JSON
|
127
|
+
|
128
|
+
"""
|
96
129
|
if self._json is None:
|
97
130
|
self._json = json.loads(self.text)
|
98
131
|
return self._json
|
99
132
|
|
100
133
|
@property
|
101
134
|
def body_size(self) -> int | None:
|
135
|
+
"""Size of response body in bytes, or None if no content."""
|
102
136
|
return len(self.content) if self.content else None
|
103
137
|
|
104
138
|
@property
|
105
139
|
def encoded_body(self) -> str | None:
|
140
|
+
"""Base64-encoded response body for binary-safe serialization."""
|
106
141
|
if self._encoded_body is None and self.content:
|
107
142
|
self._encoded_body = base64.b64encode(self.content).decode()
|
108
143
|
return self._encoded_body
|
schemathesis/engine/context.py
CHANGED
schemathesis/engine/errors.py
CHANGED
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
8
8
|
|
9
9
|
import enum
|
10
10
|
import re
|
11
|
+
from dataclasses import dataclass
|
11
12
|
from functools import cached_property
|
12
13
|
from typing import TYPE_CHECKING, Callable, Iterator, Sequence, cast
|
13
14
|
|
@@ -24,6 +25,8 @@ from schemathesis.core.errors import (
|
|
24
25
|
|
25
26
|
if TYPE_CHECKING:
|
26
27
|
import hypothesis.errors
|
28
|
+
import requests
|
29
|
+
from requests.exceptions import ChunkedEncodingError
|
27
30
|
|
28
31
|
__all__ = ["EngineErrorInfo", "DeadlineExceeded", "UnsupportedRecursiveReference", "UnexpectedError"]
|
29
32
|
|
@@ -61,8 +64,9 @@ class EngineErrorInfo:
|
|
61
64
|
It serves as a caching wrapper around exceptions to avoid repeated computations.
|
62
65
|
"""
|
63
66
|
|
64
|
-
def __init__(self, error: Exception) -> None:
|
67
|
+
def __init__(self, error: Exception, code_sample: str | None = None) -> None:
|
65
68
|
self._error = error
|
69
|
+
self._code_sample = code_sample
|
66
70
|
|
67
71
|
def __str__(self) -> str:
|
68
72
|
return self._error_repr
|
@@ -212,6 +216,9 @@ class EngineErrorInfo:
|
|
212
216
|
message.append("") # Empty line before extras
|
213
217
|
message.extend(f"{indent}{extra}" for extra in extras)
|
214
218
|
|
219
|
+
if self._code_sample is not None:
|
220
|
+
message.append(f"\nReproduce with: \n\n {self._code_sample}")
|
221
|
+
|
215
222
|
# Suggestion
|
216
223
|
suggestion = get_runtime_error_suggestion(self._kind, bold=bold)
|
217
224
|
if suggestion is not None:
|
@@ -403,3 +410,55 @@ def canonicalize_error_message(error: Exception, with_traceback: bool = True) ->
|
|
403
410
|
message = MEMORY_ADDRESS_RE.sub("0xbaaaaaaaaaad", message)
|
404
411
|
# Remove URL information
|
405
412
|
return URL_IN_ERROR_MESSAGE_RE.sub("", message)
|
413
|
+
|
414
|
+
|
415
|
+
def clear_hypothesis_notes(exc: Exception) -> None:
|
416
|
+
notes = getattr(exc, "__notes__", [])
|
417
|
+
if any("while generating" in note for note in notes):
|
418
|
+
notes.clear()
|
419
|
+
|
420
|
+
|
421
|
+
def is_unrecoverable_network_error(exc: Exception) -> bool:
|
422
|
+
from http.client import RemoteDisconnected
|
423
|
+
|
424
|
+
from urllib3.exceptions import ProtocolError
|
425
|
+
|
426
|
+
def has_connection_reset(inner: BaseException) -> bool:
|
427
|
+
exc_str = str(inner)
|
428
|
+
if any(pattern in exc_str for pattern in ["Connection reset by peer", "[Errno 104]", "ECONNRESET"]):
|
429
|
+
return True
|
430
|
+
|
431
|
+
if inner.__context__ is not None:
|
432
|
+
return has_connection_reset(inner.__context__)
|
433
|
+
|
434
|
+
return False
|
435
|
+
|
436
|
+
if isinstance(exc.__context__, ProtocolError):
|
437
|
+
if len(exc.__context__.args) == 2 and isinstance(exc.__context__.args[1], RemoteDisconnected):
|
438
|
+
return True
|
439
|
+
if len(exc.__context__.args) == 1 and exc.__context__.args[0] == "Response ended prematurely":
|
440
|
+
return True
|
441
|
+
|
442
|
+
return has_connection_reset(exc)
|
443
|
+
|
444
|
+
|
445
|
+
@dataclass()
|
446
|
+
class UnrecoverableNetworkError:
|
447
|
+
error: requests.ConnectionError | ChunkedEncodingError
|
448
|
+
code_sample: str
|
449
|
+
|
450
|
+
__slots__ = ("error", "code_sample")
|
451
|
+
|
452
|
+
def __init__(self, error: requests.ConnectionError | ChunkedEncodingError, code_sample: str) -> None:
|
453
|
+
self.error = error
|
454
|
+
self.code_sample = code_sample
|
455
|
+
|
456
|
+
|
457
|
+
@dataclass
|
458
|
+
class TestingState:
|
459
|
+
unrecoverable_network_error: UnrecoverableNetworkError | None
|
460
|
+
|
461
|
+
__slots__ = ("unrecoverable_network_error",)
|
462
|
+
|
463
|
+
def __init__(self) -> None:
|
464
|
+
self.unrecoverable_network_error = None
|
schemathesis/engine/events.py
CHANGED
@@ -200,10 +200,18 @@ class NonFatalError(EngineEvent):
|
|
200
200
|
|
201
201
|
__slots__ = ("id", "timestamp", "info", "value", "phase", "label", "related_to_operation")
|
202
202
|
|
203
|
-
def __init__(
|
203
|
+
def __init__(
|
204
|
+
self,
|
205
|
+
*,
|
206
|
+
error: Exception,
|
207
|
+
phase: PhaseName,
|
208
|
+
label: str,
|
209
|
+
related_to_operation: bool,
|
210
|
+
code_sample: str | None = None,
|
211
|
+
) -> None:
|
204
212
|
self.id = uuid.uuid4()
|
205
213
|
self.timestamp = time.time()
|
206
|
-
self.info = EngineErrorInfo(error=error)
|
214
|
+
self.info = EngineErrorInfo(error=error, code_sample=code_sample)
|
207
215
|
self.value = error
|
208
216
|
self.phase = phase
|
209
217
|
self.label = label
|
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING
|
|
16
16
|
from schemathesis.core.result import Err, Ok, Result
|
17
17
|
from schemathesis.core.transport import USER_AGENT
|
18
18
|
from schemathesis.engine import Status, events
|
19
|
+
from schemathesis.transport.prepare import get_default_headers
|
19
20
|
|
20
21
|
if TYPE_CHECKING:
|
21
22
|
import requests
|
@@ -134,6 +135,8 @@ def send(probe: Probe, ctx: EngineContext) -> ProbeRun:
|
|
134
135
|
request = probe.prepare_request(session, Request(), ctx.schema)
|
135
136
|
request.headers[HEADER_NAME] = probe.name
|
136
137
|
request.headers["User-Agent"] = USER_AGENT
|
138
|
+
for header, value in get_default_headers().items():
|
139
|
+
request.headers.setdefault(header, value)
|
137
140
|
with warnings.catch_warnings():
|
138
141
|
warnings.simplefilter("ignore", InsecureRequestWarning)
|
139
142
|
response = session.send(request, timeout=ctx.config.request_timeout or 2)
|
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
|
|
6
6
|
|
7
7
|
from schemathesis.engine import Status, events
|
8
8
|
from schemathesis.engine.phases import Phase, PhaseName, PhaseSkipReason
|
9
|
+
from schemathesis.generation.stateful import STATEFUL_TESTS_LABEL
|
9
10
|
|
10
11
|
if TYPE_CHECKING:
|
11
12
|
from schemathesis.engine.context import EngineContext
|
@@ -19,7 +20,7 @@ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
|
|
19
20
|
try:
|
20
21
|
state_machine = engine.schema.as_state_machine()
|
21
22
|
except Exception as exc:
|
22
|
-
yield events.NonFatalError(error=exc, phase=phase.name, label=
|
23
|
+
yield events.NonFatalError(error=exc, phase=phase.name, label=STATEFUL_TESTS_LABEL, related_to_operation=False)
|
23
24
|
yield events.PhaseFinished(phase=phase, status=Status.ERROR, payload=None)
|
24
25
|
return
|
25
26
|
|