schemathesis 3.25.6__py3-none-any.whl → 4.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +102 -82
  3. schemathesis/checks.py +126 -46
  4. schemathesis/cli/__init__.py +11 -1760
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +37 -0
  7. schemathesis/cli/commands/run/__init__.py +662 -0
  8. schemathesis/cli/commands/run/checks.py +80 -0
  9. schemathesis/cli/commands/run/context.py +117 -0
  10. schemathesis/cli/commands/run/events.py +35 -0
  11. schemathesis/cli/commands/run/executor.py +138 -0
  12. schemathesis/cli/commands/run/filters.py +194 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +18 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  17. schemathesis/cli/commands/run/handlers/output.py +746 -0
  18. schemathesis/cli/commands/run/hypothesis.py +105 -0
  19. schemathesis/cli/commands/run/loaders.py +129 -0
  20. schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
  21. schemathesis/cli/constants.py +5 -52
  22. schemathesis/cli/core.py +17 -0
  23. schemathesis/cli/ext/fs.py +14 -0
  24. schemathesis/cli/ext/groups.py +55 -0
  25. schemathesis/cli/{options.py → ext/options.py} +39 -10
  26. schemathesis/cli/hooks.py +36 -0
  27. schemathesis/contrib/__init__.py +1 -3
  28. schemathesis/contrib/openapi/__init__.py +1 -3
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
  30. schemathesis/core/__init__.py +58 -0
  31. schemathesis/core/compat.py +25 -0
  32. schemathesis/core/control.py +2 -0
  33. schemathesis/core/curl.py +58 -0
  34. schemathesis/core/deserialization.py +65 -0
  35. schemathesis/core/errors.py +370 -0
  36. schemathesis/core/failures.py +285 -0
  37. schemathesis/core/fs.py +19 -0
  38. schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
  39. schemathesis/core/loaders.py +104 -0
  40. schemathesis/core/marks.py +66 -0
  41. schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
  42. schemathesis/core/output/__init__.py +69 -0
  43. schemathesis/core/output/sanitization.py +197 -0
  44. schemathesis/core/rate_limit.py +60 -0
  45. schemathesis/core/registries.py +31 -0
  46. schemathesis/{internal → core}/result.py +1 -1
  47. schemathesis/core/transforms.py +113 -0
  48. schemathesis/core/transport.py +108 -0
  49. schemathesis/core/validation.py +38 -0
  50. schemathesis/core/version.py +7 -0
  51. schemathesis/engine/__init__.py +30 -0
  52. schemathesis/engine/config.py +59 -0
  53. schemathesis/engine/context.py +119 -0
  54. schemathesis/engine/control.py +36 -0
  55. schemathesis/engine/core.py +157 -0
  56. schemathesis/engine/errors.py +394 -0
  57. schemathesis/engine/events.py +337 -0
  58. schemathesis/engine/phases/__init__.py +66 -0
  59. schemathesis/{runner → engine/phases}/probes.py +50 -67
  60. schemathesis/engine/phases/stateful/__init__.py +65 -0
  61. schemathesis/engine/phases/stateful/_executor.py +326 -0
  62. schemathesis/engine/phases/stateful/context.py +85 -0
  63. schemathesis/engine/phases/unit/__init__.py +174 -0
  64. schemathesis/engine/phases/unit/_executor.py +321 -0
  65. schemathesis/engine/phases/unit/_pool.py +74 -0
  66. schemathesis/engine/recorder.py +241 -0
  67. schemathesis/errors.py +31 -0
  68. schemathesis/experimental/__init__.py +18 -14
  69. schemathesis/filters.py +103 -14
  70. schemathesis/generation/__init__.py +21 -37
  71. schemathesis/generation/case.py +190 -0
  72. schemathesis/generation/coverage.py +931 -0
  73. schemathesis/generation/hypothesis/__init__.py +30 -0
  74. schemathesis/generation/hypothesis/builder.py +585 -0
  75. schemathesis/generation/hypothesis/examples.py +50 -0
  76. schemathesis/generation/hypothesis/given.py +66 -0
  77. schemathesis/generation/hypothesis/reporting.py +14 -0
  78. schemathesis/generation/hypothesis/strategies.py +16 -0
  79. schemathesis/generation/meta.py +115 -0
  80. schemathesis/generation/modes.py +28 -0
  81. schemathesis/generation/overrides.py +96 -0
  82. schemathesis/generation/stateful/__init__.py +20 -0
  83. schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
  84. schemathesis/generation/targets.py +69 -0
  85. schemathesis/graphql/__init__.py +15 -0
  86. schemathesis/graphql/checks.py +115 -0
  87. schemathesis/graphql/loaders.py +131 -0
  88. schemathesis/hooks.py +99 -67
  89. schemathesis/openapi/__init__.py +13 -0
  90. schemathesis/openapi/checks.py +412 -0
  91. schemathesis/openapi/generation/__init__.py +0 -0
  92. schemathesis/openapi/generation/filters.py +63 -0
  93. schemathesis/openapi/loaders.py +178 -0
  94. schemathesis/pytest/__init__.py +5 -0
  95. schemathesis/pytest/control_flow.py +7 -0
  96. schemathesis/pytest/lazy.py +273 -0
  97. schemathesis/pytest/loaders.py +12 -0
  98. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
  99. schemathesis/python/__init__.py +0 -0
  100. schemathesis/python/asgi.py +12 -0
  101. schemathesis/python/wsgi.py +12 -0
  102. schemathesis/schemas.py +537 -261
  103. schemathesis/specs/graphql/__init__.py +0 -1
  104. schemathesis/specs/graphql/_cache.py +25 -0
  105. schemathesis/specs/graphql/nodes.py +1 -0
  106. schemathesis/specs/graphql/scalars.py +7 -5
  107. schemathesis/specs/graphql/schemas.py +215 -187
  108. schemathesis/specs/graphql/validation.py +11 -18
  109. schemathesis/specs/openapi/__init__.py +7 -1
  110. schemathesis/specs/openapi/_cache.py +122 -0
  111. schemathesis/specs/openapi/_hypothesis.py +146 -165
  112. schemathesis/specs/openapi/checks.py +565 -67
  113. schemathesis/specs/openapi/converter.py +33 -6
  114. schemathesis/specs/openapi/definitions.py +11 -18
  115. schemathesis/specs/openapi/examples.py +139 -23
  116. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  117. schemathesis/specs/openapi/expressions/context.py +4 -6
  118. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  119. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  120. schemathesis/specs/openapi/expressions/nodes.py +38 -14
  121. schemathesis/specs/openapi/expressions/parser.py +26 -5
  122. schemathesis/specs/openapi/formats.py +45 -0
  123. schemathesis/specs/openapi/links.py +65 -165
  124. schemathesis/specs/openapi/media_types.py +32 -0
  125. schemathesis/specs/openapi/negative/__init__.py +7 -3
  126. schemathesis/specs/openapi/negative/mutations.py +24 -8
  127. schemathesis/specs/openapi/parameters.py +46 -30
  128. schemathesis/specs/openapi/patterns.py +137 -0
  129. schemathesis/specs/openapi/references.py +47 -57
  130. schemathesis/specs/openapi/schemas.py +478 -369
  131. schemathesis/specs/openapi/security.py +25 -7
  132. schemathesis/specs/openapi/serialization.py +11 -6
  133. schemathesis/specs/openapi/stateful/__init__.py +185 -73
  134. schemathesis/specs/openapi/utils.py +6 -1
  135. schemathesis/transport/__init__.py +104 -0
  136. schemathesis/transport/asgi.py +26 -0
  137. schemathesis/transport/prepare.py +99 -0
  138. schemathesis/transport/requests.py +221 -0
  139. schemathesis/{_xml.py → transport/serialization.py} +143 -28
  140. schemathesis/transport/wsgi.py +165 -0
  141. schemathesis-4.0.0a1.dist-info/METADATA +297 -0
  142. schemathesis-4.0.0a1.dist-info/RECORD +152 -0
  143. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
  145. schemathesis/_compat.py +0 -74
  146. schemathesis/_dependency_versions.py +0 -17
  147. schemathesis/_hypothesis.py +0 -246
  148. schemathesis/_override.py +0 -49
  149. schemathesis/cli/cassettes.py +0 -375
  150. schemathesis/cli/context.py +0 -58
  151. schemathesis/cli/debug.py +0 -26
  152. schemathesis/cli/handlers.py +0 -16
  153. schemathesis/cli/junitxml.py +0 -43
  154. schemathesis/cli/output/__init__.py +0 -1
  155. schemathesis/cli/output/default.py +0 -790
  156. schemathesis/cli/output/short.py +0 -44
  157. schemathesis/cli/sanitization.py +0 -20
  158. schemathesis/code_samples.py +0 -149
  159. schemathesis/constants.py +0 -55
  160. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  161. schemathesis/contrib/openapi/formats/uuid.py +0 -15
  162. schemathesis/contrib/unique_data.py +0 -41
  163. schemathesis/exceptions.py +0 -560
  164. schemathesis/extra/_aiohttp.py +0 -27
  165. schemathesis/extra/_flask.py +0 -10
  166. schemathesis/extra/_server.py +0 -17
  167. schemathesis/failures.py +0 -209
  168. schemathesis/fixups/__init__.py +0 -36
  169. schemathesis/fixups/fast_api.py +0 -41
  170. schemathesis/fixups/utf8_bom.py +0 -29
  171. schemathesis/graphql.py +0 -4
  172. schemathesis/internal/__init__.py +0 -7
  173. schemathesis/internal/copy.py +0 -13
  174. schemathesis/internal/datetime.py +0 -5
  175. schemathesis/internal/deprecation.py +0 -34
  176. schemathesis/internal/jsonschema.py +0 -35
  177. schemathesis/internal/transformation.py +0 -15
  178. schemathesis/internal/validation.py +0 -34
  179. schemathesis/lazy.py +0 -361
  180. schemathesis/loaders.py +0 -120
  181. schemathesis/models.py +0 -1234
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -570
  184. schemathesis/runner/events.py +0 -329
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -1035
  187. schemathesis/runner/impl/solo.py +0 -90
  188. schemathesis/runner/impl/threadpool.py +0 -400
  189. schemathesis/runner/serialization.py +0 -411
  190. schemathesis/sanitization.py +0 -248
  191. schemathesis/serializers.py +0 -323
  192. schemathesis/service/__init__.py +0 -18
  193. schemathesis/service/auth.py +0 -11
  194. schemathesis/service/ci.py +0 -201
  195. schemathesis/service/client.py +0 -100
  196. schemathesis/service/constants.py +0 -38
  197. schemathesis/service/events.py +0 -57
  198. schemathesis/service/hosts.py +0 -107
  199. schemathesis/service/metadata.py +0 -46
  200. schemathesis/service/models.py +0 -49
  201. schemathesis/service/report.py +0 -255
  202. schemathesis/service/serialization.py +0 -199
  203. schemathesis/service/usage.py +0 -65
  204. schemathesis/specs/graphql/loaders.py +0 -344
  205. schemathesis/specs/openapi/filters.py +0 -49
  206. schemathesis/specs/openapi/loaders.py +0 -667
  207. schemathesis/specs/openapi/stateful/links.py +0 -92
  208. schemathesis/specs/openapi/validation.py +0 -25
  209. schemathesis/stateful/__init__.py +0 -133
  210. schemathesis/targets.py +0 -45
  211. schemathesis/throttling.py +0 -41
  212. schemathesis/transports/__init__.py +0 -5
  213. schemathesis/transports/auth.py +0 -15
  214. schemathesis/transports/headers.py +0 -35
  215. schemathesis/transports/responses.py +0 -52
  216. schemathesis/types.py +0 -35
  217. schemathesis/utils.py +0 -169
  218. schemathesis-3.25.6.dist-info/METADATA +0 -356
  219. schemathesis-3.25.6.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,66 @@
1
+ """Integrating `hypothesis.given` into Schemathesis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from inspect import getfullargspec
6
+ from typing import TYPE_CHECKING, Any, Callable, NoReturn, Union
7
+
8
+ from schemathesis.core.errors import IncorrectUsage
9
+ from schemathesis.core.marks import Mark
10
+
11
+ if TYPE_CHECKING:
12
+ from hypothesis.strategies import SearchStrategy
13
+
14
+
15
+ __all__ = ["is_given_applied", "given_proxy", "merge_given_args", "GivenInput", "GivenArgsMark", "GivenKwargsMark"]
16
+
17
+ EllipsisType = type(...)
18
+ GivenInput = Union["SearchStrategy", EllipsisType] # type: ignore[valid-type]
19
+
20
+ GivenArgsMark = Mark[tuple](attr_name="given_args", default=())
21
+ GivenKwargsMark = Mark[dict[str, Any]](attr_name="given_kwargs", default=dict)
22
+
23
+
24
+ def is_given_applied(func: Callable) -> bool:
25
+ return GivenArgsMark.is_set(func) or GivenKwargsMark.is_set(func)
26
+
27
+
28
+ def given_proxy(*args: GivenInput, **kwargs: GivenInput) -> Callable[[Callable], Callable]:
29
+ """Proxy Hypothesis strategies to ``hypothesis.given``."""
30
+
31
+ def wrapper(func: Callable) -> Callable:
32
+ if is_given_applied(func):
33
+
34
+ def wrapped_test(*_: Any, **__: Any) -> NoReturn:
35
+ raise IncorrectUsage(
36
+ f"You have applied `given` to the `{func.__name__}` test more than once, which "
37
+ "overrides the previous decorator. You need to pass all arguments to the same `given` call."
38
+ )
39
+
40
+ return wrapped_test
41
+
42
+ GivenArgsMark.set(func, args)
43
+ GivenKwargsMark.set(func, kwargs)
44
+ return func
45
+
46
+ return wrapper
47
+
48
+
49
+ def merge_given_args(func: Callable, args: tuple, kwargs: dict[str, Any]) -> dict[str, Any]:
50
+ """Merge positional arguments to ``@schema.given`` into a dictionary with keyword arguments.
51
+
52
+ Kwargs are modified inplace.
53
+ """
54
+ if args:
55
+ argspec = getfullargspec(func)
56
+ for name, strategy in zip(reversed([arg for arg in argspec.args if arg != "case"]), reversed(args)):
57
+ kwargs[name] = strategy
58
+ return kwargs
59
+
60
+
61
+ def validate_given_args(func: Callable, args: tuple, kwargs: dict[str, Any]) -> Callable | None:
62
+ from hypothesis.core import is_invalid_test
63
+ from hypothesis.internal.reflection import get_signature
64
+
65
+ signature = get_signature(func)
66
+ return is_invalid_test(func, signature, args, kwargs) # type: ignore
@@ -0,0 +1,14 @@
1
+ from contextlib import contextmanager
2
+ from typing import Generator
3
+
4
+ from hypothesis.reporting import with_reporter
5
+
6
+
7
+ def ignore(_: str) -> None:
8
+ pass
9
+
10
+
11
+ @contextmanager
12
+ def ignore_hypothesis_output() -> Generator:
13
+ with with_reporter(ignore): # type: ignore
14
+ yield
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import reduce
4
+ from operator import or_
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from hypothesis import strategies as st
9
+
10
+
11
+ def combine(strategies: list[st.SearchStrategy] | tuple[st.SearchStrategy]) -> st.SearchStrategy:
12
+ """Combine a list of strategies into a single one.
13
+
14
+ If the input is `[a, b, c]`, then the result is equivalent to `a | b | c`.
15
+ """
16
+ return reduce(or_, strategies[1:], strategies[0])
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+ from schemathesis.generation import GenerationMode
7
+
8
+
9
+ class TestPhase(str, Enum):
10
+ __test__ = False
11
+
12
+ EXPLICIT = "explicit"
13
+ COVERAGE = "coverage"
14
+ GENERATE = "generate"
15
+
16
+
17
+ class ComponentKind(str, Enum):
18
+ """Components that can be generated."""
19
+
20
+ QUERY = "query"
21
+ PATH_PARAMETERS = "path_parameters"
22
+ HEADERS = "headers"
23
+ COOKIES = "cookies"
24
+ BODY = "body"
25
+
26
+
27
+ @dataclass
28
+ class ComponentInfo:
29
+ """Information about how a specific component was generated."""
30
+
31
+ mode: GenerationMode
32
+
33
+ __slots__ = ("mode",)
34
+
35
+
36
+ @dataclass
37
+ class GeneratePhaseData:
38
+ """Metadata specific to generate phase."""
39
+
40
+
41
+ @dataclass
42
+ class ExplicitPhaseData:
43
+ """Metadata specific to explicit phase."""
44
+
45
+
46
+ @dataclass
47
+ class CoveragePhaseData:
48
+ """Metadata specific to coverage phase."""
49
+
50
+ description: str
51
+ location: str | None
52
+ parameter: str | None
53
+ parameter_location: str | None
54
+
55
+ __slots__ = ("description", "location", "parameter", "parameter_location")
56
+
57
+
58
+ @dataclass
59
+ class PhaseInfo:
60
+ """Phase-specific information."""
61
+
62
+ name: TestPhase
63
+ data: CoveragePhaseData | ExplicitPhaseData | GeneratePhaseData
64
+
65
+ __slots__ = ("name", "data")
66
+
67
+ @classmethod
68
+ def coverage(
69
+ cls,
70
+ description: str,
71
+ location: str | None = None,
72
+ parameter: str | None = None,
73
+ parameter_location: str | None = None,
74
+ ) -> PhaseInfo:
75
+ return cls(
76
+ name=TestPhase.COVERAGE,
77
+ data=CoveragePhaseData(
78
+ description=description, location=location, parameter=parameter, parameter_location=parameter_location
79
+ ),
80
+ )
81
+
82
+ @classmethod
83
+ def generate(cls) -> PhaseInfo:
84
+ return cls(name=TestPhase.GENERATE, data=GeneratePhaseData())
85
+
86
+
87
+ @dataclass
88
+ class GenerationInfo:
89
+ """Information about test case generation."""
90
+
91
+ time: float
92
+ mode: GenerationMode
93
+
94
+ __slots__ = ("time", "mode")
95
+
96
+
97
+ @dataclass
98
+ class CaseMetadata:
99
+ """Complete metadata for generated cases."""
100
+
101
+ generation: GenerationInfo
102
+ components: dict[ComponentKind, ComponentInfo]
103
+ phase: PhaseInfo
104
+
105
+ __slots__ = ("generation", "components", "phase")
106
+
107
+ def __init__(
108
+ self,
109
+ generation: GenerationInfo,
110
+ components: dict[ComponentKind, ComponentInfo],
111
+ phase: PhaseInfo,
112
+ ) -> None:
113
+ self.generation = generation
114
+ self.components = components
115
+ self.phase = phase
@@ -0,0 +1,28 @@
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
+ @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
+ @property
23
+ def is_positive(self) -> bool:
24
+ return self == GenerationMode.POSITIVE
25
+
26
+ @property
27
+ def is_negative(self) -> bool:
28
+ return self == GenerationMode.NEGATIVE
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Any, Callable
6
+
7
+ from schemathesis.core.errors import IncorrectUsage
8
+ from schemathesis.core.marks import Mark
9
+ from schemathesis.core.transforms import diff
10
+ from schemathesis.generation.meta import ComponentKind
11
+
12
+ if TYPE_CHECKING:
13
+ from schemathesis.generation.case import Case
14
+ from schemathesis.schemas import APIOperation, ParameterSet
15
+
16
+
17
+ @dataclass
18
+ class Override:
19
+ """Overrides for various parts of a test case."""
20
+
21
+ query: dict[str, str]
22
+ headers: dict[str, str]
23
+ cookies: dict[str, str]
24
+ path_parameters: dict[str, str]
25
+
26
+ def for_operation(self, operation: APIOperation) -> dict[str, dict[str, str]]:
27
+ return {
28
+ "query": (_for_parameters(self.query, operation.query)),
29
+ "headers": (_for_parameters(self.headers, operation.headers)),
30
+ "cookies": (_for_parameters(self.cookies, operation.cookies)),
31
+ "path_parameters": (_for_parameters(self.path_parameters, operation.path_parameters)),
32
+ }
33
+
34
+ @classmethod
35
+ def from_components(cls, components: dict[ComponentKind, StoredValue], case: Case) -> Override:
36
+ return Override(
37
+ **{
38
+ kind.value: get_component_diff(stored=stored, current=getattr(case, kind.value))
39
+ for kind, stored in components.items()
40
+ }
41
+ )
42
+
43
+
44
+ def _for_parameters(overridden: dict[str, str], defined: ParameterSet) -> dict[str, str]:
45
+ output = {}
46
+ for param in defined:
47
+ if param.name in overridden:
48
+ output[param.name] = overridden[param.name]
49
+ return output
50
+
51
+
52
+ @dataclass
53
+ class StoredValue:
54
+ value: dict[str, Any] | None
55
+ is_generated: bool
56
+
57
+ __slots__ = ("value", "is_generated")
58
+
59
+
60
+ def store_original_state(value: dict[str, Any] | None) -> dict[str, Any] | None:
61
+ if isinstance(value, Mapping):
62
+ return value.copy()
63
+ return value
64
+
65
+
66
+ def get_component_diff(stored: StoredValue, current: dict[str, Any] | None) -> dict[str, Any]:
67
+ """Calculate difference between stored and current components."""
68
+ if not (current and stored.value):
69
+ return {}
70
+ if stored.is_generated:
71
+ return diff(stored.value, current)
72
+ return current
73
+
74
+
75
+ def store_components(case: Case) -> dict[ComponentKind, StoredValue]:
76
+ """Store original component states for a test case."""
77
+ return {
78
+ kind: StoredValue(
79
+ value=store_original_state(getattr(case, kind.value)),
80
+ is_generated=bool(case.meta and kind in case.meta.components),
81
+ )
82
+ for kind in [
83
+ ComponentKind.QUERY,
84
+ ComponentKind.HEADERS,
85
+ ComponentKind.COOKIES,
86
+ ComponentKind.PATH_PARAMETERS,
87
+ ]
88
+ }
89
+
90
+
91
+ OverrideMark = Mark[Override](attr_name="override")
92
+
93
+
94
+ def check_no_override_mark(test: Callable) -> None:
95
+ if OverrideMark.is_set(test):
96
+ raise IncorrectUsage(f"`{test.__name__}` has already been decorated with `override`.")
@@ -0,0 +1,20 @@
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
+
11
+ def run_state_machine_as_test(
12
+ state_machine_factory: type[APIStateMachine], *, settings: hypothesis.settings | None = None
13
+ ) -> None:
14
+ """Run a state machine as a test.
15
+
16
+ It automatically adds the `_min_steps` argument if ``Hypothesis`` is recent enough.
17
+ """
18
+ from hypothesis.stateful import run_state_machine_as_test as _run_state_machine_as_test
19
+
20
+ return _run_state_machine_as_test(state_machine_factory, settings=settings, _min_steps=2)
@@ -1,36 +1,49 @@
1
1
  from __future__ import annotations
2
2
 
3
- import time
4
3
  import re
5
4
  from dataclasses import dataclass
6
- from typing import TYPE_CHECKING, Any, Callable, ClassVar
5
+ from functools import lru_cache
6
+ from typing import TYPE_CHECKING, Any, ClassVar
7
7
 
8
+ import hypothesis
8
9
  from hypothesis.errors import InvalidDefinition
9
10
  from hypothesis.stateful import RuleBasedStateMachine
10
11
 
11
- from .._dependency_versions import HYPOTHESIS_HAS_STATEFUL_NAMING_IMPROVEMENTS
12
- from ..constants import NO_LINKS_ERROR_MESSAGE, NOT_SET
13
- from ..exceptions import UsageError
14
- from ..models import APIOperation, Case, CheckFunction
12
+ from schemathesis.checks import CheckFunction
13
+ from schemathesis.core.errors import IncorrectUsage
14
+ from schemathesis.core.transport import Response
15
+ from schemathesis.generation.case import Case
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  import hypothesis
18
19
  from requests.structures import CaseInsensitiveDict
19
20
 
20
- from ..schemas import BaseSchema
21
- from ..transports.responses import GenericResponse
21
+ from schemathesis.schemas import APIOperation, BaseSchema
22
+
23
+
24
+ NO_LINKS_ERROR_MESSAGE = (
25
+ "Stateful testing requires at least one OpenAPI link in the schema, but no links detected. "
26
+ "Please add OpenAPI links to enable stateful testing or use stateless tests instead. \n"
27
+ "See https://schemathesis.readthedocs.io/en/stable/stateful.html#how-to-specify-connections for more information."
28
+ )
29
+
30
+ DEFAULT_STATE_MACHINE_SETTINGS = hypothesis.settings(
31
+ phases=[hypothesis.Phase.generate],
32
+ deadline=None,
33
+ stateful_step_count=6,
34
+ suppress_health_check=list(hypothesis.HealthCheck),
35
+ )
22
36
 
23
37
 
24
38
  @dataclass
25
39
  class StepResult:
26
40
  """Output from a single transition of a state machine."""
27
41
 
28
- response: GenericResponse
42
+ response: Response
29
43
  case: Case
30
- elapsed: float
31
44
 
32
45
 
33
- def _operation_name_to_identifier(name: str) -> str:
46
+ def _normalize_name(name: str) -> str:
34
47
  return re.sub(r"\W|^(?=\d)", "_", name).replace("__", "_")
35
48
 
36
49
 
@@ -51,26 +64,40 @@ class APIStateMachine(RuleBasedStateMachine):
51
64
  super().__init__() # type: ignore
52
65
  except InvalidDefinition as exc:
53
66
  if "defines no rules" in str(exc):
54
- raise UsageError(NO_LINKS_ERROR_MESSAGE) from None
67
+ raise IncorrectUsage(NO_LINKS_ERROR_MESSAGE) from None
55
68
  raise
56
69
  self.setup()
57
70
 
58
- def _pretty_print(self, value: Any) -> str:
59
- if isinstance(value, Case):
60
- # State machines suppose to be reproducible, hence it is OK to get kwargs here
61
- kwargs = self.get_call_kwargs(value)
62
- return _print_case(value, kwargs)
63
- if isinstance(value, tuple) and len(value) == 2:
64
- result, direction = value
65
- wrapper = _DirectionWrapper(direction)
66
- return super()._pretty_print((result, wrapper)) # type: ignore
67
- return super()._pretty_print(value) # type: ignore
71
+ @classmethod
72
+ @lru_cache
73
+ def _to_test_case(cls) -> type:
74
+ from schemathesis.generation.stateful import run_state_machine_as_test
75
+
76
+ class StateMachineTestCase(RuleBasedStateMachine.TestCase):
77
+ settings = DEFAULT_STATE_MACHINE_SETTINGS
78
+
79
+ def runTest(self) -> None:
80
+ run_state_machine_as_test(cls, settings=self.settings)
68
81
 
69
- if HYPOTHESIS_HAS_STATEFUL_NAMING_IMPROVEMENTS:
82
+ runTest.is_hypothesis_test = True # type: ignore[attr-defined]
70
83
 
71
- def _new_name(self, target: str) -> str:
72
- target = _operation_name_to_identifier(target)
73
- return super()._new_name(target) # type: ignore
84
+ StateMachineTestCase.__name__ = cls.__name__ + ".TestCase"
85
+ StateMachineTestCase.__qualname__ = cls.__qualname__ + ".TestCase"
86
+ return StateMachineTestCase
87
+
88
+ def _new_name(self, target: str) -> str:
89
+ target = _normalize_name(target)
90
+ return super()._new_name(target) # type: ignore
91
+
92
+ def _get_target_for_result(self, result: StepResult) -> str | None:
93
+ raise NotImplementedError
94
+
95
+ def _add_result_to_targets(self, targets: tuple[str, ...], result: StepResult | None) -> None:
96
+ if result is None:
97
+ return
98
+ target = self._get_target_for_result(result)
99
+ if target is not None:
100
+ super()._add_result_to_targets((target,), result)
74
101
 
75
102
  @classmethod
76
103
  def run(cls, *, settings: hypothesis.settings | None = None) -> None:
@@ -80,10 +107,7 @@ class APIStateMachine(RuleBasedStateMachine):
80
107
  return run_state_machine_as_test(cls, settings=settings)
81
108
 
82
109
  def setup(self) -> None:
83
- """Hook method that runs unconditionally in the beginning of each test scenario.
84
-
85
- Does nothing by default.
86
- """
110
+ """Hook method that runs unconditionally in the beginning of each test scenario."""
87
111
 
88
112
  def teardown(self) -> None:
89
113
  pass
@@ -94,12 +118,14 @@ class APIStateMachine(RuleBasedStateMachine):
94
118
  def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
95
119
  raise NotImplementedError
96
120
 
97
- def _step(self, case: Case, previous: tuple[StepResult, Direction] | None = None) -> StepResult:
121
+ def _step(self, case: Case, previous: StepResult | None = None, link: Direction | None = None) -> StepResult | None:
98
122
  # This method is a proxy that is used under the hood during the state machine initialization.
99
123
  # The whole point of having it is to make it possible to override `step`; otherwise, custom "step" is ignored.
100
124
  # It happens because, at the point of initialization, the final class is not yet created.
101
125
  __tracebackhide__ = True
102
- return self.step(case, previous)
126
+ if previous is not None and link is not None:
127
+ return self.step(case, (previous, link))
128
+ return self.step(case, None)
103
129
 
104
130
  def step(self, case: Case, previous: tuple[StepResult, Direction] | None = None) -> StepResult:
105
131
  """A single state machine step.
@@ -116,12 +142,10 @@ class APIStateMachine(RuleBasedStateMachine):
116
142
  case = self.transform(result, direction, case)
117
143
  self.before_call(case)
118
144
  kwargs = self.get_call_kwargs(case)
119
- start = time.monotonic()
120
145
  response = self.call(case, **kwargs)
121
- elapsed = time.monotonic() - start
122
146
  self.after_call(response, case)
123
147
  self.validate_response(response, case)
124
- return self.store_result(response, case, elapsed)
148
+ return self.store_result(response, case)
125
149
 
126
150
  def before_call(self, case: Case) -> None:
127
151
  """Hook method for modifying the case data before making a request.
@@ -148,7 +172,7 @@ class APIStateMachine(RuleBasedStateMachine):
148
172
  case.body["is_fake"] = True
149
173
  """
150
174
 
151
- def after_call(self, response: GenericResponse, case: Case) -> None:
175
+ def after_call(self, response: Response, case: Case) -> None:
152
176
  """Hook method for additional actions with case or response instances.
153
177
 
154
178
  :param response: Response from the application under test.
@@ -181,7 +205,7 @@ class APIStateMachine(RuleBasedStateMachine):
181
205
  # PATCH /users/{user_id} -> 500
182
206
  """
183
207
 
184
- def call(self, case: Case, **kwargs: Any) -> GenericResponse:
208
+ def call(self, case: Case, **kwargs: Any) -> Response:
185
209
  """Make a request to the API.
186
210
 
187
211
  :param Case case: Generated test case data that should be sent in an API call to the tested API operation.
@@ -189,13 +213,12 @@ class APIStateMachine(RuleBasedStateMachine):
189
213
  :return: Response from the application under test.
190
214
 
191
215
  Note that WSGI/ASGI applications are detected automatically in this method. Depending on the result of this
192
- detection the state machine will call ``call``, ``call_wsgi`` or ``call_asgi`` methods.
216
+ detection the state machine will call the ``call`` method.
193
217
 
194
218
  Usually, you don't need to override this method unless you are building a different state machine on top of this
195
219
  one and want to customize the transport layer itself.
196
220
  """
197
- method = self._get_call_method(case)
198
- return method(**kwargs)
221
+ return case.call(**kwargs)
199
222
 
200
223
  def get_call_kwargs(self, case: Case) -> dict[str, Any]:
201
224
  """Create custom keyword arguments that will be passed to the :meth:`Case.call` method.
@@ -214,24 +237,15 @@ class APIStateMachine(RuleBasedStateMachine):
214
237
  """
215
238
  return {}
216
239
 
217
- def _get_call_method(self, case: Case) -> Callable:
218
- if case.app is not None:
219
- from starlette.applications import Starlette
220
-
221
- if isinstance(case.app, Starlette):
222
- return case.call_asgi
223
- return case.call_wsgi
224
- return case.call
225
-
226
240
  def validate_response(
227
- self, response: GenericResponse, case: Case, additional_checks: tuple[CheckFunction, ...] = ()
241
+ self, response: Response, case: Case, additional_checks: list[CheckFunction] | None = None
228
242
  ) -> None:
229
243
  """Validate an API response.
230
244
 
231
245
  :param response: Response from the application under test.
232
246
  :param Case case: Generated test case data that should be sent in an API call to the tested API operation.
233
247
  :param additional_checks: A list of checks that will be run together with the default ones.
234
- :raises CheckFailed: If any of the supplied checks failed.
248
+ :raises FailureGroup: If any of the supplied checks failed.
235
249
 
236
250
  If you need to change the default checks or provide custom validation rules, you can do it here.
237
251
 
@@ -258,23 +272,8 @@ class APIStateMachine(RuleBasedStateMachine):
258
272
  __tracebackhide__ = True
259
273
  case.validate_response(response, additional_checks=additional_checks)
260
274
 
261
- def store_result(self, response: GenericResponse, case: Case, elapsed: float) -> StepResult:
262
- return StepResult(response, case, elapsed)
263
-
264
-
265
- def _print_case(case: Case, kwargs: dict[str, Any]) -> str:
266
- from requests.structures import CaseInsensitiveDict
267
-
268
- operation = f"state.schema['{case.operation.path}']['{case.operation.method.upper()}']"
269
- headers = case.headers or CaseInsensitiveDict()
270
- headers.update(kwargs.get("headers", {}))
271
- case.headers = headers
272
- data = [
273
- f"{name}={repr(getattr(case, name))}"
274
- for name in ("path_parameters", "headers", "cookies", "query", "body", "media_type")
275
- if getattr(case, name) not in (None, NOT_SET)
276
- ]
277
- return f"{operation}.make_case({', '.join(data)})"
275
+ def store_result(self, response: Response, case: Case) -> StepResult:
276
+ return StepResult(response, case)
278
277
 
279
278
 
280
279
  class Direction:
@@ -282,17 +281,5 @@ class Direction:
282
281
  status_code: str
283
282
  operation: APIOperation
284
283
 
285
- def set_data(self, case: Case, elapsed: float, **kwargs: Any) -> None:
284
+ def set_data(self, case: Case, **kwargs: Any) -> None:
286
285
  raise NotImplementedError
287
-
288
-
289
- @dataclass(repr=False)
290
- class _DirectionWrapper:
291
- """Purely to avoid modification of `Direction.__repr__`."""
292
-
293
- direction: Direction
294
-
295
- def __repr__(self) -> str:
296
- path = self.direction.operation.path
297
- method = self.direction.operation.method.upper()
298
- return f"state.schema['{path}']['{method}'].links['{self.direction.status_code}']['{self.direction.name}']"