schemathesis 3.15.4__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 (251) 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 -1219
  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 +682 -257
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +26 -2
  127. schemathesis/specs/graphql/scalars.py +77 -12
  128. schemathesis/specs/graphql/schemas.py +367 -148
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +555 -318
  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 +748 -82
  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 +93 -73
  154. schemathesis/specs/openapi/negative/mutations.py +294 -103
  155. schemathesis/specs/openapi/negative/utils.py +0 -9
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +647 -666
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +403 -68
  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.15.4.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.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -57
  189. schemathesis/_hypothesis.py +0 -123
  190. schemathesis/auth.py +0 -214
  191. schemathesis/cli/callbacks.py +0 -240
  192. schemathesis/cli/cassettes.py +0 -351
  193. schemathesis/cli/context.py +0 -38
  194. schemathesis/cli/debug.py +0 -21
  195. schemathesis/cli/handlers.py +0 -11
  196. schemathesis/cli/junitxml.py +0 -41
  197. schemathesis/cli/options.py +0 -70
  198. schemathesis/cli/output/__init__.py +0 -1
  199. schemathesis/cli/output/default.py +0 -521
  200. schemathesis/cli/output/short.py +0 -40
  201. schemathesis/constants.py +0 -88
  202. schemathesis/exceptions.py +0 -257
  203. schemathesis/extra/_aiohttp.py +0 -27
  204. schemathesis/extra/_flask.py +0 -10
  205. schemathesis/extra/_server.py +0 -16
  206. schemathesis/extra/pytest_plugin.py +0 -251
  207. schemathesis/failures.py +0 -145
  208. schemathesis/fixups/__init__.py +0 -29
  209. schemathesis/fixups/fast_api.py +0 -30
  210. schemathesis/graphql.py +0 -5
  211. schemathesis/internal.py +0 -6
  212. schemathesis/lazy.py +0 -301
  213. schemathesis/models.py +0 -1113
  214. schemathesis/parameters.py +0 -91
  215. schemathesis/runner/__init__.py +0 -470
  216. schemathesis/runner/events.py +0 -242
  217. schemathesis/runner/impl/__init__.py +0 -3
  218. schemathesis/runner/impl/core.py +0 -791
  219. schemathesis/runner/impl/solo.py +0 -85
  220. schemathesis/runner/impl/threadpool.py +0 -367
  221. schemathesis/runner/serialization.py +0 -206
  222. schemathesis/serializers.py +0 -253
  223. schemathesis/service/__init__.py +0 -18
  224. schemathesis/service/auth.py +0 -10
  225. schemathesis/service/client.py +0 -62
  226. schemathesis/service/constants.py +0 -25
  227. schemathesis/service/events.py +0 -39
  228. schemathesis/service/handler.py +0 -46
  229. schemathesis/service/hosts.py +0 -74
  230. schemathesis/service/metadata.py +0 -42
  231. schemathesis/service/models.py +0 -21
  232. schemathesis/service/serialization.py +0 -184
  233. schemathesis/service/worker.py +0 -39
  234. schemathesis/specs/graphql/loaders.py +0 -215
  235. schemathesis/specs/openapi/constants.py +0 -7
  236. schemathesis/specs/openapi/expressions/context.py +0 -12
  237. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  238. schemathesis/specs/openapi/filters.py +0 -44
  239. schemathesis/specs/openapi/links.py +0 -303
  240. schemathesis/specs/openapi/loaders.py +0 -453
  241. schemathesis/specs/openapi/parameters.py +0 -430
  242. schemathesis/specs/openapi/security.py +0 -129
  243. schemathesis/specs/openapi/validation.py +0 -24
  244. schemathesis/stateful.py +0 -358
  245. schemathesis/targets.py +0 -32
  246. schemathesis/types.py +0 -38
  247. schemathesis/utils.py +0 -475
  248. schemathesis-3.15.4.dist-info/METADATA +0 -202
  249. schemathesis-3.15.4.dist-info/RECORD +0 -99
  250. schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
  251. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
@@ -1,22 +1,41 @@
1
1
  """Schema mutations."""
2
+
3
+ from __future__ import annotations
4
+
2
5
  import enum
3
- from copy import deepcopy
6
+ from dataclasses import dataclass
4
7
  from functools import wraps
5
- from typing import Any, Callable, List, Optional, Sequence, Set, Tuple, TypeVar
8
+ from typing import Any, Callable, Optional, Sequence, TypeVar
6
9
 
7
- import attr
8
10
  from hypothesis import reject
9
11
  from hypothesis import strategies as st
10
12
  from hypothesis.strategies._internal.featureflags import FeatureStrategy
13
+ from hypothesis_jsonschema._canonicalise import canonicalish
14
+ from typing_extensions import TypeAlias
15
+
16
+ from schemathesis.core.jsonschema import BUNDLE_STORAGE_KEY, get_type
17
+ from schemathesis.core.jsonschema.types import JsonSchemaObject
18
+ from schemathesis.core.parameters import ParameterLocation
19
+ from schemathesis.core.transforms import deepclone
11
20
 
12
- from ..utils import is_header_location
13
21
  from .types import Draw, Schema
14
- from .utils import can_negate, get_type
22
+ from .utils import can_negate
15
23
 
16
24
  T = TypeVar("T")
17
25
 
18
26
 
19
- class MutationResult(enum.Enum):
27
+ @dataclass
28
+ class MutationMetadata:
29
+ """Metadata about a mutation that was applied."""
30
+
31
+ parameter: str | None
32
+ description: str
33
+ location: str | None
34
+
35
+ __slots__ = ("parameter", "description", "location")
36
+
37
+
38
+ class MutationResult(int, enum.Enum):
20
39
  """The result of applying some mutation to some schema.
21
40
 
22
41
  Failing to mutate something means that by applying some mutation, it is not possible to change
@@ -28,17 +47,17 @@ class MutationResult(enum.Enum):
28
47
  SUCCESS = 1
29
48
  FAILURE = 2
30
49
 
31
- def __ior__(self, other: Any) -> "MutationResult":
50
+ def __ior__(self, other: Any) -> MutationResult:
32
51
  return self | other
33
52
 
34
- def __or__(self, other: Any) -> "MutationResult":
53
+ def __or__(self, other: Any) -> MutationResult:
35
54
  # Syntactic sugar to simplify handling of multiple results
36
55
  if self == MutationResult.SUCCESS:
37
56
  return self
38
57
  return other
39
58
 
40
59
 
41
- Mutation = Callable[["MutationContext", Draw, Schema], MutationResult]
60
+ Mutation: TypeAlias = Callable[["MutationContext", Draw, Schema], tuple[MutationResult, Optional[MutationMetadata]]]
42
61
  ANY_TYPE_KEYS = {"$ref", "allOf", "anyOf", "const", "else", "enum", "if", "not", "oneOf", "then", "type"}
43
62
  TYPE_SPECIFIC_KEYS = {
44
63
  "number": ("multipleOf", "maximum", "exclusiveMaximum", "minimum", "exclusiveMinimum"),
@@ -58,31 +77,59 @@ TYPE_SPECIFIC_KEYS = {
58
77
  }
59
78
 
60
79
 
61
- @attr.s(slots=True)
80
+ @dataclass
62
81
  class MutationContext:
63
82
  """Meta information about the current mutation state."""
64
83
 
65
84
  # The original schema
66
- keywords: Schema = attr.ib() # only keywords
67
- non_keywords: Schema = attr.ib() # everything else
85
+ keywords: Schema # only keywords
86
+ non_keywords: Schema # everything else
68
87
  # Schema location within API operation (header, query, etc)
69
- location: str = attr.ib()
88
+ location: ParameterLocation
70
89
  # Payload media type, if available
71
- media_type: Optional[str] = attr.ib()
90
+ media_type: str | None
91
+ # Whether generating unexpected parameters is permitted
92
+ allow_extra_parameters: bool
93
+
94
+ __slots__ = ("keywords", "non_keywords", "location", "media_type", "allow_extra_parameters")
95
+
96
+ def __init__(
97
+ self,
98
+ *,
99
+ keywords: Schema,
100
+ non_keywords: Schema,
101
+ location: ParameterLocation,
102
+ media_type: str | None,
103
+ allow_extra_parameters: bool,
104
+ ) -> None:
105
+ self.keywords = keywords
106
+ self.non_keywords = non_keywords
107
+ self.location = location
108
+ self.media_type = media_type
109
+ self.allow_extra_parameters = allow_extra_parameters
72
110
 
73
111
  @property
74
- def is_header_location(self) -> bool:
75
- return is_header_location(self.location)
112
+ def is_path_location(self) -> bool:
113
+ return self.location == ParameterLocation.PATH
76
114
 
77
115
  @property
78
- def is_path_location(self) -> bool:
79
- return self.location == "path"
116
+ def is_query_location(self) -> bool:
117
+ return self.location == ParameterLocation.QUERY
118
+
119
+ def ensure_bundle(self, schema: Schema) -> None:
120
+ """Ensure schema has the bundle from context if needed.
80
121
 
81
- def mutate(self, draw: Draw) -> Schema:
122
+ This is necessary when working with nested schemas (e.g., property schemas)
123
+ that may contain bundled references but don't have the x-bundled key themselves.
124
+ """
125
+ if BUNDLE_STORAGE_KEY in self.non_keywords and BUNDLE_STORAGE_KEY not in schema:
126
+ schema[BUNDLE_STORAGE_KEY] = self.non_keywords[BUNDLE_STORAGE_KEY]
127
+
128
+ def mutate(self, draw: Draw) -> tuple[Schema, MutationMetadata | None]:
82
129
  # On the top level, Schemathesis creates "object" schemas for all parameter "in" values except "body", which is
83
- # taken as-is. Therefore we can only apply mutations that won't change the Open API semantics of the schema.
84
- mutations: List[Mutation]
85
- if self.location in ("header", "cookie", "query"):
130
+ # taken as-is. Therefore, we can only apply mutations that won't change the Open API semantics of the schema.
131
+ mutations: list[Mutation]
132
+ if self.location in (ParameterLocation.HEADER, ParameterLocation.COOKIE, ParameterLocation.QUERY):
86
133
  # These objects follow this pattern:
87
134
  # {
88
135
  # "properties": properties,
@@ -104,24 +151,34 @@ class MutationContext:
104
151
  # Body can be of any type and does not have any specific type semantic.
105
152
  mutations = draw(ordered(get_mutations(draw, self.keywords)))
106
153
  # Deep copy all keywords to avoid modifying the original schema
107
- new_schema = deepcopy(self.keywords)
108
- enabled_mutations = draw(st.shared(FeatureStrategy(), key="mutations")) # type: ignore
109
- result = MutationResult.FAILURE
154
+ new_schema = deepclone(self.keywords)
155
+ # Add x-bundled before mutations so they can resolve bundled references
156
+ if BUNDLE_STORAGE_KEY in self.non_keywords:
157
+ new_schema[BUNDLE_STORAGE_KEY] = self.non_keywords[BUNDLE_STORAGE_KEY]
158
+ enabled_mutations = draw(st.shared(FeatureStrategy(), key="mutations"))
159
+ # Always apply at least one mutation, otherwise everything is rejected, and we'd like to avoid it
160
+ # for performance reasons
161
+ always_applied_mutation = draw(st.sampled_from(mutations))
162
+ result, metadata = always_applied_mutation(self, draw, new_schema)
110
163
  for mutation in mutations:
111
- if enabled_mutations.is_enabled(mutation.__name__):
112
- result |= mutation(self, draw, new_schema)
164
+ if mutation is not always_applied_mutation and enabled_mutations.is_enabled(mutation.__name__):
165
+ mut_result, mut_metadata = mutation(self, draw, new_schema)
166
+ result |= mut_result
167
+ # Keep first successful mutation's metadata
168
+ if metadata is None and mut_metadata is not None:
169
+ metadata = mut_metadata
113
170
  if result == MutationResult.FAILURE:
114
171
  # If we failed to apply anything, then reject the whole case
115
- reject() # type: ignore
172
+ reject()
116
173
  new_schema.update(self.non_keywords)
117
- if self.is_header_location:
174
+ if self.location.is_in_header:
118
175
  # All headers should have names that can be sent over network
119
176
  new_schema["propertyNames"] = {"type": "string", "format": "_header_name"}
120
177
  for sub_schema in new_schema.get("properties", {}).values():
121
178
  sub_schema["type"] = "string"
122
179
  if len(sub_schema) == 1:
123
180
  sub_schema["format"] = "_header_value"
124
- if draw(st.booleans()):
181
+ if self.allow_extra_parameters and draw(st.booleans()):
125
182
  # In headers, `additionalProperties` are False by default, which means that Schemathesis won't generate
126
183
  # any headers that are not defined. This change adds the possibility of generating valid extra headers
127
184
  new_schema["additionalProperties"] = {"type": "string", "format": "_header_value"}
@@ -134,21 +191,20 @@ class MutationContext:
134
191
  and "minProperties" not in new_schema.get("not", {})
135
192
  ):
136
193
  new_schema.setdefault("minProperties", 1)
137
- return new_schema
194
+ return new_schema, metadata
138
195
 
139
196
 
140
197
  def for_types(*allowed_types: str) -> Callable[[Mutation], Mutation]:
141
198
  """Immediately return FAILURE for schemas with types not from ``allowed_types``."""
142
-
143
199
  _allowed_types = set(allowed_types)
144
200
 
145
201
  def wrapper(mutation: Mutation) -> Mutation:
146
202
  @wraps(mutation)
147
- def inner(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
203
+ def inner(ctx: MutationContext, draw: Draw, schema: Schema) -> tuple[MutationResult, MutationMetadata | None]:
148
204
  types = get_type(schema)
149
205
  if _allowed_types & set(types):
150
- return mutation(context, draw, schema)
151
- return MutationResult.FAILURE
206
+ return mutation(ctx, draw, schema)
207
+ return MutationResult.FAILURE, None
152
208
 
153
209
  return inner
154
210
 
@@ -156,7 +212,9 @@ def for_types(*allowed_types: str) -> Callable[[Mutation], Mutation]:
156
212
 
157
213
 
158
214
  @for_types("object")
159
- def remove_required_property(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
215
+ def remove_required_property(
216
+ ctx: MutationContext, draw: Draw, schema: Schema
217
+ ) -> tuple[MutationResult, MutationMetadata | None]:
160
218
  """Remove a required property.
161
219
 
162
220
  Effect: Some property won't be generated.
@@ -164,13 +222,13 @@ def remove_required_property(context: MutationContext, draw: Draw, schema: Schem
164
222
  required = schema.get("required")
165
223
  if not required:
166
224
  # No required properties - can't mutate
167
- return MutationResult.FAILURE
225
+ return MutationResult.FAILURE, None
168
226
  if len(required) == 1:
169
227
  property_name = draw(st.sampled_from(sorted(required)))
170
228
  else:
171
229
  candidate = draw(st.sampled_from(sorted(required)))
172
- enabled_properties = draw(st.shared(FeatureStrategy(), key="properties")) # type: ignore
173
- candidates = [candidate] + sorted([prop for prop in required if enabled_properties.is_enabled(prop)])
230
+ enabled_properties = draw(st.shared(FeatureStrategy(), key="properties"))
231
+ candidates = [candidate, *sorted([prop for prop in required if enabled_properties.is_enabled(prop)])]
174
232
  property_name = draw(st.sampled_from(candidates))
175
233
  required.remove(property_name)
176
234
  if not required:
@@ -186,45 +244,73 @@ def remove_required_property(context: MutationContext, draw: Draw, schema: Schem
186
244
  # This property still can be generated via `patternProperties`, but this implementation doesn't cover this case
187
245
  # Its probability is relatively low, and the complete solution compatible with Draft 4 will require extra complexity
188
246
  # The output filter covers cases like this
189
- return MutationResult.SUCCESS
247
+ metadata = MutationMetadata(
248
+ parameter=property_name,
249
+ description="Required property removed",
250
+ location=f"/properties/{property_name}",
251
+ )
252
+ return MutationResult.SUCCESS, metadata
190
253
 
191
254
 
192
- def change_type(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
255
+ def change_type(
256
+ ctx: MutationContext, draw: Draw, schema: JsonSchemaObject
257
+ ) -> tuple[MutationResult, MutationMetadata | None]:
193
258
  """Change type of values accepted by a schema."""
194
259
  if "type" not in schema:
195
260
  # The absence of this keyword means that the schema values can be of any type;
196
261
  # Therefore, we can't choose a different type
197
- return MutationResult.FAILURE
198
- if context.media_type == "application/x-www-form-urlencoded":
262
+ return MutationResult.FAILURE, None
263
+ if ctx.media_type == "application/x-www-form-urlencoded":
199
264
  # Form data should be an object, do not change it
200
- return MutationResult.FAILURE
201
- if context.is_header_location:
202
- return MutationResult.FAILURE
203
- candidates = _get_type_candidates(context, schema)
265
+ return MutationResult.FAILURE, None
266
+ # For headers, query and path parameters, if the current type is string, then it already
267
+ # includes all possible values as those parameters will be stringified before sending,
268
+ # therefore it can't be negated.
269
+ old_types = get_type(schema)
270
+ if "string" in old_types and (ctx.location.is_in_header or ctx.is_path_location or ctx.is_query_location):
271
+ return MutationResult.FAILURE, None
272
+ candidates = _get_type_candidates(ctx, schema)
204
273
  if not candidates:
205
274
  # Schema covers all possible types, not possible to choose something else
206
- return MutationResult.FAILURE
275
+ return MutationResult.FAILURE, None
207
276
  if len(candidates) == 1:
208
277
  new_type = candidates.pop()
209
278
  schema["type"] = new_type
279
+ _ensure_query_serializes_to_non_empty(ctx, schema)
280
+ prevent_unsatisfiable_schema(schema, new_type)
281
+ else:
282
+ # Choose one type that will be present in the final candidates list
283
+ candidate = draw(st.sampled_from(sorted(candidates)))
284
+ candidates.remove(candidate)
285
+ enabled_types = draw(st.shared(FeatureStrategy(), key="types"))
286
+ remaining_candidates = [
287
+ candidate,
288
+ *sorted([candidate for candidate in candidates if enabled_types.is_enabled(candidate)]),
289
+ ]
290
+ new_type = draw(st.sampled_from(remaining_candidates))
291
+ schema["type"] = new_type
292
+ _ensure_query_serializes_to_non_empty(ctx, schema)
210
293
  prevent_unsatisfiable_schema(schema, new_type)
211
- return MutationResult.SUCCESS
212
- # Choose one type that will be present in the final candidates list
213
- candidate = draw(st.sampled_from(sorted(candidates)))
214
- candidates.remove(candidate)
215
- enabled_types = draw(st.shared(FeatureStrategy(), key="types")) # type: ignore
216
- remaining_candidates = [candidate] + sorted(
217
- [candidate for candidate in candidates if enabled_types.is_enabled(candidate)]
294
+
295
+ old_type_str = " | ".join(sorted(old_types)) if len(old_types) > 1 else old_types[0]
296
+ metadata = MutationMetadata(
297
+ parameter=None,
298
+ description=f"Invalid type {new_type} (expected {old_type_str})",
299
+ location=None,
218
300
  )
219
- new_type = draw(st.sampled_from(remaining_candidates))
220
- schema["type"] = new_type
221
- prevent_unsatisfiable_schema(schema, new_type)
222
- return MutationResult.SUCCESS
301
+ return MutationResult.SUCCESS, metadata
302
+
303
+
304
+ def _ensure_query_serializes_to_non_empty(ctx: MutationContext, schema: Schema) -> None:
305
+ if ctx.is_query_location and schema.get("type") == "array":
306
+ # Query parameters with empty arrays or arrays of `None` or empty arrays / objects will not appear in the final URL
307
+ schema["minItems"] = schema.get("minItems") or 1
308
+ schema.setdefault("items", {}).update({"not": {"enum": [None, [], {}]}})
223
309
 
224
310
 
225
- def _get_type_candidates(context: MutationContext, schema: Schema) -> Set[str]:
311
+ def _get_type_candidates(ctx: MutationContext, schema: Schema) -> set[str]:
226
312
  types = set(get_type(schema))
227
- if context.is_path_location:
313
+ if ctx.is_path_location:
228
314
  candidates = {"string", "integer", "number", "boolean", "null"} - types
229
315
  else:
230
316
  candidates = {"string", "integer", "number", "object", "array", "boolean", "null"} - types
@@ -253,7 +339,9 @@ def drop_not_type_specific_keywords(schema: Schema, new_type: str) -> None:
253
339
 
254
340
 
255
341
  @for_types("object")
256
- def change_properties(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
342
+ def change_properties(
343
+ ctx: MutationContext, draw: Draw, schema: Schema
344
+ ) -> tuple[MutationResult, MutationMetadata | None]:
257
345
  """Mutate individual object schema properties.
258
346
 
259
347
  Effect: Some properties will not validate the original schema
@@ -261,12 +349,18 @@ def change_properties(context: MutationContext, draw: Draw, schema: Schema) -> M
261
349
  properties = sorted(schema.get("properties", {}).items())
262
350
  if not properties:
263
351
  # No properties to mutate
264
- return MutationResult.FAILURE
352
+ return MutationResult.FAILURE, None
265
353
  # Order properties randomly and iterate over them until at least one mutation is successfully applied to at least
266
354
  # one property
267
- ordered_properties = draw(ordered(properties, unique_by=lambda x: x[0]))
355
+ ordered_properties = [
356
+ (name, canonicalish(subschema) if isinstance(subschema, bool) else subschema)
357
+ for name, subschema in draw(ordered(properties, unique_by=lambda x: x[0]))
358
+ ]
359
+ nested_metadata = None
268
360
  for property_name, property_schema in ordered_properties:
269
- if apply_until_success(context, draw, property_schema) == MutationResult.SUCCESS:
361
+ ctx.ensure_bundle(property_schema)
362
+ result, nested_metadata = apply_until_success(ctx, draw, property_schema)
363
+ if result == MutationResult.SUCCESS:
270
364
  # It is still possible to generate "positive" cases, for example, when this property is optional.
271
365
  # They are filtered out on the upper level anyway, but to avoid performance penalty we adjust the schema
272
366
  # so the generated samples are less likely to be "positive"
@@ -280,31 +374,47 @@ def change_properties(context: MutationContext, draw: Draw, schema: Schema) -> M
280
374
  break
281
375
  else:
282
376
  # No successful mutations
283
- return MutationResult.FAILURE
284
- enabled_properties = draw(st.shared(FeatureStrategy(), key="properties")) # type: ignore
285
- enabled_mutations = draw(st.shared(FeatureStrategy(), key="mutations")) # type: ignore
377
+ return MutationResult.FAILURE, None
378
+ enabled_properties = draw(st.shared(FeatureStrategy(), key="properties"))
379
+ enabled_mutations = draw(st.shared(FeatureStrategy(), key="mutations"))
286
380
  for name, property_schema in properties:
287
381
  # Skip already mutated property
288
- if name == property_name: # pylint: disable=undefined-loop-variable
382
+ if name == property_name:
289
383
  # Pylint: `properties` variable has at least one element as it is checked at the beginning of the function
290
384
  # Then those properties are ordered and iterated over, therefore `property_name` is always defined
291
385
  continue
292
386
  if enabled_properties.is_enabled(name):
387
+ ctx.ensure_bundle(property_schema)
293
388
  for mutation in get_mutations(draw, property_schema):
294
389
  if enabled_mutations.is_enabled(mutation.__name__):
295
- mutation(context, draw, property_schema)
296
- return MutationResult.SUCCESS
390
+ mutation(ctx, draw, property_schema)
391
+
392
+ # Use nested metadata description if available, otherwise use generic description
393
+ if nested_metadata and nested_metadata.description:
394
+ description = nested_metadata.description
395
+ else:
396
+ description = "Property constraint violated"
397
+
398
+ metadata = MutationMetadata(
399
+ parameter=property_name,
400
+ description=description,
401
+ location=f"/properties/{property_name}",
402
+ )
403
+ return MutationResult.SUCCESS, metadata
297
404
 
298
405
 
299
- def apply_until_success(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
406
+ def apply_until_success(
407
+ ctx: MutationContext, draw: Draw, schema: Schema
408
+ ) -> tuple[MutationResult, MutationMetadata | None]:
300
409
  for mutation in get_mutations(draw, schema):
301
- if mutation(context, draw, schema) == MutationResult.SUCCESS:
302
- return MutationResult.SUCCESS
303
- return MutationResult.FAILURE
410
+ result, metadata = mutation(ctx, draw, schema)
411
+ if result == MutationResult.SUCCESS:
412
+ return MutationResult.SUCCESS, metadata
413
+ return MutationResult.FAILURE, None
304
414
 
305
415
 
306
416
  @for_types("array")
307
- def change_items(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
417
+ def change_items(ctx: MutationContext, draw: Draw, schema: Schema) -> tuple[MutationResult, MutationMetadata | None]:
308
418
  """Mutate individual array items.
309
419
 
310
420
  Effect: Some items will not validate the original schema
@@ -312,92 +422,173 @@ def change_items(context: MutationContext, draw: Draw, schema: Schema) -> Mutati
312
422
  items = schema.get("items", {})
313
423
  if not items:
314
424
  # No items to mutate
315
- return MutationResult.FAILURE
425
+ return MutationResult.FAILURE, None
426
+ # For query/path/header/cookie, string items cannot be meaningfully mutated
427
+ # because all types serialize to strings anyway
428
+ if ctx.location.is_in_header or ctx.is_path_location or ctx.is_query_location:
429
+ items = schema.get("items", {})
430
+ if isinstance(items, dict):
431
+ items_types = get_type(items)
432
+ if "string" in items_types:
433
+ return MutationResult.FAILURE, None
316
434
  if isinstance(items, dict):
317
- return _change_items_object(context, draw, schema, items)
435
+ return _change_items_object(ctx, draw, schema, items)
318
436
  if isinstance(items, list):
319
- return _change_items_array(context, draw, schema, items)
320
- return MutationResult.FAILURE
437
+ return _change_items_array(ctx, draw, schema, items)
438
+ return MutationResult.FAILURE, None
321
439
 
322
440
 
323
- def _change_items_object(context: MutationContext, draw: Draw, schema: Schema, items: Schema) -> MutationResult:
441
+ def _change_items_object(
442
+ ctx: MutationContext, draw: Draw, schema: Schema, items: Schema
443
+ ) -> tuple[MutationResult, MutationMetadata | None]:
444
+ ctx.ensure_bundle(items)
324
445
  result = MutationResult.FAILURE
446
+ metadata = None
325
447
  for mutation in get_mutations(draw, items):
326
- result |= mutation(context, draw, items)
448
+ mut_result, mut_metadata = mutation(ctx, draw, items)
449
+ result |= mut_result
450
+ if metadata is None and mut_metadata is not None:
451
+ metadata = mut_metadata
327
452
  if result == MutationResult.FAILURE:
328
- return MutationResult.FAILURE
453
+ return MutationResult.FAILURE, None
329
454
  min_items = schema.get("minItems", 0)
330
455
  schema["minItems"] = max(min_items, 1)
331
- return MutationResult.SUCCESS
456
+ # Use nested metadata description if available, update location to show it's in array items
457
+ if metadata:
458
+ metadata = MutationMetadata(
459
+ parameter=None,
460
+ description=f"Array item: {metadata.description}",
461
+ location="/items",
462
+ )
463
+ return MutationResult.SUCCESS, metadata
332
464
 
333
465
 
334
- def _change_items_array(context: MutationContext, draw: Draw, schema: Schema, items: List) -> MutationResult:
466
+ def _change_items_array(
467
+ ctx: MutationContext, draw: Draw, schema: Schema, items: list
468
+ ) -> tuple[MutationResult, MutationMetadata | None]:
335
469
  latest_success_index = None
470
+ metadata = None
336
471
  for idx, item in enumerate(items):
472
+ ctx.ensure_bundle(item)
337
473
  result = MutationResult.FAILURE
338
474
  for mutation in get_mutations(draw, item):
339
- result |= mutation(context, draw, item)
475
+ mut_result, mut_metadata = mutation(ctx, draw, item)
476
+ result |= mut_result
477
+ if metadata is None and mut_metadata is not None:
478
+ metadata = mut_metadata
340
479
  if result == MutationResult.SUCCESS:
341
480
  latest_success_index = idx
342
481
  if latest_success_index is None:
343
- return MutationResult.FAILURE
482
+ return MutationResult.FAILURE, None
344
483
  min_items = schema.get("minItems", 0)
345
484
  schema["minItems"] = max(min_items, latest_success_index + 1)
346
- return MutationResult.SUCCESS
485
+ # Use nested metadata description if available, update location to show specific array index
486
+ if metadata:
487
+ metadata = MutationMetadata(
488
+ parameter=None,
489
+ description=f"Array item at index {latest_success_index}: {metadata.description}",
490
+ location=f"/items/{latest_success_index}",
491
+ )
492
+ return MutationResult.SUCCESS, metadata
347
493
 
348
494
 
349
- def negate_constraints(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
495
+ def negate_constraints(
496
+ ctx: MutationContext, draw: Draw, schema: Schema
497
+ ) -> tuple[MutationResult, MutationMetadata | None]:
350
498
  """Negate schema constrains while keeping the original type."""
499
+ ctx.ensure_bundle(schema)
351
500
  if not can_negate(schema):
352
- return MutationResult.FAILURE
501
+ return MutationResult.FAILURE, None
353
502
  copied = schema.copy()
503
+ # Preserve x-bundled before clearing
504
+ bundled = schema.get(BUNDLE_STORAGE_KEY)
354
505
  schema.clear()
506
+ if bundled is not None:
507
+ schema[BUNDLE_STORAGE_KEY] = bundled
355
508
  is_negated = False
509
+ negated_keys = []
356
510
 
357
- def is_mutation_candidate(k: str) -> bool:
511
+ def is_mutation_candidate(k: str, v: Any) -> bool:
358
512
  # Should we negate this key?
513
+ if k == "required":
514
+ return v != []
515
+ if k in ("example", "examples"):
516
+ return False
517
+ if ctx.is_path_location and k == "minLength" and v == 1:
518
+ # Empty path parameter will be filtered out
519
+ return False
520
+ if (
521
+ not ctx.allow_extra_parameters
522
+ and k == "additionalProperties"
523
+ and ctx.location in (ParameterLocation.QUERY, ParameterLocation.HEADER, ParameterLocation.COOKIE)
524
+ ):
525
+ return False
359
526
  return not (
360
527
  k in ("type", "properties", "items", "minItems")
361
- or (k == "additionalProperties" and context.is_header_location)
528
+ or (k == "additionalProperties" and ctx.location.is_in_header)
362
529
  )
363
530
 
364
- enabled_keywords = draw(st.shared(FeatureStrategy(), key="keywords")) # type: ignore
531
+ enabled_keywords = draw(st.shared(FeatureStrategy(), key="keywords"))
365
532
  candidates = []
366
- mutation_candidates = [key for key in copied if is_mutation_candidate(key)]
533
+ mutation_candidates = [key for key, value in copied.items() if is_mutation_candidate(key, value)]
367
534
  if mutation_candidates:
368
535
  # There should be at least one mutated keyword
369
- candidate = draw(st.sampled_from([key for key in copied if is_mutation_candidate(key)]))
536
+ candidate = draw(st.sampled_from([key for key, value in copied.items() if is_mutation_candidate(key, value)]))
370
537
  candidates.append(candidate)
371
538
  # If the chosen candidate has dependency, then the dependency should also be present in the final schema
372
539
  if candidate in DEPENDENCIES:
373
540
  candidates.append(DEPENDENCIES[candidate])
374
541
  for key, value in copied.items():
375
- if is_mutation_candidate(key):
542
+ if is_mutation_candidate(key, value):
376
543
  if key in candidates or enabled_keywords.is_enabled(key):
377
544
  is_negated = True
545
+ negated_keys.append(key)
378
546
  negated = schema.setdefault("not", {})
379
547
  negated[key] = value
380
548
  if key in DEPENDENCIES:
381
549
  # If this keyword has a dependency, then it should be also negated
382
550
  dependency = DEPENDENCIES[key]
383
- if dependency not in negated:
384
- negated[dependency] = copied[dependency] # Assuming the schema is valid
551
+ if dependency not in negated and dependency in copied:
552
+ negated[dependency] = copied[dependency]
385
553
  else:
386
554
  schema[key] = value
387
555
  if is_negated:
388
- return MutationResult.SUCCESS
389
- return MutationResult.FAILURE
556
+ # Build concise description from negated constraints
557
+ descriptions = []
558
+ for key in negated_keys:
559
+ value = copied[key]
560
+ # Special case: format required properties list nicely with quoted names
561
+ if key == "required" and isinstance(value, list) and len(value) <= 3:
562
+ props = ", ".join(f"`{prop}`" for prop in value)
563
+ descriptions.append(f"`{key}` ({props})")
564
+ else:
565
+ # Default: show `key` (value) for all constraints
566
+ descriptions.append(f"`{key}` ({value})")
567
+
568
+ constraint_desc = ", ".join(descriptions)
569
+ metadata = MutationMetadata(
570
+ parameter=None,
571
+ description=f"Violates {constraint_desc}",
572
+ location=None,
573
+ )
574
+ return MutationResult.SUCCESS, metadata
575
+ return MutationResult.FAILURE, None
390
576
 
391
577
 
392
578
  DEPENDENCIES = {"exclusiveMaximum": "maximum", "exclusiveMinimum": "minimum"}
393
579
 
394
580
 
395
- def get_mutations(draw: Draw, schema: Schema) -> Tuple[Mutation, ...]:
581
+ def get_mutations(draw: Draw, schema: JsonSchemaObject) -> tuple[Mutation, ...]:
396
582
  """Get mutations possible for a schema."""
397
583
  types = get_type(schema)
398
584
  # On the top-level of Open API schemas, types are always strings, but inside "schema" objects, they are the same as
399
585
  # in JSON Schema, where it could be either a string or an array of strings.
400
- options: List[Mutation] = [negate_constraints, change_type]
586
+ options: list[Mutation]
587
+ if list(schema) == ["type"]:
588
+ # When there is only `type` in schema then `negate_constraints` is not applicable
589
+ options = [change_type]
590
+ else:
591
+ options = [negate_constraints, change_type]
401
592
  if "object" in types:
402
593
  options.extend([change_properties, remove_required_property])
403
594
  elif "array" in types:
@@ -409,7 +600,7 @@ def ident(x: T) -> T:
409
600
  return x
410
601
 
411
602
 
412
- def ordered(items: Sequence[T], unique_by: Callable[[T], Any] = ident) -> st.SearchStrategy[List[T]]:
603
+ def ordered(items: Sequence[T], unique_by: Callable[[T], Any] = ident) -> st.SearchStrategy[list[T]]:
413
604
  """Returns a strategy that generates randomly ordered lists of T.
414
605
 
415
606
  NOTE. Items should be unique.
@@ -1,16 +1,7 @@
1
- from typing import List
2
-
3
1
  from hypothesis_jsonschema._canonicalise import canonicalish
4
2
 
5
3
  from .types import Schema
6
4
 
7
5
 
8
- def get_type(schema: Schema) -> List[str]:
9
- type_ = schema.get("type", ["null", "boolean", "integer", "number", "string", "array", "object"])
10
- if isinstance(type_, str):
11
- return [type_]
12
- return type_
13
-
14
-
15
6
  def can_negate(schema: Schema) -> bool:
16
7
  return canonicalish(schema) != {}