schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +26 -68
  3. schemathesis/checks.py +130 -60
  4. schemathesis/cli/__init__.py +5 -2105
  5. schemathesis/cli/commands/__init__.py +37 -0
  6. schemathesis/cli/commands/run/__init__.py +662 -0
  7. schemathesis/cli/commands/run/checks.py +80 -0
  8. schemathesis/cli/commands/run/context.py +117 -0
  9. schemathesis/cli/commands/run/events.py +30 -0
  10. schemathesis/cli/commands/run/executor.py +141 -0
  11. schemathesis/cli/commands/run/filters.py +202 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
  15. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1368 -0
  17. schemathesis/cli/commands/run/hypothesis.py +105 -0
  18. schemathesis/cli/commands/run/loaders.py +129 -0
  19. schemathesis/cli/{callbacks.py → commands/run/validation.py} +59 -175
  20. schemathesis/cli/constants.py +5 -58
  21. schemathesis/cli/core.py +17 -0
  22. schemathesis/cli/ext/fs.py +14 -0
  23. schemathesis/cli/ext/groups.py +55 -0
  24. schemathesis/cli/{options.py → ext/options.py} +37 -16
  25. schemathesis/cli/hooks.py +36 -0
  26. schemathesis/contrib/__init__.py +1 -3
  27. schemathesis/contrib/openapi/__init__.py +1 -3
  28. schemathesis/contrib/openapi/fill_missing_examples.py +3 -7
  29. schemathesis/core/__init__.py +58 -0
  30. schemathesis/core/compat.py +25 -0
  31. schemathesis/core/control.py +2 -0
  32. schemathesis/core/curl.py +58 -0
  33. schemathesis/core/deserialization.py +65 -0
  34. schemathesis/core/errors.py +370 -0
  35. schemathesis/core/failures.py +315 -0
  36. schemathesis/core/fs.py +19 -0
  37. schemathesis/core/loaders.py +104 -0
  38. schemathesis/core/marks.py +66 -0
  39. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  40. schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
  41. schemathesis/core/output/sanitization.py +197 -0
  42. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  43. schemathesis/core/registries.py +31 -0
  44. schemathesis/core/transforms.py +113 -0
  45. schemathesis/core/transport.py +108 -0
  46. schemathesis/core/validation.py +38 -0
  47. schemathesis/core/version.py +7 -0
  48. schemathesis/engine/__init__.py +30 -0
  49. schemathesis/engine/config.py +59 -0
  50. schemathesis/engine/context.py +119 -0
  51. schemathesis/engine/control.py +36 -0
  52. schemathesis/engine/core.py +157 -0
  53. schemathesis/engine/errors.py +394 -0
  54. schemathesis/engine/events.py +243 -0
  55. schemathesis/engine/phases/__init__.py +66 -0
  56. schemathesis/{runner → engine/phases}/probes.py +49 -68
  57. schemathesis/engine/phases/stateful/__init__.py +66 -0
  58. schemathesis/engine/phases/stateful/_executor.py +301 -0
  59. schemathesis/engine/phases/stateful/context.py +85 -0
  60. schemathesis/engine/phases/unit/__init__.py +175 -0
  61. schemathesis/engine/phases/unit/_executor.py +322 -0
  62. schemathesis/engine/phases/unit/_pool.py +74 -0
  63. schemathesis/engine/recorder.py +246 -0
  64. schemathesis/errors.py +31 -0
  65. schemathesis/experimental/__init__.py +9 -40
  66. schemathesis/filters.py +7 -95
  67. schemathesis/generation/__init__.py +3 -3
  68. schemathesis/generation/case.py +190 -0
  69. schemathesis/generation/coverage.py +22 -22
  70. schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
  71. schemathesis/generation/hypothesis/builder.py +585 -0
  72. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  73. schemathesis/generation/hypothesis/given.py +66 -0
  74. schemathesis/generation/hypothesis/reporting.py +14 -0
  75. schemathesis/generation/hypothesis/strategies.py +16 -0
  76. schemathesis/generation/meta.py +115 -0
  77. schemathesis/generation/modes.py +28 -0
  78. schemathesis/generation/overrides.py +96 -0
  79. schemathesis/generation/stateful/__init__.py +20 -0
  80. schemathesis/{stateful → generation/stateful}/state_machine.py +84 -109
  81. schemathesis/generation/targets.py +69 -0
  82. schemathesis/graphql/__init__.py +15 -0
  83. schemathesis/graphql/checks.py +109 -0
  84. schemathesis/graphql/loaders.py +131 -0
  85. schemathesis/hooks.py +17 -62
  86. schemathesis/openapi/__init__.py +13 -0
  87. schemathesis/openapi/checks.py +387 -0
  88. schemathesis/openapi/generation/__init__.py +0 -0
  89. schemathesis/openapi/generation/filters.py +63 -0
  90. schemathesis/openapi/loaders.py +178 -0
  91. schemathesis/pytest/__init__.py +5 -0
  92. schemathesis/pytest/control_flow.py +7 -0
  93. schemathesis/pytest/lazy.py +273 -0
  94. schemathesis/pytest/loaders.py +12 -0
  95. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +94 -107
  96. schemathesis/python/__init__.py +0 -0
  97. schemathesis/python/asgi.py +12 -0
  98. schemathesis/python/wsgi.py +12 -0
  99. schemathesis/schemas.py +456 -228
  100. schemathesis/specs/graphql/__init__.py +0 -1
  101. schemathesis/specs/graphql/_cache.py +1 -2
  102. schemathesis/specs/graphql/scalars.py +5 -3
  103. schemathesis/specs/graphql/schemas.py +122 -123
  104. schemathesis/specs/graphql/validation.py +11 -17
  105. schemathesis/specs/openapi/__init__.py +6 -1
  106. schemathesis/specs/openapi/_cache.py +1 -2
  107. schemathesis/specs/openapi/_hypothesis.py +97 -134
  108. schemathesis/specs/openapi/checks.py +238 -219
  109. schemathesis/specs/openapi/converter.py +4 -4
  110. schemathesis/specs/openapi/definitions.py +1 -1
  111. schemathesis/specs/openapi/examples.py +22 -20
  112. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  113. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  114. schemathesis/specs/openapi/expressions/nodes.py +33 -32
  115. schemathesis/specs/openapi/formats.py +3 -2
  116. schemathesis/specs/openapi/links.py +123 -299
  117. schemathesis/specs/openapi/media_types.py +10 -12
  118. schemathesis/specs/openapi/negative/__init__.py +2 -1
  119. schemathesis/specs/openapi/negative/mutations.py +3 -2
  120. schemathesis/specs/openapi/parameters.py +8 -6
  121. schemathesis/specs/openapi/patterns.py +1 -1
  122. schemathesis/specs/openapi/references.py +11 -51
  123. schemathesis/specs/openapi/schemas.py +177 -191
  124. schemathesis/specs/openapi/security.py +1 -1
  125. schemathesis/specs/openapi/serialization.py +10 -6
  126. schemathesis/specs/openapi/stateful/__init__.py +97 -91
  127. schemathesis/transport/__init__.py +104 -0
  128. schemathesis/transport/asgi.py +26 -0
  129. schemathesis/transport/prepare.py +99 -0
  130. schemathesis/transport/requests.py +221 -0
  131. schemathesis/{_xml.py → transport/serialization.py} +69 -7
  132. schemathesis/transport/wsgi.py +165 -0
  133. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
  134. schemathesis-4.0.0a2.dist-info/RECORD +151 -0
  135. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
  136. schemathesis/_compat.py +0 -74
  137. schemathesis/_dependency_versions.py +0 -19
  138. schemathesis/_hypothesis.py +0 -559
  139. schemathesis/_override.py +0 -50
  140. schemathesis/_rate_limiter.py +0 -7
  141. schemathesis/cli/context.py +0 -75
  142. schemathesis/cli/debug.py +0 -27
  143. schemathesis/cli/handlers.py +0 -19
  144. schemathesis/cli/junitxml.py +0 -124
  145. schemathesis/cli/output/__init__.py +0 -1
  146. schemathesis/cli/output/default.py +0 -936
  147. schemathesis/cli/output/short.py +0 -59
  148. schemathesis/cli/reporting.py +0 -79
  149. schemathesis/cli/sanitization.py +0 -26
  150. schemathesis/code_samples.py +0 -151
  151. schemathesis/constants.py +0 -56
  152. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  153. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  154. schemathesis/contrib/unique_data.py +0 -41
  155. schemathesis/exceptions.py +0 -571
  156. schemathesis/extra/_aiohttp.py +0 -28
  157. schemathesis/extra/_flask.py +0 -13
  158. schemathesis/extra/_server.py +0 -18
  159. schemathesis/failures.py +0 -277
  160. schemathesis/fixups/__init__.py +0 -37
  161. schemathesis/fixups/fast_api.py +0 -41
  162. schemathesis/fixups/utf8_bom.py +0 -28
  163. schemathesis/generation/_methods.py +0 -44
  164. schemathesis/graphql.py +0 -3
  165. schemathesis/internal/__init__.py +0 -7
  166. schemathesis/internal/checks.py +0 -84
  167. schemathesis/internal/copy.py +0 -32
  168. schemathesis/internal/datetime.py +0 -5
  169. schemathesis/internal/deprecation.py +0 -38
  170. schemathesis/internal/diff.py +0 -15
  171. schemathesis/internal/extensions.py +0 -27
  172. schemathesis/internal/jsonschema.py +0 -36
  173. schemathesis/internal/transformation.py +0 -26
  174. schemathesis/internal/validation.py +0 -34
  175. schemathesis/lazy.py +0 -474
  176. schemathesis/loaders.py +0 -122
  177. schemathesis/models.py +0 -1341
  178. schemathesis/parameters.py +0 -90
  179. schemathesis/runner/__init__.py +0 -605
  180. schemathesis/runner/events.py +0 -389
  181. schemathesis/runner/impl/__init__.py +0 -3
  182. schemathesis/runner/impl/context.py +0 -104
  183. schemathesis/runner/impl/core.py +0 -1246
  184. schemathesis/runner/impl/solo.py +0 -80
  185. schemathesis/runner/impl/threadpool.py +0 -391
  186. schemathesis/runner/serialization.py +0 -544
  187. schemathesis/sanitization.py +0 -252
  188. schemathesis/serializers.py +0 -328
  189. schemathesis/service/__init__.py +0 -18
  190. schemathesis/service/auth.py +0 -11
  191. schemathesis/service/ci.py +0 -202
  192. schemathesis/service/client.py +0 -133
  193. schemathesis/service/constants.py +0 -38
  194. schemathesis/service/events.py +0 -61
  195. schemathesis/service/extensions.py +0 -224
  196. schemathesis/service/hosts.py +0 -111
  197. schemathesis/service/metadata.py +0 -71
  198. schemathesis/service/models.py +0 -258
  199. schemathesis/service/report.py +0 -255
  200. schemathesis/service/serialization.py +0 -173
  201. schemathesis/service/usage.py +0 -66
  202. schemathesis/specs/graphql/loaders.py +0 -364
  203. schemathesis/specs/openapi/expressions/context.py +0 -16
  204. schemathesis/specs/openapi/loaders.py +0 -708
  205. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  206. schemathesis/specs/openapi/stateful/types.py +0 -14
  207. schemathesis/specs/openapi/validation.py +0 -26
  208. schemathesis/stateful/__init__.py +0 -147
  209. schemathesis/stateful/config.py +0 -97
  210. schemathesis/stateful/context.py +0 -135
  211. schemathesis/stateful/events.py +0 -274
  212. schemathesis/stateful/runner.py +0 -309
  213. schemathesis/stateful/sink.py +0 -68
  214. schemathesis/stateful/statistic.py +0 -22
  215. schemathesis/stateful/validation.py +0 -100
  216. schemathesis/targets.py +0 -77
  217. schemathesis/transports/__init__.py +0 -359
  218. schemathesis/transports/asgi.py +0 -7
  219. schemathesis/transports/auth.py +0 -38
  220. schemathesis/transports/headers.py +0 -36
  221. schemathesis/transports/responses.py +0 -57
  222. schemathesis/types.py +0 -44
  223. schemathesis/utils.py +0 -164
  224. schemathesis-3.39.7.dist-info/RECORD +0 -160
  225. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  226. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  227. /schemathesis/{internal → core}/result.py +0 -0
  228. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
  229. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -10,32 +10,56 @@ from __future__ import annotations
10
10
 
11
11
  import enum
12
12
  import warnings
13
- from dataclasses import asdict, dataclass, field
14
- from typing import TYPE_CHECKING, Any
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING
15
15
 
16
- from ..constants import USER_AGENT
17
- from ..exceptions import format_exception
18
- from ..models import Request, Response
19
- from ..sanitization import sanitize_request, sanitize_response
20
- from ..transports import RequestConfig
21
- from ..transports.auth import get_requests_auth
16
+ from schemathesis.core.result import Err, Ok, Result
17
+ from schemathesis.core.transport import USER_AGENT
18
+ from schemathesis.engine import Status, events
22
19
 
23
20
  if TYPE_CHECKING:
24
21
  import requests
25
22
 
26
- from ..schemas import BaseSchema
23
+ from schemathesis.engine.config import NetworkConfig
24
+ from schemathesis.engine.context import EngineContext
25
+ from schemathesis.engine.events import EventGenerator
26
+ from schemathesis.engine.phases import Phase
27
+ from schemathesis.schemas import BaseSchema
27
28
 
28
29
 
29
- HEADER_NAME = "X-Schemathesis-Probe"
30
+ @dataclass
31
+ class ProbePayload:
32
+ probes: list[ProbeRun]
33
+
34
+ __slots__ = ("probes",)
35
+
36
+
37
+ def execute(ctx: EngineContext, phase: Phase) -> EventGenerator:
38
+ """Discover capabilities of the tested app."""
39
+ probes = run(ctx.schema, ctx.session, ctx.config.network)
40
+ status = Status.SUCCESS
41
+ payload: Result[ProbePayload, Exception] | None = None
42
+ for result in probes:
43
+ if isinstance(result.probe, NullByteInHeader) and result.is_failure:
44
+ from ...specs.openapi import formats
45
+ from ...specs.openapi.formats import HEADER_FORMAT, header_values
46
+
47
+ formats.register(HEADER_FORMAT, header_values(blacklist_characters="\n\r\x00"))
48
+ if result.error is not None:
49
+ status = Status.ERROR
50
+ payload = Err(result.error)
51
+ else:
52
+ status = Status.SUCCESS
53
+ payload = Ok(ProbePayload(probes=probes))
54
+ yield events.PhaseFinished(phase=phase, status=status, payload=payload)
30
55
 
31
56
 
32
- @dataclass
33
- class ProbeConfig:
34
- base_url: str | None = None
35
- request: RequestConfig = field(default_factory=RequestConfig)
36
- auth: tuple[str, str] | None = None
37
- auth_type: str | None = None
38
- headers: dict[str, str] | None = None
57
+ def run(schema: BaseSchema, session: requests.Session, config: NetworkConfig) -> list[ProbeRun]:
58
+ """Run all probes against the given schema."""
59
+ return [send(probe(), session, schema, config) for probe in PROBES]
60
+
61
+
62
+ HEADER_NAME = "X-Schemathesis-Probe"
39
63
 
40
64
 
41
65
  @dataclass
@@ -45,7 +69,7 @@ class Probe:
45
69
  name: str
46
70
 
47
71
  def prepare_request(
48
- self, session: requests.Session, request: requests.Request, schema: BaseSchema, config: ProbeConfig
72
+ self, session: requests.Session, request: requests.Request, schema: BaseSchema
49
73
  ) -> requests.PreparedRequest:
50
74
  raise NotImplementedError
51
75
 
@@ -70,49 +94,24 @@ class ProbeRun:
70
94
  outcome: ProbeOutcome
71
95
  request: requests.PreparedRequest | None = None
72
96
  response: requests.Response | None = None
73
- error: requests.RequestException | None = None
97
+ error: Exception | None = None
74
98
 
75
99
  @property
76
100
  def is_failure(self) -> bool:
77
101
  return self.outcome == ProbeOutcome.FAILURE
78
102
 
79
- def serialize(self) -> dict[str, Any]:
80
- """Serialize probe results so it can be sent over the network."""
81
- if self.request:
82
- _request = Request.from_prepared_request(self.request)
83
- sanitize_request(_request)
84
- request = asdict(_request)
85
- else:
86
- request = None
87
- if self.response:
88
- sanitize_response(self.response)
89
- response = asdict(Response.from_requests(self.response))
90
- else:
91
- response = None
92
- if self.error:
93
- error = format_exception(self.error)
94
- else:
95
- error = None
96
- return {
97
- "name": self.probe.name,
98
- "outcome": self.outcome.value,
99
- "request": request,
100
- "response": response,
101
- "error": error,
102
- }
103
-
104
103
 
105
104
  @dataclass
106
105
  class NullByteInHeader(Probe):
107
106
  """Support NULL bytes in headers."""
108
107
 
109
- name: str = "NULL_BYTE_IN_HEADER"
108
+ name: str = "Supports NULL byte in headers"
110
109
 
111
110
  def prepare_request(
112
- self, session: requests.Session, request: requests.Request, schema: BaseSchema, config: ProbeConfig
111
+ self, session: requests.Session, request: requests.Request, schema: BaseSchema
113
112
  ) -> requests.PreparedRequest:
114
113
  request.method = "GET"
115
- request.url = config.base_url or schema.get_base_url()
114
+ request.url = schema.get_base_url()
116
115
  request.headers = {"X-Schemathesis-Probe-Null": "\x00"}
117
116
  return session.prepare_request(request)
118
117
 
@@ -125,22 +124,19 @@ class NullByteInHeader(Probe):
125
124
  PROBES = (NullByteInHeader,)
126
125
 
127
126
 
128
- def send(probe: Probe, session: requests.Session, schema: BaseSchema, config: ProbeConfig) -> ProbeRun:
127
+ def send(probe: Probe, session: requests.Session, schema: BaseSchema, config: NetworkConfig) -> ProbeRun:
129
128
  """Send the probe to the application."""
130
129
  from requests import PreparedRequest, Request, RequestException
131
130
  from requests.exceptions import MissingSchema
132
131
  from urllib3.exceptions import InsecureRequestWarning
133
132
 
134
133
  try:
135
- request = probe.prepare_request(session, Request(), schema, config)
134
+ request = probe.prepare_request(session, Request(), schema)
136
135
  request.headers[HEADER_NAME] = probe.name
137
136
  request.headers["User-Agent"] = USER_AGENT
138
- kwargs: dict[str, Any] = {"timeout": config.request.prepared_timeout or 2}
139
- if config.request.proxy is not None:
140
- kwargs["proxies"] = {"all": config.request.proxy}
141
137
  with warnings.catch_warnings():
142
138
  warnings.simplefilter("ignore", InsecureRequestWarning)
143
- response = session.send(request, **kwargs)
139
+ response = session.send(request, timeout=config.timeout or 2)
144
140
  except MissingSchema:
145
141
  # In-process ASGI/WSGI testing will have local URLs and requires extra handling
146
142
  # which is not currently implemented
@@ -150,18 +146,3 @@ def send(probe: Probe, session: requests.Session, schema: BaseSchema, config: Pr
150
146
  return ProbeRun(probe, ProbeOutcome.ERROR, req, None, exc)
151
147
  result_type = probe.analyze_response(response)
152
148
  return ProbeRun(probe, result_type, request, response)
153
-
154
-
155
- def run(schema: BaseSchema, config: ProbeConfig) -> list[ProbeRun]:
156
- """Run all probes against the given schema."""
157
- from requests import Session
158
-
159
- session = Session()
160
- session.headers.update(config.headers or {})
161
- session.verify = config.request.tls_verify
162
- if config.request.cert is not None:
163
- session.cert = config.request.cert
164
- if config.auth is not None:
165
- session.auth = get_requests_auth(config.auth, config.auth_type)
166
-
167
- return [send(probe(), session, schema, config) for probe in PROBES]
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import queue
4
+ import threading
5
+ from typing import TYPE_CHECKING
6
+
7
+ from schemathesis.engine import Status, events
8
+ from schemathesis.engine.phases import Phase, PhaseName, PhaseSkipReason
9
+
10
+ if TYPE_CHECKING:
11
+ from schemathesis.engine.context import EngineContext
12
+
13
+ EVENT_QUEUE_TIMEOUT = 0.01
14
+
15
+
16
+ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
17
+ from schemathesis.engine.phases.stateful._executor import execute_state_machine_loop
18
+
19
+ try:
20
+ state_machine = engine.schema.as_state_machine()
21
+ except Exception as exc:
22
+ yield events.NonFatalError(error=exc, phase=phase.name, label="Stateful tests", related_to_operation=False)
23
+ return
24
+
25
+ event_queue: queue.Queue = queue.Queue()
26
+
27
+ thread = threading.Thread(
28
+ target=execute_state_machine_loop,
29
+ kwargs={"state_machine": state_machine, "event_queue": event_queue, "engine": engine},
30
+ name="schemathesis_stateful_tests",
31
+ )
32
+ status: Status | None = None
33
+ is_executed = False
34
+
35
+ thread.start()
36
+ try:
37
+ while True:
38
+ try:
39
+ event = event_queue.get(timeout=EVENT_QUEUE_TIMEOUT)
40
+ is_executed = True
41
+ # Set the run status based on the suite status
42
+ # ERROR & INTERRUPTED statuses are terminal, therefore they should not be overridden
43
+ if (
44
+ isinstance(event, events.SuiteFinished)
45
+ and event.status != Status.SKIP
46
+ and (status is None or status < event.status)
47
+ ):
48
+ status = event.status
49
+ yield event
50
+ except queue.Empty:
51
+ if not thread.is_alive():
52
+ break
53
+ except KeyboardInterrupt:
54
+ # Immediately notify the engine thread to stop, even though that the event will be set below in `finally`
55
+ engine.stop()
56
+ status = Status.INTERRUPTED
57
+ yield events.Interrupted(phase=PhaseName.STATEFUL_TESTING)
58
+ finally:
59
+ thread.join()
60
+
61
+ if not is_executed:
62
+ phase.skip_reason = PhaseSkipReason.NOTHING_TO_TEST
63
+ status = Status.SKIP
64
+ elif status is None:
65
+ status = Status.SKIP
66
+ yield events.PhaseFinished(phase=phase, status=status, payload=None)
@@ -0,0 +1,301 @@
1
+ from __future__ import annotations
2
+
3
+ import queue
4
+ import time
5
+ import unittest
6
+ from dataclasses import replace
7
+ from typing import Any
8
+ from warnings import catch_warnings
9
+
10
+ import hypothesis
11
+ from hypothesis.control import current_build_context
12
+ from hypothesis.errors import Flaky, Unsatisfiable
13
+ from hypothesis.stateful import Rule
14
+
15
+ from schemathesis.checks import CheckContext, CheckFunction, run_checks
16
+ from schemathesis.core.failures import Failure, FailureGroup
17
+ from schemathesis.core.transport import Response
18
+ from schemathesis.engine import Status, events
19
+ from schemathesis.engine.context import EngineContext
20
+ from schemathesis.engine.control import ExecutionControl
21
+ from schemathesis.engine.phases import PhaseName
22
+ from schemathesis.engine.phases.stateful.context import StatefulContext
23
+ from schemathesis.engine.recorder import ScenarioRecorder
24
+ from schemathesis.generation.case import Case
25
+ from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
26
+ from schemathesis.generation.stateful.state_machine import (
27
+ DEFAULT_STATE_MACHINE_SETTINGS,
28
+ APIStateMachine,
29
+ StepInput,
30
+ StepOutput,
31
+ )
32
+ from schemathesis.generation.targets import TargetMetricCollector
33
+
34
+
35
+ def _get_hypothesis_settings_kwargs_override(settings: hypothesis.settings) -> dict[str, Any]:
36
+ """Get the settings that should be overridden to match the defaults for API state machines."""
37
+ kwargs = {}
38
+ hypothesis_default = hypothesis.settings()
39
+ if settings.phases == hypothesis_default.phases:
40
+ kwargs["phases"] = DEFAULT_STATE_MACHINE_SETTINGS.phases
41
+ if settings.stateful_step_count == hypothesis_default.stateful_step_count:
42
+ kwargs["stateful_step_count"] = DEFAULT_STATE_MACHINE_SETTINGS.stateful_step_count
43
+ if settings.deadline == hypothesis_default.deadline:
44
+ kwargs["deadline"] = DEFAULT_STATE_MACHINE_SETTINGS.deadline
45
+ if settings.suppress_health_check == hypothesis_default.suppress_health_check:
46
+ kwargs["suppress_health_check"] = DEFAULT_STATE_MACHINE_SETTINGS.suppress_health_check
47
+ return kwargs
48
+
49
+
50
+ def execute_state_machine_loop(
51
+ *,
52
+ state_machine: type[APIStateMachine],
53
+ event_queue: queue.Queue,
54
+ engine: EngineContext,
55
+ ) -> None:
56
+ """Execute the state machine testing loop."""
57
+ kwargs = _get_hypothesis_settings_kwargs_override(engine.config.execution.hypothesis_settings)
58
+ if kwargs:
59
+ config = replace(
60
+ engine.config,
61
+ execution=replace(
62
+ engine.config.execution,
63
+ hypothesis_settings=hypothesis.settings(engine.config.execution.hypothesis_settings, **kwargs),
64
+ ),
65
+ )
66
+ else:
67
+ config = engine.config
68
+
69
+ ctx = StatefulContext(metric_collector=TargetMetricCollector(targets=config.execution.targets))
70
+
71
+ transport_kwargs = engine.transport_kwargs
72
+
73
+ class _InstrumentedStateMachine(state_machine): # type: ignore[valid-type,misc]
74
+ """State machine with additional hooks for emitting events."""
75
+
76
+ def setup(self) -> None:
77
+ scenario_started = events.ScenarioStarted(label=None, phase=PhaseName.STATEFUL_TESTING, suite_id=suite_id)
78
+ self._start_time = time.monotonic()
79
+ self._scenario_id = scenario_started.id
80
+ event_queue.put(scenario_started)
81
+ self.recorder = ScenarioRecorder(label="Stateful tests")
82
+ self._check_ctx = engine.get_check_context(self.recorder)
83
+
84
+ def get_call_kwargs(self, case: Case) -> dict[str, Any]:
85
+ return transport_kwargs
86
+
87
+ def _repr_step(self, rule: Rule, data: dict, result: StepOutput) -> str:
88
+ return ""
89
+
90
+ if config.override is not None:
91
+
92
+ def before_call(self, case: Case) -> None:
93
+ for location, entry in config.override.for_operation(case.operation).items(): # type: ignore[union-attr]
94
+ if entry:
95
+ container = getattr(case, location) or {}
96
+ container.update(entry)
97
+ setattr(case, location, container)
98
+ return super().before_call(case)
99
+
100
+ def step(self, input: StepInput) -> StepOutput | None:
101
+ # Checking the stop event once inside `step` is sufficient as it is called frequently
102
+ # The idea is to stop the execution as soon as possible
103
+ if input.transition is not None:
104
+ self.recorder.record_case(
105
+ parent_id=input.transition.parent_id, transition=input.transition, case=input.case
106
+ )
107
+ else:
108
+ self.recorder.record_case(parent_id=None, transition=None, case=input.case)
109
+ if engine.has_to_stop:
110
+ raise KeyboardInterrupt
111
+ try:
112
+ if config.execution.unique_inputs:
113
+ cached = ctx.get_step_outcome(input.case)
114
+ if isinstance(cached, BaseException):
115
+ raise cached
116
+ elif cached is None:
117
+ return None
118
+ result = super().step(input)
119
+ ctx.step_succeeded()
120
+ except FailureGroup as exc:
121
+ if config.execution.unique_inputs:
122
+ for failure in exc.exceptions:
123
+ ctx.store_step_outcome(input.case, failure)
124
+ ctx.step_failed()
125
+ raise
126
+ except Exception as exc:
127
+ if config.execution.unique_inputs:
128
+ ctx.store_step_outcome(input.case, exc)
129
+ ctx.step_errored()
130
+ raise
131
+ except KeyboardInterrupt:
132
+ ctx.step_interrupted()
133
+ raise
134
+ except BaseException as exc:
135
+ if config.execution.unique_inputs:
136
+ ctx.store_step_outcome(input.case, exc)
137
+ raise exc
138
+ else:
139
+ if config.execution.unique_inputs:
140
+ ctx.store_step_outcome(input.case, None)
141
+ return result
142
+
143
+ def validate_response(
144
+ self, response: Response, case: Case, additional_checks: tuple[CheckFunction, ...] = ()
145
+ ) -> None:
146
+ self.recorder.record_response(case_id=case.id, response=response)
147
+ ctx.collect_metric(case, response)
148
+ ctx.current_response = response
149
+ validate_response(
150
+ response=response,
151
+ case=case,
152
+ stateful_ctx=ctx,
153
+ check_ctx=self._check_ctx,
154
+ checks=config.execution.checks,
155
+ control=engine.control,
156
+ recorder=self.recorder,
157
+ additional_checks=additional_checks,
158
+ )
159
+
160
+ def teardown(self) -> None:
161
+ build_ctx = current_build_context()
162
+ event_queue.put(
163
+ events.ScenarioFinished(
164
+ id=self._scenario_id,
165
+ suite_id=suite_id,
166
+ phase=PhaseName.STATEFUL_TESTING,
167
+ label=None,
168
+ status=ctx.current_scenario_status or Status.SKIP,
169
+ recorder=self.recorder,
170
+ elapsed_time=time.monotonic() - self._start_time,
171
+ skip_reason=None,
172
+ is_final=build_ctx.is_final,
173
+ )
174
+ )
175
+ ctx.maximize_metrics()
176
+ ctx.reset_scenario()
177
+ super().teardown()
178
+
179
+ if config.execution.seed is not None:
180
+ InstrumentedStateMachine = hypothesis.seed(config.execution.seed)(_InstrumentedStateMachine)
181
+ else:
182
+ InstrumentedStateMachine = _InstrumentedStateMachine
183
+
184
+ while True:
185
+ # This loop is running until no new failures are found in a single iteration
186
+ suite_started = events.SuiteStarted(phase=PhaseName.STATEFUL_TESTING)
187
+ suite_id = suite_started.id
188
+ event_queue.put(suite_started)
189
+ if engine.is_interrupted:
190
+ event_queue.put(events.Interrupted(phase=PhaseName.STATEFUL_TESTING))
191
+ event_queue.put(
192
+ events.SuiteFinished(
193
+ id=suite_started.id,
194
+ phase=PhaseName.STATEFUL_TESTING,
195
+ status=Status.INTERRUPTED,
196
+ )
197
+ )
198
+ break
199
+ suite_status = Status.SUCCESS
200
+ try:
201
+ with catch_warnings(), ignore_hypothesis_output(): # type: ignore
202
+ InstrumentedStateMachine.run(settings=config.execution.hypothesis_settings)
203
+ except KeyboardInterrupt:
204
+ # Raised in the state machine when the stop event is set or it is raised by the user's code
205
+ # that is placed in the base class of the state machine.
206
+ # Therefore, set the stop event to cover the latter case
207
+ engine.stop()
208
+ suite_status = Status.INTERRUPTED
209
+ event_queue.put(events.Interrupted(phase=PhaseName.STATEFUL_TESTING))
210
+ break
211
+ except unittest.case.SkipTest:
212
+ # If `explicit` phase is used and there are no examples
213
+ suite_status = Status.SKIP
214
+ break
215
+ except FailureGroup as exc:
216
+ # When a check fails, the state machine is stopped
217
+ # The failure is already sent to the queue by the state machine
218
+ # Here we need to either exit or re-run the state machine with this failure marked as known
219
+ suite_status = Status.FAILURE
220
+ if engine.has_reached_the_failure_limit:
221
+ break # type: ignore[unreachable]
222
+ for failure in exc.exceptions:
223
+ ctx.mark_as_seen_in_run(failure)
224
+ continue
225
+ except Flaky:
226
+ suite_status = Status.FAILURE
227
+ if engine.has_reached_the_failure_limit:
228
+ break # type: ignore[unreachable]
229
+ # Mark all failures in this suite as seen to prevent them being re-discovered
230
+ ctx.mark_current_suite_as_seen_in_run()
231
+ continue
232
+ except Exception as exc:
233
+ if isinstance(exc, Unsatisfiable) and ctx.completed_scenarios > 0:
234
+ # Sometimes Hypothesis randomly gives up on generating some complex cases. However, if we know that
235
+ # values are possible to generate based on the previous observations, we retry the generation
236
+ if ctx.completed_scenarios >= config.execution.hypothesis_settings.max_examples:
237
+ # Avoid infinite restarts
238
+ break
239
+ continue
240
+ # Any other exception is an inner error and the test run should be stopped
241
+ suite_status = Status.ERROR
242
+ event_queue.put(
243
+ events.NonFatalError(
244
+ error=exc, phase=PhaseName.STATEFUL_TESTING, label="Stateful tests", related_to_operation=False
245
+ )
246
+ )
247
+ break
248
+ finally:
249
+ event_queue.put(
250
+ events.SuiteFinished(
251
+ id=suite_started.id,
252
+ phase=PhaseName.STATEFUL_TESTING,
253
+ status=suite_status,
254
+ )
255
+ )
256
+ ctx.reset()
257
+ # Exit on the first successful state machine execution
258
+ break
259
+
260
+
261
+ def validate_response(
262
+ *,
263
+ response: Response,
264
+ case: Case,
265
+ stateful_ctx: StatefulContext,
266
+ check_ctx: CheckContext,
267
+ control: ExecutionControl,
268
+ checks: list[CheckFunction],
269
+ recorder: ScenarioRecorder,
270
+ additional_checks: tuple[CheckFunction, ...] = (),
271
+ ) -> None:
272
+ """Validate the response against the provided checks."""
273
+
274
+ def on_failure(name: str, collected: set[Failure], failure: Failure) -> None:
275
+ if stateful_ctx.is_seen_in_suite(failure) or stateful_ctx.is_seen_in_run(failure):
276
+ return
277
+ failure_data = recorder.find_failure_data(parent_id=case.id, failure=failure)
278
+ recorder.record_check_failure(
279
+ name=name,
280
+ case_id=failure_data.case.id,
281
+ code_sample=failure_data.case.as_curl_command(headers=failure_data.headers, verify=failure_data.verify),
282
+ failure=failure,
283
+ )
284
+ control.count_failure()
285
+ stateful_ctx.mark_as_seen_in_suite(failure)
286
+ collected.add(failure)
287
+
288
+ def on_success(name: str, case: Case) -> None:
289
+ recorder.record_check_success(name=name, case_id=case.id)
290
+
291
+ failures = run_checks(
292
+ case=case,
293
+ response=response,
294
+ ctx=check_ctx,
295
+ checks=tuple(checks) + tuple(additional_checks),
296
+ on_failure=on_failure,
297
+ on_success=on_success,
298
+ )
299
+
300
+ if failures:
301
+ raise FailureGroup(list(failures)) from None
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from schemathesis.core import NOT_SET, NotSet
6
+ from schemathesis.core.failures import Failure
7
+ from schemathesis.core.transport import Response
8
+ from schemathesis.engine import Status
9
+ from schemathesis.generation.case import Case
10
+ from schemathesis.generation.targets import TargetMetricCollector
11
+
12
+
13
+ @dataclass
14
+ class StatefulContext:
15
+ """Mutable context for state machine execution."""
16
+
17
+ # All seen failure keys, both grouped and individual ones
18
+ seen_in_run: set[Failure] = field(default_factory=set)
19
+ # Failures keys seen in the current suite
20
+ seen_in_suite: set[Failure] = field(default_factory=set)
21
+ # Status of the current step
22
+ current_step_status: Status | None = None
23
+ # The currently processed response
24
+ current_response: Response | None = None
25
+ # Total number of failures
26
+ failures_count: int = 0
27
+ # The total number of completed test scenario
28
+ completed_scenarios: int = 0
29
+ # Metrics collector for targeted testing
30
+ metric_collector: TargetMetricCollector = field(default_factory=TargetMetricCollector)
31
+ step_outcomes: dict[int, BaseException | None] = field(default_factory=dict)
32
+
33
+ @property
34
+ def current_scenario_status(self) -> Status | None:
35
+ return self.current_step_status
36
+
37
+ def reset_scenario(self) -> None:
38
+ self.completed_scenarios += 1
39
+ self.current_step_status = None
40
+ self.current_response = None
41
+ self.step_outcomes.clear()
42
+
43
+ def step_succeeded(self) -> None:
44
+ self.current_step_status = Status.SUCCESS
45
+
46
+ def step_failed(self) -> None:
47
+ self.current_step_status = Status.FAILURE
48
+
49
+ def step_errored(self) -> None:
50
+ self.current_step_status = Status.ERROR
51
+
52
+ def step_interrupted(self) -> None:
53
+ self.current_step_status = Status.INTERRUPTED
54
+
55
+ def mark_as_seen_in_run(self, exc: Failure) -> None:
56
+ self.seen_in_run.add(exc)
57
+
58
+ def mark_as_seen_in_suite(self, exc: Failure) -> None:
59
+ self.seen_in_suite.add(exc)
60
+
61
+ def mark_current_suite_as_seen_in_run(self) -> None:
62
+ self.seen_in_run.update(self.seen_in_suite)
63
+
64
+ def is_seen_in_run(self, exc: Failure) -> bool:
65
+ return exc in self.seen_in_run
66
+
67
+ def is_seen_in_suite(self, exc: Failure) -> bool:
68
+ return exc in self.seen_in_suite
69
+
70
+ def collect_metric(self, case: Case, response: Response) -> None:
71
+ self.metric_collector.store(case, response)
72
+
73
+ def maximize_metrics(self) -> None:
74
+ self.metric_collector.maximize()
75
+
76
+ def reset(self) -> None:
77
+ self.seen_in_suite.clear()
78
+ self.reset_scenario()
79
+ self.metric_collector.reset()
80
+
81
+ def store_step_outcome(self, case: Case, outcome: BaseException | None) -> None:
82
+ self.step_outcomes[hash(case)] = outcome
83
+
84
+ def get_step_outcome(self, case: Case) -> BaseException | None | NotSet:
85
+ return self.step_outcomes.get(hash(case), NOT_SET)