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.
Files changed (245) hide show
  1. schemathesis/__init__.py +53 -25
  2. schemathesis/auths.py +507 -0
  3. schemathesis/checks.py +190 -25
  4. schemathesis/cli/__init__.py +27 -1016
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +133 -0
  7. schemathesis/cli/commands/data.py +10 -0
  8. schemathesis/cli/commands/run/__init__.py +602 -0
  9. schemathesis/cli/commands/run/context.py +228 -0
  10. schemathesis/cli/commands/run/events.py +60 -0
  11. schemathesis/cli/commands/run/executor.py +157 -0
  12. schemathesis/cli/commands/run/filters.py +53 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +45 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
  17. schemathesis/cli/commands/run/handlers/output.py +1750 -0
  18. schemathesis/cli/commands/run/loaders.py +118 -0
  19. schemathesis/cli/commands/run/validation.py +256 -0
  20. schemathesis/cli/constants.py +5 -0
  21. schemathesis/cli/core.py +19 -0
  22. schemathesis/cli/ext/fs.py +16 -0
  23. schemathesis/cli/ext/groups.py +203 -0
  24. schemathesis/cli/ext/options.py +81 -0
  25. schemathesis/config/__init__.py +202 -0
  26. schemathesis/config/_auth.py +51 -0
  27. schemathesis/config/_checks.py +268 -0
  28. schemathesis/config/_diff_base.py +101 -0
  29. schemathesis/config/_env.py +21 -0
  30. schemathesis/config/_error.py +163 -0
  31. schemathesis/config/_generation.py +157 -0
  32. schemathesis/config/_health_check.py +24 -0
  33. schemathesis/config/_operations.py +335 -0
  34. schemathesis/config/_output.py +171 -0
  35. schemathesis/config/_parameters.py +19 -0
  36. schemathesis/config/_phases.py +253 -0
  37. schemathesis/config/_projects.py +543 -0
  38. schemathesis/config/_rate_limit.py +17 -0
  39. schemathesis/config/_report.py +120 -0
  40. schemathesis/config/_validator.py +9 -0
  41. schemathesis/config/_warnings.py +89 -0
  42. schemathesis/config/schema.json +975 -0
  43. schemathesis/core/__init__.py +72 -0
  44. schemathesis/core/adapter.py +34 -0
  45. schemathesis/core/compat.py +32 -0
  46. schemathesis/core/control.py +2 -0
  47. schemathesis/core/curl.py +100 -0
  48. schemathesis/core/deserialization.py +210 -0
  49. schemathesis/core/errors.py +588 -0
  50. schemathesis/core/failures.py +316 -0
  51. schemathesis/core/fs.py +19 -0
  52. schemathesis/core/hooks.py +20 -0
  53. schemathesis/core/jsonschema/__init__.py +13 -0
  54. schemathesis/core/jsonschema/bundler.py +183 -0
  55. schemathesis/core/jsonschema/keywords.py +40 -0
  56. schemathesis/core/jsonschema/references.py +222 -0
  57. schemathesis/core/jsonschema/types.py +41 -0
  58. schemathesis/core/lazy_import.py +15 -0
  59. schemathesis/core/loaders.py +107 -0
  60. schemathesis/core/marks.py +66 -0
  61. schemathesis/core/media_types.py +79 -0
  62. schemathesis/core/output/__init__.py +46 -0
  63. schemathesis/core/output/sanitization.py +54 -0
  64. schemathesis/core/parameters.py +45 -0
  65. schemathesis/core/rate_limit.py +60 -0
  66. schemathesis/core/registries.py +34 -0
  67. schemathesis/core/result.py +27 -0
  68. schemathesis/core/schema_analysis.py +17 -0
  69. schemathesis/core/shell.py +203 -0
  70. schemathesis/core/transforms.py +144 -0
  71. schemathesis/core/transport.py +223 -0
  72. schemathesis/core/validation.py +73 -0
  73. schemathesis/core/version.py +7 -0
  74. schemathesis/engine/__init__.py +28 -0
  75. schemathesis/engine/context.py +152 -0
  76. schemathesis/engine/control.py +44 -0
  77. schemathesis/engine/core.py +201 -0
  78. schemathesis/engine/errors.py +446 -0
  79. schemathesis/engine/events.py +284 -0
  80. schemathesis/engine/observations.py +42 -0
  81. schemathesis/engine/phases/__init__.py +108 -0
  82. schemathesis/engine/phases/analysis.py +28 -0
  83. schemathesis/engine/phases/probes.py +172 -0
  84. schemathesis/engine/phases/stateful/__init__.py +68 -0
  85. schemathesis/engine/phases/stateful/_executor.py +364 -0
  86. schemathesis/engine/phases/stateful/context.py +85 -0
  87. schemathesis/engine/phases/unit/__init__.py +220 -0
  88. schemathesis/engine/phases/unit/_executor.py +459 -0
  89. schemathesis/engine/phases/unit/_pool.py +82 -0
  90. schemathesis/engine/recorder.py +254 -0
  91. schemathesis/errors.py +47 -0
  92. schemathesis/filters.py +395 -0
  93. schemathesis/generation/__init__.py +25 -0
  94. schemathesis/generation/case.py +478 -0
  95. schemathesis/generation/coverage.py +1528 -0
  96. schemathesis/generation/hypothesis/__init__.py +121 -0
  97. schemathesis/generation/hypothesis/builder.py +992 -0
  98. schemathesis/generation/hypothesis/examples.py +56 -0
  99. schemathesis/generation/hypothesis/given.py +66 -0
  100. schemathesis/generation/hypothesis/reporting.py +285 -0
  101. schemathesis/generation/meta.py +227 -0
  102. schemathesis/generation/metrics.py +93 -0
  103. schemathesis/generation/modes.py +20 -0
  104. schemathesis/generation/overrides.py +127 -0
  105. schemathesis/generation/stateful/__init__.py +37 -0
  106. schemathesis/generation/stateful/state_machine.py +294 -0
  107. schemathesis/graphql/__init__.py +15 -0
  108. schemathesis/graphql/checks.py +109 -0
  109. schemathesis/graphql/loaders.py +285 -0
  110. schemathesis/hooks.py +270 -91
  111. schemathesis/openapi/__init__.py +13 -0
  112. schemathesis/openapi/checks.py +467 -0
  113. schemathesis/openapi/generation/__init__.py +0 -0
  114. schemathesis/openapi/generation/filters.py +72 -0
  115. schemathesis/openapi/loaders.py +315 -0
  116. schemathesis/pytest/__init__.py +5 -0
  117. schemathesis/pytest/control_flow.py +7 -0
  118. schemathesis/pytest/lazy.py +341 -0
  119. schemathesis/pytest/loaders.py +36 -0
  120. schemathesis/pytest/plugin.py +357 -0
  121. schemathesis/python/__init__.py +0 -0
  122. schemathesis/python/asgi.py +12 -0
  123. schemathesis/python/wsgi.py +12 -0
  124. schemathesis/schemas.py +683 -247
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +27 -0
  127. schemathesis/specs/graphql/scalars.py +86 -0
  128. schemathesis/specs/graphql/schemas.py +395 -123
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +578 -317
  132. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  133. schemathesis/specs/openapi/adapter/parameters.py +729 -0
  134. schemathesis/specs/openapi/adapter/protocol.py +59 -0
  135. schemathesis/specs/openapi/adapter/references.py +19 -0
  136. schemathesis/specs/openapi/adapter/responses.py +368 -0
  137. schemathesis/specs/openapi/adapter/security.py +144 -0
  138. schemathesis/specs/openapi/adapter/v2.py +30 -0
  139. schemathesis/specs/openapi/adapter/v3_0.py +30 -0
  140. schemathesis/specs/openapi/adapter/v3_1.py +30 -0
  141. schemathesis/specs/openapi/analysis.py +96 -0
  142. schemathesis/specs/openapi/checks.py +753 -74
  143. schemathesis/specs/openapi/converter.py +176 -37
  144. schemathesis/specs/openapi/definitions.py +599 -4
  145. schemathesis/specs/openapi/examples.py +581 -165
  146. schemathesis/specs/openapi/expressions/__init__.py +52 -5
  147. schemathesis/specs/openapi/expressions/extractors.py +25 -0
  148. schemathesis/specs/openapi/expressions/lexer.py +34 -31
  149. schemathesis/specs/openapi/expressions/nodes.py +97 -46
  150. schemathesis/specs/openapi/expressions/parser.py +35 -13
  151. schemathesis/specs/openapi/formats.py +122 -0
  152. schemathesis/specs/openapi/media_types.py +75 -0
  153. schemathesis/specs/openapi/negative/__init__.py +117 -68
  154. schemathesis/specs/openapi/negative/mutations.py +294 -104
  155. schemathesis/specs/openapi/negative/utils.py +3 -6
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +648 -650
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +404 -69
  161. schemathesis/specs/openapi/stateful/control.py +87 -0
  162. schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
  163. schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
  164. schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
  165. schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
  166. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  167. schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
  168. schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
  169. schemathesis/specs/openapi/stateful/inference.py +254 -0
  170. schemathesis/specs/openapi/stateful/links.py +219 -78
  171. schemathesis/specs/openapi/types/__init__.py +3 -0
  172. schemathesis/specs/openapi/types/common.py +23 -0
  173. schemathesis/specs/openapi/types/v2.py +129 -0
  174. schemathesis/specs/openapi/types/v3.py +134 -0
  175. schemathesis/specs/openapi/utils.py +7 -6
  176. schemathesis/specs/openapi/warnings.py +75 -0
  177. schemathesis/transport/__init__.py +224 -0
  178. schemathesis/transport/asgi.py +26 -0
  179. schemathesis/transport/prepare.py +126 -0
  180. schemathesis/transport/requests.py +278 -0
  181. schemathesis/transport/serialization.py +329 -0
  182. schemathesis/transport/wsgi.py +175 -0
  183. schemathesis-4.4.2.dist-info/METADATA +213 -0
  184. schemathesis-4.4.2.dist-info/RECORD +192 -0
  185. {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
  186. schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
  187. {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -41
  189. schemathesis/_hypothesis.py +0 -115
  190. schemathesis/cli/callbacks.py +0 -188
  191. schemathesis/cli/cassettes.py +0 -253
  192. schemathesis/cli/context.py +0 -36
  193. schemathesis/cli/debug.py +0 -21
  194. schemathesis/cli/handlers.py +0 -11
  195. schemathesis/cli/junitxml.py +0 -41
  196. schemathesis/cli/options.py +0 -51
  197. schemathesis/cli/output/__init__.py +0 -1
  198. schemathesis/cli/output/default.py +0 -508
  199. schemathesis/cli/output/short.py +0 -40
  200. schemathesis/constants.py +0 -79
  201. schemathesis/exceptions.py +0 -207
  202. schemathesis/extra/_aiohttp.py +0 -27
  203. schemathesis/extra/_flask.py +0 -10
  204. schemathesis/extra/_server.py +0 -16
  205. schemathesis/extra/pytest_plugin.py +0 -216
  206. schemathesis/failures.py +0 -131
  207. schemathesis/fixups/__init__.py +0 -29
  208. schemathesis/fixups/fast_api.py +0 -30
  209. schemathesis/lazy.py +0 -227
  210. schemathesis/models.py +0 -1041
  211. schemathesis/parameters.py +0 -88
  212. schemathesis/runner/__init__.py +0 -460
  213. schemathesis/runner/events.py +0 -240
  214. schemathesis/runner/impl/__init__.py +0 -3
  215. schemathesis/runner/impl/core.py +0 -755
  216. schemathesis/runner/impl/solo.py +0 -85
  217. schemathesis/runner/impl/threadpool.py +0 -367
  218. schemathesis/runner/serialization.py +0 -189
  219. schemathesis/serializers.py +0 -233
  220. schemathesis/service/__init__.py +0 -3
  221. schemathesis/service/client.py +0 -46
  222. schemathesis/service/constants.py +0 -12
  223. schemathesis/service/events.py +0 -39
  224. schemathesis/service/handler.py +0 -39
  225. schemathesis/service/models.py +0 -7
  226. schemathesis/service/serialization.py +0 -153
  227. schemathesis/service/worker.py +0 -40
  228. schemathesis/specs/graphql/loaders.py +0 -215
  229. schemathesis/specs/openapi/constants.py +0 -7
  230. schemathesis/specs/openapi/expressions/context.py +0 -12
  231. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  232. schemathesis/specs/openapi/filters.py +0 -44
  233. schemathesis/specs/openapi/links.py +0 -302
  234. schemathesis/specs/openapi/loaders.py +0 -453
  235. schemathesis/specs/openapi/parameters.py +0 -413
  236. schemathesis/specs/openapi/security.py +0 -129
  237. schemathesis/specs/openapi/validation.py +0 -24
  238. schemathesis/stateful.py +0 -349
  239. schemathesis/targets.py +0 -32
  240. schemathesis/types.py +0 -38
  241. schemathesis/utils.py +0 -436
  242. schemathesis-3.13.0.dist-info/METADATA +0 -202
  243. schemathesis-3.13.0.dist-info/RECORD +0 -91
  244. schemathesis-3.13.0.dist-info/entry_points.txt +0 -6
  245. /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)