schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__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 +26 -68
- schemathesis/checks.py +130 -60
- schemathesis/cli/__init__.py +5 -2105
- 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 +30 -0
- schemathesis/cli/commands/run/executor.py +141 -0
- schemathesis/cli/commands/run/filters.py +202 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +1368 -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} +59 -175
- schemathesis/cli/constants.py +5 -58
- 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} +37 -16
- 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 -7
- 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 +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
- schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/{throttling.py → core/rate_limit.py} +16 -17
- schemathesis/core/registries.py +31 -0
- 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 +243 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +49 -68
- schemathesis/engine/phases/stateful/__init__.py +66 -0
- schemathesis/engine/phases/stateful/_executor.py +301 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +175 -0
- schemathesis/engine/phases/unit/_executor.py +322 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +246 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +9 -40
- schemathesis/filters.py +7 -95
- schemathesis/generation/__init__.py +3 -3
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +22 -22
- schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
- 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 +84 -109
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +17 -62
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +387 -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} +94 -107
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +456 -228
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +5 -3
- schemathesis/specs/graphql/schemas.py +122 -123
- schemathesis/specs/graphql/validation.py +11 -17
- schemathesis/specs/openapi/__init__.py +6 -1
- schemathesis/specs/openapi/_cache.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +97 -134
- schemathesis/specs/openapi/checks.py +238 -219
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +22 -20
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/nodes.py +33 -32
- schemathesis/specs/openapi/formats.py +3 -2
- schemathesis/specs/openapi/links.py +123 -299
- schemathesis/specs/openapi/media_types.py +10 -12
- schemathesis/specs/openapi/negative/__init__.py +2 -1
- schemathesis/specs/openapi/negative/mutations.py +3 -2
- schemathesis/specs/openapi/parameters.py +8 -6
- schemathesis/specs/openapi/patterns.py +1 -1
- schemathesis/specs/openapi/references.py +11 -51
- schemathesis/specs/openapi/schemas.py +177 -191
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +10 -6
- schemathesis/specs/openapi/stateful/__init__.py +97 -91
- 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} +69 -7
- schemathesis/transport/wsgi.py +165 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
- schemathesis-4.0.0a2.dist-info/RECORD +151 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -559
- schemathesis/_override.py +0 -50
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/context.py +0 -75
- schemathesis/cli/debug.py +0 -27
- schemathesis/cli/handlers.py +0 -19
- schemathesis/cli/junitxml.py +0 -124
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -936
- schemathesis/cli/output/short.py +0 -59
- schemathesis/cli/reporting.py +0 -79
- schemathesis/cli/sanitization.py +0 -26
- schemathesis/code_samples.py +0 -151
- schemathesis/constants.py +0 -56
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -16
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -571
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -277
- schemathesis/fixups/__init__.py +0 -37
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -28
- schemathesis/generation/_methods.py +0 -44
- schemathesis/graphql.py +0 -3
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/checks.py +0 -84
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -38
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/transformation.py +0 -26
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -474
- schemathesis/loaders.py +0 -122
- schemathesis/models.py +0 -1341
- schemathesis/parameters.py +0 -90
- schemathesis/runner/__init__.py +0 -605
- schemathesis/runner/events.py +0 -389
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/context.py +0 -104
- schemathesis/runner/impl/core.py +0 -1246
- schemathesis/runner/impl/solo.py +0 -80
- schemathesis/runner/impl/threadpool.py +0 -391
- schemathesis/runner/serialization.py +0 -544
- schemathesis/sanitization.py +0 -252
- schemathesis/serializers.py +0 -328
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -202
- schemathesis/service/client.py +0 -133
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -61
- schemathesis/service/extensions.py +0 -224
- schemathesis/service/hosts.py +0 -111
- schemathesis/service/metadata.py +0 -71
- schemathesis/service/models.py +0 -258
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -173
- schemathesis/service/usage.py +0 -66
- schemathesis/specs/graphql/loaders.py +0 -364
- schemathesis/specs/openapi/expressions/context.py +0 -16
- schemathesis/specs/openapi/loaders.py +0 -708
- schemathesis/specs/openapi/stateful/statistic.py +0 -198
- schemathesis/specs/openapi/stateful/types.py +0 -14
- schemathesis/specs/openapi/validation.py +0 -26
- schemathesis/stateful/__init__.py +0 -147
- schemathesis/stateful/config.py +0 -97
- schemathesis/stateful/context.py +0 -135
- schemathesis/stateful/events.py +0 -274
- schemathesis/stateful/runner.py +0 -309
- schemathesis/stateful/sink.py +0 -68
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -359
- schemathesis/transports/asgi.py +0 -7
- schemathesis/transports/auth.py +0 -38
- schemathesis/transports/headers.py +0 -36
- schemathesis/transports/responses.py +0 -57
- schemathesis/types.py +0 -44
- schemathesis/utils.py +0 -164
- schemathesis-3.39.7.dist-info/RECORD +0 -160
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
- /schemathesis/{internal → core}/result.py +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,175 @@
|
|
1
|
+
"""Unit testing by Schemathesis Engine.
|
2
|
+
|
3
|
+
This module provides high-level flow for single-, and multi-threaded modes.
|
4
|
+
"""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
import queue
|
9
|
+
import uuid
|
10
|
+
import warnings
|
11
|
+
from queue import Queue
|
12
|
+
from typing import TYPE_CHECKING, Any
|
13
|
+
|
14
|
+
from schemathesis.core.result import Ok
|
15
|
+
from schemathesis.engine import Status, events
|
16
|
+
from schemathesis.engine.phases import PhaseName, PhaseSkipReason
|
17
|
+
from schemathesis.engine.recorder import ScenarioRecorder
|
18
|
+
from schemathesis.generation.hypothesis.builder import HypothesisTestConfig
|
19
|
+
from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
|
20
|
+
|
21
|
+
from ._pool import TaskProducer, WorkerPool
|
22
|
+
|
23
|
+
if TYPE_CHECKING:
|
24
|
+
from schemathesis.engine.context import EngineContext
|
25
|
+
from schemathesis.engine.phases import Phase
|
26
|
+
from schemathesis.schemas import APIOperation
|
27
|
+
|
28
|
+
WORKER_TIMEOUT = 0.1
|
29
|
+
|
30
|
+
|
31
|
+
def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
|
32
|
+
"""Run a set of unit tests.
|
33
|
+
|
34
|
+
Implemented as a producer-consumer pattern via a task queue.
|
35
|
+
The main thread provides an iterator over API operations and worker threads create test functions and run them.
|
36
|
+
"""
|
37
|
+
producer = TaskProducer(engine)
|
38
|
+
workers_num = engine.config.execution.workers_num
|
39
|
+
|
40
|
+
suite_started = events.SuiteStarted(phase=phase.name)
|
41
|
+
|
42
|
+
yield suite_started
|
43
|
+
|
44
|
+
status = None
|
45
|
+
is_executed = False
|
46
|
+
|
47
|
+
with WorkerPool(
|
48
|
+
workers_num=workers_num, producer=producer, worker_factory=worker_task, ctx=engine, suite_id=suite_started.id
|
49
|
+
) as pool:
|
50
|
+
try:
|
51
|
+
while True:
|
52
|
+
try:
|
53
|
+
event = pool.events_queue.get(timeout=WORKER_TIMEOUT)
|
54
|
+
is_executed = True
|
55
|
+
if engine.is_interrupted:
|
56
|
+
raise KeyboardInterrupt
|
57
|
+
yield event
|
58
|
+
if isinstance(event, events.NonFatalError):
|
59
|
+
status = Status.ERROR
|
60
|
+
if isinstance(event, events.ScenarioFinished):
|
61
|
+
if event.status != Status.SKIP and (status is None or status < event.status):
|
62
|
+
status = event.status
|
63
|
+
if event.status in (Status.ERROR, Status.FAILURE):
|
64
|
+
engine.control.count_failure()
|
65
|
+
if isinstance(event, events.Interrupted) or engine.is_interrupted:
|
66
|
+
status = Status.INTERRUPTED
|
67
|
+
engine.stop()
|
68
|
+
if engine.has_to_stop:
|
69
|
+
break # type: ignore[unreachable]
|
70
|
+
except queue.Empty:
|
71
|
+
if all(not worker.is_alive() for worker in pool.workers):
|
72
|
+
break
|
73
|
+
continue
|
74
|
+
except KeyboardInterrupt:
|
75
|
+
engine.stop()
|
76
|
+
status = Status.INTERRUPTED
|
77
|
+
yield events.Interrupted(phase=PhaseName.UNIT_TESTING)
|
78
|
+
|
79
|
+
if not is_executed:
|
80
|
+
phase.skip_reason = PhaseSkipReason.NOTHING_TO_TEST
|
81
|
+
status = Status.SKIP
|
82
|
+
elif status is None:
|
83
|
+
status = Status.SKIP
|
84
|
+
# NOTE: Right now there is just one suite, hence two events go one after another
|
85
|
+
yield events.SuiteFinished(id=suite_started.id, phase=phase.name, status=status)
|
86
|
+
yield events.PhaseFinished(phase=phase, status=status, payload=None)
|
87
|
+
|
88
|
+
|
89
|
+
def worker_task(*, events_queue: Queue, producer: TaskProducer, ctx: EngineContext, suite_id: uuid.UUID) -> None:
|
90
|
+
from hypothesis.errors import HypothesisWarning
|
91
|
+
|
92
|
+
from schemathesis.generation.hypothesis.builder import create_test
|
93
|
+
|
94
|
+
from ._executor import run_test, test_func
|
95
|
+
|
96
|
+
warnings.filterwarnings("ignore", message="The recursion limit will not be reset", category=HypothesisWarning)
|
97
|
+
with ignore_hypothesis_output():
|
98
|
+
try:
|
99
|
+
while not ctx.has_to_stop:
|
100
|
+
result = producer.next_operation()
|
101
|
+
if result is None:
|
102
|
+
break
|
103
|
+
|
104
|
+
if isinstance(result, Ok):
|
105
|
+
operation = result.ok()
|
106
|
+
as_strategy_kwargs = get_strategy_kwargs(ctx, operation)
|
107
|
+
test_function = create_test(
|
108
|
+
operation=operation,
|
109
|
+
test_func=test_func,
|
110
|
+
config=HypothesisTestConfig(
|
111
|
+
settings=ctx.config.execution.hypothesis_settings,
|
112
|
+
seed=ctx.config.execution.seed,
|
113
|
+
generation=ctx.config.execution.generation,
|
114
|
+
as_strategy_kwargs=as_strategy_kwargs,
|
115
|
+
),
|
116
|
+
)
|
117
|
+
|
118
|
+
# The test is blocking, meaning that even if CTRL-C comes to the main thread, this tasks will continue
|
119
|
+
# executing. However, as we set a stop event, it will be checked before the next network request.
|
120
|
+
# However, this is still suboptimal, as there could be slow requests and they will block for longer
|
121
|
+
for event in run_test(operation=operation, test_function=test_function, ctx=ctx, suite_id=suite_id):
|
122
|
+
events_queue.put(event)
|
123
|
+
else:
|
124
|
+
error = result.err()
|
125
|
+
if error.method:
|
126
|
+
label = f"{error.method.upper()} {error.full_path}"
|
127
|
+
scenario_started = events.ScenarioStarted(
|
128
|
+
label=label, phase=PhaseName.UNIT_TESTING, suite_id=suite_id
|
129
|
+
)
|
130
|
+
events_queue.put(scenario_started)
|
131
|
+
|
132
|
+
events_queue.put(
|
133
|
+
events.NonFatalError(
|
134
|
+
error=error, phase=PhaseName.UNIT_TESTING, label=label, related_to_operation=True
|
135
|
+
)
|
136
|
+
)
|
137
|
+
|
138
|
+
events_queue.put(
|
139
|
+
events.ScenarioFinished(
|
140
|
+
id=scenario_started.id,
|
141
|
+
suite_id=suite_id,
|
142
|
+
phase=PhaseName.UNIT_TESTING,
|
143
|
+
label=label,
|
144
|
+
status=Status.ERROR,
|
145
|
+
recorder=ScenarioRecorder(label="Error"),
|
146
|
+
elapsed_time=0.0,
|
147
|
+
skip_reason=None,
|
148
|
+
is_final=True,
|
149
|
+
)
|
150
|
+
)
|
151
|
+
else:
|
152
|
+
assert error.full_path is not None
|
153
|
+
events_queue.put(
|
154
|
+
events.NonFatalError(
|
155
|
+
error=error,
|
156
|
+
phase=PhaseName.UNIT_TESTING,
|
157
|
+
label=error.full_path,
|
158
|
+
related_to_operation=False,
|
159
|
+
)
|
160
|
+
)
|
161
|
+
except KeyboardInterrupt:
|
162
|
+
events_queue.put(events.Interrupted(phase=PhaseName.UNIT_TESTING))
|
163
|
+
|
164
|
+
|
165
|
+
def get_strategy_kwargs(ctx: EngineContext, operation: APIOperation) -> dict[str, Any]:
|
166
|
+
kwargs = {}
|
167
|
+
if ctx.config.override is not None:
|
168
|
+
for location, entry in ctx.config.override.for_operation(operation).items():
|
169
|
+
if entry:
|
170
|
+
kwargs[location] = entry
|
171
|
+
if ctx.config.network.headers:
|
172
|
+
kwargs["headers"] = {
|
173
|
+
key: value for key, value in ctx.config.network.headers.items() if key.lower() != "user-agent"
|
174
|
+
}
|
175
|
+
return kwargs
|
@@ -0,0 +1,322 @@
|
|
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
|
+
label=operation.label,
|
205
|
+
recorder=recorder,
|
206
|
+
status=status,
|
207
|
+
elapsed_time=test_elapsed_time,
|
208
|
+
skip_reason=skip_reason,
|
209
|
+
is_final=False,
|
210
|
+
)
|
211
|
+
|
212
|
+
|
213
|
+
def setup_hypothesis_database_key(test: Callable, operation: APIOperation) -> None:
|
214
|
+
"""Make Hypothesis use separate database entries for every API operation.
|
215
|
+
|
216
|
+
It increases the effectiveness of the Hypothesis database in the CLI.
|
217
|
+
"""
|
218
|
+
# Hypothesis's function digest depends on the test function signature. To reflect it for the web API case,
|
219
|
+
# we use all API operation parameters in the digest.
|
220
|
+
extra = operation.label.encode("utf8")
|
221
|
+
for parameter in operation.iter_parameters():
|
222
|
+
extra += parameter.serialize(operation).encode("utf8")
|
223
|
+
test.hypothesis.inner_test._hypothesis_internal_add_digest = extra # type: ignore
|
224
|
+
|
225
|
+
|
226
|
+
def get_invalid_regular_expression_message(warnings: list[WarningMessage]) -> str | None:
|
227
|
+
for warning in warnings:
|
228
|
+
message = str(warning.message)
|
229
|
+
if "is not valid syntax for a Python regular expression" in message:
|
230
|
+
return message
|
231
|
+
return None
|
232
|
+
|
233
|
+
|
234
|
+
def cached_test_func(f: Callable) -> Callable:
|
235
|
+
def wrapped(*, ctx: EngineContext, case: Case, errors: list[Exception], recorder: ScenarioRecorder) -> None:
|
236
|
+
try:
|
237
|
+
if ctx.has_to_stop:
|
238
|
+
raise KeyboardInterrupt
|
239
|
+
if ctx.config.execution.unique_inputs:
|
240
|
+
cached = ctx.get_cached_outcome(case)
|
241
|
+
if isinstance(cached, BaseException):
|
242
|
+
raise cached
|
243
|
+
elif cached is None:
|
244
|
+
return None
|
245
|
+
try:
|
246
|
+
f(ctx=ctx, case=case, recorder=recorder)
|
247
|
+
except BaseException as exc:
|
248
|
+
ctx.cache_outcome(case, exc)
|
249
|
+
raise
|
250
|
+
else:
|
251
|
+
ctx.cache_outcome(case, None)
|
252
|
+
else:
|
253
|
+
f(ctx=ctx, case=case, recorder=recorder)
|
254
|
+
except (KeyboardInterrupt, Failure):
|
255
|
+
raise
|
256
|
+
except Exception as exc:
|
257
|
+
errors.append(exc)
|
258
|
+
raise UnexpectedError from None
|
259
|
+
|
260
|
+
wrapped.__name__ = f.__name__
|
261
|
+
|
262
|
+
return wrapped
|
263
|
+
|
264
|
+
|
265
|
+
@cached_test_func
|
266
|
+
def test_func(*, ctx: EngineContext, case: Case, recorder: ScenarioRecorder) -> None:
|
267
|
+
recorder.record_case(parent_id=None, transition=None, case=case)
|
268
|
+
try:
|
269
|
+
response = case.call(**ctx.transport_kwargs)
|
270
|
+
except (requests.Timeout, requests.ConnectionError) as error:
|
271
|
+
if isinstance(error.request, requests.Request):
|
272
|
+
recorder.record_request(case_id=case.id, request=error.request.prepare())
|
273
|
+
elif isinstance(error.request, requests.PreparedRequest):
|
274
|
+
recorder.record_request(case_id=case.id, request=error.request)
|
275
|
+
raise
|
276
|
+
recorder.record_response(case_id=case.id, response=response)
|
277
|
+
targets.run(ctx.config.execution.targets, case=case, response=response)
|
278
|
+
validate_response(
|
279
|
+
case=case,
|
280
|
+
ctx=ctx.get_check_context(recorder),
|
281
|
+
checks=ctx.config.execution.checks,
|
282
|
+
response=response,
|
283
|
+
no_failfast=ctx.config.execution.no_failfast,
|
284
|
+
recorder=recorder,
|
285
|
+
)
|
286
|
+
|
287
|
+
|
288
|
+
def validate_response(
|
289
|
+
*,
|
290
|
+
case: Case,
|
291
|
+
ctx: CheckContext,
|
292
|
+
checks: Iterable[CheckFunction],
|
293
|
+
response: Response,
|
294
|
+
no_failfast: bool,
|
295
|
+
recorder: ScenarioRecorder,
|
296
|
+
) -> None:
|
297
|
+
failures = set()
|
298
|
+
|
299
|
+
def on_failure(name: str, collected: set[Failure], failure: Failure) -> None:
|
300
|
+
collected.add(failure)
|
301
|
+
failure_data = recorder.find_failure_data(parent_id=case.id, failure=failure)
|
302
|
+
recorder.record_check_failure(
|
303
|
+
name=name,
|
304
|
+
case_id=failure_data.case.id,
|
305
|
+
code_sample=failure_data.case.as_curl_command(headers=failure_data.headers, verify=failure_data.verify),
|
306
|
+
failure=failure,
|
307
|
+
)
|
308
|
+
|
309
|
+
def on_success(name: str, _case: Case) -> None:
|
310
|
+
recorder.record_check_success(name=name, case_id=_case.id)
|
311
|
+
|
312
|
+
failures = run_checks(
|
313
|
+
case=case,
|
314
|
+
response=response,
|
315
|
+
ctx=ctx,
|
316
|
+
checks=checks,
|
317
|
+
on_failure=on_failure,
|
318
|
+
on_success=on_success,
|
319
|
+
)
|
320
|
+
|
321
|
+
if failures and not no_failfast:
|
322
|
+
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_unit_tests_{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()
|