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
@@ -1,4 +1,4 @@
|
|
1
|
-
from __future__ import annotations
|
1
|
+
from __future__ import annotations # noqa: I001
|
2
2
|
|
3
3
|
import queue
|
4
4
|
import time
|
@@ -8,9 +8,11 @@ from typing import Any
|
|
8
8
|
from warnings import catch_warnings
|
9
9
|
|
10
10
|
import hypothesis
|
11
|
+
import requests
|
11
12
|
from hypothesis.control import current_build_context
|
12
13
|
from hypothesis.errors import Flaky, Unsatisfiable
|
13
14
|
from hypothesis.stateful import Rule
|
15
|
+
from requests.exceptions import ChunkedEncodingError
|
14
16
|
from requests.structures import CaseInsensitiveDict
|
15
17
|
|
16
18
|
from schemathesis.checks import CheckContext, CheckFunction, run_checks
|
@@ -19,19 +21,26 @@ from schemathesis.core.transport import Response
|
|
19
21
|
from schemathesis.engine import Status, events
|
20
22
|
from schemathesis.engine.context import EngineContext
|
21
23
|
from schemathesis.engine.control import ExecutionControl
|
24
|
+
from schemathesis.engine.errors import (
|
25
|
+
TestingState,
|
26
|
+
UnrecoverableNetworkError,
|
27
|
+
clear_hypothesis_notes,
|
28
|
+
is_unrecoverable_network_error,
|
29
|
+
)
|
22
30
|
from schemathesis.engine.phases import PhaseName
|
23
31
|
from schemathesis.engine.phases.stateful.context import StatefulContext
|
24
32
|
from schemathesis.engine.recorder import ScenarioRecorder
|
25
33
|
from schemathesis.generation import overrides
|
26
34
|
from schemathesis.generation.case import Case
|
27
35
|
from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
|
36
|
+
from schemathesis.generation.stateful import STATEFUL_TESTS_LABEL
|
28
37
|
from schemathesis.generation.stateful.state_machine import (
|
29
38
|
DEFAULT_STATE_MACHINE_SETTINGS,
|
30
39
|
APIStateMachine,
|
31
40
|
StepInput,
|
32
41
|
StepOutput,
|
33
42
|
)
|
34
|
-
from schemathesis.generation.
|
43
|
+
from schemathesis.generation.metrics import MetricCollector
|
35
44
|
|
36
45
|
|
37
46
|
def _get_hypothesis_settings_kwargs_override(settings: hypothesis.settings) -> dict[str, Any]:
|
@@ -72,7 +81,8 @@ def execute_state_machine_loop(
|
|
72
81
|
hypothesis_settings = hypothesis.settings(configured_hypothesis_settings, **kwargs)
|
73
82
|
generation = engine.config.generation_for(phase="stateful")
|
74
83
|
|
75
|
-
ctx = StatefulContext(metric_collector=
|
84
|
+
ctx = StatefulContext(metric_collector=MetricCollector(metrics=generation.maximize))
|
85
|
+
state = TestingState()
|
76
86
|
|
77
87
|
# Caches for validate_response to avoid repeated config lookups per operation
|
78
88
|
_check_context_cache: dict[str, CachedCheckContextData] = {}
|
@@ -123,6 +133,20 @@ def execute_state_machine_loop(
|
|
123
133
|
ctx.step_failed()
|
124
134
|
raise
|
125
135
|
except Exception as exc:
|
136
|
+
if isinstance(exc, (requests.ConnectionError, ChunkedEncodingError)) and is_unrecoverable_network_error(
|
137
|
+
exc
|
138
|
+
):
|
139
|
+
transport_kwargs = engine.get_transport_kwargs(operation=input.case.operation)
|
140
|
+
if exc.request is not None:
|
141
|
+
headers = {key: value[0] for key, value in exc.request.headers.items()}
|
142
|
+
else:
|
143
|
+
headers = {**dict(input.case.headers or {}), **transport_kwargs.get("headers", {})}
|
144
|
+
verify = transport_kwargs.get("verify", True)
|
145
|
+
state.unrecoverable_network_error = UnrecoverableNetworkError(
|
146
|
+
error=exc,
|
147
|
+
code_sample=input.case.as_curl_command(headers=headers, verify=verify),
|
148
|
+
)
|
149
|
+
|
126
150
|
if generation.unique_inputs:
|
127
151
|
ctx.store_step_outcome(input.case, exc)
|
128
152
|
ctx.step_errored()
|
@@ -172,7 +196,7 @@ def execute_state_machine_loop(
|
|
172
196
|
case=case,
|
173
197
|
stateful_ctx=ctx,
|
174
198
|
check_ctx=check_ctx,
|
175
|
-
checks=check_ctx.
|
199
|
+
checks=check_ctx._checks,
|
176
200
|
control=engine.control,
|
177
201
|
recorder=self.recorder,
|
178
202
|
additional_checks=additional_checks,
|
@@ -259,11 +283,20 @@ def execute_state_machine_loop(
|
|
259
283
|
# Avoid infinite restarts
|
260
284
|
break
|
261
285
|
continue
|
286
|
+
clear_hypothesis_notes(exc)
|
262
287
|
# Any other exception is an inner error and the test run should be stopped
|
263
288
|
suite_status = Status.ERROR
|
289
|
+
code_sample: str | None = None
|
290
|
+
if state.unrecoverable_network_error is not None:
|
291
|
+
exc = state.unrecoverable_network_error.error
|
292
|
+
code_sample = state.unrecoverable_network_error.code_sample
|
264
293
|
event_queue.put(
|
265
294
|
events.NonFatalError(
|
266
|
-
error=exc,
|
295
|
+
error=exc,
|
296
|
+
phase=PhaseName.STATEFUL_TESTING,
|
297
|
+
label=STATEFUL_TESTS_LABEL,
|
298
|
+
related_to_operation=False,
|
299
|
+
code_sample=code_sample,
|
267
300
|
)
|
268
301
|
)
|
269
302
|
break
|
@@ -7,7 +7,7 @@ from schemathesis.core.failures import Failure
|
|
7
7
|
from schemathesis.core.transport import Response
|
8
8
|
from schemathesis.engine import Status
|
9
9
|
from schemathesis.generation.case import Case
|
10
|
-
from schemathesis.generation.
|
10
|
+
from schemathesis.generation.metrics import MetricCollector
|
11
11
|
|
12
12
|
|
13
13
|
@dataclass
|
@@ -27,7 +27,7 @@ class StatefulContext:
|
|
27
27
|
# The total number of completed test scenario
|
28
28
|
completed_scenarios: int = 0
|
29
29
|
# Metrics collector for targeted testing
|
30
|
-
metric_collector:
|
30
|
+
metric_collector: MetricCollector = field(default_factory=MetricCollector)
|
31
31
|
step_outcomes: dict[int, BaseException | None] = field(default_factory=dict)
|
32
32
|
|
33
33
|
@property
|
@@ -11,6 +11,7 @@ from hypothesis.errors import InvalidArgument
|
|
11
11
|
from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
|
12
12
|
from jsonschema.exceptions import SchemaError as JsonSchemaError
|
13
13
|
from jsonschema.exceptions import ValidationError
|
14
|
+
from requests.exceptions import ChunkedEncodingError
|
14
15
|
from requests.structures import CaseInsensitiveDict
|
15
16
|
|
16
17
|
from schemathesis.checks import CheckContext, run_checks
|
@@ -33,13 +34,17 @@ from schemathesis.engine import Status, events
|
|
33
34
|
from schemathesis.engine.context import EngineContext
|
34
35
|
from schemathesis.engine.errors import (
|
35
36
|
DeadlineExceeded,
|
37
|
+
TestingState,
|
36
38
|
UnexpectedError,
|
39
|
+
UnrecoverableNetworkError,
|
37
40
|
UnsupportedRecursiveReference,
|
41
|
+
clear_hypothesis_notes,
|
38
42
|
deduplicate_errors,
|
43
|
+
is_unrecoverable_network_error,
|
39
44
|
)
|
40
45
|
from schemathesis.engine.phases import PhaseName
|
41
46
|
from schemathesis.engine.recorder import ScenarioRecorder
|
42
|
-
from schemathesis.generation import
|
47
|
+
from schemathesis.generation import metrics, overrides
|
43
48
|
from schemathesis.generation.case import Case
|
44
49
|
from schemathesis.generation.hypothesis.builder import (
|
45
50
|
InvalidHeadersExampleMark,
|
@@ -70,9 +75,12 @@ def run_test(
|
|
70
75
|
skip_reason = None
|
71
76
|
test_start_time = time.monotonic()
|
72
77
|
recorder = ScenarioRecorder(label=operation.label)
|
78
|
+
state = TestingState()
|
73
79
|
|
74
|
-
def non_fatal_error(error: Exception) -> events.NonFatalError:
|
75
|
-
return events.NonFatalError(
|
80
|
+
def non_fatal_error(error: Exception, code_sample: str | None = None) -> events.NonFatalError:
|
81
|
+
return events.NonFatalError(
|
82
|
+
error=error, phase=phase, label=operation.label, related_to_operation=True, code_sample=code_sample
|
83
|
+
)
|
76
84
|
|
77
85
|
def scenario_finished(status: Status) -> events.ScenarioFinished:
|
78
86
|
return events.ScenarioFinished(
|
@@ -111,6 +119,7 @@ def run_test(
|
|
111
119
|
with catch_warnings(record=True) as warnings, ignore_hypothesis_output():
|
112
120
|
test_function(
|
113
121
|
ctx=ctx,
|
122
|
+
state=state,
|
114
123
|
errors=errors,
|
115
124
|
check_ctx=check_ctx,
|
116
125
|
recorder=recorder,
|
@@ -198,6 +207,7 @@ def run_test(
|
|
198
207
|
yield non_fatal_error(InvalidRegexPattern.from_schema_error(exc, from_examples=False))
|
199
208
|
except Exception as exc:
|
200
209
|
status = Status.ERROR
|
210
|
+
clear_hypothesis_notes(exc)
|
201
211
|
# Likely a YAML parsing issue. E.g. `00:00:00.00` (without quotes) is parsed as float `0.0`
|
202
212
|
if str(exc) == "first argument must be string or compiled pattern":
|
203
213
|
yield non_fatal_error(
|
@@ -207,7 +217,10 @@ def run_test(
|
|
207
217
|
)
|
208
218
|
)
|
209
219
|
else:
|
210
|
-
|
220
|
+
code_sample: str | None = None
|
221
|
+
if state.unrecoverable_network_error is not None and state.unrecoverable_network_error.error is exc:
|
222
|
+
code_sample = state.unrecoverable_network_error.code_sample
|
223
|
+
yield non_fatal_error(exc, code_sample=code_sample)
|
211
224
|
if (
|
212
225
|
status == Status.SUCCESS
|
213
226
|
and continue_on_failure
|
@@ -270,6 +283,7 @@ def cached_test_func(f: Callable) -> Callable:
|
|
270
283
|
def wrapped(
|
271
284
|
*,
|
272
285
|
ctx: EngineContext,
|
286
|
+
state: TestingState,
|
273
287
|
case: Case,
|
274
288
|
errors: list[Exception],
|
275
289
|
check_ctx: CheckContext,
|
@@ -313,6 +327,21 @@ def cached_test_func(f: Callable) -> Callable:
|
|
313
327
|
except (KeyboardInterrupt, Failure):
|
314
328
|
raise
|
315
329
|
except Exception as exc:
|
330
|
+
if isinstance(exc, (requests.ConnectionError, ChunkedEncodingError)) and is_unrecoverable_network_error(
|
331
|
+
exc
|
332
|
+
):
|
333
|
+
# Server likely has crashed and does not accept any connections at all
|
334
|
+
# Don't report these error - only the original crash should be reported
|
335
|
+
if exc.request is not None:
|
336
|
+
headers = {key: value[0] for key, value in exc.request.headers.items()}
|
337
|
+
else:
|
338
|
+
headers = {**dict(case.headers or {}), **transport_kwargs.get("headers", {})}
|
339
|
+
verify = transport_kwargs.get("verify", True)
|
340
|
+
state.unrecoverable_network_error = UnrecoverableNetworkError(
|
341
|
+
error=exc,
|
342
|
+
code_sample=case.as_curl_command(headers=headers, verify=verify),
|
343
|
+
)
|
344
|
+
raise
|
316
345
|
errors.append(exc)
|
317
346
|
raise UnexpectedError from None
|
318
347
|
|
@@ -334,14 +363,14 @@ def test_func(
|
|
334
363
|
recorder.record_case(parent_id=None, transition=None, case=case)
|
335
364
|
try:
|
336
365
|
response = case.call(**transport_kwargs)
|
337
|
-
except (requests.Timeout, requests.ConnectionError) as error:
|
366
|
+
except (requests.Timeout, requests.ConnectionError, ChunkedEncodingError) as error:
|
338
367
|
if isinstance(error.request, requests.Request):
|
339
368
|
recorder.record_request(case_id=case.id, request=error.request.prepare())
|
340
369
|
elif isinstance(error.request, requests.PreparedRequest):
|
341
370
|
recorder.record_request(case_id=case.id, request=error.request)
|
342
371
|
raise
|
343
372
|
recorder.record_response(case_id=case.id, response=response)
|
344
|
-
|
373
|
+
metrics.maximize(generation.maximize, case=case, response=response)
|
345
374
|
validate_response(
|
346
375
|
case=case,
|
347
376
|
ctx=check_ctx,
|
@@ -378,7 +407,7 @@ def validate_response(
|
|
378
407
|
case=case,
|
379
408
|
response=response,
|
380
409
|
ctx=ctx,
|
381
|
-
checks=ctx.
|
410
|
+
checks=ctx._checks,
|
382
411
|
on_failure=on_failure,
|
383
412
|
on_success=on_success,
|
384
413
|
)
|
@@ -10,9 +10,6 @@ __all__ = [
|
|
10
10
|
]
|
11
11
|
|
12
12
|
|
13
|
-
DEFAULT_GENERATOR_MODES = [GenerationMode.default()]
|
14
|
-
|
15
|
-
|
16
13
|
CASE_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
17
14
|
BASE = len(CASE_ID_ALPHABET)
|
18
15
|
# Separate `Random` as Hypothesis might interfere with the default one
|
schemathesis/generation/case.py
CHANGED
@@ -121,7 +121,7 @@ class CoverageContext:
|
|
121
121
|
path: list[str | int] | None = None,
|
122
122
|
) -> None:
|
123
123
|
self.location = location
|
124
|
-
self.generation_modes = generation_modes if generation_modes is not None else GenerationMode
|
124
|
+
self.generation_modes = generation_modes if generation_modes is not None else list(GenerationMode)
|
125
125
|
self.path = path or []
|
126
126
|
|
127
127
|
@contextmanager
|
@@ -9,7 +9,7 @@ from time import perf_counter
|
|
9
9
|
from typing import Any, Callable, Generator, Mapping
|
10
10
|
|
11
11
|
import hypothesis
|
12
|
-
from hypothesis import Phase
|
12
|
+
from hypothesis import Phase, Verbosity
|
13
13
|
from hypothesis import strategies as st
|
14
14
|
from hypothesis._settings import all_settings
|
15
15
|
from hypothesis.errors import Unsatisfiable
|
@@ -27,6 +27,7 @@ from schemathesis.core.validation import has_invalid_characters, is_latin_1_enco
|
|
27
27
|
from schemathesis.generation import GenerationMode, coverage
|
28
28
|
from schemathesis.generation.case import Case
|
29
29
|
from schemathesis.generation.hypothesis import DEFAULT_DEADLINE, examples, setup, strategies
|
30
|
+
from schemathesis.generation.hypothesis.examples import add_single_example
|
30
31
|
from schemathesis.generation.hypothesis.given import GivenInput
|
31
32
|
from schemathesis.generation.meta import (
|
32
33
|
CaseMetadata,
|
@@ -96,6 +97,9 @@ def create_test(
|
|
96
97
|
if settings.deadline == default.deadline:
|
97
98
|
settings = hypothesis.settings(settings, deadline=DEFAULT_DEADLINE)
|
98
99
|
|
100
|
+
if settings.verbosity == default.verbosity:
|
101
|
+
settings = hypothesis.settings(settings, verbosity=Verbosity.quiet)
|
102
|
+
|
99
103
|
if config.settings is not None:
|
100
104
|
# Merge the user-provided settings with the current ones
|
101
105
|
settings = hypothesis.settings(
|
@@ -126,7 +130,14 @@ def create_test(
|
|
126
130
|
and Phase.explicit in settings.phases
|
127
131
|
and specification.supports_feature(SpecificationFeature.EXAMPLES)
|
128
132
|
):
|
129
|
-
|
133
|
+
phases_config = config.project.phases_for(operation=operation)
|
134
|
+
hypothesis_test = add_examples(
|
135
|
+
hypothesis_test,
|
136
|
+
operation,
|
137
|
+
fill_missing=phases_config.examples.fill_missing,
|
138
|
+
hook_dispatcher=hook_dispatcher,
|
139
|
+
**strategy_kwargs,
|
140
|
+
)
|
130
141
|
|
131
142
|
if (
|
132
143
|
HypothesisTestMode.COVERAGE in config.modes
|
@@ -142,7 +153,8 @@ def create_test(
|
|
142
153
|
generation.modes,
|
143
154
|
auth_storage,
|
144
155
|
config.as_strategy_kwargs,
|
145
|
-
phases_config.coverage.
|
156
|
+
generate_duplicate_query_parameters=phases_config.coverage.generate_duplicate_query_parameters,
|
157
|
+
unexpected_methods=phases_config.coverage.unexpected_methods,
|
146
158
|
)
|
147
159
|
|
148
160
|
setattr(hypothesis_test, SETTINGS_ATTRIBUTE_NAME, settings)
|
@@ -188,7 +200,11 @@ def make_async_test(test: Callable) -> Callable:
|
|
188
200
|
|
189
201
|
|
190
202
|
def add_examples(
|
191
|
-
test: Callable,
|
203
|
+
test: Callable,
|
204
|
+
operation: APIOperation,
|
205
|
+
fill_missing: bool,
|
206
|
+
hook_dispatcher: HookDispatcher | None = None,
|
207
|
+
**kwargs: Any,
|
192
208
|
) -> Callable:
|
193
209
|
"""Add examples to the Hypothesis test, if they are specified in the schema."""
|
194
210
|
from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
|
@@ -212,7 +228,11 @@ def add_examples(
|
|
212
228
|
if isinstance(exc, SchemaError):
|
213
229
|
InvalidRegexMark.set(test, exc)
|
214
230
|
|
215
|
-
|
231
|
+
if fill_missing and not result:
|
232
|
+
strategy = operation.as_strategy()
|
233
|
+
add_single_example(strategy, result)
|
234
|
+
|
235
|
+
context = HookContext(operation=operation) # context should be passed here instead
|
216
236
|
GLOBAL_HOOK_DISPATCHER.dispatch("before_add_examples", context, result)
|
217
237
|
operation.schema.hooks.dispatch("before_add_examples", context, result)
|
218
238
|
if hook_dispatcher:
|
@@ -246,6 +266,7 @@ def add_coverage(
|
|
246
266
|
generation_modes: list[GenerationMode],
|
247
267
|
auth_storage: AuthStorage | None,
|
248
268
|
as_strategy_kwargs: dict[str, Any],
|
269
|
+
generate_duplicate_query_parameters: bool,
|
249
270
|
unexpected_methods: set[str] | None = None,
|
250
271
|
) -> Callable:
|
251
272
|
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
@@ -259,7 +280,9 @@ def add_coverage(
|
|
259
280
|
for container in LOCATION_TO_CONTAINER.values()
|
260
281
|
if container in as_strategy_kwargs
|
261
282
|
}
|
262
|
-
for case in _iter_coverage_cases(
|
283
|
+
for case in _iter_coverage_cases(
|
284
|
+
operation, generation_modes, generate_duplicate_query_parameters, unexpected_methods
|
285
|
+
):
|
263
286
|
if case.media_type and operation.schema.transport.get_first_matching_media_type(case.media_type) is None:
|
264
287
|
continue
|
265
288
|
adjust_urlencoded_payload(case)
|
@@ -396,6 +419,7 @@ def _stringify_value(val: Any, container_name: str) -> Any:
|
|
396
419
|
def _iter_coverage_cases(
|
397
420
|
operation: APIOperation,
|
398
421
|
generation_modes: list[GenerationMode],
|
422
|
+
generate_duplicate_query_parameters: bool,
|
399
423
|
unexpected_methods: set[str] | None = None,
|
400
424
|
) -> Generator[Case, None, None]:
|
401
425
|
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
@@ -549,7 +573,7 @@ def _iter_coverage_cases(
|
|
549
573
|
),
|
550
574
|
)
|
551
575
|
# Generate duplicate query parameters
|
552
|
-
if operation.query:
|
576
|
+
if generate_duplicate_query_parameters and operation.query:
|
553
577
|
container = template["query"]
|
554
578
|
for parameter in operation.query:
|
555
579
|
instant = Instant()
|
@@ -0,0 +1,93 @@
|
|
1
|
+
"""Support for Targeted Property-Based Testing."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from typing import Callable, Sequence
|
7
|
+
|
8
|
+
from schemathesis.core.registries import Registry
|
9
|
+
from schemathesis.core.transport import Response
|
10
|
+
from schemathesis.generation.case import Case
|
11
|
+
|
12
|
+
|
13
|
+
@dataclass
|
14
|
+
class MetricContext:
|
15
|
+
"""Context for evaluating a metric on a single test execution.
|
16
|
+
|
17
|
+
This object bundles together the test `case` that was sent and
|
18
|
+
the corresponding HTTP `response`. Metric functions receive an
|
19
|
+
instance of `MetricContext` to compute a numeric score.
|
20
|
+
"""
|
21
|
+
|
22
|
+
case: Case
|
23
|
+
"""Generated test case."""
|
24
|
+
response: Response
|
25
|
+
"""The HTTP response returned by the server for this test case."""
|
26
|
+
|
27
|
+
__slots__ = ("case", "response")
|
28
|
+
|
29
|
+
|
30
|
+
MetricFunction = Callable[[MetricContext], float]
|
31
|
+
|
32
|
+
METRICS = Registry[MetricFunction]()
|
33
|
+
|
34
|
+
|
35
|
+
def metric(func: MetricFunction) -> MetricFunction:
|
36
|
+
"""Decorator to register a custom metric for targeted property-based testing.
|
37
|
+
|
38
|
+
Example:
|
39
|
+
```python
|
40
|
+
import schemathesis
|
41
|
+
|
42
|
+
@schemathesis.metric
|
43
|
+
def response_size(ctx: schemathesis.MetricContext) -> float:
|
44
|
+
return float(len(ctx.response.content))
|
45
|
+
```
|
46
|
+
|
47
|
+
"""
|
48
|
+
return METRICS.register(func)
|
49
|
+
|
50
|
+
|
51
|
+
@metric
|
52
|
+
def response_time(ctx: MetricContext) -> float:
|
53
|
+
"""Response time as a metric to maximize."""
|
54
|
+
return ctx.response.elapsed
|
55
|
+
|
56
|
+
|
57
|
+
class MetricCollector:
|
58
|
+
"""Collect multiple observations for metrics."""
|
59
|
+
|
60
|
+
__slots__ = ("metrics", "observations")
|
61
|
+
|
62
|
+
def __init__(self, metrics: list[MetricFunction] | None = None) -> None:
|
63
|
+
self.metrics = metrics or []
|
64
|
+
self.observations: dict[str, list[float]] = {metric.__name__: [] for metric in self.metrics}
|
65
|
+
|
66
|
+
def reset(self) -> None:
|
67
|
+
"""Reset all collected observations."""
|
68
|
+
for metric in self.metrics:
|
69
|
+
self.observations[metric.__name__].clear()
|
70
|
+
|
71
|
+
def store(self, case: Case, response: Response) -> None:
|
72
|
+
"""Calculate metrics & store them."""
|
73
|
+
ctx = MetricContext(case=case, response=response)
|
74
|
+
for metric in self.metrics:
|
75
|
+
self.observations[metric.__name__].append(metric(ctx))
|
76
|
+
|
77
|
+
def maximize(self) -> None:
|
78
|
+
"""Give feedback to the Hypothesis engine, so it maximizes the aggregated metrics."""
|
79
|
+
import hypothesis
|
80
|
+
|
81
|
+
for metric in self.metrics:
|
82
|
+
# Currently aggregation is just a sum
|
83
|
+
value = sum(self.observations[metric.__name__])
|
84
|
+
hypothesis.target(value, label=metric.__name__)
|
85
|
+
|
86
|
+
|
87
|
+
def maximize(metrics: Sequence[MetricFunction], case: Case, response: Response) -> None:
|
88
|
+
import hypothesis
|
89
|
+
|
90
|
+
ctx = MetricContext(case=case, response=response)
|
91
|
+
for metric in metrics:
|
92
|
+
value = metric(ctx)
|
93
|
+
hypothesis.target(value, label=metric.__name__)
|
schemathesis/generation/modes.py
CHANGED
@@ -11,14 +11,6 @@ class GenerationMode(str, Enum):
|
|
11
11
|
# Doesn't fit the API schema
|
12
12
|
NEGATIVE = "negative"
|
13
13
|
|
14
|
-
@classmethod
|
15
|
-
def default(cls) -> GenerationMode:
|
16
|
-
return cls.POSITIVE
|
17
|
-
|
18
|
-
@classmethod
|
19
|
-
def all(cls) -> list[GenerationMode]:
|
20
|
-
return list(GenerationMode)
|
21
|
-
|
22
14
|
@property
|
23
15
|
def is_positive(self) -> bool:
|
24
16
|
return self == GenerationMode.POSITIVE
|
@@ -7,6 +7,8 @@ if TYPE_CHECKING:
|
|
7
7
|
|
8
8
|
from schemathesis.generation.stateful.state_machine import APIStateMachine
|
9
9
|
|
10
|
+
STATEFUL_TESTS_LABEL = "Stateful tests"
|
11
|
+
|
10
12
|
|
11
13
|
def run_state_machine_as_test(
|
12
14
|
state_machine_factory: type[APIStateMachine], *, settings: hypothesis.settings | None = None
|
@@ -17,4 +19,6 @@ def run_state_machine_as_test(
|
|
17
19
|
"""
|
18
20
|
from hypothesis.stateful import run_state_machine_as_test as _run_state_machine_as_test
|
19
21
|
|
22
|
+
__tracebackhide__ = True
|
23
|
+
|
20
24
|
return _run_state_machine_as_test(state_machine_factory, settings=settings, _min_steps=2)
|
@@ -196,6 +196,7 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
196
196
|
"""Run state machine as a test."""
|
197
197
|
from . import run_state_machine_as_test
|
198
198
|
|
199
|
+
__tracebackhide__ = True
|
199
200
|
return run_state_machine_as_test(cls, settings=settings)
|
200
201
|
|
201
202
|
def setup(self) -> None:
|