schemathesis 3.13.0__py3-none-any.whl → 4.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. schemathesis/__init__.py +53 -25
  2. schemathesis/auths.py +507 -0
  3. schemathesis/checks.py +190 -25
  4. schemathesis/cli/__init__.py +27 -1016
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +133 -0
  7. schemathesis/cli/commands/data.py +10 -0
  8. schemathesis/cli/commands/run/__init__.py +602 -0
  9. schemathesis/cli/commands/run/context.py +228 -0
  10. schemathesis/cli/commands/run/events.py +60 -0
  11. schemathesis/cli/commands/run/executor.py +157 -0
  12. schemathesis/cli/commands/run/filters.py +53 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +45 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
  17. schemathesis/cli/commands/run/handlers/output.py +1750 -0
  18. schemathesis/cli/commands/run/loaders.py +118 -0
  19. schemathesis/cli/commands/run/validation.py +256 -0
  20. schemathesis/cli/constants.py +5 -0
  21. schemathesis/cli/core.py +19 -0
  22. schemathesis/cli/ext/fs.py +16 -0
  23. schemathesis/cli/ext/groups.py +203 -0
  24. schemathesis/cli/ext/options.py +81 -0
  25. schemathesis/config/__init__.py +202 -0
  26. schemathesis/config/_auth.py +51 -0
  27. schemathesis/config/_checks.py +268 -0
  28. schemathesis/config/_diff_base.py +101 -0
  29. schemathesis/config/_env.py +21 -0
  30. schemathesis/config/_error.py +163 -0
  31. schemathesis/config/_generation.py +157 -0
  32. schemathesis/config/_health_check.py +24 -0
  33. schemathesis/config/_operations.py +335 -0
  34. schemathesis/config/_output.py +171 -0
  35. schemathesis/config/_parameters.py +19 -0
  36. schemathesis/config/_phases.py +253 -0
  37. schemathesis/config/_projects.py +543 -0
  38. schemathesis/config/_rate_limit.py +17 -0
  39. schemathesis/config/_report.py +120 -0
  40. schemathesis/config/_validator.py +9 -0
  41. schemathesis/config/_warnings.py +89 -0
  42. schemathesis/config/schema.json +975 -0
  43. schemathesis/core/__init__.py +72 -0
  44. schemathesis/core/adapter.py +34 -0
  45. schemathesis/core/compat.py +32 -0
  46. schemathesis/core/control.py +2 -0
  47. schemathesis/core/curl.py +100 -0
  48. schemathesis/core/deserialization.py +210 -0
  49. schemathesis/core/errors.py +588 -0
  50. schemathesis/core/failures.py +316 -0
  51. schemathesis/core/fs.py +19 -0
  52. schemathesis/core/hooks.py +20 -0
  53. schemathesis/core/jsonschema/__init__.py +13 -0
  54. schemathesis/core/jsonschema/bundler.py +183 -0
  55. schemathesis/core/jsonschema/keywords.py +40 -0
  56. schemathesis/core/jsonschema/references.py +222 -0
  57. schemathesis/core/jsonschema/types.py +41 -0
  58. schemathesis/core/lazy_import.py +15 -0
  59. schemathesis/core/loaders.py +107 -0
  60. schemathesis/core/marks.py +66 -0
  61. schemathesis/core/media_types.py +79 -0
  62. schemathesis/core/output/__init__.py +46 -0
  63. schemathesis/core/output/sanitization.py +54 -0
  64. schemathesis/core/parameters.py +45 -0
  65. schemathesis/core/rate_limit.py +60 -0
  66. schemathesis/core/registries.py +34 -0
  67. schemathesis/core/result.py +27 -0
  68. schemathesis/core/schema_analysis.py +17 -0
  69. schemathesis/core/shell.py +203 -0
  70. schemathesis/core/transforms.py +144 -0
  71. schemathesis/core/transport.py +223 -0
  72. schemathesis/core/validation.py +73 -0
  73. schemathesis/core/version.py +7 -0
  74. schemathesis/engine/__init__.py +28 -0
  75. schemathesis/engine/context.py +152 -0
  76. schemathesis/engine/control.py +44 -0
  77. schemathesis/engine/core.py +201 -0
  78. schemathesis/engine/errors.py +446 -0
  79. schemathesis/engine/events.py +284 -0
  80. schemathesis/engine/observations.py +42 -0
  81. schemathesis/engine/phases/__init__.py +108 -0
  82. schemathesis/engine/phases/analysis.py +28 -0
  83. schemathesis/engine/phases/probes.py +172 -0
  84. schemathesis/engine/phases/stateful/__init__.py +68 -0
  85. schemathesis/engine/phases/stateful/_executor.py +364 -0
  86. schemathesis/engine/phases/stateful/context.py +85 -0
  87. schemathesis/engine/phases/unit/__init__.py +220 -0
  88. schemathesis/engine/phases/unit/_executor.py +459 -0
  89. schemathesis/engine/phases/unit/_pool.py +82 -0
  90. schemathesis/engine/recorder.py +254 -0
  91. schemathesis/errors.py +47 -0
  92. schemathesis/filters.py +395 -0
  93. schemathesis/generation/__init__.py +25 -0
  94. schemathesis/generation/case.py +478 -0
  95. schemathesis/generation/coverage.py +1528 -0
  96. schemathesis/generation/hypothesis/__init__.py +121 -0
  97. schemathesis/generation/hypothesis/builder.py +992 -0
  98. schemathesis/generation/hypothesis/examples.py +56 -0
  99. schemathesis/generation/hypothesis/given.py +66 -0
  100. schemathesis/generation/hypothesis/reporting.py +285 -0
  101. schemathesis/generation/meta.py +227 -0
  102. schemathesis/generation/metrics.py +93 -0
  103. schemathesis/generation/modes.py +20 -0
  104. schemathesis/generation/overrides.py +127 -0
  105. schemathesis/generation/stateful/__init__.py +37 -0
  106. schemathesis/generation/stateful/state_machine.py +294 -0
  107. schemathesis/graphql/__init__.py +15 -0
  108. schemathesis/graphql/checks.py +109 -0
  109. schemathesis/graphql/loaders.py +285 -0
  110. schemathesis/hooks.py +270 -91
  111. schemathesis/openapi/__init__.py +13 -0
  112. schemathesis/openapi/checks.py +467 -0
  113. schemathesis/openapi/generation/__init__.py +0 -0
  114. schemathesis/openapi/generation/filters.py +72 -0
  115. schemathesis/openapi/loaders.py +315 -0
  116. schemathesis/pytest/__init__.py +5 -0
  117. schemathesis/pytest/control_flow.py +7 -0
  118. schemathesis/pytest/lazy.py +341 -0
  119. schemathesis/pytest/loaders.py +36 -0
  120. schemathesis/pytest/plugin.py +357 -0
  121. schemathesis/python/__init__.py +0 -0
  122. schemathesis/python/asgi.py +12 -0
  123. schemathesis/python/wsgi.py +12 -0
  124. schemathesis/schemas.py +683 -247
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +27 -0
  127. schemathesis/specs/graphql/scalars.py +86 -0
  128. schemathesis/specs/graphql/schemas.py +395 -123
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +578 -317
  132. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  133. schemathesis/specs/openapi/adapter/parameters.py +729 -0
  134. schemathesis/specs/openapi/adapter/protocol.py +59 -0
  135. schemathesis/specs/openapi/adapter/references.py +19 -0
  136. schemathesis/specs/openapi/adapter/responses.py +368 -0
  137. schemathesis/specs/openapi/adapter/security.py +144 -0
  138. schemathesis/specs/openapi/adapter/v2.py +30 -0
  139. schemathesis/specs/openapi/adapter/v3_0.py +30 -0
  140. schemathesis/specs/openapi/adapter/v3_1.py +30 -0
  141. schemathesis/specs/openapi/analysis.py +96 -0
  142. schemathesis/specs/openapi/checks.py +753 -74
  143. schemathesis/specs/openapi/converter.py +176 -37
  144. schemathesis/specs/openapi/definitions.py +599 -4
  145. schemathesis/specs/openapi/examples.py +581 -165
  146. schemathesis/specs/openapi/expressions/__init__.py +52 -5
  147. schemathesis/specs/openapi/expressions/extractors.py +25 -0
  148. schemathesis/specs/openapi/expressions/lexer.py +34 -31
  149. schemathesis/specs/openapi/expressions/nodes.py +97 -46
  150. schemathesis/specs/openapi/expressions/parser.py +35 -13
  151. schemathesis/specs/openapi/formats.py +122 -0
  152. schemathesis/specs/openapi/media_types.py +75 -0
  153. schemathesis/specs/openapi/negative/__init__.py +117 -68
  154. schemathesis/specs/openapi/negative/mutations.py +294 -104
  155. schemathesis/specs/openapi/negative/utils.py +3 -6
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +648 -650
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +404 -69
  161. schemathesis/specs/openapi/stateful/control.py +87 -0
  162. schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
  163. schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
  164. schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
  165. schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
  166. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  167. schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
  168. schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
  169. schemathesis/specs/openapi/stateful/inference.py +254 -0
  170. schemathesis/specs/openapi/stateful/links.py +219 -78
  171. schemathesis/specs/openapi/types/__init__.py +3 -0
  172. schemathesis/specs/openapi/types/common.py +23 -0
  173. schemathesis/specs/openapi/types/v2.py +129 -0
  174. schemathesis/specs/openapi/types/v3.py +134 -0
  175. schemathesis/specs/openapi/utils.py +7 -6
  176. schemathesis/specs/openapi/warnings.py +75 -0
  177. schemathesis/transport/__init__.py +224 -0
  178. schemathesis/transport/asgi.py +26 -0
  179. schemathesis/transport/prepare.py +126 -0
  180. schemathesis/transport/requests.py +278 -0
  181. schemathesis/transport/serialization.py +329 -0
  182. schemathesis/transport/wsgi.py +175 -0
  183. schemathesis-4.4.2.dist-info/METADATA +213 -0
  184. schemathesis-4.4.2.dist-info/RECORD +192 -0
  185. {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
  186. schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
  187. {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -41
  189. schemathesis/_hypothesis.py +0 -115
  190. schemathesis/cli/callbacks.py +0 -188
  191. schemathesis/cli/cassettes.py +0 -253
  192. schemathesis/cli/context.py +0 -36
  193. schemathesis/cli/debug.py +0 -21
  194. schemathesis/cli/handlers.py +0 -11
  195. schemathesis/cli/junitxml.py +0 -41
  196. schemathesis/cli/options.py +0 -51
  197. schemathesis/cli/output/__init__.py +0 -1
  198. schemathesis/cli/output/default.py +0 -508
  199. schemathesis/cli/output/short.py +0 -40
  200. schemathesis/constants.py +0 -79
  201. schemathesis/exceptions.py +0 -207
  202. schemathesis/extra/_aiohttp.py +0 -27
  203. schemathesis/extra/_flask.py +0 -10
  204. schemathesis/extra/_server.py +0 -16
  205. schemathesis/extra/pytest_plugin.py +0 -216
  206. schemathesis/failures.py +0 -131
  207. schemathesis/fixups/__init__.py +0 -29
  208. schemathesis/fixups/fast_api.py +0 -30
  209. schemathesis/lazy.py +0 -227
  210. schemathesis/models.py +0 -1041
  211. schemathesis/parameters.py +0 -88
  212. schemathesis/runner/__init__.py +0 -460
  213. schemathesis/runner/events.py +0 -240
  214. schemathesis/runner/impl/__init__.py +0 -3
  215. schemathesis/runner/impl/core.py +0 -755
  216. schemathesis/runner/impl/solo.py +0 -85
  217. schemathesis/runner/impl/threadpool.py +0 -367
  218. schemathesis/runner/serialization.py +0 -189
  219. schemathesis/serializers.py +0 -233
  220. schemathesis/service/__init__.py +0 -3
  221. schemathesis/service/client.py +0 -46
  222. schemathesis/service/constants.py +0 -12
  223. schemathesis/service/events.py +0 -39
  224. schemathesis/service/handler.py +0 -39
  225. schemathesis/service/models.py +0 -7
  226. schemathesis/service/serialization.py +0 -153
  227. schemathesis/service/worker.py +0 -40
  228. schemathesis/specs/graphql/loaders.py +0 -215
  229. schemathesis/specs/openapi/constants.py +0 -7
  230. schemathesis/specs/openapi/expressions/context.py +0 -12
  231. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  232. schemathesis/specs/openapi/filters.py +0 -44
  233. schemathesis/specs/openapi/links.py +0 -302
  234. schemathesis/specs/openapi/loaders.py +0 -453
  235. schemathesis/specs/openapi/parameters.py +0 -413
  236. schemathesis/specs/openapi/security.py +0 -129
  237. schemathesis/specs/openapi/validation.py +0 -24
  238. schemathesis/stateful.py +0 -349
  239. schemathesis/targets.py +0 -32
  240. schemathesis/types.py +0 -38
  241. schemathesis/utils.py +0 -436
  242. schemathesis-3.13.0.dist-info/METADATA +0 -202
  243. schemathesis-3.13.0.dist-info/RECORD +0 -91
  244. schemathesis-3.13.0.dist-info/entry_points.txt +0 -6
  245. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
@@ -1,118 +1,74 @@
1
- import json
2
- import re
3
- import string
4
- from base64 import b64encode
5
- from contextlib import contextmanager, suppress
6
- from copy import deepcopy
7
- from typing import Any, Callable, Dict, Generator, Iterable, Optional, Tuple, Union
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import Any, Callable, Iterable, Optional, Union, cast
8
6
  from urllib.parse import quote_plus
9
- from weakref import WeakKeyDictionary
10
7
 
8
+ import jsonschema.protocols
9
+ from hypothesis import event, note, reject
11
10
  from hypothesis import strategies as st
12
11
  from hypothesis_jsonschema import from_schema
13
- from requests.auth import _basic_auth_str
14
12
  from requests.structures import CaseInsensitiveDict
15
13
 
16
- from ... import utils
17
- from ...constants import DataGenerationMethod
18
- from ...exceptions import InvalidSchema
19
- from ...hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher
20
- from ...models import APIOperation, Case
21
- from ...types import NotSet
22
- from ...utils import NOT_SET, compose
23
- from .constants import LOCATION_TO_CONTAINER
24
- from .negative import negative_schema
25
- from .parameters import OpenAPIBody, parameters_to_json_schema
26
- from .utils import is_header_location
27
-
28
- HEADER_FORMAT = "_header_value"
29
- PARAMETERS = frozenset(("path_parameters", "headers", "cookies", "query", "body"))
30
- SLASH = "/"
31
- STRING_FORMATS: Dict[str, st.SearchStrategy] = {}
32
- StrategyFactory = Callable[[Dict[str, Any], str, str, Optional[str]], st.SearchStrategy]
33
-
34
-
35
- def register_string_format(name: str, strategy: st.SearchStrategy) -> None:
36
- """Register a new strategy for generating data for specific string "format".
37
-
38
- :param str name: Format name. It should correspond the one used in the API schema as the "format" keyword value.
39
- :param strategy: Hypothesis strategy you'd like to use to generate values for this format.
40
- """
41
- if not isinstance(name, str):
42
- raise TypeError(f"name must be of type {str}, not {type(name)}")
43
- if not isinstance(strategy, st.SearchStrategy):
44
- raise TypeError(f"strategy must be of type {st.SearchStrategy}, not {type(strategy)}")
45
-
46
- STRING_FORMATS[name] = strategy
47
-
48
-
49
- def init_default_strategies() -> None:
50
- """Register all default "format" strategies."""
51
- register_string_format("binary", st.binary())
52
- register_string_format("byte", st.binary().map(lambda x: b64encode(x).decode()))
53
-
54
- def make_basic_auth_str(item: Tuple[str, str]) -> str:
55
- return _basic_auth_str(*item)
56
-
57
- latin1_text = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255))
58
-
59
- # RFC 7230, Section 3.2.6
60
- register_string_format(
61
- "_header_name",
62
- st.text(min_size=1, alphabet=st.sampled_from("!#$%&'*+-.^_`|~" + string.digits + string.ascii_letters)),
63
- )
64
- # Define valid characters here to avoid filtering them out in `is_valid_header` later
65
- header_value = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255, blacklist_characters="\n\r"))
66
- # Header values with leading non-visible chars can't be sent with `requests`
67
- register_string_format(HEADER_FORMAT, header_value.map(str.lstrip))
68
- register_string_format("_basic_auth", st.tuples(latin1_text, latin1_text).map(make_basic_auth_str)) # type: ignore
69
- register_string_format(
70
- "_bearer_auth",
71
- header_value.map("Bearer {}".format),
72
- )
73
-
74
-
75
- def is_valid_header(headers: Dict[str, Any]) -> bool:
76
- """Verify if the generated headers are valid."""
77
- for name, value in headers.items():
78
- if not utils.is_latin_1_encodable(value):
79
- return False
80
- if utils.has_invalid_characters(name, value):
81
- return False
82
- return True
83
-
84
-
85
- def is_illegal_surrogate(item: Any) -> bool:
86
- def check(value: Any) -> bool:
87
- return isinstance(value, str) and bool(re.search(r"[\ud800-\udfff]", value))
88
-
89
- if isinstance(item, list):
90
- return any(check(item_) for item_ in item)
91
- return check(item)
92
-
93
-
94
- def is_valid_query(query: Dict[str, Any]) -> bool:
95
- """Surrogates are not allowed in a query string.
14
+ from schemathesis.config import GenerationConfig
15
+ from schemathesis.core import NOT_SET, media_types
16
+ from schemathesis.core.control import SkipTest
17
+ from schemathesis.core.errors import SERIALIZERS_SUGGESTION_MESSAGE, MalformedMediaType, SerializationNotPossible
18
+ from schemathesis.core.jsonschema.types import JsonSchema
19
+ from schemathesis.core.parameters import ParameterLocation
20
+ from schemathesis.core.transport import prepare_urlencoded
21
+ from schemathesis.generation.meta import (
22
+ CaseMetadata,
23
+ ComponentInfo,
24
+ ExamplesPhaseData,
25
+ FuzzingPhaseData,
26
+ GenerationInfo,
27
+ PhaseInfo,
28
+ StatefulPhaseData,
29
+ TestPhase,
30
+ )
31
+ from schemathesis.openapi.generation.filters import is_valid_urlencoded
32
+ from schemathesis.schemas import APIOperation
33
+ from schemathesis.specs.openapi.adapter.parameters import FORM_MEDIA_TYPES, OpenApiBody, OpenApiParameterSet
34
+ from schemathesis.specs.openapi.negative.mutations import MutationMetadata
35
+
36
+ from ... import auths
37
+ from ...generation import GenerationMode
38
+ from ...hooks import HookContext, HookDispatcher, apply_to_all_dispatchers
39
+ from .formats import (
40
+ DEFAULT_HEADER_EXCLUDE_CHARACTERS,
41
+ HEADER_FORMAT,
42
+ STRING_FORMATS,
43
+ get_default_format_strategies,
44
+ header_values,
45
+ )
46
+ from .media_types import MEDIA_TYPES
47
+ from .negative import GeneratedValue, negative_schema
48
+ from .negative.utils import can_negate
96
49
 
97
- `requests` and `werkzeug` will fail to send it to the application.
98
- """
99
- for name, value in query.items():
100
- if is_illegal_surrogate(name) or is_illegal_surrogate(value):
101
- return False
102
- return True
50
+ SLASH = "/"
51
+ StrategyFactory = Callable[
52
+ [JsonSchema, str, ParameterLocation, Optional[str], GenerationConfig, type[jsonschema.protocols.Validator]],
53
+ st.SearchStrategy,
54
+ ]
103
55
 
104
56
 
105
- @st.composite # type: ignore
106
- def get_case_strategy( # pylint: disable=too-many-locals
107
- draw: Callable,
57
+ @st.composite # type: ignore[misc]
58
+ def openapi_cases(
59
+ draw: st.DrawFn,
60
+ *,
108
61
  operation: APIOperation,
109
- hooks: Optional[HookDispatcher] = None,
110
- data_generation_method: DataGenerationMethod = DataGenerationMethod.default(),
111
- path_parameters: Union[NotSet, Dict[str, Any]] = NOT_SET,
112
- headers: Union[NotSet, Dict[str, Any]] = NOT_SET,
113
- cookies: Union[NotSet, Dict[str, Any]] = NOT_SET,
114
- query: Union[NotSet, Dict[str, Any]] = NOT_SET,
62
+ hooks: HookDispatcher | None = None,
63
+ auth_storage: auths.AuthStorage | None = None,
64
+ generation_mode: GenerationMode = GenerationMode.POSITIVE,
65
+ path_parameters: dict[str, Any] | None = None,
66
+ headers: dict[str, Any] | None = None,
67
+ cookies: dict[str, Any] | None = None,
68
+ query: dict[str, Any] | None = None,
115
69
  body: Any = NOT_SET,
70
+ media_type: str | None = None,
71
+ phase: TestPhase = TestPhase.FUZZING,
116
72
  ) -> Any:
117
73
  """A strategy that creates `Case` instances.
118
74
 
@@ -126,254 +82,573 @@ def get_case_strategy( # pylint: disable=too-many-locals
126
82
  The primary purpose of this behavior is to prevent sending incomplete explicit examples by generating missing parts
127
83
  as it works with `body`.
128
84
  """
129
- to_strategy = DATA_GENERATION_METHOD_TO_STRATEGY_FACTORY[data_generation_method]
130
-
131
- context = HookContext(operation)
132
-
133
- with detect_invalid_schema(operation):
134
- path_parameters_value = get_parameters_value(
135
- path_parameters, "path", draw, operation, context, hooks, to_strategy
136
- )
137
- headers_value = get_parameters_value(headers, "header", draw, operation, context, hooks, to_strategy)
138
- cookies_value = get_parameters_value(cookies, "cookie", draw, operation, context, hooks, to_strategy)
139
- query_value = get_parameters_value(query, "query", draw, operation, context, hooks, to_strategy)
140
-
141
- media_type = None
142
- if body is NOT_SET:
143
- if operation.body:
144
- parameter = draw(st.sampled_from(operation.body.items))
145
- strategy = _get_body_strategy(parameter, to_strategy, operation)
146
- strategy = apply_hooks(operation, context, hooks, strategy, "body")
147
- media_type = parameter.media_type
148
- body = draw(strategy)
85
+ start = time.monotonic()
86
+
87
+ generation_config = operation.schema.config.generation_for(operation=operation, phase=phase.value)
88
+
89
+ ctx = HookContext(operation=operation)
90
+
91
+ path_parameters_ = generate_parameter(
92
+ ParameterLocation.PATH, path_parameters, operation, draw, ctx, hooks, generation_mode, generation_config
93
+ )
94
+ headers_ = generate_parameter(
95
+ ParameterLocation.HEADER, headers, operation, draw, ctx, hooks, generation_mode, generation_config
96
+ )
97
+ cookies_ = generate_parameter(
98
+ ParameterLocation.COOKIE, cookies, operation, draw, ctx, hooks, generation_mode, generation_config
99
+ )
100
+ query_ = generate_parameter(
101
+ ParameterLocation.QUERY, query, operation, draw, ctx, hooks, generation_mode, generation_config
102
+ )
103
+
104
+ if body is NOT_SET:
105
+ if operation.body:
106
+ body_generator = generation_mode
107
+ if generation_mode.is_negative:
108
+ # Consider only schemas that are possible to negate
109
+ candidates = [item for item in operation.body.items if can_negate(item.optimized_schema)]
110
+ # Not possible to negate body, fallback to positive data generation
111
+ if not candidates:
112
+ candidates = operation.body.items
113
+ body_generator = GenerationMode.POSITIVE
114
+ else:
115
+ candidates = operation.body.items
116
+ parameter = draw(st.sampled_from(candidates))
117
+ strategy = _get_body_strategy(parameter, operation, generation_config, draw, body_generator)
118
+ strategy = apply_hooks(operation, ctx, hooks, strategy, ParameterLocation.BODY)
119
+ # Parameter may have a wildcard media type. In this case, choose any supported one
120
+ possible_media_types = sorted(
121
+ operation.schema.transport.get_matching_media_types(parameter.media_type), key=lambda x: x[0]
122
+ )
123
+ if not possible_media_types:
124
+ all_media_types = operation.get_request_payload_content_types()
125
+ if all(
126
+ operation.schema.transport.get_first_matching_media_type(media_type) is None
127
+ for media_type in all_media_types
128
+ ):
129
+ # None of media types defined for this operation are not supported
130
+ raise SerializationNotPossible.from_media_types(*all_media_types) from None
131
+ # Other media types are possible - avoid choosing this media type in the future
132
+ event_text = f"Can't serialize data to `{parameter.media_type}`."
133
+ note(f"{event_text} {SERIALIZERS_SUGGESTION_MESSAGE}")
134
+ event(event_text)
135
+ reject()
136
+ media_type, _ = draw(st.sampled_from(possible_media_types))
137
+ if media_type is not None and media_types.parse(media_type) == (
138
+ "application",
139
+ "x-www-form-urlencoded",
140
+ ):
141
+ if body_generator.is_negative:
142
+ # For negative strategies, unwrap GeneratedValue, apply transformation, then rewrap
143
+ strategy = strategy.map(
144
+ lambda x: GeneratedValue(prepare_urlencoded(x.value), x.meta)
145
+ if isinstance(x, GeneratedValue)
146
+ else prepare_urlencoded(x)
147
+ ).filter(lambda x: is_valid_urlencoded(x.value if isinstance(x, GeneratedValue) else x))
148
+ else:
149
+ strategy = strategy.map(prepare_urlencoded).filter(is_valid_urlencoded)
150
+ body_result = draw(strategy)
151
+ body_metadata = None
152
+ # Negative strategy returns GeneratedValue, positive returns just value
153
+ if isinstance(body_result, GeneratedValue):
154
+ body_metadata = body_result.meta
155
+ body_result = body_result.value
156
+ body_ = ValueContainer(value=body_result, location="body", generator=body_generator, meta=body_metadata)
149
157
  else:
150
- media_types = operation.get_request_payload_content_types() or ["application/json"]
151
- # Take the first available media type.
152
- # POSSIBLE IMPROVEMENT:
153
- # - Test examples for each available media type on Open API 2.0;
154
- # - On Open API 3.0, media types are explicit, and each example has it.
155
- # We can pass `OpenAPIBody.media_type` here from the examples handling code.
156
- media_type = media_types[0]
157
-
158
- if operation.schema.validate_schema and operation.method.upper() == "GET" and operation.body:
159
- raise InvalidSchema("Body parameters are defined for GET request.")
160
- return Case(
161
- operation=operation,
158
+ body_ = ValueContainer(value=body, location="body", generator=None, meta=None)
159
+ else:
160
+ # This explicit body payload comes for a media type that has a custom strategy registered
161
+ # Such strategies only support binary payloads, otherwise they can't be serialized
162
+ if not isinstance(body, bytes) and media_type and _find_media_type_strategy(media_type) is not None:
163
+ all_media_types = operation.get_request_payload_content_types()
164
+ raise SerializationNotPossible.from_media_types(*all_media_types)
165
+ body_ = ValueContainer(value=body, location="body", generator=None, meta=None)
166
+
167
+ # If we need to generate negative cases but no generated values were negated, then skip the whole test
168
+ if generation_mode.is_negative and not any_negated_values([query_, cookies_, headers_, path_parameters_, body_]):
169
+ if generation_config.modes == [GenerationMode.NEGATIVE]:
170
+ raise SkipTest("Impossible to generate negative test cases")
171
+ else:
172
+ reject()
173
+
174
+ # Extract mutation metadata from negated values and create phase-appropriate data
175
+ if generation_mode.is_negative:
176
+ negated_container = None
177
+ for container in [query_, cookies_, headers_, path_parameters_, body_]:
178
+ if container.generator == GenerationMode.NEGATIVE and container.meta is not None:
179
+ negated_container = container
180
+ break
181
+
182
+ if negated_container and negated_container.meta:
183
+ metadata = negated_container.meta
184
+ location_map = {
185
+ "query": ParameterLocation.QUERY,
186
+ "path_parameters": ParameterLocation.PATH,
187
+ "headers": ParameterLocation.HEADER,
188
+ "cookies": ParameterLocation.COOKIE,
189
+ "body": ParameterLocation.BODY,
190
+ }
191
+ parameter_location = location_map.get(negated_container.location)
192
+ _phase_data = {
193
+ TestPhase.EXAMPLES: ExamplesPhaseData(
194
+ description=metadata.description,
195
+ parameter=metadata.parameter,
196
+ parameter_location=parameter_location,
197
+ location=metadata.location,
198
+ ),
199
+ TestPhase.FUZZING: FuzzingPhaseData(
200
+ description=metadata.description,
201
+ parameter=metadata.parameter,
202
+ parameter_location=parameter_location,
203
+ location=metadata.location,
204
+ ),
205
+ TestPhase.STATEFUL: StatefulPhaseData(
206
+ description=metadata.description,
207
+ parameter=metadata.parameter,
208
+ parameter_location=parameter_location,
209
+ location=metadata.location,
210
+ ),
211
+ }[phase]
212
+ phase_data = cast(Union[ExamplesPhaseData, FuzzingPhaseData, StatefulPhaseData], _phase_data)
213
+ else:
214
+ _phase_data = {
215
+ TestPhase.EXAMPLES: ExamplesPhaseData(
216
+ description="Schema mutated",
217
+ parameter=None,
218
+ parameter_location=None,
219
+ location=None,
220
+ ),
221
+ TestPhase.FUZZING: FuzzingPhaseData(
222
+ description="Schema mutated",
223
+ parameter=None,
224
+ parameter_location=None,
225
+ location=None,
226
+ ),
227
+ TestPhase.STATEFUL: StatefulPhaseData(
228
+ description="Schema mutated",
229
+ parameter=None,
230
+ parameter_location=None,
231
+ location=None,
232
+ ),
233
+ }[phase]
234
+ phase_data = cast(Union[ExamplesPhaseData, FuzzingPhaseData, StatefulPhaseData], _phase_data)
235
+ else:
236
+ _phase_data = {
237
+ TestPhase.EXAMPLES: ExamplesPhaseData(
238
+ description="Positive test case",
239
+ parameter=None,
240
+ parameter_location=None,
241
+ location=None,
242
+ ),
243
+ TestPhase.FUZZING: FuzzingPhaseData(
244
+ description="Positive test case",
245
+ parameter=None,
246
+ parameter_location=None,
247
+ location=None,
248
+ ),
249
+ TestPhase.STATEFUL: StatefulPhaseData(
250
+ description="Positive test case",
251
+ parameter=None,
252
+ parameter_location=None,
253
+ location=None,
254
+ ),
255
+ }[phase]
256
+ phase_data = cast(Union[ExamplesPhaseData, FuzzingPhaseData, StatefulPhaseData], _phase_data)
257
+
258
+ instance = operation.Case(
162
259
  media_type=media_type,
163
- path_parameters=path_parameters_value,
164
- headers=CaseInsensitiveDict(headers_value) if headers_value is not None else headers_value,
165
- cookies=cookies_value,
166
- query=query_value,
167
- body=body,
168
- data_generation_method=data_generation_method,
260
+ path_parameters=path_parameters_.value or {},
261
+ headers=headers_.value or CaseInsensitiveDict(),
262
+ cookies=cookies_.value or {},
263
+ query=query_.value or {},
264
+ body=body_.value,
265
+ _meta=CaseMetadata(
266
+ generation=GenerationInfo(
267
+ time=time.monotonic() - start,
268
+ mode=generation_mode,
269
+ ),
270
+ phase=PhaseInfo(name=phase, data=phase_data),
271
+ components={
272
+ kind: ComponentInfo(mode=value.generator)
273
+ for kind, value in [
274
+ (ParameterLocation.QUERY, query_),
275
+ (ParameterLocation.PATH, path_parameters_),
276
+ (ParameterLocation.HEADER, headers_),
277
+ (ParameterLocation.COOKIE, cookies_),
278
+ (ParameterLocation.BODY, body_),
279
+ ]
280
+ if value.generator is not None
281
+ },
282
+ ),
169
283
  )
284
+ auth_context = auths.AuthContext(
285
+ operation=operation,
286
+ app=operation.app,
287
+ )
288
+ auths.set_on_case(instance, auth_context, auth_storage)
289
+ return instance
170
290
 
171
291
 
172
- YAML_PARSING_ISSUE_MESSAGE = (
173
- "The API schema contains non-string keys. "
174
- "If you store your schema in YAML, it is likely caused by unquoted keys parsed as "
175
- "non-strings. For example, `on` is parsed as boolean `true`, "
176
- "but `'on'` (with quotes) is a string `'on'`. See more information at https://noyaml.com/."
177
- )
292
+ OPTIONAL_BODY_RATE = 0.05
178
293
 
179
294
 
180
- @contextmanager
181
- def detect_invalid_schema(operation: APIOperation) -> Generator[None, None, None]:
182
- """Detect common issues with schemas."""
183
- try:
184
- yield
185
- except TypeError as exc:
186
- if is_yaml_parsing_issue(operation):
187
- raise InvalidSchema(YAML_PARSING_ISSUE_MESSAGE) from exc
188
- raise
189
-
295
+ def _find_media_type_strategy(content_type: str) -> st.SearchStrategy[bytes] | None:
296
+ """Find a registered strategy for a content type, supporting wildcard patterns."""
297
+ # Try exact match first
298
+ if content_type in MEDIA_TYPES:
299
+ return MEDIA_TYPES[content_type]
190
300
 
191
- def is_yaml_parsing_issue(operation: APIOperation) -> bool:
192
- """Detect whether the API operation has problems because of YAML syntax.
193
-
194
- For example, unquoted 'on' is parsed as `True`.
195
- """
196
301
  try:
197
- # Sorting keys involves their comparison, when there is a non-string value, it leads to a TypeError
198
- json.dumps(operation.schema.raw_schema, sort_keys=True)
199
- except TypeError:
200
- return True
201
- return False
302
+ main, sub = media_types.parse(content_type)
303
+ except MalformedMediaType:
304
+ return None
305
+
306
+ # Check registered media types for wildcard matches
307
+ for registered_type, strategy in MEDIA_TYPES.items():
308
+ try:
309
+ target_main, target_sub = media_types.parse(registered_type)
310
+ except MalformedMediaType:
311
+ continue
312
+ # Match if both main and sub types are compatible
313
+ # "*" in either the requested or registered type acts as a wildcard
314
+ main_match = main == "*" or target_main == "*" or main == target_main
315
+ sub_match = sub == "*" or target_sub == "*" or sub == target_sub
316
+ if main_match and sub_match:
317
+ return strategy
318
+
319
+ return None
320
+
321
+
322
+ def _build_form_strategy_with_encoding(
323
+ parameter: OpenApiBody,
324
+ operation: APIOperation,
325
+ generation_config: GenerationConfig,
326
+ generation_mode: GenerationMode,
327
+ ) -> st.SearchStrategy | None:
328
+ """Build a strategy for form bodies that have custom encoding contentType.
202
329
 
330
+ Supports wildcard media type matching (e.g., "image/*" matches "image/png").
203
331
 
204
- _BODY_STRATEGIES_CACHE: WeakKeyDictionary = WeakKeyDictionary()
332
+ Returns `None` if no custom encoding with registered strategies is found.
333
+ """
334
+ schema = parameter.optimized_schema
335
+ if not isinstance(schema, dict) or schema.get("type") != "object":
336
+ return None
337
+
338
+ properties = schema.get("properties", {})
339
+ if not properties:
340
+ return None
341
+
342
+ # Check which properties have custom content types with registered strategies
343
+ custom_property_strategies = {}
344
+ for property_name in properties:
345
+ content_type = parameter.get_property_content_type(property_name)
346
+
347
+ if content_type is not None and not isinstance(content_type, str):
348
+ # Happens in broken schemas
349
+ continue # type: ignore[unreachable]
350
+
351
+ if content_type:
352
+ # Handle multiple content types (e.g., "image/png, image/jpeg")
353
+ content_types = [ct.strip() for ct in content_type.split(",")]
354
+ strategies_for_types = []
355
+ for ct in content_types:
356
+ strategy = _find_media_type_strategy(ct)
357
+ if strategy is not None:
358
+ strategies_for_types.append(strategy)
359
+
360
+ if strategies_for_types:
361
+ custom_property_strategies[property_name] = st.one_of(*strategies_for_types)
362
+
363
+ if not custom_property_strategies:
364
+ return None
365
+
366
+ # Build strategies for properties
367
+ property_strategies = {}
368
+ for property_name, subschema in properties.items():
369
+ if property_name in custom_property_strategies:
370
+ property_strategies[property_name] = custom_property_strategies[property_name]
371
+ else:
372
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
373
+
374
+ assert isinstance(operation.schema, BaseOpenAPISchema)
375
+ strategy_factory = GENERATOR_MODE_TO_STRATEGY_FACTORY[generation_mode]
376
+ property_strategies[property_name] = strategy_factory(
377
+ subschema,
378
+ operation.label,
379
+ ParameterLocation.BODY,
380
+ parameter.media_type,
381
+ generation_config,
382
+ operation.schema.adapter.jsonschema_validator_cls,
383
+ )
384
+
385
+ # Build fixed dictionary strategy with optional properties
386
+ required = set(schema.get("required", []))
387
+ required_strategies = {k: v for k, v in property_strategies.items() if k in required}
388
+ optional_strategies = {k: st.just(NOT_SET) | v for k, v in property_strategies.items() if k not in required}
389
+
390
+ @st.composite # type: ignore[misc]
391
+ def build_body(draw: st.DrawFn) -> dict[str, Any]:
392
+ body: dict[str, Any] = {}
393
+ # Generate required properties
394
+ for key, strategy in required_strategies.items():
395
+ body[key] = draw(strategy)
396
+ # Generate optional properties, filtering out NOT_SET
397
+ for key, strategy in optional_strategies.items():
398
+ value = draw(strategy)
399
+ if value is not NOT_SET:
400
+ body[key] = value
401
+ return body
402
+
403
+ return build_body()
205
404
 
206
405
 
207
406
  def _get_body_strategy(
208
- parameter: OpenAPIBody,
209
- to_strategy: StrategyFactory,
407
+ parameter: OpenApiBody,
210
408
  operation: APIOperation,
409
+ generation_config: GenerationConfig,
410
+ draw: st.DrawFn,
411
+ generation_mode: GenerationMode,
211
412
  ) -> st.SearchStrategy:
212
- # The cache key relies on object ids, which means that the parameter should not be mutated
213
- # Note, the parent schema is not included as each parameter belong only to one schema
214
- if parameter in _BODY_STRATEGIES_CACHE and to_strategy in _BODY_STRATEGIES_CACHE[parameter]:
215
- return _BODY_STRATEGIES_CACHE[parameter][to_strategy]
216
- schema = parameter.as_json_schema()
217
- schema = operation.schema.prepare_schema(schema)
218
- strategy = to_strategy(schema, operation.verbose_name, "body", parameter.media_type)
219
- if not parameter.is_required:
413
+ # Check for custom encoding in form bodies (multipart/form-data or application/x-www-form-urlencoded)
414
+ if parameter.media_type in FORM_MEDIA_TYPES:
415
+ custom_strategy = _build_form_strategy_with_encoding(parameter, operation, generation_config, generation_mode)
416
+ if custom_strategy is not None:
417
+ return custom_strategy
418
+
419
+ # Check for custom media type strategy
420
+ custom_strategy = _find_media_type_strategy(parameter.media_type)
421
+ if custom_strategy is not None:
422
+ return custom_strategy
423
+
424
+ # Use the cached strategy from the parameter
425
+ strategy = parameter.get_strategy(operation, generation_config, generation_mode)
426
+
427
+ # It is likely will be rejected, hence choose it rarely
428
+ if (
429
+ not parameter.is_required
430
+ and draw(st.floats(min_value=0.0, max_value=1.0, allow_infinity=False, allow_nan=False, allow_subnormal=False))
431
+ < OPTIONAL_BODY_RATE
432
+ ):
220
433
  strategy |= st.just(NOT_SET)
221
- _BODY_STRATEGIES_CACHE.setdefault(parameter, {})[to_strategy] = strategy
222
434
  return strategy
223
435
 
224
436
 
225
437
  def get_parameters_value(
226
- value: Union[NotSet, Dict[str, Any]],
227
- location: str,
228
- draw: Callable,
438
+ value: dict[str, Any] | None,
439
+ location: ParameterLocation,
440
+ draw: st.DrawFn,
229
441
  operation: APIOperation,
230
- context: HookContext,
231
- hooks: Optional[HookDispatcher],
232
- to_strategy: StrategyFactory,
233
- ) -> Optional[Dict[str, Any]]:
442
+ ctx: HookContext,
443
+ hooks: HookDispatcher | None,
444
+ generation_mode: GenerationMode,
445
+ generation_config: GenerationConfig,
446
+ ) -> tuple[dict[str, Any] | None, Any]:
234
447
  """Get the final value for the specified location.
235
448
 
236
449
  If the value is not set, then generate it from the relevant strategy. Otherwise, check what is missing in it and
237
450
  generate those parts.
238
451
  """
239
- if isinstance(value, NotSet):
240
- strategy = get_parameters_strategy(operation, to_strategy, location)
241
- strategy = apply_hooks(operation, context, hooks, strategy, location)
242
- return draw(strategy)
243
- strategy = get_parameters_strategy(operation, to_strategy, location, exclude=value.keys())
244
- strategy = apply_hooks(operation, context, hooks, strategy, location)
245
- value = deepcopy(value)
246
- value.update(draw(strategy))
247
- return value
452
+ if value is None:
453
+ strategy = get_parameters_strategy(operation, generation_mode, location, generation_config)
454
+ strategy = apply_hooks(operation, ctx, hooks, strategy, location)
455
+ result = draw(strategy)
456
+ # Negative strategy returns GeneratedValue, positive returns just value
457
+ if isinstance(result, GeneratedValue):
458
+ return result.value, result.meta
459
+ return result, None
460
+ strategy = get_parameters_strategy(operation, generation_mode, location, generation_config, exclude=value.keys())
461
+ strategy = apply_hooks(operation, ctx, hooks, strategy, location)
462
+ new = draw(strategy)
463
+ metadata = None
464
+ # Negative strategy returns GeneratedValue, positive returns just value
465
+ if isinstance(new, GeneratedValue):
466
+ new, metadata = new.value, new.meta
467
+ if new is not None:
468
+ copied = dict(value)
469
+ copied.update(new)
470
+ return copied, metadata
471
+ return value, metadata
472
+
473
+
474
+ @dataclass
475
+ class ValueContainer:
476
+ """Container for a value generated by a data generator or explicitly provided."""
477
+
478
+ value: Any
479
+ location: str
480
+ generator: GenerationMode | None
481
+ meta: MutationMetadata | None
482
+
483
+ __slots__ = ("value", "location", "generator", "meta")
484
+
485
+ @property
486
+ def is_generated(self) -> bool:
487
+ """If value was generated."""
488
+ return self.generator is not None and (self.location == "body" or self.value is not None)
489
+
490
+
491
+ def any_negated_values(values: list[ValueContainer]) -> bool:
492
+ """Check if any generated values are negated."""
493
+ return any(value.generator == GenerationMode.NEGATIVE for value in values if value.is_generated)
494
+
495
+
496
+ def generate_parameter(
497
+ location: ParameterLocation,
498
+ explicit: dict[str, Any] | None,
499
+ operation: APIOperation,
500
+ draw: st.DrawFn,
501
+ ctx: HookContext,
502
+ hooks: HookDispatcher | None,
503
+ generator: GenerationMode,
504
+ generation_config: GenerationConfig,
505
+ ) -> ValueContainer:
506
+ """Generate a value for a parameter.
507
+
508
+ Fallback to positive data generator if parameter can not be negated.
509
+ """
510
+ if generator.is_negative and (
511
+ (location == ParameterLocation.PATH and not can_negate_path_parameters(operation))
512
+ or (location.is_in_header and not can_negate_headers(operation, location))
513
+ ):
514
+ # If we can't negate any parameter, generate positive ones
515
+ # If nothing else will be negated, then skip the test completely
516
+ generator = GenerationMode.POSITIVE
517
+ value, metadata = get_parameters_value(
518
+ explicit, location, draw, operation, ctx, hooks, generator, generation_config
519
+ )
520
+ used_generator: GenerationMode | None = generator
521
+ if value == explicit:
522
+ # When we pass `explicit`, then its parts are excluded from generation of the final value
523
+ # If the final value is the same, then other parameters were generated at all
524
+ if value is not None and location == ParameterLocation.PATH:
525
+ value = quote_all(value)
526
+ used_generator = None
527
+ return ValueContainer(value=value, location=location, generator=used_generator, meta=metadata)
528
+
529
+
530
+ def can_negate_path_parameters(operation: APIOperation) -> bool:
531
+ """Check if any path parameter can be negated."""
532
+ # No path parameters to negate
533
+ parameters = cast(OpenApiParameterSet, operation.path_parameters).schema["properties"]
534
+ if not parameters:
535
+ return True
536
+ return any(can_negate(parameter) for parameter in parameters.values())
248
537
 
249
538
 
250
- _PARAMETER_STRATEGIES_CACHE: WeakKeyDictionary = WeakKeyDictionary()
539
+ def can_negate_headers(operation: APIOperation, location: ParameterLocation) -> bool:
540
+ """Check if any header can be negated."""
541
+ container = getattr(operation, location.container_name)
542
+ # No headers to negate
543
+ headers = container.schema["properties"]
544
+ if not headers:
545
+ return True
546
+ return any(
547
+ header not in ({"type": "string"}, {"type": "string", "format": HEADER_FORMAT}) for header in headers.values()
548
+ )
251
549
 
252
550
 
253
551
  def get_parameters_strategy(
254
552
  operation: APIOperation,
255
- to_strategy: StrategyFactory,
256
- location: str,
553
+ generation_mode: GenerationMode,
554
+ location: ParameterLocation,
555
+ generation_config: GenerationConfig,
257
556
  exclude: Iterable[str] = (),
258
557
  ) -> st.SearchStrategy:
259
558
  """Create a new strategy for the case's component from the API operation parameters."""
260
- parameters = getattr(operation, LOCATION_TO_CONTAINER[location])
261
- if parameters:
262
- # The cache key relies on object ids, which means that the parameter should not be mutated
263
- nested_cache_key = (to_strategy, location, tuple(sorted(exclude)))
264
- if operation in _PARAMETER_STRATEGIES_CACHE and nested_cache_key in _PARAMETER_STRATEGIES_CACHE[operation]:
265
- return _PARAMETER_STRATEGIES_CACHE[operation][nested_cache_key]
266
- schema = parameters_to_json_schema(parameters)
267
- if not operation.schema.validate_schema and location == "path":
268
- # If schema validation is disabled, we try to generate data even if the parameter definition
269
- # contains errors.
270
- # In this case, we know that the `required` keyword should always be `True`.
271
- schema["required"] = list(schema["properties"])
272
- schema = operation.schema.prepare_schema(schema)
273
- for name in exclude:
274
- # Values from `exclude` are not necessarily valid for the schema - they come from user-defined examples
275
- # that may be invalid
276
- schema["properties"].pop(name, None)
277
- with suppress(ValueError):
278
- schema["required"].remove(name)
279
- strategy = to_strategy(schema, operation.verbose_name, location, None)
280
- serialize = operation.get_parameter_serializer(location)
281
- if serialize is not None:
282
- strategy = strategy.map(serialize)
283
- filter_func = {
284
- "path": is_valid_path,
285
- "header": is_valid_header,
286
- "cookie": is_valid_header,
287
- "query": is_valid_query,
288
- }[location]
289
- # Headers with special format do not need filtration
290
- if not (is_header_location(location) and _can_skip_header_filter(schema)):
291
- strategy = strategy.filter(filter_func)
292
- # Path & query parameters will be cast to string anyway, but having their JSON equivalents for
293
- # `True` / `False` / `None` improves chances of them passing validation in apps that expect boolean / null types
294
- # and not aware of Python-specific representation of those types
295
- map_func = {
296
- "path": compose(quote_all, jsonify_python_specific_types),
297
- "query": jsonify_python_specific_types,
298
- }.get(location)
299
- if map_func:
300
- strategy = strategy.map(map_func) # type: ignore
301
- _PARAMETER_STRATEGIES_CACHE.setdefault(operation, {})[nested_cache_key] = strategy
302
- return strategy
559
+ container = getattr(operation, location.container_name)
560
+ if container:
561
+ return container.get_strategy(operation, generation_config, generation_mode, exclude)
303
562
  # No parameters defined for this location
304
563
  return st.none()
305
564
 
306
565
 
307
- def _jsonify_leaves(value: Any) -> Any:
308
- if isinstance(value, dict):
309
- for key, sub_item in value.items():
310
- value[key] = _jsonify_leaves(sub_item)
311
- elif isinstance(value, list):
312
- value = [_jsonify_leaves(sub_item) for sub_item in value]
313
- elif isinstance(value, bool):
314
- return "true" if value else "false"
315
- elif value is None:
316
- return "null"
566
+ def jsonify_python_specific_types(value: dict[str, Any]) -> dict[str, Any]:
567
+ """Convert Python-specific values to their JSON equivalents."""
568
+ stack: list = [value]
569
+ while stack:
570
+ item = stack.pop()
571
+ if isinstance(item, dict):
572
+ for key, sub_item in item.items():
573
+ if isinstance(sub_item, bool):
574
+ item[key] = "true" if sub_item else "false"
575
+ elif sub_item is None:
576
+ item[key] = "null"
577
+ elif isinstance(sub_item, dict):
578
+ stack.append(sub_item)
579
+ elif isinstance(sub_item, list):
580
+ stack.extend(item)
581
+ elif isinstance(item, list):
582
+ stack.extend(item)
317
583
  return value
318
584
 
319
585
 
320
- def jsonify_python_specific_types(value: Dict[str, Any]) -> Dict[str, Any]:
321
- """Convert Python-specific values to their JSON equivalents."""
322
- return _jsonify_leaves(value)
586
+ def _build_custom_formats(generation_config: GenerationConfig) -> dict[str, st.SearchStrategy]:
587
+ custom_formats = {**get_default_format_strategies(), **STRING_FORMATS}
588
+ header_values_kwargs = {}
589
+ if generation_config.exclude_header_characters is not None:
590
+ header_values_kwargs["exclude_characters"] = generation_config.exclude_header_characters
591
+ if not generation_config.allow_x00:
592
+ header_values_kwargs["exclude_characters"] += "\x00"
593
+ elif not generation_config.allow_x00:
594
+ header_values_kwargs["exclude_characters"] = DEFAULT_HEADER_EXCLUDE_CHARACTERS + "\x00"
595
+ if generation_config.codec is not None:
596
+ header_values_kwargs["codec"] = generation_config.codec
597
+ if header_values_kwargs:
598
+ custom_formats[HEADER_FORMAT] = header_values(**header_values_kwargs)
599
+ return custom_formats
323
600
 
324
601
 
325
602
  def make_positive_strategy(
326
- schema: Dict[str, Any], operation_name: str, location: str, media_type: Optional[str]
603
+ schema: JsonSchema,
604
+ operation_name: str,
605
+ location: ParameterLocation,
606
+ media_type: str | None,
607
+ generation_config: GenerationConfig,
608
+ validator_cls: type[jsonschema.protocols.Validator],
327
609
  ) -> st.SearchStrategy:
328
610
  """Strategy for generating values that fit the schema."""
329
- if is_header_location(location):
330
- # We try to enforce the right header values via "format"
331
- # This way, only allowed values will be used during data generation, which reduces the amount of filtering later
332
- # If a property schema contains `pattern` it leads to heavy filtering and worse performance - therefore, skip it
333
- for sub_schema in schema.get("properties", {}).values():
334
- if list(sub_schema) == ["type"]:
335
- sub_schema.setdefault("format", HEADER_FORMAT)
336
- return from_schema(schema, custom_formats=STRING_FORMATS)
611
+ custom_formats = _build_custom_formats(generation_config)
612
+ return from_schema(
613
+ schema,
614
+ custom_formats=custom_formats,
615
+ allow_x00=generation_config.allow_x00,
616
+ codec=generation_config.codec,
617
+ )
337
618
 
338
619
 
339
- def _can_skip_header_filter(schema: Dict[str, Any]) -> bool:
620
+ def _can_skip_header_filter(schema: dict[str, Any]) -> bool:
340
621
  # All headers should contain HEADER_FORMAT in order to avoid header filter
341
622
  return all(sub_schema.get("format") == HEADER_FORMAT for sub_schema in schema.get("properties", {}).values())
342
623
 
343
624
 
344
625
  def make_negative_strategy(
345
- schema: Dict[str, Any], operation_name: str, location: str, media_type: Optional[str]
626
+ schema: JsonSchema,
627
+ operation_name: str,
628
+ location: ParameterLocation,
629
+ media_type: str | None,
630
+ generation_config: GenerationConfig,
631
+ validator_cls: type[jsonschema.protocols.Validator],
346
632
  ) -> st.SearchStrategy:
633
+ custom_formats = _build_custom_formats(generation_config)
347
634
  return negative_schema(
348
- schema, operation_name=operation_name, location=location, media_type=media_type, custom_formats=STRING_FORMATS
635
+ schema,
636
+ operation_name=operation_name,
637
+ location=location,
638
+ media_type=media_type,
639
+ custom_formats=custom_formats,
640
+ generation_config=generation_config,
641
+ validator_cls=validator_cls,
349
642
  )
350
643
 
351
644
 
352
- DATA_GENERATION_METHOD_TO_STRATEGY_FACTORY = {
353
- DataGenerationMethod.positive: make_positive_strategy,
354
- DataGenerationMethod.negative: make_negative_strategy,
645
+ GENERATOR_MODE_TO_STRATEGY_FACTORY = {
646
+ GenerationMode.POSITIVE: make_positive_strategy,
647
+ GenerationMode.NEGATIVE: make_negative_strategy,
355
648
  }
356
649
 
357
650
 
358
- def is_valid_path(parameters: Dict[str, Any]) -> bool:
359
- """Empty strings ("") are excluded from path by urllib3.
360
-
361
- A path containing to "/" or "%2F" will lead to ambiguous path resolution in
362
- many frameworks and libraries, such behaviour have been observed in both
363
- WSGI and ASGI applications.
364
-
365
- In this case one variable in the path template will be empty, which will lead to 404 in most of the cases.
366
- Because of it this case doesn't bring much value and might lead to false positives results of Schemathesis runs.
367
- """
368
- disallowed_values = (SLASH, "")
369
-
370
- return not any(
371
- (value in disallowed_values or is_illegal_surrogate(value) or isinstance(value, str) and SLASH in value)
372
- for value in parameters.values()
373
- )
374
-
375
-
376
- def quote_all(parameters: Dict[str, Any]) -> Dict[str, Any]:
651
+ def quote_all(parameters: dict[str, Any]) -> dict[str, Any]:
377
652
  """Apply URL quotation for all values in a dictionary."""
378
653
  # Even though, "." is an unreserved character, it has a special meaning in "." and ".." strings.
379
654
  # It will change the path:
@@ -381,37 +656,23 @@ def quote_all(parameters: Dict[str, Any]) -> Dict[str, Any]:
381
656
  # - http://localhost/foo/../ -> http://localhost/
382
657
  # Which is not desired as we need to test precisely the original path structure.
383
658
 
384
- def quote(value: str) -> str:
385
- quoted = quote_plus(value)
386
- if quoted == ".":
387
- return "%2E"
388
- if quoted == "..":
389
- return "%2E%2E"
390
- return quoted
391
-
392
- return {key: quote(value) if isinstance(value, str) else value for key, value in parameters.items()}
659
+ for key, value in parameters.items():
660
+ if isinstance(value, str):
661
+ if value == ".":
662
+ parameters[key] = "%2E"
663
+ elif value == "..":
664
+ parameters[key] = "%2E%2E"
665
+ else:
666
+ parameters[key] = quote_plus(value)
667
+ return parameters
393
668
 
394
669
 
395
670
  def apply_hooks(
396
671
  operation: APIOperation,
397
- context: HookContext,
398
- hooks: Optional[HookDispatcher],
672
+ ctx: HookContext,
673
+ hooks: HookDispatcher | None,
399
674
  strategy: st.SearchStrategy,
400
- location: str,
401
- ) -> st.SearchStrategy:
402
- """Apply all `before_generate_` hooks related to the given location."""
403
- strategy = _apply_hooks(context, GLOBAL_HOOK_DISPATCHER, strategy, location)
404
- strategy = _apply_hooks(context, operation.schema.hooks, strategy, location)
405
- if hooks is not None:
406
- strategy = _apply_hooks(context, hooks, strategy, location)
407
- return strategy
408
-
409
-
410
- def _apply_hooks(
411
- context: HookContext, hooks: HookDispatcher, strategy: st.SearchStrategy, location: str
675
+ location: ParameterLocation,
412
676
  ) -> st.SearchStrategy:
413
- """Apply all `before_generate_` hooks related to the given location & dispatcher."""
414
- container = LOCATION_TO_CONTAINER[location]
415
- for hook in hooks.get_all_by_name(f"before_generate_{container}"):
416
- strategy = hook(context, strategy)
417
- return strategy
677
+ """Apply all hooks related to the given location."""
678
+ return apply_to_all_dispatchers(operation, ctx, hooks, strategy, location.container_name)