schemathesis 3.25.5__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 -1766
  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/{cli → engine/phases}/probes.py +63 -70
  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 +153 -39
  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 +483 -367
  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.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.5.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 -55
  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 -765
  156. schemathesis/cli/output/short.py +0 -40
  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 -1231
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -555
  184. schemathesis/runner/events.py +0 -309
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -986
  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 -315
  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 -184
  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.5.dist-info/METADATA +0 -356
  219. schemathesis-3.25.5.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,17 @@
1
1
  """Processing of ``securityDefinitions`` or ``securitySchemes`` keywords."""
2
+
2
3
  from __future__ import annotations
3
- from dataclasses import dataclass
4
- from typing import Any, ClassVar, Generator
5
4
 
6
- from jsonschema import RefResolver
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any, ClassVar, Generator
7
7
 
8
- from ...models import APIOperation
9
8
  from .parameters import OpenAPI20Parameter, OpenAPI30Parameter, OpenAPIParameter
10
9
 
10
+ if TYPE_CHECKING:
11
+ from jsonschema import RefResolver
12
+
13
+ from schemathesis.schemas import APIOperation
14
+
11
15
 
12
16
  @dataclass
13
17
  class BaseSecurityProcessor:
@@ -124,9 +128,23 @@ class OpenAPISecurityProcessor(BaseSecurityProcessor):
124
128
  """In Open API 3 security definitions are located in ``components`` and may have references inside."""
125
129
  components = schema.get("components", {})
126
130
  security_schemes = components.get("securitySchemes", {})
127
- if "$ref" in security_schemes:
128
- return resolver.resolve(security_schemes["$ref"])[1]
129
- return security_schemes
131
+ # At this point, the resolution scope could differ from the root scope, that's why we need to restore it
132
+ # as now we resolve root-level references
133
+ if len(resolver._scopes_stack) > 1:
134
+ scope = resolver.resolution_scope
135
+ resolver.pop_scope()
136
+ else:
137
+ scope = None
138
+ resolve = resolver.resolve
139
+ try:
140
+ if "$ref" in security_schemes:
141
+ return resolve(security_schemes["$ref"])[1]
142
+ return {
143
+ key: resolve(value["$ref"])[1] if "$ref" in value else value for key, value in security_schemes.items()
144
+ }
145
+ finally:
146
+ if scope is not None:
147
+ resolver._scopes_stack.append(scope)
130
148
 
131
149
  def _make_http_auth_parameter(self, definition: dict[str, Any]) -> dict[str, Any]:
132
150
  schema = make_auth_header_schema(definition)
@@ -1,9 +1,8 @@
1
1
  from __future__ import annotations
2
+
2
3
  import json
3
4
  from typing import Any, Callable, Dict, Generator, List
4
5
 
5
- from ...utils import compose
6
-
7
6
  Generated = Dict[str, Any]
8
7
  Definition = Dict[str, Any]
9
8
  DefinitionList = List[Definition]
@@ -16,10 +15,16 @@ def make_serializer(
16
15
  """A maker function to avoid code duplication."""
17
16
 
18
17
  def _wrapper(definitions: DefinitionList) -> Callable | None:
19
- conversions = list(func(definitions))
20
- if conversions:
21
- return compose(*[conv for conv in conversions if conv is not None])
22
- 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
23
28
 
24
29
  return _wrapper
25
30
 
@@ -1,30 +1,50 @@
1
1
  from __future__ import annotations
2
+
2
3
  from collections import defaultdict
3
- from typing import TYPE_CHECKING, Any, List, cast
4
+ from functools import lru_cache
5
+ from typing import TYPE_CHECKING, Any, Callable, Iterator
4
6
 
5
7
  from hypothesis import strategies as st
6
8
  from hypothesis.stateful import Bundle, Rule, precondition, rule
7
- from requests.structures import CaseInsensitiveDict
8
9
 
9
- from ....internal.result import Ok
10
- from ....stateful.state_machine import APIStateMachine, Direction, StepResult
11
- from ....utils import combine_strategies
10
+ from schemathesis.core import NOT_SET, NotSet
11
+ from schemathesis.core.result import Ok
12
+ from schemathesis.generation.case import Case
13
+ from schemathesis.generation.hypothesis import strategies
14
+ from schemathesis.generation.stateful.state_machine import APIStateMachine, Direction, StepResult, _normalize_name
15
+
16
+ from ....generation import GenerationMode
12
17
  from .. import expressions
13
- from . import links
14
- from .links import APIOperationConnections, Connection, _convert_strategy, apply, make_response_filter
18
+ from ..links import get_all_links
19
+ from ..utils import expand_status_code
15
20
 
16
21
  if TYPE_CHECKING:
17
- from ....models import APIOperation, Case
22
+ from schemathesis.generation.stateful.state_machine import StepResult
23
+
18
24
  from ..schemas import BaseOpenAPISchema
19
25
 
26
+ FilterFunction = Callable[["StepResult"], bool]
27
+
20
28
 
21
29
  class OpenAPIStateMachine(APIStateMachine):
30
+ _response_matchers: dict[str, Callable[[StepResult], str | None]]
31
+
32
+ def _get_target_for_result(self, result: StepResult) -> str | None:
33
+ matcher = self._response_matchers.get(result.case.operation.label)
34
+ if matcher is None:
35
+ return None
36
+ return matcher(result)
37
+
22
38
  def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
23
39
  context = expressions.ExpressionContext(case=result.case, response=result.response)
24
- direction.set_data(case, elapsed=result.elapsed, context=context)
40
+ direction.set_data(case, context=context)
25
41
  return case
26
42
 
27
43
 
44
+ # The proportion of negative tests generated for "root" transitions
45
+ NEGATIVE_TEST_CASES_THRESHOLD = 20
46
+
47
+
28
48
  def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
29
49
  """Create a state machine class.
30
50
 
@@ -34,75 +54,167 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
34
54
 
35
55
  This state machine won't make calls to (2) without having a proper response from (1) first.
36
56
  """
37
- # Bundles are special strategies, allowing us to draw responses from previous calls
38
- bundles = init_bundles(schema)
39
- connections: APIOperationConnections = defaultdict(list)
40
57
  operations = [result.ok() for result in schema.get_all_operations() if isinstance(result, Ok)]
58
+ bundles = {}
59
+ incoming_transitions = defaultdict(list)
60
+ _response_matchers: dict[str, Callable[[StepResult], str | None]] = {}
61
+ # Statistic structure follows the links and count for each response status code
41
62
  for operation in operations:
42
- apply(operation, bundles, connections)
63
+ all_status_codes = tuple(operation.definition.raw["responses"])
64
+ bundle_matchers = []
65
+ for _, link in get_all_links(operation):
66
+ bundle_name = f"{operation.label} -> {link.status_code}"
67
+ bundles[bundle_name] = Bundle(bundle_name)
68
+ target_operation = link.get_target_operation()
69
+ incoming_transitions[target_operation.label].append(link)
70
+ bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
71
+ if bundle_matchers:
72
+ _response_matchers[operation.label] = make_response_matcher(bundle_matchers)
73
+ rules = {}
74
+ catch_all = Bundle("catch_all")
75
+
76
+ for target in operations:
77
+ incoming = incoming_transitions.get(target.label)
78
+ if incoming is not None:
79
+ for link in incoming:
80
+ source = link.operation
81
+ bundle_name = f"{source.label} -> {link.status_code}"
82
+ name = _normalize_name(f"{target.label} -> {link.status_code}")
83
+ case_strategy = strategies.combine(
84
+ [target.as_strategy(generation_mode=mode) for mode in schema.generation_config.modes]
85
+ )
86
+ bundle = bundles[bundle_name]
87
+ rules[name] = transition(
88
+ name=name,
89
+ target=catch_all,
90
+ previous=bundle,
91
+ case=case_strategy,
92
+ link=st.just(link),
93
+ )
94
+ elif any(
95
+ incoming.operation.label == target.label
96
+ for transitions in incoming_transitions.values()
97
+ for incoming in transitions
98
+ ):
99
+ # No incoming transitions, but has at least one outgoing transition
100
+ # For example, POST /users/ -> GET /users/{id}/
101
+ # The source operation has no prerequisite, but we need to allow this rule to be executed
102
+ # in order to reach other transitions
103
+ name = _normalize_name(f"{target.label} -> X")
104
+ if len(schema.generation_config.modes) == 1:
105
+ case_strategy = target.as_strategy(generation_mode=schema.generation_config.modes[0])
106
+ else:
107
+ _strategies = {
108
+ method: target.as_strategy(generation_mode=method) for method in schema.generation_config.modes
109
+ }
110
+
111
+ @st.composite # type: ignore[misc]
112
+ def case_strategy_factory(
113
+ draw: st.DrawFn, strategies: dict[GenerationMode, st.SearchStrategy] = _strategies
114
+ ) -> Case:
115
+ if draw(st.integers(min_value=0, max_value=99)) < NEGATIVE_TEST_CASES_THRESHOLD:
116
+ return draw(strategies[GenerationMode.NEGATIVE])
117
+ return draw(strategies[GenerationMode.POSITIVE])
118
+
119
+ case_strategy = case_strategy_factory()
120
+
121
+ rules[name] = precondition(ensure_links_followed)(
122
+ transition(
123
+ name=name,
124
+ target=catch_all,
125
+ previous=st.none(),
126
+ case=case_strategy,
127
+ )
128
+ )
129
+
130
+ return type(
131
+ "APIWorkflow",
132
+ (OpenAPIStateMachine,),
133
+ {
134
+ "schema": schema,
135
+ "bundles": bundles,
136
+ "_response_matchers": _response_matchers,
137
+ **rules,
138
+ },
139
+ )
140
+
141
+
142
+ def ensure_links_followed(machine: APIStateMachine) -> bool:
143
+ # If there are responses that have links to follow, reject any rule without incoming transitions
144
+ for bundle in machine.bundles.values():
145
+ if bundle:
146
+ return False
147
+ return True
148
+
149
+
150
+ def transition(
151
+ *,
152
+ name: str,
153
+ target: Bundle,
154
+ previous: Bundle | st.SearchStrategy,
155
+ case: st.SearchStrategy,
156
+ link: st.SearchStrategy | NotSet = NOT_SET,
157
+ ) -> Callable[[Callable], Rule]:
158
+ def step_function(*args_: Any, **kwargs_: Any) -> StepResult | None:
159
+ return APIStateMachine._step(*args_, **kwargs_)
160
+
161
+ step_function.__name__ = name
162
+
163
+ kwargs = {"target": target, "previous": previous, "case": case}
164
+ if not isinstance(link, NotSet):
165
+ kwargs["link"] = link
166
+
167
+ return rule(**kwargs)(step_function)
168
+
169
+
170
+ def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepResult], str | None]:
171
+ def compare(result: StepResult) -> str | None:
172
+ for bundle_name, response_filter in matchers:
173
+ if response_filter(result):
174
+ return bundle_name
175
+ return None
176
+
177
+ return compare
178
+
179
+
180
+ @lru_cache
181
+ def make_response_filter(status_code: str, all_status_codes: Iterator[str]) -> FilterFunction:
182
+ """Create a filter for stored responses.
183
+
184
+ This filter will decide whether some response is suitable to use as a source for requesting some API operation.
185
+ """
186
+ if status_code == "default":
187
+ return default_status_code(all_status_codes)
188
+ return match_status_code(status_code)
43
189
 
44
- rules = make_all_rules(operations, bundles, connections)
45
190
 
46
- kwargs: dict[str, Any] = {"bundles": bundles, "schema": schema}
47
- return type("APIWorkflow", (OpenAPIStateMachine,), {**kwargs, **rules})
191
+ def match_status_code(status_code: str) -> FilterFunction:
192
+ """Create a filter function that matches all responses with the given status code.
193
+
194
+ Note that the status code can contain "X", which means any digit.
195
+ For example, 50X will match all status codes from 500 to 509.
196
+ """
197
+ status_codes = set(expand_status_code(status_code))
198
+
199
+ def compare(result: StepResult) -> bool:
200
+ return result.response.status_code in status_codes
201
+
202
+ compare.__name__ = f"match_{status_code}_response"
203
+
204
+ return compare
48
205
 
49
206
 
50
- def init_bundles(schema: BaseOpenAPISchema) -> dict[str, CaseInsensitiveDict]:
51
- """Create bundles for all operations in the given schema.
207
+ def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
208
+ """Create a filter that matches all "default" responses.
52
209
 
53
- Each API operation has a bundle that stores all responses from that operation.
54
- We need to create bundles first, so they can be referred when building connections between operations.
210
+ In Open API, the "default" response is the one that is used if no other options were matched.
211
+ Therefore, we need to match only responses that were not matched by other listed status codes.
55
212
  """
56
- output: dict[str, CaseInsensitiveDict] = {}
57
- for result in schema.get_all_operations():
58
- if isinstance(result, Ok):
59
- operation = result.ok()
60
- output.setdefault(operation.path, CaseInsensitiveDict())
61
- output[operation.path][operation.method.upper()] = Bundle(operation.verbose_name) # type: ignore
62
- return output
63
-
64
-
65
- def make_all_rules(
66
- operations: list[APIOperation],
67
- bundles: dict[str, CaseInsensitiveDict],
68
- connections: APIOperationConnections,
69
- ) -> dict[str, Rule]:
70
- """Create rules for all API operations, based on the provided connections."""
71
- rules = {}
72
- for operation in operations:
73
- new_rule = make_rule(operation, bundles[operation.path][operation.method.upper()], connections)
74
- if new_rule is not None:
75
- rules[f"rule {operation.verbose_name}"] = new_rule
76
- return rules
77
-
78
-
79
- def make_rule(
80
- operation: APIOperation,
81
- bundle: Bundle,
82
- connections: APIOperationConnections,
83
- ) -> Rule | None:
84
- """Create a rule for an API operation."""
85
-
86
- def _make_rule(previous: st.SearchStrategy) -> Rule:
87
- decorator = rule(target=bundle, previous=previous, case=operation.as_strategy()) # type: ignore
88
- return decorator(APIStateMachine._step)
89
-
90
- incoming = connections.get(operation.verbose_name)
91
- if incoming is not None:
92
- incoming_connections = cast(List[Connection], incoming)
93
- strategies = [connection.strategy for connection in incoming_connections]
94
- _rule = _make_rule(combine_strategies(strategies))
95
-
96
- def has_source_response(self: OpenAPIStateMachine) -> bool:
97
- # To trigger this transition, there should be matching responses from the source operations
98
- return any(connection.source in self.bundles for connection in incoming_connections)
99
-
100
- return precondition(has_source_response)(_rule)
101
- # No incoming transitions - make rules only for operations that have at least one outgoing transition
102
- if any(
103
- connection.source == operation.verbose_name
104
- for operation_connections in connections.values()
105
- for connection in operation_connections
106
- ):
107
- return _make_rule(st.none())
108
- return None
213
+ expanded_status_codes = {
214
+ status_code for value in status_codes if value != "default" for status_code in expand_status_code(value)
215
+ }
216
+
217
+ def match_default_response(result: StepResult) -> bool:
218
+ return result.response.status_code not in expanded_status_codes
219
+
220
+ return match_default_response
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
+
2
3
  import string
3
- from itertools import product
4
+ from itertools import chain, product
4
5
  from typing import Any, Generator
5
6
 
6
7
 
@@ -10,6 +11,10 @@ def expand_status_code(status_code: str | int) -> Generator[int, None, None]:
10
11
  yield int("".join(expanded))
11
12
 
12
13
 
14
+ def expand_status_codes(status_codes: list[str]) -> set[int]:
15
+ return set(chain.from_iterable(expand_status_code(code) for code in status_codes))
16
+
17
+
13
18
  def is_header_location(location: str) -> bool:
14
19
  """Whether this location affects HTTP headers."""
15
20
  return location in ("header", "cookie")
@@ -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()