schemathesis 3.13.0__py3-none-any.whl → 4.4.2__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 +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1016
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +683 -247
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +27 -0
- schemathesis/specs/graphql/scalars.py +86 -0
- schemathesis/specs/graphql/schemas.py +395 -123
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +578 -317
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +753 -74
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +117 -68
- schemathesis/specs/openapi/negative/mutations.py +294 -104
- schemathesis/specs/openapi/negative/utils.py +3 -6
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +648 -650
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +404 -69
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -41
- schemathesis/_hypothesis.py +0 -115
- schemathesis/cli/callbacks.py +0 -188
- schemathesis/cli/cassettes.py +0 -253
- schemathesis/cli/context.py +0 -36
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -51
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -508
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -79
- schemathesis/exceptions.py +0 -207
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -216
- schemathesis/failures.py +0 -131
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/lazy.py +0 -227
- schemathesis/models.py +0 -1041
- schemathesis/parameters.py +0 -88
- schemathesis/runner/__init__.py +0 -460
- schemathesis/runner/events.py +0 -240
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -755
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -189
- schemathesis/serializers.py +0 -233
- schemathesis/service/__init__.py +0 -3
- schemathesis/service/client.py +0 -46
- schemathesis/service/constants.py +0 -12
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -39
- schemathesis/service/models.py +0 -7
- schemathesis/service/serialization.py +0 -153
- schemathesis/service/worker.py +0 -40
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -302
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -413
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -349
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -436
- schemathesis-3.13.0.dist-info/METADATA +0 -202
- schemathesis-3.13.0.dist-info/RECORD +0 -91
- schemathesis-3.13.0.dist-info/entry_points.txt +0 -6
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
|
@@ -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__)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GenerationMode(str, Enum):
|
|
7
|
+
"""Defines what data Schemathesis generates for tests."""
|
|
8
|
+
|
|
9
|
+
# Generate data, that fits the API schema
|
|
10
|
+
POSITIVE = "positive"
|
|
11
|
+
# Doesn't fit the API schema
|
|
12
|
+
NEGATIVE = "negative"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def is_positive(self) -> bool:
|
|
16
|
+
return self == GenerationMode.POSITIVE
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def is_negative(self) -> bool:
|
|
20
|
+
return self == GenerationMode.NEGATIVE
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Iterator
|
|
6
|
+
|
|
7
|
+
from schemathesis.config import ProjectConfig
|
|
8
|
+
from schemathesis.core.parameters import ParameterLocation
|
|
9
|
+
from schemathesis.core.transforms import diff
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from schemathesis.generation.case import Case
|
|
13
|
+
from schemathesis.schemas import APIOperation, OperationParameter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Override:
|
|
18
|
+
"""Overrides for various parts of a test case."""
|
|
19
|
+
|
|
20
|
+
query: dict[str, str]
|
|
21
|
+
headers: dict[str, str]
|
|
22
|
+
cookies: dict[str, str]
|
|
23
|
+
path_parameters: dict[str, str]
|
|
24
|
+
body: dict[str, str]
|
|
25
|
+
|
|
26
|
+
__slots__ = ("query", "headers", "cookies", "path_parameters", "body")
|
|
27
|
+
|
|
28
|
+
def items(self) -> Iterator[tuple[ParameterLocation, dict[str, str]]]:
|
|
29
|
+
for key, value in (
|
|
30
|
+
(ParameterLocation.QUERY, self.query),
|
|
31
|
+
(ParameterLocation.HEADER, self.headers),
|
|
32
|
+
(ParameterLocation.COOKIE, self.cookies),
|
|
33
|
+
(ParameterLocation.PATH, self.path_parameters),
|
|
34
|
+
):
|
|
35
|
+
if value:
|
|
36
|
+
yield key, value
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_components(cls, components: dict[ParameterLocation, StoredValue], case: Case) -> Override:
|
|
40
|
+
return Override(
|
|
41
|
+
**{
|
|
42
|
+
kind.container_name: get_component_diff(stored=stored, current=getattr(case, kind.container_name))
|
|
43
|
+
for kind, stored in components.items()
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def for_operation(config: ProjectConfig, *, operation: APIOperation) -> Override:
|
|
49
|
+
operation_config = config.operations.get_for_operation(operation)
|
|
50
|
+
|
|
51
|
+
output = Override(query={}, headers={}, cookies={}, path_parameters={}, body={})
|
|
52
|
+
groups = [
|
|
53
|
+
(output.query, operation.query),
|
|
54
|
+
(output.headers, operation.headers),
|
|
55
|
+
(output.cookies, operation.cookies),
|
|
56
|
+
(output.path_parameters, operation.path_parameters),
|
|
57
|
+
]
|
|
58
|
+
for container, params in groups:
|
|
59
|
+
for param in params:
|
|
60
|
+
# Attempt to get the override from the operation-specific configuration.
|
|
61
|
+
value = None
|
|
62
|
+
if operation_config:
|
|
63
|
+
value = _get_override_value(param, operation_config.parameters)
|
|
64
|
+
# Fallback to the global project configuration.
|
|
65
|
+
if value is None:
|
|
66
|
+
value = _get_override_value(param, config.parameters)
|
|
67
|
+
if value is not None:
|
|
68
|
+
container[param.name] = value
|
|
69
|
+
|
|
70
|
+
return output
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_override_value(param: OperationParameter, parameters: dict[str, Any]) -> Any:
|
|
74
|
+
key = param.name
|
|
75
|
+
full_key = f"{param.location.value}.{param.name}"
|
|
76
|
+
if key in parameters:
|
|
77
|
+
return parameters[key]
|
|
78
|
+
elif full_key in parameters:
|
|
79
|
+
return parameters[full_key]
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class StoredValue:
|
|
85
|
+
value: Any
|
|
86
|
+
is_generated: bool
|
|
87
|
+
|
|
88
|
+
__slots__ = ("value", "is_generated")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def store_original_state(value: Any) -> Any:
|
|
92
|
+
if isinstance(value, Mapping):
|
|
93
|
+
return dict(value)
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_component_diff(stored: StoredValue, current: Any) -> dict[str, Any]:
|
|
98
|
+
"""Calculate difference between stored and current components."""
|
|
99
|
+
if not (current and stored.value):
|
|
100
|
+
return {}
|
|
101
|
+
if stored.is_generated:
|
|
102
|
+
# Only compute diff for mapping types (dicts)
|
|
103
|
+
# Non-mapping bodies (e.g., GraphQL strings) are not tracked
|
|
104
|
+
if isinstance(stored.value, Mapping) and isinstance(current, Mapping):
|
|
105
|
+
return diff(stored.value, current)
|
|
106
|
+
return {}
|
|
107
|
+
# For non-generated components, return current if it's a dict, otherwise empty
|
|
108
|
+
if isinstance(current, Mapping):
|
|
109
|
+
return dict(current)
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def store_components(case: Case) -> dict[ParameterLocation, StoredValue]:
|
|
114
|
+
"""Store original component states for a test case."""
|
|
115
|
+
return {
|
|
116
|
+
kind: StoredValue(
|
|
117
|
+
value=store_original_state(getattr(case, kind.container_name)),
|
|
118
|
+
is_generated=bool(case._meta and kind in case._meta.components),
|
|
119
|
+
)
|
|
120
|
+
for kind in [
|
|
121
|
+
ParameterLocation.QUERY,
|
|
122
|
+
ParameterLocation.HEADER,
|
|
123
|
+
ParameterLocation.COOKIE,
|
|
124
|
+
ParameterLocation.PATH,
|
|
125
|
+
ParameterLocation.BODY,
|
|
126
|
+
]
|
|
127
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
import hypothesis
|
|
7
|
+
|
|
8
|
+
from schemathesis.generation.stateful.state_machine import APIStateMachine
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"APIStateMachine",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def __getattr__(name: str) -> type[APIStateMachine]:
|
|
16
|
+
if name == "APIStateMachine":
|
|
17
|
+
from schemathesis.generation.stateful.state_machine import APIStateMachine
|
|
18
|
+
|
|
19
|
+
return APIStateMachine
|
|
20
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
STATEFUL_TESTS_LABEL = "Stateful tests"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_state_machine_as_test(
|
|
27
|
+
state_machine_factory: type[APIStateMachine], *, settings: hypothesis.settings | None = None
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Run a state machine as a test.
|
|
30
|
+
|
|
31
|
+
It automatically adds the `_min_steps` argument if ``Hypothesis`` is recent enough.
|
|
32
|
+
"""
|
|
33
|
+
from hypothesis.stateful import run_state_machine_as_test as _run_state_machine_as_test
|
|
34
|
+
|
|
35
|
+
__tracebackhide__ = True
|
|
36
|
+
|
|
37
|
+
return _run_state_machine_as_test(state_machine_factory, settings=settings, _min_steps=2)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
7
|
+
|
|
8
|
+
import hypothesis
|
|
9
|
+
from hypothesis.errors import InvalidDefinition
|
|
10
|
+
from hypothesis.stateful import RuleBasedStateMachine
|
|
11
|
+
|
|
12
|
+
from schemathesis.checks import CheckFunction
|
|
13
|
+
from schemathesis.core import DEFAULT_STATEFUL_STEP_COUNT
|
|
14
|
+
from schemathesis.core.errors import STATEFUL_TESTING_GUIDE_URL, NoLinksFound
|
|
15
|
+
from schemathesis.core.result import Result
|
|
16
|
+
from schemathesis.core.transport import Response
|
|
17
|
+
from schemathesis.generation.case import Case
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
import hypothesis
|
|
21
|
+
from requests.structures import CaseInsensitiveDict
|
|
22
|
+
|
|
23
|
+
from schemathesis.schemas import BaseSchema
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_STATE_MACHINE_SETTINGS = hypothesis.settings(
|
|
27
|
+
phases=[hypothesis.Phase.generate],
|
|
28
|
+
deadline=None,
|
|
29
|
+
stateful_step_count=DEFAULT_STATEFUL_STEP_COUNT,
|
|
30
|
+
suppress_health_check=list(hypothesis.HealthCheck),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class StepInput:
|
|
36
|
+
"""Input for a single state machine step."""
|
|
37
|
+
|
|
38
|
+
case: Case
|
|
39
|
+
transition: Transition | None # None for initial steps
|
|
40
|
+
# What parameters were actually applied
|
|
41
|
+
# Data extraction failures can prevent it, as well as transitions can be skipped in some cases
|
|
42
|
+
# to improve discovery of bugs triggered by non-stateful inputs during stateful testing
|
|
43
|
+
applied_parameters: list[str]
|
|
44
|
+
|
|
45
|
+
__slots__ = ("case", "transition", "applied_parameters")
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def initial(cls, case: Case) -> StepInput:
|
|
49
|
+
return cls(case=case, transition=None, applied_parameters=[])
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_applied(self) -> bool:
|
|
53
|
+
# If the transition has no parameters or body, count it as applied
|
|
54
|
+
if self.transition is not None and not self.transition.parameters and self.transition.request_body is None:
|
|
55
|
+
return True
|
|
56
|
+
return bool(self.applied_parameters)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Transition:
|
|
61
|
+
"""Data about transition execution."""
|
|
62
|
+
|
|
63
|
+
# ID of the transition (e.g. link name)
|
|
64
|
+
id: str
|
|
65
|
+
parent_id: str
|
|
66
|
+
is_inferred: bool
|
|
67
|
+
parameters: dict[str, dict[str, ExtractedParam]]
|
|
68
|
+
request_body: ExtractedParam | None
|
|
69
|
+
|
|
70
|
+
__slots__ = ("id", "parent_id", "is_inferred", "parameters", "request_body")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ExtractedParam:
|
|
75
|
+
"""Result of parameter extraction."""
|
|
76
|
+
|
|
77
|
+
definition: Any
|
|
78
|
+
value: Result[Any, Exception]
|
|
79
|
+
is_required: bool
|
|
80
|
+
|
|
81
|
+
__slots__ = ("definition", "value", "is_required")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ExtractionFailure:
|
|
86
|
+
"""Represents a failure to extract data from a transition."""
|
|
87
|
+
|
|
88
|
+
# e.g., "GetUser"
|
|
89
|
+
id: str
|
|
90
|
+
case_id: str
|
|
91
|
+
# e.g., "POST /users"
|
|
92
|
+
source: str
|
|
93
|
+
# e.g., "GET /users/{userId}"
|
|
94
|
+
target: str
|
|
95
|
+
# e.g., "userId"
|
|
96
|
+
parameter_name: str
|
|
97
|
+
# e.g., "$response.body#/id"
|
|
98
|
+
expression: str
|
|
99
|
+
# Previous test cases in the chain, from newest to oldest
|
|
100
|
+
# Stored as a case + response pair
|
|
101
|
+
history: list[tuple[Case, Response]]
|
|
102
|
+
# The actual response that caused the failure
|
|
103
|
+
response: Response
|
|
104
|
+
error: Exception | None
|
|
105
|
+
|
|
106
|
+
__slots__ = ("id", "case_id", "source", "target", "parameter_name", "expression", "history", "response", "error")
|
|
107
|
+
|
|
108
|
+
def __eq__(self, other: object) -> bool:
|
|
109
|
+
assert isinstance(other, ExtractionFailure)
|
|
110
|
+
return (
|
|
111
|
+
self.source == other.source
|
|
112
|
+
and self.target == other.target
|
|
113
|
+
and self.id == other.id
|
|
114
|
+
and self.parameter_name == other.parameter_name
|
|
115
|
+
and self.expression == other.expression
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def __hash__(self) -> int:
|
|
119
|
+
return hash(
|
|
120
|
+
(
|
|
121
|
+
self.source,
|
|
122
|
+
self.target,
|
|
123
|
+
self.id,
|
|
124
|
+
self.parameter_name,
|
|
125
|
+
self.expression,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class StepOutput:
|
|
132
|
+
"""Output from a single transition of a state machine."""
|
|
133
|
+
|
|
134
|
+
response: Response
|
|
135
|
+
case: Case
|
|
136
|
+
|
|
137
|
+
__slots__ = ("response", "case")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _normalize_name(name: str) -> str:
|
|
141
|
+
return re.sub(r"\W|^(?=\d)", "_", name).replace("__", "_")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class APIStateMachine(RuleBasedStateMachine):
|
|
145
|
+
"""State machine for executing API operation sequences based on OpenAPI links.
|
|
146
|
+
|
|
147
|
+
Automatically generates test scenarios by chaining API operations according
|
|
148
|
+
to their defined relationships in the schema.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
# This is a convenience attribute, which happened to clash with `RuleBasedStateMachine` instance level attribute
|
|
152
|
+
# They don't interfere, since it is properly overridden on the Hypothesis side, but it is likely that this
|
|
153
|
+
# attribute will be renamed in the future
|
|
154
|
+
bundles: ClassVar[dict[str, CaseInsensitiveDict]]
|
|
155
|
+
schema: BaseSchema
|
|
156
|
+
|
|
157
|
+
def __init__(self) -> None:
|
|
158
|
+
try:
|
|
159
|
+
super().__init__()
|
|
160
|
+
except InvalidDefinition as exc:
|
|
161
|
+
if "defines no rules" in str(exc):
|
|
162
|
+
if not self.schema.statistic.links.total:
|
|
163
|
+
message = "Schema contains no link definitions required for stateful testing"
|
|
164
|
+
else:
|
|
165
|
+
message = "All link definitions required for stateful testing are excluded by filters"
|
|
166
|
+
message += f"\n\nLearn how to define links: {STATEFUL_TESTING_GUIDE_URL}"
|
|
167
|
+
raise NoLinksFound(message) from None
|
|
168
|
+
raise
|
|
169
|
+
self.setup()
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
@lru_cache
|
|
173
|
+
def _to_test_case(cls) -> type:
|
|
174
|
+
from schemathesis.generation.stateful import run_state_machine_as_test
|
|
175
|
+
|
|
176
|
+
class StateMachineTestCase(RuleBasedStateMachine.TestCase):
|
|
177
|
+
settings = DEFAULT_STATE_MACHINE_SETTINGS
|
|
178
|
+
|
|
179
|
+
def runTest(self) -> None:
|
|
180
|
+
run_state_machine_as_test(cls, settings=self.settings)
|
|
181
|
+
|
|
182
|
+
runTest.is_hypothesis_test = True # type: ignore[attr-defined]
|
|
183
|
+
|
|
184
|
+
StateMachineTestCase.__name__ = cls.__name__ + ".TestCase"
|
|
185
|
+
StateMachineTestCase.__qualname__ = cls.__qualname__ + ".TestCase"
|
|
186
|
+
return StateMachineTestCase
|
|
187
|
+
|
|
188
|
+
def _new_name(self, target: str) -> str:
|
|
189
|
+
target = _normalize_name(target)
|
|
190
|
+
return super()._new_name(target)
|
|
191
|
+
|
|
192
|
+
def _get_target_for_result(self, result: StepOutput) -> str | None:
|
|
193
|
+
raise NotImplementedError
|
|
194
|
+
|
|
195
|
+
def _add_result_to_targets(self, targets: tuple[str, ...], result: StepOutput | None) -> None:
|
|
196
|
+
if result is None:
|
|
197
|
+
return
|
|
198
|
+
target = self._get_target_for_result(result)
|
|
199
|
+
if target is not None:
|
|
200
|
+
super()._add_result_to_targets((target,), result)
|
|
201
|
+
|
|
202
|
+
def _add_results_to_targets(self, targets: tuple[str, ...], results: list[StepOutput | None]) -> None:
|
|
203
|
+
# Hypothesis >6.131.15
|
|
204
|
+
for result in results:
|
|
205
|
+
if result is None:
|
|
206
|
+
continue
|
|
207
|
+
target = self._get_target_for_result(result)
|
|
208
|
+
if target is not None:
|
|
209
|
+
super()._add_results_to_targets((target,), [result])
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def run(cls, *, settings: hypothesis.settings | None = None) -> None:
|
|
213
|
+
"""Execute the state machine test scenarios.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
settings: Hypothesis settings for test execution.
|
|
217
|
+
|
|
218
|
+
"""
|
|
219
|
+
from . import run_state_machine_as_test
|
|
220
|
+
|
|
221
|
+
__tracebackhide__ = True
|
|
222
|
+
return run_state_machine_as_test(cls, settings=settings)
|
|
223
|
+
|
|
224
|
+
def setup(self) -> None:
|
|
225
|
+
"""Called once at the beginning of each test scenario."""
|
|
226
|
+
|
|
227
|
+
def teardown(self) -> None:
|
|
228
|
+
"""Called once at the end of each test scenario."""
|
|
229
|
+
|
|
230
|
+
# To provide the return type in the rendered documentation
|
|
231
|
+
teardown.__doc__ = RuleBasedStateMachine.teardown.__doc__
|
|
232
|
+
|
|
233
|
+
def _step(self, input: StepInput) -> StepOutput | None:
|
|
234
|
+
__tracebackhide__ = True
|
|
235
|
+
return self.step(input)
|
|
236
|
+
|
|
237
|
+
def step(self, input: StepInput) -> StepOutput:
|
|
238
|
+
__tracebackhide__ = True
|
|
239
|
+
self.before_call(input.case)
|
|
240
|
+
kwargs = self.get_call_kwargs(input.case)
|
|
241
|
+
response = self.call(input.case, **kwargs)
|
|
242
|
+
self.after_call(response, input.case)
|
|
243
|
+
self.validate_response(response, input.case, **kwargs)
|
|
244
|
+
return StepOutput(response, input.case)
|
|
245
|
+
|
|
246
|
+
def before_call(self, case: Case) -> None:
|
|
247
|
+
"""Called before each API operation in the scenario.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
case: Test case data for the operation.
|
|
251
|
+
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
def after_call(self, response: Response, case: Case) -> None:
|
|
255
|
+
"""Called after each API operation in the scenario.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
response: HTTP response from the operation.
|
|
259
|
+
case: Test case data that was executed.
|
|
260
|
+
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
def call(self, case: Case, **kwargs: Any) -> Response:
|
|
264
|
+
return case.call(**kwargs)
|
|
265
|
+
|
|
266
|
+
def get_call_kwargs(self, case: Case) -> dict[str, Any]:
|
|
267
|
+
"""Returns keyword arguments for the API call.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
case: Test case being executed.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Dictionary passed to the `case.call()` method.
|
|
274
|
+
|
|
275
|
+
"""
|
|
276
|
+
return {}
|
|
277
|
+
|
|
278
|
+
def validate_response(
|
|
279
|
+
self, response: Response, case: Case, additional_checks: list[CheckFunction] | None = None, **kwargs: Any
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Validates the API response using configured checks.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
response: HTTP response to validate.
|
|
285
|
+
case: Test case that generated the response.
|
|
286
|
+
additional_checks: Extra validation functions to run.
|
|
287
|
+
kwargs: Transport-level keyword arguments.
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
FailureGroup: When validation checks fail.
|
|
291
|
+
|
|
292
|
+
"""
|
|
293
|
+
__tracebackhide__ = True
|
|
294
|
+
case.validate_response(response, additional_checks=additional_checks, transport_kwargs=kwargs)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from schemathesis.graphql.loaders import from_asgi, from_dict, from_file, from_path, from_url, from_wsgi
|
|
2
|
+
|
|
3
|
+
from ..specs.graphql import nodes
|
|
4
|
+
from ..specs.graphql.scalars import scalar
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"from_url",
|
|
8
|
+
"from_asgi",
|
|
9
|
+
"from_wsgi",
|
|
10
|
+
"from_file",
|
|
11
|
+
"from_path",
|
|
12
|
+
"from_dict",
|
|
13
|
+
"nodes",
|
|
14
|
+
"scalar",
|
|
15
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from schemathesis.core.failures import Failure, Severity
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from graphql.error import GraphQLFormattedError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UnexpectedGraphQLResponse(Failure):
|
|
12
|
+
"""GraphQL response is not a JSON object."""
|
|
13
|
+
|
|
14
|
+
__slots__ = ("operation", "type_name", "title", "message", "case_id", "severity")
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
operation: str,
|
|
20
|
+
type_name: str,
|
|
21
|
+
title: str = "Unexpected GraphQL Response",
|
|
22
|
+
message: str,
|
|
23
|
+
case_id: str | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.operation = operation
|
|
26
|
+
self.type_name = type_name
|
|
27
|
+
self.title = title
|
|
28
|
+
self.message = message
|
|
29
|
+
self.case_id = case_id
|
|
30
|
+
self.severity = Severity.MEDIUM
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def _unique_key(self) -> str:
|
|
34
|
+
return self.type_name
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GraphQLClientError(Failure):
|
|
38
|
+
"""GraphQL query has not been executed."""
|
|
39
|
+
|
|
40
|
+
__slots__ = ("operation", "errors", "title", "message", "case_id", "_unique_key_cache", "severity")
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
operation: str,
|
|
46
|
+
message: str,
|
|
47
|
+
errors: list[GraphQLFormattedError],
|
|
48
|
+
title: str = "GraphQL client error",
|
|
49
|
+
case_id: str | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.operation = operation
|
|
52
|
+
self.errors = errors
|
|
53
|
+
self.title = title
|
|
54
|
+
self.message = message
|
|
55
|
+
self.case_id = case_id
|
|
56
|
+
self._unique_key_cache: str | None = None
|
|
57
|
+
self.severity = Severity.MEDIUM
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def _unique_key(self) -> str:
|
|
61
|
+
if self._unique_key_cache is None:
|
|
62
|
+
self._unique_key_cache = _group_graphql_errors(self.errors)
|
|
63
|
+
return self._unique_key_cache
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GraphQLServerError(Failure):
|
|
67
|
+
"""GraphQL response indicates at least one server error."""
|
|
68
|
+
|
|
69
|
+
__slots__ = ("operation", "errors", "title", "message", "case_id", "_unique_key_cache", "severity")
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
operation: str,
|
|
75
|
+
message: str,
|
|
76
|
+
errors: list[GraphQLFormattedError],
|
|
77
|
+
title: str = "GraphQL server error",
|
|
78
|
+
case_id: str | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self.operation = operation
|
|
81
|
+
self.errors = errors
|
|
82
|
+
self.title = title
|
|
83
|
+
self.message = message
|
|
84
|
+
self.case_id = case_id
|
|
85
|
+
self._unique_key_cache: str | None = None
|
|
86
|
+
self.severity = Severity.CRITICAL
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def _unique_key(self) -> str:
|
|
90
|
+
if self._unique_key_cache is None:
|
|
91
|
+
self._unique_key_cache = _group_graphql_errors(self.errors)
|
|
92
|
+
return self._unique_key_cache
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _group_graphql_errors(errors: list[GraphQLFormattedError]) -> str:
|
|
96
|
+
entries = []
|
|
97
|
+
for error in errors:
|
|
98
|
+
message = error["message"]
|
|
99
|
+
if "locations" in error:
|
|
100
|
+
message += ";locations:"
|
|
101
|
+
for location in sorted(error["locations"]):
|
|
102
|
+
message += f"({location['line'], location['column']})"
|
|
103
|
+
if "path" in error:
|
|
104
|
+
message += ";path:"
|
|
105
|
+
for chunk in error["path"]:
|
|
106
|
+
message += str(chunk)
|
|
107
|
+
entries.append(message)
|
|
108
|
+
entries.sort()
|
|
109
|
+
return "".join(entries)
|