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,23 +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
11
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
12
20
 
13
- from ..utils import is_header_location
14
21
  from .types import Draw, Schema
15
- from .utils import get_type
22
+ from .utils import can_negate
16
23
 
17
24
  T = TypeVar("T")
18
25
 
19
26
 
20
- 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):
21
39
  """The result of applying some mutation to some schema.
22
40
 
23
41
  Failing to mutate something means that by applying some mutation, it is not possible to change
@@ -29,17 +47,17 @@ class MutationResult(enum.Enum):
29
47
  SUCCESS = 1
30
48
  FAILURE = 2
31
49
 
32
- def __ior__(self, other: Any) -> "MutationResult":
50
+ def __ior__(self, other: Any) -> MutationResult:
33
51
  return self | other
34
52
 
35
- def __or__(self, other: Any) -> "MutationResult":
53
+ def __or__(self, other: Any) -> MutationResult:
36
54
  # Syntactic sugar to simplify handling of multiple results
37
55
  if self == MutationResult.SUCCESS:
38
56
  return self
39
57
  return other
40
58
 
41
59
 
42
- Mutation = Callable[["MutationContext", Draw, Schema], MutationResult]
60
+ Mutation: TypeAlias = Callable[["MutationContext", Draw, Schema], tuple[MutationResult, Optional[MutationMetadata]]]
43
61
  ANY_TYPE_KEYS = {"$ref", "allOf", "anyOf", "const", "else", "enum", "if", "not", "oneOf", "then", "type"}
44
62
  TYPE_SPECIFIC_KEYS = {
45
63
  "number": ("multipleOf", "maximum", "exclusiveMaximum", "minimum", "exclusiveMinimum"),
@@ -59,31 +77,59 @@ TYPE_SPECIFIC_KEYS = {
59
77
  }
60
78
 
61
79
 
62
- @attr.s(slots=True)
80
+ @dataclass
63
81
  class MutationContext:
64
82
  """Meta information about the current mutation state."""
65
83
 
66
84
  # The original schema
67
- keywords: Schema = attr.ib() # only keywords
68
- non_keywords: Schema = attr.ib() # everything else
85
+ keywords: Schema # only keywords
86
+ non_keywords: Schema # everything else
69
87
  # Schema location within API operation (header, query, etc)
70
- location: str = attr.ib()
88
+ location: ParameterLocation
71
89
  # Payload media type, if available
72
- 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
73
110
 
74
111
  @property
75
- def is_header_location(self) -> bool:
76
- return is_header_location(self.location)
112
+ def is_path_location(self) -> bool:
113
+ return self.location == ParameterLocation.PATH
77
114
 
78
115
  @property
79
- def is_path_location(self) -> bool:
80
- 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.
81
121
 
82
- 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]:
83
129
  # On the top level, Schemathesis creates "object" schemas for all parameter "in" values except "body", which is
84
- # taken as-is. Therefore we can only apply mutations that won't change the Open API semantics of the schema.
85
- mutations: List[Mutation]
86
- 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):
87
133
  # These objects follow this pattern:
88
134
  # {
89
135
  # "properties": properties,
@@ -105,24 +151,34 @@ class MutationContext:
105
151
  # Body can be of any type and does not have any specific type semantic.
106
152
  mutations = draw(ordered(get_mutations(draw, self.keywords)))
107
153
  # Deep copy all keywords to avoid modifying the original schema
108
- new_schema = deepcopy(self.keywords)
109
- enabled_mutations = draw(st.shared(FeatureStrategy(), key="mutations")) # type: ignore
110
- 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)
111
163
  for mutation in mutations:
112
- if enabled_mutations.is_enabled(mutation.__name__):
113
- 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
114
170
  if result == MutationResult.FAILURE:
115
171
  # If we failed to apply anything, then reject the whole case
116
- reject() # type: ignore
172
+ reject()
117
173
  new_schema.update(self.non_keywords)
118
- if self.is_header_location:
174
+ if self.location.is_in_header:
119
175
  # All headers should have names that can be sent over network
120
176
  new_schema["propertyNames"] = {"type": "string", "format": "_header_name"}
121
177
  for sub_schema in new_schema.get("properties", {}).values():
122
178
  sub_schema["type"] = "string"
123
179
  if len(sub_schema) == 1:
124
180
  sub_schema["format"] = "_header_value"
125
- if draw(st.booleans()):
181
+ if self.allow_extra_parameters and draw(st.booleans()):
126
182
  # In headers, `additionalProperties` are False by default, which means that Schemathesis won't generate
127
183
  # any headers that are not defined. This change adds the possibility of generating valid extra headers
128
184
  new_schema["additionalProperties"] = {"type": "string", "format": "_header_value"}
@@ -135,21 +191,20 @@ class MutationContext:
135
191
  and "minProperties" not in new_schema.get("not", {})
136
192
  ):
137
193
  new_schema.setdefault("minProperties", 1)
138
- return new_schema
194
+ return new_schema, metadata
139
195
 
140
196
 
141
197
  def for_types(*allowed_types: str) -> Callable[[Mutation], Mutation]:
142
198
  """Immediately return FAILURE for schemas with types not from ``allowed_types``."""
143
-
144
199
  _allowed_types = set(allowed_types)
145
200
 
146
201
  def wrapper(mutation: Mutation) -> Mutation:
147
202
  @wraps(mutation)
148
- def inner(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
203
+ def inner(ctx: MutationContext, draw: Draw, schema: Schema) -> tuple[MutationResult, MutationMetadata | None]:
149
204
  types = get_type(schema)
150
205
  if _allowed_types & set(types):
151
- return mutation(context, draw, schema)
152
- return MutationResult.FAILURE
206
+ return mutation(ctx, draw, schema)
207
+ return MutationResult.FAILURE, None
153
208
 
154
209
  return inner
155
210
 
@@ -157,7 +212,9 @@ def for_types(*allowed_types: str) -> Callable[[Mutation], Mutation]:
157
212
 
158
213
 
159
214
  @for_types("object")
160
- 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]:
161
218
  """Remove a required property.
162
219
 
163
220
  Effect: Some property won't be generated.
@@ -165,13 +222,13 @@ def remove_required_property(context: MutationContext, draw: Draw, schema: Schem
165
222
  required = schema.get("required")
166
223
  if not required:
167
224
  # No required properties - can't mutate
168
- return MutationResult.FAILURE
225
+ return MutationResult.FAILURE, None
169
226
  if len(required) == 1:
170
227
  property_name = draw(st.sampled_from(sorted(required)))
171
228
  else:
172
229
  candidate = draw(st.sampled_from(sorted(required)))
173
- enabled_properties = draw(st.shared(FeatureStrategy(), key="properties")) # type: ignore
174
- 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)])]
175
232
  property_name = draw(st.sampled_from(candidates))
176
233
  required.remove(property_name)
177
234
  if not required:
@@ -187,45 +244,73 @@ def remove_required_property(context: MutationContext, draw: Draw, schema: Schem
187
244
  # This property still can be generated via `patternProperties`, but this implementation doesn't cover this case
188
245
  # Its probability is relatively low, and the complete solution compatible with Draft 4 will require extra complexity
189
246
  # The output filter covers cases like this
190
- 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
191
253
 
192
254
 
193
- 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]:
194
258
  """Change type of values accepted by a schema."""
195
259
  if "type" not in schema:
196
260
  # The absence of this keyword means that the schema values can be of any type;
197
261
  # Therefore, we can't choose a different type
198
- return MutationResult.FAILURE
199
- if context.media_type == "application/x-www-form-urlencoded":
262
+ return MutationResult.FAILURE, None
263
+ if ctx.media_type == "application/x-www-form-urlencoded":
200
264
  # Form data should be an object, do not change it
201
- return MutationResult.FAILURE
202
- if context.is_header_location:
203
- return MutationResult.FAILURE
204
- 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)
205
273
  if not candidates:
206
274
  # Schema covers all possible types, not possible to choose something else
207
- return MutationResult.FAILURE
275
+ return MutationResult.FAILURE, None
208
276
  if len(candidates) == 1:
209
277
  new_type = candidates.pop()
210
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)
211
293
  prevent_unsatisfiable_schema(schema, new_type)
212
- return MutationResult.SUCCESS
213
- # Choose one type that will be present in the final candidates list
214
- candidate = draw(st.sampled_from(sorted(candidates)))
215
- candidates.remove(candidate)
216
- enabled_types = draw(st.shared(FeatureStrategy(), key="types")) # type: ignore
217
- remaining_candidates = [candidate] + sorted(
218
- [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,
219
300
  )
220
- new_type = draw(st.sampled_from(remaining_candidates))
221
- schema["type"] = new_type
222
- prevent_unsatisfiable_schema(schema, new_type)
223
- 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, [], {}]}})
224
309
 
225
310
 
226
- def _get_type_candidates(context: MutationContext, schema: Schema) -> Set[str]:
311
+ def _get_type_candidates(ctx: MutationContext, schema: Schema) -> set[str]:
227
312
  types = set(get_type(schema))
228
- if context.is_path_location:
313
+ if ctx.is_path_location:
229
314
  candidates = {"string", "integer", "number", "boolean", "null"} - types
230
315
  else:
231
316
  candidates = {"string", "integer", "number", "object", "array", "boolean", "null"} - types
@@ -254,7 +339,9 @@ def drop_not_type_specific_keywords(schema: Schema, new_type: str) -> None:
254
339
 
255
340
 
256
341
  @for_types("object")
257
- 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]:
258
345
  """Mutate individual object schema properties.
259
346
 
260
347
  Effect: Some properties will not validate the original schema
@@ -262,12 +349,18 @@ def change_properties(context: MutationContext, draw: Draw, schema: Schema) -> M
262
349
  properties = sorted(schema.get("properties", {}).items())
263
350
  if not properties:
264
351
  # No properties to mutate
265
- return MutationResult.FAILURE
352
+ return MutationResult.FAILURE, None
266
353
  # Order properties randomly and iterate over them until at least one mutation is successfully applied to at least
267
354
  # one property
268
- 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
269
360
  for property_name, property_schema in ordered_properties:
270
- 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:
271
364
  # It is still possible to generate "positive" cases, for example, when this property is optional.
272
365
  # They are filtered out on the upper level anyway, but to avoid performance penalty we adjust the schema
273
366
  # so the generated samples are less likely to be "positive"
@@ -281,31 +374,47 @@ def change_properties(context: MutationContext, draw: Draw, schema: Schema) -> M
281
374
  break
282
375
  else:
283
376
  # No successful mutations
284
- return MutationResult.FAILURE
285
- enabled_properties = draw(st.shared(FeatureStrategy(), key="properties")) # type: ignore
286
- 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"))
287
380
  for name, property_schema in properties:
288
381
  # Skip already mutated property
289
- if name == property_name: # pylint: disable=undefined-loop-variable
382
+ if name == property_name:
290
383
  # Pylint: `properties` variable has at least one element as it is checked at the beginning of the function
291
384
  # Then those properties are ordered and iterated over, therefore `property_name` is always defined
292
385
  continue
293
386
  if enabled_properties.is_enabled(name):
387
+ ctx.ensure_bundle(property_schema)
294
388
  for mutation in get_mutations(draw, property_schema):
295
389
  if enabled_mutations.is_enabled(mutation.__name__):
296
- mutation(context, draw, property_schema)
297
- 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
298
404
 
299
405
 
300
- 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]:
301
409
  for mutation in get_mutations(draw, schema):
302
- if mutation(context, draw, schema) == MutationResult.SUCCESS:
303
- return MutationResult.SUCCESS
304
- 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
305
414
 
306
415
 
307
416
  @for_types("array")
308
- def change_items(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
417
+ def change_items(ctx: MutationContext, draw: Draw, schema: Schema) -> tuple[MutationResult, MutationMetadata | None]:
309
418
  """Mutate individual array items.
310
419
 
311
420
  Effect: Some items will not validate the original schema
@@ -313,92 +422,173 @@ def change_items(context: MutationContext, draw: Draw, schema: Schema) -> Mutati
313
422
  items = schema.get("items", {})
314
423
  if not items:
315
424
  # No items to mutate
316
- 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
317
434
  if isinstance(items, dict):
318
- return _change_items_object(context, draw, schema, items)
435
+ return _change_items_object(ctx, draw, schema, items)
319
436
  if isinstance(items, list):
320
- return _change_items_array(context, draw, schema, items)
321
- return MutationResult.FAILURE
437
+ return _change_items_array(ctx, draw, schema, items)
438
+ return MutationResult.FAILURE, None
322
439
 
323
440
 
324
- 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)
325
445
  result = MutationResult.FAILURE
446
+ metadata = None
326
447
  for mutation in get_mutations(draw, items):
327
- 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
328
452
  if result == MutationResult.FAILURE:
329
- return MutationResult.FAILURE
453
+ return MutationResult.FAILURE, None
330
454
  min_items = schema.get("minItems", 0)
331
455
  schema["minItems"] = max(min_items, 1)
332
- 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
333
464
 
334
465
 
335
- 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]:
336
469
  latest_success_index = None
470
+ metadata = None
337
471
  for idx, item in enumerate(items):
472
+ ctx.ensure_bundle(item)
338
473
  result = MutationResult.FAILURE
339
474
  for mutation in get_mutations(draw, item):
340
- 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
341
479
  if result == MutationResult.SUCCESS:
342
480
  latest_success_index = idx
343
481
  if latest_success_index is None:
344
- return MutationResult.FAILURE
482
+ return MutationResult.FAILURE, None
345
483
  min_items = schema.get("minItems", 0)
346
484
  schema["minItems"] = max(min_items, latest_success_index + 1)
347
- 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
348
493
 
349
494
 
350
- 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]:
351
498
  """Negate schema constrains while keeping the original type."""
352
- if canonicalish(schema) == {}:
353
- return MutationResult.FAILURE
499
+ ctx.ensure_bundle(schema)
500
+ if not can_negate(schema):
501
+ return MutationResult.FAILURE, None
354
502
  copied = schema.copy()
503
+ # Preserve x-bundled before clearing
504
+ bundled = schema.get(BUNDLE_STORAGE_KEY)
355
505
  schema.clear()
506
+ if bundled is not None:
507
+ schema[BUNDLE_STORAGE_KEY] = bundled
356
508
  is_negated = False
509
+ negated_keys = []
357
510
 
358
- def is_mutation_candidate(k: str) -> bool:
511
+ def is_mutation_candidate(k: str, v: Any) -> bool:
359
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
360
526
  return not (
361
527
  k in ("type", "properties", "items", "minItems")
362
- or (k == "additionalProperties" and context.is_header_location)
528
+ or (k == "additionalProperties" and ctx.location.is_in_header)
363
529
  )
364
530
 
365
- enabled_keywords = draw(st.shared(FeatureStrategy(), key="keywords")) # type: ignore
531
+ enabled_keywords = draw(st.shared(FeatureStrategy(), key="keywords"))
366
532
  candidates = []
367
- 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)]
368
534
  if mutation_candidates:
369
535
  # There should be at least one mutated keyword
370
- 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)]))
371
537
  candidates.append(candidate)
372
538
  # If the chosen candidate has dependency, then the dependency should also be present in the final schema
373
539
  if candidate in DEPENDENCIES:
374
540
  candidates.append(DEPENDENCIES[candidate])
375
541
  for key, value in copied.items():
376
- if is_mutation_candidate(key):
542
+ if is_mutation_candidate(key, value):
377
543
  if key in candidates or enabled_keywords.is_enabled(key):
378
544
  is_negated = True
545
+ negated_keys.append(key)
379
546
  negated = schema.setdefault("not", {})
380
547
  negated[key] = value
381
548
  if key in DEPENDENCIES:
382
549
  # If this keyword has a dependency, then it should be also negated
383
550
  dependency = DEPENDENCIES[key]
384
- if dependency not in negated:
385
- negated[dependency] = copied[dependency] # Assuming the schema is valid
551
+ if dependency not in negated and dependency in copied:
552
+ negated[dependency] = copied[dependency]
386
553
  else:
387
554
  schema[key] = value
388
555
  if is_negated:
389
- return MutationResult.SUCCESS
390
- 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
391
576
 
392
577
 
393
578
  DEPENDENCIES = {"exclusiveMaximum": "maximum", "exclusiveMinimum": "minimum"}
394
579
 
395
580
 
396
- def get_mutations(draw: Draw, schema: Schema) -> Tuple[Mutation, ...]:
581
+ def get_mutations(draw: Draw, schema: JsonSchemaObject) -> tuple[Mutation, ...]:
397
582
  """Get mutations possible for a schema."""
398
583
  types = get_type(schema)
399
584
  # On the top-level of Open API schemas, types are always strings, but inside "schema" objects, they are the same as
400
585
  # in JSON Schema, where it could be either a string or an array of strings.
401
- 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]
402
592
  if "object" in types:
403
593
  options.extend([change_properties, remove_required_property])
404
594
  elif "array" in types:
@@ -410,7 +600,7 @@ def ident(x: T) -> T:
410
600
  return x
411
601
 
412
602
 
413
- 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]]:
414
604
  """Returns a strategy that generates randomly ordered lists of T.
415
605
 
416
606
  NOTE. Items should be unique.
@@ -1,10 +1,7 @@
1
- from typing import List
1
+ from hypothesis_jsonschema._canonicalise import canonicalish
2
2
 
3
3
  from .types import Schema
4
4
 
5
5
 
6
- def get_type(schema: Schema) -> List[str]:
7
- type_ = schema.get("type", ["null", "boolean", "integer", "number", "string", "array", "object"])
8
- if isinstance(type_, str):
9
- return [type_]
10
- return type_
6
+ def can_negate(schema: Schema) -> bool:
7
+ return canonicalish(schema) != {}