schemathesis 3.25.5__py3-none-any.whl → 4.0.0a1__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 +27 -65
- schemathesis/auths.py +102 -82
- schemathesis/checks.py +126 -46
- schemathesis/cli/__init__.py +11 -1766
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +35 -0
- schemathesis/cli/commands/run/executor.py +138 -0
- schemathesis/cli/commands/run/filters.py +194 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +746 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
- schemathesis/cli/constants.py +5 -52
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +39 -10
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +285 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
- schemathesis/core/output/__init__.py +69 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +31 -0
- schemathesis/{internal → core}/result.py +1 -1
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +337 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{cli → engine/phases}/probes.py +63 -70
- schemathesis/engine/phases/stateful/__init__.py +65 -0
- schemathesis/engine/phases/stateful/_executor.py +326 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +174 -0
- schemathesis/engine/phases/unit/_executor.py +321 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +241 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +18 -14
- schemathesis/filters.py +103 -14
- schemathesis/generation/__init__.py +21 -37
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/generation/hypothesis/__init__.py +30 -0
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/hypothesis/examples.py +50 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +115 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +99 -67
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +412 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -261
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +25 -0
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +7 -5
- schemathesis/specs/graphql/schemas.py +215 -187
- schemathesis/specs/graphql/validation.py +11 -18
- schemathesis/specs/openapi/__init__.py +7 -1
- schemathesis/specs/openapi/_cache.py +122 -0
- schemathesis/specs/openapi/_hypothesis.py +146 -165
- schemathesis/specs/openapi/checks.py +565 -67
- schemathesis/specs/openapi/converter.py +33 -6
- schemathesis/specs/openapi/definitions.py +11 -18
- schemathesis/specs/openapi/examples.py +153 -39
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +4 -6
- schemathesis/specs/openapi/expressions/extractors.py +23 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +38 -14
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +45 -0
- schemathesis/specs/openapi/links.py +65 -165
- schemathesis/specs/openapi/media_types.py +32 -0
- schemathesis/specs/openapi/negative/__init__.py +7 -3
- schemathesis/specs/openapi/negative/mutations.py +24 -8
- schemathesis/specs/openapi/parameters.py +46 -30
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +47 -57
- schemathesis/specs/openapi/schemas.py +483 -367
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +11 -6
- schemathesis/specs/openapi/stateful/__init__.py +185 -73
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +143 -28
- schemathesis/transport/wsgi.py +165 -0
- schemathesis-4.0.0a1.dist-info/METADATA +297 -0
- schemathesis-4.0.0a1.dist-info/RECORD +152 -0
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -17
- schemathesis/_hypothesis.py +0 -246
- schemathesis/_override.py +0 -49
- schemathesis/cli/cassettes.py +0 -375
- schemathesis/cli/context.py +0 -55
- schemathesis/cli/debug.py +0 -26
- schemathesis/cli/handlers.py +0 -16
- schemathesis/cli/junitxml.py +0 -43
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -765
- schemathesis/cli/output/short.py +0 -40
- schemathesis/cli/sanitization.py +0 -20
- schemathesis/code_samples.py +0 -149
- schemathesis/constants.py +0 -55
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -15
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -560
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -17
- schemathesis/failures.py +0 -209
- schemathesis/fixups/__init__.py +0 -36
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -29
- schemathesis/graphql.py +0 -4
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/copy.py +0 -13
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -34
- schemathesis/internal/jsonschema.py +0 -35
- schemathesis/internal/transformation.py +0 -15
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -361
- schemathesis/loaders.py +0 -120
- schemathesis/models.py +0 -1231
- schemathesis/parameters.py +0 -86
- schemathesis/runner/__init__.py +0 -555
- schemathesis/runner/events.py +0 -309
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -986
- schemathesis/runner/impl/solo.py +0 -90
- schemathesis/runner/impl/threadpool.py +0 -400
- schemathesis/runner/serialization.py +0 -411
- schemathesis/sanitization.py +0 -248
- schemathesis/serializers.py +0 -315
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -201
- schemathesis/service/client.py +0 -100
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -57
- schemathesis/service/hosts.py +0 -107
- schemathesis/service/metadata.py +0 -46
- schemathesis/service/models.py +0 -49
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/usage.py +0 -65
- schemathesis/specs/graphql/loaders.py +0 -344
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/loaders.py +0 -667
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis/specs/openapi/validation.py +0 -25
- schemathesis/stateful/__init__.py +0 -133
- schemathesis/targets.py +0 -45
- schemathesis/throttling.py +0 -41
- schemathesis/transports/__init__.py +0 -5
- schemathesis/transports/auth.py +0 -15
- schemathesis/transports/headers.py +0 -35
- schemathesis/transports/responses.py +0 -52
- schemathesis/types.py +0 -35
- schemathesis/utils.py +0 -169
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,321 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import time
|
4
|
+
import unittest
|
5
|
+
import uuid
|
6
|
+
from typing import TYPE_CHECKING, Callable, Iterable
|
7
|
+
from warnings import WarningMessage, catch_warnings
|
8
|
+
|
9
|
+
import requests
|
10
|
+
from hypothesis.errors import InvalidArgument
|
11
|
+
from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
|
12
|
+
from jsonschema.exceptions import SchemaError as JsonSchemaError
|
13
|
+
from jsonschema.exceptions import ValidationError
|
14
|
+
|
15
|
+
from schemathesis.checks import CheckContext, CheckFunction, run_checks
|
16
|
+
from schemathesis.core.compat import BaseExceptionGroup
|
17
|
+
from schemathesis.core.control import SkipTest
|
18
|
+
from schemathesis.core.errors import (
|
19
|
+
SERIALIZERS_SUGGESTION_MESSAGE,
|
20
|
+
InternalError,
|
21
|
+
InvalidHeadersExample,
|
22
|
+
InvalidRegexPattern,
|
23
|
+
InvalidRegexType,
|
24
|
+
InvalidSchema,
|
25
|
+
MalformedMediaType,
|
26
|
+
SerializationNotPossible,
|
27
|
+
)
|
28
|
+
from schemathesis.core.failures import Failure, FailureGroup
|
29
|
+
from schemathesis.core.transport import Response
|
30
|
+
from schemathesis.engine import Status, events
|
31
|
+
from schemathesis.engine.context import EngineContext
|
32
|
+
from schemathesis.engine.errors import (
|
33
|
+
DeadlineExceeded,
|
34
|
+
UnexpectedError,
|
35
|
+
UnsupportedRecursiveReference,
|
36
|
+
deduplicate_errors,
|
37
|
+
)
|
38
|
+
from schemathesis.engine.phases import PhaseName
|
39
|
+
from schemathesis.engine.recorder import ScenarioRecorder
|
40
|
+
from schemathesis.generation import targets
|
41
|
+
from schemathesis.generation.case import Case
|
42
|
+
from schemathesis.generation.hypothesis.builder import (
|
43
|
+
InvalidHeadersExampleMark,
|
44
|
+
InvalidRegexMark,
|
45
|
+
NonSerializableMark,
|
46
|
+
UnsatisfiableExampleMark,
|
47
|
+
)
|
48
|
+
from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
|
49
|
+
|
50
|
+
if TYPE_CHECKING:
|
51
|
+
from schemathesis.schemas import APIOperation
|
52
|
+
|
53
|
+
|
54
|
+
def run_test(
|
55
|
+
*, operation: APIOperation, test_function: Callable, ctx: EngineContext, suite_id: uuid.UUID
|
56
|
+
) -> events.EventGenerator:
|
57
|
+
"""A single test run with all error handling needed."""
|
58
|
+
import hypothesis.errors
|
59
|
+
|
60
|
+
# To simplify connecting `before` and `after` events in external systems
|
61
|
+
scenario_started = events.ScenarioStarted(label=operation.label, phase=PhaseName.UNIT_TESTING, suite_id=suite_id)
|
62
|
+
yield scenario_started
|
63
|
+
errors: list[Exception] = []
|
64
|
+
skip_reason = None
|
65
|
+
test_start_time = time.monotonic()
|
66
|
+
recorder = ScenarioRecorder(label=operation.label)
|
67
|
+
|
68
|
+
def non_fatal_error(error: Exception) -> events.NonFatalError:
|
69
|
+
return events.NonFatalError(
|
70
|
+
error=error, phase=PhaseName.UNIT_TESTING, label=operation.label, related_to_operation=True
|
71
|
+
)
|
72
|
+
|
73
|
+
try:
|
74
|
+
setup_hypothesis_database_key(test_function, operation)
|
75
|
+
with catch_warnings(record=True) as warnings, ignore_hypothesis_output():
|
76
|
+
test_function(ctx=ctx, errors=errors, recorder=recorder)
|
77
|
+
# Test body was not executed at all - Hypothesis did not generate any tests, but there is no error
|
78
|
+
status = Status.SUCCESS
|
79
|
+
except (SkipTest, unittest.case.SkipTest) as exc:
|
80
|
+
status = Status.SKIP
|
81
|
+
skip_reason = {"Hypothesis has been told to run no examples for this test.": "No examples in schema"}.get(
|
82
|
+
str(exc), str(exc)
|
83
|
+
)
|
84
|
+
except (FailureGroup, Failure):
|
85
|
+
status = Status.FAILURE
|
86
|
+
except UnexpectedError:
|
87
|
+
# It could be an error in user-defined extensions, network errors or internal Schemathesis errors
|
88
|
+
status = Status.ERROR
|
89
|
+
for idx, err in enumerate(errors):
|
90
|
+
if isinstance(err, MalformedMediaType):
|
91
|
+
errors[idx] = InvalidSchema(str(err))
|
92
|
+
except hypothesis.errors.Flaky as exc:
|
93
|
+
if isinstance(exc.__cause__, hypothesis.errors.DeadlineExceeded):
|
94
|
+
status = Status.ERROR
|
95
|
+
yield non_fatal_error(DeadlineExceeded.from_exc(exc.__cause__))
|
96
|
+
elif isinstance(exc, hypothesis.errors.FlakyFailure) and any(
|
97
|
+
isinstance(subexc, hypothesis.errors.DeadlineExceeded) for subexc in exc.exceptions
|
98
|
+
):
|
99
|
+
for sub_exc in exc.exceptions:
|
100
|
+
if isinstance(sub_exc, hypothesis.errors.DeadlineExceeded):
|
101
|
+
yield non_fatal_error(DeadlineExceeded.from_exc(sub_exc))
|
102
|
+
status = Status.ERROR
|
103
|
+
elif errors:
|
104
|
+
status = Status.ERROR
|
105
|
+
else:
|
106
|
+
status = Status.FAILURE
|
107
|
+
except BaseExceptionGroup:
|
108
|
+
status = Status.ERROR
|
109
|
+
except hypothesis.errors.Unsatisfiable:
|
110
|
+
# We need more clear error message here
|
111
|
+
status = Status.ERROR
|
112
|
+
yield non_fatal_error(hypothesis.errors.Unsatisfiable("Failed to generate test cases for this API operation"))
|
113
|
+
except KeyboardInterrupt:
|
114
|
+
yield events.Interrupted(phase=PhaseName.UNIT_TESTING)
|
115
|
+
return
|
116
|
+
except AssertionError as exc: # May come from `hypothesis-jsonschema` or `hypothesis`
|
117
|
+
status = Status.ERROR
|
118
|
+
try:
|
119
|
+
operation.schema.validate()
|
120
|
+
msg = "Unexpected error during testing of this API operation"
|
121
|
+
exc_msg = str(exc)
|
122
|
+
if exc_msg:
|
123
|
+
msg += f": {exc_msg}"
|
124
|
+
try:
|
125
|
+
raise InternalError(msg) from exc
|
126
|
+
except InternalError as exc:
|
127
|
+
yield non_fatal_error(exc)
|
128
|
+
except ValidationError as exc:
|
129
|
+
yield non_fatal_error(
|
130
|
+
InvalidSchema.from_jsonschema_error(
|
131
|
+
exc,
|
132
|
+
path=operation.path,
|
133
|
+
method=operation.method,
|
134
|
+
full_path=operation.schema.get_full_path(operation.path),
|
135
|
+
)
|
136
|
+
)
|
137
|
+
except HypothesisRefResolutionError:
|
138
|
+
status = Status.ERROR
|
139
|
+
yield non_fatal_error(UnsupportedRecursiveReference())
|
140
|
+
except InvalidArgument as exc:
|
141
|
+
status = Status.ERROR
|
142
|
+
message = get_invalid_regular_expression_message(warnings)
|
143
|
+
if message:
|
144
|
+
# `hypothesis-jsonschema` emits a warning on invalid regular expression syntax
|
145
|
+
yield non_fatal_error(InvalidRegexPattern.from_hypothesis_jsonschema_message(message))
|
146
|
+
else:
|
147
|
+
yield non_fatal_error(exc)
|
148
|
+
except hypothesis.errors.DeadlineExceeded as exc:
|
149
|
+
status = Status.ERROR
|
150
|
+
yield non_fatal_error(DeadlineExceeded.from_exc(exc))
|
151
|
+
except JsonSchemaError as exc:
|
152
|
+
status = Status.ERROR
|
153
|
+
yield non_fatal_error(InvalidRegexPattern.from_schema_error(exc, from_examples=False))
|
154
|
+
except Exception as exc:
|
155
|
+
status = Status.ERROR
|
156
|
+
# Likely a YAML parsing issue. E.g. `00:00:00.00` (without quotes) is parsed as float `0.0`
|
157
|
+
if str(exc) == "first argument must be string or compiled pattern":
|
158
|
+
yield non_fatal_error(
|
159
|
+
InvalidRegexType(
|
160
|
+
"Invalid `pattern` value: expected a string. "
|
161
|
+
"If your schema is in YAML, ensure `pattern` values are quoted",
|
162
|
+
)
|
163
|
+
)
|
164
|
+
else:
|
165
|
+
yield non_fatal_error(exc)
|
166
|
+
if (
|
167
|
+
status == Status.SUCCESS
|
168
|
+
and ctx.config.execution.no_failfast
|
169
|
+
and any(check.status == Status.FAILURE for checks in recorder.checks.values() for check in checks)
|
170
|
+
):
|
171
|
+
status = Status.FAILURE
|
172
|
+
if UnsatisfiableExampleMark.is_set(test_function):
|
173
|
+
status = Status.ERROR
|
174
|
+
yield non_fatal_error(
|
175
|
+
hypothesis.errors.Unsatisfiable("Failed to generate test cases from examples for this API operation")
|
176
|
+
)
|
177
|
+
non_serializable = NonSerializableMark.get(test_function)
|
178
|
+
if non_serializable is not None and status != Status.ERROR:
|
179
|
+
status = Status.ERROR
|
180
|
+
media_types = ", ".join(non_serializable.media_types)
|
181
|
+
yield non_fatal_error(
|
182
|
+
SerializationNotPossible(
|
183
|
+
"Failed to generate test cases from examples for this API operation because of"
|
184
|
+
f" unsupported payload media types: {media_types}\n{SERIALIZERS_SUGGESTION_MESSAGE}",
|
185
|
+
media_types=non_serializable.media_types,
|
186
|
+
)
|
187
|
+
)
|
188
|
+
|
189
|
+
invalid_regex = InvalidRegexMark.get(test_function)
|
190
|
+
if invalid_regex is not None and status != Status.ERROR:
|
191
|
+
status = Status.ERROR
|
192
|
+
yield non_fatal_error(InvalidRegexPattern.from_schema_error(invalid_regex, from_examples=True))
|
193
|
+
invalid_headers = InvalidHeadersExampleMark.get(test_function)
|
194
|
+
if invalid_headers:
|
195
|
+
status = Status.ERROR
|
196
|
+
yield non_fatal_error(InvalidHeadersExample.from_headers(invalid_headers))
|
197
|
+
test_elapsed_time = time.monotonic() - test_start_time
|
198
|
+
for error in deduplicate_errors(errors):
|
199
|
+
yield non_fatal_error(error)
|
200
|
+
yield events.ScenarioFinished(
|
201
|
+
id=scenario_started.id,
|
202
|
+
suite_id=suite_id,
|
203
|
+
phase=PhaseName.UNIT_TESTING,
|
204
|
+
recorder=recorder,
|
205
|
+
status=status,
|
206
|
+
elapsed_time=test_elapsed_time,
|
207
|
+
skip_reason=skip_reason,
|
208
|
+
is_final=False,
|
209
|
+
)
|
210
|
+
|
211
|
+
|
212
|
+
def setup_hypothesis_database_key(test: Callable, operation: APIOperation) -> None:
|
213
|
+
"""Make Hypothesis use separate database entries for every API operation.
|
214
|
+
|
215
|
+
It increases the effectiveness of the Hypothesis database in the CLI.
|
216
|
+
"""
|
217
|
+
# Hypothesis's function digest depends on the test function signature. To reflect it for the web API case,
|
218
|
+
# we use all API operation parameters in the digest.
|
219
|
+
extra = operation.label.encode("utf8")
|
220
|
+
for parameter in operation.iter_parameters():
|
221
|
+
extra += parameter.serialize(operation).encode("utf8")
|
222
|
+
test.hypothesis.inner_test._hypothesis_internal_add_digest = extra # type: ignore
|
223
|
+
|
224
|
+
|
225
|
+
def get_invalid_regular_expression_message(warnings: list[WarningMessage]) -> str | None:
|
226
|
+
for warning in warnings:
|
227
|
+
message = str(warning.message)
|
228
|
+
if "is not valid syntax for a Python regular expression" in message:
|
229
|
+
return message
|
230
|
+
return None
|
231
|
+
|
232
|
+
|
233
|
+
def cached_test_func(f: Callable) -> Callable:
|
234
|
+
def wrapped(*, ctx: EngineContext, case: Case, errors: list[Exception], recorder: ScenarioRecorder) -> None:
|
235
|
+
try:
|
236
|
+
if ctx.has_to_stop:
|
237
|
+
raise KeyboardInterrupt
|
238
|
+
if ctx.config.execution.unique_inputs:
|
239
|
+
cached = ctx.get_cached_outcome(case)
|
240
|
+
if isinstance(cached, BaseException):
|
241
|
+
raise cached
|
242
|
+
elif cached is None:
|
243
|
+
return None
|
244
|
+
try:
|
245
|
+
f(ctx=ctx, case=case, recorder=recorder)
|
246
|
+
except BaseException as exc:
|
247
|
+
ctx.cache_outcome(case, exc)
|
248
|
+
raise
|
249
|
+
else:
|
250
|
+
ctx.cache_outcome(case, None)
|
251
|
+
else:
|
252
|
+
f(ctx=ctx, case=case, recorder=recorder)
|
253
|
+
except (KeyboardInterrupt, Failure):
|
254
|
+
raise
|
255
|
+
except Exception as exc:
|
256
|
+
errors.append(exc)
|
257
|
+
raise UnexpectedError from None
|
258
|
+
|
259
|
+
wrapped.__name__ = f.__name__
|
260
|
+
|
261
|
+
return wrapped
|
262
|
+
|
263
|
+
|
264
|
+
@cached_test_func
|
265
|
+
def test_func(*, ctx: EngineContext, case: Case, recorder: ScenarioRecorder) -> None:
|
266
|
+
recorder.record_case(parent_id=None, case=case)
|
267
|
+
try:
|
268
|
+
response = case.call(**ctx.transport_kwargs)
|
269
|
+
except (requests.Timeout, requests.ConnectionError) as error:
|
270
|
+
if isinstance(error.request, requests.Request):
|
271
|
+
recorder.record_request(case_id=case.id, request=error.request.prepare())
|
272
|
+
elif isinstance(error.request, requests.PreparedRequest):
|
273
|
+
recorder.record_request(case_id=case.id, request=error.request)
|
274
|
+
raise
|
275
|
+
recorder.record_response(case_id=case.id, response=response)
|
276
|
+
targets.run(ctx.config.execution.targets, case=case, response=response)
|
277
|
+
validate_response(
|
278
|
+
case=case,
|
279
|
+
ctx=ctx.get_check_context(recorder),
|
280
|
+
checks=ctx.config.execution.checks,
|
281
|
+
response=response,
|
282
|
+
no_failfast=ctx.config.execution.no_failfast,
|
283
|
+
recorder=recorder,
|
284
|
+
)
|
285
|
+
|
286
|
+
|
287
|
+
def validate_response(
|
288
|
+
*,
|
289
|
+
case: Case,
|
290
|
+
ctx: CheckContext,
|
291
|
+
checks: Iterable[CheckFunction],
|
292
|
+
response: Response,
|
293
|
+
no_failfast: bool,
|
294
|
+
recorder: ScenarioRecorder,
|
295
|
+
) -> None:
|
296
|
+
failures = set()
|
297
|
+
|
298
|
+
def on_failure(name: str, collected: set[Failure], failure: Failure) -> None:
|
299
|
+
collected.add(failure)
|
300
|
+
failure_data = recorder.find_failure_data(parent_id=case.id, failure=failure)
|
301
|
+
recorder.record_check_failure(
|
302
|
+
name=name,
|
303
|
+
case_id=failure_data.case.id,
|
304
|
+
code_sample=failure_data.case.as_curl_command(headers=failure_data.headers, verify=failure_data.verify),
|
305
|
+
failure=failure,
|
306
|
+
)
|
307
|
+
|
308
|
+
def on_success(name: str, _case: Case) -> None:
|
309
|
+
recorder.record_check_success(name=name, case_id=_case.id)
|
310
|
+
|
311
|
+
failures = run_checks(
|
312
|
+
case=case,
|
313
|
+
response=response,
|
314
|
+
ctx=ctx,
|
315
|
+
checks=checks,
|
316
|
+
on_failure=on_failure,
|
317
|
+
on_success=on_success,
|
318
|
+
)
|
319
|
+
|
320
|
+
if failures and not no_failfast:
|
321
|
+
raise FailureGroup(list(failures)) from None
|
@@ -0,0 +1,74 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import threading
|
4
|
+
import uuid
|
5
|
+
from queue import Queue
|
6
|
+
from types import TracebackType
|
7
|
+
from typing import TYPE_CHECKING, Callable
|
8
|
+
|
9
|
+
from schemathesis.core.result import Result
|
10
|
+
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
from schemathesis.engine.context import EngineContext
|
13
|
+
|
14
|
+
|
15
|
+
class TaskProducer:
|
16
|
+
"""Produces test tasks for workers to execute."""
|
17
|
+
|
18
|
+
def __init__(self, ctx: EngineContext) -> None:
|
19
|
+
self.operations = ctx.schema.get_all_operations(generation_config=ctx.config.execution.generation)
|
20
|
+
self.lock = threading.Lock()
|
21
|
+
|
22
|
+
def next_operation(self) -> Result | None:
|
23
|
+
"""Get next API operation in a thread-safe manner."""
|
24
|
+
with self.lock:
|
25
|
+
return next(self.operations, None)
|
26
|
+
|
27
|
+
|
28
|
+
class WorkerPool:
|
29
|
+
"""Manages a pool of worker threads."""
|
30
|
+
|
31
|
+
def __init__(
|
32
|
+
self,
|
33
|
+
workers_num: int,
|
34
|
+
producer: TaskProducer,
|
35
|
+
worker_factory: Callable,
|
36
|
+
ctx: EngineContext,
|
37
|
+
suite_id: uuid.UUID,
|
38
|
+
) -> None:
|
39
|
+
self.workers_num = workers_num
|
40
|
+
self.producer = producer
|
41
|
+
self.worker_factory = worker_factory
|
42
|
+
self.ctx = ctx
|
43
|
+
self.suite_id = suite_id
|
44
|
+
self.workers: list[threading.Thread] = []
|
45
|
+
self.events_queue: Queue = Queue()
|
46
|
+
|
47
|
+
def start(self) -> None:
|
48
|
+
"""Start all worker threads."""
|
49
|
+
for i in range(self.workers_num):
|
50
|
+
worker = threading.Thread(
|
51
|
+
target=self.worker_factory,
|
52
|
+
kwargs={
|
53
|
+
"ctx": self.ctx,
|
54
|
+
"events_queue": self.events_queue,
|
55
|
+
"producer": self.producer,
|
56
|
+
"suite_id": self.suite_id,
|
57
|
+
},
|
58
|
+
name=f"schemathesis_{i}",
|
59
|
+
daemon=True,
|
60
|
+
)
|
61
|
+
self.workers.append(worker)
|
62
|
+
worker.start()
|
63
|
+
|
64
|
+
def stop(self) -> None:
|
65
|
+
"""Stop all workers gracefully."""
|
66
|
+
for worker in self.workers:
|
67
|
+
worker.join()
|
68
|
+
|
69
|
+
def __enter__(self) -> WorkerPool:
|
70
|
+
self.start()
|
71
|
+
return self
|
72
|
+
|
73
|
+
def __exit__(self, ty: type[BaseException] | None, value: BaseException | None, tb: TracebackType | None) -> None:
|
74
|
+
self.stop()
|
@@ -0,0 +1,241 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import base64
|
4
|
+
import time
|
5
|
+
import uuid
|
6
|
+
from dataclasses import dataclass
|
7
|
+
from typing import TYPE_CHECKING, Iterator, cast
|
8
|
+
|
9
|
+
from schemathesis.core.failures import Failure
|
10
|
+
from schemathesis.core.transport import Response
|
11
|
+
from schemathesis.engine import Status
|
12
|
+
from schemathesis.generation.case import Case
|
13
|
+
|
14
|
+
if TYPE_CHECKING:
|
15
|
+
import requests
|
16
|
+
|
17
|
+
|
18
|
+
@dataclass
|
19
|
+
class ScenarioRecorder:
|
20
|
+
"""Tracks and organizes all data related to a logical block of testing.
|
21
|
+
|
22
|
+
Records test cases, their hierarchy, API interactions, and results of checks performed during execution.
|
23
|
+
"""
|
24
|
+
|
25
|
+
id: uuid.UUID
|
26
|
+
# Human-readable label
|
27
|
+
label: str
|
28
|
+
|
29
|
+
# Recorded test cases
|
30
|
+
cases: dict[str, CaseNode]
|
31
|
+
# Results of checks categorized by test case ID
|
32
|
+
checks: dict[str, list[CheckNode]]
|
33
|
+
# Network interactions by test case ID
|
34
|
+
interactions: dict[str, Interaction]
|
35
|
+
|
36
|
+
__slots__ = ("id", "label", "status", "roots", "cases", "checks", "interactions")
|
37
|
+
|
38
|
+
def __init__(self, *, label: str) -> None:
|
39
|
+
self.id = uuid.uuid4()
|
40
|
+
self.label = label
|
41
|
+
self.cases = {}
|
42
|
+
self.checks = {}
|
43
|
+
self.interactions = {}
|
44
|
+
|
45
|
+
def record_case(self, *, parent_id: str | None, case: Case) -> None:
|
46
|
+
"""Record a test case and its relationship to a parent, if applicable."""
|
47
|
+
self.cases[case.id] = CaseNode(value=case, parent_id=parent_id)
|
48
|
+
|
49
|
+
def record_response(self, *, case_id: str, response: Response) -> None:
|
50
|
+
"""Record the API response for a given test case."""
|
51
|
+
request = Request.from_prepared_request(response.request)
|
52
|
+
self.interactions[case_id] = Interaction(request=request, response=response)
|
53
|
+
|
54
|
+
def record_request(self, *, case_id: str, request: requests.PreparedRequest) -> None:
|
55
|
+
"""Record a network-level error for a given test case."""
|
56
|
+
self.interactions[case_id] = Interaction(request=Request.from_prepared_request(request), response=None)
|
57
|
+
|
58
|
+
def record_check_failure(self, *, name: str, case_id: str, code_sample: str, failure: Failure) -> None:
|
59
|
+
"""Record a failure of a check for a given test case."""
|
60
|
+
self.checks.setdefault(case_id, []).append(
|
61
|
+
CheckNode(
|
62
|
+
name=name,
|
63
|
+
status=Status.FAILURE,
|
64
|
+
failure_info=CheckFailureInfo(code_sample=code_sample, failure=failure),
|
65
|
+
)
|
66
|
+
)
|
67
|
+
|
68
|
+
def record_check_success(self, *, name: str, case_id: str) -> None:
|
69
|
+
"""Record a successful pass of a check for a given test case."""
|
70
|
+
self.checks.setdefault(case_id, []).append(CheckNode(name=name, status=Status.SUCCESS, failure_info=None))
|
71
|
+
|
72
|
+
def find_failure_data(self, *, parent_id: str, failure: Failure) -> FailureData:
|
73
|
+
"""Retrieve the relevant test case & interaction data for a failure.
|
74
|
+
|
75
|
+
It may happen that a failure comes from a different test case if a check generated some additional
|
76
|
+
test cases & interactions.
|
77
|
+
"""
|
78
|
+
case_id = failure.case_id or parent_id
|
79
|
+
case = self.cases[case_id].value
|
80
|
+
request = self.interactions[case_id].request
|
81
|
+
response = self.interactions[case_id].response
|
82
|
+
assert isinstance(response, Response)
|
83
|
+
headers = {key: value[0] for key, value in request.headers.items()}
|
84
|
+
return FailureData(case=case, headers=headers, verify=response.verify)
|
85
|
+
|
86
|
+
def find_parent(self, *, case_id: str) -> Case | None:
|
87
|
+
"""Find the parent case of a given test case, if it exists."""
|
88
|
+
case = self.cases.get(case_id)
|
89
|
+
if case is not None and case.parent_id is not None:
|
90
|
+
parent = self.cases.get(case.parent_id)
|
91
|
+
# The recorder state should always be consistent
|
92
|
+
assert parent is not None, "Parent does not exist"
|
93
|
+
return parent.value
|
94
|
+
return None
|
95
|
+
|
96
|
+
def find_related(self, *, case_id: str) -> Iterator[Case]:
|
97
|
+
"""Iterate over all ancestors and their children for a given case."""
|
98
|
+
current_id = case_id
|
99
|
+
seen = {current_id}
|
100
|
+
|
101
|
+
while True:
|
102
|
+
current_node = self.cases.get(current_id)
|
103
|
+
if current_node is None or current_node.parent_id is None:
|
104
|
+
break
|
105
|
+
|
106
|
+
# Get all children of the parent (siblings of the current case)
|
107
|
+
parent_id = current_node.parent_id
|
108
|
+
for case_id, maybe_child in self.cases.items():
|
109
|
+
# If this case has the same parent and we haven't seen it yet
|
110
|
+
if parent_id == maybe_child.parent_id and case_id not in seen:
|
111
|
+
seen.add(case_id)
|
112
|
+
yield maybe_child.value
|
113
|
+
|
114
|
+
# Move up to the parent
|
115
|
+
current_id = parent_id
|
116
|
+
if current_id not in seen:
|
117
|
+
seen.add(current_id)
|
118
|
+
parent_node = self.cases.get(current_id)
|
119
|
+
if parent_node:
|
120
|
+
yield parent_node.value
|
121
|
+
|
122
|
+
def find_response(self, *, case_id: str) -> Response | None:
|
123
|
+
"""Retrieve the API response for a given test case, if available."""
|
124
|
+
interaction = self.interactions.get(case_id)
|
125
|
+
if interaction is None or interaction.response is None:
|
126
|
+
return None
|
127
|
+
return interaction.response
|
128
|
+
|
129
|
+
|
130
|
+
@dataclass
|
131
|
+
class CaseNode:
|
132
|
+
"""Represents a test case and its parent-child relationship."""
|
133
|
+
|
134
|
+
value: Case
|
135
|
+
parent_id: str | None
|
136
|
+
|
137
|
+
__slots__ = ("value", "parent_id")
|
138
|
+
|
139
|
+
|
140
|
+
@dataclass
|
141
|
+
class CheckNode:
|
142
|
+
name: str
|
143
|
+
status: Status
|
144
|
+
failure_info: CheckFailureInfo | None
|
145
|
+
|
146
|
+
__slots__ = ("name", "status", "failure_info")
|
147
|
+
|
148
|
+
|
149
|
+
@dataclass
|
150
|
+
class CheckFailureInfo:
|
151
|
+
code_sample: str
|
152
|
+
failure: Failure
|
153
|
+
|
154
|
+
__slots__ = ("code_sample", "failure")
|
155
|
+
|
156
|
+
|
157
|
+
def serialize_payload(payload: bytes) -> str:
|
158
|
+
return base64.b64encode(payload).decode()
|
159
|
+
|
160
|
+
|
161
|
+
@dataclass(repr=False)
|
162
|
+
class Request:
|
163
|
+
"""Request data extracted from `Case`."""
|
164
|
+
|
165
|
+
method: str
|
166
|
+
uri: str
|
167
|
+
body: bytes | None
|
168
|
+
body_size: int | None
|
169
|
+
headers: dict[str, list[str]]
|
170
|
+
|
171
|
+
__slots__ = ("method", "uri", "body", "body_size", "headers", "_encoded_body_cache")
|
172
|
+
|
173
|
+
def __init__(
|
174
|
+
self,
|
175
|
+
method: str,
|
176
|
+
uri: str,
|
177
|
+
body: bytes | None,
|
178
|
+
body_size: int | None,
|
179
|
+
headers: dict[str, list[str]],
|
180
|
+
):
|
181
|
+
self.method = method
|
182
|
+
self.uri = uri
|
183
|
+
self.body = body
|
184
|
+
self.body_size = body_size
|
185
|
+
self.headers = headers
|
186
|
+
self._encoded_body_cache: str | None = None
|
187
|
+
|
188
|
+
@classmethod
|
189
|
+
def from_prepared_request(cls, prepared: requests.PreparedRequest) -> Request:
|
190
|
+
"""A prepared request version is already stored in `requests.Response`."""
|
191
|
+
body = prepared.body
|
192
|
+
|
193
|
+
if isinstance(body, str):
|
194
|
+
# can be a string for `application/x-www-form-urlencoded`
|
195
|
+
body = body.encode("utf-8")
|
196
|
+
|
197
|
+
# these values have `str` type at this point
|
198
|
+
uri = cast(str, prepared.url)
|
199
|
+
method = cast(str, prepared.method)
|
200
|
+
return cls(
|
201
|
+
uri=uri,
|
202
|
+
method=method,
|
203
|
+
headers={key: [value] for (key, value) in prepared.headers.items()},
|
204
|
+
body=body,
|
205
|
+
body_size=len(body) if body is not None else None,
|
206
|
+
)
|
207
|
+
|
208
|
+
@property
|
209
|
+
def encoded_body(self) -> str | None:
|
210
|
+
if self.body is not None:
|
211
|
+
if self._encoded_body_cache is None:
|
212
|
+
self._encoded_body_cache = serialize_payload(self.body)
|
213
|
+
return self._encoded_body_cache
|
214
|
+
return None
|
215
|
+
|
216
|
+
|
217
|
+
@dataclass
|
218
|
+
class Interaction:
|
219
|
+
"""Represents a single interaction with the tested application."""
|
220
|
+
|
221
|
+
request: Request
|
222
|
+
response: Response | None
|
223
|
+
timestamp: float
|
224
|
+
|
225
|
+
__slots__ = ("request", "response", "timestamp")
|
226
|
+
|
227
|
+
def __init__(self, request: Request, response: Response | None) -> None:
|
228
|
+
self.request = request
|
229
|
+
self.response = response
|
230
|
+
self.timestamp = time.time()
|
231
|
+
|
232
|
+
|
233
|
+
@dataclass
|
234
|
+
class FailureData:
|
235
|
+
"""Details about a test failure, including the case and its context."""
|
236
|
+
|
237
|
+
case: Case
|
238
|
+
headers: dict[str, str]
|
239
|
+
verify: bool
|
240
|
+
|
241
|
+
__slots__ = ("case", "headers", "verify")
|
schemathesis/errors.py
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
"""Public Schemathesis errors."""
|
2
|
+
|
3
|
+
from schemathesis.core.errors import IncorrectUsage as IncorrectUsage
|
4
|
+
from schemathesis.core.errors import InternalError as InternalError
|
5
|
+
from schemathesis.core.errors import InvalidHeadersExample as InvalidHeadersExample
|
6
|
+
from schemathesis.core.errors import InvalidRateLimit as InvalidRateLimit
|
7
|
+
from schemathesis.core.errors import InvalidRegexPattern as InvalidRegexPattern
|
8
|
+
from schemathesis.core.errors import InvalidRegexType as InvalidRegexType
|
9
|
+
from schemathesis.core.errors import InvalidSchema as InvalidSchema
|
10
|
+
from schemathesis.core.errors import LoaderError as LoaderError
|
11
|
+
from schemathesis.core.errors import OperationNotFound as OperationNotFound
|
12
|
+
from schemathesis.core.errors import SchemathesisError as SchemathesisError
|
13
|
+
from schemathesis.core.errors import SerializationError as SerializationError
|
14
|
+
from schemathesis.core.errors import SerializationNotPossible as SerializationNotPossible
|
15
|
+
from schemathesis.core.errors import UnboundPrefix as UnboundPrefix
|
16
|
+
|
17
|
+
__all__ = [
|
18
|
+
"IncorrectUsage",
|
19
|
+
"InternalError",
|
20
|
+
"InvalidHeadersExample",
|
21
|
+
"InvalidRateLimit",
|
22
|
+
"InvalidRegexPattern",
|
23
|
+
"InvalidRegexType",
|
24
|
+
"InvalidSchema",
|
25
|
+
"LoaderError",
|
26
|
+
"OperationNotFound",
|
27
|
+
"SchemathesisError",
|
28
|
+
"SerializationError",
|
29
|
+
"SerializationNotPossible",
|
30
|
+
"UnboundPrefix",
|
31
|
+
]
|