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,7 +10,7 @@ from .parameters import OpenAPI20Parameter, OpenAPI30Parameter, OpenAPIParameter
10
10
  if TYPE_CHECKING:
11
11
  from jsonschema import RefResolver
12
12
 
13
- from ...models import APIOperation
13
+ from schemathesis.schemas import APIOperation
14
14
 
15
15
 
16
16
  @dataclass
@@ -3,8 +3,6 @@ from __future__ import annotations
3
3
  import json
4
4
  from typing import Any, Callable, Dict, Generator, List
5
5
 
6
- from ...utils import compose
7
-
8
6
  Generated = Dict[str, Any]
9
7
  Definition = Dict[str, Any]
10
8
  DefinitionList = List[Definition]
@@ -17,10 +15,16 @@ def make_serializer(
17
15
  """A maker function to avoid code duplication."""
18
16
 
19
17
  def _wrapper(definitions: DefinitionList) -> Callable | None:
20
- conversions = list(func(definitions))
21
- if conversions:
22
- return compose(*[conv for conv in conversions if conv is not None])
23
- return None
18
+ functions = list(func(definitions))
19
+
20
+ def composed(x: Any) -> Any:
21
+ result = x
22
+ for func in reversed(functions):
23
+ if func is not None:
24
+ result = func(result)
25
+ return result
26
+
27
+ return composed
24
28
 
25
29
  return _wrapper
26
30
 
@@ -2,46 +2,38 @@ from __future__ import annotations
2
2
 
3
3
  from collections import defaultdict
4
4
  from functools import lru_cache
5
- from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator
5
+ from typing import TYPE_CHECKING, Any, Callable, Iterator
6
6
 
7
7
  from hypothesis import strategies as st
8
8
  from hypothesis.stateful import Bundle, Rule, precondition, rule
9
9
 
10
- from ....constants import NOT_SET
11
- from ....generation import DataGenerationMethod, combine_strategies
12
- from ....internal.result import Ok
13
- from ....stateful.state_machine import APIStateMachine, Direction, StepResult
14
- from ....types import NotSet
15
- from .. import expressions
16
- from ..links import get_all_links
10
+ from schemathesis.core.result import Ok
11
+ from schemathesis.generation.case import Case
12
+ from schemathesis.generation.hypothesis import strategies
13
+ from schemathesis.generation.stateful.state_machine import APIStateMachine, StepInput, StepOutput, _normalize_name
14
+ from schemathesis.schemas import APIOperation
15
+
16
+ from ....generation import GenerationMode
17
+ from ..links import OpenApiLink, get_all_links
17
18
  from ..utils import expand_status_code
18
- from .statistic import OpenAPILinkStats
19
19
 
20
20
  if TYPE_CHECKING:
21
- from ....models import Case
21
+ from schemathesis.generation.stateful.state_machine import StepOutput
22
+
22
23
  from ..schemas import BaseOpenAPISchema
23
- from .types import FilterFunction, LinkName, StatusCode, TargetName
24
+
25
+ FilterFunction = Callable[["StepOutput"], bool]
24
26
 
25
27
 
26
28
  class OpenAPIStateMachine(APIStateMachine):
27
- _transition_stats_template: ClassVar[OpenAPILinkStats]
28
- _response_matchers: dict[str, Callable[[StepResult], str | None]]
29
+ _response_matchers: dict[str, Callable[[StepOutput], str | None]]
29
30
 
30
- def _get_target_for_result(self, result: StepResult) -> str | None:
31
- matcher = self._response_matchers.get(result.case.operation.verbose_name)
31
+ def _get_target_for_result(self, result: StepOutput) -> str | None:
32
+ matcher = self._response_matchers.get(result.case.operation.label)
32
33
  if matcher is None:
33
34
  return None
34
35
  return matcher(result)
35
36
 
36
- def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
37
- context = expressions.ExpressionContext(case=result.case, response=result.response)
38
- direction.set_data(case, elapsed=result.elapsed, context=context)
39
- return case
40
-
41
- @classmethod
42
- def format_rules(cls) -> str:
43
- return "\n".join(item.line for item in cls._transition_stats_template.iter_with_format())
44
-
45
37
 
46
38
  # The proportion of negative tests generated for "root" transitions
47
39
  NEGATIVE_TEST_CASES_THRESHOLD = 20
@@ -56,57 +48,41 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
56
48
 
57
49
  This state machine won't make calls to (2) without having a proper response from (1) first.
58
50
  """
59
- from ....stateful.state_machine import _normalize_name
60
-
61
51
  operations = [result.ok() for result in schema.get_all_operations() if isinstance(result, Ok)]
62
52
  bundles = {}
63
53
  incoming_transitions = defaultdict(list)
64
- _response_matchers: dict[str, Callable[[StepResult], str | None]] = {}
54
+ _response_matchers: dict[str, Callable[[StepOutput], str | None]] = {}
65
55
  # Statistic structure follows the links and count for each response status code
66
- transitions = {}
67
56
  for operation in operations:
68
- operation_links: dict[StatusCode, dict[TargetName, dict[LinkName, dict[int | None, int]]]] = {}
69
57
  all_status_codes = tuple(operation.definition.raw["responses"])
70
58
  bundle_matchers = []
71
59
  for _, link in get_all_links(operation):
72
- bundle_name = f"{operation.verbose_name} -> {link.status_code}"
60
+ bundle_name = f"{operation.label} -> {link.status_code}"
73
61
  bundles[bundle_name] = Bundle(bundle_name)
74
- target_operation = link.get_target_operation()
75
- incoming_transitions[target_operation.verbose_name].append(link)
76
- response_targets = operation_links.setdefault(link.status_code, {})
77
- target_links = response_targets.setdefault(target_operation.verbose_name, {})
78
- target_links[link.name] = {}
62
+ incoming_transitions[link.target.label].append(link)
79
63
  bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
80
- if operation_links:
81
- transitions[operation.verbose_name] = operation_links
82
64
  if bundle_matchers:
83
- _response_matchers[operation.verbose_name] = make_response_matcher(bundle_matchers)
65
+ _response_matchers[operation.label] = make_response_matcher(bundle_matchers)
84
66
  rules = {}
85
67
  catch_all = Bundle("catch_all")
86
68
 
87
69
  for target in operations:
88
- incoming = incoming_transitions.get(target.verbose_name)
70
+ incoming = incoming_transitions.get(target.label)
89
71
  if incoming is not None:
90
72
  for link in incoming:
91
- source = link.operation
92
- bundle_name = f"{source.verbose_name} -> {link.status_code}"
93
- name = _normalize_name(f"{target.verbose_name} -> {link.status_code}")
94
- case_strategy = combine_strategies(
95
- [
96
- target.as_strategy(data_generation_method=data_generation_method)
97
- for data_generation_method in schema.data_generation_methods
98
- ]
99
- )
100
- bundle = bundles[bundle_name]
101
- rules[name] = transition(
102
- name=name,
103
- target=catch_all,
104
- previous=bundle,
105
- case=case_strategy,
106
- link=st.just(link),
73
+ bundle_name = f"{link.source.label} -> {link.status_code}"
74
+ name = _normalize_name(f"{link.status_code} -> {target.label}")
75
+ rules[name] = precondition(ensure_non_empty_bundle(bundle_name))(
76
+ transition(
77
+ name=name,
78
+ target=catch_all,
79
+ input=bundles[bundle_name].flatmap(
80
+ into_step_input(target=target, link=link, modes=schema.generation_config.modes)
81
+ ),
82
+ )
107
83
  )
108
84
  elif any(
109
- incoming.operation.verbose_name == target.verbose_name
85
+ incoming.source.label == target.label
110
86
  for transitions in incoming_transitions.values()
111
87
  for incoming in transitions
112
88
  ):
@@ -114,32 +90,26 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
114
90
  # For example, POST /users/ -> GET /users/{id}/
115
91
  # The source operation has no prerequisite, but we need to allow this rule to be executed
116
92
  # in order to reach other transitions
117
- name = _normalize_name(f"{target.verbose_name} -> X")
118
- if len(schema.data_generation_methods) == 1:
119
- case_strategy = target.as_strategy(data_generation_method=schema.data_generation_methods[0])
93
+ name = _normalize_name(f"{target.label} -> X")
94
+ if len(schema.generation_config.modes) == 1:
95
+ case_strategy = target.as_strategy(generation_mode=schema.generation_config.modes[0])
120
96
  else:
121
- strategies = {
122
- method: target.as_strategy(data_generation_method=method)
123
- for method in schema.data_generation_methods
97
+ _strategies = {
98
+ method: target.as_strategy(generation_mode=method) for method in schema.generation_config.modes
124
99
  }
125
100
 
126
101
  @st.composite # type: ignore[misc]
127
102
  def case_strategy_factory(
128
- draw: st.DrawFn, strategies: dict[DataGenerationMethod, st.SearchStrategy] = strategies
103
+ draw: st.DrawFn, strategies: dict[GenerationMode, st.SearchStrategy] = _strategies
129
104
  ) -> Case:
130
105
  if draw(st.integers(min_value=0, max_value=99)) < NEGATIVE_TEST_CASES_THRESHOLD:
131
- return draw(strategies[DataGenerationMethod.negative])
132
- return draw(strategies[DataGenerationMethod.positive])
106
+ return draw(strategies[GenerationMode.NEGATIVE])
107
+ return draw(strategies[GenerationMode.POSITIVE])
133
108
 
134
109
  case_strategy = case_strategy_factory()
135
110
 
136
111
  rules[name] = precondition(ensure_links_followed)(
137
- transition(
138
- name=name,
139
- target=catch_all,
140
- previous=st.none(),
141
- case=case_strategy,
142
- )
112
+ transition(name=name, target=catch_all, input=case_strategy.map(StepInput.initial))
143
113
  )
144
114
 
145
115
  return type(
@@ -148,13 +118,60 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
148
118
  {
149
119
  "schema": schema,
150
120
  "bundles": bundles,
151
- "_transition_stats_template": OpenAPILinkStats(transitions=transitions),
152
121
  "_response_matchers": _response_matchers,
153
122
  **rules,
154
123
  },
155
124
  )
156
125
 
157
126
 
127
+ def into_step_input(
128
+ target: APIOperation, link: OpenApiLink, modes: list[GenerationMode]
129
+ ) -> Callable[[StepOutput], st.SearchStrategy[StepInput]]:
130
+ def builder(_output: StepOutput) -> st.SearchStrategy[StepInput]:
131
+ @st.composite # type: ignore[misc]
132
+ def inner(draw: st.DrawFn, output: StepOutput) -> StepInput:
133
+ transition_data = link.extract(output)
134
+
135
+ kwargs: dict[str, Any] = {
136
+ container: {
137
+ name: extracted.value.ok()
138
+ for name, extracted in data.items()
139
+ if isinstance(extracted.value, Ok) and extracted.value.ok() is not None
140
+ }
141
+ for container, data in transition_data.parameters.items()
142
+ }
143
+ if (
144
+ transition_data.request_body is not None
145
+ and isinstance(transition_data.request_body.value, Ok)
146
+ and not link.merge_body
147
+ ):
148
+ kwargs["body"] = transition_data.request_body.value.ok()
149
+ cases = strategies.combine([target.as_strategy(generation_mode=mode, **kwargs) for mode in modes])
150
+ case = draw(cases)
151
+ if (
152
+ transition_data.request_body is not None
153
+ and isinstance(transition_data.request_body.value, Ok)
154
+ and link.merge_body
155
+ ):
156
+ new = transition_data.request_body.value.ok()
157
+ if isinstance(case.body, dict) and isinstance(new, dict):
158
+ case.body = {**case.body, **new}
159
+ else:
160
+ case.body = new
161
+ return StepInput(case=case, transition=transition_data)
162
+
163
+ return inner(output=_output)
164
+
165
+ return builder
166
+
167
+
168
+ def ensure_non_empty_bundle(bundle_name: str) -> Callable[[APIStateMachine], bool]:
169
+ def inner(machine: APIStateMachine) -> bool:
170
+ return bool(machine.bundles.get(bundle_name))
171
+
172
+ return inner
173
+
174
+
158
175
  def ensure_links_followed(machine: APIStateMachine) -> bool:
159
176
  # If there are responses that have links to follow, reject any rule without incoming transitions
160
177
  for bundle in machine.bundles.values():
@@ -163,28 +180,17 @@ def ensure_links_followed(machine: APIStateMachine) -> bool:
163
180
  return True
164
181
 
165
182
 
166
- def transition(
167
- *,
168
- name: str,
169
- target: Bundle,
170
- previous: Bundle | st.SearchStrategy,
171
- case: st.SearchStrategy,
172
- link: st.SearchStrategy | NotSet = NOT_SET,
173
- ) -> Callable[[Callable], Rule]:
174
- def step_function(*args_: Any, **kwargs_: Any) -> StepResult | None:
175
- return APIStateMachine._step(*args_, **kwargs_)
183
+ def transition(*, name: str, target: Bundle, input: st.SearchStrategy[StepInput]) -> Callable[[Callable], Rule]:
184
+ def step_function(self: APIStateMachine, input: StepInput) -> StepOutput | None:
185
+ return APIStateMachine._step(self, input=input)
176
186
 
177
187
  step_function.__name__ = name
178
188
 
179
- kwargs = {"target": target, "previous": previous, "case": case}
180
- if not isinstance(link, NotSet):
181
- kwargs["link"] = link
182
-
183
- return rule(**kwargs)(step_function)
189
+ return rule(target=target, input=input)(step_function)
184
190
 
185
191
 
186
- def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepResult], str | None]:
187
- def compare(result: StepResult) -> str | None:
192
+ def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepOutput], str | None]:
193
+ def compare(result: StepOutput) -> str | None:
188
194
  for bundle_name, response_filter in matchers:
189
195
  if response_filter(result):
190
196
  return bundle_name
@@ -212,7 +218,7 @@ def match_status_code(status_code: str) -> FilterFunction:
212
218
  """
213
219
  status_codes = set(expand_status_code(status_code))
214
220
 
215
- def compare(result: StepResult) -> bool:
221
+ def compare(result: StepOutput) -> bool:
216
222
  return result.response.status_code in status_codes
217
223
 
218
224
  compare.__name__ = f"match_{status_code}_response"
@@ -230,7 +236,7 @@ def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
230
236
  status_code for value in status_codes if value != "default" for status_code in expand_status_code(value)
231
237
  }
232
238
 
233
- def match_default_response(result: StepResult) -> bool:
239
+ def match_default_response(result: StepOutput) -> bool:
234
240
  return result.response.status_code not in expanded_status_codes
235
241
 
236
242
  return match_default_response
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from inspect import iscoroutinefunction
5
+ from typing import Any, Callable, Generic, Iterator, TypeVar
6
+
7
+ from schemathesis.core import media_types
8
+ from schemathesis.core.errors import SerializationNotPossible
9
+
10
+
11
+ def get(app: Any) -> BaseTransport:
12
+ """Get transport to send the data to the application."""
13
+ from schemathesis.transport.asgi import ASGI_TRANSPORT
14
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
15
+ from schemathesis.transport.wsgi import WSGI_TRANSPORT
16
+
17
+ if app is None:
18
+ return REQUESTS_TRANSPORT
19
+ if iscoroutinefunction(app) or (
20
+ hasattr(app, "__call__") and iscoroutinefunction(app.__call__) # noqa: B004
21
+ ):
22
+ return ASGI_TRANSPORT
23
+ return WSGI_TRANSPORT
24
+
25
+
26
+ C = TypeVar("C", contravariant=True)
27
+ R = TypeVar("R", covariant=True)
28
+ S = TypeVar("S", contravariant=True)
29
+
30
+
31
+ @dataclass
32
+ class SerializationContext(Generic[C]):
33
+ """Generic context for serialization process."""
34
+
35
+ case: C
36
+
37
+ __slots__ = ("case",)
38
+
39
+
40
+ Serializer = Callable[[SerializationContext[C], Any], Any]
41
+
42
+
43
+ class BaseTransport(Generic[C, R, S]):
44
+ """Base implementation with serializer registration."""
45
+
46
+ def __init__(self) -> None:
47
+ self._serializers: dict[str, Serializer[C]] = {}
48
+
49
+ def serialize_case(self, case: C, **kwargs: Any) -> dict[str, Any]:
50
+ """Prepare the case for sending."""
51
+ raise NotImplementedError
52
+
53
+ def send(self, case: C, *, session: S | None = None, **kwargs: Any) -> R:
54
+ """Send the case using this transport."""
55
+ raise NotImplementedError
56
+
57
+ def serializer(self, *media_types: str) -> Callable[[Serializer[C]], Serializer[C]]:
58
+ """Register a serializer for given media types."""
59
+
60
+ def decorator(func: Serializer[C]) -> Serializer[C]:
61
+ for media_type in media_types:
62
+ self._serializers[media_type] = func
63
+ return func
64
+
65
+ return decorator
66
+
67
+ def unregister_serializer(self, *media_types: str) -> None:
68
+ for media_type in media_types:
69
+ self._serializers.pop(media_type, None)
70
+
71
+ def _copy_serializers_from(self, transport: BaseTransport) -> None:
72
+ self._serializers.update(transport._serializers)
73
+
74
+ def get_first_matching_media_type(self, media_type: str) -> tuple[str, Serializer[C]] | None:
75
+ return next(self.get_matching_media_types(media_type), None)
76
+
77
+ def get_matching_media_types(self, media_type: str) -> Iterator[tuple[str, Serializer[C]]]:
78
+ """Get all registered media types matching the given media type."""
79
+ if media_type == "*/*":
80
+ # Shortcut to avoid comparing all values
81
+ yield from iter(self._serializers.items())
82
+ else:
83
+ main, sub = media_types.parse(media_type)
84
+ checks = [
85
+ media_types.is_json,
86
+ media_types.is_xml,
87
+ media_types.is_plain_text,
88
+ media_types.is_yaml,
89
+ ]
90
+ for registered_media_type, serializer in self._serializers.items():
91
+ # Try known variations for popular media types and fallback to comparison
92
+ if any(check(media_type) and check(registered_media_type) for check in checks):
93
+ yield media_type, serializer
94
+ else:
95
+ target_main, target_sub = media_types.parse(registered_media_type)
96
+ if main in ("*", target_main) and sub in ("*", target_sub):
97
+ yield registered_media_type, serializer
98
+
99
+ def _get_serializer(self, input_media_type: str) -> Serializer[C]:
100
+ pair = self.get_first_matching_media_type(input_media_type)
101
+ if pair is None:
102
+ # This media type is set manually. Otherwise, it should have been rejected during the data generation
103
+ raise SerializationNotPossible.for_media_type(input_media_type)
104
+ return pair[1]
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from schemathesis.core.transport import Response
6
+ from schemathesis.generation.case import Case
7
+ from schemathesis.python import asgi
8
+ from schemathesis.transport.prepare import normalize_base_url
9
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT, RequestsTransport
10
+
11
+ if TYPE_CHECKING:
12
+ import requests
13
+
14
+
15
+ class ASGITransport(RequestsTransport):
16
+ def send(self, case: Case, *, session: requests.Session | None = None, **kwargs: Any) -> Response:
17
+ if kwargs.get("base_url") is None:
18
+ kwargs["base_url"] = normalize_base_url(case.operation.base_url)
19
+ application = kwargs.pop("app", case.operation.app)
20
+
21
+ with asgi.get_client(application) as client:
22
+ return super().send(case, session=client, **kwargs)
23
+
24
+
25
+ ASGI_TRANSPORT = ASGITransport()
26
+ ASGI_TRANSPORT._copy_serializers_from(REQUESTS_TRANSPORT)
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Mapping, cast
4
+ from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
5
+
6
+ from schemathesis.core import SCHEMATHESIS_TEST_CASE_HEADER, NotSet
7
+ from schemathesis.core.errors import InvalidSchema
8
+ from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
9
+ from schemathesis.core.transport import USER_AGENT
10
+
11
+ if TYPE_CHECKING:
12
+ from requests import PreparedRequest
13
+ from requests.structures import CaseInsensitiveDict
14
+
15
+ from schemathesis.generation.case import Case
16
+
17
+
18
+ def prepare_headers(case: Case, headers: dict[str, str] | None = None) -> CaseInsensitiveDict:
19
+ from requests.structures import CaseInsensitiveDict
20
+
21
+ final_headers = case.headers.copy() if case.headers is not None else CaseInsensitiveDict()
22
+ if headers:
23
+ final_headers.update(headers)
24
+ final_headers.setdefault("User-Agent", USER_AGENT)
25
+ final_headers.setdefault(SCHEMATHESIS_TEST_CASE_HEADER, case.id)
26
+ return final_headers
27
+
28
+
29
+ def prepare_url(case: Case, base_url: str | None) -> str:
30
+ """Prepare URL based on case type."""
31
+ from schemathesis.specs.graphql.schemas import GraphQLSchema
32
+
33
+ base_url = base_url or case.operation.base_url
34
+ assert base_url is not None
35
+ path = prepare_path(case.path, case.path_parameters)
36
+
37
+ if isinstance(case.operation.schema, GraphQLSchema):
38
+ parts = list(urlsplit(base_url))
39
+ parts[2] = path
40
+ return urlunsplit(parts)
41
+ else:
42
+ path = path.lstrip("/")
43
+ if not base_url.endswith("/"):
44
+ base_url += "/"
45
+ return unquote(urljoin(base_url, quote(path)))
46
+
47
+
48
+ def prepare_body(case: Case) -> list | dict[str, Any] | str | int | float | bool | bytes | NotSet:
49
+ """Prepare body based on case type."""
50
+ from schemathesis.specs.graphql.schemas import GraphQLSchema
51
+
52
+ if isinstance(case.operation.schema, GraphQLSchema):
53
+ return case.body if isinstance(case.body, (NotSet, bytes)) else {"query": case.body}
54
+ return case.body
55
+
56
+
57
+ def normalize_base_url(base_url: str | None) -> str | None:
58
+ """Normalize base URL by ensuring proper hostname for local URLs.
59
+
60
+ If URL has no hostname (typical for WSGI apps), adds "localhost" as default hostname.
61
+ """
62
+ if base_url is None:
63
+ return None
64
+ parts = urlsplit(base_url)
65
+ if not parts.hostname:
66
+ path = cast(str, parts.path or "")
67
+ return urlunsplit(("http", "localhost", path or "", "", ""))
68
+ return base_url
69
+
70
+
71
+ def prepare_path(path: str, parameters: dict[str, Any] | None) -> str:
72
+ try:
73
+ return path.format(**parameters or {})
74
+ except KeyError as exc:
75
+ # This may happen when a path template has a placeholder for variable "X", but parameter "X" is not defined
76
+ # in the parameters list.
77
+ # When `exc` is formatted, it is the missing key name in quotes. E.g. 'id'
78
+ raise InvalidSchema(f"Path parameter {exc} is not defined") from exc
79
+ except (IndexError, ValueError) as exc:
80
+ # A single unmatched `}` inside the path template may cause this
81
+ raise InvalidSchema(f"Malformed path template: `{path}`\n\n {exc}") from exc
82
+
83
+
84
+ def prepare_request(case: Case, headers: Mapping[str, Any] | None, sanitize: bool) -> PreparedRequest:
85
+ import requests
86
+
87
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
88
+
89
+ base_url = normalize_base_url(case.operation.base_url)
90
+ kwargs = REQUESTS_TRANSPORT.serialize_case(case, base_url=base_url, headers=headers)
91
+ if sanitize:
92
+ kwargs["url"] = sanitize_url(kwargs["url"])
93
+ sanitize_value(kwargs["headers"])
94
+ if kwargs["cookies"]:
95
+ sanitize_value(kwargs["cookies"])
96
+ if kwargs["params"]:
97
+ sanitize_value(kwargs["params"])
98
+
99
+ return requests.Request(**kwargs).prepare()